一起寫個 WSGI Web Framework

餓了麼物流技術團隊發表於2019-02-25

作者簡介

旺旺,switch狂熱愛好者(掌遊癮少年),但是寫程式碼的功力還是可以的,負責騎手相關的開發工作,常年充當老張、老趙、老方...等人的backup,同時常年把老張、老趙、老方...等人列為自己的backup

寫在前面

本文中所列舉的程式碼僅在 Python 2.7.15 和 Python 3.7.0 版本下進行編寫測試。

什麼是 WSGI

使用 Python 進行 Web 專案開發時,一定少不了聽到 WSGI 這個詞。WSGI 指的是某種 Web 服務麼?或者是某個框架?還是應用程式的名字?

WSGI(Web Server Gateway Interface) 其實是一套呼叫約定(calling convention),它規定了 HTTP Server 與 HTTP Application 之間的資料交換方式。

引用 PEP333 裡的背景介紹:

Python currently boasts a wide variety of web application frameworks, such as Zope, Quixote, Webware, SkunkWeb, PSO, and Twisted Web -- to name just a few. This wide variety of choices can be a problem for new Python users, because generally speaking, their choice of web framework will limit their choice of usable web servers, and vice versa.

By contrast, although Java has just as many web application frameworks available, Java's "servlet" API makes it possible for applications written with any Java web application framework to run in any web server that supports the servlet API.

規定這樣一套約定的大致原因就是,Python 的 Web 框架越來越豐富多樣,給 Python 開發者帶來了多種選擇的同時也帶來了困擾——如果想從一個框架遷移到另一個上,需要對你的上層業務應用做不小的改動和適配。

因此在 Server 和 Application之間加入 WSGI 增加了可移植性。當然,你可以在 Server 與 Application 中間堆疊進多組中介軟體,前提是中介軟體需要實現 Server 和 Application 兩側的對應介面。

wsgi

Python 字串編碼

字串編碼可以說是 Python 初學者的「勸退怪」,UnicodeDecodeErrorUnicodeEncodeError 一路帶大家「從入門到放棄」。雖然這在 Python 3 裡有一定的緩解,但是當需要進行讀寫檔案和我們馬上就要處理的網路資料時,你依舊逃避不了。

Python 2 的原生字元 str 採用 ASCII 編碼,支援的字元極其有限,同時位元組型別 bytesstr 等同,而 Unicode 字元則使用內建 unicode

# Python 2.7
>>> str is bytes
True
>>> type('字串')
<type 'str'>
>>> type(u'字串')
<type 'unicode'>
複製程式碼

而 Python 3 之所以在字元編碼方面對初學者友好,是因為 Python 3 原生字元 str 涵蓋了 Unicode,不過又將 bytes 剝離了出來。

# Python 3
>>> str is bytes
False
>>> type('字元')
<class 'str'>
>>> type(b'byte')
<class 'bytes'>
複製程式碼

處理 HTTP 請求和處理檔案一樣都只接受位元組型別,因此在編寫 HTTP 應用時需要格外注意字串編碼問題,尤其是當你需要同時相容 Python 2 和 Python 3 。

WSGI Application

首先,我們先來編寫 WSGI 應用。

根據呼叫約定,應用側需要適應一個可呼叫物件 (callable object) ,在 Python 中,可呼叫物件可以是一個函式 (function) ,方法 (method) ,一個類 (class) 或者是一個實現了 __call__() 方法的例項。同時,這個可呼叫物件還必須:

  1. 可接收兩個位置引數:
    • 一個包含 CGI 鍵值的字典;
    • 一個用來構造 HTTP 狀態和頭資訊的回撥函式。
  2. 返回的 response body 必須是一個可迭代物件 (iterable) 。

這一章節著重討論 WSGI 應用,因此我們直接引入 Python 內建的 simple_server 來裝載我們的應用。

A Naive App

我們先完成一個可用的 WSGI 應用。

