项目结构这个话题估计每个写过一段时间 Python 的人都会经历从“能跑就行”到“不得不重新理一下”的过程。我刚开始接触 Python 的那几年代码基本是单脚本结构一个文件写到底函数、类、全局变量全堆在一起。说实话在只有百来行的时候挺爽改起来也够快。但当业务逻辑越来越多脚本长到上千行之后我发现事情开始不对劲了改一个函数另一个地方莫名其妙崩了想加一个配置项要翻遍整个文件测试更是无从下手因为根目录和业务代码搅在一起压根不知道该测哪个。后来我参考了一些开源项目的目录组织方式又在自己带过的几个项目里反复试错才算整理出一套比较顺手的 Python 项目结构。这篇内容就把我对项目结构组织的思路、具体模板、依赖管理、常见坑一次性讲清楚适合正在从脚本开发转向工程化开发的同学。1. 从脚本到工程为什么项目结构值得认真设计1.1 脚本时代随写随用工程时代需要边界很多人入门 Python 是从一个脚本开始的比如写个爬虫、处理表格数据、批量重命名文件。脚本的特点是“面向过程、面向一次性”它只有一个目标把当前任务跑完。脚本不需要考虑明天还要不要维护不需要考虑别人怎么看你的代码更不需要考虑测试和部署。在这个阶段结构确实不重要强行设计结构反而会增加负担。但工程化开发完全是另一回事。一个工程化的 Python 项目通常有这些特点需求会持续变更、会有多人协作、会依赖很多第三方库、需要有自动化测试、将来可能要部署到服务器或者交付给其他人使用。这些特点决定了代码必须被拆分成边界清晰的模块不然项目越大沟通和改动成本就越高。我见过太多项目说大不大说小不小却因为结构混乱导致开发效率极低。比如两个人同时改了某个模块合并代码时冲突成一片比如新增一个配置项不知道放在哪个文件里最后到处都有硬编码再比如测试写不出来因为所有逻辑都耦合在入口脚本里。这些问题不是代码水平的问题而是结构设计的问题。结构设计本质上是在为代码划分边界让每个模块知道“自己该管什么不该管什么”。1.2 坏结构最常见的三个信号如果非要用一句话概括坏结构那就是“看起来每个文件都能跑但合在一起之后谁也说不清楚整个系统怎么运转”。具体来说我在实际项目里看到最多的坏结构信号是下面三个。第一个信号是 import 关系混乱。模块之间互相引用A 导入 BB 又导入 A或者 C 同时依赖了 A 和 B而 A 和 B 又各自依赖 C。这种结构一旦出现哪怕只有五六个模块你也会发现改一个文件需要连带检查一堆文件。更麻烦的是这类问题通常是在运行时才暴露的编辑器里看着不报错一跑就炸。第二个信号是配置文件散落。有人把数据库连接参数放在 settings.py有人放在 yaml 文件里还有人直接写死在代码里。配置文件散落造成的后果是换一个环境部署你得手工修改 N 个位置漏掉一个就等着线上出问题。真正常见的做法是把配置集中到项目的一个入口区域并且通过环境变量注入运行时参数这样代码本身不需要改环境变了配置跟着变。第三个信号是测试代码无从下手。如果业务逻辑和外部依赖数据库、网络请求、文件读取全部耦合在一起你写测试的时候会发现只能在真实环境里跑一旦离了真实服务就测不了。这通常是因为项目缺少“业务逻辑和外部副作用分离”的结构设计。一个健康的项目应该能把核心业务逻辑抽象成纯函数或独立服务测试时很方便 mock 外部依赖。1.3 结构设计不是“规定目录”而是划分责任每次说到项目结构总有同学觉得“目录结构就是个形式条条大路通罗马”。这话对了一半。目录结构确实是形式但好的形式能倒逼你思考责任划分。你想想一个厨房如果把备菜、炒菜、洗碗的空间搅在一起做饭的时候一定会手忙脚乱。代码也是同样的道理包目录就是代码的“厨房隔间”它告诉你每个类、每个函数应该在哪个空间里活动。我习惯把一个 Python 项目按职责拆成几层入口层、接口层、业务逻辑层、数据访问层、公共基础设施层。入口层负责接收外部请求接口层负责参数校验和数据序列化业务逻辑层负责核心规则数据访问层负责和数据库或其他外部服务打交道公共基础设施层放日志、配置、通用工具。这五层之间有明确的依赖方向只能是上层依赖下层不能反过来。你在搭目录结构时脑子里始终要有这张依赖地图目录只是地图的物理体现。2. 一个干净的基础目录结构从零搭起标准模板2.1 src 布局 vs 平铺布局我为什么选 src layoutPython 项目的目录布局最常见的有两种一种是“平铺布局”项目根目录直接放你的包目录另一种是“src 布局”项目根目录下先放一个 src 目录再把你的包目录放进 src 里。这两种布局我都在真实项目里用过踩过坑之后我现在更推荐 src 布局。平铺布局长这个样子project_root/ ├── my_package/ │ ├── __init__.py │ └── module.py ├── tests/ ├── requirements.txt └── README.mdsrc 布局长这个样子project_root/ ├── src/ │ └── my_package/ │ ├── __init__.py │ └── module.py ├── tests/ ├── pyproject.toml └── README.md表面上看只是多了一层 src实际区别很大。平铺布局下如果你不安装项目而是直接在项目根目录跑 Python 脚本Python 会把根目录加入模块搜索路径这时import my_package当然没问题。听起来很好但问题在于这种方式特别容易让你依赖“当前目录”。一旦换到别的环境或者别人用不同方式运行你的代码导入就会失败。src 布局强制你先安装项目再用统一的包名导入。也就是说开发时必须通过pip install -e .把项目装进当前虚拟环境之后无论在哪个目录下运行 Python都能正常导入。这种方式更接近真实的生产环境也避免了很多隐藏的路径问题。我第一次把一个项目从平铺改成 src 布局时不少 import 诡异报错直接消失了。2.2 顶层文件的职责README、pyproject.toml、.gitignore不管选哪种布局项目根目录总会有几个顶层文件。很多人不太在意这些文件认为它们只是“摆设”但我觉得恰恰是这些“摆设”决定了一个项目能不能被其他人快速接手。首先是 README。README 不是给机器看的是给人看的。它应该回答三个问题这个项目是干什么的怎么安装怎么运行我见过很多项目代码写得不错README 却只有一行标题。后来接手的人只能靠读代码猜意图耗时又痛苦。好一点的 README 至少要有项目简介、环境要求、安装步骤、运行方式、测试命令和目录结构说明如果项目有特殊配置也应该写清楚。然后是 pyproject.toml。这是现代 Python 项目的标准配置文件前后端框架都认它。它统一管理项目元数据、依赖、构建系统、工具配置。相比散落多个配置文件集中在 pyproject.toml 里方便得多。后面我单独讲依赖管理时会再展开。最后是 .gitignore。它解决的是“不要把不该提交的文件提交上去”的问题。通常需要忽略虚拟环境目录、缓存文件、环境变量文件、日志、构建产物等。很多新手一上来就把 .venv 整个目录推到代码仓库小项目还好大项目会引发一堆连锁问题代码评审也能把你烦死。2.3 包内模块如何划分按功能域而非按层级包内模块怎么拆是项目结构的核心命题。我见过一种常见的拆法按技术角色拆比如 models 目录放所有数据模型services 目录放所有业务逻辑apis 目录放所有接口定义。这样的拆法在早期逻辑少的时候还行但项目一变大就出问题。因为“所有数据模型”放在一起会导致各个业务域的模型互相依赖你改一个用户模型的字段订单模块可能莫名其妙被影响。我建议按功能域拆也就是把业务按模块划分每个模块内部再按技术角色组织。举个例子假设你在写一个订单系统可以拆成 user、order、payment 三个功能域。每个功能域内部有自己的 models、services、apisrc/ └── order_system/ ├── user/ │ ├── models.py │ ├── services.py │ └── api.py ├── order/ │ ├── models.py │ ├── services.py │ └── api.py └── payment/ ├── models.py ├── services.py └── api.py这样拆分的好处是模块之间的边界非常自然。订单模块知道用户模块存在但用户模块不需要依赖订单模块。哪怕以后用户模块大改订单模块的迁移成本也可控。团队协作时每个人负责一个功能域冲突率会低非常多。我最初做结构设计时习惯先把所有公共类按类型放后来发现代码量超过几万行后非常痛苦按功能域拆之后整个项目的可维护性提升了一个档次。2.4 配置文件与常量管理避免到处都是魔数代码里最让人头疼的不是复杂的算法而是隐藏在业务逻辑里的“魔数”和“硬编码字符串”。比如某段代码里写了一个奇怪的超时时间 3.5没人知道为什么是 3.5也不敢改。又比如数据库连接串直接写在某个函数里一旦环境切换所有位置都要跟着动。我现在的做法是在包内单独配置一个 config 模块用配置类或者数据类统一管理所有可变参数。比如数据库地址、缓存超时、外部接口地址、开关功能列表这些都应该有明确的配置入口而不是散落在各个文件中。环境相关的敏感信息比如密码和密钥不建议直接写进代码而是通过环境变量或者本地配置文件注入。这里有个细节要注意不要在模块导入时就去读环境变量或配置文件。因为导入阶段如果有副作用很容易引起循环导入或者导致测试环境、生产环境切换时行为不一致。更好的做法是等程序真正运行到初始化阶段再构建配置对象。简单说config 模块只负责定义配置项和加载逻辑不负责在 import 时执行加载动作。3. 依赖、环境与可复现构建结构之外的工程实践3.1 虚拟环境与锁文件的价值目录结构只是项目健康的一部分真正让项目在团队里跑起来还要靠依赖和环境管理。很多新手会问为什么不能用电脑全局的 Python 环境我在几年前也这么干过直到某天升级了系统全局里的一个第三方库导致另一个项目直接退出工作从那以后我就老老实实为每个项目建独立虚拟环境。虚拟环境的作用是隔离依赖让每个项目可以拥有自己的一套第三方库版本。即便两个项目需要同一个库的不同版本也不会互相影响。在项目根目录创建虚拟环境一般会得到一个 .venv 目录用它来安装依赖。这一步看起来基础却是整个可复现构建的地基。锁文件的价值更高。简单说锁文件把“直接依赖”的精确版本以及它们的传递依赖版本都固定下来。这样哪怕某个小改动导致新版本不兼容团队其他成员和环境里安装的依赖版本也完全一致。我曾经的经历是开发环境一切正常但一道部署命令下去生产环境跑出来的结果和本地完全不一样查到最后发现是第三方库版本不一致导致。锁文件就是为了彻底消灭这种“在我机器上是好的”问题。3.2 requirements.txt 还是 pyproject.toml这个问题在项目里经常被问到。不能简单说谁好谁坏得看场景。如果你维护的是一个老项目已经用 requirements.txt 管理依赖短期内没必要强行迁移。但我会建议至少在 requirements.txt 里区分“基础运行依赖”和“开发依赖”比如拆成 requirements-base.txt 和 requirements-dev.txt避免所有人安装全部开发依赖增加无谓的安装风险。如果是新项目我更倾向于把所有依赖和项目元数据都写进 pyproject.toml。因为 pyproject.toml 不仅管依赖还能配置构建、格式化工具、静态检查、打包发布规则。它把项目相关的信息集中到一个文件里别人接手项目的时候只需要看一个文件就能了解项目怎么运行、怎么测试、怎么打包。而且现代依赖解析工具基本都原生支持 pyproject.toml不需要额外维护多套文件。不管用哪种方式最关键的一点是安装项目本身而不是靠调整来个乱七八糟的 PYTHONPATH。我见过很多项目依赖列表里没有项目自己的包名代码导入全靠“恰好当前目录是项目根目录”。一旦别人换目录运行脚本就出现找不到模块的报错。正确的方式就是把项目作为本地包安装到虚拟环境里一切导入都基于包名而不是路径。3.3 测试目录和测试代码组织结构测试代码的位置和组织方式也能直接影响项目结构是否健康。我一般把测试统一放在项目根目录的 tests 目录下按被测模块拆分子目录。比如被测模块有 user、order、payment测试目录下也对应有 test_user、test_order、test_payment。测试文件命名用test_xxx.py这样测试框架可以自动发现全部测试。测试代码需要做到尽量不依赖运行目录。什么意思就是你不管在项目根目录执行测试还是从 tests 目录执行测试结果都应该一样。为了达到这个目标我通常会让测试通过项目的公开接口来导入被测代码而不是通过相对路径去 import 某文件。如果你用了 src 布局并且把项目安装到了虚拟环境这个问题会天然少很多因为import order_system能稳定工作不受当前工作目录影响。另外测试本身也要注意“让外部副作用可控”。比如真实数据库、外部 HTTP 调用、文件系统读写最好都通过依赖注入或测试替身来控制。如果测试代码里到处是真实网络请求那测试就不是一个可靠的反馈工具而是一个随机失败的制造器。我在项目里习惯把测试分成单元测试和集成测试单元测试完全不用外部资源集成测试才去连真实依赖而且专门打标签这样平时持续跑的快速测试不会因为网速波动就一片红。3.4 示例一个实际项目的目录树奉上一个我比较常用、也推荐给很多项目的结构模板。假设你正在做一个叫 example_service 的后端服务我的习惯目录大致是这样project_root/ ├── src/ │ └── example_service/ │ ├── __init__.py │ ├── config.py │ ├── models/ │ │ ├── __init__.py │ │ ├── user.py │ │ └── order.py │ ├── repositories/ │ │ ├── __init__.py │ │ ├── user_repository.py │ │ └── order_repository.py │ ├── services/ │ │ ├── __init__.py │ │ ├── user_service.py │ │ └── order_service.py │ ├── api/ │ │ ├── __init__.py │ │ ├── dependencies.py │ │ ├── routes_user.py │ │ └── routes_order.py │ ├── core/ │ │ ├── __init__.py │ │ ├── log.py │ │ └── exceptions.py │ └── main.py ├── tests/ │ ├── conftest.py │ ├── test_user.py │ ├── test_order.py │ └── integration/ ├── pyproject.toml ├── README.md └── .gitignoremodels 放数据模型repositories 放数据访问services 放业务逻辑api 放路由和入参校验core 放跨模块的公共支撑。你会发现数据模型不会直接和 API 层打交道业务逻辑也不会直接写 SQL每一层只依赖下一层。这个模板不一定适合所有场景但如果你正愁不知道怎么搭可以直接照这个起步。4. 模块划分与导入避免循环导入的设计原则4.1 循环导入是怎么产生的循环导入算是 Python 项目里最常见、也最让新人崩溃的问题之一。经典的报错是ImportError: cannot import name xxx from partially initialized module。很多人第一次遇到的时候会一脸懵明明模块在那儿怎么就导不入了要理解这个问题得知道 Python 导入模块时做了什么。当 Python 第一次导入一个模块时会从上到下执行模块里的代码这个过程会先把模块放进系统缓存再逐行执行。如果模块 A 在执行过程中需要导入模块 B它就会去加载模块 B而 B 又需要导入 A这时候 A 可能还没完全执行完某些名字还没定义于是 B 拿不到 A 里还没定义的那个对象报错就来了。循环导入的根源是模块之间的依赖关系成了环。最常见的原因是“为了用另一个模块里的一个函数或常量直接反向 import 回去”。举个例子订单模块需要用户模块里的一个验证函数而用户模块又为了某个通知逻辑导入了订单模块最后两边的依赖就缠住了。4.2 用依赖方向约束模块边界解决循环导入首要原则是让模块依赖方向成为单向图。什么是单向图你可以把模块想象成一栋楼的楼层接口层在最上面业务逻辑层在中间数据访问层在最下面。上面可以调用下面下面不能调用上面。如果出现下面模块需要调用上面模块里的东西通常是职责放错位置了。我在实际项目中通常会用“依赖方向检查”来约束自己。具体做法是在项目结构设计阶段先画出模块之间的依赖草图。如果发现两个模块之间要双向依赖我就会停下来问自己这个功能是不是应该放到更底层的公共模块里或者说是不是设计上的依赖方向反了实际处理时经常要把公共的部分下沉。比如两个业务模块都要用到一个时间格式化函数与其 A 依赖 B、B 依赖 A不如把这个函数放到 core 公共模块里然后 A 和 B 都去依赖 core。这样环就破开了模块边界也清晰了。我见过有人为了避免循环导入在代码里到处做“函数内延迟导入”说实话这是治标不治本。4.3 延迟导入是不是好主意“延迟导入”是指在函数内部再去 import而不是在模块顶部 import。这确实能绕开一些循环导入问题但我不建议把它当成常规方案。因为延迟导入会让模块顶部的依赖信息变得不完整别人看代码时不知道这个模块到底依赖了谁需要翻遍每个函数才能理清关系同时延迟导入会延迟错误暴露时间某个依赖缺失可能要等运行到那个函数时才发现而不是项目启动就报错。我自己的经验是延迟导入可以作为一种过渡手段但绝不是结构设计目标。如果你发现自己不得不用大量延迟导入才能跑通项目说明模块边界已经出问题了。正确做法是回到依赖图上找出环在哪里然后把真正被共享的东西抽到更低层。比如两个模块都依赖对方的一个配置对象那就先问问这个配置对象是不是应该放到 config 或 core 模块里。把共享的东西往下抽一层环自然而然就断了。4.4 用接口隔离弱化耦合除了依赖方向我还喜欢用“接口隔离”的办法来避免过多模块耦合。具体来说模块之间不直接依赖具体实现而是依赖一个接口定义。比如业务逻辑层需要访问数据但我不会让业务代码直接引用某个具体的数据访问类而是定义一简单的数据访问协议让业务代码依赖这个协议具体实现由数据访问层提供。Python 里可以用Protocol来实现这个模式。这样做的最大好处是数据访问层换成别的实现时业务逻辑层完全不用动只要新实现满足协议即可。结构上接口通常放在被依赖方那一层或者放到公共核心层避免模块间“为了用一下接口反过来 import 实现类”的尴尬。我的体验是使用接口隔离之后循环导入的概率会大幅降低因为依赖关系变成了依赖协议而不是依赖具体对象。5. 常见问题与排查技巧实录5.1 明明在同一个目录为什么 import 不到这个问题在刚切换项目结构时几乎一定会遇到。现象是你的代码文件和目标模块在同一个目录下你用编辑器打开时智能提示也正常但命令行一跑就报ModuleNotFoundError。原因在于 Python 的模块搜索路径并不永远包含当前文件所在目录而是取决于你启动脚本的方式和当前工作目录。解决这个问题有三个途径。第一个途径是尽量用模块方式运行python -m my_package.main这种方式会把项目根目录加进模块搜索路径比较符合包管理的直觉。第二个途径是把项目安装进虚拟环境利用包名导入。第三个途径是检查项目里是否不小心创建了和标准库同名的目录比如utils.py、logging.py这种目录会遮蔽标准库造成各种奇奇怪怪的导入问题。排查时我习惯先打印sys.path看看当前模块搜索路径里到底有没有项目根目录。如果没有就用上面三个途径之一补上。别一上来就怀疑代码逻辑大多数导入问题都是路径问题。5.2 测试跑不通根目录路径问题怎么排查测试目录的路径问题比普通脚本更隐蔽。因为很多测试框架在执行测试时会把 tests 目录加入模块搜索路径而不是项目根目录。此时如果你在测试代码里import project_root.something很可能会失败。即使不失败当你依赖相对路径读取测试数据文件时也可能因为工作目录不同而找不到文件。针对 pytest 这类测试框架我常用的做法是在项目根目录的配置文件里设置好 pythonpath。比如在 pyproject.toml 中配置测试工具让它把项目根目录或 src 目录加入模块搜索路径。同时在测试代码里尽量避免读取“当前工作目录”下的文件应该使用文件相对于测试文件的路径来计算这样做能保证测试在任何目录下执行都稳定。排查时可以先看测试执行时的工作目录是什么再打印sys.path。如果发现 tests 目录被加入而项目根目录没被加入那就配置一下 pythonpath。如果项目已经正确安装到虚拟环境src 布局下一般不会出现这个问题所以我会优先建议安装项目而不是一直靠调路径来修复。5.3 重构时如何保持项目结构稳定重构项目结构的过程中最容易犯的错误是“大爆炸式重构”也就是花两天时间把所有文件全部搬到新目录然后统一改导入。这种做法的风险极大中途一旦出现问题整个项目处于半旧半新的状态根本没办法快速定位是哪个改动引入的 bug回滚也非常麻烦。我推荐的方式是“渐进式迁移”。先把新结构目录搭出来并保留旧结构的入口文件作为兼容层。每迁移一个模块就跑一遍该模块对应的测试通过后再继续迁移下一个。整个过程保持项目随时可运行只是旧入口逐步被淘汰。迁移完成后再统一删除旧入口文件做一轮全量回归。另外重构前一定要确认测试覆盖到位。没有测试做保护的重构就像不带安全绳爬楼。如果之前没有测试先把核心路径的测试补上再动结构。我在重构过程中吃过不少亏后来养成的习惯是动结构之前先跑一遍全量测试动完之后立刻再跑一遍任何一步测试变红都要停下来看原因。5.4 快速自检清单结构是否健康最后我整理了一个很短但很实用的自检清单你可以拿来自查项目结构健不健康。简单粗暴但够用。检查项健康的表现危险的表现安装与导入用包名导入无需依赖当前目录靠相对路径或手动改 sys.path 才能运行测试可复现在任何目录执行测试结果一致测试依赖工作目录或外部真实服务依赖方向单向依赖无循环导入模块间互相 import需要延迟导入打补丁配置集中配置集中在 config 模块可通过环境变量覆盖配置散落各处硬编码多模块范围按功能域组织边界清晰按技术角色堆一堆改一处处处受影响顶层文件pyproject.toml、README、.gitignore 齐全只有代码其他靠口头传播如果你发现项目里有多条“危险”表现别慌这很正常很多项目都是从不健康慢慢改健康的。关键是从现在开始有意识地调整而不是继续让结构问题积累。6. 后续扩展从单项目结构走向多项目与统一规范6.1 私有工具库怎么复用封装当你在一个项目里沉淀出一些通用能力比如统一的日志样式、数据校验函数、字符串处理工具你会开始考虑跨项目复用。这时候最应该做的不是把代码复制到每个项目里而是把这些公共组件抽成一个独立的包发布到团队私有的包索引里然后让不同项目通过依赖安装来使用它。抽独立包的时候依然推荐使用 src 布局因为这样最规范。独立包版本需要严格管理项目里通过锁文件固定版本避免公共库升级导致多个项目行为不一致。我在项目里习惯给公共库设定清晰的 API 边界公共库只暴露少量函数入口内部实现可以随便改但接口不能随意变。这样下游项目升级成本低维护公共库的人也轻松。6.2 代码风格和静态检查工具项目不止一个人写的时候代码风格统一特别重要否则代码评审会把大量时间花在“你换行我回车”的无谓争论上。现在比较好的做法是在项目根目录的 pyproject.toml 里配置格式化、排序和静态检查规则让工具自动修正大部分风格问题。比如使用代码格式化工具统一缩进、引号、换行使用 import 排序工具自动整理导入顺序再用静态检查工具找出未使用变量、未定义名称、可读性较差的写法。我自己的习惯是在编辑器里配置保存时自动格式化再配置提交前自动检查。上线的代码先过机器这一关再让人类评审关注真正的逻辑问题。这套组合下来项目结构会在协作过程中保持稳定而不是越写越乱。6.3 我的一些体会和习惯写到最后分享一些我个人在整个结构演进过程中沉淀下来的体会。第一先画依赖草图再写代码。很多项目结构乱是因为一开始大家都不清楚模块之间应该怎么依赖写多了就乱了。我现在哪怕只写一个很小的工具也会在脑子里过一遍哪些功能放哪个模块模块之间从哪里依赖到哪里。理清楚之后再动手速度反而更快。第二测试接口先于实现。我习惯在日常开发中先想清楚某个模块对外暴露的函数签名然后再去写实现。这样不仅让模块边界更明确还能保证模块间的依赖关系不会乱掉。一个项目如果每个模块都能独立测试结构通常不会差到哪里去。第三定期做小规模重构。项目结构不是一成不变的它会随着业务演进慢慢走形。与其等大问题爆发再动大手术不如每隔一段时间就做一次小清理比如把某个越界的功能挪回正确模块删掉不再使用入口统一一下配置位置。这些零碎动作累积起来会让项目保持长期健康。如果你现在正被一个几千行、结构混乱的 Python 脚本困扰我的建议是不用等“有空再重构”今天就可以先拆分文件用一套清晰的结构装下未来的复杂度。项目结构不是一条死板的规则而是一个帮助你持续开发的工具。真正合适的结构是你和你的团队成员都能一眼看懂、改起来不慌、跑起来稳定的结构。