Mkdir700's Note

Mkdir700's Note

Flask 源码分析总结:Context 上下文原理

2022-08-23

#上下文 #描述器 #代理类

什么是 Context(上下文) ?

首先明白 Context 是什么,这东西常被翻译为上下文,这里借鉴知乎网友的回答 https://www.zhihu.com/question/26387327

这其实像以前的阅读理解题,“请结合语境上下文,谈一谈作者的感想”。

一个句子,单独来看,我并不知道其完整的含义,必须要结合上下文语境才能理解。

举个例子,我大喊一声“卧槽”。有人就会有疑问,“诶,发生了什么事情”(当然,也可能心理暗道:”这sb玩意儿“)

那么,上文,就是已经发生的事,下文就是即将发生的事。

可以说,知道事情的来龙去脉就是上下文的作用。

让我们回到程序中来,对于一个已有结果,为了后续能够使用它,我是不是可以用一个变量来指向它,方便我后续的访问。就像这样:

ret = do()

现在,有多个函数都需要用到 ret 的值,最简单的做法就是将 ret 的值传递进去,像这样:

ret = do()

do1(ret)
do2(ret)

这没什么问题,如果我们要有非常多类似 ret 的变量,并且都是要传入到各式各样的函数中的,每次调用岂不是要显式的写一大段参数传递。

ret = do()

do3(ret, ret1, ret2, ...)

可不可以换一种思路,将这些要用到值暂时存放起来,其他函数你想用就自己来拿,我不主动传给你了。

results = {
	"ret": 0,
	"ret1": 1
	...
}

def get_ret(key):
	return results.get(key, None)


def do():
	get_ret("ret1")

看到这里,相信你可以理解 “上下文” 就是这么朴实的东西,暂存接下来可能会用到的“上文”,当需要时主动获取即可。

在 Python 相信大家都使用过 with 关键字,比如 with open() as f,打开文件,退出 with 块时,则自动 close 文件。在这个过程中,打开文件的操作就属于上文,会将 open 的文件对象返回出来,在 with 所表示的这个上下文环境中,f 是上下文环境变量,是可以访问的,而关闭文件对象的下文已经提前写好了,待退出时会自动调用。

基于 with 使用上下文需要实现两个魔术方法:__enter____exit____enter__ 是上文,__exit__ 就是下文。

把上面示例用这种方式改造一下:

class MyContext:
	def __init__(self):
		self.results = {}

	def __enter__(self, key):
		return self.results.get(key, None)

	def __exit__(self, *args, **kwargs):
		pass

然后就可以这么使用了:

with MyContext("ret1") as ret:
	print(ret)

是不是清爽了许多呢?

self.results 的是个空字典,我们可以再为 MyContext 增加两个方法,用于更新 self.results 中的数据。

class MyContext:
	def __init__(self):
		self.results = {}

	def __enter__(self, key):
		return self.results.get(key, None)

	def __exit__(self, *args, **kwargs):
		pass

	def push(self, key, val):
		self.results[key] = val

	def pop(self):
		return self.results.popitem()

恭喜你,你已经写好了一个与 Flask 中类似的上下文类.

Flask 中的上下文

Flask 一共两个上下文 AppContextRequestContext,可以在 ctx.py 中找到。

AppContext 内部保存了 appgfrom flask import current_app, g 就是来自于这里。

RequestContext 内部保存了 requestfrom flask import request也是来源此处。

两者的实现大体相同,内部使用了一个列表充当栈,提供 pushpop 方法用于更新内部的栈。

为什么 Flask 总是知道我需要的请求实例?

为什么可以通过 from flask import reuqest 访问到这个当前这个请求的实例呢?

是不是觉得很神奇,也很迷惑?

接下来,就来梳理梳理,我们是如何拿到这个看似拿不到的上下文对象的。

动动小脑瓜想一想,“如果想获取一个还没有被创建的实例,怎么办?”,对于一个请求上下文,是在一个请求到来时才会被立即创建的。那么最简单的办法就是,写一个获取当前请求上下文的方法,需要时我们就主动调用一下这个方法,这样就可以拿到它了。

先明确一个概念,Flask 中的每一个请求到来时,都会创建一个单独的线程处理这个请求。所以这往往会伴随着多个请求,即多个线程。将线程与请求上下文建立映射关系,就可以根据线程标识获取到当前线程所对应的请求上下文了。

  1. 实现一个 get_current_request 函数,其功能就是根据当前线程名获取对应的 request 实例;
  2. 使用时,在需要 request 的视图函数中,主动调用 get_current_request 即可;

