TT Lab
开始
学习 学习路径 课程

FastAPI — 类型就是契约

请求失败时也要关闭资源:设计原理

在 TT Lab 中继续学习

一句话总结

把启动、关闭和异常路径分开,并真正运行 FastAPI 的 lifespan。

为什么需要它

测试通过了,但生产环境重启时连接却残留了下来。原因是使用 TestClient 时没有用 context manager,所以启动和关闭的代码没有被执行。只看一次正常响应的测试,无法知道应用在什么时候打开和关闭资源。这里不用外部连接,而是用一个记录事件的小资源来观察生命周期。

工作原理

把打开和关闭分开,再用 contextmanager 的 finally 把它们串起来。重复打开同一个资源是错误,而重复关闭已经关闭的资源是安全的空操作。FastAPI 的 lifespan 使用 asynccontextmanager,在启动时把资源连接到 app.state。在 with TestClient 之内可以读取就绪状态,离开代码块或发生异常时,close 事件必须恰好留下一次。

닫힘 → start → 열림 → 요청 → finally stop → 닫힘
                         └ 오류 ────────┘

阅读契约并预测失败的工作表

下面不是让你把整个实现背下来的答案,而是分步骤的代码评审。每个改动片段都故意破坏了契约。请注意,改动之后,正常用例仍然可能通过。在运行之前,先预测观测哪些输入、异常和状态才能看出差异,实现之后,再把这个预测与结果进行比较。

1. 让资源状态彼此独立

new_resource() 是 {open:False, events:[]} 的新字典,各次调用之间不共享 events。

判断依据:不要通过全局变量或默认参数共享可变的列表。

待评审的错误改动片段:

"open":True

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

2. 拒绝重复启动

start(resource) 在已经打开时是 ValueError,否则把 open 改为 True,并往 events 中追加 'open'。

判断依据:拒绝启动两次而弄丢一个资源的行为。

待评审的错误改动片段:

if False:
        raise

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

3. 让关闭具有幂等性

stop(resource) 只有在打开时才把 open 改为 False,并往 events 中追加 'close'。如果已经关闭,就保持原样。

判断依据:即使多条清理路径重叠,也不应产生重复的 close 事件。

待评审的错误改动片段:

resource["events"].append("closed")

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

4. 阻止使用已关闭的资源

read(resource) 在已关闭时是 RuntimeError,在打开时返回 {ready:True}。

判断依据:就绪状态与对象是否存在是两回事。对象即使存在,也可能是关闭的。

待评审的错误改动片段:

if False:

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

5. 在异常路径上放置 finally

scope(resource) 是一个 contextmanager。进入时调用 start,在代码块内 yield resource,无论代码块成功还是失败,都用 stop 关闭。代码块中的异常要向外传播。

判断依据:如果只在 yield 之后写 close,那么发生异常时就执行不到那一行。

待评审的错误改动片段:

pass

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

6. 把应用生命周期与资源连接起来

lifespan_for(resource) 返回一个 asynccontextmanager 函数 lifespan(app)。在 scope(resource) 之内设置 app.state.resource,并 yield。

判断依据:不是调用 lifespan 函数本身,而是把它传给 FastAPI 的构造函数。

待评审的错误改动片段:

app.state.resource = dict(resource)

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

7. 用真实请求读取就绪状态

create_app(resource) 使用 lifespan_for。GET /ready 返回对 app.state.resource 执行 read 的结果。context 结束时必须关闭资源。

判断依据:必须使用 with TestClient,才会把 lifespan 的启动和关闭都执行。

待评审的错误改动片段:

FastAPI()

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

8. 请求之后的失败也要清理

exercise(resource, fail=False) 在 with TestClient(create_app(resource)) 之内调用 GET /ready。fail=True 时在其中抛出 RuntimeError,否则返回响应 JSON。两种情形下资源都必须被关闭。

判断依据:把正常路径和异常路径放进同一种清理结构,就能减少遗漏的关闭路径。

待评审的错误改动片段:

if False:

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

在现场相遇的样子

教学用的资源字典是一个代替真实 DB 连接池的观测装置。在生产环境中,还要设计部分初始化失败、连接池的并发、取消处理以及关闭的超时时间。检查的不是写进报告里的事件字符串,而是学习者的代码在运行过程中所改变的对象状态。

下一项实验要做什么

八个步骤会连成一个可运行的成果。让资源状态彼此独立 → 拒绝重复启动 → 让关闭具有幂等性 → 阻止使用已关闭的资源 → 在异常路径上放置 finally → 把应用生命周期与资源连接起来 → 用真实请求读取就绪状态 → 请求之后的失败也要清理。

每一步检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,请故意改动边界比较或清理代码,确认哪些测试会失败。请说明前面的测试为什么在下一步中依然保持有效,并写出一条本实验不能保证的生产条件。