跳转至

import 一行代码,服务就炸了?

AI 学习助手的 typed core 已经跑起来了,路由也注册好了。现在你想加一个功能: 服务启动时加载配置文件,把配置信息提供给后续的请求处理。

你的第一反应可能是这样写:

# config.py
import json
from pathlib import Path

settings = json.loads(Path("config.json").read_text())  # 模块顶层执行
# main.py
from config import settings  # 这一行就触发了加载
from fastapi import FastAPI

app = FastAPI()

看起来简洁明了。然后你跑测试:

$ python -m pytest tests/
ImportError: ...
  File "config.py", line 4, in <module>
    settings = json.loads(Path("config.json").read_text())
FileNotFoundError: [Errno 2] No such file or directory: 'config.json'

你只是想 import 一个模块跑测试,结果直接炸了——因为测试环境里没有 config.json。错误信息指向 import 语句,但问题根本不在 import 本身。

运行环境

Python 3.12、FastAPI 0.115+、uvicorn。

python -m pip install "fastapi[standard]>=0.115" "pydantic>=2,<3"

1. 模块顶层初始化的三个麻烦

上面的 traceback 暴露了一个根本问题:初始化行为被绑定在了 import 时机上。 来看这会带来哪些具体麻烦。

Import 副作用:Python 的 import 语句会执行模块顶层的所有代码。如果你在 顶层创建连接、加载文件或发起网络请求,那么任何 import 该模块的代码都会触发 这些操作——无论它是否需要这些资源。

# services.py
import httpx

client = httpx.AsyncClient(base_url="https://api.openai.com")  # import 即连接

任何文件只要写了 from services import something,就会尝试建立网络连接。 如果网络不通,import 失败;如果跑在 CI 容器里没有外网权限,import 失败。

测试污染:测试文件 import 被测模块时,顶层初始化也会执行。你本来只想测一个 纯函数,结果测试进程去连了数据库、去读了配置文件。测试变慢、不可复现、依赖外部 状态——这些都不是你想要的。

顺序依赖:当多个模块在顶层互相依赖对方的初始化结果时,import 顺序变得关键。 模块 A 顶层用了模块 B 的连接,模块 B 顶层用了模块 A 的配置——循环依赖或启动 顺序问题就此产生。

三个问题指向同一个结论:初始化应该有明确的时机和位置,而不是散布在 import 副作用中

那 FastAPI 提供了什么机制来解决这个问题?

2. lifespan:初始化和清理的配对入口

FastAPI 提供的答案叫 lifespan——一个异步上下文管理器,把初始化和清理代码 配对写在同一个函数里:

# main.py — FastAPI 0.115+, Python 3.12
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    # --- yield 之前:初始化 ---
    print("🚀 服务启动:加载配置")
    yield
    # --- yield 之后:清理 ---
    print("🛑 服务关闭:释放资源")

app = FastAPI(lifespan=lifespan)

启动 uvicorn 观察输出:

$ uvicorn main:app
🚀 服务启动:加载配置
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000

按 Ctrl+C 停止:

🛑 服务关闭:释放资源
INFO:     Application shutdown complete.

时序很清晰:

  1. yield 之前的代码在应用开始接收请求之前执行(初始化阶段)
  2. 应用进入可用状态,开始处理请求
  3. yield 之后的代码在应用停止接收请求之后执行(清理阶段)

对比之前的写法:配置加载不再发生在 import 时,而是发生在服务明确启动时。测试 可以 import 任何模块而不触发初始化;初始化失败时,错误信息直指 lifespan 而非 某个 import 语句。

再想一步:初始化和清理写在同一个函数里意味着什么?你不会忘记清理。在顶层写 client = httpx.AsyncClient() 时,await client.aclose() 该写在哪里?没有 明确位置,只能指望进程退出时隐式回收。lifespan 的 yield 结构让获取和释放成对 出现——一个函数看到全貌。

现在把 AI 学习助手的配置加载放进 lifespan:

@asynccontextmanager
async def lifespan(app: FastAPI):
    config = load_config("config.json")
    app.state.config = config
    print(f"✅ 配置加载完成: {config['app_name']}")
    yield
    print("🧹 清理完成")

load_config 只在服务启动时被调用一次,不在 import 时触发。

3. 初始化失败:启动失败好过伪可用

lifespan 的配对机制建立了。但如果初始化过程中出了错呢?

比如配置文件不存在:

@asynccontextmanager
async def lifespan(app: FastAPI):
    config = load_config("config.json")  # 文件不存在,raise FileNotFoundError
    app.state.config = config
    yield

观察 uvicorn 的行为:

$ uvicorn main:app
ERROR:    Traceback (most recent call last):
  ...