好像是可以了,假如我有几百个视图函数,就要调用几百次 get_current_request ,写几百次重复的代码,实在是不优雅😫,而且基于字典实现的映射表也没有加锁,也会有线程安全的问题。

由上面这个不那么优雅的写法,引出两个问题:

1. 如何优雅地全局地访问上下文;
2. 如何确保线程安全;

可不可以创建一个上下文的替身,当我访问替身时,就是访问实际的上下文对象,这个就是代理设计模式。


假设我们已经实现了在 Proxy 类内部自动调用 get_current_request 函数以获取当前线程的请求上下文,但是还有一个问题,基于字典实现的线程与请求上下文的映射表不是线程安全的。

emmm?加锁,上锁!!

这种方式是可行的,不过也需要我们自己去管理锁。

标准库中有一个 ThreadLocal,使用 ThreadLocal 声明一个全局变量 local,所有子线程访问 local时,实际访问的是 local 的副本,各个线程对 local 进行操作都是独立的,线程与线程之间互不影响。

关于 threading.local 原理解析可以看这篇文章:https://zhuanlan.zhihu.com/p/60126952

所以,将上下文声明为一个 ThreadLocal,是不是每个线程都可以获取到只属于自己的上下文对象了。不过用于并发编程的上下文变量,Python 推荐使用 ContextVar 而不是 threading.local,但是他们的作用一样的。Flask 也是使用 ContextVar 来定义一个全局的上下文变量。下面会讲到 ContextVar,先不着急,接下往下。

柿子还得挑软的捏,我们还是从最熟悉的 request 开始:

from flask import request

request 这玩意非常常用,上面也讲过 request 来源于 RequestContext ,是请求上下文中的一个成员属性。request 是在 globals.py 中定义的,代码如下:

_cv_request: ContextVar["RequestContext"] = ContextVar("flask.request_ctx")
__request_ctx_stack = _FakeStack("request", _cv_request)
request_ctx: "RequestContext" = LocalProxy(  # type: ignore[assignment]
    _cv_request, unbound_message=_no_req_msg
)
request: "Request" = LocalProxy(  # type: ignore[assignment]
    _cv_request, "request", unbound_message=_no_req_msg
)
session: "SessionMixin" = LocalProxy(  # type: ignore[assignment]
    _cv_request, "session", unbound_message=_no_req_msg
)

看不明白没关系,先了解这两个玩意儿:ContextVarLocalProxy,我们先对 ContextVar 做一定的了解,毕竟这是标准库。

ContextVar - 上下文变量的原材料

ContextVar 来源于 contentvars 标准库,因为我从来没用过它,对这个库也是一无所知,查看 contentvars 官方文档 https://docs.python.org/zh-cn/3/library/contextvars.html

粗略的看了下,可以理解为使用 ContextVar 可以定义一个专用于上下文环境的全局变量,一共有三个方法:

  • get,返回当前上下文环境中此变量的值;
  • set,设置当前上下文环境中此变量的值;
  • reset,将上下文变量重置为调用 ContextVar.set() 之前、创建 _token_时候的状态。

注意:再次提醒,上下文变量就是某个上下文环境中的一个变量,所以,上下文环境不同,该变量值也不同,环境与环境之间互不影响。


这个 reset 比较有意思,它根据你指定的 token,还原上下文变量之前的值。token 是在调用 set 之后返回的。你可以理解这个 token 就是一个 tag 标签,指定标签就可以回滚到该 token 前的值。

from contextvars import ContextVar


cv = ContextVar("foo")

token1 = cv.set(1)
token2 = cv.set(2)
token3 = cv.set(3)
cv.reset(token2)
print(cv.get(None)) # token2 之前的值是 1
cv.reset(token3)
print(cv.get(None)) # token3 之前的值是 2

这个也是 ContextVar 相比 ThreadLocal 应用于上下文场景的优势,上下文变量可以获取之前的值,而 ThreadLocal 则不能,一旦被设置就无法得知设置前的值是什么了。

ContextVar 了解到这里就差不多,知道其基本使用方法和作用就可以了。

它有以下特征:

  • 需要全局声明或定义
  • 线程与线程之间,使用的是其副本,读写互不影响
  • 根据 token 可还原该 token 之前的值

LocalProxy - 上下文变量的调味剂

LocalProxy 是 Werkzeug 中的一个类,它的作用及用法是什么呢?虽然代码里有详细的注释,但这仅仅是对一个类或一个方法的注解,你不能通过一个类或一个方法而窥得全貌,所以这里我还是推荐看 Werkzeug 的官方文档比较好 https://werkzeug.palletsprojects.com/en/2.2.x/local/?highlight=LocalProxy#module-werkzeug.local

先看最前面的简介,这是对一个模块的功能概要,可以方便我们对该模块有一个整体的了解。

即便是英文文档,也尽量去看吧,实在不行还有翻译软件嘛,下面是翻译后的内容:

您可能会发现,在每个请求期间都有一些数据需要跨函数使用。
您可能希望将它们作为全局数据访问,而不是在每个函数之间传递这些参数。
然而,在 Python Web 应用程序中使用全局变量不是线程安全的;
不同的 Worker 可能会相互干扰对方的数据。
您必须使用上下文局部变量,而不是在请求期间使用全局变量存储公共数据。
局部上下文是全局定义或导入的,但是它包含的数据是特定于当前线程、异步任务或 greenlet 的。
您不会意外地获取或覆盖另一个 Worker 的数据。
在 Python 中存储每个上下文数据的方法是 contextvars 模块。
上下文变量存储每个线程、异步任务或 greenlet 的数据。
这将取代旧的 threading.Local (以前的版本使用的是 threading.Local)
Werkzeug 提供了 ContextVar 的一些包装器,使其更容易使用。

了解到这几点:

  • 必须使用上下文变量作为数据导入;
  • 上下文变量在并发编程中的读写,互不影响;
  • local.py 中的内容就是对 ContextVar 的进一步封装,使其更容易使用。

LocalProxy allows treating a context var as an object directly instead of needing to use and check ContextVar.get(). If the context var is set, the local proxy will look and behave like the object the var is set to. If it’s not set, a RuntimeError is raised for most operations.
LocalProxy 允许将一个上下文变量直接看做是一个对象,而不需要使用和检查get方法,如果这个上下文变量被设置了值,那么这个 local_proxy 将看起来和表现得像被设置的值。如果没有被设置,对于绝大多数操作都将抛出 RuntimeError 异常。

这个读下来,有点不能理解没关系,还贴心的给了一段代码:

from contextvars import ContextVar
from werkzeug.local import LocalProxy

# 定义了一个上下文变量
_request_var = ContextVar("request")
# 使用LocalProxy包装了这个上下文变量
request = LocalProxy(_request_var)

from werkzeug.wrappers import Request

@Request.application
def app(r):
    _request_var.set(r)
    check_auth()
    ...

from werkzeug.exceptions import Unauthorized

def check_auth():
	# 使用request,看起来request是Request的实例,而不是LocalProxy
    if request.form["username"] != "admin":
        raise Unauthorized()

首先有个疑问,request 它喵的不是 LocalProxy 的实例吗?怎么在代码里面看起来像 Request 的实例呢?

这里用了代理设计模式,LocalProxyContextVar 的代理人,对 LocalProxy 的操作都将转发给 ContextVar

LocalProxy 是代理人,ContextVar 是幕后大佬,那么代理人遵循幕后大佬的规定情况下,可以有很多操作空间。

比如对于一个上下文变量而言,总是需要调用 get 方法(这个我们在上面了解过,上下文变量需要使用 get 获取值)才可以拿到返回值,LocalProxy 就将其封装了,你访问 LocalProxy 直接就能访问 ContextVar 的值,可以看做是 LocalProxy() 等价为 ContextVar(...).get()

LocalProxy 代理了很多魔术方法,可以说是 ContextVar 的全权代理人。

鸭子类型:当一个动物看起来像鸭子,叫起来像鸭子,我就认为它是一只鸭子。

相同的意思,当一个实例对象看起来像 Request 的实例,用法也和 Request 一致,我就认为它是 Request 的实例。

给个对比,来看看 LocalProxy 相比直接使用 ContextVar 的优点

# 定义一个上下文变量,类型为 str 类型
ctx = ContextVar("s", str)
ctx.set("abx")

# 获取值并调用字符串的 strip 方法

# 方法1,使用原生 ContextVar:
ctx.get("").strip()

# 方法2,使用 LocalProxy
s = LocalProxy(s)
s.strip()

再来一个比较:

class Foo(object):
	def __init__(self, value: str):
		# 字符串类型
		self.string: str = value

ctx = ContextVar("foo", Foo)
ctx.set(Foo("abc"))

# 访问上下文变量中的 string
# 方法1,原生
foo = ctx.get(None)
if foo:
	print(foo.string)  # abc

# 方法2,使用 LocalProxy
string = LocalProxy(ctx, "string")
print(string)  # abc

我们再看下,request 是怎么使用的:

_cv_request: ContextVar["RequestContext"] = ContextVar("flask.request_ctx")
__request_ctx_stack = _FakeStack("request", _cv_request)
request_ctx: "RequestContext" = LocalProxy(  # type: ignore[assignment]
    _cv_request, unbound_message=_no_req_msg
)
request: "Request" = LocalProxy(  # type: ignore[assignment]
    _cv_request, "request", unbound_message=_no_req_msg
)
session: "SessionMixin" = LocalProxy(  # type: ignore[assignment]
    _cv_request, "session", unbound_message=_no_req_msg
)
  1. _cv_request 是一个全局上下文变量,存储的数据类型为 RequestContext
  2. request_ctx_cv_request 上下文变量的代理实例,对 request_ctx 的操作实际都会转发给 RequestContext
  3. requestRequestContext 中的实例属性,为了避免反复使用 request_ctx.request,干脆再使用 LocalProxyrequest 也代理出来,session 同理。

Tip: unbound_message 只是一个警告信息(字符串),当没有绑定的上下文变量,获取 requestsessionrequest_ctx 就会抛出异常信息。

request 的来源清楚了,那么 gcurrent_app 也是一样道理,这里就不再赘述了。

_cv_app: ContextVar["AppContext"] = ContextVar("flask.app_ctx")
__app_ctx_stack = _FakeStack("app", _cv_app)
app_ctx: "AppContext" = LocalProxy(  # type: ignore[assignment]
    _cv_app, unbound_message=_no_app_msg
)
current_app: "Flask" = LocalProxy(  # type: ignore[assignment]
    _cv_app, "app", unbound_message=_no_app_msg
)
g: "_AppCtxGlobals" = LocalProxy(  # type: ignore[assignment]
    _cv_app, "g", unbound_message=_no_app_msg
)

上下文变量是何时被赋值的?

带着问题去阅读源码,我在阅读源码的时候就一直在想,它这个上下文是什么时候被赋值的?

ContextVar 是被全局定义的,所以 Flask 定义了,之后肯定会去使用 set 方法为其赋值的。

那么,我们就对 Flask 的源代码进行全局搜索 .set( 看看呗,如图:

一共就 5 个地方,排除 testing.py,就是 4 个,再排除掉前两个,前两个都是在 _FakeStack 里,这个东西可以直接不看。

也就是,_cv_app_cv_request 都只会在一个固定为位置被赋值,老样子,先看 request

RequestContextpush 方法内:

    def push(self) -> None:
        app_ctx = _cv_app.get(None)

        if app_ctx is None or app_ctx.app is not self.app:
            app_ctx = self.app.app_context()
            app_ctx.push()
        else:
            app_ctx = None
		# 设置值,并将 token 保存起来
        self._cv_tokens.append((_cv_request.set(self), app_ctx))

        if self.session is None:
            session_interface = self.app.session_interface
            self.session = session_interface.open_session(self.app, self.request)

            if self.session is None:
                self.session = session_interface.make_null_session(self.app)

        if self.url_adapter is not None:
            self.match_request()

再看 RequestContext 是什么时候被实例化的,搜索 RequestContext(,只有一个地方,在 Flask 中的 request_context 方法:

接着再看,request_context 何时被调用:

    def wsgi_app(self, environ: dict, start_response: t.Callable) -> t.Any:
        ctx = self.request_context(environ)
        ...

Flask 中的 wsgi_app 中的第一行被调用,参数是 environ

先来说下 wsgi_app 方法,这个方法在 Flask.__call__ 中被调用,即在 Flask(...)() 时就会调用。

Flask(...)Werkzeug 中的一个 app,每当有请求过来时,Werkzeug 都会开一个线程并调用我们写的 Flask(...)

Werkzeug 会将该请求的环境(一个字典)传给 FlaskFlask 会根据这个 environ 产生一个 RequestContext_cv_request 里面又有我们常用的 request,最终通过 LocalProxy 的代理让我们在视图函数中顺利拿到 request

关于 Werkzeug 干了什么,可以看下这位大佬的文章:https://cizixs.com/2017/01/11/flask-insight-start-process/

后续有时间的话,我也会发一发 Werkzeug 的源码分析。

个人总结

以下是我个人的一些总结,读者可跳过不看。

  • 了解了 ContextVar 这玩意;
  • 了解了 LocalProxy 这种”全权代理人“的写法;
  • 对上下文的概念有了更深入的了解;

阅读源码确实是一个看上去收益极小的事情,不过可以看看大佬们是如何写代码的,即便一时不明白你也可以借鉴,很多事情都是从模仿开始,并不是与生俱来的。