一塊兒寫個 WSGI Web Framework

做者簡介python

旺旺,switch狂熱愛好者(掌遊癮少年),可是寫代碼的功力仍是能夠的,負責騎手相關的開發工做,常年充當老張、老趙、老方...等人的backup,同時常年把老張、老趙、老方...等人列爲本身的backupgit

寫在前面

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

什麼是 WSGI

使用 Python 進行 Web 項目開發時,必定少不了聽到 WSGI 這個詞。WSGI 指的是某種 Web 服務麼?或者是某個框架?仍是應用程序的名字?web

WSGI(Web Server Gateway Interface) 實際上是一套調用約定(calling convention),它規定了 HTTP Server 與 HTTP Application 之間的數據交換方式。shell

引用 PEP333 裏的背景介紹:flask

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.bash

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()
複製代碼

參考





閱讀博客還不過癮?

歡迎你們掃二維碼經過添加羣助手,加入交流羣,討論和博客有關的技術問題,還能夠和博主有更多互動

博客轉載、線下活動及合做等問題請郵件至 shadowfly_zyl@hotmail.com 進行溝通
相關文章
相關標籤/搜索