FastAPI系列-04-路由

FastAPI系列-04-路由

_

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 defdef 两种 handler 形态可以在同一个 Router 下和平共处,框架会根据函数是协程还是普通函数自动分流到事件循环或线程池。

本篇会完成两件事:用 APIRouter 把图书管理 API 的第一组端点按业务域拆分,然后通过 /health(纯计算)和 /books/count(数据库占位)两个对照示例,让你彻底看清 async defdef 的选型逻辑。

原理解析:从装饰器扫描到请求分流的完整链路

路由的本质,是把"HTTP 方法 + 路径模板"映射到一个 Python 函数。FastAPI 把这个映射按 APIRouter 维度组织,每个 Router 内部维护自己的端点列表。当 app.include_router(router) 被调用时,这些端点被拷贝进主应用的路由表——不是引用,是货真价实的拷贝,这意味着同一个 Router 实例可以挂载到不同 prefix 下。

APIRouter 构造器里两个最常用的参数是 prefixtags,但它们的职责完全不同,搞混了会很痛苦:

参数

影响范围

典型用法

prefix

实际的 HTTP 路径

prefix="/books" 让所有端点路径自动加上 /books 前缀

tags

OpenAPI 文档分组

tags=["books"] 让 Swagger UI 把相关端点收进同一个折叠组

一个常见的最佳实践是让 prefixtags 同名——比如 prefix="/books"tags=["books"]。这样 URL 结构和文档分组天然对齐,前端对接时一眼就能定位到自己关心的端点组。

现在来看从应用启动到请求处理的全链路:

flowchart TD A["启动期:装饰器扫描"] --> B["APIRouter 端点列表"] B --> C["app.include_router"] C --> D["FastAPI 路由表(扁平化)"] D --> E["请求进入"] E --> F{"路径前缀匹配?"} F -- "否" --> G["404 Not Found"] F -- "是" --> H["解析路径参数"] H --> I["调用依赖"] I --> J{"handler 形态?"} J -- "async def" --> K["事件循环内 await"] J -- "def" --> L["线程池 run_in_threadpool"] K --> M["序列化响应"] L --> M

(图注:端点从装饰器到运行时的完整注册链路;handler 在运行时按协程/普通函数分流到不同执行位置。)

值得特别留意的是图中最后的那个分叉——async defdef。同一个路由表里两种形态可以共存,但它们的执行位置完全不同:

flowchart TD A["启动期:装饰器扫描"] --> B["APIRouter 端点列表"] B --> C["app.include_router"] C --> D["FastAPI 路由表(扁平化)"] D --> E["请求进入"] E --> F{"路径前缀匹配?"} F -- "否" --> G["404 Not Found"] F -- "是" --> H["解析路径参数"] H --> I["调用依赖"] I --> J{"handler 形态?"} J -- "async def" --> K["事件循环内 await"] J -- "def" --> L["线程池 run_in_threadpool"] K --> M["序列化响应"] L --> M

(图注:同一份路由表里,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 defdef 的执行位置不同,选型直接影响并发模型——这不是语法糖,是架构决策。

代码实现:两个端点,两种形态,一个 Router

下面的代码把两个对照端点放进 app/api/health.pyGET /health 做纯 CPU 的 SHA256 计算,用 defGET /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 [项目]: APIRouterprefixtags 的职责分别是什么?为什么推荐同名?

prefix 影响实际 HTTP 路径——prefix="/books" 让所有端点自动加上 /books 前缀。tags 只影响 OpenAPI 文档分组,不会改变 URL。

推荐同名的原因很简单:让 URL 结构和 Swagger 文档的折叠组天然对齐。前端对接方在 /docs 页面看到 “books” 这个 tag,展开后所有端点的路径都以 /books 开头,认知负担最小。

在图书 API 中,后期会拆成 books_routerauthors_routerborrow_router 三个 Router,主应用分别挂载。如果引入多版本,再通过 include_router(router, prefix="/api/v1") 统一下沉一级——所有端点从 /books 变成 /api/v1/books,一行改动,全局生效。

小结

本篇用 APIRouter 把第一组端点从"散装"收进了有组织的路由器,并通过 /healthdef)与 /books/countasync def)两个对照示例确立了 handler 形态的选型准则:数据库、网络、I/O 用 async def,纯 CPU 用 def。选型的关键不是"哪种写法更高级",而是"函数体里有没有需要让出事件循环的等待"。

下一篇《05 参数分类》将进入参数细节——PathQueryBody 分别从哪里取数据,Annotated 怎么把类型和约束写在一起,以及校验失败时 FastAPI 如何自动返回 422。参数是接口契约的第一道防线,别错过。

FastAPI系列-05-参数分类 2026-06-16
FastAPI系列-03-同步与异步 2026-06-12

评论区