FastAPI 路由拆分和依赖注入实践

后端最早只有一个 main.py,九个接口不到一百行看着很顺,接口一多就找不着了。我按功能拆成 routes 子文件,拆完跑不起来,因为 include_router 要 import 和注册两步都做。后面讲依赖注入除了拿数据库还能干什么。
单文件 main.py 是怎么爆炸的
我这个博客最早只有一个文件。后端 API 一共 9 个接口,路由定义加上依赖注入代码大概 100 行不到,看着很顺。
接口一多就出问题了。第一个是我想看某个接口时,必须在 main.py 里面上下滚屏找;第二个是有人要改后台登录那块,他得把我那 100 行读一遍,因为登录鉴权和小程序无关的接口堆在一起。
我第一次拆分了,按功能拆。后台、前台、内容管理,每一块放进一个 routes/ 子文件。但跑起来报错 include_router 找不到,调了半天才想起新建的文件要在 main.py 里 import + include_router 两步都加,漏一处就是 404。这个疏忽走了三遍我才养成肌肉记忆。
真拆路由的样子
后来稳定下来的结构是:routes/posts.py、routes/admin.py、routes/site.py。每个文件只声明一个 APIRouter,对外暴露不带前缀的接口,回主入口挂前缀。
# backend/app/api/routes/posts.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/list")
def list_posts():
return {"items": []}
@router.get("/detail")
def detail(slug: str):
return {"slug": slug, "ok": True}注册时机统一在 main.py 里。
from fastapi import FastAPI
from .api.routes.posts import router as posts_router
from .api.routes.admin import router as admin_router
app = FastAPI()
app.include_router(posts_router, prefix="/api/blog/posts", tags=["posts"])
app.include_router(admin_router, prefix="/api/blog/admin", tags=["admin"])这样做的好处是改一个模块的人只动一个文件,不会顺手把不相关的代码改了。后端调试时,框架打日志也能看出是哪一块路由出问题。
依赖注入不只用来拿数据库
我一开始以为依赖注入就是用来拿数据库 session 的,写完发现它另一个用法更值钱:把通用鉴权做成一个依赖,统一挂在需要登录的接口上。
from fastapi import Depends, Header, HTTPException
from ..services.session_service import get_session
def require_admin(authorization: str = Header(default="")):
token = authorization.replace("Bearer ", "", 1).strip()
session = get_session(token)
if not session or session.get("role") != "admin":
raise HTTPException(status_code=401, detail={"code": "AUTH_REQUIRED"})
return session挂法有两种。一种是接口级:
@router.post("/backup/create", dependencies=[Depends(require_admin)])
def create_backup():
return {"ok": True}另一种是路由级,整组接口一起保护:
router_admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])我选后者,因为我的后台接口全部都得登录,逐个挂麻烦,忘一处就是漏洞。FastAPI 的设计最贴心一处是:依赖函数可以嵌套。我把 require_admin 里调用了一个 get_db 依赖拿 session,框架帮我把这件事的拓扑自动解开了。
这事最怕的几个坑
一个权限相关的依赖千万别抛错不带 detail。我第一次写鉴权时,为了图省事直接 raise HTTPException(401),前端拿到的是空字符串的 message,提示框里写着「未知错误」,用户看不懂。前端拦截器拿不到结构化错误,就只能猜。
第二个坑是依赖函数自己开了事务却忘记关闭。FastAPI 默认不会替你自动开事务,但如果依赖里用了 Depends(get_db) 这种全局连接池,要确认 session 在请求结束后归还。我用 @contextmanager 包的依赖代替手动 try/finally,问题就消失了。
第三个坑是依赖函数里调了阻塞 IO 而接口又没声明异步。后端接口是 def,依赖里却跑去 requests.get 同步调一个外部服务,结果这个请求把整个事件循环卡住。我后来约定:同步接口只能用同步依赖,阻塞 IO 用线程池跑;异步接口里调同步依赖 FastAPI 自己会丢到线程池,这点对新人是个常见的认知盲区。
最后一次踩坑是测试时遇到的。依赖里如果从环境变量读密钥,测试代码忘了设环境变量直接抛错,错误栈又长又难看。我后来把环境变量读取放在 Settings 实例里,测试开始前 patch 掉,比每个测试函数自己手动 os.environ 干净。
路由边界和业务边界别混
我见过有人把数据库写入逻辑直接写进路由函数里:def save_post(payload): db.insert(payload)。这样路由文件变成了数据库操作手册,一旦换存储实现,一堆文件都得改。
我学到的分层是这样的:路由层只接参数、做参数校验、调 service;service 层做业务规则;repository 层做数据读写。三层之上一是替代数据库时只动 repository,改接口协议时只动路由,业务逻辑单独走 service。
分层后还有个意外的好处:测试时不再需要启动整个 FastAPI 应用,只测 service 即可,比之前起整个进程再 curl 接口快几个量级。
现在我再加新接口的步骤
新加一个后台接口,先打开 routes/admin.py,看新接口属于哪一类操作,从现有依赖函数里挑一个或写一个新的。@router.post(...) 一行写完接口签名,主体只调 service。再补一个 pytest 跑这个 service 函数的单元测试。这样每个接口的新增成本是可预期的,不会越加越乱。