1.2 按业务领域组织代码
按业务领域组织代码
上一节的单文件版本很适合确认接口契约,但当用户、文章和外部服务同时出现时,按 routers/、services/、schemas/ 分成几大目录,未必能让修改范围更清楚。修改文章标题规则时,开发者可能要在多个技术目录之间来回查找;同名的 service.py 还容易被误导入。
这里采用按业务领域组织的最小版本:文章相关的路由、接口模型和业务读取逻辑放在 src/posts/,应用入口保留在 src/main.py。这沿用了参考资料对领域包的核心建议,但没有为了“完整结构”提前创建数据库模型、配置、异常和工具文件。文件只有在承担真实职责时才值得出现。
什么时候按领域拆分
两种目录方式都可以成立。小型服务只有一个领域、几条路由时,单文件或按技术类型集中存放都容易理解;当一个单体应用有多个领域,并且每个领域都有自己的路由、数据规则和业务操作时,领域包能把相关变化聚在一起。它不是微服务拆分,也不会自动解决依赖关系,更不是每个项目都必须照搬的固定树形。
本节的结构如下:
ch01_modules/
├── check.py # 当前示例的可运行检查
└── src/
├── __init__.py
├── main.py # 组装应用、注册路由
└── posts/
├── __init__.py
├── router.py # HTTP 路由和状态码
├── schemas.py # 对外输入/输出模型
└── service.py # 文章读取规则和临时数据
从目录名可以直接看出,posts 是业务边界。main.py 只负责组装;router.py 把 HTTP 请求转换成服务调用,并把“没有文章”转换成 404;service.py 不依赖 FastAPI,因此以后换成数据库实现时,路由的职责仍然清楚;schemas.py 描述接口传输的数据形状。
可以用一次具体需求来判断修改位置。假设产品要求“列表只展示已发布文章,标题最长 80 个字符”:如果只是调整标题允许的长度,改 posts/schemas.py 的字段约束;如果是根据发布状态筛选已有数据,改 posts/service.py 的查询规则;只有 URL、查询参数或状态码发生变化时,才改 posts/router.py。本节的内存文章还没有 status 字段,所以不为了演示而偷偷添加它;真正接到该需求时,应先确认数据契约,再让对应层承担变化。
数据库模型和接口模型不是一回事
本节暂时没有数据库,所以不创建 models.py。将来接入 SQLAlchemy 时,models.py 会描述表、列、外键和关系;schemas.py 仍然描述客户端可以提交或看到的字段。两者可能共享字段名,但它们的变化原因不同:数据库迁移需要关注存量数据和约束,接口模型需要关注兼容性和信息暴露。
例如,文章表可能有内部的创建时间、软删除标记或审计字段,PostResponse 未必应该全部返回;客户端提交的 PostCreate 也不应该允许直接填写服务端决定的 author_id。现在先用一个响应模型表达最小只读契约,创建与更新模型会在数据契约章节再展开。
创建重构后的完整文件
下面的代码都属于一个新目录 ch01_modules/。不要把它们和上一节的 ch01_start/main.py 混在同一目录,也不要同时从两个目录导入名为 main 的模块。最稳妥的运行方式是进入 ch01_modules,以 src.main:app 指定应用。
"""第一章领域模块练习项目。"""
from fastapi import FastAPI
from .posts.router import router as posts_router
app = FastAPI(title="文章管理 API(领域模块版)")
app.include_router(posts_router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
入口文件显式导入 posts_router 并注册它。这样新增领域时,入口只增加一条组装语句,文章的具体规则不会被复制到入口中。
"""文章领域。"""
from pydantic import BaseModel
class PostResponse(BaseModel):
id: int
title: str
content: str
author_id: int
from typing import Any
_POSTS: list[dict[str, Any]] = [
{
"id": 1,
"title": "FastAPI 项目从哪里开始",
"content": "先定义可检查的接口,再决定如何拆分目录。",
"author_id": 1,
},
{
"id": 2,
"title": "让错误结果也有约定",
"content": "找不到文章时返回明确的 404,而不是返回空对象。",
"author_id": 2,
},
]
def list_posts() -> list[dict[str, Any]]:
"""返回文章副本,避免调用方直接改动服务内的教学数据。"""
return [post.copy() for post in _POSTS]
def get_post(post_id: int) -> dict[str, Any] | None:
for post in _POSTS:
if post["id"] == post_id:
return post.copy()
return None
服务函数只表达“查询文章”的结果:找到就返回数据,找不到就返回 None。它不导入 HTTPException,因为 404 是 HTTP 层如何表达领域结果的问题。将这两个层次混在一起,会让同一服务函数更难被后台任务或测试复用。
from fastapi import APIRouter, HTTPException
from . import service
from .schemas import PostResponse
router = APIRouter(prefix="/posts", tags=["posts"])
@router.get("", response_model=list[PostResponse])
def list_posts() -> list[dict[str, object]]:
return service.list_posts()
@router.get("/{post_id}", response_model=PostResponse)
def get_post(post_id: int) -> dict[str, object]:
post = service.get_post(post_id)
if post is None:
raise HTTPException(status_code=404, detail="文章不存在")
return post
这里的路由仍然很薄,但不是没有职责:它定义 URL、响应模型和 HTTP 状态码;它把服务的 None 转换为客户端能理解的 404。response_model 让 FastAPI 按公开模型处理返回值,后续增加内部字段时,接口边界不会自动把所有字段暴露出去。
从正确的工作目录运行
安装命令与上一节相同。把上述文件保存后,在 ch01_modules 目录执行:
cd ch01_modules
python -m uvicorn src.main:app --reload
这里使用 src.main:app 而不是 main:app,因为 main.py 位于 src 包中;相对导入 from .posts.router ... 也只有在按包导入时才有明确的父包。另开终端运行以下检查。它仍然覆盖上一节的健康、列表、详情和 404 行为,因此重构的目标是保持接口行为不变。
from fastapi.testclient import TestClient
from src.main import app
def main() -> None:
with TestClient(app) as client:
health = client.get("/health")
assert health.status_code == 200
assert health.json() == {"status": "ok"}
posts = client.get("/posts")
assert posts.status_code == 200
items = posts.json()
assert items
assert all("title" in item for item in items)
detail = client.get("/posts/1")
assert detail.status_code == 200
assert detail.json()["title"] == "FastAPI 项目从哪里开始"
missing = client.get("/posts/999")
assert missing.status_code == 404
print("领域模块版检查通过")
if __name__ == "__main__":
main()
在 ch01_modules 目录直接执行:
python check.py
如果出现 attempted relative import with no known parent package,通常是直接执行了 python src/main.py。不要把包内入口当作独立脚本运行,使用 python -m uvicorn src.main:app 或从 check.py 导入 src.main。如果返回结果少了字段,先检查 response_model 和 PostResponse 是否与服务返回的数据一致。
当前只有一个文章领域,所以没有创建全局 utils.py、空的 dependencies.py 或数据库模型。等确实出现用户读取、身份判断或数据库连接时,再按职责新增文件;目录数量本身不是可维护性的指标。
下一节会在这个目录上增加只读的 users 服务,并用显式导入连接文章和作者;同时会展示一个故意不可执行的循环导入例子,说明为什么入口层应当负责组装而不是被业务模块反向调用。
来源与相邻小节
- 参考资料:fastapi-best-practices 中文 README 的“项目结构”部分。本节重新设计了文件内容和文章案例。
- 框架参考:FastAPI Bigger Applications。
- 上一节:从能用的接口到可维护的项目
- 下一节:控制模块依赖与公共代码边界
阅读相邻小节时,请在教程目录中选择对应标题。

免费 AI IDE


更多建议: