codecamp

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
  • 上一节:按业务领域组织代码
  • 下一章:选择同步路由还是异步路由

阅读相邻小节时,请在教程目录中选择对应标题。

1.2 按业务领域组织代码
2.1 选择同步路由还是异步路由
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }