笔记 · 架构演进 / KNOWLEDGE-BASE

为了异步 AOP,我们造了一个 IoC 容器

2026-08-26约 17,369 字asyncaop-framework.md

一套 2762 行自研框架,经过 13 轮迭代后的技术复盘

复盘对象:cyt-ioc 封存快照 c5d27ef,Python 3.14,源码 2762 行, 测试 4227 行,共 154 个测试函数。对照对象包括早期实现、Dishka 1.10.1、 aspectlib 2.0.0、FastAPI 生命周期机制和标准库 contextlib.AsyncExitStack

本文讨论的是这套框架在 Buyer 项目中的适用性,不是在证明 IoC、AOP 或某个 开源框架普遍无效。

系列前篇:《PyMapper 的诞生》


开篇:我们最初想解决的就是异步 AOP

这套框架不是从“依赖注入”开始的。最初的问题很直接:能不能像 Spring 一样,在业务方法 外面统一加事务、审计、耗时统计和异常处理,同时完整覆盖异步方法的真实执行过程?

同步调用中,代理进入方法、拿到结果或异常,再退出方法,调用边界是完整的:

代理进入 -> 业务方法执行 -> 返回结果或抛出异常 -> 代理退出

异步调用把这条边界拆开了:

代理进入 -> 方法返回 Mono / Future / coroutine / async generator -> 代理退出
                                                        |
                                                        +-> 稍后才真正执行、失败或完成

如果拦截器只包住“方法调用”,它拿到的只是一个异步载体,不是最终结果。计时会提前结束, 异常捕获看不到后续失败,事务和清理也可能在业务真正执行前结束。从完整覆盖异步任务的角度看, 传统同步型 AOP 在这里失效了。 这是后来尝试统一代理、IoC 和生命周期的起点。

Spring/WebFlux 也面对“先返回异步载体”的问题

这个问题不是 Python 独有。Spring 的常规代理式 AOP 可以完整拦截同步 Java 方法,但 WebFlux 方法通常先返回 MonoFlux,使用 CompletionStage 的接口则先返回 Future。真实 I/O、 异常和完成信号发生在订阅或 Future 完成时,不一定发生在 Java 方法返回之前。

普通 Around Advice 如果只执行下面的代码:

long startedAt = System.nanoTime();
Object result = proceedingJoinPoint.proceed();
long elapsed = System.nanoTime() - startedAt;
return result;

它测到的主要是“组装并返回 Publisher”的耗时,不是 Publisher 从订阅到结束的耗时; try/catch 只能捕获组装阶段异常,捕获不到后续 onError。Future 同理,advice 拿到的是一个 尚未完成的占位对象。

因此,“Spring 异步无法 AOP”更准确的含义是:普通同步 Advice 不会自动理解异步载体的完成 语义。 Spring 并非完全没有解决方案。它针对响应式事务做了专项适配: TransactionInterceptor 检查返回类型,返回 Publisher 或 Kotlin Flow 时使用 ReactiveTransactionManager,并通过 Reactor Context 传播事务上下文。

但这种能力是针对响应式事务的专门实现,不会让任意自定义 Advice 自动覆盖真实异步执行。 计时、重试和审计若要覆盖 Mono/Flux 的完整生命周期,仍需包装 Publisher,在 doOnErrordoFinallytransformDeferred 等信号位置处理;Future 也需要完成回调或 包装后的 Future。

场景普通代理拦截能看到什么完整覆盖所需机制
Java 同步方法方法的完整执行常规 Spring AOP
Mono/Fluxpipeline 组装和 Publisher 返回reactive-aware Advice / operator
CompletionStage/FutureFuture 对象返回completion callback / 包装 Future
Python coroutinecoroutine 对象;async wrapper 可以继续 awaitasync wrapper + await
Python async generator生成器对象返回包装整个异步迭代过程

Spring 给我们的启发不是“框架足够大就能消灭异步边界”,而是:每种异步载体都需要明确的 上下文传播方式和完成信号协议。 通用 AOP 无法只凭方法签名判断一个异步对象何时才算真正完成。

参考:Spring 声明式事务实现Spring AOP 代理机制

Python 现有 AOP 方案也不能透明覆盖所有异步形态

Python coroutine 比 Mono/Flux 直接一些。装饰器如果本身是 async def,并且明确 await func(...),就能包住 coroutine 的返回值、异常和耗时:

@functools.wraps(func)
async def wrapper(*args, **kwargs):
    started_at = time.perf_counter()
    try:
        return await func(*args, **kwargs)
    finally:
        record_elapsed(time.perf_counter() - started_at)