def application(
    # 包含 CGI 環境變數的字典,貫穿一整個請求過程,是請求的上下文
    environ, 
    # 呼叫方傳入的回撥方法,我們暫時不需要知道它具體做了什麼,只需要
    # 在函式返回前呼叫它並傳入 HTTP 狀態和 HTTP 頭資訊即可
    start_response
):
    # 我們啥也不幹,就把請求時的 method 返回給客戶端
    body = 'Request Method: {}'.format(environ['REQUEST_METHOD'])
    # 注意,body原來是原生字串,因此在往下傳遞資料前需要轉化為位元組型別
    body = body.encode('utf-8')
    
    # HTTP 返回狀態,注意中間的空格
    status = '200 OK'
    
    # 返回的 HTTP 頭資訊,結構為
    # [(Header Name, Header Value)]
    headers = [
        ('Content-Type', 'text/plain'),
        ('Content-Length', str(len(body))),
    ]
    
    # 呼叫 start_response 回撥
    start_response(status, headers)
    
    # 返回 response body
    # 需要特別注意的是,返回值必須是一個可迭代物件 (iterable) ,
    # 同時,如果這裡返回的是字串,那麼外部將會對字串內每一個字元做單獨處理,
    # 所以用列表包一下
    return [body]
複製程式碼

Put them together

最後,我們將寫好的 callable 物件傳入內建的 make_server 方法並繫結在本地 8010 埠上:

#! /usr/bin/env python
# coding: utf-8

from wsgiref.simple_server import make_server


def application(environ, start_response):
    body = 'Request Method: {}'.format(environ['REQUEST_METHOD'])
    body = body.encode('utf-8')
    status = '200 OK'
    headers = [
        ('Content-Type', 'text/plain'),
        ('Content-Length', str(len(body))),
    ]
    start_response(status, headers)
    return [body]


def main():
    httpd = make_server('localhost', 8010, application)
    httpd.serve_forever()


if __name__ == '__main__':
    main()
複製程式碼

通過 curl 我們就能看到對應的返回了。

$ curl 127.0.0.1:8010 -i
# HTTP/1.0 200 OK
# Date: Sun, 06 Jan 2019 13:37:24 GMT
# Server: WSGIServer/0.1 Python/2.7.10
# Content-Type: text/plain
# Content-Length: 19
# 
# Request Method: GET

$ curl 127.0.0.1:8010 -i -XPOST
# HTTP/1.0 200 OK
# Date: Sun, 06 Jan 2019 13:38:15 GMT
# Server: WSGIServer/0.1 Python/2.7.10
# Content-Type: text/plain
# Content-Length: 20
#
# Request Method: POST
複製程式碼

A Step Further

寫好了一個可用的應用,可也太難用了!

那麼,我們就更近一步,對請求過程做一些封裝和擴充套件。

class Request(object):

    MAX_BUFF_SIZE = 1024 ** 2

    def __init__(self, environ=None):
        self.environ = {} if environ is None else environ
        self._body = ''

    # 與請求中的 environ 做繫結
    def load(self, environ):
        self.environ = environ

    # QUERY_STRING 是 URL 裡「?」後面的字串
    # 這裡我們解析這串字元,並且以鍵值的形式返回
    @property
    def args(self):
        return dict(parse_qsl(self.environ.get('QUERY_STRING', '')))

    @property
    def url(self):
        return self.environ['PATH_INFO']

    @property
    def method(self):
        return self.environ['REQUEST_METHOD']

    # 提供原生字元,方便再應用層內使用
    @property
    def body(self):
        return tonat(self._get_body_string())
    
    # 讀取請求的 body
    # 資料可以通過 self.environ['wsgi.input'] 控制程式碼讀取
    # 呼叫讀取方法使得檔案指標後移,為了防止請求多次讀取,
    # 直接將檔案控制程式碼替換成讀到的資料
    def _get_body_string(self):
        try:
            read_func = self.environ['wsgi.input'].read
        except KeyError:
            return self.environ['wsgi.input']
        content_length = int(self.environ.get('CONTENT_LENGTH') or 0)
        if content_length > 0:
            self._body = read_func(max(0, content_length))
            self.environ['wsgi.input'] = self._body
        return self._body
    

# 因為 Python 是單執行緒,同時多執行緒在IO上不太友好
# 所以應用生命週期內只需要一個 request 請求物件就好
request = Request()
複製程式碼

接下來是封裝返回物件。Response 物件需要確保 body 內的資料為位元組型別。

