K 的一隅

Python FastAPI 后端实战

包怎么拆才好改:FastAPI 项目结构

从单文件应用到包与模块拆分:应用工厂、APIRouter 与配置/模型/服务各放哪,import 路径如何保持清晰。

8 分钟阅读 更新于 2026-07-28
本文目录

教程里的 Todo API 往往一个 main.py 搞定;真正上线后,路由 会按业务域分成十几张表、几十个端点,测试还要 mock 数据库。若仍把模型、SQL、权限校验全堆在同一个文件里,改一个字段要在三千行里搜索——这不是 FastAPI 的错,是 边界没划清。

单文件应用的极限

最小可运行形态:

python
# main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/ping")
async def ping() -> dict[str, str]:
    return {"msg": "pong"}

够用来学 路由,不够用来协作。第一个信号是 循环 importmainservicesservices 又引 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)负责 应用 实例化,不写业务:

python
# 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路由 拆到 模块

python
# 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)
python
# 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 契约,字段可裁剪、只读字段不同)
python
# 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 路径上。开发时常见两种:

bash
# 在仓库根目录,shop 为包名
uvicorn shop.main:app --reload

# 或安装为可编辑包后任意目录启动
pip install -e .
uvicorn shop.main:app

pyproject.toml 里声明 packages = ["shop"] 后,测试与 IDE 解析一致,减少 ModuleNotFoundError

包内用绝对 import 为主:

python
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.pydb/URL、池大小

把高频改动限制在窄 模块 里,代码审查范围更小,合并冲突也更少。若每次改价格逻辑都要动 main.py,说明 服务 层还没抽干净。

测试目录与 import 一致

测试 建议镜像生产 名,而不是把测试全堆在根目录 tests/test_everything.py

tests/
  conftest.py          # db fixture、TestClient
  api/
    test_items.py
  services/
    test_item_service.py

conftest.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,避免 servicesapi 互相 import 只为一个数字。不要把仅某个 路由 用到的枚举塞进 global config——局部类型放在对应 schemasservices 模块 更清晰。

多团队协作时的包约定

两人以上后端协作时,书面约定三件事即可减少摩擦:

  1. 路由 必须挂到 api/router.py(或域子 router),禁止悄悄 @app.getmain
  2. 新增 Model 必须在 models/__init__.py export,保证 Alembic 能看见 metadata。
  3. Breaking API 变更走 /api/v2 ,不在 v1 悄悄改字段含义。

代码审查用 checklist 比事后争论「这个函数为什么写在 router 里」便宜。

从单文件迁移的渐进步骤

已有 main.py 两千行时,不必停机重写。可每周挪一块:

  1. 抽出 schemas/(无 import 副作用,最安全)。
  2. @app.get 挪到 api/v1/*.pymaininclude_router
  3. 把 SQL 与规则挪到 services/路由 留 HTTP 壳。
  4. 最后拆 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 片段:

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 schemasinclude_router 同时挂 /api/v1/api/v2 时,应用 仍是一个 FastAPI 实例,进程内共享 Engine服务 层——变的只是 HTTP 契约层。客户端迁移完毕再下线 v1 路由 模块,而不是删数据库

core/deps.py 将来会集中 get_dbget_current_user 等 Depends 入口;即使现在只有 get_db,也建议预留 模块,避免 路由 文件互相 import 依赖函数造成环。依赖的方向应始终是:路由 → deps → db/session,而不是 db → api。

静态资源、模板、邮件模板不应塞进 api/:它们不是 HTTP 路由,可放 assets/templates/ 或独立 。混淆「能 import 的 Python 模块」与「静态文件目录」会让新人误以为 api/images/logo.png 是 REST 资源——只有明确暴露的 /static 路由 才对外。

命名冲突时, 名不要与标准库或顶层第三方 模块 撞车(例如 emailjson)。仓库根目录名、PyPI 名、import 名三者尽量一致,减少「装了一个叫 shop 的包,import 却是 shop_api」的摩擦。

小结

应用 组装、路由、契约 模块、持久化分开;APIRouter 聚合 路由maininclude_router。结构为后续 SQLAlchemy、依赖注入、测试打底。下一篇进入数据库:Engine连接 怎么从配置里长出来。