自己寫一個Web伺服器(2)

2016-04-14    分類:作業系統、程式設計開發、首頁精華0人評論發表於2016-04-14

本文由碼農網 – 王堅原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

自己寫一個Web伺服器(1)

自己寫一個Web伺服器(2)

自己寫一個Web伺服器(3)

還記著第一篇的問題嗎?你怎麼在你剛建立的Web伺服器上執行一個Django應用,Flask應用和Pyramid應用,如何不做任何改變而適應不同的web架構呢?往下看,來找答案。

在以前,你選擇 Python web 架構會受制於可用的web伺服器,反之亦然。如果架構和伺服器可以協同工作,那你就走運了:

但你有可能面對(或者曾有過)下面的問題,當要把一個伺服器和一個架構結合起來是發現他們不是被設計成協同工作的:

基本上你只能用可以一起執行的而非你想要使用的。

那麼,你怎麼可以不修改伺服器和架構程式碼而確保可以在多個架構下執行web伺服器呢?答案就是 Python Web Server Gateway Interface (或簡稱 WSGI,讀作“wizgy”)。

WSGI允許開發者將選擇web框架和web伺服器分開。現在你可以混合匹配web伺服器和web框架,選擇一個適合你需要的配對。比如,你可以在Gunicorn 或者 Nginx/uWSGI 或者 Waitress上執行 Django, Flask, 或 Pyramid。真正的混合匹配,得益於WSGI同時支援伺服器和架構:

WSGI是第一篇和這篇開頭又重複問道問題的答案。你的web伺服器必須具備WSGI介面,所有的現代Python Web框架都已具備WSGI介面,它讓你不對程式碼作修改就能使伺服器和特點的web框架協同工作。

現在你知道WSGI由web伺服器支援,而web框架允許你選擇適合自己的配對,但它同樣對於伺服器和框架開發者提供便利使他們可以專注於自己偏愛的領域和專長而不至於相互牽制。其他語言也有類似介面:java有Servlet API,Ruby 有 Rack。

說這麼多了,你肯定在喊,給我看程式碼!好吧,看看這個極簡的WSGI伺服器實現:

# Tested with Python 2.7.9, Linux & Mac OS X
import socket
import StringIO
import sys

class WSGIServer(object):

    address_family = socket.AF_INET
    socket_type = socket.SOCK_STREAM
    request_queue_size = 1

    def __init__(self, server_address):
        # Create a listening socket
        self.listen_socket = listen_socket = socket.socket(
            self.address_family,
            self.socket_type
        )
        # Allow to reuse the same address
        listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        # Bind
        listen_socket.bind(server_address)
        # Activate
        listen_socket.listen(self.request_queue_size)
        # Get server host name and port
        host, port = self.listen_socket.getsockname()[:2]
        self.server_name = socket.getfqdn(host)
        self.server_port = port
        # Return headers set by Web framework/Web application
        self.headers_set = []

    def set_app(self, application):
        self.application = application

    def serve_forever(self):
        listen_socket = self.listen_socket
        while True:
            # New client connection
            self.client_connection, client_address = listen_socket.accept()
            # Handle one request and close the client connection. Then
            # loop over to wait for another client connection
            self.handle_one_request()

    def handle_one_request(self):
        self.request_data = request_data = self.client_connection.recv(1024)
        # Print formatted request data a la 'curl -v'
        print(''.join(
            '< {line}\n'.format(line=line)
            for line in request_data.splitlines()
        ))

        self.parse_request(request_data)

        # Construct environment dictionary using request data
        env = self.get_environ()

        # It's time to call our application callable and get
        # back a result that will become HTTP response body
        result = self.application(env, self.start_response)

        # Construct a response and send it back to the client
        self.finish_response(result)

    def parse_request(self, text):
        request_line = text.splitlines()[0]
        request_line = request_line.rstrip('\r\n')
        # Break down the request line into components
        (self.request_method,  # GET
         self.path,            # /hello
         self.request_version  # HTTP/1.1
         ) = request_line.split()

    def get_environ(self):
        env = {}
        # The following code snippet does not follow PEP8 conventions
        # but it's formatted the way it is for demonstration purposes
        # to emphasize the required variables and their values
        #
        # Required WSGI variables
        env['wsgi.version']      = (1, 0)
        env['wsgi.url_scheme']   = 'http'
        env['wsgi.input']        = StringIO.StringIO(self.request_data)
        env['wsgi.errors']       = sys.stderr
        env['wsgi.multithread']  = False
        env['wsgi.multiprocess'] = False
        env['wsgi.run_once']     = False
        # Required CGI variables
        env['REQUEST_METHOD']    = self.request_method    # GET
        env['PATH_INFO']         = self.path              # /hello
        env['SERVER_NAME']       = self.server_name       # localhost
        env['SERVER_PORT']       = str(self.server_port)  # 8888
        return env

    def start_response(self, status, response_headers, exc_info=None):
        # Add necessary server headers
        server_headers = [
            ('Date', 'Tue, 31 Mar 2015 12:54:48 GMT'),
            ('Server', 'WSGIServer 0.2'),
        ]
        self.headers_set = [status, response_headers + server_headers]
        # To adhere to WSGI specification the start_response must return
        # a 'write' callable. We simplicity's sake we'll ignore that detail
        # for now.
        # return self.finish_response

    def finish_response(self, result):
        try:
            status, response_headers = self.headers_set
            response = 'HTTP/1.1 {status}\r\n'.format(status=status)
            for header in response_headers:
                response += '{0}: {1}\r\n'.format(*header)
            response += '\r\n'
            for data in result:
                response += data
            # Print formatted response data a la 'curl -v'
            print(''.join(
                '> {line}\n'.format(line=line)
                for line in response.splitlines()
            ))
            self.client_connection.sendall(response)
        finally:
            self.client_connection.close()

