第 28 章 项目结构最佳实践

这一章解决你从 Go 转 Python 最容易栽的跟头:代码能跑,但项目结构一团乱,别人 clone 下来不知道从哪进、测试跑不起来、发布装不上。咱们用 src/ 布局 + 分层架构,把 Go 的 project layout 习惯平移过来。

墨叔:老哥,你回想 Go 项目。一个 repo 下来,cmd/ 放各二进制入口,internal/ 放私有代码,pkg/ 放可复用库,目录即包、包即目录,几乎不用想「项目长什么样」。换到 Python,你说一个服务该放哪?main.py 直接甩根目录?所有代码平铺?还是也搞 src/?我见过太多 Python 项目,半年后 import 互相绕、测试找不到包、打包发布一堆坑。你觉得根子在哪儿?

老哥:……Python 没有强制的「项目长相」?

墨叔:一针见血。Go 把结构焊在工具链里(go build 认目录),Python 把结构留给社区约定。约定有好几种,但最稳、最不容易踩坑的一种,就是 src/ 布局 + 可安装包。今天咱们把这条线全拉通:为什么用 src/ 而不是平铺、怎么用 __main__.py 做命令行入口、cookiecutter 那套标准目录长啥样、router/service/repository 怎么分层、配置测试 CI 怎么摆,最后对照 Go 的 project layout。

一、src/ 布局到底是什么(怎么用)

先说最核心的一个决定:你的代码放 src/your_package/ 里,而不是直接扔项目根目录。为什么?因为 Python 的 import 有个坑——如果你把包直接放在根目录,运行脚本时「当前目录」会被塞进 sys.path,导致你既能 import your_package(装了)又能 import your_package(没装、靠当前目录),两套解析路径混在一起,测试时 import 一个样、安装后 import 另一个样,经典「本地能跑、CI 挂」事故。

src/ 布局把所有源码收进 src/ 这个根包目录,根目录只留配置和文档,强迫你「必须先安装(或 editable install)才能 import」,统一了开发期和运行期的解析路径。

my_service/
├── pyproject.toml          # 项目元数据 + 依赖 + 工具配置,唯一真相
├── src/
│   └── my_service/         # 真正的包,名字要唯一
│       ├── __init__.py
│       ├── __main__.py     # python -m my_service 的入口
│       ├── api.py
│       ├── service.py
│       └── repository.py
├── tests/
│   ├── conftest.py
│   └── test_service.py
├── .github/workflows/ci.yml
└── README.md

pyproject.toml 里声明包位置,现在标准做法是 [tool.setuptools.packages.find] 指定 where = ["src"]:

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
 
[project]
name = "my-service"
version = "0.1.0"
dependencies = ["fastapi", "pydantic"]
 
[tool.setuptools.packages.find]
where = ["src"]

开发时 pip install -e .(editable 安装),你的包就被装进环境、指向 src/ 源码,改代码即时生效,import 路径在任何地方都一致。对照 Go:Go 的 module 模式下 go build ./... 直接认目录,天然没有「当前目录混入 sys.path」这层麻烦;src/ 布局是 Python 社区为了人为制造一层「必须显式安装才能用」的边界,把 Go 那种干净感补回来。

老哥:那我不搞 src/,直接根目录放包行不行?小项目。

墨叔:能跑,但你会逐渐踩三个坑:一是上面说的 import 双路径;二是 pytest 在根目录跑和从别处跑解析不同;三是打包发布时容易把测试、脚本、配置一起打进 wheel。src/ 是「一次规范、终身免烦」,多一层目录而已,没必要省。小脚本(单文件工具)当然无所谓,但凡超过一个模块、要测试、要发布,src/ 就值。

二、main.py 与命令行入口(怎么用)

Go 里 package main + func main() 是程序唯一入口,清清楚楚。Python 没有 main 包,但靠 __main__.py 实现等价物:当一个目录(包)被 python -m your_package 调用时,解释器会执行该包下的 __main__.py。

# src/my_service/__main__.py
import sys
from my_service.cli import build_parser, run
 
def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    return run(args)
 
if __name__ == "__main__":
    sys.exit(main())

这样用户就能 python -m my_service --port 8080 启动服务,而不用记得「入口脚本叫啥、放哪」。对照 Go:这就是 go run ./cmd/server 的 Python 版——用包名而不是文件名启动,目录结构即入口导航。

更进一步,如果你想发布成一个可以直接敲命令的 CLI(比如 my_service --port 8080),在 pyproject.toml 里声明 scripts 入口点:

[project.scripts]
my-service = "my_service.__main__:main"