class Response(object):

    default_status = '200 OK'

    def __init__(self, body='', status=None, **headers):
        # 將 body 轉為位元組型別
        self._body = tobytes(body)
        self._status = status or self.default_status
        self._headers = {
            'Content-Type': 'text/plain',
            
            # Content-Length 的計算需要在 body 轉為位元組型別後,
            # 否則由於編碼的不同,字串所需要的長度也不一致
            'Content-Length': str(len(self.body)),
        }
        if headers:
            for name, value in headers.items():
                # Python 慣用 snakecase 命名變數,
                # 所以我們需要對字串做一個簡單的轉換
                self._headers[header_key(name)] = str(value)

    @property
    def body(self):
        return self._body

    @property
    def headerlist(self):
        return sorted(self._headers.items())

    @property
    def status_code(self):
        return int(self.status.split(' ')[0])

    @property
    def status(self):
        return self._status
複製程式碼

接下來就是對應用的封裝。不過別忘了,它需要是一個 callable 物件。

class Application(object):

    def __init__(self, name):
        self.name = name

    def wsgi(self, environ, start_response):
        # 將請求的環境變數載入 request 物件
        request.load(environ)
        
        body = 'Request Method: {}'.format(request.method)
        response = Response(body)
        start_response(response.status, response.headerlist)
        return [tobytes(response.body)]

    def __call__(self, environ, start_response):
        return self.wsgi(environ, start_response)
    

app = Application(__name__)
httpd = make_server('localhost', 8010, app)
httpd.serve_forever()
複製程式碼

目前為止,我們已經將上一小節的程式碼做了封裝和擴充套件,獲取 HTTP 請求的資料更方便了。

接下來我們來完成 URL 路由功能。

class Application(object):
    
    def __init__(self, name):
        self.name = name
        self.routers = {}
    
    # 路由裝飾器
    # 我們將註冊的路由儲存在 routers 字典裡
    def route(self, url, methods=['GET'], view_func=None):
        def decorator(view_func):
            self.routers[url] = (methods, view_func)
        return decorator

    def _handle(self, request):
        methods, view_func = self.routers.get(request.url, (None, None))
        # 不僅要 URL 一致,method 也要和註冊的一致才能呼叫對應的方法
        if methods is None or request.method not in methods:
            return Response(status='404 Not Found')
        return view_func()

    def wsgi(self, environ, start_response):
        request.load(environ)
        response = self._handle(request)
        start_response(response.status, response.headerlist)
        return [tobytes(response.body)]

    def __call__(self, environ, start_response):
        return self.wsgi(environ, start_response)
    
    
app = Application(__name__)

@app.route('/')
def index():
    return Response('Hello World!')

@app.route('/hungry', methods=['POST'])
def hugry():
	return Response('餓了就叫餓了麼')

httpd = make_server('localhost', 8010, app)
httpd.serve_forever()
複製程式碼
$ curl 127.0.0.1:8010/hungry -i
# HTTP/1.0 404 Not Found
# Date: Sun, 06 Jan 2019 15:09:05 GMT
# Server: WSGIServer/0.2 Python/2.7.15
# Content-Length: 0
# Content-Type: text/plain

$ curl 127.0.0.1:8010/hungry -i -XPOST -d'yes'
# HTTP/1.0 200 OK
# Date: Sun, 06 Jan 2019 15:11:15 GMT
# Server: WSGIServer/0.1 Python/2.7.15
# Content-Length: 21
# Content-Type: text/plain
#
# 餓了就叫餓了麼

複製程式碼

到這裡,我們完成了 URL 和端點的註冊和路由,有了用來解析 HTTP 請求的 Request 物件,也封裝了 HTTP 介面返回的 Response 物件,已經完成了 WSGI Web Framework 主幹道上的功能。

當然,這裡「好用」還差得遠。我們還需要有合理的異常處理 (Error Handling) ,URL 重組 (URL Reconstruction) ,對執行緒和非同步的支援,對不同平臺適配檔案處理 (Platform-Specific File Handling) ,支援緩衝和流 (Stream) ,最好還能攜帶上 Websocket 。每一項都值得仔細探討一番,篇幅有限,本文就不贅述了。

