本文目录
教程里的 Todo API 往往一个 main.py 搞定;真正上线后,路由 会按业务域分成十几张表、几十个端点,测试还要 mock 数据库。若仍把模型、SQL、权限校验全堆在同一个文件里,改一个字段要在三千行里搜索——这不是 FastAPI 的错,是 包 边界没划清。
单文件应用的极限
最小可运行形态:
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/ping")
async def ping() -> dict[str, str]:
return {"msg": "pong"}够用来学 路由,不够用来协作。第一个信号是 循环 import:main 引 services,services 又引 main 里的 app。第二个信号是测试困难:想只测「创建用户」却要 import 整个 应用 并副作用连数据库。
拆分目标:应用 组装层薄、业务 模块 可单独 import、路由 按域聚合。
推荐的包布局直觉
没有唯一标准,但社区常见骨架如下:
shop/
__init__.py
main.py # 创建 app,挂 router,lifespan
core/
__init__.py
config.py # 设置类,读环境变量
deps.py # Depends 公共依赖(后续篇展开)
api/
__init__.py
router.py # 汇总各 v1 子路由
v1/
__init__.py
items.py # 与 items 相关的路由
users.py
models/ # SQLAlchemy ORM(下一篇起)
__init__.py
item.py
schemas/ # Pydantic 请求/响应 DTO
__init__.py
item.py
services/ # 业务逻辑,不直接碰 Request/Response
__init__.py
item_service.py
db/
__init__.py
session.py # Engine / Session 工厂原则:
| 目录 | 放什么 | 避免什么 |
|---|---|---|
api/ | HTTP 路由、参数声明、调用 service | 直接写复杂 SQL |
schemas/ | Pydantic 模型,入参/出参 | 数据库 session |
models/ | ORM 映射 类 | FastAPI 特有类型 |
services/ | 业务规则、事务边界 | Request 对象 |
core/ | 配置、共享依赖 | 具体业务 |
模块 名用复数或单数保持一致即可,关键是团队能一眼找到「改接口去 api,改表去 models」。
应用工厂:main 只做组装
main.py(或 app.py)负责 应用 实例化,不写业务:
# shop/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from shop.api.router import api_router
from shop.core.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动:连池、预热;关闭:释放连接(后续 database 篇细化)
yield
def create_app() -> FastAPI:
app = FastAPI(title=settings.PROJECT_NAME, lifespan=lifespan)
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
return app
app = create_app()create_app() 便于测试里 from shop.main import create_app 后注入假依赖,而不污染全局单例。
APIRouter:路由按域拆分
不要把所有 @app.get 写在 应用 根上。用 APIRouter 把 路由 拆到 模块:
# shop/api/v1/items.py
from fastapi import APIRouter, status
from shop.schemas.item import ItemCreate, ItemRead
router = APIRouter(prefix="/items", tags=["items"])
@router.post("", response_model=ItemRead, status_code=status.HTTP_201_CREATED)
async def create_item(body: ItemCreate) -> ItemRead:
# 调用 item_service.create(...) —— 第 7 篇会强调薄路由
return ItemRead(id=1, name=body.name, price=body.price)
@router.get("/{item_id}", response_model=ItemRead)
async def get_item(item_id: int) -> ItemRead:
return ItemRead(id=item_id, name="demo", price=0.0)# shop/api/router.py
from fastapi import APIRouter
from shop.api.v1 import items
api_router = APIRouter()
api_router.include_router(items.router)include_router 可嵌套 prefix:settings.API_V1_PREFIX = "/api/v1" 时,完整路径为 /api/v1/items。换版本时加 v2 包,旧 路由 保留,客户端渐进迁移。
schemas 与 models 为什么要分开
同一个「商品」在系统里常有两个形状:
- ORM Model(数据库行,含关系、懒加载)
- Schema(API 契约,字段可裁剪、只读字段不同)
# shop/schemas/item.py
from pydantic import BaseModel, Field
class ItemCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
price: float = Field(ge=0)
class ItemRead(BaseModel):
id: int
name: str
price: float
model_config = {"from_attributes": True} # 允许从 ORM 对象构造混在一个类里会导致:对外暴露了 password_hash,或创建接口要求客户端传 id。分 模块 是成本最低的安全边界。
import 路径与运行方式
包 根目录要在 Python 路径上。开发时常见两种:
# 在仓库根目录,shop 为包名
uvicorn shop.main:app --reload
# 或安装为可编辑包后任意目录启动
pip install -e .
uvicorn shop.main:apppyproject.toml 里声明 packages = ["shop"] 后,测试与 IDE 解析一致,减少 ModuleNotFoundError。
包内用绝对 import 为主:
from shop.services.item_service import create_item相对 import(from ..schemas import ItemRead)仅在 包 内部 模块 间使用;脚本直接 python items.py 会失败——始终用 python -m 或 uvicorn 入口。
什么不必过早抽象
五人以下、接口个位数:两层(main + routers.py)即可,别先上 Clean Architecture 六层。等到出现「同一业务被 HTTP 和 CLI 共用」「单测必须 mock 数据库」再抽 services/ 与 db/。
也勿把每个函数都拆成单独文件——模块 几百行可控,按业务域分文件,不是按「一个函数一个文件」。
按变更频率分目录
维护项目时,不同文件的「被改原因」不一样:
| 变更原因 | 常动目录 | 典型改动 |
|---|---|---|
| 产品改接口契约 | api/、schemas/ | 增字段、改校验 |
| 业务规则调整 | services/ | 计价、权限 |
| 表结构变化 | models/、Alembic | 新列、索引 |
| 部署换库 | core/config.py、db/ | URL、池大小 |
把高频改动限制在窄 模块 里,代码审查范围更小,合并冲突也更少。若每次改价格逻辑都要动 main.py,说明 服务 层还没抽干净。
测试目录与 import 一致
测试 包 建议镜像生产 包 名,而不是把测试全堆在根目录 tests/test_everything.py:
tests/
conftest.py # db fixture、TestClient
api/
test_items.py
services/
test_item_service.pyconftest.py 里用 create_app() 构造 应用,并 override 数据库依赖,这样 API 测试不必连真实 Postgres。services 测试直接传 Session fixture,无需 HTTP——这与第 7 篇的分层一致。关键是测试 import 路径与业务代码相同:from shop.services.item_service import create_item,避免 sys.path 黑魔法。
配置、常量与「放 core」的边界
core/config.py 放环境相关项:数据库 URL、密钥、功能开关。跨 模块 的纯常量(分页默认上限、正则 pattern)可放 core/constants.py,避免 services 与 api 互相 import 只为一个数字。不要把仅某个 路由 用到的枚举塞进 global config——局部类型放在对应 schemas 或 services 模块 更清晰。
多团队协作时的包约定
两人以上后端协作时,书面约定三件事即可减少摩擦:
- 新 路由 必须挂到
api/router.py(或域子 router),禁止悄悄@app.get在main。 - 新增 Model 必须在
models/__init__.pyexport,保证 Alembic 能看见 metadata。 - Breaking API 变更走
/api/v2包,不在 v1 悄悄改字段含义。
代码审查用 checklist 比事后争论「这个函数为什么写在 router 里」便宜。
从单文件迁移的渐进步骤
已有 main.py 两千行时,不必停机重写。可每周挪一块:
- 抽出
schemas/(无 import 副作用,最安全)。 - 把
@app.get挪到api/v1/*.py,main只include_router。 - 把 SQL 与规则挪到
services/, 路由 留 HTTP 壳。 - 最后拆
db/与core/config.py。
每步保持测试绿灯,比一次性大重构更适合线上项目。旧 模块 可暂时 re-export 保持兼容:from shop.services.item_service import create_item as create_item_legacy。
常见误解
误解一:「FastAPI 官方规定了目录结构。」 没有;上面是社区惯例,可按团队改名。
误解二:「router 里 import models 就行,不必有 schemas。」 可以跑,但 ORM 对象泄漏到 JSON 响应时,循环引用、懒加载、敏感字段都难控。
误解三:「create_app 多余,全局 app 更简单。」 全局单例在测试并行、多配置环境(staging 配置)时会踩坑。
误解四:「schemas 和 models 字段永远一一对应。」 创建 DTO 常比读 DTO 字段少;更新 DTO 可能全可选。契约随用例变,不是数据库表的复印件。
pyproject.toml 与可安装包
把项目做成可安装 包 后,shop 在任何工作目录都能被 import。最小 pyproject.toml 片段:
[project]
name = "shop-api"
version = "0.1.0"
requires-python = ">=3.11"
[tool.setuptools.packages.find]
where = ["."]
include = ["shop*"]配合 pip install -e .,IDE 跳转、pytest、Uvicorn 启动共用同一路径解析,少踩「命令行能跑、测试找不到 模块」的坑。Monorepo 里后端只是子目录时,仍建议后端根目录独立成 包,不要依赖 sys.path.insert 临时 hack。
文档字符串与类型注解写在 schemas 和 路由 上,OpenAPI 会自动汇总;因此 包 结构清晰不仅为了 import,也为了让 /docs 里 tag 分组可读——tags=["items"] 与文件 items.py 对齐,前端同学找接口更快。
v1 / v2 与向后兼容
API 对外承诺一旦发布,改字段语义比删 路由 伤害更大。目录上用 api/v1/、api/v2/ 分 包,旧 模块 只修 bug 不加字段,新能力进 v2 schemas。include_router 同时挂 /api/v1 与 /api/v2 时,应用 仍是一个 FastAPI 实例,进程内共享 Engine 与 服务 层——变的只是 HTTP 契约层。客户端迁移完毕再下线 v1 路由 模块,而不是删数据库 表。
core/deps.py 将来会集中 get_db、get_current_user 等 Depends 入口;即使现在只有 get_db,也建议预留 模块,避免 路由 文件互相 import 依赖函数造成环。依赖的方向应始终是:路由 → deps → db/session,而不是 db → api。
静态资源、模板、邮件模板不应塞进 api/:它们不是 HTTP 路由,可放 assets/、templates/ 或独立 包。混淆「能 import 的 Python 模块」与「静态文件目录」会让新人误以为 api/images/logo.png 是 REST 资源——只有明确暴露的 /static 路由 才对外。
命名冲突时,包 名不要与标准库或顶层第三方 模块 撞车(例如 包 叫 email 或 json)。仓库根目录名、PyPI 名、import 包 名三者尽量一致,减少「装了一个叫 shop 的包,import 却是 shop_api」的摩擦。
小结
用 包 把 应用 组装、路由、契约 模块、持久化分开;APIRouter 聚合 路由,main 只 include_router。结构为后续 SQLAlchemy、依赖注入、测试打底。下一篇进入数据库:Engine 与 连接 怎么从配置里长出来。