04 路由 —— 当接口多起来,你需要的不是更多装饰器,而是一张地图
前置阅读: 01-FastAPI介绍、02-项目结构、03-同步与异步
关键词: APIRouter, prefix, tags, async def, def
难度: ★★★☆☆
场景导入:从"全写在一个文件里"到"按业务域分家"
前几章我们一直把端点直接挂在 app 对象上——@app.get("/health")、@app.get("/books/count"),干净利落。但这种写法有一个隐形的天花板:当接口数量突破十几条,所有路由挤在同一个文件里,代码导航就开始变得痛苦,OpenAPI 文档也会呈现为一堵扁平的端点墙。
FastAPI 的答案是 APIRouter。你可以把它理解为"迷你 FastAPI 应用"——它独立维护自己的端点列表,有自己的 prefix 和 tags,最后通过 app.include_router() 像拼乐高一样拼到主应用上。更妙的是,同一个工程里,async def 和 def 两种 handler 形态可以在同一个 Router 下和平共处,框架会根据函数是协程还是普通函数自动分流到事件循环或线程池。
本篇会完成两件事:用 APIRouter 把图书管理 API 的第一组端点按业务域拆分,然后通过 /health(纯计算)和 /books/count(数据库占位)两个对照示例,让你彻底看清 async def 与 def 的选型逻辑。
原理解析:从装饰器扫描到请求分流的完整链路
路由的本质,是把"HTTP 方法 + 路径模板"映射到一个 Python 函数。FastAPI 把这个映射按 APIRouter 维度组织,每个 Router 内部维护自己的端点列表。当 app.include_router(router) 被调用时,这些端点被拷贝进主应用的路由表——不是引用,是货真价实的拷贝,这意味着同一个 Router 实例可以挂载到不同 prefix 下。
APIRouter 构造器里两个最常用的参数是 prefix 和 tags,但它们的职责完全不同,搞混了会很痛苦:
一个常见的最佳实践是让 prefix 和 tags 同名——比如 prefix="/books" 配 tags=["books"]。这样 URL 结构和文档分组天然对齐,前端对接时一眼就能定位到自己关心的端点组。
现在来看从应用启动到请求处理的全链路:
(图注:端点从装饰器到运行时的完整注册链路;handler 在运行时按协程/普通函数分流到不同执行位置。)
值得特别留意的是图中最后的那个分叉——async def 和 def。同一个路由表里两种形态可以共存,但它们的执行位置完全不同:
(图注:同一份路由表里,def 走线程池旁路,async def 留在事件循环主路——框架根据函数类型自动选择,不需要你手动配置。)
路径参数是另一个值得展开的点。你在路径模板里写的 {book_id} 只是一个占位符,FastAPI 在底层做了两件事:Starlette 的 compile_path 在启动时把模板编译成正则表达式;请求到达后,FastAPI 根据你的类型注解(比如 book_id: int)把提取到的字符串转成整数。如果客户端传了 abc 过来,框架直接返回 422,你一行校验代码都不用写。字符串类型的路径参数即使不加类型注解也能工作,但显式写上 : str 或 : int 会让 OpenAPI 文档更精确。
设计动机可以从三个角度看。关注点分离:图书、作者、借阅三个业务域各自一个 Router,Code Review 时一眼就知道改动影响面。文档可读性:Swagger UI 按 tags 分组折叠,而不是把所有端点堆成一堵墙。运行时分流:async def 和 def 的执行位置不同,选型直接影响并发模型——这不是语法糖,是架构决策。
代码实现:两个端点,两种形态,一个 Router
下面的代码把两个对照端点放进 app/api/health.py:GET /health 做纯 CPU 的 SHA256 计算,用 def;GET /books/count 是数据库计数占位,用 async def。两端点放在同一个文件纯粹是为了对照形态——实际工程中通常按业务域拆分。
# app/api/health.py
import hashlib
from fastapi import APIRouter
router = APIRouter(tags=["health"])
def _signature(payload: bytes) -> str:
"""纯 CPU 计算,Starlette 会把外层 def 路由函数自动放入线程池。"""
return hashlib.sha256(payload).hexdigest()
@router.get("/health")
def health() -> dict:
"""健康检查:纯计算,无 I/O,使用 def。"""
return {"status": "ok", "sig": _signature(b"ok")}
@router.get("/books/count")
async def book_count() -> dict:
"""占位:数据库计数端点,使用 async def(完整实现在 16 章)。"""
return {"count": 0}
然后是 main.py 的注册部分,极其简洁:
# main.py
from fastapi import FastAPI
from app.api.health import router as health_router
app = FastAPI(title="图书管理 API", version="0.1.0")
app.include_router(health_router)
几个容易被忽略的细节:
_signature是模块级辅助函数,不涉及任何 I/O。外层的health()handler 也是def,Starlette 会把它整个投递到线程池,事件循环完全不受影响。book_count虽然现在是占位实现,但它将来要await db.execute(select(func.count(Book.id)))——这个await只能在async def内部使用,所以现在就必须写成async def,避免将来改签名时连调用方一起动。app.include_router(health_router)没有传prefix,所以实际路径就是/health和/books/count。如果后续引入版本前缀,只需一行改动:app.include_router(health_router, prefix="/api/v1")。
这些坑我替你踩过了
async def不是性能开关。如果你把纯计算 handler 写成async def,它就在事件循环里跑,等同于阻塞事件循环——因为没有await让出控制权。反过来,把纯计算写成def,Starlette 扔进线程池,事件循环继续处理其他请求,并发能力反而更好。记住那条铁律:先看函数体里有没有可await的 I/O,再决定用哪种形态。def里别碰 aiomysql。线程池里跑的同步函数如果试图await一个协程,或者操作AsyncSession,会触发跨线程事件循环错误。数据库 handler 必须async def,通过第 13 章的 yield 依赖拿到异步 Session。response_model序列化时可能触发 lazy loading。即使 handler 是async def,如果你返回了 ORM 对象且没有设expire_on_commit=False,Pydantic 在序列化阶段访问未加载的关系字段时,会偷偷发出额外的 SELECT,把异步栈延迟优势抵消掉。这个配置在第 13 章会详细展开。路由挂载顺序有讲究。后
include_router的路由条目排在路由表靠后的位置。如果你有一个宽泛的/books/{book_id}和一个具体的/books/count,记得把更具体的路径对应的 Router 先挂载,避免/books/count被/books/{book_id}的模板误匹配。
面试 QA:把路由这件事聊透
Q1 [原理]: handler 应该写成 async def 还是 def?判别准则到底是什么?
核心判别只靠一条:函数体里有没有可 await 的调用。有(数据库异步 Session、httpx 异步客户端、asyncio.sleep 等)就必须是 async def;只有纯 CPU 计算或不可避免的同步阻塞调用(hashlib、没提供异步版的第三方 SDK)时用 def,框架会自动把同步函数投递到线程池。
但这里有一个关键补充:async def 里绝对不能直接调同步阻塞库。在协程里写个裸的 requests.get(...) 会让整个事件循环卡死,所有其他请求的 P99 延迟一起抖动。解决办法就两条:要么把整个 handler 改成 def,要么在调用点包上 await run_in_threadpool(...)。
落到本系列:数据库相关 handler 一律 async def(第 13 章起的数据库依赖以 async def 生成器形态提供,db.execute() 必须 await);/health 这类纯计算端点用 def,把 CPU 工作交给线程池,事件循环不空转。
Q2 [项目]: APIRouter 的 prefix 与 tags 的职责分别是什么?为什么推荐同名?
prefix 影响实际 HTTP 路径——prefix="/books" 让所有端点自动加上 /books 前缀。tags 只影响 OpenAPI 文档分组,不会改变 URL。
推荐同名的原因很简单:让 URL 结构和 Swagger 文档的折叠组天然对齐。前端对接方在 /docs 页面看到 “books” 这个 tag,展开后所有端点的路径都以 /books 开头,认知负担最小。
在图书 API 中,后期会拆成 books_router、authors_router、borrow_router 三个 Router,主应用分别挂载。如果引入多版本,再通过 include_router(router, prefix="/api/v1") 统一下沉一级——所有端点从 /books 变成 /api/v1/books,一行改动,全局生效。
小结
本篇用 APIRouter 把第一组端点从"散装"收进了有组织的路由器,并通过 /health(def)与 /books/count(async def)两个对照示例确立了 handler 形态的选型准则:数据库、网络、I/O 用 async def,纯 CPU 用 def。选型的关键不是"哪种写法更高级",而是"函数体里有没有需要让出事件循环的等待"。
下一篇《05 参数分类》将进入参数细节——Path、Query、Body 分别从哪里取数据,Annotated 怎么把类型和约束写在一起,以及校验失败时 FastAPI 如何自动返回 422。参数是接口契约的第一道防线,别错过。