编写简单的 python web 框架

Reinventing the wheel is great if your goal is to learn more about wheels.” – James Tauber

Python web 框架众多,大而全的 django,小巧的 flask,支持异步的 tornado 等,可能是因为用 python 实现个 web 框架太简单了吧,python 的框架层出不穷。 下边一步步介绍下一个 web 框架的基础组成,以及如何写个简单的 web 框架(使用 python3),从而了解下 web 框架的工作原理。


WSGI

首先要了解 WSGI 规范的概念,WSGI(Web Server Gateway Interface)规范描述了web server(Gunicorn,uWSGI等)如何与web application(flask, django等)交互、web application如何处理请求,定义在 pep 3333。正是有了 WSGI 规范,我们才能在任意 web server 上跑各种 web 应用。WSGI API 定义看起来很简单:

def application(environ, start_response)
  • application 就是 WSGI app,一个可调用对象
  • 参数:
    • environ: 一个包含 WSGI 环境信息的字典,由 WSGI 服务器提供,常见的 key 有 PATH_INFO,QUERY_STRING 等
    • start_response: 生成 WSGI 响应的回调函数,接收两个参数,status 和 headers
  • 函数返回响应体的迭代器

下面举个简单的例子,比如一个返回 hello world 的应用:

def application(environ, start_response):
    status = '200 OK'
    headers = [('Content-Type', 'text/html; charset=utf8')]
    start_response(status, headers)
    return  [b"<h1>Hello, World!</h1>"]

接下来我们使用 python 内置的 wsgi server 来跑这个应用:

# 导入python内置的WSGI server
from wsgiref.simple_server import make_server

def application(environ, start_response):
    print(environ)    # 我建议你打出来这个字典看看有哪些参数
    status = '200 OK'
    headers = [('Content-Type', 'text/html; charset=utf8')]
    start_response(status, headers)
    return [b"<h1>Hello, World!</h1>"]    # WSGI applications return iterables that yield bytes

if __name__ == '__main__':
    httpd = make_server('127.0.0.1', 8888, application)
    httpd.serve_forever()

运行这个文件,使用浏览器或者 curl 等工具访问 http://localhost:8888 就能看到结果了。如果你打出来了 environ,能看到很多由 WSGI 服务器提供的环境信息,常见的有以下几个:

Key Contents
PATH_INFO 请求路径,比如 /foo/bar/
QUERY_STRING GET 请求参数,比如 foo=bar&bar=spam,我们可以从这个变量中获取用户的请求参数
HTTP_{HEADER} http 头信息,比如 HTTP_HOST 等
wsgi.input 包含请求内容的类文件对象(file-like object),比如 post 请求数据

接下来看看 start_response 这个可调用对象:

status = '200 OK'
headers = [('Content-Type', 'text/html; charset=utf8')]
start_response(status, headers)

start_response 接收两个参数 :

start_response(status, headers)
"""
status: 一个包含 http 状态码和描述的字符串, 比如 '200 OK'
headers: 一个包含 http 头的元祖列表,[('Content-Type', 'text/html; charset=utf8')]
"""

最后 WSGI 应用返回一个可迭代的 bytes 序列,比如

# 注意返回的 bytes 编码要符合你指定的返回头
def app(environ, start_response):
    # ...
    return [b'hello', b'world']

def app(environ, start_response):
    # ...
    yield b'hello'
    yield b'world'

到这里我们就知道如何编写一个最简单的 WSGI 应用了,我们做个简单的练习,当用户访问 http://localhost:8888/?name=John 的时候, 在网页上输出 “Hello John”。代码如下:

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

from wsgiref.simple_server import make_server


def application(environ, start_response):
    # print(environ)
    status = '200 OK'
    headers = [('Content-Type', 'text/html; charset=utf8')]

    query_string = environ['QUERY_STRING']    # 这里是 "name=John"
    name = query_string.split("=")[1]    # 从查询字符串 "name=John" 里获取 "John"
    start_response(status, headers)
    return [b"<h1>Hello, {}!</h1>".format(name)]


if __name__ == '__main__':
    httpd = make_server('127.0.0.1', 8888, application)
    httpd.serve_forever()

抽象 Request 和 Response 对象

前面看到我们可以直接编写符合 WSGI 规范的应用,但是要做的工作量比较多,比如我们得直接去处理 query string,大部分 web 框架会抽象出 Request/Response 对象,这样一个 web 应用看起来会像这样:

from somewhere import Response

def application(request):
    # ...
    return Response('blablabla')

这样做的好处就是概念上更加清晰,测试更加容易,大部分的 web 框架都采用了类似抽象。接下来我们看看 web 框架是如何映射 WSGI 到 Request/Response 对象的。我们的代码结构将类似这样:

class Request: ...
class Response: ...

def request_response_application(function):
    def application(environ, start_response):
    # ...
    return application

@request_response_application
def application(request):
    # ...
    return Response(...)

先来实现 Request 对象,思路是这样的,我们把 environ 作为构造函数的参数传过去,这样我们就能利用各种子函数来获取我们需要的值。
比如请求地址(HTTP_HOST),请求参数(QUERY_STRING) 等。比如我们可以用一个函数把 QUERY_STRING 字符串解析后作为 请求参数字典返回,这样使用的时候就方便很多。

import urllib.parse

class Request:
    def __init__(self, environ):
        self.environ = environ

    @property
    def args(self):
        """ 把查询参数转成字典形式 """
        get_arguments = urllib.parse.parse_qs(self.environ['QUERY_STRING'])
        return {k: v[0] for k, v in get_arguments.items()}

Response 对象需要返回的值有返回内容,状态码,字符编码,返回类型等。大概如下:

import http.client
from wsgiref.headers import Headers


class Response:
    def __init__(self, response=None, status=200, charset='utf-8', content_type='text/html'):
        self.response = [] if response is None else response
        self.charset = charset
        self.headers = Headers()
        content_type = '{content_type}; charset={charset}'.format(content_type=content_type, charset=charset)
        self.headers.add_header('content-type', content_type)
        self._status = status

    @property
    def status(self):
        status_string = http.client.responses.get(self._status, 'UNKNOWN')
        return '{status} {status_string}'.format(status=self._status, status_string=status_string)

    def __iter__(self):
        for val in self.response:
            if isinstance(val, bytes):
                yield val
            else:
                yield val.encode(self.charset)

现在 Request/Response 对象都有啦,还差一个转换函数,用来把之前的 WSGI 函数转换成使用我们的 Request/Response 对象的函数,我们写个装饰器来实现这个功能:

def request_response_application(func):
    def application(environ, start_response):
        request = Request(environ)
        response = func(request)
        start_response(response.status, response.headers.items())
        return iter(response)
    return application

大功告成,来试一把:

@request_response_application
def application(request):
    name = request.args.get('name', 'default_name')    # 获取查询字符串中的 name
    return Response(['<h1>hello {name}</h1>'.format(name=name)])

if __name__ == '__main__':
    httpd = make_server('127.0.0.1', 8888, application)
    httpd.serve_forever()

其实如果你用过 flask 你一定知道 Werkzeug,一套 flask 依赖的 WSGI 工具集。我们换用 Werkzeug 来写上面的应用几乎一样(嗯,就是照葫芦画瓢):

from werkzeug.wrappers import Request, Response

@Request.application
def application(request):
    name = request.args.get('name', 'PyCon')
    return Response(['<h1>hello {name}</h1>'.format(name=name)])

路由 Routing

前面我们实现了 Request/Response 对象,不过好像还缺点什么,似乎只能处理一个 url,web 框架都可以处理很多请求路径,然后把不同的请求路径映射到不同的处理逻辑。
比如:

大部分 web 框架实现了基于匹配的路由,将 url 模式与一个可调用对象匹配,比如 flask 的路由方式:

@app.route("/user/<username>/photos/<int:photo_id>")
def photo_detail(username, photo_id):
    # ...

我们也来写个 Router 实现路由功能:

def hello(request, name):
    return Response(f"<h1>Hello, {name}</h1>")

def goodbye(request, name):
    return Response(f"<h1>Goodbye, {name}</h1>")

routes = Router()
routes.add_route(r'/hello/(.*)/$', hello)
routes.add_route(r'/goodbye/(.*)/$', goodbye)

Router 实现方式如下,维护一个请求路径到可调用对象的 tuple 列表,每次从列表中查找请求路径是否满足当前 pattern,
满足则调用当前 pattern 对应的可调用对象进行处理。否则抛个异常返回 404 response。实现代码:

class NotFoundError(Exception):
    """ url pattern not found """
    pass


class Router:
    def __init__(self):
        self.routing_table = []    # 保存 url pattern 和 可调用对象

    def add_route(self, pattern, callback):
        self.routing_table.append((pattern, callback))

    def match(self, path):
        for (pattern, callback) in self.routing_table:
            m = re.match(pattern, path)
            if m:
                return (callback, m.groups())
        raise NotFoundError()


def hello(request, name):
    return Response("<h1>Hello, {name}</h1>".format(name=name))


def goodbye(request, name):
    return Response("<h1>Goodbye, {name}</h1>".format(name=name))