问题在于,通用 AOP 框架不能假设所有目标都是这种形态。同步拦截器调用 async 目标时只会拿到 coroutine;async generator 的真正执行发生在每次迭代中;取消通过 CancelledError 传播; staticmethodclassmethod、描述符、装饰器顺序和 FastAPI 对函数签名的读取也都会影响织入。

我们实际验证过的 aspectlib、早期 pico-ioc 接入和自研拦截链,都在这些边界上出现过问题: 有的 advice 在 coroutine 执行前已经结束,有的捕获不到异步异常,有的改变了函数签名或方法绑定, 有的在重试第二次执行时已经消费完拦截器链。它们可以覆盖限定场景,但不能在不声明目标类型和 执行协议的前提下,透明覆盖 coroutine、Future、generator 和 async generator。

这使问题从“写一个 async 装饰器”变成了:谁发现被增强对象,谁创建拦截器,谁保证包装顺序, 谁在启动期校验不兼容的组合,谁管理这些对象的创建和销毁。IoC 正是在这里被引入。

能不能把 .py 当成 Java class,直接对模块做 IoC

引入 IoC 后,我们很快又遇到第二个问题:Python 项目存在大量模块级 Router、Service 函数, 如果为了代理入口强制把它们全部改成 class,代码会增加很多 self、注册和生命周期样板。 于是产生了一个更贴近 Python 的设想:既然 .py 才是业务代码常用的组织单元,能不能直接 把模块作为装配边界?

这个类比只能成立一半。Java class 是类型,Spring bean 通常是该类型的实例;Python .py 导入后得到的是 module 对象,它更接近“进程内单例命名空间和装配单元”,不完全等价于 class 或 bean。sys.modules 会缓存 module,但不会替它管理请求作用域、连接释放和停机顺序。

一套 module-centric IoC 可以这样工作:

# orders_service.py
orders_mapper: OrdersMapper = inject(OrdersMapper)
audit_logger: AuditLogger = inject(AuditLogger)

@intercepted_by(AuditInterceptor)
async def approve_order(order_id: int) -> None:
    await orders_mapper.approve(order_id=order_id)
扫描目标 package
  -> import 模块
  -> 查找 module.__dict__ 中的 inject(...) 标记
  -> 解析依赖并回填模块属性
  -> 在应用启动前校验未解析项

它能减少为了注入而增加的 class,也能保留 orders_mapper.approve() 这种来源明确的调用方式。 但它仍有无法绕开的边界:

  1. import 顺序和循环 import 会变成框架协议;
  2. from module import func 复制出去的旧引用,不会随之后的 setattr 或代理替换更新;
  3. module 是进程级 singleton,请求级状态仍需 FastAPI dependency 或 ContextVar
  4. 多 worker 会分别装配,不能把 module 当成跨进程单例;
  5. reload、测试隔离和模块副作用更难控制;
  6. 模块保存资源引用后,仍要回答谁启动、谁关闭、失败后如何回滚;
  7. 启动后替换函数不能保证拦截已被其他模块缓存的引用,定义期装饰器仍更可靠。

因此,模块级 IoC 可以作为一种受限的装配方案,却不能消除异步 AOP 和资源生命周期问题。 更稳妥的边界是:

.py module                         = 业务函数和装配边界
provider / factory                 = 对象创建边界
FastAPI lifespan + AsyncExitStack  = singleton 资源生命周期
FastAPI dependency / ContextVar    = request scope
显式函数装饰器                     = 方法增强

真正把项目推向完整 IoC 的,不是“自动注入”本身,而是希望所有被增强对象和拦截器都能在启动期 统一发现、创建和校验。一旦容器取得对象创建权,就必须继续承担作用域、并发初始化、取消传播、 销毁顺序和停机预算。后面的 13 轮迭代,实际上都从这个选择开始。


先说结论

最初的需求不是“造一个 IoC 容器”,而是让异步 Python 业务代码拥有类似 Spring 的 声明式事务:业务方法只写 @transactional(),连接获取、事务传播、提交和回滚由框架处理。 后续还希望用同一种机制承载审计、耗时统计和重试。

当时采用的推导路径是:

声明式事务
  -> 需要方法拦截
  -> 需要通用 AOP
  -> 拦截器也需要注入依赖
  -> 需要 IoC 容器统一创建 bean
  -> 容器必须管理 singleton/context/prototype
  -> 必须处理异步创建、取消、销毁和停机

