1.3 控制模块依赖与公共代码边界
控制模块依赖与公共代码边界
按领域拆目录以后,新的问题会变成“谁可以调用谁”。如果 main.py、文章服务和用户服务互相导入,目录看起来很整齐,应用仍然可能在导入阶段失败。好的结构不是文件越多越好,而是依赖方向能够被读者从导入语句中看懂。
本节继续使用上一节的 ch01_modules/ 项目,增加一个只读的 users 服务。文章服务根据文章的 author_id 读取作者摘要,新增 GET /posts/{post_id}/with-author。这只是跨领域调用示例,不是登录系统:用户数据来自内存字典,没有令牌校验,也不能证明请求者有权访问文章。
先画出依赖方向
这次的调用链保持单向:
src.main
└── posts.router
├── posts.schemas
└── posts.service
└── users.service
main 是组装入口,负责把路由注册到 FastAPI;路由负责 HTTP 路径、响应模型和状态码;文章服务负责文章查询,并显式调用用户服务;用户服务只知道如何读取用户,不反向导入文章。这样文章详情仍然可以独立使用,带作者的详情只是文章服务组合两个领域结果的一个用例。
跨领域导入使用完整的领域名称,例如 from src.users import service as user_service。别把多个领域都叫作 service 后再依靠模糊的相对路径猜含义。别的模块如果也要使用用户服务,看到这个导入就能知道调用目标;如果别名太长,优先缩短局部变量,而不是隐藏模块来源。
在第二节目录上新增用户服务
以下文件直接用于上一节的 ch01_modules/。src/main.py 不需要修改,因为它已经注册了整个 posts.router;新增端点属于文章路由的一部分。请先保留第二节文件,再按下面的“替换”块覆盖同名文件,最后新增 users 目录。
新增 users 领域
<!-- file: ch01_modules/src/users/init.py -->
"""用户领域。"""
<!-- file: ch01_modules/src/users/service.py -->
from typing import Any
_USERS: dict[int, dict[str, Any]] = {
1: {"id": 1, "display_name": "林青"},
2: {"id": 2, "display_name": "周宁"},
}
def get_user(user_id: int) -> dict[str, Any] | None:
user = _USERS.get(user_id)
return user.copy() if user is not None else None
这个服务只暴露“按 ID 读取用户摘要”,没有把注册、密码或认证概念提前塞进示例。真实项目里,用户读取和“当前登录者是谁”通常是不同的职责;后面的依赖项章节会再说明身份校验,不能用这里的内存查询替代认证。
替换文章模型、服务和路由
先替换响应模型,为组合结果声明一个嵌套的作者对象。PostResponse 仍然保持第二节的基线字段,因此原来的两个接口契约不变。
<!-- file: ch01_modules/src/posts/schemas.py -->
from pydantic import BaseModel
class AuthorSummary(BaseModel):
id: int
display_name: str
class PostResponse(BaseModel):
id: int
title: str
content: str
author_id: int
class PostWithAuthorResponse(PostResponse):
author: AuthorSummary
下面是文章服务的完整替换版。跨领域调用集中在服务层,路由不需要知道用户数据来自内存字典、数据库还是其他服务。当前示例把“文章存在但作者不存在”按资源不可用处理并返回 None;数据库章节会用外键约束保证这种不一致不会静默产生。
<!-- file: ch01_modules/src/posts/service.py -->
from typing import Any
from src.users import service as user_service
_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
def get_post_with_author(post_id: int) -> dict[str, Any] | None:
post = get_post(post_id)
if post is None:
return None
author = user_service.get_user(post["author_id"])
if author is None:
return None
return {**post, "author": author}
这里的 user_service 是模块别名,不是全局单例,也不是依赖注入容器。它只让导入来源清晰。服务返回普通字典,路由再交给 response_model 进行输出边界控制;若将来用户字段增加了密码哈希,AuthorSummary 不会因为服务返回了更多字段就自动把它输出。
<!-- file: ch01_modules/src/posts/router.py -->
from fastapi import APIRouter, HTTPException
from . import service
from .schemas import PostResponse, PostWithAuthorResponse
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}/with-author", response_model=PostWithAuthorResponse)
def get_post_with_author(post_id: int) -> dict[str, object]:
post = service.get_post_with_author(post_id)
if post is None:
raise HTTPException(status_code=404, detail="文章或作者不存在")
return post
@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
路由顺序把带作者的完整路径写在普通详情路径前面,读者不容易误以为 with-author 是整数 ID。两条路径实际上段数不同,但明确的顺序仍然能降低审查时的误读。main.py 仍然只注册 posts_router,不需要为了新领域再把用户服务直接注册成一个 HTTP 接口。
循环导入为什么是结构信号
下面是一个故意制造错误的反例,只用于说明,不能保存或运行:
## 不可执行:不要把这两行分别保存到项目中。
## src/posts/service.py
from src.users.service import get_user
## src/users/service.py
from src.posts.service import get_post
当 Python 正在初始化 posts.service 时,它先去加载 users.service;后者又要求从尚未初始化完成的 posts.service 导入 get_post,就可能出现“partially initialized module”相关的导入错误。即使某种写法暂时没有报错,两个领域也已经互相知道实现细节,后续修改很容易形成更深的环。
本节的最小修复是让用户服务保持独立:文章服务可以调用用户服务,用户服务不调用文章服务。若真实业务确实存在“同时协调文章和用户”的流程,应先把它描述为一个明确的应用用例,再决定是否需要独立的编排模块;不要为了预防未来需求,先建一个没有语义的 common/service.py。
哪些代码可以放在公共层
公共代码的判断标准是“跨多个领域共享、语义稳定、不会偷偷包含某个领域规则”,而不是“看起来以后可能复用”。例如:
src/config.py适合放全应用都需要的环境配置;某个领域独有的第三方密钥,等真正接入该服务时再放在领域配置中。src/database.py适合放数据库引擎和会话生命周期;文章查询条件仍属于文章领域,不要把文章 SQL 塞进数据库连接文件。src/pagination.py只有在多个领域真的采用同一种分页参数和结果契约时才值得抽出;分页后的文章响应仍由文章 schema 定义。src/utils.py不应成为所有无法归类代码的抽屉。一个函数如果包含用户规则、文章规则或 HTTP 语义,就应该回到对应领域。
这些公共文件都没有在本节创建,因为当前案例还没有数据库、环境配置或跨领域分页需求。删除无职责的空文件,比留下“以后再填”的目录更容易维护。
运行并验收依赖边界
在 ch01_modules 目录中执行下面的替换版检查。它同时验证原有基线和跨领域结果:作者对象存在、作者 ID 与文章的 author_id 一致、文章不存在时仍是 404。
<!-- file: ch01_modules/check.py -->
from unittest.mock import patch
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 项目从哪里开始"
enriched = client.get("/posts/1/with-author")
assert enriched.status_code == 200
enriched_item = enriched.json()
assert enriched_item["author"]["id"] == enriched_item["author_id"]
assert enriched_item["author"]["display_name"] == "林青"
missing = client.get("/posts/999")
assert missing.status_code == 404
missing_author = client.get("/posts/999/with-author")
assert missing_author.status_code == 404
with patch("src.users.service.get_user", return_value=None):
unavailable_author = client.get("/posts/1/with-author")
assert unavailable_author.status_code == 404
print("模块依赖边界检查通过")
if __name__ == "__main__":
main()
运行:
cd ch01_modules
python check.py
python -c "from src.main import app; assert app.title.startswith('文章管理 API'); print('应用可导入')"
最后一条命令检查入口可以被导入,check.py 检查真实路由行为。两者都通过,只能说明当前内存示例的模块关系和基线接口满足约定;它不代表跨进程数据一致、认证安全或数据库约束已经完成。后续章节会在引入具体需求时再增加对应边界。
来源与相邻小节
- 参考资料:fastapi-best-practices 中文 README 的“项目结构”及显式模块导入建议。本节的跨领域端点、用户服务和循环导入示例为教程新增内容。
- 框架参考:FastAPI Bigger Applications。
- 上一节:按业务领域组织代码
- 下一章:选择同步路由还是异步路由
阅读相邻小节时,请在教程目录中选择对应标题。