Python的contextlib与 FastAPI 中的上下文管理

32 阅读12分钟

contextlib 是 Python 标准库里一组围绕上下文管理器设计的工具。它解决的核心问题很朴素:某项资源在使用前需要准备,使用结束后必须清理,而且无论中途是否抛出异常,清理动作都不能漏掉。

数据库连接、文件句柄、锁、网络会话、临时目录,以及 FastAPI 应用启动时加载的模型,都属于这种有明确生命周期的资源。contextlib 的价值,就是把这些成对出现的初始化与清理动作放在同一个结构中,减少资源泄漏和异常路径遗漏。


一、contextlib 究竟是什么

Python 的 with 语句依赖一种名为上下文管理器的协议。最常见的例子是文件操作。

with open("data.txt", "r", encoding="utf-8") as file:
    content = file.read()

进入 with 代码块时,文件被打开;离开代码块时,文件会被关闭。即使读取过程中发生异常,关闭动作仍会执行。

上下文管理协议

同步上下文管理器包含两个特殊方法。

class ManagedResource:
    def __enter__(self):
        print("获取资源")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("释放资源")

使用方式如下。

with ManagedResource() as resource:
    print("使用资源")

它的执行过程可以理解为:

flowchart TD
    A[进入 with] --> B[调用 __enter__]
    B --> C[执行代码块]
    C --> D{是否发生异常}
    D -->|没有| E[调用 __exit__]
    D -->|发生| F[将异常信息传给 __exit__]
    F --> G{异常是否被抑制}
    G -->|是| H[继续运行]
    G -->|否| I[向外抛出异常]
    E --> H

__exit__ 会收到异常类型、异常对象和追踪信息。它返回真值时,异常会被视为已经处理;返回假值或 None 时,异常继续向外传播。

异步场景采用对应的协议:

  • __aenter__
  • __aexit__
  • async with

PEP 343 定义了 with 语句及其上下文管理机制,而 contextlib 在这套协议之上提供了更便捷、更易组合的工具。


二、contextlib 涉及的核心概念

理解 contextlib,关键是抓住三个词:生命周期、异常安全和组合管理。

1.资源生命周期

很多资源都有三个阶段:

  • 创建或获取资源;
  • 使用资源;
  • 释放、关闭或回滚资源。

如果手写 try...finally,通常会是这样。

resource = acquire_resource()

try:
    use_resource(resource)
finally:
    release_resource(resource)

上下文管理器把同样的逻辑收进 with 中。

with managed_resource() as resource:
    use_resource(resource)

两者在控制流程上很接近,但上下文管理器更适合复用,也更容易嵌套和组合。

2.异常安全

清理代码不能只放在正常路径上。例如下面的写法存在风险。

connection = open_connection()
run_query(connection)
connection.close()

一旦 run_query() 抛出异常,close() 就不会运行。

上下文管理器在语义上接近 try...finally,可以保证离开作用域时执行清理逻辑。这里的离开既包括正常结束,也包括异常、return、break 等控制流程。

3.生成器式上下文管理器

手写 __enter__ 和 __exit__ 有时略显笨重。contextlib.contextmanager 可以把一个生成器函数转换成同步上下文管理器。

from contextlib import contextmanager


@contextmanager
def managed_connection():
    connection = open_connection()

    try:
        yield connection
    finally:
        connection.close()

调用时仍然使用普通的 with。

with managed_connection() as connection:
    run_query(connection)

这里的 yield 是整个结构的分界线:

  • yield 之前负责初始化;
  • yield 返回的值交给 as 后面的变量;
  • yield 之后负责清理;
  • yield 在一次上下文执行中只能出现一次。

如果 with 内部抛出异常,该异常会在 yield 所在位置重新出现。因此,生成器内部既可以通过 finally 清理资源,也可以捕获并处理特定异常。

4.异步上下文管理器

数据库连接池、异步 HTTP 客户端等资源,初始化和关闭过程本身可能需要 await。此时可以使用 asynccontextmanager。

from contextlib import asynccontextmanager


@asynccontextmanager
async def managed_client():
    client = await create_client()

    try:
        yield client
    finally:
        await client.close()

调用方式是:

