[源码分析] Flask 中路由匹配是如何实现的

avatar
Chaojie

首先让我们来了解下 WSGI 规范是啥?

简单来说,WSGI 是服务器和应用之间的接口,前端过来的请求传到服务器之后比如 gunicorn,之后服务器会将请求转发给应用。因为有很多个服务器,如果我们为我们的应用根据不同的服务写不同的代码,会很麻烦,所以就出现了 WSGI。

WSGI 规定了 application 应该实现一个可调用的对象(函数,类,方法或者带__call__的实例),这个对象应该接受两个位置参数:

  1. 环境变量(比如 header 信息,状态码等)
  2. 回调函数(WSGI 服务器负责),用来发送 http 状态和 header 等

同时,该对象需要返回可迭代的响应文本。

更具体的解释可以去 google 搜索相关知识。

一个最简单的实现:

def app(environ, start_response):
    response_body = b"Hello, World!"
    status = "200 OK"
    # 将响应状态和 header 交给 WSGI 服务器比如 gunicorn
    start_response(status, headers=[])
    return iter([response_body])

我们可以直接使用 gunicorn 之类的服务启动这个 app。

有了 WSGI 规定,框架中就要实现规范中所要求的部分。我们来看看 Flask 是如何实现的。

Flask0.1 版本的实现中只有一个文件,一共 600 多行代码。根据官方文档,一个最简单的 web 服务像这样:

from flask import Flask
app = Flask(__name__)

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

if __name__ == '__main__':
    app.run()

调用 Flask() 之后发生了什么?

首先在 __init__ 内置方法中有这么几个变量:

class Flask(object):
    def __init__(self, package_name):
        # view_functions 存储视图函数名称和视图函数
        self.view_functions = {}
        # 路由字典
        self.url_map = Map()

根据名字可以猜测,view_functions 用来存放视图函数,url_map 用来存放路由字典。暂时跳过,来看看 __call__内置方法:

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

environ,start_response,是不是在哪里见过?WSGI 规范中要求实现的对不对。它返回了 wsgi_app 方法:

def wsgi_app(self, environ, start_response):
        with self.request_context(environ):
            rv = self.preprocess_request()
            if rv is None:
                rv = self.dispatch_request()
            response = self.make_response(rv)
            response = self.process_response(response)
            return response(environ, start_response)

看到了没,跟我们上面实现的那个简单的 app 是不是很像。

首先预处理请求,然后分发请求到不同的视图函数,最后响应。

我们先来看 dispatch_request 是如何实现的:

def dispatch_request(self):
    	# 精简了下代码
        try:
            endpoint, values = self.match_request()
            return self.view_functions[endpoint](**values)
        except HTTPException, e:
            ......

def match_request(self):
        rv = _request_ctx_stack.top.url_adapter.match()
        request.endpoint, request.view_args = rv
        return rv

dispatch_request 首先获取 endpoint 和一些变量,然后在视图函数字典里找到对应的视图函数返回。endpoint 和 values 就是我们在定义路由的处理函数时,比如:

url_for('profile', username='John Doe')

其中 profile 就是 endpoint,也就是对应视图函数的名称,username 就是变量。

match_request 中这个 _request_ctx_stack 又是个啥。看起来它像是用来匹配路由的。

_request_ctx_stack 是请求上下文栈,用一个栈把当前请求相关的数据压入栈中,然后进行路由分发和后续处理,处理完成后退出。

具体来说,我们回过头看 wsgi_app方法中有个 with 语句,控制请求上下文的进入和退出。

with self.request_context(environ):

这个 request_context是这样的:

class _RequestContext(object):
    def __init__(self, app, environ):
		...
        self.url_adapter = app.url_map.bind_to_environ(environ)
        ...

    def __enter__(self):
        _request_ctx_stack.push(self)

    def __exit__(self, exc_type, exc_value, tb):
        if tb is None or not self.app.debug:
            _request_ctx_stack.pop()

其中的 url_adapter 获取了路由字典,然后连同其他变量一起被压入栈中,这样在上面的 match_request 方法中,从栈中获取 url_adapter ,然后匹配路由找到对应的 endpoint 和参数,然后根据 endpoint 和参数从view_functions 中查找对应的视图函数。

self.url_adapter = app.url_map.bind_to_environ(environ)
rv = _request_ctx_stack.top.url_adapter.match()

其中,bind_to_environ 将 url 绑定到目前的环境返回一个适配器,然后适配器去匹配请求。这两个方法都来自 Flask 的底层调用 werkzeug

梳理一下流程,首先适配器在 url_map 中查找当前路由对应的 endpoint 和 values,然后 dispatch_request 根据 endpoint 找到对应的视图函数,然后返回。

那么,url_map 中的路由和 endpoint 对应关系是从哪里来的?

我们在使用 Flask 是不是要用装饰器给视图函数加上路由和方法对吧,像这样:

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

这个 route 装饰器长这样,可以看到它调用了 add_url_rule 方法。

def route(self, rule, **options):
    def decorator(f):
            self.add_url_rule(rule, f.__name__, **options)
            self.view_functions[f.__name__] = f
            return f
        return decorator

def add_url_rule(self, rule, endpoint, **options):
    options['endpoint'] = endpoint
    options.setdefault('methods', ('GET',))
    self.url_map.add(Rule(rule, **options))

add_url_rule 添加了路由和 endpoint 到 url_map 中,这样一个请求的路由过来后,url_adapter.match() 就能匹配到对应的 endpoint,然后根据 endpoint 从 view_functions 里面查找视图函数。

url_map 是 werkzeug 中的 Map 对象,然后添加的是 Rule 对象。它看起来像这样:

self.url_map = Map([
    Rule('/', endpoint='home'),
    Rule('/book/<id>', endpoint='book')
])

除了利用装饰器,我们也可以这样使用:

def index():
    pass
app.add_url_rule('index', '/')
app.view_functions['index'] = index

现在,一切都解释清楚了,定义好视图函数后,app.run运行即可。

可以看到,Flask 中路由匹配是利用字典实现的,还有一种利用前缀树来实现路由的,比如 go 语言中的 gin 框架,关于如何用前缀树实现路由可以看我的另一篇文章:

前缀树算法实现路由匹配原理解析

© CC BY-NC-SA 4.0 | Chaojie