Put them together

#! /usr/bin/env python
# coding: utf-8

import os
import sys
import functools
from wsgiref.simple_server import make_server

py3 = sys.version_info.major > 2

if py3:
    from urllib.parse import unquote as urlunquote
    urlunquote = functools.partial(urlunquote, encoding='u8')

    unicode = str
    
else:
    from urllib import unquote as urlunquote


def tobytes(s, enc='utf-8'):
    if isinstance(s, unicode):
        return s.encode(enc)
    return bytes() if s is None else bytes(s)


def tounicode(s, enc='utf-8', err='strict'):
    if isinstance(s, bytes):
        return s.decode(enc, err)
    return unicode('' if s is None else s)


tonat = tounicode if py3 else tobytes


def parse_qsl(qs):
    r = []
    for pair in qs.replace(';', '&').split('&'):
        if not pair:
            continue
        kv = urlunquote(pair.replace('+', ' ')).split('=', 1)
        if len(kv) != 2:
            kv.append('')
        r.append((kv[0], kv[1]))
    return r


def header_key(key):
    return '-'.join([word.title() for word in key.split('_')])


class Request(object):

    MAX_BUFF_SIZE = 1024 ** 2

    def __init__(self, environ=None):
        self.environ = {} if environ is None else environ
        self._body = ''

    def load(self, environ):
        self.environ = environ

    @property
    def args(self):
        return dict(parse_qsl(self.environ.get('QUERY_STRING', '')))

    @property
    def url(self):
        return self.environ['PATH_INFO']

    @property
    def method(self):
        return self.environ['REQUEST_METHOD']

    @property
    def body(self):
        return tonat(self._get_body_string())

    def _get_body_string(self):
        try:
            read_func = self.environ['wsgi.input'].read
        except KeyError:
            return self.environ['wsgi.input']
        content_length = int(self.environ.get('CONTENT_LENGTH') or 0)
        if content_length > 0:
            self._body = read_func(max(0, content_length))
            self.environ['wsgi.input'] = self._body
        return self._body


class Response(object):

    default_status = '200 OK'

    def __init__(self, body='', status=None, **headers):
        self._body = tobytes(body)
        self._status = status or self.default_status
        self._headers = {
            'Content-Type': 'text/plain',
            'Content-Length': str(len(self.body)),
        }
        if headers:
            for name, value in headers.items():
                self._headers[header_key(name)] = str(value)

    @property
    def body(self):
        return self._body

    @property
    def headerlist(self):
        return sorted(self._headers.items())

    @property
    def status_code(self):
        return int(self.status.split(' ')[0])

    @property
    def status(self):
        return self._status


request = Request()


class Application(object):

    def __init__(self, name):
        self.name = name
        self.routers = {}

    def route(self, url, methods=['GET'], view_func=None):
        def decorator(view_func):
            self.routers[url] = (methods, view_func)
        return decorator

    def _handle(self, request):
        methods, view_func = self.routers.get(request.url, (None, None))
        if methods is None or request.method not in methods:
            return Response(status='404 Not Found')
        return view_func()

    def wsgi(self, environ, start_response):
        request.load(environ)
        response = self._handle(request)
        start_response(response.status, response.headerlist)
        return [tobytes(response.body)]

    def __call__(self, environ, start_response):
        return self.wsgi(environ, start_response)


def main():
    app = Application(__name__)

    @app.route('/')
    def index():
        return Response('Hello')

    @app.route('/hungry', methods=['POST'])
    def eleme():
        if request.body == 'yes':
            return Response('餓了就叫餓了麼')
        return Response('再等等')

    httpd = make_server('localhost', 8010, app)
    httpd.serve_forever()


if __name__ == '__main__':
    main()
複製程式碼

WSGI Server

寫完了 Application 是不是還不過癮?那我們來看看 WSGI Server 要怎麼工作。

本小節主要說明 WSGI 約定下 Server 與 Application 如何協作處理 HTTP 請求,為了避免過度討論,引入 Python 內建 HTTPServerBaseHTTPRequestHandler ,遮蔽套接字和 HTTP 處理細節。