async with managed_client() as client:
    await client.send_request()

FastAPI 是异步 Web 框架,因此 asynccontextmanager 在应用生命周期管理中十分常见。


三、contextlib 中常用的工具

contextlib 不只有两个装饰器。它还提供了一组适合不同资源管理场景的实用工具。

工具主要用途典型场景
contextmanager用生成器创建同步上下文管理器文件、事务、锁
asynccontextmanager用异步生成器创建异步上下文管理器数据库连接池、HTTP 客户端
closing离开作用域时调用对象的 close()不支持上下文协议的第三方对象
aclosing离开异步作用域时调用 aclose()异步生成器、异步流
suppress有选择地忽略异常删除可能不存在的文件
nullcontext提供一个什么也不做的上下文可选的上下文管理
redirect_stdout临时重定向标准输出捕获命令输出、测试
ExitStack动态管理多个同步上下文资源数量运行时才能确定
AsyncExitStack混合管理异步上下文和清理回调多个异步客户端或连接

suppress 有选择地忽略异常

from contextlib import suppress
from pathlib import Path


with suppress(FileNotFoundError):
    Path("cache.tmp").unlink()

这相当于只捕获并忽略 FileNotFoundError。不应写成过于宽泛的异常抑制,否则真正的程序错误也可能悄悄溜走。

nullcontext 处理可选资源

假设调用者有时传入已打开的文件,有时传入文件路径,可以用 nullcontext 统一控制流程。

from contextlib import nullcontext


def read_data(file_object=None):
    context = (
        nullcontext(file_object)
        if file_object is not None
        else open("data.txt", "r", encoding="utf-8")
    )

    with context as file:
        return file.read()

ExitStack 动态组合资源

普通 with 更适合资源数量固定的情况。

with open("a.txt") as file_a, open("b.txt") as file_b:
    ...

如果文件数量在运行时才知道,ExitStack 更自然。

from contextlib import ExitStack


paths = ["a.txt", "b.txt", "c.txt"]

with ExitStack() as stack:
    files = [
        stack.enter_context(open(path, "r", encoding="utf-8"))
        for path in paths
    ]

    contents = [file.read() for file in files]

退出 ExitStack 时,已经成功进入的上下文会按照后进先出的顺序关闭。即使打开第三个文件时发生异常,前两个文件仍能得到妥善关闭。

AsyncExitStack 是它的异步版本,适合 FastAPI 中需要动态组合多个异步资源的场景。


四、FastAPI 中最重要的用途——应用生命周期

FastAPI 服务往往需要在开始接收请求前完成初始化,在停止服务时释放资源。例如:

  • 建立数据库连接池;
  • 创建 Redis 客户端;
  • 创建共享的异步 HTTP 客户端;
  • 加载机器学习模型;
  • 启动后台任务;
  • 在关闭时刷新缓冲区并断开连接。

FastAPI 推荐通过 lifespan 参数接收一个异步上下文管理器。yield 之前是启动阶段,yield 之后是关闭阶段。

基本示例

from contextlib import asynccontextmanager

from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    print("应用启动,初始化资源")

    app.state.model = load_model()

    try:
        yield
    finally:
        print("应用关闭,释放资源")
        app.state.model = None


app = FastAPI(lifespan=lifespan)

整体流程如下。

flowchart TD
    A[进程启动] --> B[进入 lifespan]
    B --> C[执行 yield 之前的初始化]
    C --> D[FastAPI 开始接收请求]
    D --> E[持续处理请求]
    E --> F[服务器准备关闭]
    F --> G[执行 yield 之后的清理]
    G --> H[退出进程]

yield 之前的代码执行完成后,FastAPI 才开始处理请求。服务器收到关闭信号后,会继续执行 yield 后面的代码。

管理数据库连接池

下面用抽象接口展示结构,具体方法会随数据库驱动变化。

from contextlib import asynccontextmanager

from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    pool = await create_database_pool()
    app.state.db_pool = pool

    try:
        yield
    finally:
        await pool.close()


app = FastAPI(lifespan=lifespan)

业务代码可以从 request.app.state 读取连接池。

from fastapi import Request


