FastAPI 项目开始变大后,如何用 APIRouter 拆分代码

摘要:当模型、存储和路由挤进同一个 main.py,先用测试固定行为,再通过 APIRouter、Python 包和清晰的依赖方向完成一次可验证重构。

把单文件应用拆成路由、模型和存储模块

图:单文件中的职责被整理成路由、数据模型和存储模块,外部接口保持不变。

单文件 FastAPI 在几十行时很舒服,到了 CRUD、认证和数据库一起出现时,修改一个模型却要在几百行代码里来回跳。问题不是文件长,而是不同变化原因混在了一起。

下面不新增任何接口,只做一次重构:任务模型、存储、路由和应用入口各回到自己的位置。重构前后仍然访问 /tasks,测试负责证明外部行为没有变化。

拆分以后,目录应该一眼能看懂

ch06_routers/
├── main.py
└── app/
    ├── main.py
    ├── schemas.py
    ├── store.py
    └── routers/
        └── tasks.py
fastapi dev examples/ch06_routers/main.py

原有 /tasks 接口继续工作,/docs 中任务接口归入 tasks 分组。

重构之前,先留下一条安全绳

本文的重构目标是保持外部行为不变。在动手修改代码之前,先用已有的测试确认旧版接口全部通过,这样重构后才能对比结果。

如果你是按顺序从上一篇读下来,可以直接运行上一章的 CRUD 接口测试:

cd 04-fastapi-beginner
source .venv/bin/activate
python -m pytest tests/test_ch05.py -q

如果你是从公众号单独阅读本文,请先完成本系列第 05 篇或直接进入示例目录确认代码可运行。

确认测试通过后,打开 示例目录 开始重构。

按变化原因拆出路由、模型和存储

创建任务路由器

router = APIRouter(prefix="/tasks", tags=["tasks"])


@router.post("", response_model=TaskRead, status_code=201)
def create_task(payload: TaskCreate):
    return store.add_task(payload.title)

路由装饰器从 @app 变为 @router。公共前缀只声明一次,标签会进入 OpenAPI 文档。

组装应用

from fastapi import FastAPI

from .routers import tasks

app = FastAPI(title="任务管理 API", version="0.6.0")
app.include_router(tasks.router)

入口只负责创建应用和组装模块;任务细节留在任务模块。

使用相对导入

from .. import store
from ..schemas import TaskCreate, TaskRead

两个点表示从 routers 返回上一级 app 包。使用模块方式启动应用,Python 才能正确理解包关系。

路由层、模型层和存储层按变化原因拆分

图:按职责拆分模块后,每一类变化都有清楚的落点。

接口地址没变,重构才算完成

fastapi dev examples/ch06_routers/main.py
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" -d '{"title":"拆分路由"}'
curl http://127.0.0.1:8000/tasks/1
curl http://127.0.0.1:8000/health

预期三个请求分别为 201、200、200。自动验证路由行为和 OpenAPI 标签:

python -m pytest tests/test_ch06.py -q

模块重构保持地址、响应和测试契约不变

图:重构改变的是内部组织方式,对外地址和响应契约应保持不变。

APIRouter 是路由集合,不是新服务

拆分依据是变化原因:schemas 随接口契约变化,store 随存储策略变化,router 随 HTTP 行为变化,main 随应用组装变化。目录不是越深越专业;只有当职责已经出现时才拆分。

APIRouter 可以理解为"尚未挂载到最终应用的路由集合"。include_router 把集合合入应用,最终客户端仍然只面对一个服务。

根目录的 main.py 只是教学启动入口,正式项目也可以在 pyproject.toml 声明 entrypoint。

包导入和 prefix 最容易出错

  • attempted relative import:直接运行包内文件;改用教程给出的 FastAPI 启动命令。
  • 忘记 include_router:代码存在但 /docs 没有路由。
  • prefix 两边重复 /tasks:最终地址变成 /tasks/tasks
  • 循环导入:schemas 不应反向导入 main;依赖方向应指向更稳定模块。
  • 为每个函数创建一个文件:导航成本会超过拆分收益。

再拆一个 system 路由试试

  1. 新增 routers/system.py,把 /health 移进去。
  2. 为所有任务路由统一添加 responses={404: ...} 文档。
  3. 故意移除 include_router,用测试定位失败。
  4. 画出 main、router、schemas、store 的导入方向,检查是否有环。

最后,用测试固定这次改动

手动请求通过后,运行与本文对应的自动化测试:

python -m pytest tests/test_ch06.py -q
python tests/validate_course.py

写在最后

路由拆分解决了文件职责问题,但分页、数据库会话和鉴权仍会在多个路由重复。下一篇使用 FastAPI 依赖注入表达这些公共前置条件。