安装后,环境里就有 my_service 这个可执行命令,框架帮你生成了 wrapper。这等价于 Go 编译出二进制、go install 后放进 $GOPATH/bin。注意一个细节:main() 返回 int 当退出码,sys.exit(main()) 把退出码传给 shell——和 Go 的 os.Exit(code) / main 返回值语义对齐,别裸跑 main() 忽略了非零退出码,CI 里会误判成功。

三、分层架构:router / service / repository(什么场景用)

你写 Go 服务,习惯把 handler(处理 HTTP)、service(业务逻辑)、repository(数据访问)分开,依赖单向向下。Python 项目也一样要分,否则所有逻辑挤在 Flask/FastAPI 的 route 函数里,三个月后谁都改不动。

src/my_service/
├── api.py            # router 层:只管 HTTP 协议(解析请求、拼响应、状态码)
├── service.py        # service 层:业务规则、事务编排,不碰 HTTP、不碰 DB 细节
├── repository.py     # repository 层:只管数据存取,返回领域对象
└── models.py         # 领域模型(dataclass / pydantic)

各层怎么写才不串味:

# repository.py —— 只管存和取,不关心业务
class UserRepository:
    def __init__(self, db: Database):
        self.db = db
    def get(self, uid: int) -> User | None:
        row = self.db.query("SELECT ...")   # 纯数据操作
        return User(**row) if row else None
 
# service.py —— 业务规则,注入 repository,不碰 HTTP
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
    def get_profile(self, uid: int) -> UserProfile:
        user = self.repo.get(uid)
        if user is None:
            raise NotFoundError(uid)        # 业务异常,不要在这拼 HTTP 响应
        return UserProfile.from_user(user)
 
# api.py —— 协议层,把异常翻译成状态码
@app.get("/users/{uid}")
def get_user(uid: int, svc: UserService = Depends(get_user_service)):
    try:
        return svc.get_profile(uid)
    except NotFoundError:
        raise HTTPException(status_code=404)

依赖方向严格单向:api → service → repository → db。上层依赖下层抽象(用构造函数注入,别在 service 里 from my_service.repository import UserRepository 直接 new)。这跟 Go 的 handler → service → repo 分层一模一样。为什么这么分?因为每一层都能独立测试:service 测试时传个假 repo,根本不用起 HTTP、不用连库;api 测试用 FastAPI 的 TestClient 打接口,不用关心业务细节。对照 Go:你用接口 type UserRepo interface { Get(id int) (*User, error) } 解耦,Python 这边用「构造函数注入 + 鸭子类型 / Protocol」达到同样效果,省了显式接口声明。

边界提醒:别在 repository 里写业务规则(比如「VIP 用户折扣」),也别在 api 里直接查数据库。一旦某层越界,分层就名存实亡,重构时你会哭。真实项目里最常见的腐败就是从「在 route 里顺手查一下库」开始的。

四、一个贴近项目的例子(完整骨架)

把上面全串起来,给你一个能直接 git init 开干的项目骨架。除了前面见过的 pyproject.toml 和 src/ 结构,重点看目录组织和配置/测试/CI 摆放:

my_service/
├── pyproject.toml
├── README.md
├── .env.example          # 配置模板,提交到仓库;真实 .env 不提交
├── src/
│   └── my_service/
│       ├── __init__.py
│       ├── __main__.py
│       ├── config.py          # pydantic Settings,读环境变量
│       ├── api.py             # router 层
│       ├── service.py         # service 层
│       ├── repository.py      # repository 层
│       ├── models.py          # 领域模型
│       └── errors.py          # 自定义异常
├── tests/
│   ├── conftest.py           # 公共 fixture(假 db、假 svc)
│   ├── test_service.py
│   └── test_api.py
├── migrations/              # 数据库迁移脚本(如 alembic)
└── .github/workflows/ci.yml

config.py 用第 12 章讲的 pydantic-settings,配置即类型、多环境靠环境变量:

# src/my_service/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
 
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
    db_url: str
    debug: bool = False
    log_level: str = "INFO"
 
settings = Settings()   # 模块级单例,全局唯一(第 27 章讲过)

测试目录 tests/ 和 src/ 平级,是 pytest 标准约定——测试代码不进包、不打进发布物。conftest.py 放共享 fixture,比如一个内存版假 repository,让 service 测试完全不碰真库:

# tests/conftest.py
import pytest
from my_service.service import UserService
from my_service.repository import UserRepository
 
class FakeRepo(UserRepository):       # 鸭子替身
    def get(self, uid: int):
        return User(id=uid, name="test") if uid == 1 else None
 
@pytest.fixture
def svc():
    return UserService(FakeRepo(db=None))

这套骨架,对照 Go 的 standard project layout:cmd/ ≈ src/my_service/__main__.py(入口),internal/ ≈ src/my_service/ 私有包,pkg/ ≈ 可发布的独立包,/configs 或环境变量 ≈ .env + config.py,/test 约定 ≈ tests/。差异只在 Python 用 src/ 额外隔离、用 pyproject.toml 替代 go.mod 管依赖。骨架一旦固定,新人进来不看文档也知道「配置在哪、入口在哪、业务在哪层」,这就是工程化的复利。

五、用它的收益与代价(为什么这么设计)

老哥:这套结构比「平铺几个 py 文件」复杂不少,小项目值得吗?

墨叔:又回到那个老问题——值不值看「出错代价和寿命」。我给你算:

收益:

  1. 可发现性。 任何人 clone 项目,三十秒知道入口(__main__.py)、配置(config.py + .env.example)、业务(service.py)、测试(tests/)。平铺项目得通读才知道,新人 onboarding 成本翻倍。
  2. 可发布性。 src/ + pyproject.toml 让 pip install . 直接产出干净 wheel,CI 发版一行命令。平铺项目打包时经常把 __pycache__、测试、脚本乱打进去,发布物又肥又脏。
  3. 可测试性。 分层 + 依赖注入,让每一层能单独测,测试跑得快、不依赖外部。这和 Go 分层测试一个道理——你第 25 章 pytest 那套才发挥得出价值。
  4. import 稳定。 src/ 布局消除「本地能跑 CI 挂」的 import 双路径问题,少一种玄学报错。

代价:

  1. 前期多写几层目录、多配一个 pyproject.toml、多敲一次 pip install -e .。对新手有个「先安装才能跑」的认知门槛——平铺项目 python app.py 就能起,src/ 必须先在虚拟环境里装好自己。
  2. 过度分层对极小项目是负担。 一个 200 行的脚本工具,硬分 router/service/repo 三层,反而绕。分层是给「会变复杂、会多人维护」的项目准备的,别对玩具项目上纲上线。

所以判断标准就一句:项目要不要被别人维护、要不要发布、测试多不多?要,就上完整骨架;纯个人脚本,平铺即可。这点 Go 也是这样——你写 hello.go 单文件根本不搞 cmd/internal,但公司服务一定分层。

六、cookiecutter 与标准目录(延伸)

老哥:每次新项目都手搭这套,不累吗?

墨叔:不累,因为有脚手架。cookiecutter 是个项目模板工具,社区有 cookiecutter-pypackage 这类现成模板,一条命令生成带 src/ 布局、pytest、CI、README 的标准骨架。大厂内部一般有自己的模板仓库,新人 cookiecutter git@internal/template 一下,结构和规范全到位,不用每次重新发明。对照 Go:go mod init + 公司脚手架脚本也是同理——都是「把成功经验固化成模板,降低每次起步的熵」。强烈建议你在团队里推一个内部模板,比写十遍规范文档管用。

另外提一句 pyclean / 工具链清理:项目根该有 .gitignore 忽略 __pycache__/、*.egg-info/、venv/、.env,和第 11 章虚拟环境、第 26 章流水线是一套组合拳——结构规范、依赖隔离、质量闸门,三件合起来才是「能长期维护的 Python 项目」。

七、常见结构反模式(对照 Go 经验排雷)

老哥:你讲了「该怎么摆」,能不能反着讲讲「千万别怎么摆」?我怕自己不知不觉又写歪了。

墨叔:好,排雷比建规矩更救命。给你列四个 Python 项目里最高频的结构反模式,每个都对照你 Go 的经验,你会觉得「这坑我熟」:

反模式一:循环导入靠「把 import 塞到函数里」强行绕过。 Go 里有循环包依赖编译直接报错,你马上知道结构有问题。Python 不会,你为了跑通,把 from b import X 移到函数体内部延迟导入,程序能跑了,但结构早已是环。正确解法不是挪 import,而是「抽公共依赖到第三个模块」——和 Go 里把互相依赖的逻辑抽到 internal/common 一个道理。延迟导入是创可贴,不是治疗。

反模式二:把业务逻辑写进 __init__.py。 有人图方便,在 __init__.py 里写初始化、连数据库、起后台线程。这文件是「包被导入时自动执行」的,意味着任何地方 import my_service 都会触发这些副作用,测试、工具脚本、文档生成全被牵连。对照 Go:init() 函数也只在导入时跑,但 Go 社区约定 init 只做轻量注册。Python 这边更严格——__init__.py 最好只放 __all__ 和极少量 re-export,重活全挪到普通模块,让导入无副作用。这也是为什么测试要隔离、为什么 pip install -e . 装包时不会乱跑代码。

反模式三:配置散落各处、硬编码到处是。 你在 api.py 写死 db_url = "localhost",在 service.py 又写死一份,环境一变全得改。Go 项目习惯用 viper 或环境变量集中配置,Python 这边用第 12 章的 pydantic Settings 集中到 config.py 单例,所有地方 from my_service.config import settings 取。配置中心化是跨语言的基本功,别在 Python 里退化。