这条链上的每一步单独看都合理,合在一起却把一个局部需求扩成了一套应用运行时。 最终,真正进入 Buyer 业务主线的不是通用 AOP 和 IoC,而是 cyt-pymapper 中更贴近 数据库资源的实现:ContextVar 保存当前连接,@transactional() 管理事务边界, Mapper 自动复用事务连接。

这次工作的结论可以概括为三点:

  1. 声明式事务需要的是明确的资源边界,不必先拥有一套通用 AOP。
  2. Python 不是不能做 IoC,而是自研容器只有在确实需要复杂装配和 scope 时才划算。
  3. 异步生命周期的真正难点是资源所有权、取消传播和停机预算,不是依赖注入语法。

cyt-ioc 没有作为 Buyer 的生产依赖继续推广,但它把这些边界用代码和测试验证了一遍。 这部分经验比容器本身更值得保留。


一、我们最初到底想解决什么

Buyer 后端是 FastAPI 异步项目,SQL 层使用 XML Mapper。希望业务代码写成下面这样:

@transactional(propagation="REQUIRED")
async def approve_order(order_id: int) -> None:
    order = await orders_mapper.lock_order(order_id=order_id)
    await orders_mapper.update_status(order_id=order_id, status="approved")

业务方法不显式接收 sessionconnection,也不重复编写:

async with pool.acquire() as connection:
    async with connection.transaction():
        ...

这项需求包含四个可以独立描述的目标:

目标期望行为
隐式连接Mapper 自动取得当前事务连接
事务边界方法正常返回时提交,抛异常时回滚
传播行为REQUIRED 复用当前事务,REQUIRES_NEW 临时使用独立事务
横切扩展审计、计时、重试可以用统一方式挂到方法上

前三项直接属于数据库访问层。第四项才是通用 AOP 需求。早期设计把四项一起交给 IoC + AOP 框架,导致“事务怎么开”与“应用中所有对象怎么创建、怎么销毁”变成了同一个问题。

先区分几个容易混在一起的概念

概念解决的问题不负责什么
DI(依赖注入)一个对象需要的依赖从哪里来不天然负责对象销毁
IoC 容器统一登记、创建、选择和注入对象不等于 AOP
AOP在方法调用前后统一插入事务、审计等逻辑不一定需要 IoC
Scope同一个对象在多大范围内复用不等于数据库事务
生命周期对象何时创建、何时释放、失败后如何补偿不等于依赖解析
织入把拦截逻辑接到目标调用上的实现手段只是 AOP 的一种实现

Spring 把这些能力组合得很完整,容易让人产生一个错觉:既然最终编码体验像 Spring, 底层也应该沿着 Spring 的实现路径重建。实际并非如此。Spring 的方案建立在统一的 BeanFactory、代理调用边界和成熟的生命周期体系上;Python/FastAPI 的默认代码形态、 对象创建方式和异步取消语义都不同。


二、为什么异步 AOP 比预期复杂

早期 aop.py 有 466 行,包含 JoinPointPointcutAdviceProxyFactory 和多种 通知类型,接近一套 Spring AOP 的 Python 翻译。到封存版时,aop.py 缩到 120 行, 只保留显式的异步环绕拦截。

代码减少不是因为所有问题都被优雅解决了,而是因为适用范围被主动收窄了。

1. Python 中“函数”至少有四种执行语义

目标类型调用时发生什么真正执行发生在何时
普通函数直接返回结果调用时
协程函数返回 coroutine 对象await
生成器函数返回 generator 对象迭代时
异步生成器函数返回 async generator 对象async for / anext()

这四种目标不能共用一个简单 wrapper。

例如,异步生成器的函数调用本身不会执行完整业务:

async def stream_rows():
    async for row in source:
        yield row

如果拦截器只包住 stream_rows() 的调用,它会在返回异步生成器对象后立即退出;真正的 耗时和异常发生在后续迭代阶段。若 wrapper 错把异步生成器当 coroutine 去 await,则会 直接产生类型错误。

本地对 aspectlib 2.0.0 的源码和行为测试也暴露了这一类问题:async generator 被分支 识别后,后续路径仍按 coroutine/generator 处理。这个例子并不是为了否定 aspectlib, 而是说明“透明支持所有可调用对象”本身就是一项独立而复杂的框架能力。

封存版 cyt-ioc 最终选择在装饰期明确拒绝同步方法和异步生成器,只允许 async 方法。 这让行为可预测,但也意味着它不再是一套通用 Python AOP。

