Python项目结构实战:从扁平布局到src布局的工程规范
1. 为什么Python项目最后总是一团乱麻先从一个我自己经历过的事情说起。前几年帮一位朋友整理他攒了半年的数据分析脚本打开目录的一瞬间我整个人是崩溃的根目录下躺着四十多个.py文件命名从test_final.py、test_final_2.py一路排到test_最终版_不再改.py还有一堆没人知道是干什么用的utils.py。代码能跑但是除了他本人没有任何人敢动这些文件——包括三个月后的他自己。这不是个案。Python 因为上手快、写起来随意项目很容易在不知不觉中膨胀成无法维护的状态。很多人觉得项目结构是个虚的东西能把功能实现就行了。但等你需要加一个新功能、修一个陈年 bug、或者把一个模块抽出去给别人用的时候糟糕的目录结构会成倍放大你的痛苦。项目结构本质上解决的是三个问题代码放哪里、代码怎么被找到、代码如何被隔离。这三个问题处理不好轻则导入路径混乱、循环依赖频发重则整个项目推倒重来的成本远高于当年整理目录的成本。这篇文章我想用一套可复制的方案把组织 Python 项目的关键决策和背后的逻辑讲清楚。不是给你一个万能模板——因为不同规模、不同类型的项目最优结构是有差异的——而是让你掌握判断标准知道什么情况下该用什么布局为什么这样做以及如何从一团乱麻中逐步恢复秩序。无论你是刚入门的初学者还是维护过几个中型项目的开发者这些经验应该都能用得上。2. 目录结构的两种主流风格src布局与扁平布局的选择2.1 先说结论大多数新项目推荐 src 布局关于 Python 项目目录社区里吵得最凶的一个话题就是——要不要把业务代码放进一个src子目录里。所谓扁平布局就是项目根目录下直接放业务代码包像这样myproject/ ├── myproject/ │ ├── __init__.py │ ├── core.py │ └── models.py ├── tests/ │ └── test_core.py ├── README.md └── pyproject.toml而 src 布局长这样myproject/ ├── src/ │ └── myproject/ │ ├── __init__.py │ ├── core.py │ └── models.py ├── tests/ │ └── test_core.py ├── README.md └── pyproject.toml区别只在于中间多了个src壳。但就是这一层壳带来了一个非常关键的改变src 布局强制你的代码必须被安装才能使用而不是直接靠运行目录下的脚本路径来导入。2.2 src 布局为什么能逼你养成好习惯扁平布局最大的坑在于你在项目根目录下运行python main.py的时候Python 会把当前目录加入sys.path于是import myproject能直接成功。但当你把这个包安装到其他环境或者别人从别的目录import myproject时往往就找不到了。更隐蔽的问题是一旦根目录下同时存在myproject包和同名顶层模块容易在不知不觉中导入了错误的目标。src 布局把业务代码全部塞进src下一层根目录里只剩配置文件和工程文件。这时候你在根目录跑脚本直接import myproject是会失败的必须先执行pip install -e .可编辑安装。这看起来多了一步但恰恰是这一步保证了每一次导入都走的是真正的安装路径提前暴露打包和路径问题。我在实际项目里遇到过太多因为扁平布局导致的诡异 bug本地跑得好好的部署到服务器一 import 就报ModuleNotFoundErrortests 目录下能通过换个环境就挂了。这些问题在 src 布局下几乎不可能出现因为你的代码从一开始就是被安装的代码。2.3 什么情况可以选扁平布局src 布局不是银弹。如果你的项目是一个小工具、单文件脚本或者教学 Demo总共就两三个模块硬套 src 布局反而显得过度设计。这种情况下扁平布局更直接少一层目录少一些认知负担。我的判断标准很简单项目是否会被外部环境另一台机器、容器、别人的电脑安装和调用。会就选 src只是一个自己跑的通勤脚本扁平布局完全没问题。另外如果你的团队有相当一部分人还不太理解打包安装的机制强行上 src 布局可能会让协作成本上升——这个决策需要综合考虑团队技术水位。3. 逐层拆解一个可落地的 src 布局骨架3.1 顶层文件与目录的职责划分确定用 src 布局后完整的骨架大概是这样的myproject/ ├── src/ │ └── myproject/ │ ├── __init__.py │ ├── __main__.py │ ├── config.py │ ├── models/ │ │ ├── __init__.py │ │ └── user.py │ ├── services/ │ │ ├── __init__.py │ │ └── order.py │ └── utils/ │ ├── __init__.py │ └── logger.py ├── tests/ │ ├── conftest.py │ ├── test_models/ │ └── test_services/ ├── docs/ │ └── architecture.md ├── scripts/ │ └── download_data.py ├── .gitignore ├── README.md └── pyproject.toml先说顶层。src下面就是你的主包包名建议全部小写、用下划线分隔单词比如order_processing不要用连字符。连字符在 Python 里是减号不能作为模块名虽然它可以用在发行版名称上但为了少给自己添麻烦统一用下划线就好。tests和src平级表示被测代码和测试代码在逻辑上是两套东西测试不混在业务模块里。docs放文档。scripts放一些临时性的运维脚本、数据脚本——这些脚本不属于主包逻辑但因为要用到主包它们被明确隔离出来避免有人把乱七八糟的脚本堆进业务包里。3.2 主包内部怎么再分层主包内部不建议搞出太深的嵌套。很多人喜欢把services、models、utils、handlers、managers、helpers全部分层结果每个目录下只有一个文件找东西反而更困难。我的经验是先按业务领域分再按技术职责分。比如一个电商项目可以按order、user、payment这样拆子包每个子包内部自己管理模型和业务逻辑而不是先按models把所有人的模型集中在一起再按services把所有业务逻辑集中在一起。后者的坏处是当你改一个订单功能时你要在 models 里找一个文件、services 里找另一个文件、utils 里再找第三个跨越的目录越多理解成本越高。按领域划分后包结构长这样src/myproject/ ├── order/ │ ├── models.py │ ├── service.py │ └── views.py ├── user/ │ ├── models.py │ └── service.py ├── common/ │ ├── logger.py │ └── decorators.py └── __init__.pycommon或utils这种公共杂物间很容易失控一定要控制它的膨胀速度。我给自己定了个规矩一个函数如果只被某一个领域用到就放进那个领域内部绝不因为以后可能复用就早早扔进 utils。等真的出现第二个使用方再提取到公共层不迟。3.3__init__.py里该写什么__init__.py的写法同样有讲究。一个常见的错误是做大量显式导入把模块内部的符号全部重新导出结果一import 包名就把一堆底层依赖全拉起来了又慢又容易循环导入。我推荐的做法是__init__.py只做最小导出把对外的公开 API 明确列出内部复杂的模块关系留给具体模块去处理。比如# src/myproject/__init__.py __version__ 0.1.0需要对外暴露核心类或函数的时候再单独定义__all__from .order.service import create_order from .user.service import get_user __all__ [create_order, get_user]这里的原则是导入链要短而清晰。别把__init__.py变成一个隐藏的上帝模块否则排查导入问题的时候你会想骂人。4. 模块组织的关键决策一个文件放多少代码才合适4.1 函数、类、文件、目录之间的粒度关系模块级组织的困惑大多数时候围绕一个词——粒度。一个.py文件里放多少类一个类里放多少方法一个方法写多长这个问题没有绝对标准但有几个信号可以参考。信号一导入时是否引起明显的循环依赖。如果你发现a.py要 importb.py而b.py又回头 importa.py哪怕你用from .a import xxx暂时绕过去了也说明这两个模块之间存在不合理的耦合大概率是职责边界划错了。循环依赖是代码组织混乱的最强警报一旦出现不要试图用技巧绕过优先考虑拆模块。信号二一个文件是否超过 400~500 行。这个数字不是铁律但超过这个规模意味着它承载了太多职责或者说内部逻辑不够内聚。我见过核心业务文件写到两千行看起来也能跑但每次改动都牵一发动全身。把一个大文件拆成models.py、service.py、schema.py这样按职责拆分的若干文件维护起来会轻松很多。信号三测试的难度。如果为了测一个函数你得 mock 掉七八个依赖那大概率是这个函数放错了位置或者是它做了太多事。好的模块组织应该让可测试性自然变好——函数纯粹一点、依赖注入清晰一点测试代码就简单了。4.2 常量集中还是就近放置很多人喜欢建一个constants.py把所有常量放一起。对这种做法我现在比较谨慎。全局常量集中管理在小项目里还好项目一大constants.py会变成一个大杂烩数据库配置、业务阈值、错误码、默认参数什么都有任何模块都依赖它改动一个值要全局排查。更合理的思路是常量就近放在最相关的模块内部只在跨模块共享时提升层级。比如订单模块有个状态枚举就放在order/models.py或order/constants.py而不是塞进全局constants.py。全局只保留真正全局的东西比如项目版本号。4.3 相对导入黑话该不该用from .foo import bar同一包内相对导入和from myproject.foo import bar绝对导入之间的选择是另一个容易纠结的地方。我的看法很明确包内部尽量用相对导入因为它在重构时能减少修改量。比如你把myproject包整体改名了或者把它从一个父包挪到另一个父包下面绝对导入的路径全要改相对导入则不受影响。但要注意相对导入层级不要写得太过分from ...utils.helpers import parse_date这种三层以上的相对导入可读性已经明显下降。出现这种情况往往说明模块嵌套过深该考虑压扁目录层级了。5. 配置管理Settings、环境变量与配置文件的边界划分5.1 配置信息的三种来源与合并策略任何稍微正规一点的项目都会遇到配置从哪里来这个问题。直白说配置来自三个地方代码里的默认值、配置文件.env、config.yaml、settings.ini、环境变量。三者的优先级从低到高默认值最低环境变量最高。因为环境变量是部署环境最后能兜底覆盖的东西——同一份代码在开发机和生产机上可以靠环境变量产生完全不同的行为。Python 生态里我比较推荐用pydantic-settings或python-dotenv来管理配置前者更现代支持类型校验和自动补全后者更轻量。核心用法是定义一个 Settings 类# src/myproject/config.py from pydantic_settings import BaseSettings class Settings(BaseSettings): app_name: str MyProject debug: bool False database_url: str sqlite:///./data.db class Config: env_prefix MYAPP_ env_file .env settings Settings()这样做有个隐藏的好处你不再需要在代码里到处写os.getenv(DATABASE_URL)这种裸调用。所有配置从一个Settings实例读取默认值、.env文件、环境变量三者自动合并字段名称统一、类型安全。如果部署时忘记配某个关键环境变量启动时对应的字段会因缺失而报错或被填充默认值问题更容易暴露。5.2 配置文件的放置与保护.env文件按惯例放在项目根目录但它必须被.gitignore排除。注意.env.example或.env.template这种模板文件可以提交到仓库里面只写键名和示例值不写真凭实据的密码密钥。别人 clone 项目后复制.env.example为.env填上自己的本地值即可。这一点我吃过亏。早期项目把.env直接提交进仓库数据库账户密码全在历史记录里躺着后来不得不整改证书和密钥。所有敏感配置进环境变量、不进代码库这个底线不要突破。5.3 settings 与项目结构的联动config.py应该放在主包的顶层还要考虑配置可能需要被不同执行入口使用的现实——不管是跑主服务、跑脚本、跑测试都能稳定地from myproject.config import settings。不要把这个模块藏在深层子包里否则每次导入都要绕很长路径。6. 依赖管理与运行环境用现代工具锁住版本6.1 pyproject.toml 取代 requirements.txt过去 Python 项目的依赖管理几乎被requirements.txt垄断但在 2024 年的当下我强烈建议新项目直接以pyproject.toml为核心来组织。pyproject.toml是官方认可的工程配置文件它不仅能声明依赖还能描述项目元信息、构建系统和入口点一套配置打通开发、打包、发布三个阶段。一个简洁的pyproject.toml看起来是这样的[build-system] requires [setuptools68] build-backend setuptools.build_meta [project] name myproject version 0.1.0 description A sample project structure demo requires-python 3.10 dependencies [ requests2.31,3, pydantic2,3, pydantic-settings2,3, ] [project.optional-dependencies] dev [ pytest8, ruff0.4, ] [tool.setuptools.packages.find] where [src]注意[tool.setuptools.packages.find]里的where [src]这保证了从src目录下发现包。如果漏掉这个配置pip install -e .时会报包未找到的错误或者项目能安装却导不了。这一步搭配之前的 src 布局属于关键联动。6.2 可编辑安装与虚拟环境再强调一遍虚拟环境的使用。很多初学者在全局环境里直接pip install各种依赖不同项目的依赖版本互相干扰几个月后环境变成一团浆糊。我个人的标准流程是cd到项目根目录python -m venv .venv创建虚拟环境source .venv/bin/activate激活环境pip install -e .[dev]安装项目本身和开发依赖后续所有测试和运行都在这个虚拟环境里进行可编辑安装-e特别适合项目开发阶段代码改了立刻生效不需要反复重新安装。而且它能强制你的包结构在真实安装语境下被验证扁平布局 直接跑脚本的那种只在当下目录能跑的问题到这一步就会暴露出来。6.3 依赖版本策略锁定与容忍的平衡依赖版本写法上dependencies里建议用兼容范围比如requests2.31,3这样既避免版本过旧又避免大版本升级带来的破坏性变更。生产部署时可以使用 lock 文件如uv或poetry生成的锁定文件来锁定精确版本保证部署的一致性。开发时用宽松范围、部署时用精确锁定这俩并不冲突。7. 测试与文档在结构中的安置7.1 测试目录与 src 如何对应测试目录的组织方式推荐与 src 内部结构保持镜像。也就是说tests/test_order/对应src/myproject/order/tests/test_user/对应src/myproject/user/。这种镜像结构最大的好处是当你在某个模块附近找不到对应测试时按路径走过去盲找也能找到。一个需要注意的细节是conftest.py的层级。tests/conftest.py放全局 fixturetests/test_order/conftest.py放订单领域的局部 fixture。fixture 尽量贴近使用范围不要把所有 fixture 堆在全局否则换个项目加载时可以明显感觉到慢而且 fixture 之间的隐式依赖会让人看不明白。7.2 测试里导入被测代码的姿势测试文件里如何导入被测模块是新手最容易踩坑的地方。如果用了 src 布局测试文件里直接写from myproject.order.service import create_order前提是你已经执行过pip install -e .。如果你没有安装项目直接跑 pytest大概率报导包失败。这个要先安装的设计不是麻烦而是故意为之——它保证测试用的代码和真实环境用的代码是同一套而不是某个目录下恰好能导入的副本。如果因为某种原因没法执行可编辑安装也可以借助 pytest 的rootdir和pythonpath配置来处理但那是绕路方案不是推荐路径。7.3 文档README 的边界与 docs 目录的补充README 不应该试图承载所有文档。它的核心任务是让人在 30 秒内了解这个项目是干什么的、怎么装、怎么跑、给谁用。更详细的技术设计、数据流程、架构说明放入docs/目录。docs/目录内部也可以结构化。一个极简的方案docs/ ├── architecture.md ├── data_flow.md └── decisions.mddecisions.md记下关键设计决策和为什么这么做这对未来的维护者是一份珍贵的资料。我见过太多项目代码写完了当初为什么这么设计没人记得。花二十分钟写下决策记录未来省下的是好几个下午的逆向推理。8. 从混乱走向有序老项目的渐进式重构路径8.1 先识乱一眼看出项目结构不健康的信号不是所有项目都有机会从第一天按标准结构搭建。很多人的真实处境是——手里已经有一个运行了半年、目录乱七八糟的项目。这时不要推倒重来先做诊断。几个典型的不健康信号根目录下散落超过 10 个.py文件存在名称相近但内容重叠的文件比如utils.py和helpers.py用sys.path.append(..)处理导入超过三层的绝对导入路径测试文件放在被测模块同一个目录里依赖没有锁定pip freeze输出几百行8.2 重构不靠意志力靠搬家三步法我总结了一个渐进式重构路径适合处在混乱期的项目。第一步先建骨架不做大迁移。把空目录结构、pyproject.toml、.gitignore、初步的 README 建好让新结构先存在。这一步风险为零纯粹是搭脚手架。第二步按模块逐步搬家。一次只处理一个子模块把它移入src/myproject/对应位置同步改导入语句然后跑对应测试确认功能没坏。这里的关键是每次只动一个模块不要一口气把所有文件全搬完然后再统一改导入——一旦出错排查范围成倍放大。第三步处理公共依赖和配置。把散落在各处的配置读取逻辑收拢到config.py把重复的工具函数合并进common/并趁这个时机清理掉那些命名含糊的万能模块。8.3 重构期间如何降低风险重构过程中测试是你最可靠的保险网。如果你的项目测试覆盖几乎为零我建议在重构之前先补一点关键功能的冒烟测试不用追求覆盖率先把最重要的几条链路测通。没有测试保护的大规模重构就像在悬崖边蒙眼散步——不是一定会出问题但出事概率太高了。同时利用好版本管理。每次搬家完成、测试通过后即时提交。如果中途改坏了git revert一下就能回来不用半夜一边哭一边恢复文件。9. 几个容易忽视但在实战中重要的经验9.1 命名一致性比命名完美更重要团队协作的项目里命名风格是否统一直接影响到代码的可读性。包名用order_processing还是orderprocessing不重要——重要的是所有地方都用同一种。模块内导入用from .order import service还是from .order_service import ...也不重要——重要的是别在同一次代码 review 里看到两种风格。建议在项目文档里明确一条最简单、可执行的命名约定包名和模块名全小写、以单词为单位、下划线分隔类名用驼峰函数和变量用小写下划线常量全大写。9.2 别把类型检查、代码格式化当可有可无sorted检查、类型标注和代码格式化在现代 Python 项目里不应该算可选项。pyproject.toml里顺手配一下类型检查配置比如mypy和代码风格检查比如ruff开发时带来的约束感会在未来无数次排错中转化为便捷感。一个能自动发现你这里少了个返回值类型的声音提示比运行时爆一个AttributeError再回头翻代码舒服太多了。9.3 结构文档要及时更新不然等于没有我见过不少项目README 里写着的目录树和实际代码已经不一致到没法看。结构文档一旦过时它的危害不仅是误导更严重的是大家会认为文档反正不准不看也罢——整个团队的工程信心都受影响。所以每次调整目录结构、改名、搬迁顺手把 README 里的目录树同步更新一下。别小看这个动作它能维持项目结构即文档、文档即结构的正循环。附一个完整的最小项目实例最后给一个可以直接拿来改的完整骨架包含前面讲到的大部分要点myproject/ ├── src/ │ └── myproject/ │ ├── __init__.py # 版本号 公开 API │ ├── __main__.py # python -m myproject 入口 │ ├── config.py # Settings 定义 │ ├── order/ │ │ ├── __init__.py │ │ ├── models.py │ │ └── service.py │ ├── user/ │ │ ├── __init__.py │ │ ├── models.py │ │ └── service.py │ └── common/ │ ├── __init__.py │ └── logger.py ├── tests/ │ ├── conftest.py │ ├── test_order/ │ └── test_user/ ├── scripts/ │ └── init_data.py ├── .env.example ├── .gitignore ├── README.md ├── docs/ │ ├── architecture.md │ └── decisions.md └── pyproject.toml运行时入口的__main__.py里可以放一个极简的if __name__ __main__:逻辑配合[project.scripts]配置把命令行入口注册好。装上包之后就能在任意路径下用命令启动项目而不是每次都cd到某个固定目录再执行。我自己在带团队和写开源项目时花了很长一段时间才理解结构不是给计算机看的是给人看的。计算机只要能 import 到模块就能运行真正难受的是人——隔了一个月回去改代码的人接手别人烂摊子的新人还有试图把某个功能抽离出去做复用的你自己。一个清晰的结构本质上是把这个项目怎么运转这件事以最直观的方式写进了目录里。所以每次新建项目时多花十分钟搭骨架每次重构时多花几分钟保持同步这些看起来不产出功能的时间最后都会以更少的排错时间、更顺手的开发体验加倍还回来。