
刚接手一个FastAPI项目的时候我差点被那个一千多行的main.py劝退。所有接口、所有依赖、所有路由全部堆在一个文件里改一处全局变量的引用都要提心吊胆加了几个接口之后整个文件已经没人敢动了。后来被带我的老哥按着头学了include_router做路由分发才算是真正摸到了FastAPI工程化的门。这几年带项目、看别人的代码我自己也总结了不少路由分发的实战经验这篇就一次性把include_router讲透。如果你刚开始写FastAPI接口或者正在看别人项目里那种一眼望不到头的main.py这篇值得你花十分钟读完。它解决的核心问题只有一个当你的接口数量超过二三十个时怎么能让路由注册这件事变得清晰、可控、可维护。对应的核心工具就是FastAPI自带的APIRouter和app.include_router。1. 为什么要做路由分发从一坨路由到APIRouter先说一个我在实际团队里见过的真实场景。同事把八十多个接口全部写在main.py里每个接口都有自己的装饰器、依赖、响应模型乍一看功能都能跑但只要业务一扩张问题就全来了新人接手根本不知道某个接口应该改哪个文件只能在main.py里疯狂搜索。多人协作时每个人改自己的模块都会碰到同一个文件Git冲突没完没了。接口没有任何模块边界Swagger文档里全部挤在一个分组前端对接的人看着都头疼。想给整个用户模块统一加一个鉴权依赖你得跑到每个接口装饰器里去改漏一个就是安全隐患。这就像你租了一个单间衣服、鞋子、杂物全往地上堆。刚开始住着还行东西一多你连下脚的地方都没有。APIRouter干的事就是给你的项目装几个独立衣柜把用户相关、订单相关、商品相关的东西各归各位。1.1 APIRouter本质上是什么APIRouter是FastAPI提供的路由分组工具。你可以把它理解成一个待注册的路由集合。在模块文件里你创建自己的APIRouter实例然后像用app.get、app.post一样用router.get、router.post去注册接口。但这些接口不会立刻生效它们只是先挂在这个router对象上。真正让这些路由生效的就是app.include_router()。调用这个方法时FastAPI会把router里所有已经登记好的路径操作一条一条拆出来合并到主应用的路由表里。注意这是追加和合并不是替换和覆盖。这个细节非常关键后面很多坑都是从这儿来的。举个例子from fastapi import APIRouter router APIRouter(prefix/users, tags[用户]) router.get(/list) def list_users(): return {message: user list}这个文件里定义的/list在未注册时访问一定是404。只有当你把它include进主应用它才会真正对外暴露from fastapi import FastAPI from app.api.v1 import users app FastAPI() app.include_router(users.router)这时的访问路径就是/users/list。1.2 对比直接写在main.py里的区别直接写在main.py里的写法是app.get(/users/list)功能上也能跑但代价是所有的路由决策都集中在了一个文件里。你无法独立测试某个模块无法单独加载某个模块无法在多个版本API之间轻松切换。include_router给了你一把手术刀每个模块的接口定义、依赖、标签、前缀全部封装在模块自己的router里主应用只需要做聚合注册。更重要的是这种设计天然支持了多人协作的分工边界——你维护你的user模块我维护我的order模块大家各自在自己的文件里改代码互不越界。如果你用过Flask这个思路跟Blueprint非常像。如果你用过Django可以把它类比成Django的urlconf模块化拆分。FastAPI把这种成熟的路由组织方式做成了一个非常轻量的API用起来几乎没有任何额外负担。2. 项目目录结构照着抄就能用的FastAPI分层方案光说不练假把式。路由分发不是只在一个文件里import一下那么简单它需要配合一套合理的目录结构才能发挥最大效果。下面这个目录结构是我在多个项目中反复调整后觉得最顺手的一版特别适合中小型团队和快速发展的业务。project/ ├── main.py # 应用入口创建FastAPI实例统一注册路由 ├── app/ │ ├── __init__.py │ ├── api/ │ │ ├── __init__.py │ │ └── v1/ │ │ ├── __init__.py # 聚合v1版本所有路由模块 │ │ ├── users.py # 用户模块全部接口 │ │ ├── orders.py # 订单模块全部接口 │ │ ├── products.py # 商品模块全部接口 │ │ └── dashboard.py # 后台报表模块 │ ├── core/ │ │ ├── config.py # 配置项 │ │ └── security.py # 鉴权依赖、安全相关工具 │ ├── models/ # SQLAlchemy ORM模型 │ ├── schemas/ # Pydantic请求/响应模型 │ └── services/ # 业务逻辑层2.1 为什么是api/v1这个层级很多初学者看到v1这个目录会问为什么多套一层答案是为了API版本管理。线上产品迭代到一定阶段总会有接口需要升级。你大改了一个响应的数据结构但老的移动端App还没升级不能直接把/v1的接口改掉。有了这一层你只需要在api目录下新增一个v2目录把新版模块放进去然后在主应用里用不同的prefix分别注册完美做到新旧版本共存app.include_router(v1_router, prefix/api/v1, deprecatedTrue) app.include_router(v2_router, prefix/api/v2)注意这里deprecatedTrue的效果往下会细说。2.2 每个环节的职责边界main.py只做三件事创建FastAPI实例、加载配置、include路由。它不关心某个接口的逻辑怎么写的不关心数据库ORM模型长什么样它只做路口调度。app/api/v1下面的每个模块文件比如users.py只负责一个业务领域的路由定义。你在这个文件里可以创建多个APIRouter但通常一个文件一个模块就够了。如果一个模块接口特别多比如orders模块下有订单、订单明细、退货单你还可以在这个模块下再分子模块往下我会演示嵌套include_router怎么做。core/security.py放的都是跨模块通用的依赖比如登录态校验、权限校验、请求签名校验。这些依赖可以作为参数传到APIRouter里也可以放到include_router里实现模块级统一管控。2.3 聚合路由的好习惯这里分享一个我特别推荐的小实践在api/v1/init.py里做路由聚合。# app/api/v1/__init__.py from fastapi import APIRouter from app.api.v1 import users, orders, products, dashboard v1_router APIRouter() v1_router.include_router(users.router) v1_router.include_router(orders.router) v1_router.include_router(products.router) v1_router.include_router(dashboard.router)这样main.py里只需要一行注册整个v1版本就全进来了from app.api.v1 import v1_router app.include_router(v1_router, prefix/api/v1)好处很明显未来v1再新增模块只需要在v1的__init__.py里加一行main.py永远不用频繁改动。模块的组装逻辑被集中到了版本聚合文件里职责非常清晰。3. include_router核心参数逐项拆解include_router看着就是一个简单的注册方法但它的参数信息量非常大。很多人只用了第一个参数router其他参数一概没碰这其实是把最方便的能力浪费了。我们逐个过一遍。3.1 prefix统一URL前缀这是最常用的参数。它的完整语义是给当前router里所有已经存在的路径在前面追加一段前缀。这样说可能有点抽象。我们先看一个具体组合子模块里定义了prefix# app/api/v1/users.py router APIRouter(prefix/users, tags[用户]) router.get(/list) def list_users(): return {message: user list}主应用注册时又加了一层app.include_router(users.router, prefix/api/v1)那么最终暴露出来的路径是/api/v1/users/list。也就是说include_router里的prefix是在原有路径前面再拼接一层不是把原来定义的prefix覆盖掉。这个追加语义很多人一开始都理解错了。最常见的翻车现场就是子路由里已经写了prefix/usersinclude_router时以为是覆盖又写了prefix/api/v1/users结果实际路径变成了/api/v1/users/users一访问全404。那么prefix到底写在APIRouter创建时还是include_router时我的习惯是模块内的业务前缀写在APIRouter里版本前缀和全局分组前缀写在include_router里。比如/users是业务前缀/api/v1是版本前缀。职责分明不会乱。还有一个小技巧prefix是支持路径参数的。比如多租户SaaS系统每个租户有独立的访问上下文你可以这样写app.include_router(users.router, prefix/{tenant_id}/api/v1)这样/abc123/api/v1/users/list里那串租户ID就会自动变成路径参数tenant_id代码里可以直接取用。3.2 tagsSwagger文档分组FastAPI自动生成的Swagger文档之所以能按模块清晰分组靠的就是tags。但如果include_router时不传tags分组信息会沿用子路由APIRouter上定义的tags。如果子路由没定义接口就全部跑到默认分组里去了整个文档页看起来非常乱。需要特别注意的是include_router里如果传了tags会整体替换掉子路由原来定义的tags不是合并。假设你的子路由tags写的是[用户模块]include时传了[用户管理]最终文档里用的就是[用户管理]。实战中我更推荐把tags定义在子模块的APIRouter里这样模块文件自身就带有文档分组信息include时不去覆盖。这样每个模块文件自解释程度高任何人打开这个文件就知道这批接口对外展示在哪个分组。3.3 dependencies模块级统一依赖注入这是include_router最强大的参数之一它让你能够为一个模块下所有接口统一注入依赖。比如用户中心所有接口都需要校验登录态你不需要在每个接口函数里都写一遍Depends(verify_token)只需在include时加一个模块级的dependenciesfrom fastapi import Depends from app.core.security import verify_token app.include_router( users.router, prefix/api/v1, dependencies[Depends(verify_token)], )这样用户在访问该模块下任何接口时都会先执行verify_token。如果校验失败抛HTTPException接口直接返回401或403后面的业务逻辑根本不会执行。同样如果你在子路由APIRouter创建时也定义了dependencies两者是共同生效的。注意顺序问题include_router的依赖会追加到子路由已有依赖之后一般建议不要写依赖执行顺序敏感的逻辑毕竟跨模块后顺序容易变得不直观。还有一个非常容易踩的坑dependencies列表里的每一项必须用Depends()包裹完整。我见过有人写成这样dependencies[verify_token] # 错误写法这种写法FastAPI不会按依赖去解析运行时会报错或者压根不执行。正确写法是dependencies[Depends(verify_token)]3.4 include_in_schema和deprecatedAPI文档控制这两个参数虽然不算高频但用好了非常实用。include_in_schemaFalse可以让这组路由不出现在OpenAPI/Swagger文档里。典型的场景是内部调试接口、灰度测试接口或者你还没打算让前端对接的半成品接口。注册方式就是在include_router时加一个参数app.include_router(debug_router, prefix/debug, include_in_schemaFalse)deprecatedTrue则是在保持接口可访问的前提下在Swagger文档里打上一个已废弃的标识提示调用方尽快迁移app.include_router(v1_router, prefix/api/v1, deprecatedTrue)这个在接口版本迁移时特别好用。你已经上线了v2但v1的老接口还在给旧客户端用那就给它打上deprecated标记既不破坏现有调用又明确了它的生命周期状态。4. 实战代码从单模块到多版本路由注册讲完参数我们用一套完整的示例把这些串起来。这个示例模拟一个电商系统的用户和订单模块包含了模块定义、主应用注册、嵌套路由、多版本管理几个完整环节。4.1 子路由模块定义首先是用户模块# app/api/v1/users.py from fastapi import APIRouter, Depends from app.core.security import verify_token router APIRouter( prefix/users, tags[用户模块], dependencies[Depends(verify_token)], ) router.get(/list) def list_users(): return {message: user list} router.get(/me) def get_me(): return {message: current user} router.get(/{user_id}) def get_user(user_id: int): return {message: fuser {user_id}}然后是订单模块。订单模块里我再拆一个订单明细子模块用来演示嵌套路由# app/api/v1/order_detail.py from fastapi import APIRouter router APIRouter( prefix/detail, tags[订单明细], ) router.get(/list) def list_order_detail(): return {message: order detail list}# app/api/v1/orders.py from fastapi import APIRouter from app.api.v1 import order_detail router APIRouter( prefix/orders, tags[订单模块], ) router.get(/list) def list_orders(): return {message: order list} # 嵌套子路由 router.include_router(order_detail.router)这里你可能会问子模块也能用include_router吗当然可以。include_router本来就是APIRouter上的方法不是FastAPI实例专用。当主应用include_router(orders.router)时订单模块下面的订单明细子路由也会被一并收集注册你不需要在主应用里单独再include一次order_detail。这就是模块内部再拆分的实现方式。4.2 主应用注册聚合# main.py from fastapi import FastAPI from app.api.v1 import v1_router, v2_router app FastAPI(title电商订单系统, version2.0.0) # 注册v1版本标记为废弃但保留可用 app.include_router(v1_router, prefix/api/v1, deprecatedTrue) # 注册v2版本 app.include_router(v2_router, prefix/api/v2)注意这里v1_router来自app/api/v1/init.py里的聚合对象v2_router同理。最终实际可访问路径对照如下模块定义include_router前缀最终访问路径router(prefix/users) router.get(/list)prefix/api/v1/api/v1/users/listrouter(prefix/orders) router.get(/list)prefix/api/v1/api/v1/orders/listrouter(prefix/detail) router.get(/list)嵌套在orders下prefix/api/v1/api/v1/orders/detail/list4.3 注册后核对路由的小技巧每次include完一大堆路由怎么确认最终路径符合预期我有个习惯写一个简单的脚本或者直接在调试时打印一下所有路由。# debug_routes.py from app.main import app for route in app.routes: if hasattr(route, methods): print(route.path, sorted(route.methods))输出结果类似/api/v1/users/list [GET] /api/v1/users/me [GET] /api/v1/users/{user_id} [GET] /api/v1/orders/list [GET] /api/v1/orders/detail/list [GET]我实测过很多次路径拼接出问题的情况用这一招可以秒排查。你在启动应用之前跑一遍这个脚本对最终暴露了哪些接口心里就有底了。4.4 版本聚合文件写法上面用到了v1_router和v2_router这里给出v1聚合文件的完整参考# app/api/v1/__init__.py from fastapi import APIRouter from app.api.v1 import users, orders, products v1_router APIRouter() v1_router.include_router(users.router) v1_router.include_router(orders.router) v1_router.include_router(products.router)v2目录结构一样只是业务代码可能是新的实现。主应用里只include两个聚合router就算你的项目接口数量破百main.py也依然是那几行干净得像刚擦过的白板。5. 常见问题与排查实录路由分发这块网上教程讲参数的多讲实际踩坑的少。我自己在项目里遇到过不少看起来玄学、实际上原因很明确的问题整理成一个速查表你们直接对照着排查。现象可能原因解决办法接口访问404子路由没有被include_router到主应用确认main.py里是否注册了对应router接口访问404prefix路径拼接错误比如出现了双斜杠统一前缀不带末尾斜杠路径开头带斜杠路径变成/api/api/usersinclude_router里的prefix写成了完整业务前缀和子路由prefix重复叠加只在一处定义业务前缀include时只写版本前缀/users/me被/users/{user_id}吃掉返回422静态路径在动态路径之后定义被通配捕获把静态路由放在动态路由之前Swagger文档分组混乱子路由和include_router都没有传tags统一在模块APIRouter里定义tags接口没有走鉴权逻辑dependencies写成dependencies[verify_token]漏了Depends()改为dependencies[Depends(verify_token)]新加的模块接口全部404聚合文件__init__.py忘了include新模块在v1_router里补上聚合注册5.1 路径双斜杠和prefix冗余这可以说是路由分发里最常见的坑。很多人习惯写prefix/users/然后在子路由里写router.get(/list)结果FastAPI拼出来就是/users//list有些情况下能访问有些情况下在Swagger文档里显示一个奇怪的路径。更糟的是include_router再叠一个prefix/api/v1/最终变成/api/v1//users//list排查起来让人抓狂。我的规范就三条APIRouter的prefix不写末尾斜杠路径操作装饰器的路径一律以斜杠开头include_router追加的前缀不写末尾斜杠。按这三条来路径拼接每次都是单一斜杠不会出幺蛾子。5.2 动态路由与静态路由的顺序/users/me和/users/{user_id}同时存在时如果你把动态路由写在前面访问/users/me时{user_id}会匹配到me然后因为int类型转换失败返回422 Validation Error。这不是FastAPI的bug而是路由按注册顺序匹配的机制。解决方案很简单把静态路由放在动态路由前面定义。router.get(/me) def get_me(): return {message: current user} router.get(/{user_id}) def get_user(user_id: int): return {message: fuser {user_id}}这个坑在include_router模式下更容易出现因为多个模块合并后路由的注册顺序被隐藏在了聚合文件里开发者对全局顺序的感知会变弱。5.3 循环导入问题当你的目录结构变复杂后很容易出现循环导入。典型场景是app/api/v1/users.py里from app.core.security import verify_token而app/core/security.py又可能引用app.api里的某个内容。Python的循环导入有时候并不会立刻报错而是等某个模块被访问时才抛ImportError非常迷惑。经验法则依赖方向应该是单向的。api模块依赖corecore不反向依赖api。如果确实需要core里访问某个业务函数优先考虑通过参数传入、回调、或者把公共逻辑下沉到services层而不是直接在两个模块文件里互相import。5.4 排查方法论遇到路由相关的问题我一般按三个步骤排查先打印app.routes看最终路由表再看Swagger文档里的路径是否与预期一致最后看服务端日志里的404或422详情。90%的路由问题都能在前两步解决不需要Debug到业务代码层。6. include_router的进阶玩法基础的注册你学会了接下来这几个进阶用法能让你在项目里把include_router用得更加得心应手。6.1 不同模块不同鉴权策略真实项目里不是所有模块都使用同一套鉴权。比如用户中心的接口只需要登录态校验但后台管理的接口还需要校验管理员权限。这时候可以用include_router的dependencies做差异化配置。# 用户中心只要登录 app.include_router( users.router, prefix/api/v1, dependencies[Depends(verify_token)], ) # 后台管理登录 管理员权限 app.include_router( dashboard.router, prefix/api/v1, dependencies[Depends(verify_token), Depends(verify_admin)], )如果你直接把依赖写死在子路由APIRouter里切换版本、调整权限的时候就得改模块文件。把权限策略交给include_router模块的复用性和灵活性就大大提升了。这一点在面试问到FastAPI项目架构时也很加分。6.2 按需注册做功能开关include_router是运行时注册的意味着你可以根据配置决定哪些模块对外暴露。这在灰度发布、功能开关场景下非常实用。from app.core.config import settings if settings.enable_orders: app.include_router(orders.router, prefix/api/v1) if settings.enable_new_dashboard: app.include_router(dashboard_v2.router, prefix/api/v1) else: app.include_router(dashboard.router, prefix/api/v1)某模块还在开发中、不想让它出现在Swagger里就先不include等接口稳定了再放出来。这个用法对前后端并行开发特别友好。6.3 利用单个路由对象做隔离测试include_router的模块化特性让我在写测试时可以只加载被测模块不依赖整个应用。具体是这样# tests/test_users.py from fastapi import FastAPI from fastapi.testclient import TestClient from app.api.v1 import users def build_users_app(): app FastAPI() app.include_router(users.router, prefix/api/v1) return app def test_user_list(): client TestClient(build_users_app()) resp client.get(/api/v1/users/list) assert resp.status_code 200 assert resp.json() {message: user list}这样测试的边界很清晰我只测users模块的路由不需要启动整个项目也不需要被其他模块的依赖干扰。如果users模块的业务逻辑依赖数据库再单独mock数据源即可。6.4 动态子域名或租户隔离的路径设计前面说过prefix支持路径参数这里展开说一个真实场景。假设你是多租户SaaS系统不同租户调用的是同一套接口但数据必须严格隔离。你用include_router设计路径时可以这样app.include_router( users.router, prefix/{tenant_id}/api/v1, )然后在接口函数里直接声明路径参数tenant_idrouter.get(/list) def list_users(tenant_id: str): return {message: f当前租户: {tenant_id}}这种玩法等于把租户上下文注入到了模块下的每一个接口里。当然这只是一种设计思路实现时还要配合鉴权中间件确认租户权限但对于路由层面的支持include_router已经给得很全面了。最后再分享一点个人经验我用include_router最大的感受是它不是高阶技巧而是FastAPI项目的基础设施。从项目第一行代码开始就应该按照模块划分路由文件而不是等到代码量大了再来做重构。我见过不止一个团队寄希望于后面再整理路由结果后面永远没有时间main.py就像滚雪球一样越滚越大最终谁都不想去碰那个文件。项目里如果接口量不大比如十几个放在一个文件里还能忍超过二十个真的建议立刻拆。拆完你会明显感觉到模块A的接口改坏了影响不到模块B新同事接手只需要打开对应的模块文件比翻一堆注释和TODO高效太多。最后一个小技巧送给你每次调整完include_router之后用那个打印app.routes的小脚本把全部路径过一遍。我习惯把它们复制到接口清单文档里每次发版前对照一遍确认本次新增或变更的接口路径、方法都正确。这一套流程虽然简单但帮我在线上少出了很多次丑。