说实话我见过太多Python项目从单个脚本一路堆到几千行最后连作者自己都说不清某个函数定义在哪个文件里。项目结构这件事表面看是文件夹怎么摆实际上决定的是一次改动要牵动多少文件、一个新同事多久能上手、一个测试能不能稳定跑起来。这篇文章我想结合这几年维护多个Python项目的实际经验把代码组织这件事从头到尾捋一遍。不管你是刚把脚本写成程序的新手还是要给团队定规范的负责人这篇都值得花几分钟认真看完。我特别想强调一点结构不是一次定死的东西。它跟着项目生命周期走你得先理解它为什么存在才能改得动它、调得准它。1. 项目结构的底层逻辑先想清楚为什么要这样放1.1 结构与代码生命周期的关系很多人一开始写Python都是这样的新建一个main.py想到什么写什么函数、类、测试全塞在一起。这个状态在脚本阶段没问题但一旦代码量超过一千行问题就来了。函数之间的调用关系开始变得模糊你不敢轻易删掉任何一段代码因为不知道哪里还在用它。再往下你开始复制项目文件夹每次改需求就复制一份加个后缀最后硬盘里躺着project_final_v3_真的最终版.py这种名字。其实项目结构要解决的核心就是三件事搞清楚代码属于哪一层、依赖方向是什么、以及哪里是变更的边界。说得直白一点结构是给变化设计的。业务逻辑会变数据库连接方式会变展示层会换但是核心的领域规则应该相对稳定。你组织代码的时候要尽量让经常一起变的东西放在一起让不常变的东西成为底层依赖。1.2 扁平布局与src布局之争关于Python项目布局社区里讨论最多的就是src布局和扁平布局两种方案。扁平布局最简单根目录下就是包目录project/ ├── mypackage/ │ ├── __init__.py │ └── module.py └── tests/ └── test_module.py这种布局在小型项目里非常直观但有一个隐藏风险当你在项目根目录运行测试时Python会把当前目录加入sys.path于是导入的可能是本地的mypackage而不是你安装到环境里的那个版本。听起来没什么一旦你同时在使用发布到PyPI上的同名包或者测试环境里装的包版本和本地不一致就会出现测试通过但发布后报错的诡异问题。src布局就是为这个场景设计的project/ ├── src/ │ └── mypackage/ │ ├── __init__.py │ └── module.py └── tests/ └── test_module.py把包放在src下面项目根目录没有包名。这样你跑测试的时候导入的一定是安装到环境里的包本地的源码不会被意外混进来。虽然多了一层目录但这种必须安装才能导入的约束能逼着整个项目走上正确的构建和发布流程。1.3 组织结构的两个基本原则我整理项目结构的时候脑子里始终绷着两根弦。第一是依赖单向原则。在项目内部画一张依赖图箭头应该是从上层业务指向底层工具。不要让底层工具反过来依赖上层业务更不要出现环。如果出现了环说明你的模块职责拆分有问题或者抽象层次被破坏了。第二是按职责划分而不是按技术栈划分。很多人喜欢建一堆utils、helpers目录把乱七八糟的辅助函数全扔进去。短期看是很爽长期看这就是个垃圾堆。更好的做法是按业务模块划分比如用户模块、订单模块、支付模块每个模块内再各自管理自己的工具函数。这样你改订单相关代码的时候基本只需要进orders这个目录而不是在一整个utils里捞针。2. 一个能扛住演变的目录结构长什么样2.1 生产级项目的推荐骨架这些年我推荐过很多次一套骨架它不是最炫酷的但胜在稳定、易懂、容易演进。这套骨架对中小型Python项目非常合适尤其是Web服务、数据处理工具和内部自动化系统。project/ ├── src/ │ └── yourpackage/ │ ├── __init__.py │ ├── main.py # 入口控制程序启动 │ ├── config.py # 配置读取与校验 │ ├── models/ # 数据模型与数据结构 │ │ └── __init__.py │ ├── services/ # 业务逻辑层 │ │ └── __init__.py │ ├── repositories/ # 数据访问层 │ │ └── __init__.py │ ├── api/ # 接口层HTTP或CLI封装 │ │ └── __init__.py │ ├── utils/ # 纯粹的技术工具 │ ├── schemas/ # 输入输出校验结构 │ └── exceptions.py # 统一异常定义 ├── tests/ │ ├── conftest.py │ ├── unit/ │ ├── integration/ │ └── fixtures/ ├── scripts/ # 运维、部署、数据迁移脚本 ├── docs/ ├── pyproject.toml ├── README.md ├── .env.example └── .gitignore一眼看上去层数不少但每一层都有明确分工。api层接收外部请求转发给services层services层处理业务规则需要数据时调用repositories层repositories层负责读写数据库或调用外部服务。当你把HTTP接口换成命令行工具时只需要动api层业务逻辑根本不用碰。2.2 每个目录的职责边界我逐个说下关键的目录顺便讲清楚它们之间是什么关系。models放的是和数据表一一对应的模型类。如果你用ORM模型字段和数据库列对应如果你只是做数据处理这里放的是核心数据结构。注意models绝对不能放业务逻辑它只是数据的骨架。services是整棵树的中心。用户下单、退款、计算价格这类业务规则都放这里。这里的代码应该是纯逻辑不关心数据从哪来、跑到哪去也不关心外部是怎么调进来的。写测试的时候主要测的就是这一层。repositories负责数据访问。为什么单独拆一层我踩过一次很深的坑业务代码里到处直接写数据库查询后来数据库从MySQL换成了PostgreSQL几乎每个文件都被牵连。把数据访问隔离以后更换存储方案的影响范围就被限制在repositories内部了。schemas这层是很多人容易忽略的。对外接收的数据、返回的数据都需要结构定义。尤其是在写Web服务时用pydantic或其他校验库定义请求响应模型能挡掉大量非法输入。config.py单独拎出来是因为配置管理实在太容易失控。环境变量、配置文件、命令行参数这些来源的优先级一旦不明确就可能出现本机能跑、服务器上挂了的问题。我通常的做法是config.py里面统一读取环境变量并提供默认值最后返回一个全局配置对象。启动程序时尽早加载配置验证必填项缺失就大声报错。2.3 命名规范与包内组织命名这件事看似简单实际影响很大。Python官方建议包名都用小写单词之间用下划线分隔不要用连字符。因为连字符在import语句里不能直接用但在PyPI上又可以注册于是经常出现安装的名字带横杠、导入的名字带下划线这种经典混淆。我建议包名用一个简短的、有意义的名字不要超过两三个词。比如内部工具叫deploy_tool、log_analyzer简单明了。模块文件名也要小写避免与类名混淆。在包内部我习惯给每个业务模块建子目录子目录里的公共接口尽量在__init__.py里重新导出。这样外部导入时路径短内部实现还能随便改。举个例子用户模块的对外入口可以这样屏蔽内部实现细节# src/yourpackage/services/__init__.py from .user_service import UserService from .order_service import OrderService __all__ [UserService, OrderService]外部调用时只需要from yourpackage.services import UserService不用管UserService到底定义在services下的哪个文件。这样重构文件位置时改动被限制在一个模块内部。3. 从零搭一个带pyproject.toml的标准Python项目3.1 项目元信息与构建配置现在Python项目的基础配置基本都集中在pyproject.toml里了这东西取代了以前一堆setup.py、setup.cfg、requirements.txt混用的局面。我建议新项目一律从pyproject.toml开始。一个最简配置文件长这样[build-system] requires [setuptools61.0] build-backend setuptools.build_meta [project] name yourpackage version 0.1.0 description A sample Python project structure readme README.md requires-python 3.10 dependencies [ requests2.31.0, pydantic2.0.0, ] [project.optional-dependencies] dev [ pytest8.0.0, ruff0.4.0, mypy1.8.0, ] [tool.setuptools.packages.find] where [src] [tool.pytest.ini_options] testpaths [tests] [tool.ruff] line-length 100 target-version py310重点说两个细节。第一[tool.setuptools.packages.find]里的where [src]告诉构建工具去src目录下找包这是配合src布局的关键。如果你用扁平布局这里是where [.]。第二把开发工具依赖放到optional-dependencies里这样生产环境安装时只需要装运行依赖开发环境多装测试和代码检查工具互不干扰。3.2 把业务代码放进src包的完整流程假设你现在要写一个内部数据采集工具按照前面说的骨架我建议按下面流程创建。第一步建目录结构mkdir -p src/collector/{models,services,repositories,api,utils} mkdir -p tests/{unit,integration,fixtures} mkdir -p scripts docs第二步在每个子目录下放一个空的__init__.py。很多人问__init__.py一定要有吗在Python 3.3之前没有它目录就不算包现在有命名空间包机制没有__init__.py也能导入但我不推荐这么做。加上它你可以在里面导出模块的公共接口还能放包版本号、初始化逻辑是规范化的第一步。第三步写入口文件。入口文件不要叫main.py放到根目录那样会把根目录再次变成包的一部分。更好的方式是让src/collector/__main__.py作为命令行入口然后在pyproject.toml里声明[project.scripts] collector collector.__main__:main这样用户执行collector命令就能启动程序内部自动调用你的main函数。程序入口、业务逻辑、数据访问全部解耦。3.3 测试与配置文件的落位测试文件放哪里也是项目结构的一个高频争论点。我的建议是tests目录放在项目根目录和src平级目录内部再按类型拆成unit和integration。单元测试要无外部依赖不连数据库、不调真实接口跑起来要快。集成测试可以连测试库、打桩外部服务用来验证模块之间的协作。conftest.py是pytest的夹具集中地。测试用的数据库连接、临时目录、HTTP mock都放到这里。注意conftest.py的作用域很有讲究放在根目录的conftest.py对全部测试生效放在某个子目录只对该目录生效。我一般只放根目录一个子目录除非特殊需求不乱加。配置文件方面项目根目录放一个.env.example把需要的环境变量全部列出来比如数据库连接串、日志级别、接口密钥。真实的.env文件不进版本库。.gitignore里要写清楚哪些文件永不提交包括.env、__pycache__、.pytest_cache、*.egg-info、dist和build目录。提示配置里的默认值一定要谨慎。我见过默认数据库连到本机内存库结果线上环境忘了配环境变量程序正常启动但数据全写到了临时内存里。默认值应该选安全且明显不合适的宁可启动失败也不要默默用错配置。4. 模块划分、导入与依赖管理的实操细节4.1 相对导入与绝对导入的正确用法导入机制是Python项目里最容易出幺蛾子的地方。先给一个结论包内部优先用相对导入对外提供接口用绝对路径导入。包内部的相对导入长这样# src/collector/services/collect.py from ..models import RawData from ..repositories import save_data..models表示上一级包里的models模块。好处是当你把这个包整个挪到别处或者改了包名内部代码不用跟着改。坏处是如果你在src目录下直接运行某个子模块的脚本相对导入会立刻报错因为此时包上下文不存在。避免这种情况的通用办法是不要在包里写if __name__ __main__直接跑的脚本。调试时用python -m collector.services.collect这种模块方式运行它会把包上下文带上相对导入就能正常工作。绝对导入则主要用于跨大模块的引用或者当你要导入安装好的第三方库时系统天然用绝对导入。混用的时候记得一个原则不要既用相对导入又用绝对导入导入同一个模块那会让读者精神分裂。4.2 循环导入的成因与处理循环导入大概是Python项目结构里最常见的鬼打墙问题A模块导入了B模块B模块又导入了A模块于是ImportError: cannot import name。真正的成因是Python执行import语句时是运行时加载不是C语言那种编译期链接。当A导入B时A的代码还没执行完B反过来想从A拿一个还没定义的名字于是直接报错。处理循环导入有几种思路按优先级排。第一种重新划分职责。A依赖BB又依赖A通常意味着公共部分没有被抽出来。你应该把两边都依赖的逻辑抽到第三个模块C里让A和B都只依赖C。这是最干净的做法值得优先考虑。第二种把导入移到函数内部或模块底部。虽然不太优雅但作为临时方案很有效能迅速打破启动时的加载链。注意这只是止血后续应该回到第一种方案。第三种调整导入粒度。比如只要from .module_a import helper而不是from .module_a import *有时候能减少加载顺序的冲突面。下面是一个典型坏味道的例子# module_a.py from .module_b import func_b def func_a(): return func_b() # module_b.py from .module_a import func_a def func_b(): return func_a()这时候你只要在func_b内部再导入func_a就能立刻跑起来但更好的方式是想想module_a和module_b之间那部分公共逻辑是不是该抽出来了。4.3 依赖锁定与虚拟环境的管理策略依赖管理是项目结构里最容易被轻视的一块。我见过太多人在README里写先装一下依赖但没有任何东西能精确复现当时的环境。建议是所有项目都从虚拟环境起步。创建方式有很多可以直接用Python自带的venvpython -m venv .venv source .venv/bin/activate pip install -e .[dev]-e是editable模式也就是以开发模式安装当前包。这样你改src目录下的代码导入时立刻生效不需要反复重新安装。我强烈推荐这一步它让源码即已安装包变成事实测试时导入的就是最新代码。锁定版本方面生产项目建议同时维护pyproject.toml里的粗粒度依赖比如requests2.31.0和一个锁文件记录精确版本。日常开发中直接改pyproject.toml来增加依赖然后更新锁文件。不要往仓库里提交requirements.txt一个来源能解决的事不要搞两个入口。如果你一个人维护项目、又希望省事我建议直接上带锁文件的项目管理工具。这类工具能自动创建虚拟环境、解析依赖版本、生成锁文件体验比手工组合venv加pip流畅得多。不愿意引入新工具的话就用最朴素的方式改依赖后写清楚变更说明锁文件认真维护不要偷懒。5. 常见问题与排查技巧实录5.1 从单文件长成大项目的演化路径很多项目不是一开始就规划完美的。你拿着一个300行的script.py跑了好几个月突然要加个Web界面、加个任务队列怎么平稳演进我的建议是不要急着大重写先做抽屉化。把原来的脚本按职责拆成函数再按函数之间的调用频率分组慢慢把每组塞进独立的模块。这一阶段结构允许丑一点但要让每个模块的职责边界先立起来。第二步确定入口和业务分界。如果你要加Web界面新建一个api/模块把所有HTTP路由放进去这些路由只做参数解析和结果返回具体逻辑继续调用原来的业务函数。等业务函数稳定了再把它们迁入services/。第三步把测试补上。结构混乱的时候写测试最难但恰恰因为难写出来的测试才最有价值。优先给最核心的业务逻辑补测试哪怕只有十几个用例也能在后面的重构里救你无数回。我自己经历过一次大重构整个项目从扁平结构改成src布局几百个接口之所以敢动就是因为核心用例都攥在手里。5.2 测试总是导入失败的几类原因被问得最多的就是No module named这个错。归结起来常见原因就这几类。第一类忘了用开发模式安装。测试里导入的是collector你的环境里根本没装这个包自然报错。解决办法就是执行pip install -e .[dev]。第二类目录结构放错了。你把包目录放在tests里面了或者tests目录下多了不该有的__init__.py。tests目录本身不要设成包pytest有能力自己发现测试文件硬加__init__.py反而会改变导入路径的解析逻辑。第三类pyproject.toml里testpaths配置了错误路径或者pytest跑在了错误的目录。我建议统一在项目根目录跑pytest不要进子目录跑。子目录跑测试时根目录的conftest.py可能加载不到夹具全部失效。现象大概率原因处置ModuleNotFoundError: No module named collector包没安装到环境执行pip install -e .[dev]测试全绿但发布后报导入错误用了扁平布局测试导入的是本地路径改用src布局夹具不起作用运行目录有误回到项目根目录执行pytest子模块里用了相对导入却直接运行脚本包上下文缺失改用python -m方式运行5.3 项目模板与脚手架工具的正确用法结构稳定下来以后每次新建项目都手敲文件夹太累了自然会想用项目模板。社区里有现成的模板生成工具能根据配置文件快速生成整个项目骨架包括pyproject.toml、测试目录、文档目录和CI配置。我的建议是模板可以直接用但不要无脑复制标准模板。标准模板是为通用场景设计的往往包含很多你的项目用不上的内容比如复杂的CI矩阵、多平台打包、各种文档主题。我用标准模板生成后通常会做三处裁减把docs精简成一篇README、一个CHANGELOG把CI配置砍到只跑主分支的测试把src内部的包层级改成自己项目的业务模块名。模板的意义是起点不是终点。真正值钱的是你已经理解每个目录的存在原因这样模板里的每一行你都吃得透需要调整时知道往哪动。对我来说一个干净的项目结构最好的状态是新同事接手不需要看文档也能大致猜到每个文件该去哪里找三个月没动的模块重新打开时还能快速回到上下文别人提PR的时候改动范围是局部的、可预期的。这些感觉很难量化但维护过大型项目的人一定懂。最后再分享一个小技巧每次准备重构项目结构之前先给当前项目拍一张依赖关系快照。用现成的工具画出模块之间的导入关系确认哪些模块是核心枢纽、哪些是孤立节点。重构完再画一次对比两张图你会清楚地知道结构调整带来的影响。把这件事养成习惯项目结构就再也不会成为你心头的负担了。