反模式四:tests 放进 src 包内部。 有人把 test_xxx.py 和源码放一起,结果打包时测试被打进 wheel、发布物变肥,而且测试 import 路径和源码 import 路径混用容易踩坑。标准做法是 tests/ 与 src/ 平级、永远不进包。对照 Go:*_test.go 虽然和源码同目录,但 Go 工具链明确区分测试文件不会进构建产物,Python 没有这种内置区分,所以得靠「物理隔离目录」手动达成——这正是 src/ + 外部 tests/ 布局存在的理由之一。

这四个反模式,根子都是一个:「以为 Python 松就可以随便摆」。其实越松的语言,越需要你用纪律补上编译器本来会替你守的边界。你从 Go 带来的「结构洁癖」,在 Python 项目里不是负担,是最值钱的能力。

【思考题】

  1. 你现在手头有没有「平铺式」的 Python 项目(所有 py 放根目录或几个散目录)?挑一个,试着改成 src/ 布局,描述你会遇到哪些 import 路径需要调整、测试怎么跟着搬、.env 和配置怎么收敛。改完最大的痛点会是什么?
  2. 分层架构讲「api → service → repository 单向依赖」。如果某个需求需要 service 调用另一个 service(比如下单服务要调库存服务),依赖方向怎么设计才不会变成网状混乱?结合你在 Go 里处理过 service 互相调用的经验谈。
  3. Go 有 go.mod 把依赖和版本锁死在文件里,go build 离线可重现。Python 的 pyproject.toml + lockfile(uv/poetry)能完全等价于 Go 的可重现构建吗?它在哪方面还不如 Go,又在哪方面更灵活?

【参考答案】

  1. 改造步骤与痛点:(一)在根建 src/your_pkg/,把所有模块移进去,加 __init__.py。(二)import 调整:原来 from utils import x 改成 from your_pkg.utils import x——所有内部 import 加包前缀,这是最大一处改动,通常要全局替换。(三)测试搬迁:tests/ 保持根级,但测试里 from your_pkg import ... 现在必须靠 pip install -e . 或 pytest 的 rootdir/pythonpath 配置才能解析,别忘了在 pyproject.toml 的 [tool.pytest.ini_options] 设 pythonpath = ["src"] 或在 conftest 里处理。(四)配置收敛:散落的 config.json / 硬编码常量,统一进 config.py(pydantic Settings)+ .env.example。最大痛点通常是「内部循环 import 在平铺时靠当前目录隐式掩盖,src/ 后暴露出来」,逼你真正理顺依赖——短期痛,长期好。(五)CI 也要改:安装步骤加 pip install -e .。整体是一次性投入,之后 import 一致性、可发布性都上一个台阶。
  2. service 调 service 是分层架构最容易腐化的点。原则:保持「依赖向下、可编排层在最上」。具体做法:(一)把「下单」和「库存」都视为领域 service,引入一个更上层的 OrderOrchestrator(或就叫 OrderService 依赖 InventoryService),由它注入并协调两个 service,避免 service 之间平级互调形成环。(二)如果库存是远程服务,把它抽象成 InventoryClient 接口(Python 用 Protocol),OrderService 依赖抽象而非具体,测试可替换,且方向清晰:OrderService → InventoryClient 抽象,不反向。(三)警惕循环:A 调 B、B 又调 A,往往说明职责切错了,应该把公共逻辑抽到第三个 service 或 domain 层。这点和 Go 完全一致——Go 里 package 循环依赖编译直接报错,Python 不会编译报错但运行时 import 环会炸,所以更要靠纪律和分层自觉守住单向依赖。
  3. Python 的 pyproject.toml + lockfile(uv 的 uv.lock / poetry 的 poetry.lock)能做到「依赖版本完全锁定、可重现安装」,这点等价于 Go 的 go.mod + go.sum。但差异在:(一)Go 的 module graph 是语言一级、所有依赖同为 Go 编译,可重现性极强且 go build 天然离线可重现;Python 依赖可能是 C 扩展(需编译)、可能依赖系统库(如 libpq),lockfile 锁了版本但没锁「编译环境」,换平台可能装不上同二进制,可重现性弱于 Go。(二)灵活度上 Python 更松:pyproject.toml 允许多后端(setuptools/poetry/uv)、可装 git 直接依赖、可 editable 开发,Go 的 module 更刚性。结论:Python 通过 uv/poetry 的 lockfile 已接近 Go 的可重现构建体验,但「C 扩展 + 系统依赖」这道关是语言生态差异,工具补不回来——所以 Python 项目常配容器(Docker)把「编译环境」也锁死,正好补上 Go 原生就有的那块。