2. 同步拦截器拿到的可能只是一个未执行的 coroutine

假设拦截器是同步方法:

def around(call_next):
    started_at = perf_counter()
    result = call_next()
    elapsed = perf_counter() - started_at
    return result

当目标是 async def 时,call_next() 只创建 coroutine,没有真正执行业务。因此:

  • elapsed 接近 0;
  • 目标异常不会在这段 try/except 中出现;
  • 如果上层忘记 await,业务甚至不会执行;
  • 整个过程可能没有任何立即报错。

这类“看起来执行了、实际没有执行”的静默错误比显式异常更危险。因此封存版要求 拦截器统一实现:

async def around(self, ctx, proceed):
    return await proceed()

并在启动前验证签名和异步属性。

3. proceed() 不是普通回调,重试会暴露链式实现错误

早期实现用 pop(0) 消费拦截器列表:

current = interceptors.pop(0)
return await current.around(ctx, proceed)

第一次 proceed() 正常,第二次重试时列表已经被破坏。外层 Retry 拦截器再次调用 proceed(),可能绕过内层事务拦截器,导致第二次业务执行跑到事务外。

最终实现改成基于索引创建新的调用闭包:

def proceed_at(index: int):
    async def proceed():
        if index == len(chain):
            return await target(*args, **kwargs)
        return await chain[index].around(ctx, proceed_at(index + 1))

    return proceed

这样每次调用 proceed() 都会重新经过完整的剩余链。这个细节说明,重试、事务和缓存 一旦允许任意组合,拦截链本身就需要有严格的调用协议。

4. 取消不是普通异常

异步请求超时、服务停机和调用方主动取消,都会通过任务取消传播。拦截器如果宽泛捕获 异常并返回默认值,可能把取消吞掉:

try:
    return await proceed()
except BaseException:
    return fallback

结果是调用方已经执行 task.cancel(),任务却以“正常结果”结束。上游会误以为业务成功, 底层任务还可能继续占用连接或锁。

框架需要明确区分:

  • 业务异常是否允许转换;
  • CancelledError 必须如何继续传播;
  • KeyboardInterruptSystemExit 是否应进入业务拦截器;
  • finally 中的清理是否允许独立完成。

这不是多写一个 except 能解决的问题,而是拦截器协议必须定义的语义。

5. 包装函数必须保留签名

FastAPI 通过 inspect.signature() 识别路径参数、查询参数、请求体和 Depends。如果织入后 函数只剩 (*args, **kwargs),路由参数解析就会失效。

可靠的 wrapper 至少要用 functools.wraps() 保留 __wrapped__,并验证第三方装饰器 叠放后是否仍能沿包装链找到原始签名。只复制 __name____doc__ 不够。

6. 描述符和装饰器顺序会扩大测试面

类中的可调用成员不只有普通实例方法,还包括:

  • staticmethod
  • classmethod
  • property / cached_property
  • 被其他装饰器包装的方法;
  • 继承自父类的方法。

如果框架在 dir(instance) 后直接 getattr(),描述符可能已经绑定或执行;如果在装饰器 阶段读取标记,第三方 functools.wraps() 又可能复制标记。最终会出现参数整体右移、 重复织入、漏织入或错误启动校验。

封存版通过限制目标类型、登记自家 wrapper、启动期统一校验来缩小问题面。它能工作, 但代价是使用者必须遵守一套新的方法模型。


三、class-based AOP 不是错误,但不适合当前 Buyer

“Python 不能基于 class 做 AOP”不准确。真正的问题是:代理式 AOP 需要稳定的受管对象 边界,而 Buyer 当时没有这个前提。

FastAPI 常见写法是模块级路由函数:

@router.post("/orders/{order_id}/approve")
async def approve_order(order_id: int):
    ...

Service 也可能是模块函数,Mapper 则由 @amapper() 直接绑定 XML。强行要求这些代码先 全部改成容器管理的类,只是为了让 AOP 有代理入口,会同时增加类、注册、扫描和生命周期 成本。

还要注意,经典代理 AOP 通常只拦截“经过代理对象”的调用。目标对象内部的直接调用 是否被拦截,取决于具体织入方式,不能简单声称一定支持。cyt-ioc 采用类定义期替换方法, 与 Spring 常见的外部代理也不是同一种实现。

对 Buyer 当前横切需求,更直接的归属是:

需求更合适的实现位置
数据库事务cyt-pymapper.@transactional
请求日志、请求耗时、request IDFastAPI middleware
登录和权限FastAPI dependency / 明确的权限装饰器
ERP/Gmail HTTP 重试对应 integration client 或专用重试库
业务审计Service 中明确调用,必要时使用专用装饰器
应用启动和停机FastAPI lifespan + AsyncExitStack

这里的判断标准不是“装饰器比 AOP 高级”,而是让能力归属于最了解资源语义的模块。 数据库层知道事务传播,HTTP client 知道哪些错误可重试,Web 层知道一次请求何时开始和结束。


四、声明式事务最终是怎样落地的

cyt-pymapper 的实现没有依赖通用 IoC。核心由四部分组成:

  1. ContextVar 保存当前 asyncpg connection;
  2. 普通 Mapper 调用在没有事务时借用一条连接,执行完立即归还;
  3. transaction_scope() 负责连接、事务、提交/回滚和上下文恢复;
  4. @transactional() 只负责把业务方法包进该 scope。

简化后的逻辑是:

_ACTIVE_CONNECTION = ContextVar("mapper_active_connection", default=None)

@asynccontextmanager
async def transaction_scope(*, requires_new: bool = False):
    current = _ACTIVE_CONNECTION.get()
    if current is not None and not requires_new:
        yield current
        return

    async with acquire_raw_connection() as connection:
        token = _ACTIVE_CONNECTION.set(connection)
        try:
            async with connection.transaction():
                yield connection
        finally:
            _ACTIVE_CONNECTION.reset(token)

@transactional() 再用 functools.wraps() 包装业务方法:

def transactional(*, propagation: str = "REQUIRED"):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            async with transaction_scope(
                requires_new=propagation == "REQUIRES_NEW"
            ):
                return await func(*args, **kwargs)

        return wrapper

    return decorator

这并不是“只用一个 ContextVar 就实现事务”。真正不可缺少的还有:

  • 连接池借用和归还;
  • asyncpg transaction 的提交与回滚;
  • REQUIRED 的复用规则;
  • REQUIRES_NEW 对外层连接的暂存和恢复;
  • 隔离级别、只读选项冲突校验;
  • finally 中可靠地 reset token。

它之所以仍比通用 AOP 简单,是因为问题边界固定:只处理 async 函数,只处理数据库连接, 只实现项目实际使用的两种传播行为。

事务装饰器也有明确边界

ContextVar 会被复制到 asyncio.create_task() 创建的子任务。若事务内用 gather() 让多个 子任务同时操作同一 asyncpg connection,可能触发并发使用错误。因此规约是:

  • 同一事务中的 SQL 按顺序执行;
  • 大批量更新分批提交,不把十万行处理塞进一个长事务;
  • 外部 HTTP 调用通常放在数据库事务外;
  • 必须夹在 A、B 两段数据库动作之间的 HTTP 调用,要用状态机、幂等和补偿设计,不假设 PostgreSQL 事务能回滚已经成功的远程请求。

这些约束写在数据库包里,比放进一个通用拦截器更容易被理解和测试。


五、IoC 真正困难的部分不是注入,而是生命周期

构造函数注入本身不复杂。给定类型注解,找到实现并调用构造函数,几十到几百行就能完成。 框架复杂度真正增长,是从“容器创建对象”扩展到“容器对对象的一生负责”之后。

需要回答的问题包括:

  • 两个请求同时首次获取同一个 bean,构造一次还是两次?
  • 构造进行中,其中一个等待者被取消,构造任务归谁?
  • @configure 失败,已经创建的连接池由谁关闭?
  • scope 还在使用 session 时,容器能否先销毁底层 engine?
  • 一个 cleanup 卡死,Cloud Run 停机是否永久等待?
  • 多个 cleanup 抛错,如何继续释放其他资源并保留全部错误?
  • 启动尚未完成就收到停机信号,启动任务和关闭任务如何交接?

这已经不是 DI,而是一套并发资源管理系统。

1. 最终收敛出的三个模型

13 轮修复后,框架把职责收敛到三层:

模型负责什么关键状态
BeanDefinition启动期编译注册信息、依赖和生命周期钩子静态元数据
BeanLifecycleRecord一个 bean 的构造结果、异常和并发等待building / ready / failed
ScopeContext一组实例的所有权、缓存和释放open / closing / closed

容器本身还有完整状态机:

new -> starting -> started -> draining -> closing -> closed

其中 draining 不是为了好看。它表达的是:不再接受新 scope,但要先等已经存在的 scope 退出,再释放被这些 scope 依赖的 singleton。缺少这个阶段,就可能在 Session 尚未归还时 先关闭 Engine。

2. 并发首次创建需要共享结果,而不只是加锁

最初用一把全局 RLock 包住创建过程,虽然避免了重复构造,却把所有 bean 创建串行化, 还让用户的 __init__@configure 和代理创建都在锁内执行。慢 bean 会阻塞无关 bean, 生命周期回调如果反向进入容器,还可能形成死锁。

更合适的模型是:

  1. 第一个调用者登记一条 building 记录和共享 Future;
  2. 构造逻辑在全局锁外执行;
  3. 后续调用者等待同一个 Future;
  4. 成功时所有等待者拿到同一实例;
  5. 失败时所有等待者看到同一个异常,不静默重建。

这里 Future 不只是性能优化,它还是“本轮构造结果”的所有权凭证。

3. ContextVar 能传播上下文,不能自动解决共享构造

请求 scope 早期只把缓存字典放进 ContextVar。在 asyncio.gather() 中,子任务复制父任务 上下文,但子任务对 ContextVar 的重新赋值不会回传父任务。于是三个子任务可能各建一份实例。

把 ContextVar 的值换成共享可变字典,只解决“看见同一份缓存”,仍有:

检查缓存为空 -> await 构造 -> 写入缓存

多个任务可以同时通过第一次检查。因此还需要共享 Future 或 per-key 创建锁来消除 check-then-await 竞态。

4. 等待者取消,不应等于取消公共构造

如果构造 task 归第一个请求所有,第一个请求超时会把构造一起取消,其他等待者被连带影响。 反过来,如果构造完全脱离所有者,scope 关闭后它可能晚到并产出一个无人清理的实例。

最终语义是:

  • 构造 task 归 scope,而不是归某个业务请求;
  • 业务请求取消只停止自己的等待;
  • scope 关闭时先给在飞构造一个宽限期;
  • 超时后由 scope 发起取消并继续观察晚到结果;
  • 若晚到构造最终成功,必须立即补偿清理。

难点不是 asyncio.create_task(),而是每个 task 和每个产物都必须有明确主人。

5. 启动和停机是同一个所有权问题

后几轮反复出现“修了 scope,漏了 container;修了正常关闭,漏了启动中关闭”的问题。 根因是创建和销毁曾有多条实现路径:singleton、context、prototype、factory product 分别处理, 同步关闭和异步关闭也各走一套逻辑。

统一后的要求是:

  • 启动任务由 container 持有;
  • 调用 astart() 的协程被取消,只中断调用方等待,不让启动任务变成无主任务;
  • aclose() 有权中止启动,并等待或回收已经创建的资源;
  • 启动失败按就绪顺序的反序回滚;
  • 已关闭容器不能被晚到的启动结果重新发布为 started。

这组问题在普通 CRUD 业务中不常被主动触发,但 Cloud Run 收到 SIGTERM、数据库连接缓慢、 应用启动期间健康检查失败时都可能发生。它们不是纯理论问题,只是发生频率低、复现成本高。

6. 清理需要顺序、隔离和时间预算

资源释放至少有三个独立维度:

维度需要保证什么
顺序按创建完成顺序的反序释放,先关依赖方,再关被依赖资源
异常一个 cleanup 失败,不跳过其他 cleanup,并保留完整 traceback
时间单步和整体都必须有预算,避免停机无限等待

AsyncExitStack 很适合承担登记和 LIFO 调度,也会在关闭后清空回调,天然支持重复 aclose()。 但它不直接提供项目需要的结构化 CleanupReport、单步超时和总停机预算。因此最终设计让:

  • AsyncExitStack 只负责编排顺序;
  • 每个清理回调内部捕获并上报自己的失败;
  • asyncio.timeout() 控制单步预算;
  • 绝对 deadline 控制整个 scope/container 的剩余预算。

为什么既要单步预算又要总预算?假设有 20 个资源,每个资源允许关闭 5 秒。如果只有单步 预算,最坏停机时间是 100 秒,Cloud Run 早已强制结束进程。总预算确保资源数量增加时, 应用仍能在平台宽限期内返回。

同步 cleanup 不能靠 asyncio.timeout() 可靠中断,因为它如果阻塞事件循环,超时任务本身 也得不到执行。因此同步清理必须足够短,阻塞型释放应改成异步接口或明确转入线程执行。


六、为什么 13 轮修复迟迟没有收敛

这些缺陷并不是毫无关联的极端案例。它们主要来自三个结构性问题。

1. 同一生命周期语义存在多份实现

