uvicorn · Starlette · APIRouter · 路径 / 查询 / 请求体参数 · Depends · lifespan · HTTPException · 中间件 · OpenAPI
FastAPI 写接口:把前面学的装饰器、Pydantic、asyncio 串成一个服务
FastAPI 本身很薄:底下的 uvicorn 收字节,Starlette 管路由和中间件,FastAPI 只做一件事。精华:函数签名就是接口契约。FastAPI 在启动时读每个端点的参数名、类型标注和默认值,推出“每个参数从请求的哪里取、按什么规则校验、要先调哪些依赖”,再用同一份信息生成接口文档。Spring MVC 靠注解加容器扫描做的事,这里全由签名决定。不懂这一点的典型误用:参数写法差一点,来源就变了(比如 list[str] 不加 Query() 就被当成请求体);或者在每个请求里新建 HTTP 客户端、在 async def 里调同步库。
全景:一个 FastAPI 请求的一生 · 第 2、3、4、9 站地图:站 01 · FastAPI 写接口(对照 Spring MVC)以 FastAPI 0.162.2、Starlette 1.10.0、uvicorn 0.54.0 为例
①为什么学:值班助手要从一段脚本变成一个能被调用的服务
在全景第 2、3、4、9 站,这一步要解决的是:字节进来以后(第 2 站),找到该调的函数(第 3 站),把参数取出来校验好(第 4 站),结果变成 JSON 送回去(第 9 站)。前面几页把零件都讲了:装饰器登记路由、Pydantic 校验、asyncio 同时等。这一页把它们装成一个有路由分组、依赖、共享连接池、统一错误和中间件的真服务。
②老办法:Spring MVC 你很熟,Python 这边的老办法是 Flask
@RestController
@RequestMapping("/gc")
class GcController {
@Autowired GcService svc;
@GetMapping("/summary")
GcSummary summary(
@RequestParam String service,
@RequestParam(defaultValue="10")
@Min(1) @Max(60) int minutes) {
return svc.summary(service, minutes);
}
}
@app.get("/gc/summary")
def summary():
service = request.args.get("service")
if not service:
return {"error": "service required"}, 400
try:
minutes = int(request.args.get("minutes", 10))
except ValueError:
return {"error": "bad minutes"}, 400
if not 1 <= minutes <= 60:
return {"error": "1..60"}, 400
...
@router.get("/summary", response_model=GcSummary)
async def gc_summary(
service: str,
minutes: Annotated[int, Query(ge=1, le=60)] = 10,
):
...
async def 直接同时等。- Flask 时代,Python 写接口最费的是“把请求里的字符串变成可信的值”,每个接口都要重复一遍。
- Spring 把这件事交给注解和容器,但要启动时扫描,注解本身不执行,行为分散在框架各处。
- FastAPI 的做法:类型标注已经写清楚了“要什么”,就让它同时决定“从哪取、怎么校验、文档怎么写”,只写一份。
③选谁:Python 写 Web 接口的四个常见选择
| 框架 | 模型 | 校验和文档 | 适合 |
|---|---|---|---|
| Flask | 同步(2.0 起能写 async,但仍是一个请求一个线程) | 自己加插件 | 小工具、老项目 |
| Django + DRF | 同步为主,带 ORM、后台、权限 | 序列化器手写 | 带后台和数据库的完整网站 |
| FastAPI | ASGI,异步优先 | 类型标注自动生成 | 纯接口服务、AI 应用后端 |
| Litestar | ASGI,异步优先 | 类型标注自动生成 | 和 FastAPI 同类,生态小一些 |
本页学 FastAPI。选择规则:只做接口、要大量等外部服务(大模型 API、日志代理)就选 FastAPI;要后台管理和 ORM 一体就选 Django。大模型 SDK 的示例、MCP 的 Python SDK、LangServe 这类工具,默认都配 FastAPI 或同一套 Starlette。
④原理:三层分工,签名决定一切,共享资源放在 lifespan 里
1三层分工:uvicorn 收字节,Starlette 管路由,FastAPI 读签名
监听端口,用 httptools 解析 HTTP,跑事件循环(装了 uvloop 就用它)。≈ Tomcat 的 Connector。
路由表、中间件、Request / Response、run_in_threadpool。≈ DispatcherServlet 加 Filter 链。
启动时读每个端点的签名,建好“参数从哪取、依赖怎么调、怎么校验”的方案,再生成 OpenAPI。≈ 参数解析器 + Bean Validation + springdoc。
和 Spring 的差别:FastAPI 没有容器,不管对象的生命周期。它的“依赖注入”只发生在调用端点的那一刻,注入的是函数的返回值,不是单例 Bean。
2参数从哪来:看名字、看类型、看默认值 ≈ @PathVariable / @RequestParam / @RequestBody / @RequestHeader
| 签名里这样写 | FastAPI 认为来自 | Spring 里的写法 |
|---|---|---|
名字出现在路径里:/gc/{host} 配 host: str | 路径 | @PathVariable |
| 简单类型(str、int、float、bool……) | 查询串 | @RequestParam |
| Pydantic 模型、或者 list 这类容器 | 请求体 | @RequestBody |
显式写 Query()、Header()、Cookie()、Body() | 按你写的 | 对应的注解 |
Depends(fn) | 不来自请求,先调用 fn,取它的返回值 | 参数解析器 / 请求作用域的 Bean |
实验台:这个参数从哪来?答案取自本机 FastAPI 生成的 OpenAPI
先猜:
3Depends:每个请求现场调用一遍依赖函数 ≈ 参数解析器 + 请求作用域
yield:yield 之前是请求开始时做的事,之后是这次请求处理完的清理,≈ 请求级别的 try/finally(清理具体在响应发出前还是后,不同版本有调整,按所用版本的文档为准)。测试时用 dependency_overrides 换依赖,比上一页的 monkeypatch 稳得多,下一页 pytest 会用到。4lifespan 和线程池:哪些东西整个进程共用一份 ≈ 单例 Bean、Tomcat 线程池
lifespan:启动时建,关闭时收
@asynccontextmanager
async def lifespan(app):
app.state.client = httpx.AsyncClient(...)
yield # 服务运行期间
await app.state.client.aclose()
app = FastAPI(lifespan=lifespan)
httpx 客户端带连接池。共用一个,请求复用已经建好的 TCP 连接:本机打 200 次,每次新建客户端平均 10.30 ms,共用一个 1.77 ms 实测。≈ @Bean 加 @PreDestroy。
def 端点进线程池,上限 40
| 端点写法(各睡 1 秒) | 并发 | 总耗时 |
|---|---|---|
async def + await asyncio.sleep | 50 | 1.08 s |
def + time.sleep | 40 | 1.10 s |
def + time.sleep | 50 | 2.08 s |
async def + time.sleep | 5 | 5.01 s |
本机 uvicorn 实测。普通 def 端点由 Starlette 交给 anyio 的线程池,默认上限 40 个线程(total_tokens 实测为 40),第 41 个起排队;async def 里阻塞则全部串行。
5为什么这样设计
FastAPI:签名是唯一的事实来源选了它
- 参数、校验、依赖、文档只写一次,改了签名,文档跟着变,不会对不上。
- 没有容器,启动快,函数就是普通函数,单独拿出来也能调用和测试。
- 代价:规则是隐式的,写法差一点来源就变(实验台里的
list[str]);也没有容器帮你管对象的生命周期,共享资源要自己放进 lifespan。
Spring MVC:注解 + 容器没选
- 每个参数都显式写注解,来源一目了然;容器管好所有 Bean 的创建和销毁。
- 注解要在启动时反射扫描才生效;文档、校验、参数解析分在不同组件里配置。
- Python 没有一个公认的 IoC 容器,照搬这套会很重。
决定胜负的约束:Python 3.5 以后类型标注已经普遍写在签名里,而 Pydantic 能在运行时读懂它们。既然“要什么”已经写在那儿了,再让人另写一遍注解或配置就是重复劳动。FastAPI 赌的就是这一点。
最少的语法:Spring MVC 怎么写,FastAPI 怎么写 细节放到 ⑥
| Spring MVC | FastAPI | 说明 |
|---|---|---|
@RestController @RequestMapping("/gc") | router = APIRouter(prefix="/gc") | 再 app.include_router(router) |
@GetMapping("/summary") | @router.get("/summary") | post、put、delete 同理 |
@PathVariable String host | /{host} + host: str | 名字对上就行 |
@RequestParam @Min(1) int minutes | minutes: Annotated[int, Query(ge=1)] = 10 | |
@RequestBody @Valid GcQuery q | q: GcQuery | 模型自动当请求体 |
@RequestHeader("X-Trace-Id") | x_trace_id: Annotated[str | None, Header()] = None | 下划线自动变连字符 |
@Autowired GcService svc | svc: Annotated[GcService, Depends(get_svc)] | 每个请求调用 get_svc |
throw new ResponseStatusException(404) | raise HTTPException(404, detail=...) | |
@ControllerAdvice @ExceptionHandler | @app.exception_handler(httpx.HTTPError) | 统一转换异常 |
Filter / HandlerInterceptor | @app.middleware("http") | 拿到 request 和 call_next |
@Bean + @PreDestroy | lifespan + app.state | 共享资源 |
java -jar app.jar | uv run uvicorn main:app --port 8000 | 生产加 --workers N |
springdoc 的 /swagger-ui.html | /docs、/openapi.json | 自带 |
⑤跑起来:值班助手的第一个真服务,外加一个假日志代理
还在 gc-helper 项目里。日志文件用 语法对照页的 make_logs.py 生成。先装依赖:
$ uv add fastapi "uvicorn[standard]" httpx
# uvicorn[standard] 会带上 uvloop 和 httptools,本机装到 uvicorn 0.54.0、uvloop 0.23.0
# 假日志代理:按路径返回 GC 日志,order-04 卡住不回
import asyncio
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
app = FastAPI()
DELAYS = {"order-01": .12, "order-02": .08, "order-03": .15, "order-04": 3600, "order-05": .09}
@app.get("/{host}/gc.log", response_class=PlainTextResponse)
async def gc_log(host: str, minutes: int = 10):
await asyncio.sleep(DELAYS.get(host, 0))
return Path(f"logs/{host}.log").read_text()
import asyncio, re, time
from contextlib import asynccontextmanager
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
from pydantic import BaseModel
AGENT = "http://127.0.0.1:9100"
SERVICES = {"order": [f"order-0{i}" for i in range(1, 6)]}
PAUSE = re.compile(r"Pause .* (\d+\.\d+)ms$")
class HostGc(BaseModel):
host: str
pauses: int = 0
max_ms: float | None = None
avg_ms: float | None = None
error: str | None = None
class GcSummary(BaseModel):
service: str
minutes: int
hosts: list[HostGc]
elapsed_ms: int
@asynccontextmanager
async def lifespan(app: FastAPI): # ≈ @Bean + @PreDestroy:整个进程共用一个连接池
app.state.client = httpx.AsyncClient(base_url=AGENT, timeout=5.0)
yield
await app.state.client.aclose()
app = FastAPI(title="值班助手", lifespan=lifespan)
router = APIRouter(prefix="/gc", tags=["gc"]) # ≈ 类上的 @RequestMapping("/gc")
def get_client(request: Request) -> httpx.AsyncClient: # 依赖:从 app.state 取共享的客户端
return request.app.state.client
def get_hosts(service: str) -> list[str]: # 依赖:校验 service 并查出主机列表
if service not in SERVICES:
raise HTTPException(status_code=404, detail=f"unknown service: {service}")
return SERVICES[service]
Client = Annotated[httpx.AsyncClient, Depends(get_client)]
Hosts = Annotated[list[str], Depends(get_hosts)]
def parse(host: str, text: str) -> HostGc:
ms = [float(m.group(1)) for line in text.splitlines() if (m := PAUSE.search(line))]
if not ms:
return HostGc(host=host, error="no pauses")
return HostGc(host=host, pauses=len(ms), max_ms=max(ms), avg_ms=round(sum(ms) / len(ms), 1))
async def fetch(client: httpx.AsyncClient, host: str, minutes: int) -> HostGc:
try:
async with asyncio.timeout(1.0):
r = await client.get(f"/{host}/gc.log", params={"minutes": minutes})
except TimeoutError:
return HostGc(host=host, error="timeout 1s")
return parse(host, r.text)
@router.get("/summary", response_model=GcSummary)
async def gc_summary(client: Client, hosts: Hosts, service: str,
minutes: Annotated[int, Query(ge=1, le=60)] = 10):
t = time.perf_counter()
results = await asyncio.gather(*(fetch(client, h, minutes) for h in hosts))
return GcSummary(service=service, minutes=minutes, hosts=results,
elapsed_ms=round((time.perf_counter() - t) * 1000))
@router.get("/{host}", response_model=HostGc) # 写在 /summary 后面
async def gc_host(host: str, client: Client, minutes: Annotated[int, Query(ge=1, le=60)] = 10):
return await fetch(client, host, minutes)
app.include_router(router)
@app.middleware("http") # ≈ Filter:每个请求都经过
async def timing(request: Request, call_next):
t = time.perf_counter()
response = await call_next(request)
response.headers["X-Process-Ms"] = f"{(time.perf_counter() - t) * 1000:.1f}"
return response
@app.get("/healthz")
async def healthz():
return {"ok": True}
$ uv run uvicorn agent:app --port 9100 &
$ uv run uvicorn main:app --port 8000 &
$ curl -i "localhost:8000/gc/summary?service=order&minutes=10"
HTTP/1.1 200 OK x-process-ms: 1003.6
{"service":"order","minutes":10,"hosts":[{"host":"order-01","pauses":60,"max_ms":244.05,"avg_ms":26.1,"error":null},{"host":"order-02","pauses":60,"max_ms":248.1,"avg_ms":33.6,"error":null},{"host":"order-03","pauses":60,"max_ms":252.15,"avg_ms":31.6,"error":null},{"host":"order-04","pauses":0,"max_ms":null,"avg_ms":null,"error":"timeout 1s"},{"host":"order-05","pauses":60,"max_ms":260.25,"avg_ms":30.6,"error":null}],"elapsed_ms":1003}
$ curl -i "localhost:8000/gc/summary?service=pay"
HTTP/1.1 404 Not Found x-process-ms: 0.8
{"detail":"unknown service: pay"}
$ curl -i "localhost:8000/gc/summary?service=order&minutes=0"
HTTP/1.1 422 Unprocessable Content x-process-ms: 0.9
{"detail":[{"type":"greater_than_equal","loc":["query","minutes"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]}
$ curl -i "localhost:8000/gc/order-02?minutes=5"
HTTP/1.1 200 OK x-process-ms: 82.9
{"host":"order-02","pauses":60,"max_ms":248.1,"avg_ms":33.6,"error":null}
$ curl -i "localhost:8000/gc/summary/?service=order"
HTTP/1.1 307 Temporary Redirect x-process-ms: 0.3
location: http://127.0.0.1:8000/gc/summary?service=order
$ curl -i "localhost:8000/healthz"
HTTP/1.1 200 OK x-process-ms: 0.4
{"ok":true}
- 看第一条:1003.6 ms,5 台里 4 台有数,order-04 标成超时;响应头里多了中间件加的
x-process-ms。 - 看
service=pay被依赖get_hosts挡住,0.8 ms 返回 404,端点没被调用;minutes=0被参数校验挡住,422。 - 看路径末尾多一个斜杠,Starlette 返回 307 让你重定向到不带斜杠的地址,见 ⑥ 坑 7。
- 看打开
localhost:8000/docs就是自动生成的接口文档;/openapi.json里列出了/gc/summary、/gc/{host}、/healthz三个路径,参数来源都标好了 实测。
实测 本机 uv 0.13.7、CPython 3.13.18、FastAPI 0.162.2、Starlette 1.10.0、uvicorn 0.54.0、httpx 0.28.1,输出逐字复制(只保留了状态行、耗时头和响应体)。测完两个 uvicorn 都已停掉。
⑥细节·规范·优化
第一圈 7 个坑,6 个在本机复现过 实测,第 7 个里 httpx 不跟随重定向那半句按官方文档。
注意事项:不知道就会出事
- 现象
/gc/{host}登记在/gc/summary前面,请求/gc/summary进了gc_host,host="summary"。- 根源
- Starlette 按登记顺序逐条匹配,第一条能匹配上的就用;
{host}能匹配任何一段。 - 做法
- 固定路径写在前面;或者给变量加约束,比如
/gc/hosts/{host}。
list[str] 参数不加 Query(),变成了请求体← ④-2 容器当请求体
- 现象
GET /d?hosts=a&hosts=b返回 422,"loc":["body"],Field required。- 根源
- 只有简单类型默认从查询串取,list 这类容器默认当请求体,而 GET 请求没有请求体。
- 做法
hosts: Annotated[list[str], Query()],才是?hosts=a&hosts=b。
async def 端点里调同步库,所有请求串行← ④-4 事件循环线程
- 现象
- 端点里
time.sleep(1),5 个并发请求总共 5.01 s。 - 根源
async def端点直接跑在事件循环上,阻塞调用占着那唯一的线程。详见 asyncio 页坑 1。- 做法
- 端点里只要有同步库,就干脆写成普通
def,FastAPI 会放进线程池。
def 端点,第 41 个请求开始排队← ④-4 线程池上限 40
- 现象
- 各睡 1 秒,40 个并发 1.10 s,50 个并发 2.08 s。
- 根源
- Starlette 用 anyio 的默认线程池跑同步端点,上限 40 个线程,再多就等。
- 做法
- 能改成 async 就改;确实要更多线程,在 lifespan 里调大
anyio.to_thread.current_default_thread_limiter().total_tokens;或者加 worker 进程。
- 现象
- 每次
async with httpx.AsyncClient(),平均 10.30 ms 一次;共用一个 1.77 ms。 - 根源
- 新客户端就是新连接池,每次都要重新建 TCP 连接(HTTPS 还要再握手一次)。
- 做法
- 在 lifespan 里建一个,放进
app.state,用依赖取出来。调大模型 API 也一样。
{"error": ...},状态码却是 200设计如此
- 现象
- 端点
return {"error": "unknown service"},客户端收到 200。 - 根源
- 你返回什么,FastAPI 就序列化什么,默认状态码 200。调用方、监控、重试逻辑全把它当成功。
- 做法
raise HTTPException(404, detail=...);同一类错误用@app.exception_handler统一转换。
- 现象
/gc/summary/返回 307,location指向不带斜杠的地址。curl 不加-L不会跟过去;按 httpx 文档,它默认也不跟随重定向,拿到的是空响应体。- 根源
- Starlette 的
redirect_slashes默认开着,路径对不上时试着补上或去掉末尾斜杠再重定向。 - 做法
- 团队约定路径统一不带末尾斜杠;客户端拼地址别手写斜杠。
使用规范:团队里怎么写
routers/gc.py、routers/alerts.py,main.py 只负责组装。
一个文件几十个端点,路由顺序互相打架。
Annotated 起别名Client = Annotated[httpx.AsyncClient, Depends(get_client)],端点里写 client: Client。
同一个 Depends 抄几十遍,改一处漏一处。
response_model= 或返回类型标注成模型。
字段泄露,文档里没有返回结构。
HTTP 客户端、数据库连接池、加载好的模型。
每个请求重建连接;import 时就连外部服务。
app.dependency_overrides[get_client] = fake。
monkeypatch 改错模块,测试悄悄打了真网络。
/docs对外服务 FastAPI(docs_url=None, openapi_url=None),或者放在鉴权后面。
接口清单和参数全部公开。
优化和深处
同一份签名,到了站 07 就是工具说明
/openapi.json 里每个参数的名字、类型、约束、是否必填都写好了。站 04 给 agent 定义工具、站 07 写 MCP 服务端时,需要的正是这样一份 JSON Schema:FastAPI 的“签名即契约”和“工具定义即契约”是同一个思路。
框架本身花多少时间
/healthz 从中间件进到出,x-process-ms 是 0.4 ms;带两个依赖、参数校验的 404 和 422 是 0.8~0.9 ms 实测。值班助手的 1 秒几乎全在等日志代理,框架开销可以忽略。真正要调的是连接池(坑 5)、线程池(坑 4)和 worker 数。
worker 数怎么定
一个 uvicorn 进程一个事件循环,只吃得满一个核。纯 IO 的服务,worker 数从 CPU 核数起步;有 CPU 活(解析大日志)的,CPU 活交给进程池或独立服务,别靠多开 worker 硬扛。容器里通常一个容器一个 worker,扩容交给 K8s,站 10 再讲。
版本差异
| 版本 | 变化 |
|---|---|
| 0.93 | lifespan 参数,取代 @app.on_event("startup") |
| 0.95 | 推荐用 Annotated[...] 写 Query、Depends |
| 0.100 | 支持 Pydantic v2 |
网上和 AI 生成的老代码里常见 on_event 和 minutes: int = Query(10) 写法,还能用,新代码按上表写。
下一圈
- 流式响应:
StreamingResponse和 SSE,把大模型的输出边生成边推(站 03)。 - 后台任务和长任务:
BackgroundTasks够用到哪,什么时候该上队列(站 10)。 - 鉴权:
Security、OAuth2 依赖,对照 Spring Security(站 07、10)。 - 中间件的顺序和纯 ASGI 中间件:对照 Filter 链。
- Starlette 源码:
Router.__call__怎么匹配,redirect_slashes在哪。
↺回到⑤:拿⑥改进值班助手
1 · 加一个 POST 接口
加 POST /gc/compare,请求体是 {"service": "order", "hosts": ["order-01", "order-03"], "minutes": 10},只比较指定的几台。先猜 hosts 该怎么声明,再看 /docs 里它在哪。
用到:④-2、坑 2
2 · 换掉依赖测一次
不启动日志代理,用 app.dependency_overrides[get_client] 换成一个 httpx.AsyncClient(transport=httpx.MockTransport(handler)),让 TestClient 打 /gc/summary。
用到:④-3、规范“测试换依赖”;下一页 pytest 接着做
3 · 日志代理挂了怎么办
停掉 agent.py 再请求,看到 500。写一个 @app.exception_handler(httpx.ConnectError) 把它转成 502 和一句人能看懂的 detail。
用到:坑 6
1. 用一句话说出这一页的精华。
FastAPI 把函数签名当成接口契约:启动时读参数名、类型和默认值,推出每个参数从哪取、怎么校验、要先调哪些依赖,再用同一份信息生成接口文档;底下是 uvicorn 收字节、Starlette 管路由和中间件。
2. Flask 老办法痛在哪?Spring MVC 又是怎么解决的?
Flask 要手写取参、转换、校验、报错,每个接口重复一遍,而且同步模型。Spring 交给注解和容器,但要启动时扫描、文档另配。FastAPI 让类型标注一次性决定这些。
3. 坑 4 的“第 41 个请求开始排队”从 ④ 哪一条来?
④-4:普通 def 端点交给 anyio 的线程池执行,默认上限 40 个线程,超过的要等前面的线程空出来。
4. 预测:async def x(ids: set[int]) 和 async def y(q: GcQuery, minutes: int = 10),ids、q、minutes 各从哪来?
ids 是容器,默认当请求体;q 是 Pydantic 模型,请求体;minutes 是简单类型,查询串。
从 ④-2 的规则推出,可以在实验台里对照 list[str] 那一项。
5. 预测:把 get_hosts 里的 raise HTTPException(404) 改成 return [],service=pay 会得到什么?
200,hosts: [],elapsed_ms 接近 0。gather 空列表立刻返回,接口看起来“成功了”,调用方分不清是服务名写错还是这个服务真的没有机器。
从坑 6 推出:错误要用状态码表达,不要藏在“成功”的响应里。
6. 放回全景:第 2、3、4、9 站分别在 ⑤ 的代码里对应哪几行?如果把 lifespan 删掉、在 fetch 里每次新建客户端,整条链路会怎样?
第 2 站是 uvicorn main:app;第 3 站是 @router.get(...) 登记、include_router 装进路由表;第 4 站是 service: str、Query(ge=1, le=60) 和两个 Depends;第 9 站是 response_model=GcSummary。
每次新建客户端,第 6、7 站发出的 5 个请求都要重新建 TCP 连接,每个多花几毫秒(本机实测平均多 8.5 ms),HTTPS 时更多;并发一高,短连接还会大量堆在 TIME_WAIT 里。值班助手这一次的总耗时主要由 1 秒超时决定,所以单次请求看不太出来,压力上来才暴露。
见 全景第 6、7 站。