class WSGIServer(HTTPServer):

    def __init__(self, address, app):
        HTTPServer.__init__(self, address, WSGIRequestHandler)

        self.app = app
        self.environ = {
            'SERVER_NAME': self.server_name,
            'GATEWAY_INTERFACE': 'CGI/1.0',
            'SERVER_PORT': str(self.server_port),
        }
        

class WSGIRequestHandler(BaseHTTPRequestHandler):

    def handle_one_request(self):
        try:
            # 讀取 HTTP 請求資料第一行:
            # <command> <path> <version><CRLF>
            # 例如:GET /index HTTP/1.0
            self.raw_requestline = self.rfile.readline()
            if not self.raw_requestline:
                self.close_connection = 1
                return
            
            # 解析請求後設資料
            elif self.parse_request():
                return self.run()
        except Exception:
            self.close_connection = 1
            raise
複製程式碼

這段程式碼就比較簡單了。 WSGIServer 的主要工作就是初始化一些例項屬性,其中包括註冊 WSGI 應用和初始化 environ 變數。Server 接收請求後都會呼叫一次 RequestHandler ,同時將客戶端發來的資料傳入。RequestHandler 的核心方法是 handle_one_request ,負責處理每一次請求資料。

我們先來初始化請求的變數和上下文:

    def make_environ(self):
        if '?' in self.path:
            path, query = self.path.split('?', 1)
        else:
            path, query = self.path, ''
        path = urlunquote(path)
        environ = os.environ.copy()
        environ.update(self.server.environ)
        environ.update({
            # 客戶端請求體控制程式碼,可以預讀
            'wsgi.input': self.rfile,
            'wsgi.errors': sys.stderr,
            
            # WSGI 版本,沿用預設 1.0
            'wsgi.version': (1, 0),
            
            # 我們的實現版本既不是多執行緒也不是多程式
            'wsgi.multithread': False,
            'wsgi.multiprocess': False,
            
            # 表示 server/gateway 處理請求時只呼叫應用一次
            # ** 這個變數我沒能找到詳盡的說明和具體使用的地方 **
            'wsgi.run_once': True,
            'wsgi.url_scheme': 'http'

            'SERVER_PROTOCOL': '1.0',
            'REQUEST_METHOD': self.command,
            'QUERY_STRING': query,
            'PATH_INFO': urlunquote(path),
            'CONTENT_LENGTH': self.headers.get('content-length')
        })
        return environ
複製程式碼

接下來我們按照 WSGI Server 側的呼叫約定完成 run 方法, writestart_reponse 兩個閉包分別完成資料寫入和頭資訊設定。

    def run(self):
        # 初始化請求的上下文
        environ = self.make_environ()
        
        headers_set = []
        headers_sent = []

        def write(data):
            # 確保在寫入 response body 之前頭資訊已經設定
            assert headers_set, 'write() before start_response()'
            
            if not headers_sent:
                status, response_headers = headers_sent[:] = headers_set
                try:
                    code, msg = status.split(' ', 1)
                except ValueError:
                    code, msg = status, ''
                code = int(code)
                self.wfile.write(tobytes('{} {} {}\r\n'.format(
                    self.protocol_version, code, msg)))
                for header in response_headers:
                    self.wfile.write(tobytes('{}: {}\r\n'.format(*header)))
                self.wfile.write(tobytes('\r\n'))
			
            # 確保 body 為位元組型別
            assert isinstance(data, bytes), 'applications must write bytes'
            self.wfile.write(data)
            self.wfile.flush()

        def start_response(status, response_headers, exc_info=None):
            if exc_info:
                try:
                    # 如果頭資訊傳送,只能重拋異常
                    if headers_sent:
                        reraise(*exc_info)
                finally:
                    # 避免 traceback 迴圈引用
                    exc_info = None
                    
            elif headers_set:
                raise AssertionError('Headers already set!')

            headers_set[:] = [status, response_headers]
            return write

        # 這裡就是呼叫 WSGI 應用
        result = self.server.app(environ, start_response)
        try:
            # 迴圈 WSGI 應用的返回值並寫入
            # 從這一步可以看出,如果應用返回的是字串而不是列表,
            # 那麼字串內的每一個字元都會呼叫一次 write
            for data in result:
                if data:
                    write(data)
            if not headers_sent:
                write(tobytes(''))
        finally:
            if hasattr(result, 'close'):
                result.close()