SERVER_ADDRESS = (HOST, PORT) = '', 8888

def make_server(server_address, application):
    server = WSGIServer(server_address)
    server.set_app(application)
    return server

if __name__ == '__main__':
    if len(sys.argv) < 2:
        sys.exit('Provide a WSGI application object as module:callable')
    app_path = sys.argv[1]
    module, application = app_path.split(':')
    module = __import__(module)
    application = getattr(module, application)
    httpd = make_server(SERVER_ADDRESS, application)
    print('WSGIServer: Serving HTTP on port {port} ...\n'.format(port=PORT))
    httpd.serve_forever()

這比第一篇的程式碼長的多,但也足夠短(只有150行)來讓你理解而避免在細節裡越陷越深。上面的伺服器可以做更多——可以執行你鍾愛web框架所寫基本的web應用,Pyramid, Flask, Django, 或其他 Python WSGI 框架.

不相信我?你自己試試看。儲存上面的程式碼為webserver2.py或者直接在Github下載。如果你不傳入任何引數它會提醒然後推出。

$ python webserver2.py
Provide a WSGI application object as module:callable

它需要為web應用服務,這樣才會有意思。執行伺服器你唯一要做的就是按照python。但是要執行 Pyramid, Flask, 和 Django 寫的應用你得先按照這些框架。我們索性三個都安裝好了。我偏愛用virtualenv。只要按照下面的步驟建立一個虛擬環境然後按照這三個web框架。

$ [sudo] pip install virtualenv
$ mkdir ~/envs
$ virtualenv ~/envs/lsbaws/
$ cd ~/envs/lsbaws/
$ ls
bin  include  lib
$ source bin/activate
(lsbaws) $ pip install pyramid
(lsbaws) $ pip install flask
(lsbaws) $ pip install django

這時你要建立一個web應用了。我們從Pyramid開始。在webserver2.py所在的資料夾儲存下面程式碼為pyramidapp.py,也可以直接在Githhub下載:

from pyramid.config import Configurator
from pyramid.response import Response

def hello_world(request):
    return Response(
        'Hello world from Pyramid!\n',
        content_type='text/plain',
    )

config = Configurator()
config.add_route('hello', '/hello')
config.add_view(hello_world, route_name='hello')
app = config.make_wsgi_app()

你的伺服器已經為你的 Pyramid 應用準備好了:

(lsbaws) $ python webserver2.py pyramidapp:app
WSGIServer: Serving HTTP on port 8888 ...

你告訴伺服器載入一個來自python ‘pyramidapp’模組的‘app’,然後做好準備接收請求並傳給你的 Pyramid 應用。這個應用只處理一個路徑: /hello 路徑。在瀏覽器中輸入地址 http://localhost:8888/hello,按下Enter,就看到結果:

你也可以用’curl‘在命令列中測試伺服器:

$ curl -v http://localhost:8888/hello
...

接著是 Flask。同樣的步驟:

from flask import Flask
from flask import Response
flask_app = Flask('flaskapp')

@flask_app.route('/hello')
def hello_world():
    return Response(
        'Hello world from Flask!\n',
        mimetype='text/plain'
    )

app = flask_app.wsgi_app

儲存上面的程式碼為 flaskapp.py 或者從 GitHub下載,然後執行伺服器:

(lsbaws) $ python webserver2.py flaskapp:app
WSGIServer: Serving HTTP on port 8888 ...

在瀏覽器中輸入地址 http://localhost:8888/hello,按下Enter:

繼續,用’curl‘看看伺服器返回 Flask 應用生成的訊息:

$ curl -v http://localhost:8888/hello
...

這個伺服器能處理 Django 應用嗎?試試看!這會更復雜一點,我建議克隆整個repo,用djangoapp.py, 它是GitHub repository的一部分。這裡給出程式碼,只是新增Django ’helloworld‘工程到當前python路徑然後匯入它的WSGI應用。

import sys
sys.path.insert(0, './helloworld')
from helloworld import wsgi

app = wsgi.application

儲存程式碼為 djangoapp.py 然後在伺服器上執行 Django 應用:

(lsbaws) $ python webserver2.py djangoapp:app
WSGIServer: Serving HTTP on port 8888 ...

輸入地址,按下Enter:

同樣的就像你以及試過的幾次,再用命令列試試看,確認這個 Django 應用也是可以處理你的請求:

