- 后端
- Web框架
- WebSocket
【免费下载链接】aiohttp
Asynchronous HTTP client/server framework for asyncio and Python
本篇文章围绕 aiohttp 变更日志条目 CHANGES/13637.bugfix.rst("FixedCookieJar.update_cookies()to copy user-passed mutableMorselobjects")展开,深入剖析该 bugfix 的缺陷成因、修复方式与底层实现原理。通过阅读本文,你将理解 aiohttp 客户端 cookie 存储模型中的对象所有权与防御性复制机制,掌握CookieJar.update_cookies()、update_cookies_from_headers()与Morsel可变对象之间的交互细节,并学会如何在业务代码中规避同类"共享可变对象被意外修改"的经典问题。
变更日志说了什么
CHANGES/13637.bugfix.rst全文仅一句话,却概括了一次典型的行为修复:
Fixed
CookieJar.update_cookies()to copy user-passed mutableMorselobjects -- by :user:Dreamsorcerer.
拆解其含义:
- 修复对象:
aiohttp.CookieJar的公开方法update_cookies(); - 修复内容:该方法现在会复制调用方传入的可变
Morsel对象(而非直接持有引用); - 修复目的:保证调用方在调用之后继续修改自己手中的
Morsel时,不会意外改写 cookie jar 内部已存储的 cookie 状态;同时,jar 内部的规范化处理(如补全domain、path)也不会反向"污染"调用方的对象。
要真正理解这条修复,需要先弄清 aiohttp 中 cookie 存储的核心数据结构。
背景:CookieJar与Morsel的对象模型
CookieJar 是客户端 cookie 的存储与过滤中心
在 aiohttp 中,CookieJar(定义于 aiohttp/cookiejar.py)实现了遵循 RFC 6265 的 cookie 存储:
- 构造参数
unsafe(是否接受来自 IP 地址主机的 cookie)、quote_cookie(发送时是否对 cookie 值做编码)与treat_as_secure_origin(将某些来源视为安全来源); - 内部使用
defaultdict[tuple[str, str], SimpleCookie]按(domain, path)分组存放 cookie,cookie 的完整身份是(domain, path, name)三元组; host_only_cookies集合记录未携带Domain属性的主机限定 cookie;filter_cookies()负责按请求 URL 的域名、路径前缀、secure 标记等规则筛选出应当发送的 cookie。
CookieJar在整个客户端生命周期中的位置可从 aiohttp/client.py 看出:ClientSession.__init__在cookie_jar参数缺省时默认创建CookieJar()(client.py#L379-L381),并在构造时把用户传入的cookies参数一次性写入 jar:
if cookies: self._cookie_jar.update_cookies(cookies)也就是说,update_cookies()是用户向会话"播种" cookie 的主要入口,它的健壮性直接影响整个会话的 cookie 行为。
Morsel 是 Python 标准库中的可变 cookie 单元
Morsel来自 Python 标准库http.cookies,用于描述单个 cookie 的key、value、coded_value及domain、path、secure等属性。它本质上是可变对象——可以通过morsel.set()或属性赋值来修改其值。
aiohttp 的LooseCookies类型(见 aiohttp/typedefs.py)允许update_cookies()接受多种形态的输入:
LooseCookiesMappings = Mapping[str, str | BaseCookie[str] | Morsel[Any]] LooseCookiesIterables = Iterable[tuple[str, str | BaseCookie[str] | Morsel[Any]]] LooseCookies = LooseCookiesMappings | LooseCookiesIterables | BaseCookie[str]即:字符串、BaseCookie(如SimpleCookie)或单个Morsel对象都可以作为 cookie 来源。问题恰恰出在"调用方直接传入Morsel"这条路径上。
缺陷本质:共享可变对象导致的相互污染
传入 Morsel 时的两种污染方向
在没有防御性复制的情况下,如果 jar 直接保存调用方传入的Morsel引用,会出现两个方向的互相污染:
- 调用方改写 jar:调用方在
update_cookies()之后继续修改自己手中的Morsel(例如登录流程中复用同一个SimpleCookie对象、刷新 token 后原地改值),jar 中已存储的 cookie 会被同步篡改,后续filter_cookies()发出的Cookie头随之出错; - jar 改写调用方:
update_cookies()内部会对 cookie 做规范化处理——例如根据response_url补全缺失的path(取响应路径的目录部分)、无Domain时把domain设为请求主机、去掉域名首部的点号、去除 path 末尾的斜杠等。如果直接修改传入对象,这些规范化会"泄漏"回调用方,导致调用方手中的Morsel属性被意外改写。
修复前的实际表现
仓库测试 tests/test_cookiejar.py 中新增的两个回归测试(均引用 issue #13634,与本次变更日志 13637 同源)精确刻画了上述两种污染:
def test_update_cookies_copies_caller_morsel() -> None: """Test that mutating a Morsel after update_cookies() does not change the jar.""" jar = CookieJar() url = URL("http://example.com/") sc = SimpleCookie() sc["auth"] = "original-value" jar.update_cookies({"auth": sc["auth"]}, url) # Mutate the caller's Morsel after the jar has stored it. sc["auth"].set("auth", "mutated-value", "mutated-value") assert jar.filter_cookies(url)["auth"].value == "original-value" def test_update_cookies_does_not_mutate_caller_morsel() -> None: """Test that update_cookies() normalization does not leak into the caller's Morsel.""" jar = CookieJar() sc = SimpleCookie() sc["sid"] = "value" jar.update_cookies({"sid": sc["sid"]}, URL("http://example.com/sub/page")) # The jar normalizes its private copy, not the caller's object. assert sc["sid"]["domain"] == "" assert sc["sid"]["path"] == "" filtered = jar.filter_cookies(URL("http://example.com/sub/page")) assert filtered["sid"].value == "value"- 第一个测试断言"事后篡改调用方 Morsel 不影响 jar"(正向隔离);
- 第二个测试断言"jar 的 domain/path 规范化不反向写回调用方对象"(反向隔离)。
修复实现:copy_morsels参数与两阶段处理
修复后的调用关系
修复的核心落在 aiohttp/cookiejar.py 的update_cookies()与内部方法_update_cookies()上:
def update_cookies(self, cookies: LooseCookies, response_url: URL = URL()) -> None: """Update cookies.""" self._update_cookies(cookies, response_url, copy_morsels=True) def update_cookies_from_headers( self, headers: Sequence[str], response_url: URL ) -> None: """Update cookies from raw Set-Cookie headers.""" if headers and (cookies_to_update := parse_set_cookie_headers(headers)): # The freshly parsed Morsels are not shared with the caller, # so they can be stored and normalized without a defensive copy. self._update_cookies(cookies_to_update, response_url, copy_morsels=False)两个公开入口共享同一个_update_cookies()内部实现,通过布尔参数copy_morsels区分是否需要防御性复制:
update_cookies()面向用户传入的、可能与调用方共享的对象,copy_morsels=True;update_cookies_from_headers()面向从Set-Cookie原始头解析出来的、新创建且不与任何外部代码共享的Morsel(由 aiohttp/_cookie_helpers.py 的parse_set_cookie_headers()生成),因此copy_morsels=False,省去一次不必要的复制开销。
复制发生的精确位置
在_update_cookies()的处理循环中:
for name, cookie in cookies: if not isinstance(cookie, Morsel): tmp = SimpleCookie() tmp[name] = cookie # type: ignore[assignment] cookie = tmp[name] elif copy_morsels: # TODO(https://github.com/python/typeshed/pull/16346): Remove cast cookie = cast("Morsel[str]", cookie.copy())处理逻辑分三种情况:
- 传入的是普通
str(非Morsel):先装入临时SimpleCookie得到标准Morsel,该对象本就是新建的,天然与调用方无共享,无需复制; - 传入的是
Morsel且copy_morsels=True(即经由update_cookies()):调用Morsel.copy()得到独立副本,此后所有 domain/path 规范化与属性写入都作用于副本; - 传入的是
Morsel且copy_morsels=False(即经由update_cookies_from_headers()):直接复用解析产物。
Morsel.copy()是 Python 标准库http.cookies.Morsel提供的方法,返回一个深拷贝(含key、value、coded_value及全部保留属性),足以切断与源对象的别名关系。
复制之后的规范化流程
获得私有副本后,_update_cookies()继续执行 RFC 6265 相关的规范化(这些步骤现在只影响 jar 内部副本):
- 拒绝来自 IP 地址主机的 cookie(除非
unsafe=True); - 去掉
domain末尾的点号、剥离首部的点号(如.example.com→example.com); - 校验
domain与response_url主机是否匹配(_is_domain_match,example.com可匹配www.example.com子域); - 无
path时按响应 URL 的路径取目录前缀(如/sub/page→/sub),path统一去除末尾斜杠; - 无
domain且存在主机名时,将 cookie 记入host_only_cookies并把domain设为该主机;反之(显式Domain)则从host_only_cookies中移除对应身份,因为域 cookie 会取代同身份的 host-only cookie; - 解析
max-age/expires并调度过期(_expire_cookie维护小顶堆_expire_heap,配合_do_expiration()惰性清理过期条目); - 写入
self._cookies[(domain, path)][name],并清空对应的_morsel_cache缓存项,保证下次filter_cookies()重建发送用 Morsel。
所有这些就地修改,都只发生在复制出的对象上——这正是两个回归测试能够同时通过的原因。
从调用链看修复的实际价值
ClientSession 初始化播种 cookies
ClientSession(cookies=...)是update_cookies()最常见的使用场景(aiohttp/client.py#L383-L384)。修复前,如果用户复用同一个SimpleCookie对象去创建多个会话或在同一会话内重复使用,jar 之间或 jar 与用户对象之间会互相干扰;修复后,每个会话都持有独立的 cookie 副本,行为可预测。
单次请求的临时 cookie jar
在处理单次请求级cookies=参数时,aiohttp/client.py#L649-L657 会构造一个临时CookieJar并把请求级 cookies 写入其中再与 jar 结果合并:
if cookies is not None: tmp_cookie_jar = CookieJar( unsafe=self._cookie_jar.unsafe, quote_cookie=self._cookie_jar.quote_cookie, ) tmp_cookie_jar.update_cookies(cookies) req_cookies = tmp_cookie_jar.filter_cookies(url) if req_cookies: all_cookies.load(req_cookies)这里同样依赖update_cookies()的防御性复制:传入的cookies可能包含用户直接构造的Morsel,若不加复制,临时 jar 的规范化会反向改写用户对象,且后续请求中用户复用该对象时又会把陈旧值带进新的临时 jar。
与响应侧解析路径的对照
update_cookies_from_headers()是响应侧路径——ClientSession._request在收到Set-Cookie响应头后调用它(aiohttp/client.py#L758)。这条路径传入的是 aiohttp/_cookie_helpers.py 中parse_set_cookie_headers()新解析出的Morsel列表,对象完全由 aiohttp 内部创建、不对外共享,因此可以安全地跳过复制以节省性能。同一修复同时保留了两条路径各自的最优策略,是"按对象所有权决定复制策略"的典型设计。
相关演进脉络
update_cookies()并非孤立修复,CookieJar在近期版本中经历了多轮与"对象所有权"相关的加固,彼此印证:
- 3.14.1:
CookieJar.save()/load()重构——load()改为先清空再写入(而非合并),且加载的 cookie 走与update_cookies()相同的接受规则,并持久化绝对过期时间戳与 host-only 标记(见 CHANGES.rst 3.14.1 版本段,issue 12824); - 3.14.0:修复
unsafe=True时请求级 cookies 发往 IP 地址主机被丢弃的问题(issue 12011),其临时 jar 的unsafe设置需要从会话 jar 复制; - 3.14.0:修复 per-request
cookies在跨域重定向时被错误丢弃的问题(issue 12550)。
这些修复共同强化了 aiohttp 客户端 cookie 行为的确定性与隔离性,而 13637 这条则是最基础的一环:确保 jar 与调用方之间不发生可变对象别名共享。
对使用者的启示
- 无需改变调用习惯:修复对 API 完全透明,
update_cookies(mapping)、update_cookies(SimpleCookie)、update_cookies(iterable_of_Morsel)的用法都不变; - 可以放心复用 Morsel 对象:修复之后,你可以安全地持有并继续修改自己构造的
Morsel,jar 内部的存储不会再受其影响,也不会反过来被 jar 修改; - 调试线索:如果遇到"明明 set 了 cookies 却发出旧值"或"调用方 Morsel 的 domain/path 莫名被填上"的诡异行为,且使用的 aiohttp 版本较老,应优先考虑升级到包含 13637 修复的版本;
- 可验证的回归测试:
tests/test_cookiejar.py中的test_update_cookies_copies_caller_morsel与test_update_cookies_does_not_mutate_caller_morsel是本次修复的行为契约,任何未来改动都必须保持这两个隔离性断言成立。
总结
CHANGES/13637.bugfix.rst记录了一次小而关键的防御性编程修复:CookieJar.update_cookies()现在会对用户传入的可变Morsel执行copy(),从根源上切断调用方与 cookie jar 之间的共享可变状态。其实现通过_update_cookies()的copy_morsels开关,对"用户提供的对象"与"内部解析的对象"采取差异化的复制策略,既保证了正确性,又没有给响应头解析这一高频路径增加额外开销。理解这条修复,也就理解了 aiohttp 客户端 cookie 存储中"对象所有权决定复制策略"的核心设计思想。
- 后端
- Web框架
- WebSocket
【免费下载链接】aiohttp
Asynchronous HTTP client/server framework for asyncio and Python
相关推荐
ClickHouse v23.8.15.35-lts 变更解读:9 项用户可见缺陷修复与发布机制改进
ClickHouse v23.8.15.35 lts 变更解读:9 项用户可见缺陷修复与发布机制改进 本文以 ClickHouse 官方发布说明 docs/ch
数据库OLAP列式数据库大数据实时分析数据分析ClickHouse v25.6.8.10-stable 版本变更深度解读:S3 备份重试风暴抑制与对象存储缺陷修复
ClickHouse v25.6.8.10 stable 版本变更深度解读:S3 备份重试风暴抑制与对象存储缺陷修复 本篇基于 ClickHouse 官方 ch
数据库OLAP列式数据库大数据实时分析数据分析Leaflet 1.3.3 缺陷修复发布解析:全局变量 L 暴露问题的成因、修复与构建机制
Leaflet 1.3.3 缺陷修复发布解析:全局变量 L 暴露问题的成因、修复与构建机制 Leaflet 1.3.3 是 2018 年 7 月 18 日发布的
前端数据可视化GIS
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考