Flask 活了 16 年,最难的一次重构是拆掉自己的双上下文
- Flask 3.2.0 将维持 14 年的 RequestContext 与 AppContext 双上下文合并,context 机制从 LocalStack 双栈逐步收敛为标准库 ContextVar 单上下文
- Flask 全部源码仅 9513 行、6 个依赖中 5 个来自 Pallets 自家,靠组合 Werkzeug、Jinja2 等库实现,体现克制而非功能精简
- 默认会话经 itsdangerous 签名后存于客户端 cookie,签名仅防篡改不防窃读,敏感字段明文暴露且签名算法仍为 SHA-1,需靠 SECRET_KEY_FALLBACKS 做密钥轮换
- Flask 的 async 视图由 asgiref 包装成同步函数运行于 WSGI 之上,非真并发,真正的异步方案是继承其 sansio 内核的 Quart
- 维护上 davidism 一人贡献 1855 次提交,bus factor 贴近 1,发布节奏放缓,但 issue 区整洁度与管理纪律仍属顶级
2010 年 4 月 1 日,Armin Ronacher 把一个叫 Flask 的东西发了出来。那天正赶上愚人节,看到的人都以为这是个玩笑,毕竟这个「新框架」一行路由匹配代码都没自己写,核心动作就是把 Werkzeug 和 Jinja2 两个现成的库粘在一起。一周之后,pallets/flask 仓库在 GitHub 上落了户。
16 年过去,这个玩笑攒下了 73,581 个 Star,16,979 次 fork。更有意思的是,我最近把它的源码整个翻了一遍,正撞上这个项目在做 16 年来最疼的一次手术。未发布的 3.2.0 版本里,维持了 14 年的双上下文设计被官方亲手推翻,RequestContext 和 AppContext 合并成了一个。借着这个切口往下拆,能看清这个老框架全部的家底。
9513 行的「微框架」
一句话定位,Flask 是一个用组合代替实现的 Python Web 微框架,HTTP 解析、路由匹配、模板渲染、命令行、事件信号这些脏活全部外包,自己只留一层调度代码。
数字最能说明问题。src/flask 目录下的全部 Python 源码加起来 9,513 行,最大的文件 app.py 也才 1,628 行。作为对照,Django 光一个 core 目录就不止这个数。再看 pyproject.toml 声明的运行时依赖,一共 6 个。
| 依赖 | 干什么 | 出身 |
|---|---|---|
| werkzeug | WSGI 协议、路由、开发服务器 | Pallets |
| jinja2 | 模板引擎 | Pallets |
| markupsafe | HTML 转义 | Pallets |
| itsdangerous | 签名序列化 | Pallets |
| click | 命令行 | Pallets |
| blinker | 信号广播 | 外部 |
六个依赖五个是 Pallets 自家兄弟,真正的外部依赖只有 blinker 一个。你想想看,所谓「微框架」的微观,不是功能少,是克制。框架自己拒绝写任何一行可以委托出去的代码。
这种克制也渗透进了 API 设计。app.py 第 1569 行的 wsgi_app 方法,docstring 里花了一大段讲中间件的正确用法,翻译过来就是,别用 app = MyMiddleware(app) 这种包法,要用 app.wsgi_app = MyMiddleware(app.wsgi_app),这样原始 app 对象还在你手里,随时能继续调它的方法。一个装饰入口,从 0.7 版本守到现在没变过。
把上面这些模块关系画成一张图,整个项目的骨架一目了然,五层结构,中间那层蓝色高亮的 Sans-IO 内核是被 WSGI 外壳和 Quart 共同继承的公共底座。
看图的顺序建议从下往上,底座是 Pallets 全家桶,中间是无关协议的内核,最上面那层才碰 WSGI。理解了这个分层,下面看请求怎么走就顺了。
一个请求的一生
翻 app.py 的过程里,我把一个请求的完整路径梳理了出来,链路比想象中干净。
WSGI 服务器每收到一个请求,就调用一次 Flask.__call__,它转手交给 wsgi_app。第一步是 self.request_context(environ) 造一个上下文对象 push 进去,接着 full_dispatch_request 接管整个流程。
整条链路长这样,实线是主流程,虚线是异常兜底。
对着图看代码,每个节点都能对上源码里的一个函数。
# app.py,full_dispatch_request 核心链路(有删减)
try:
request_started.send(self, _async_wrapper=self.ensure_sync)
rv = self.preprocess_request(ctx)
if rv is None:
rv = self.dispatch_request(ctx)
except Exception as e:
rv = self.handle_user_exception(ctx, e)
return self.finalize_request(ctx, rv)
preprocess_request 跑完所有 before_request 钩子,谁返回了非 None 值就直接短路。dispatch_request 是真正找到视图函数的地方,它从请求上已挂好的 url_rule 取 endpoint(路由匹配在 werkzeug 那边早就完成了),处理完自动 OPTIONS,最后一行是全部调度逻辑的终点。
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
视图返回值进 finalize_request,make_response 把字符串、元组、dict 统一转成 Response 对象,process_response 跑 after_request 钩子,上下文 pop,一次请求结束。
整条链路里没有一个环节是 Flask 自己发明的协议,它做的是把 werkzeug 的路由结果、jinja 的模板、你的视图函数,按固定顺序串成一条流水线。
还有个很多人撞过的设计藏在注册环节。sansio/scaffold.py 第 42 行有个 setupmethod 装饰器,包住了 route、before_request 这些注册方法,一旦第一个请求进来,_check_setup_finished 就把门锁死。你在外网环境调试时收到「The setup method 'route' can no longer be called」的报错,源头就是它。多进程下各 worker 注册状态不一致的坑,Flask 选择了直接禁止而不是修复。
双上下文,撑了 14 年后被自己拆掉
现在说到这次手术本身。
Flask 的上下文机制,是它区别于其他框架最深的胎记。request 和 session 是请求级数据,current_app 和 g 是应用级数据,2012 年的 0.9 版本把应用上下文独立出来之后,Flask 用两个独立的栈管它们,RequestContext 一个栈,AppContext 一个栈,请求栈 push 时顺带 push 应用栈,靠 werkzeug 的 LocalStack 做线程隔离。你在任意函数里裸写 from flask import request 就能拿到当前请求,靠的就是这套栈。
这个设计养活了整个扩展生态,也背了 14 年的复杂度。两套栈的联动关系是隐式的,扩展作者经常分不清该依赖哪个,新手教程里「Working outside of application context」的报错几乎人手一份。
2.2 版本先动了一刀,LocalStack 换成标准库的 ContextVar,globals.py 第 40 行现在只剩一个 _cv_app,current_app、g、request、session 四个代理全部从它取值。到 3.2.0 这个未发布版本,官方下了狠手,RequestContext 直接并进 AppContext,request_context() 方法返回的已经是 AppContext 对象,全局的 request_ctx 变成带 DeprecationWarning 的别名,4.0 里会删掉。
CHANGES.rst 里对这次合并的官方解释,说这大大简化了追踪活动上下文的内部代码。坦白讲,拆掉双栈的代价也明摆着,所有依赖 request_ctx 的老扩展都要跟着改。但一个 16 年的项目愿意动自己最核心的胎记,这件事本身就值得看两眼。
其实吧,把三代演进连起来看,LocalStack 双栈,ContextVar 单变量,单上下文合并,每一步都在收敛。上下文机制从「框架的魔法」慢慢变成「Python 语言自带的能力」,魔法没有消失,只是被标准库收编了。
会话那把锁,签名不加密
再拆会话,这里藏着 Flask 最容易被误解的一件事。
默认会话不落在服务端任何存储里,sessions.py 第 284 行的 SecureCookieSessionInterface,把整个 session dict 用 itsdangerous 的 URLSafeTimedSerializer 签名后塞进一个 cookie。也就是说,你往 session 里放的每个字段,都以 Base64 明文的形式躺在用户的浏览器里。
签名只保证不被篡改,不保证不被偷看。
你要是把用户角色、手机号这类敏感字段直接往 session 里塞,等于明文寄给了客户端。文档提过这个坑,但翻讨论区就知道每年都有人踩。顺带一提 json/provider.py 的 DefaultJSONProvider,sort_keys = True 是默认值,jsonify 输出的 key 永远按字典序排,官方理由是测试可复现,但拿它当顺序敏感接口用就会翻车。
源码里还有一笔更细的旧账。第 293 行的摘要算法配置,digest_method = staticmethod(_lazy_sha1),会话签名默认还是 SHA-1。旁边 _lazy_sha1 函数的注释写着是为了 FIPS 构建延迟导入,但真正的阻力是换成 SHA-256 会让所有存量签名 cookie 集体失效,所以一直没动。不过它留了后手,SECRET_KEY_FALLBACKS 配置支持密钥轮换,新密钥签发,旧密钥只用来验签。
save_session 里的细节也讲究。会话只要被访问过,响应就加 Vary: Cookie 头,缓存中间件看到它就不敢复用旧响应。session 被清空且发生过修改时,删 cookie 而不是设置空 cookie。2026 年 2 月的 3.1.3 还修过一个安全相关的边角,对 session 做 in 判断和 len() 也要标记为已访问,对应公告 GHSA-68rp-wp8r-4726。这类缓存语义的坑,只有签名 cookie 这种把状态存在客户端的方案才会遇到。
async 的诚实边界
Flask 2.0 加了 async 视图支持,但翻完 app.py 第 1068 行的 ensure_sync,你会看清这个支持的成色。
if iscoroutinefunction(func):
return self.async_to_sync(func)
return func
async def 的视图函数,被 asgiref 的 async_to_sync 包成同步函数再进 WSGI 流水线。也就是说,WSGI worker 底下还是同步模型,async 视图只是「能写」,不是「真并发」。而且 asgiref 是可选依赖,没装 flask[async] 的话,运行时直接抛 RuntimeError 提示你装 extra。
真想要 ASGI 的并发能力,官方给的答案不是 Flask 自己,而是下面这个。
sansio,一个内核两种外壳
源码树里最特别的目录是 src/flask/sansio,里面躺着 scaffold.py、app.py、blueprints.py 三个文件,加起来 2,505 行。README 一共四句话,说这里的代码要供 Flask 的替代实现复用,比如 Quart,因此不能做任何 IO,不能出现在可能的 IO 路径上,也不能用 Flask 的全局对象。
这就是 2.2 那次重构埋下的主线。类继承链是 Flask(App),App 继承 sansio 的 Scaffold,Blueprint 也继承 Scaffold,路由注册、钩子管理、模板配置这些和 IO 无关的逻辑全部沉在 sansio 内核里,Flask 只是在外面套了 WSGI 这层壳。
而 ASGI 阵营的 Quart,3,665 Star,就是直接继承 flask.sansio 里的 Scaffold 来复用这套内核的。同一个内核,WSGI 外壳是 Flask,ASGI 外壳是 Quart,两个项目共享同一套视图注册、钩子、蓝图语义。
我一直觉得这是 Flask 给整个行业贡献的最可复用的一招,可以叫它 Sans-IO 内核模式。把协议无关的核心逻辑抽出来,让多个运行时外壳共享同一个内核,Flask 和 Quart 是这个模式,HTTPX 的同步异步双客户端是这个模式,很多网络库的用户态协议解析也是这个模式。想支持多种运行时又不想满屏 if else 时,这是比「适配器层」更彻底的答案。
和后浪们比,它还香吗
把时间拨回 2026 年,这个赛道早就换了打法,同场竞技的几位把数据摆出来看。
| 框架 | Star | 出生 | 服务器模型 | 定位 |
|---|---|---|---|---|
| FastAPI | 102,180 | 2018 | ASGI | 类型驱动,自动文档 |
| Django | 90,394 | 2005 | WSGI/ASGI | 全家桶 |
| Flask | 73,581 | 2010 | WSGI | 微内核加扩展 |
| Quart | 3,665 | 2017 | ASGI | Flask 的异步镜像 |
FastAPI 的 Star 在 2026 年已经反超 Flask 近三万,趋势不言自明。但说真的,Star 曲线不等于选型结论。写一个内部管理后台、一个传统的服务端渲染站点、或者一堆原型 API,Flask 的心智负担依然是最低那档,route、request、session、blueprint、cli 五个概念就能开工,教程和答案多到随手可查。做高并发 IO 密集的 API、要 WebSocket、要自动生成的 OpenAPI 文档,那 FastAPI 才是对口工具。Django 适合一个框架从 ORM 买到 Admin 的场景,Quart 适合已经写好 Flask、又确实需要 async 的存量迁移。
谁在维护它
维护现状这部分,数据比想象中健康,也比想象中脆弱。
健康的一面,仓库最后一次 push 是 2026 年 8 月 16 日,3.1.3 发布于 2026 年 2 月,issue 区常年只挂着个位数存货(我查的时候是 4 个),pallets 组织用强硬的 issue 政策换来了极干净的追踪区,提问一律引去 GitHub Discussions。16 年的项目做到这个整洁度,管理纪律是顶级的。
脆弱的一面,看贡献分布。davidism 一人 1,855 次提交,创始人 mitsuhiko 的 1,189 次早已停更,第三名人类贡献者 untitaker 只有 274 次,bus factor 贴着 1 在走。release 节奏也从鼎盛期的一年多个版本,放缓到现在一年一到两个 patch,3.1.x 系列里除了那次会话修复几乎都是小修。这不是坏信号,一个功能面收口的老项目本该如此,但它意味着这个项目的命运和一个人的精力深度绑定。
收在内核上
拆完这 9,513 行,我真正想留下的只有两条,比那 73,581 个 Star 实在。
一条是工程上的,Sans-IO 内核模式。把协议无关的核心抽成内核,运行时差异全部推到外壳层,Flask 用这一招让 2010 年的代码在 2026 年还能长出 ASGI 分身。你自己的项目里,凡是要同时支持多种运行时、多种协议的地方,这个模式都值得先于适配器考虑。
另一条是品味上的,克制。六个依赖五个自产,9,513 行只做调度,双上下文用了 14 年发现不对,敢拆就拆。框架和人一样,知道自己不做什么,比知道自己能做什么更难。
顺手一提,Flask 的官方文档是 Pallets 全家桶里写得最舒服的一档,想系统了解上下文机制的话,比绝大多数二手教程准确。

评论互动