$ curl -v http://localhost:8888/hello
...

你試了嗎?你確定這個伺服器和三個框架都能工作嗎?要是沒有,快去做吧。看文章很重要,但這個系列是重建,就是說你得身體力行。去試試,我會等你的,別擔心。你必須自己驗證,最好能自己寫所用的東西以確保它能達到預期。

好了,你已經體驗了WSGI的強大:它讓你混合匹配web伺服器和架構。WSGI為python web伺服器和框架提供一個最小的介面。它非常簡單且容易應用到伺服器和框架兩端。下面的程式碼段是伺服器和框架端的介面:

def run_application(application):
    """Server code."""
    # This is where an application/framework stores
    # an HTTP status and HTTP response headers for the server
    # to transmit to the client
    headers_set = []
    # Environment dictionary with WSGI/CGI variables
    environ = {}

    def start_response(status, response_headers, exc_info=None):
        headers_set[:] = [status, response_headers]

    # Server invokes the ‘application' callable and gets back the
    # response body
    result = application(environ, start_response)
    # Server builds an HTTP response and transmits it to the client
    …

def app(environ, start_response):
    """A barebones WSGI app."""
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return ['Hello world!']

run_application(app)

它這樣工作:

  1. 這個框架提供一個可呼叫的’application’(WSGI規範沒有規定如何應實現)
  2. 伺服器從HTTP客戶端接收請求,並呼叫’application’。它把包含 WSGI/CGI 變數的字典‘environ’和‘start_response’ 呼叫作為引數傳給 ‘application’ 。
  3. 框架/應用生成一個HTTP狀態和HTTP響應頭,將他們傳入‘start_response’ 讓伺服器來儲存。框架/應用同時返回響應體。
  4. 伺服器將狀態,響應頭和響應提結合成HTTP響應並且傳送到客戶端(這一步不是標準中的部分,但卻是合乎邏輯的下一步,為明瞭起見我加在這裡)

這裡是介面的視覺化表示:

到此為止,你看到了Pyramid, Flask, 和 Django Web 應用,看到了伺服器端實現WSGI規範的程式碼。你看到了沒用任何框架的WSGI應用程式碼。

問題是你在用這些框架寫web應用時是在一個更高的層級並不直接接觸WSGI,但我知道你也好奇框架端的WSGI介面,當然也因為你在看這篇文章。所以,我們來建一個極簡的WSGI web應用/框架,不用Pyramid, Flask, 或Django,在你的伺服器上執行:

def app(environ, start_response):
    """A barebones WSGI application.

    This is a starting point for your own Web framework :) 
    """
    status = '200 OK'
    response_headers = [('Content-Type', 'text/plain')]
    start_response(status, response_headers)
    return ['Hello world from a simple WSGI application!\n']

儲存上面程式碼為wsgiapp.py,或者在GitHub下載,想下面這樣執行:

(lsbaws) $ python webserver2.py wsgiapp:app
WSGIServer: Serving HTTP on port 8888 ...

輸入地址,按下Enter,你就看到結果:

回去看伺服器傳了什麼給客戶端 。這裡是你用HTTP客戶端呼叫你的Pyramid應用時伺服器生成的HTTP響應:

這個響應有些部分和第一篇看到的相似,但也有些新的東西。它有四個你之前沒看到過的HTTP頭:內容型別,內容長度,日期和伺服器。這些頭飾一個web伺服器響應通常應該有的。即便沒有一個是必須的,它沒的作用是傳送HTTP請求/響應的附加資訊。

你對WSGI介面有了更多的瞭解,這裡還有些資訊關於這條HTTP響應是那部分產生的:

我還沒說過‘environ’字典的任何東西,它基本上就是必須包含由WSGI規範規定的明確WSGI和CGI引數的python字典。伺服器解析請求後從請求中取出引數放入字典。這是字典中包含內容的樣子:

web框架用字典中的資訊決定通過特點路徑的呈現,響應方式去,哪裡去讀取響應體和哪裡去寫入錯誤,如果有的話。

你建立了自己的WSGI web伺服器,你用不同框架寫了自己的web應用。你也建立了自己基本的web應用/框架。這是一個 heck 之旅。來回顧一下你的WSGI伺服器對應用都要做些什麼:

  • 首先,伺服器啟動然後載入你的web框架/應用提供的‘application’呼叫
  • 然後,伺服器讀取請求
  • 然後,伺服器解析它
  • 然後,它根據請求資料建立一個‘environ’ 字典
  • 然後,它用‘environ’ 字典呼叫‘application’,‘start_response’ 做引數同時得到一個響應體
  • 然後, 通過‘start_response’ 用‘application’返回的資料和狀態及響應頭建立一個HTTP響應。
  • 最後,伺服器將HTTP響應傳回到客戶端。

結束之前,有個問題你想想看,你怎麼保證你的伺服器能同時處理多個請求?

敬請期待,我在第三篇會告訴你一個辦法做到這點。

譯文連結:http://www.codeceo.com/article/make-web-server-2.html
英文原文:Let’s Build A Web Server. Part 2
翻譯作者:碼農網 – 王堅
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章