FileNotFoundError: [Errno 2] No such file or directory: 'config.json'
ERROR:    Application startup failed. Exiting.

应用没有启动。没有开始监听端口,没有进入可接收请求的状态。这是正确行为

现在看一个危险的"修复":

@asynccontextmanager
async def lifespan(app: FastAPI):
    try:
        config = load_config("config.json")
    except FileNotFoundError:
        config = {}  # 吞掉异常,用空配置"凑合"
        print("⚠️ 配置加载失败,使用默认值")
    app.state.config = config
    yield

服务"成功"启动了。客户端发来请求,处理逻辑尝试读取 app.state.config["model_endpoint"]——KeyError。每一个请求都失败,返回 500。

从客户端视角看:服务在运行,端口在监听,但每次调用都得到不可预期的错误。这比 "服务压根没启动"更难排查——你得先排除网络问题、权限问题、请求格式问题,最后 才发现是配置压根没加载。

判断标准很简单:关键资源未就绪时,不应让服务进入可用状态。让异常传播出 lifespan,FastAPI/uvicorn 会阻止应用启动——这正是你想要的信号:启动失败, 修好再来。

什么时候可以用默认值?当缺失的配置确实是可选的、有合理默认值、不影响核心功能 时。"模型 API 地址"不是可选的——没有它,服务无法完成任何有价值的工作。

4. 最小 lifespan 的增量价值

你可能会想:"当前 AI 学习助手还没有数据库,没有外部 HTTP 客户端,lifespan 里只打了两行日志——有必要现在就写吗?"

看当前的最小 lifespan:

@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info("service ready")
    yield
    logger.info("service stopped")

app = FastAPI(lifespan=lifespan)

它现在能做什么:

  • 可观察:启动日志让你确认服务确实经历了初始化阶段,而不是"碰巧在运行"
  • 可测试:测试中可以验证 lifespan 是否正常执行,而不需要真正启动 uvicorn

更关键的是将来的价值。假设下一课需要接入数据库:

@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info("service ready")
    db = await connect_db(app.state.config["db_url"])  # 新增一行
    app.state.db = db
    yield
    await db.close()  # 新增一行
    logger.info("service stopped")

只需在已有的 yield 前后各加一行。不需要重新设计启动结构,不需要找一个"应该 放在哪里"的位置——配对入口已经在那里了。

对比没有 lifespan 时接入数据库的改动:你得找到一个"应用启动时执行"的位置 (模块顶层?某个全局变量?某个被调用一次的函数?),还得找到一个"应用关闭时 执行"的位置——而且两者之间没有结构性关联,清理代码很容易被遗忘。

这不是过度工程。这是用一个最小结构为将来的增量修改留出配对入口:初始化的代码 加在 yield 前,对应的清理加在 yield 后。结构本身保证了配对关系。

边界与常见误区

误区一:在模块顶层创建连接或加载资源

# 反例
db_pool = create_pool("postgres://...")  # import 即连接

任何 import 该模块的文件都会触发数据库连接。测试时得准备一个真实数据库,CI 中得配置网络权限。正确做法:把资源创建移入 lifespan。

误区二:认为"没有重资源就不需要 lifespan"

没有数据库不代表不需要 lifespan。当前的最小 lifespan 使启动过程可观察、可 测试,并为后续资源接入保留配对入口。等到真需要时再从零搭建,代价是重新设计 启动结构,还要找到散落各处的初始化代码。

误区三:把 lifespan 当成请求级依赖的替代

# 反例:在 lifespan 中处理每请求逻辑
@asynccontextmanager
async def lifespan(app: FastAPI):
    while True:  # ??
        process_next_request()
        yield

lifespan 管理的是应用级资源:从服务启动到关闭期间存在的东西(连接池、配置、 客户端)。每请求获取和释放的资源(数据库会话、临时文件)由带 yield 的依赖 管理,作用范围完全不同。

误区四:吞掉初始化异常让服务"先跑起来再说"

# 反例
try:
    essential_resource = init_resource()
except Exception:
    essential_resource = None  # 伪可用

服务启动了,但核心资源为 None。第一个请求到来时才爆出 AttributeError。客户端 看到的是"服务有时能用有时不能用"——比"服务没启动"更难诊断。关键资源初始化 失败时,应让异常传播,让服务明确拒绝启动。

本章小结

服务生命周期的核心问题是:初始化行为应该在什么时机、什么位置执行?

模块顶层执行初始化会把资源创建绑定到 import 时机,导致 import 副作用、测试 污染和顺序依赖。lifespan 提供了明确的替代方案:yield 之前执行初始化,yield 之后执行清理,二者在同一个函数中配对。

