本文目录
进程刚被 Uvicorn 拉起来时,路由表还没接到第一个 HTTP 请求——但数据库连接池要不要预热、日志格式要不要切 JSON、定时任务要不要注册,这些往往必须在处理请求之前完成。反过来,进程收到 SIGTERM 时要不要排空队列、关闭 Redis 连接?若把这些逻辑散在模块 import 的顶层,测试时 import 一次就副作用一次;若全塞进路由函数,又会在每个请求里重复初始化。配置回答「环境差异读哪」;lifespan 回答「进程级资源何时建、何时拆」。
配置:Settings 集中读环境
硬编码 DATABASE_URL = "postgresql://..." 在本地能跑,换预发、生产就要改代码或打补丁。常见做法是:配置从环境变量(或 .env 文件)读入,应用代码只依赖一个类型化的 Settings 对象。
Pydantic v2 生态里用 pydantic-settings:
from functools import lru_cache
from pydantic import Field, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "shop-api"
debug: bool = False
database_url: PostgresDsn
jwt_secret: str = Field(min_length=32)
log_level: str = "INFO"
@lru_cache
def get_settings() -> Settings:
return Settings()要点:
- 字段名与环境变量默认一一对应(大小写不敏感),如
DATABASE_URL→database_url。 @lru_cache让 Settings 在进程内只构造一次;依赖注入里Depends(get_settings)与直接调get_settings()拿到同一份只读配置。- 敏感项(
jwt_secret)不进仓库,由部署环境注入;本地开发用.env,且.env应列入.gitignore。
常见误解:把「打开数据库连接」写在 Settings 的 model_post_init 里。Settings 只应描述环境,不应持有长生命周期的连接;连接池属于 lifespan 或专门的工厂模块。
环境分层与校验
预发与生产的差别通常只在几个键:数据库地址、对象存储桶名、第三方 API 密钥。Settings 用类型标注把「缺字段、类型不对」提前到进程 启动 阶段暴露,而不是等到某个冷门路由被访问才 ValidationError。可以为不同部署场景准备 .env.development、.env.staging,通过 env_file 或容器 启动 时的 -e 指定,但同一套代码读同一套字段名。
嵌套 配置 可用 Pydantic 嵌套模型,例如对象存储、邮件网关各占一个子对象,便于在测试里只 override 子树。敏感字段可用 SecretStr,日志里 repr(settings) 时不会明文打印。
另一类「系统设置」:库里可改的业务配置
课件「系统设置」落地的是另一件事:管理员在后台改的运行时配置(上传大小上限、OSS 桶名、功能开关),存在 setting / setting_group 表里,经 Service/API 读写——和上面的 环境 Settings 不是同一层。
| 环境 Settings(本篇前半) | 库内系统设置 | |
|---|---|---|
| 谁改 | 运维 / 部署流水线 | 运营 / 管理员 API |
| 何时生效 | 进程重启(或热读 .env 少见) | 下次读库或清缓存后 |
| 例子 | DATABASE_URL、JWT_SECRET | max_upload_mb、OSS endpoint |
| 放哪 | pydantic-Settings | Model + Service + 短事务读 |
密钥、数据库地址仍应留在环境变量:写进库等于把机密交给能改设置的账号。库内设置适合可热更新、非机密的业务旋钮。读取模式常见两种:
- 请求内短查询:
SettingService.get("oss.bucket"),配合请求级缓存; - 进程缓存 + 版本号 / TTL:lifespan 或定时刷新,避免每个请求打库。
写设置时仍走三层:DTO 校验 → Service 校验权限与值域 → Repository 更新 → 可选发事件让其它 worker 失效缓存。下一篇讲长短事务时,「先短事务读出 OSS 配置,再调外部上传」就是典型用法——读配置与调外部不要捏在同一长连接里。
若课程工程里同时有 core/config.py 与 Setting 模型,读代码时先分清:前者随部署变,后者随运营变。
lifespan:启动与关闭的挂钩点
FastAPI 推荐用 @asynccontextmanager 定义 lifespan,替代旧的 @app.on_event("startup") / shutdown:
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
app.state.engine = create_async_engine(str(settings.database_url))
app.state.redis = await aioredis.from_url(settings.redis_url)
yield
await app.state.redis.close()
await app.state.engine.dispose()
app = FastAPI(lifespan=lifespan)执行顺序:
- Uvicorn 加载 ASGI 应用。
- 进入
lifespan,yield之前为启动逻辑:建池、预热缓存、注册后台消费者。 - 应用开始接请求;
app.state可在依赖里读取。 - 进程退出时,
yield之后为关闭逻辑:按与启动相反的顺序释放资源。
与「请求级 yield 依赖」对比:lifespan 绑的是进程/ worker 生命周期,整台 worker 共享一份 app.state;get_db() 那种 yield 依赖绑的是单次请求,每个请求一个 Session。
与旧版 startup 事件的区别
早期 FastAPI 用 @app.on_event("startup") 注册多个回调,顺序依赖注册先后,且与 shutdown 配对分散。lifespan 把「成对逻辑」收进一个 context manager,读代码时 启动 与 teardown 上下对照,减少「只注册了 startup 忘记 shutdown」的泄漏。Starlette 同样支持 lifespan;子应用挂载时,父应用的 lifespan 与子应用按框架规则组合,大型 monolith 拆 APIRouter 仍共享一个顶层 lifespan 即可。
多 worker 下 each 进程各跑一遍
Uvicorn --workers 4 会 fork 四个进程,每个 worker 独立执行一次 lifespan 启动。连接池、Redis 客户端都是每进程一份,不是全机 singleton。若 启动 逻辑里写「全局计数器递增」,会得到四份而不是一份——需要 Redis 等外部存储做协调。理解这一点,就不会奇怪「为什么连接数 = worker 数 × pool_size」。
在依赖里使用配置与启动态资源
from typing import Annotated
from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncEngine
def get_engine(request: Request) -> AsyncEngine:
return request.app.state.engine
EngineDep = Annotated[AsyncEngine, Depends(get_engine)]
@app.get("/health")
async def health(settings: Annotated[Settings, Depends(get_settings)]) -> dict[str, str]:
return {"app": settings.app_name, "status": "ok"}路由函数签名里出现 Settings,测试时 app.dependency_overrides[get_settings] = lambda: Settings(...) 即可换配置,无需改环境变量。
app.state 适合放「整进程共享、请求只读」的对象:引擎、Redis 客户端、HTTP 连接池。不要把请求级 Session 塞进 app.state——那会和 DI 篇的 get_db 作用域冲突。从 Request 取 state 的依赖写法,也便于在测试里替换 app.state 上的 fake 客户端。
启动阶段还要做什么
除连接池外,lifespan 启动段常见任务:
| 任务 | 放 lifespan 的理由 |
|---|---|
| 配置 logging | 在第一个请求前统一格式,避免 Uvicorn 与业务日志两套标准 |
| 校验必需配置 | 缺 jwt_secret 时立刻 fail fast,而不是首请求 500 |
| 加载只读元数据 | 如特性开关快照、本地化字典,避免每请求查库 |
关闭段则要可预测地释放:异步客户端 await close()、线程池 shutdown(wait=True)。Kubernetes 滚动更新只给几秒 grace period,阻塞过长的 teardown 会被强杀。
与日志、迁移的协作
配置 篇常和 日志 篇连着做:lifespan 启动 最早行读取 Settings.log_level,调用 setup_logging(),再建连接池。这样 Uvicorn 与应用 日志 格式一致。数据库 schema 变更仍应用 Alembic 在部署流水线单独执行,不要放进 lifespan——启动 时自动 create_all 在生产是反模式,无法版本化、无法回滚。
本地 --reload 的重复 启动
开发模式文件变更会重载模块,lifespan 可能短时间执行两次。避免在 启动 里发「仅一次」的远程副作用(注册 webhooks、消费独占队列)。若必须调试 启动 逻辑,可临时关 reload 或打日志区分 worker pid。
常见误解
误解 1:在模块顶层 engine = create_engine(...) 也能用。能跑,但测试 import 模块就会连真实库;且多 worker 下每个进程一份池是预期行为,顶层全局变量有时与 reload 行为纠缠。
误解 2:lifespan 里做重型同步 IO 会阻塞整个 worker 的启动accept。长时间初始化应放后台任务,或启动后 lazy 连接。
误解 3:开发环境 --reload 时 lifespan 会随文件变更反复执行。不要在 lifespan 里做不可重复的副作用(如「仅一次」的数据迁移),迁移应交给 Alembic 等独立流程。
串起来:一次部署 启动 时间线
便于记忆,把 配置 与 lifespan 放进同一条时间线:
- 操作系统拉起 Uvicorn 进程。
- 导入应用模块;
get_settings()首次被调用时读环境,缺jwt_secret则 启动 失败。 - 进入 lifespan 启动 段:配置 logging、创建
engine、连接 Redis、注册app.state上的客户端。 yield之后应用开始 accept;每个请求用get_db借连接,用Depends(get_settings)读只读 配置。- 收到 SIGTERM:停止 accept 新连接,等待在途请求;lifespan 关闭 段 dispose 引擎、关闭 Redis。
- 进程退出。
这条线里,Settings 回答「读什么」、lifespan 回答「何时建/拆进程级资源」、请求依赖回答「一次 HTTP 的生命周期」。三层时间尺度分开,代码就不会在 import 时连库、在路由里重复 启动 线程池。
测试里如何 mock 启动 态
TestClient 会跑 lifespan 启动 与 关闭(可通过 with TestClient(app) 触发)。集成测试若不想连真实 Redis,可在 lifespan 启动 前 app.dependency_overrides 或在 fixture 里替换 app.state.redis 为 fake。纯单元测路由时,也可构造最小 FastAPI 实例、手动塞 app.state.engine 为 sqlite 内存引擎,而不跑完整 启动 脚本。
配置 override 与 app.state 替换是互补的:前者换 Settings 字段,后者换进程级客户端。弄清两者,测试不必 启动 整套 Docker。
.env 与十二因子
十二因子应用要求配置存环境,不硬编码进代码。Settings 读 .env 只是本地便利;生产 容器 或进程管理器注入同名变量即可,无需把 .env 文件打进 镜像。extra="ignore" 可防止环境里多余变量导致 启动 失败——运维常有多余 export,应用应忽略未知键而不是 crash。
弄清 配置 与 lifespan 的分工,是多环境部署与可测试性的基础。
小结
配置用 Settings 类型化环境差异,进程内单例读取;lifespan 在 启动 与关闭时管理进程级资源,与请求级依赖互补。弄清「读配置」与「建连接」的分工,应用才能在多环境、多 worker、测试 override 下行为一致。