创建曾分散在 singleton、context、prototype、factory product 以及同步/异步路径中; 销毁也分别存在 container close、scope close、启动失败回滚和晚到任务补偿。

审查发现一条路径的问题时,修复很容易只覆盖当前分支。下一轮出现的“新问题”往往是同一 语义在另一条路径中的镜像。重复打补丁的根因不是某个 if 写错,而是没有唯一的状态转换入口。

2. 只处理了一个维度

典型例子是“异常隔离”完成后,仍可能漏掉“时间隔离”:cleanup 即使不会因抛错中断,也可能 永远不返回。又例如 Future 解决了并发去重,却没有自动解决 Future 归谁、何时取消、晚到产物 谁清理。

每个机制至少要从以下维度检查:

正常返回 / 抛异常 / 被取消 / 永不返回
单调用 / 并发调用
运行中 / 启动中 / 停机中
同步入口 / 异步入口

3. 测试由 bug 驱动,而不是由状态空间驱动

早期测试主要是“出现一个 bug,加一个回归”。这种方式能防止旧问题复发,但不能证明模型 完整。更适合生命周期框架的是规格矩阵:

scope 类型 × 当前状态 × 事件 × 预期状态 × 资源归属 × 可观察结果

例如:

Scope当前状态事件预期
contextopen两个 task 同时 aget构造一次,共享实例或异常
contextclosing新 aget立即拒绝,不创建 task
containerstartingaclose中止启动,回滚已就绪资源
containerdraining新建 scope立即拒绝
singletoncleanup 超时后续 cleanup继续执行并记录 TimeoutError

当测试按状态空间组织时,代码结构是否缺少统一入口会很快暴露,不必等到第十三轮。


七、Python 原生能力能解决什么,不能解决什么

原稿中“Python 都自带答案”的说法过于简单。更准确的结论是:标准库和框架已经覆盖了 不少基础机制,但它们各有明确边界。

能力可以解决不能直接解决
模块 import 缓存进程内模块初始化一次多实现选择、请求 scope、显式销毁、测试 override
Protocol静态描述依赖契约创建实现、选择实现、管理生命周期
ContextVar沿异步调用链传递上下文子任务并发共享连接、构造去重、资源销毁
AsyncExitStack动态登记资源、LIFO 退出、集中关闭项目级错误报告、单步/总时间预算
asyncio.timeout给当前异步步骤设置超时中断阻塞事件循环的同步代码
FastAPI lifespan应用启动和停机边界自动完成所有 bean 的依赖解析
FastAPI yield dependency请求级资源申请与释放跨请求 singleton 图和通用 AOP

这张表的意义不是“不要容器”,而是先用最小机制解决明确问题。只有剩余需求确实包括 多实现绑定、作用域、测试替换、启动期依赖校验和资源释放时,DI 容器才开始体现价值。

从现有框架借鉴什么

Dishka 的主要启发是 provider + scope + generator finalization:创建和清理写在同一个 provider 中,登记顺序天然接近资源获得顺序。它说明 scope 可以是核心概念,不需要先造 一套 Java 风格的 stereotype 和 pointcut。

dependency-injectorResource/provider 思路同样强调显式工厂和资源边界。容器最适合 管理“如何创建”的工厂,而不是扫描并接管项目里所有 class 或模块函数。

FastAPI 已经提供应用 lifespan 和请求依赖。Web 层资源优先沿这些原生边界管理,通常比 再建立一套平行 request scope 更容易与框架协作。

aspectlib 展示了通用 weaving 的价值,也展示了兼容各种 callable、签名、描述符和异常 语义需要多大维护面。对第一方业务代码,如果可以显式加装饰器,就没有必要默认选择织入。

借鉴这些机制不等于必须同时引入多个包。更合理的方式是先确定自己的最小语义,再决定: 直接使用成熟框架、包装其中一项能力,还是用标准库实现一个小而专用的组件。


八、最终保留了什么,放弃了什么

已进入业务主线

  • cyt-pymapper 的 XML SQL、参数绑定和结果映射;
  • asyncpg 连接池;
  • Mapper 隐式连接;
  • @transactional()REQUIRED / REQUIRES_NEW
  • 启动时 Mapper/XML 契约校验;
  • FastAPI lifespan 管理连接池启动和关闭。

这些能力与数据库访问强相关,放在同一个包里边界清晰、测试目标明确。