routers = Router()
routers.add_route(r'/hello/(.*)/$', hello)
routers.add_route(r'/goodbye/(.*)/$', goodbye)


def application(environ, start_response):
    try:
        request = Request(environ)
        callback, args = routers.match(request.path)
        response = callback(request, *args)
    except NotFoundError:
        response = Response("<h1>Not found</h1>", status=404)
    start_response(response.status, response.headers.items())
    return iter(response)


if __name__ == '__main__':
    httpd = make_server('127.0.0.1', 8888, application)
    httpd.serve_forever()

当然了,如果你喜欢类似 flask 那样的装饰器实现,我们也可以使用类的 __call__ 方法:

class DecoratorRouter:
    def __init__(self):
        self.routing_table = []    # 保存 url pattern 和 可调用对象

    def match(self, path):
        for (pattern, callback) in self.routing_table:
            m = re.match(pattern, path)
            if m:
                return (callback, m.groups())
        raise NotFoundError()

    def __call__(self, pattern):
        def _(func):
            self.routing_table.append((pattern, func))
        return _

routers = DecoratorRouter()


@routers(r'/hello/(.*)/$')
def hello(request, name):
    return Response("<h1>Hello, {name}</h1>".format(name=name))


@routers(r'/goodbye/(.*)/$')
def goodbye(request, name):
    return Response("<h1>Goodbye, {name}</h1>".format(name=name))

我们运行下这个文件,然后执行 curl http://127.0.0.1:8888/hello/laowang/curl http://127.0.0.1:8888/goodbye/laowang/ 就能看到不同结果了,是不是挺简单。


MVC 模式

上面我们差不多实现了一个简单的 web 框架,现代 web 框架大部分都是使用的 MVC 模式:

  • Model: 数据存储、验证逻辑
  • Controller: 行为和业务逻辑
  • View: 展示层 (html)。大部分 web 框架都会提供一个模板引擎用来渲染页面(mako, Jinja2等),如果是做前后端分离或者仅提供 api 接口就不需要了(貌似这是个趋势,越来越多渲染放在了非服务端)。这个时候通常需要一个数据格式化层(Formatter),一个开源实现是 marshmallow 库,用来决定返回什么样的数据和格式。

实际上我个人习惯这么分层次,http 请求 -> 表单验证层(wtforms 等库) -> views -> controllers (业务逻辑层) -> models (ORM 层) -> formatters (marshmallow 等格式化输出)。大概就是一个请求到了,先用表单验证等库校验参数的类型、范围等,接下来调用 controllers 里的业务逻辑,controllers 层会调用 orm 层获取数据后处理,之后返回的数据使用 marshmallow 等格式化库输出 json 数据。总体来说还是比较清晰的,之前碰到很多项目都是校验、业务逻辑、数据获取揉在一起,代码相当难看。

MVC 模式常用的几种方式:

def hello(request, name):
    t = load_template("say_hello.html")
    return Response(t.render(name=name))

def hello(request, name)
    return TemplateResponse("say_hello.html", name=name)

@app.route("/hello/<name>", template="say_hello.html")
def hello(request, name):
    return {"name": name}

模板引擎大致的原理就是使用标记符号等替换填充的数据(content),格式化成字符串后返回。有点类似于 format 函数, 比如

"<h1>hello {name}</h1>".format(name=name)

ORM : To ORM or not to ORM ?

ORM(Object Relatetional Mapping): 对象关系映射,就是把关系数据库中的表映射到程序语言中的一个对象,这样我们就能以更加面向对象的方式操作数据库中的数据。在 python 中比较著名的 ORM 框架就是 sqlalchemy,还有轻量点的 peewee,以及 django 自带的 ORM等。笔者所经历过的项目中基本上都用了 sqlalchemy,直接使用其 ORM 或者抽象层次更低一些的 core。包括知乎、reddit 等也是使用了 sqlalchemy。ORM 的编写需要使用元类的知识,如果你感兴趣,可以参考这篇文章。『简单』的 python 元类


其他

上面基本列举了一个 web 框架的基础结构,当然实现一个 web 应用还有很多需要做,比如表单验证,用户认证、国际化、测试、部署、web 服务器等。笔者曾经用过 flask 全家桶(各种插件),感觉开发效率还是比较高的。这篇文章只是为了让大家了解下一个基础的 web 框架如何实现,有兴趣的可以看看 django、flask、tornado 等比较著名的开源 web 框架实现。


Ref

理解Python WSGI

LET’S BUILD A WEB FRAMEWORK!

PEP-333-Python-Web-Server-Gateway-Interface-v1-0