@app.get("/users")
async def list_users(request: Request):
    pool = request.app.state.db_pool

    async with pool.acquire() as connection:
        return await connection.fetch("SELECT * FROM users")

这里存在两层生命周期:

  • 数据库连接池与整个 FastAPI 应用同生共死;
  • 单个数据库连接只服务于当前查询或请求。

将不同生命周期分开,代码会清楚许多,也能避免每个请求都重复创建昂贵的连接池。


五、管理多个 FastAPI 共享资源

真实项目通常不止一个共享资源。可以直接嵌套多个 async with,也可以使用 AsyncExitStack 集中管理。

from contextlib import AsyncExitStack, asynccontextmanager

from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    async with AsyncExitStack() as stack:
        database = await stack.enter_async_context(
            create_database_context()
        )
        http_client = await stack.enter_async_context(
            create_http_client_context()
        )
        redis = await stack.enter_async_context(
            create_redis_context()
        )

        app.state.database = database
        app.state.http_client = http_client
        app.state.redis = redis

        yield

AsyncExitStack 会记住成功注册的资源,并在退出时按相反顺序清理。假如 Redis 初始化失败,已经建立的 HTTP 客户端和数据库资源仍会被关闭。

这类结构尤其适合以下情况:

  • 资源是否启用由配置决定;
  • 资源数量在运行时变化;
  • 同时存在同步和异步清理逻辑;
  • 初始化过程可能在中途失败;
  • 希望把清理责任集中到一个出口。

示意流程如下。

flowchart TD
    A[创建 AsyncExitStack] --> B[注册数据库]
    B --> C[注册 HTTP 客户端]
    C --> D[注册 Redis]
    D --> E[应用运行]
    E --> F[关闭 Redis]
    F --> G[关闭 HTTP 客户端]
    G --> H[关闭数据库]

资源关闭顺序通常与创建顺序相反,类似叠盘子:后放上去的盘子先拿下来。


六、在 FastAPI 依赖注入中的用法

FastAPI 的 yield 依赖与上下文管理器有相似的生命周期语义。依赖在 yield 前获取资源,将资源交给路径函数,并在请求结束后执行 yield 后的清理代码。

请求级数据库会话

from collections.abc import AsyncIterator

from fastapi import Depends, FastAPI


app = FastAPI()


async def get_database_session() -> AsyncIterator[DatabaseSession]:
    session = DatabaseSession()

    try:
        yield session
    finally:
        await session.close()


@app.get("/users/{user_id}")
async def get_user(
    user_id: int,
    session: DatabaseSession = Depends(get_database_session),
):
    return await session.get_user(user_id)

这类依赖通常不必再添加 @asynccontextmanager。FastAPI 能直接识别带 yield 的依赖函数,并负责驱动它的进入与退出过程。

复用已有的异步上下文管理器

如果资源管理逻辑还需要在 FastAPI 之外使用,可以先定义独立上下文管理器,再通过依赖包装。

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager


@asynccontextmanager
async def database_session_context():
    session = DatabaseSession()

    try:
        yield session
    finally:
        await session.close()


async def get_database_session() -> AsyncIterator[DatabaseSession]:
    async with database_session_context() as session:
        yield session

这种分层方式很实用:

  • database_session_context 是通用资源管理工具;
  • get_database_session 负责接入 FastAPI 依赖系统;
  • 命令行任务、后台任务和测试也能复用同一套资源管理逻辑。

应用级与请求级资源的区别

资源级别存活时间适合管理的对象常见实现
应用级从服务启动到关闭连接池、模型、共享客户端lifespan
请求级从请求开始到请求结束数据库会话、事务、临时文件yield 依赖
局部代码块某段操作执行期间单个连接、锁、文件with 或 async with

判断资源应该放在哪里,主要看它的生命周期。创建成本高且能够安全共享的对象适合放入 lifespan;与单次请求状态紧密相关的对象,更适合放入依赖函数。


七、事务管理与异常回滚

上下文管理器非常适合表达数据库事务的语义:成功则提交,失败则回滚。

from contextlib import asynccontextmanager


@asynccontextmanager
async def transaction(session):
    try:
        yield session
    except Exception:
        await session.rollback()
        raise
    else:
        await session.commit()

在 FastAPI 依赖中复用:

async def get_transactional_session():
    async with create_session() as session:
        async with transaction(session):
            yield session

业务函数只需关注业务逻辑。

@app.post("/orders")
async def create_order(
    payload: OrderCreate,
    session=Depends(get_transactional_session),
):
    order = await insert_order(session, payload)
    await update_inventory(session, payload.items)
    return order

如果库存更新失败,异常会沿调用栈传播到事务上下文,触发回滚。回滚完成后再次 raise,让 FastAPI 的异常处理机制继续处理错误。

除非确实要把异常视为成功,否则不要在上下文管理器中捕获异常后保持沉默。资源是清理干净了,但错误也可能因此被藏进地毯下面。


八、测试中的用途

FastAPI 的 TestClient 本身也可以作为上下文管理器使用。需要测试应用启动与关闭逻辑时,应通过 with 进入客户端。

from fastapi.testclient import TestClient


def test_application_lifespan():
    with TestClient(app) as client:
        response = client.get("/health")
        assert response.status_code == 200

进入 with TestClient(app) 时,测试客户端执行 FastAPI 的启动生命周期;退出时执行关闭生命周期。

如果只实例化客户端却不进入上下文,测试环境中的 lifespan 可能不会按照预期运行。

client = TestClient(app)

因此,测试数据库连接池、模型加载、缓存初始化等启动行为时,推荐使用上下文形式。


九、常见误区与实践建议

把短生命周期资源放进全局状态

数据库会话、事务和临时文件通常不应放在 app.state 中长期共享。它们往往携带请求状态,并发共享可能产生数据污染或事务混乱。

更合理的结构是:

  • app.state 保存线程安全或协程安全的长期资源;
  • FastAPI 依赖保存请求级资源;
  • 局部 with 保存操作级资源。

忘记在 yield 后清理

下面的上下文管理器没有释放资源。

@asynccontextmanager
async def broken_context():
    client = await create_client()
    yield client

更稳妥的写法是使用 try...finally。

@asynccontextmanager
async def managed_context():
    client = await create_client()

    try:
        yield client
    finally:
        await client.close()

在生命周期中执行永久阻塞任务

yield 之前的代码必须执行完毕,应用才能开始接收请求。如果直接等待一个永不结束的任务,FastAPI 就会一直停留在启动阶段。

后台任务应创建为独立任务,并在关闭阶段取消和等待。

import asyncio
from contextlib import asynccontextmanager, suppress


@asynccontextmanager
async def lifespan(app):
    task = asyncio.create_task(background_worker())

    try:
        yield
    finally:
        task.cancel()

        with suppress(asyncio.CancelledError):
            await task

混淆同步和异步上下文

同步上下文使用:

with resource:
    ...

异步上下文使用:

async with resource:
    ...

若资源的进入或退出需要 await,就应实现异步上下文协议,并使用 asynccontextmanager。不能仅因为路径函数写成了 async def,就把所有同步对象都改成 async with。对象支持哪一种协议,才是判断依据。


结语

contextlib 可以看作 Python 的资源生命周期工具箱。它把初始化、使用、异常处理和清理收拢在清晰的作用域中,让代码在正常路径和异常路径上保持一致。

在 FastAPI 项目里,可以按生命周期划分职责:

  • 应用启动到关闭的共享资源交给 lifespan 和 asynccontextmanager;
  • 单次请求使用的会话、事务交给带 yield 的依赖;
  • 数量动态变化的资源交给 ExitStack 或 AsyncExitStack;
  • 测试生命周期时,用 with TestClient(app) 驱动启动和关闭事件。

掌握这套思路后,contextlib 就不再只是几个装饰器。它会成为组织数据库、缓存、网络客户端、后台任务和模型资源的一条主线,让 FastAPI 服务更可靠,也让清理代码不再四处救火。

参考资料

Python Documentation contextlib Utilities for with Statement Contexts
docs.python.org/3/library/c…

PEP 343 The with Statement
peps.python.org/pep-0343/

FastAPI Documentation Lifespan Events
fastapi.tiangolo.com/advanced/ev…

FastAPI Documentation Testing Events Lifespan and Startup Shutdown
fastapi.tiangolo.com/advanced/te…