
我接过一个让我印象深刻的Python项目。打开目录里面只有一个文件大概5000多行从用户登录到数据清洗从画图到发邮件全部硬塞在一个文件里。跑起来倒是能跑但每次改动都在赌运气——改一个函数可能牵扯到三个地方想找某段逻辑得靠编辑器的折叠面板一层一层往下翻。项目能运行和项目能维护完全是两回事。我后来花了一个周末把它拆开重排过程中踩了不少坑也重新想清楚了一个问题Python项目结构到底该怎么组织。这篇东西就是围绕“Python项目结构如何组织代码”这个主题写的。我会先从“好结构要解决什么问题”说起再讲从小脚本到大项目的演进过程然后是导入机制这个最容易被忽略的底层逻辑接着是配置、依赖、测试这些骨架设施怎么摆最后给几类常见项目的目录模板以及我在实际重构中踩过的坑。适合刚入门想规范化的同学也适合那些接手别人“祖传代码”正发愁怎么收拾的人。1. 先想清楚一个清晰的项目结构到底要解决什么问题很多人一提到“组织结构”第一反应是“目录怎么建”“文件夹叫什么名字”。但我更建议你反过来想结构不是目的它是用来降低项目维护成本的工具。一个Python项目从写出来到上线后面还有修bug、加功能、换人接手这个阶段的成本远高于第一版写码的成本。1.1 三个看不见的成本可读性、可测试性、可扩展性结构混乱的项目最先出问题的是可读性。一个新同事拿到项目如果打开目录完全看不出业务从哪进、逻辑在哪层、数据怎么流那他在理解上就要花掉大量时间。第二个是可测试性。所有逻辑都耦合在一起时你想给某个函数写单元测试会发现它偷偷依赖了全局变量、外部文件甚至网络请求根本没法单独测。第三个是可扩展性。加一个新功能按理说应该只动一个模块但在混乱结构里往往要连改好几个地方牵一发动全身。可以用一个衣柜来类比。好的衣柜不是把衣服全都堆在一个格子里而是按季节、按类型、按使用频率分区。常穿的外套挂在顺手的位置换季的被子收进收纳箱袜子和领带有各自的抽屉。你每天出门找衣服只要十几秒。混乱的项目结构就像衣柜里的“爆炸”状态——东西都能找到但每次都要把整个衣柜翻一遍。1.2 好结构的三个硬标准一眼定位、边界清晰、导入无环我在实际工作中判断一个项目结构好不好基本就看三点。第一一眼定位。看到文件名或者目录名不用点开就能猜到里面是什么。比如你找数据库连接的代码应该能在db/或者core/database.py里看到而不是在一个叫helper.py或者misc.py的“杂货铺”里翻半天。第二边界清晰。业务逻辑、数据访问、接口层、工具函数各有各的地盘。接口层只负责参数接收和响应返回业务逻辑层处理规则数据层只做存取。方向是单向的——上层依赖下层下层不反向依赖。第三导入无环。A模块能用B模块B模块也能用A模块这就出现了循环导入。Python虽然在某些情况下能勉强处理但这种模块之间的“环状依赖”是结构设计失败的最典型信号。后面我会专门讲这个。看一个典型的坏结构长什么样project/ ├── main.py ├── helper.py ├── utils.py ├── tools.py ├── test.py ├── config.py └── requirements.txt所有文件堆在根目录文件名都是“万能型”的。helper.py里既处理日期又发邮件还做数据校验utils.py里也有一堆重复的函数。你想问某个函数在哪只能全局搜索。这种结构几乎没有任何信息量等于告诉别人“我懒得想”。好结构通常长这样project/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── api/ │ ├── core/ │ ├── models/ │ ├── services/ │ └── utils/ ├── tests/ ├── requirements.txt └── README.md对比一下就能感受到差别好结构里每个目录的名字都在回答“这段代码负责什么”而不是“这段代码大概能干嘛”。这就解决了最关键的定位问题。2. 从小脚本到大项目项目结构的三个演进阶段项目结构不是一步到位的。我见过太多人一开始就按照网上大项目的模板铺一堆目录结果项目本身只有几百行撑不起这个骨架反而觉得每个文件都是空壳读起来特别累。结构应该跟着项目复杂度走分阶段演进。2.1 阶段一单脚本时代什么时候就应该拆了写数据分析脚本、写一个小爬虫、写一个自动化处理任务一个.py文件完全够用。这个阶段强行做模块拆分是在给代码加不必要的复杂度。但单脚本也有阈值我自己总结了几条判断标准命中两条以上就该考虑拆分了文件超过500行往上翻一遍都要半天同一个文件里既有“获取数据”又有“处理数据”还有“保存结果”你发现自己在用# 这里是XXX功能这种注释来回跳转一段逻辑要在文件里复制粘贴好几次只是参数不同if __name__ __main__:下面堆了一大串启动代码。比如你写了一个爬虫一开始简单就是一个main.py从头跑到尾。后来加了代理池、加了重试机制、加了数据清洗还要存到数据库这时候所有功能挤在一起每加一个功能都要在无数个函数之间找位置。正确做法是趁早拆哪怕只是拆成同目录下的几个平级模块。spider/ ├── main.py ├── fetch.py ├── parser.py ├── storage.py └── config.py这样拆还是扁平结构但每个文件都只做一件事。fetch.py管网络请求parser.py管解析storage.py管存储config.py放常量。改超时时间去fetch.py或者config.py改解析规则去parser.py世界的秩序一下就清晰了。2.2 阶段二按功能拆模块包的概念自然出现当你的代码拆出来的模块变多比如fetch.py、parser.py、storage.py都各自有了好几个配套文件平铺的目录也会乱。这时候就该引入“包”的概念——用目录来组织模块每个目录就是一个包目录里放一个__init__.py文件。真正的转折点在于你发现有一些模块是“内部实现细节”不想让外部直接 import。比如爬虫项目里的fetch.py可能需要一个proxy_manager.py来管代理storage.py需要一个schema.py定义表结构。如果都摊在根目录开目录一看十几个文件还是懵。更好的组织方式是把相关的东西收进各自的包spider/ ├── main.py ├── fetcher/ │ ├── __init__.py │ ├── client.py │ ├── proxy.py │ └── retry.py ├── parser/ │ ├── __init__.py │ ├── html_parser.py │ └── json_parser.py ├── storage/ │ ├── __init__.py │ ├── db.py │ └── schema.py └── config/ ├── __init__.py └── settings.py此时main.py只负责编排流程调用fetcher.client拿数据交给parser.html_parser解析再让storage.db存库。每个包内部怎么做包外面的代码不需要关心。这就是封装也是结构和业务复杂度匹配的开始。2.3 阶段三src布局与项目化为部署和测试做准备再到项目规模更大一点比如要上线部署、要写自动化测试、要发布成可安装的库这时候“把代码放在项目根目录”的方式就会遇到麻烦——测试代码和源码混在一起直接 import 本地的项目路径容易产生各种路径相关的隐秘问题。这时候推荐src布局myproject/ ├── src/ │ └── mypackage/ │ ├── __init__.py │ ├── core/ │ ├── api/ │ ├── services/ │ └── utils/ ├── tests/ │ ├── test_core.py │ └── test_api.py ├── pyproject.toml ├── README.md └── .gitignoresrc布局的核心思想是把你的包当成一个真正要被“安装”的包来处理你的代码不是靠“它在当前目录下所以能被 import”而是靠项目配置被正确识别。测试代码放在独立的tests/目录里和源码彻底分离。这种布局最直接的好处是测试时不会因为“当前目录恰好包含模块”而误打误撞通过你必须让包被正确安装或者配置好路径。很多老手都推荐在能发布的库工程里用 src 布局如果你只是写内部脚本用前面阶段的扁平结构就够了——结构永远是匹配复杂度的不是越复杂越好。3. 目录只是表象导入机制才是结构设计的底层逻辑有人会问目录结构不就是在文件系统里建几个文件夹吗还有什么底层逻辑关键在于Python的 import 机制决定了你“建的文件夹”如何被你的代码引用以及能不能被正确引用。很多结构问题表象是目录乱本质是对 import 机制的理解不到位。3.1 sys.path、包与命名空间Python 如何找到你的代码当你在代码里写import mypackage.core时Python 解释器会按照一个顺序去搜索。大致是内置模块优先然后是sys.path里列出的目录最后是当前脚本所在目录。把一段代码变成“包”靠的就是目录里放一个__init__.py文件。有了这个文件Python 才会把一个目录当作一个包来对待才能用import 目录名.模块名的方式引用。这里有一个常见的困惑有人问__init__.py到底是干嘛的小项目里它可以是一个空文件作用就是标记“这是一个包”。大项目里它还可以做“对外接口的统一出口”比如在__init__.py里显式写出from .core import main_func这样外部只需要from mypackage import main_func不需要知道内部还有几层结构。这其实是在利用包机制控制“公众API”的暴露面。3.2 绝对导入优先相对导入何时可用模块之间的互相引用有两种写法。绝对导入是from mypackage.core import module从包的最顶层开始定位路径完整、含义清楚。相对导入是from .core import module用.表示当前包..表示上一级包。相对导入在包内部很好用因为包一旦改了一级目录名绝对导入的路径全都要改相对导入不受影响。但相对导入有一个大坑当你把一个包含相对导入的文件当作入口直接运行时会报ImportError因为相对导入依赖“包上下文”。所谓包上下文就是整个包必须被以包的方式加载。实践中的建议是入口文件和启动脚本尽量只用绝对导入包内部的平级模块之间相对导入可以省事尤其是要移动整个包的位置时。我自己更偏好在内部写绝对导入牺牲一点增量重命名的便利换来的是无论从哪个角度看代码都不会产生歧义。3.3 循环导入问题与结构层面的解法循环导入是Python菜鸟和老手都会遇到的问题。A模块里写了import BB模块里又写了import A解释器在加载时就会发现“我还没加载完A你怎么要我加载B而B又要加载A”然后当场报错。这种问题往往在重构时爆发因为文件拆多了边界没理清很容易发展出互相引用的关系。本质原因只有一个这两个模块之间的职责没有分干净它们在逻辑上处于同一个层级却互相依赖。循环导入的临时解法有几种——把其中一个 import 移到函数内部延迟导入、把公共代码抽到第三个模块。但你得知道这些都是妥协不是根治。我个人的经验是一旦检测到循环导入立刻停下来审视“这两个模块真的必须互相引用吗”。如果你发现自己需要从A拿一个工具函数而那个工具函数其实和A的主逻辑无关那就把它抽到utils/或一个独立的common模块里。真正的解法永远是调整结构——让依赖变成单向的像水流一样从高层往低层流而不是在两条平级管道里来回倒腾。4. 配置、依赖与测试藏在骨架里的基础设施一个项目的结构除了代码之外还有一类东西很容易被忽略却决定了项目能不能被长期维护。它们是依赖管理、配置管理和测试代码的组织。这部分不弄好你会遇到“换台电脑就跑不起来”“上线之后才发现配置不对”这种低级问题。4.1 依赖管理requirements 还是 pyprojectPython 的依赖管理经历过一段混乱时期。小项目用requirements.txt足够了一行一个包名加版本号requests2.32.3 pandas2.0,3.0 pydantic2.0注意一下版本号的写法表示锁死版本适合部署环境表示最低版本适合开发阶段。如果你要发布一个库给别人用建议用pyproject.toml它现在是Python打包和依赖管理的规范格式。简单理解requirements.txt是“这个项目需要谁”pyproject.toml是“这个项目是什么它打包发布时需要谁构建时用什么工具”。关于依赖还有一个建议用虚拟环境隔离不同项目的依赖不要把全局环境当成垃圾桶。Python 项目结构里必须包含.venv目录或者至少让每个项目知道“自己的依赖在哪”这也是结构的一部分和运行环境的一部分。4.2 配置放哪里环境隔离的思路配置可以简单到就是一个config.py文件写着DATABASE_URL postgresql://... API_KEY xxx但真正要注意的不是配置放哪而是配置和代码怎么相处。硬编码在代码里的配置一旦换了环境本地、测试、线上就得改代码这非常危险。更稳妥的思路是让配置从环境变量里读或者用配置管理库来加载。一个典型项目里配置文件长这样import os DATABASE_URL os.getenv(DATABASE_URL, sqlite:///dev.db) API_KEY os.getenv(API_KEY, ) DEBUG os.getenv(DEBUG, true).lower() true如果你用pydantic-settings还能做得更优雅自动从.env文件读取、自动校验类型。关键原则是代码不携带环境信息环境信息从外部注入。这样同一段代码在不同的机器上只需要配不同的环境变量就能跑出不同的行为。4.3 tests目录与源码结构保持镜像测试代码的目录组织我推荐一个原则与源码保持镜像。源码里有services/order_service.py测试目录里就有tests/test_services/test_order_service.py。这样做的好处是当你需要为一个模块补测试时闭着眼睛就能定位到对应测试文件的位置。tests/ ├── conftest.py ├── test_services/ │ └── test_order_service.py └── test_utils/ └── test_date_utils.pyconftest.py是 pytest 的“全局配置”文件可以在里面定义共享的 fixture比如测试数据库连接、模拟的HTTP响应。所有测试文件自动发现这个文件里的 fixture避免了到处复制初始化代码。这个镜像原则看似简单实际省了大量查找成本——测试本身就是代码也需要被组织得一眼能定位。5. 看几个具体场景的目录结构Web后端、数据处理、库工程不同领域的Python项目结构侧重点完全不同。下面给几个我实际用过的模板不是让你照抄而是看看不同场景下的“边界”是怎么划分的。5.1 Web后端项目FastAPI 的典型分层Web后端项目是结构讨论最多的一类。以 FastAPI 为例一个比较公认的合理结构是这样的app/ ├── main.py # 应用入口创建 FastAPI 实例 ├── api/ │ ├── routes/ # 路由定义每个业务域一个文件 │ │ ├── items.py │ │ └── users.py │ └── dependencies.py # 路由依赖注入 ├── core/ │ ├── config.py # 配置 │ └── security.py # 认证、密码哈希 ├── models/ # ORM 模型 ├── schemas/ # Pydantic 请求/响应的数据结构 ├── services/ # 业务逻辑 ├── db/ │ ├── session.py # 数据库会话 │ └── base.py └── utils/为什么要这样分核心在于“路由层要薄”。api/routes/里只放路由定义和参数的接收、校验不写业务逻辑。真正处理业务的代码在services/里数据存取在models/和db/里。这样改路由不影响业务改业务不影响数据层。如果项目里出现“路由文件里写了几百行业务代码”的情况那多半是分层没有执行到位。5.2 数据处理与量化策略类项目热词里有人搜“python量化交易策略代码”这种策略类/数据处理项目的结构和Web项目完全是两回事。它的核心是数据流数据获取 → 特征构建 → 策略信号 → 回测评估 → 风险控制。我常用的结构strategy/ ├── config/ │ └── settings.yaml # 策略参数用yaml还是用代码看你习惯 ├── data/ │ ├── loader.py # 读取原始数据 │ └── clean.py # 清洗 ├── features/ │ ├── indicators.py # 技术指标 │ └── signals.py # 信号生成 ├── strategy/ │ ├── base.py # 策略基类 │ └── ma_cross.py # 某个具体策略 ├── backtest/ │ ├── engine.py │ └── metrics.py ├── risk/ │ └── position_sizer.py # 仓位管理 └── main.py数据类项目的边界划分不是按“请求/响应”来分而是按“数据的生命周期阶段”来分。数据在哪一步、特征在哪一步、策略在哪一步一目了然。尤其在做策略迭代时你经常会改特征或者改参数结构清晰能让你立刻知道改的是哪个文件而不用担心破坏其它部分。5.3 可发布的Python库工程如果你写的是一个要发布到 PyPI 的开源库或者要在多个项目中复用的内部库那结构就更讲究了。前面提到的 src 布局是标配再加上文档、打包配置、示例代码awesome_lib/ ├── src/ │ └── awesome_lib/ │ ├── __init__.py │ ├── core.py │ └── cli.py ├── tests/ ├── docs/ ├── examples/ ├── pyproject.toml ├── README.md └── LICENSE库工程的要点是“公共API要收敛”。__init__.py里明确导出哪些内容避免用户面对一整个包不知道用什么。测试要覆盖核心函数而examples/目录放最小可运行的示例代码帮助新用户快速上手。这里最忌讳的就是把内部实现的模块名暴露出去用户想用from awesome_lib import something结果发现真正的入口藏在bson_decoder这种内部模块里。三种类型的结构关注点我用表格对比一下项目类型核心边界划分依据最该关注的点最容易犯的错Web后端请求/响应链路路由、业务、数据路由层要薄业务独立业务逻辑堆在路由里数据处理/策略数据生命周期阶段数据流动方向清晰乱建一堆无意义目录库工程/发布项目公共API与内部实现隔离对外暴露面收敛内部结构毫无约束全部公开6. 实际重构中的踩坑经历与调整思路结构篇最后我想分享几个自己在实际重构项目时踩过的坑。纸上谈兵谁都会真正动手拆一个项目才会遇到各种书上没写的“意外”。6.1 循环导入发生在重构后的头号问题有次我把一个5000行的单文件拆开拆到一半运行测试直接报ImportError: cannot import name xxx from yyy。当时第一反应是某个 import 写错了结果查了半天发现是循环导入——模块A导入模块B模块B又导入模块A。原因是我把一个“计算订单金额”的函数放进了services/order_service.py而models/order.py里为了让订单序列化时能带出金额又反过来导入了这个函数。这从设计上就错了订单模型不应该依赖订单服务金额计算应该是独立的一块。最后解法是把金额计算抽到services/pricing.py让order_service和order model都依赖它谁也不依赖谁环自然就打开了。这个经历给我的教训是循环导入不只是改改 import 位置就能糊弄过去它是你模块边界划分出问题的最好提示。6.2 utils.py 膨胀所有杂项都往里塞的坏味道另一个高频问题是utils.py的膨胀。很多项目一开始都会建一个utils.py放“乱七八糟但好像有点用”的函数。慢慢地这个文件会变成一个垃圾桶有日期格式化、有文件路径处理、有字符串转换、有看似通用的数据清洗。两三年之后一个utils.py能有两千行比主业务文件还吓人。我处理过的最有效方式是“按主题拆”。先看这个文件里有哪些明确的主题群日期时间类的、文件读写类的、文本处理类的、加密哈希类的。然后把每个主题拆成独立模块utils/ ├── __init__.py ├── date_utils.py ├── file_utils.py ├── text_utils.py └── crypto_utils.py不要小看这个动作拆完之后最直观的变化是 import 语句变得有信息量了。from utils.date_utils import format_date比from utils import format_date在“传达含义”上强得多而且在 IDE 里找函数也容易很多。6.3 迁移项目的实操顺序一次只动一个文件如果你要重构的是一坨旧代码我强烈建议你按照这个顺序来而不是一股脑全拆。第一步先画出目标目录树。哪怕是在纸上画也要明确“哪个模块进哪个目录”。第二步一次只移动或拆分一个文件每动完一个文件立刻跑一遍测试。没有测试的就先补一个最小冒烟脚本至少保证能 import、不报错。第三步所有文件都归位之后再统一处理那些临时留下的兼容代码。一次只动一个文件听起来慢实际上是最省时间的。因为如果一次动了一堆文件出了问题你根本不知道是哪个操作导致。而一次动一个配合每次都跑测试出问题能立刻定位到最后一个改动。这个习惯我保持了很多年重构成功率高不少。6.4 我整理的经验清单最后给一份自己的结构经验清单都是实际踩过坑之后总结的目录深度控制在三到四层以内超过四层基本说明设计过度了命名要一致要么全用复数目录models、services要么全用单数混用会让人怀疑两个目录是不是有区别__init__.py认真对待别只当“占位符”它决定你的包对外暴露什么入口脚本只做编排和启动不要在里面写大段业务逻辑当你发现一个模块开始做“太多事”时拆不要等到彻底失控结构文档写进 README哪怕只是画一个简短的目录树也能让接手的人少死一堆脑细胞。结构这个问题从来没有“谁一定能用”的银弹。不同项目、不同团队、不同阶段答案都不一样。但有一点是共通的好的结构一定让下一步的改动变得更轻松而不是更艰难。我的建议很朴素——从你的下一个新项目开始动手写第一行代码之前先花十分钟把目录树画出来。哪怕画得不完美先拆好一层边界后面调整的成本比从一团乱麻里爬出来低得多。