封存但值得保留的设计成果

  • BeanDefinition 的启动期编译和 fail-fast 思路;
  • 用共享 Future 表示并发构造结果;
  • scope 是资源所有者,而不是调用方 task;
  • AsyncExitStack + CleanupReport + deadline 的清理账本;
  • 用显式状态机和 FrameworkInvariantError 保护框架内部约束;
  • 启动中关闭、取消、晚到结果等生命周期测试矩阵。

暂不进入 Buyer 生产

  • 通用 class-based AOP;
  • 为了 AOP 强制所有业务代码 class 化;
  • 自研 singleton/context/prototype 全套容器;
  • 运行期包扫描和 stereotype 体系;
  • FastAPI lifespan、dependency 平行的第二套生命周期。

封存不是认定这些功能永远无用,而是当前 Buyer 的收益不足以覆盖维护成本。以后如果出现 大量可替换实现、复杂 request scope、插件化 provider 或跨项目统一装配需求,应先重新评估 Dishka、dependency-injector 等成熟方案,再决定是否恢复自研代码。


九、这次最重要的工程经验

1. 从业务边界出发,不从框架名称出发

需求是事务,就先定义连接归属、提交回滚和传播行为;需求是审计,就先定义何时写、失败是否 影响主业务。不要因为编码体验像 Spring,就默认需要复制 Spring 的整套底层结构。

2. 横切逻辑优先放到最了解它的层

  • HTTP 级逻辑放 middleware/dependency;
  • SQL 事务放数据库访问包;
  • ERP 重试放 integration client;
  • 业务审计放 Service 或事件/outbox;
  • 应用资源放 lifespan。

通用 AOP 只有在确实需要批量匹配、动态组合、且调用入口统一受管时才有明显优势。

3. 一旦管理资源,就必须先定义所有权

每个连接、client、构造 task 和 cleanup task 都要能回答:

谁创建?
谁可以取消?
谁负责等待?
谁负责销毁?
正常、异常、超时、停机时是否仍是同一个答案?

答不清所有权时,继续加锁、shield 或 timeout 通常只会把问题移动到另一条路径。

4. 同类缺陷第二次出现,就审查结构

如果同一语义在 container、scope、sync close、async close 中分别修一次,说明需要的是统一 状态机和唯一入口,不是第四个补丁。

5. 测试最终语义,不锁死中间实现

生命周期测试应断言:实例只创建一次、异常被所有等待者共享、资源最终释放、停机在预算内 返回。过度断言内部 list 长度或某个私有状态,会让重构困难,却不能证明业务结果正确。

6. 框架代码必须 fail-fast,也必须可观测

内部不变量不能用 assert,因为 python -O 会移除断言。配置错误、重复注册、错误访问路径 应该抛语义明确的异常;cleanup 失败需要保留 traceback、资源名、阶段和剩余预算。


十、以后再遇到类似需求,按这张清单判断

  1. 需求是事务、日志、权限、重试,还是对象装配?先拆开,不要统称 AOP。
  2. 横切逻辑能否用一个显式装饰器、middleware 或 dependency 完成?
  3. 项目中真正需要容器创建和销毁的长期资源有多少个?
  4. 是否真的存在多实现选择、scope、override 和启动期依赖校验?
  5. 目标调用是否都经过受管对象?如果不是,代理式 AOP 会有盲区。
  6. 是否必须支持同步函数、协程、生成器和异步生成器?
  7. 取消、超时、启动失败和停机时,资源所有权是否仍然明确?
  8. 标准库、FastAPI 或成熟 DI 框架已经覆盖了哪些部分?
  9. 同类 bug 是否已经第二次出现?如果是,先停下来调整模型。
  10. 最终方案能否由业务团队理解、调试和长期维护?

这次最大的教训不是“不要自研框架”,而是:框架必须收敛问题,而不是把一个局部需求扩成 新的运行时。 当 13 轮修复主要围绕框架自身的生命周期,而不是 Buyer 的业务能力时, 就应该重新审视边界。


附:数据口径

以下数字基于桌面封存快照 cyt-ioc-封存-c5d27ef,按 UTF-8 物理行统计:

项目结果
封存版源码2762 行 / 10 个 Python 文件
封存版测试4227 行 / 27 个 Python 文件
测试函数154 个
早期版核心源码1826 行
aop.py466 行 -> 120 行
新增 scopes.py + lifecycle.py488 + 249 = 737 行
迭代轮次13 轮

数字只用于说明复杂度如何迁移:AOP 文件缩小了,生命周期代码和测试成为主体。它们不能单独 证明设计好坏,真正的判断依据仍是业务收益、故障面和维护成本。