複製程式碼

Put them together

#! /usr/bin/env python
# coding: utf-8

import os
import sys
import functools

py3 = sys.version_info.major > 2

if py3:
    from http.server import BaseHTTPRequestHandler, HTTPServer
    from urllib.parse import unquote as urlunquote
    urlunquote = functools.partial(urlunquote, encoding='u8')

    def reraise(*a):
        raise a[0](a[1]).with_traceback(a[2])

else:
    from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
    from urllib import unquote as urlunquote

    exec(compile('def reraise(*a): raise a[0], a[1], a[2]', '<py3fix>', 'exec'))

    
class WSGIServer(HTTPServer):

    def __init__(self, address, app):
        HTTPServer.__init__(self, address, WSGIRequestHandler)

        self.app = app
        self.environ = {
            'SERVER_NAME': self.server_name,
            'GATEWAY_INTERFACE': 'CGI/1.0',
            'SERVER_PORT': str(self.server_port),
        }


class WSGIRequestHandler(BaseHTTPRequestHandler):

    def make_environ(self):
        if '?' in self.path:
            path, query = self.path.split('?', 1)
        else:
            path, query = self.path, ''
        path = urlunquote(path)
        environ = os.environ.copy()
        environ.update(self.server.environ)
        environ.update({
            'wsgi.input': self.rfile,
            'wsgi.errors': sys.stderr,
            'wsgi.version': (1, 0),
            'wsgi.multithread': False,
            'wsgi.multiprocess': False,
            'wsgi.run_once': True,
            'wsgi.url_scheme': 'http'

            'SERVER_PROTOCOL': '1.0',
            'REQUEST_METHOD': self.command,
            'QUERY_STRING': query,
            'PATH_INFO': urlunquote(path),
            'CONTENT_LENGTH': self.headers.get('content-length')
        })
        return environ

    def run(self):
        environ = self.make_environ()
        headers_set = []
        headers_sent = []

        def write(data):
            assert headers_set, 'write() before start_response()'
            if not headers_sent:
                status, response_headers = headers_sent[:] = headers_set
                try:
                    code, msg = status.split(' ', 1)
                except ValueError:
                    code, msg = status, ''
                code = int(code)
                self.wfile.write(tobytes('{} {} {}\r\n'.format(
                    self.protocol_version, code, msg)))
                for header in response_headers:
                    self.wfile.write(tobytes('{}: {}\r\n'.format(*header)))
                self.wfile.write(tobytes('\r\n'))

            assert isinstance(data, bytes), 'applications must write bytes'
            self.wfile.write(data)
            self.wfile.flush()

        def start_response(status, response_headers, exc_info=None):
            if exc_info:
                try:
                    if headers_sent:
                        reraise(*exc_info)
                finally:
                    exc_info = None
            elif headers_set:
                raise AssertionError('Headers already set!')

            headers_set[:] = [status, response_headers]
            return write

        result = self.server.app(environ, start_response)
        try:
            for data in result:
                if data:
                    write(data)
            if not headers_sent:
                write(tobytes(''))
        finally:
            if hasattr(result, 'close'):
                result.close()

    def handle_one_request(self):
        try:
            self.raw_requestline = self.rfile.readline()
            print(self.raw_requestline)
            if not self.raw_requestline:
                self.close_connection = 1
                return
            elif self.parse_request():
                return self.run()
        except Exception:
            self.close_connection = 1
            raise
           
        
def make_server(host, port, app):
    server = WSGIServer((host, port), app)
    return server


app = Application(__name__)

@app.route('/')
def index():
	return Response('Hello')
    
httpd = make_server('localhost', 8010, app)
httpd.serve_forever()
複製程式碼

參考





閱讀部落格還不過癮?

歡迎大家掃二維碼通過新增群助手,加入交流群,討論和部落格有關的技術問題,還可以和博主有更多互動

一起寫個 WSGI Web Framework
部落格轉載、線下活動及合作等問題請郵件至 shadowfly_zyl@hotmail.com 進行溝通

相關文章