初始化失败时,异常应传播出 lifespan,阻止应用进入可用状态——启动失败好过伪 可用。即使当前服务只有简单配置,最小 lifespan 也使启动过程可观察,并为后续 资源增量接入提供配对入口。

练习

练习一:预测执行时序

以下 lifespan 中,各 print 语句的输出顺序是什么?

@asynccontextmanager
async def lifespan(app: FastAPI):
    print("A")
    print("B")
    yield
    print("C")
    print("D")

假设服务启动后收到一个请求并正常处理,然后 Ctrl+C 关闭。完整的输出顺序是?

练习二:判断初始化失败的处理方式

AI 学习助手的 lifespan 需要加载模型配置。以下两种写法,哪种更合理?为什么?

写法 A:

@asynccontextmanager
async def lifespan(app: FastAPI):
    try:
        model_config = load_model_config()
    except FileNotFoundError:
        model_config = {"model": "gpt-4o", "temperature": 0.7}
    app.state.model_config = model_config
    yield

写法 B:

@asynccontextmanager
async def lifespan(app: FastAPI):
    model_config = load_model_config()  # 失败则服务不启动
    app.state.model_config = model_config
    yield

提示:思考 model_config 对服务功能的重要程度。

练习三:从顶层初始化重构到 lifespan

以下代码在模块顶层创建了 HTTP 客户端。请重构为使用 lifespan 管理:

# services.py(当前写法)
import httpx

client = httpx.AsyncClient(
    base_url="https://api.openai.com/v1",
    headers={"Authorization": f"Bearer {API_KEY}"},
)

要求:(1) 客户端在服务启动时创建;(2) 服务关闭时正确关闭客户端;(3) import 该模块不会触发网络连接。

练习四:判断资源应该放在哪里

以下资源分别应该由 lifespan 管理还是由请求级依赖管理?

  1. 数据库连接池(整个服务共享)
  2. 单次请求的数据库会话(事务)
  3. 全局配置对象(启动时加载一次)
  4. 临时上传文件的句柄(请求结束后删除)

练习解析

练习一解析

输出顺序:A → B → [请求处理] → C → D

具体时序: 1. 服务启动时,lifespan 执行到 yield —— 依次输出 AB 2. yield 之后应用进入可用状态,开始接收请求 3. 请求到来,正常处理并响应 4. Ctrl+C 触发关闭,lifespan 从 yield 之后继续 —— 依次输出 CD

关键理解:yield 是分界线。yield 之前的所有代码在"接收第一个请求之前"完成; yield 之后的所有代码在"最后一个请求处理完毕之后"执行。中间才是服务的可用阶段。

练习二解析

写法 B 更合理

model_config 包含模型 API 地址和参数——这是 AI 学习助手完成任何请求的必要 条件。如果加载失败:

  • 写法 A:服务启动,但后续请求尝试调用模型时会因为配置不完整而失败。客户端 看到不可预期的 500 错误,排查时才发现配置没加载。
  • 写法 B:服务直接拒绝启动,运维人员立即看到 FileNotFoundError,修复配置 文件后重启即可。

写法 A 中的 {"model": "gpt-4o", "temperature": 0.7} 看似"合理的默认值", 但如果 API key 或 endpoint 也在配置文件中,空默认值会让每次模型调用都失败。

什么时候写法 A 可以接受?当缺失的配置确实不影响核心功能时。比如一个可选的 日志级别配置——缺了就用默认的 INFO 级别,服务照常运行。

练习三解析

重构后的代码:

# main.py
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.openai_client = httpx.AsyncClient(
        base_url="https://api.openai.com/v1",
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    yield
    await app.state.openai_client.aclose()

app = FastAPI(lifespan=lifespan)

对比原始写法的改进: - import services 模块不再触发网络连接 - 客户端创建和关闭配对出现在同一个函数中,不会忘记 aclose() - 测试可以 import 任何模块而不需要真实的 OpenAI 连接 - 如果网络不通,服务启动失败——而不是 import 失败

练习四解析

资源 管理位置 理由
数据库连接池 lifespan 服务启动时创建,关闭时销毁;整个生命周期内所有请求共享
单次请求的数据库会话 请求级依赖 每个请求独立获取会话,请求结束后提交/回滚并释放
全局配置对象 lifespan 启动时加载一次,整个生命周期内不变
临时文件句柄 请求级依赖 只在处理该请求时有意义,请求结束后应立即清理

判断标准:这个资源的生命周期跟应用一样长,还是跟单个请求一样长?跟应用 一样长的放 lifespan,跟请求一样长的放请求级依赖(带 yield 的 Depends)。

参考资料