ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python项目结构最佳实践:从单脚本到模块化重构

Python项目结构最佳实践:从单脚本到模块化重构 写 Python 写了足够久之后你会发现一个特别讽刺的事实语法和框架从来不是最大的门槛真正让你头疼的是“代码越来越多之后文件到底该怎么摆”。我见过太多的项目一上来就是二十几个平铺的 .py 文件import 全靠缘分改一个参数全局搜给别人交代码的时候对方根本不知道从哪个文件看起。项目结构这件事听起来像“规范”“整洁”这类虚词实际上它决定的是你每天要花多少时间在找代码、改代码、和同事对齐代码上。这篇文章我就把自己这些年组织 Python 项目结构的方法完整讲一遍从最简单的脚本怎么演化成多模块工程到不同场景下目录应该怎么搭、拆包要注意什么再到实际重构时踩过的坑全部用具体案例说清楚。无论你是刚入门的人还是正在被老项目折磨的朋友按这个思路走代码会清爽很多。1. 为什么项目结构这件事值得专门琢磨1.1 没有结构的代码是怎么一步步失控的先还原一个很常见的场景你刚开始学 Python需求很简单于是写了一个main.py两百多行跑得很欢。后来产品经理说要加个报表功能你又写了report.py接着要连数据库你写了db.py再后来你会发现main.py里 import 了 dbdb 里又 import 了 report 里的一个常量report 反过来又用到了 main 里的全局变量。所有文件都在同一个目录里平铺着每个文件都几百行函数之间互相调用谁也不敢删一行代码。这个失控过程几乎是必然的因为 Python 的入门门槛太低你可以十分钟就写出能跑的脚本但脚本和工程之间缺少一个“平滑过渡”的阶段。很多人不是不想整理而是等到意识到要整理的时候代码已经成了一团乱麻重构的成本高到让人放弃。我用一个生活里的类比项目结构就像房间里的收纳。你往地板上扔一件衣服放一双鞋都不算什么房间照样能住人。但如果你从不收拾三个月后你找一双袜子可能要翻遍整个房间。代码也一样单文件可以靠脑子记住所有逻辑一旦文件多了、依赖复杂了“记忆力”就变成了“搜索能力”。而搜索能力再强也不如一眼就能看出“这个功能的代码大概在哪个目录”来得快。1.2 好的结构到底解决了什么问题一个好的项目结构表面上看起来是“目录整齐”本质上解决的是三个问题可读性、可维护性、可测试性。先说服读性。你新接手一个项目或者两周后的你自己回头看你写的代码如果打开目录就能知道“用户相关的逻辑放在这里订单相关的逻辑放在那里公共工具放在那边”那你的心智负担会降低非常多。相反如果一个项目所有代码都堆在同一个包里你只能靠 IDE 的全局搜索来找函数效率会差一个量级。再说可维护性。项目结构好的时候你改一个功能影响面是可控的。比如数据库模型只放在models里接口路由只放在routers里那“改表结构”就只需要动 models 和对应的数据访问层而不是在十几个文件里去找SELECT语句。这种“改动范围可控”的能力是长期项目能不能活下去的关键。最后说可测试性。很多老项目的代码没法写单元测试不是因为代码本身复杂而是因为业务逻辑和副作用读写文件、连数据库、打印日志混在一起你压根没法在测试环境里构造输入。结构合理之后“纯逻辑”和“外部依赖”被分开测试自然就好写了。这一点对任何想认真维护代码库的人都比想象中更重要。2. 主流 Python 项目结构方案怎么选2.1 脚本型单文件如何体面地生长很多人写 Python 的起点就是一个脚本文件这没有什么可耻的很多工具的雏形都是脚本。问题是脚本也会长大今天加一个参数明天加一个分支后天加一个 Excel 导出两百行变成一千行。这时候第一件应该做的事不是急着拆目录而是在文件内部做结构整理。我建议所有的脚本型代码都遵守一个简单规则逻辑自上而下分层。最上面是 import 和常量中间是函数定义最下面是if __name__ __main__:入口。入口部分只做三件事解析参数、调用业务函数、处理退出码。所有真正干活的代码都放进函数而不是直接写在文件顶层。这样做的好处是哪怕暂时不拆文件你依然可以单独 import 某个函数来测试也可以在不依赖入口的情况下复用逻辑。我曾经维护过一些“考古级”的脚本它们能活下来靠的就是这个最小规则。等我后来想把它拆成真正的包时这个习惯也让迁移变得非常顺利。2.2 包型模块分包的核心逻辑当脚本超过单文件能承载的复杂度时就该考虑把它变成一个包package。包的本质是在目录里放一个__init__.py文件让 Python 把它当作一个模块命名空间。但很多人拆包的时候有个误区就是按“文件类型”分utils.py、helpers.py、common.py所有不知道怎么分类的函数都往里面塞。这种分包看起来有序实际上等于把一个混乱的大目录变成了“几个更大的垃圾桶”。真正的分包逻辑应该是按“业务领域”或“功能边界”分也就是每个包里面装的是同一个领域的东西。举个例子如果你在做股票数据抓取你可能会把代码拆成stock/ ├── __init__.py ├── fetcher/ │ ├── __init__.py │ └── api_client.py ├── parser/ │ ├── __init__.py │ └── dataframe_builder.py ├── strategy/ │ ├── __init__.py │ └── backtest.py └── cli.py这样你一眼就能看出抓数据的在fetcher解析的在parser策略回测的在strategy。如果你哪天要换数据源只需要动fetcher要改解析逻辑只需要动parser。这就是“高内聚、低耦合”在项目结构上的直接体现。2.3 扁平布局和 src 布局的取舍Python 社区里一直有两种主流的顶层布局方案一种是扁平布局flat layout一种是 src 布局src layout。扁平布局就是项目根目录下直接放你的包目录比如myproject/ ├── myproject/ │ ├── __init__.py │ └── core.py ├── tests/ │ └── test_core.py └── pyproject.tomlsrc 布局是在中间多套一层srcmyproject/ ├── src/ │ └── myproject/ │ ├── __init__.py │ └── core.py ├── tests/ │ └── test_core.py └── pyproject.toml如果你网上搜会发现很多人推荐 src 布局理由是这个布局强制你通过安装包的方式运行代码而不是依赖“当前目录在 sys.path 里”这个巧合。换句话说src 布局能更早暴露“我的包其实导入不了”的问题因为你的代码不直接躺在根目录你必须先pip install -e .把它装成可导入的包才能跑测试。我自己的体会是对于正式发布的库或稍具规模的业务项目src 布局更稳。它把“项目源码”和“构建产物、测试、文档”彻底分开不容易出现你在根目录跑得好好的、换个环境就 ModuleNotFoundError 的尴尬。但对一个简单的小工具扁平布局够用了强行上 src 布局反而多一层目录、多一些折腾。两种方案没有绝对优劣关键是你知道它们的区别扁平布局图省事src 布局图严谨。2.4 以 FastAPI 为代表的 Web 项目目录范式热词里高频出现的 FastAPI 项目目录结构其实是 Python 服务端项目结构的最佳样本。很多人第一次接触正经的 Python 项目结构就是从 FastAPI 或 Django 的项目模板开始的。我以 FastAPI 为例给出一套我实际用下来比较顺手的目录范式myapi/ ├── app/ │ ├── __init__.py │ ├── main.py # 应用入口负责创建 FastAPI 实例 │ ├── config.py # 配置读取与业务逻辑隔离 │ ├── routers/ # 路由层只负责 HTTP 相关的参数校验和响应 │ │ ├── __init__.py │ │ ├── users.py │ │ └── orders.py │ ├── services/ # 业务逻辑层不感知 HTTP │ │ ├── __init__.py │ │ ├── user_service.py │ │ └── order_service.py │ ├── models/ # 数据库模型 │ │ ├── __init__.py │ │ ├── user.py │ │ └── order.py │ ├── schemas/ # Pydantic 校验模型请求/响应 │ │ ├── __init__.py │ │ ├── user.py │ │ └── order.py │ └── crud/ # 数据访问层封装数据库操作 │ ├── __init__.py │ ├── user_crud.py │ └── order_crud.py ├── tests/ │ ├── __init__.py │ ├── test_users.py │ └── test_orders.py ├── requirements.txt ├── .env.example # 环境变量模板 └── README.md这套结构的关键在于分层路由不写业务业务不直接写 SQL模型只描述数据结构校验单独放 schemas。每一层的职责单一依赖方向从上往下路由依赖服务服务依赖数据访问层数据访问层依赖模型。一旦遇到线上问题你顺着请求路径就能一层层定位这在团队协作时特别明显——前端同学看 routers后端核心逻辑看 services数据库问题看 crud。3. 组织代码的核心原则和细节要点3.1 保持依赖方向单一结构这件事表面看是目录长得好看内里看是依赖关系是否干净。我见过不少项目目录层次分明但代码之间的 import 乱七八糟业务层 import 了路由层的函数工具模块反过来 import 了业务层最后成了一个环。这种项目不管目录多整齐改起来照样痛苦。所以组织代码的时候请时刻问自己我的依赖方向是不是单方向的理想情况是“底层不依赖上层”比如数据访问层不能去 import 路由层工具模块不能反过来依赖业务模块。你可以用一个很粗暴的规则来检查如果 A 依赖 BB 又依赖 A那这两个模块必然有一个责任划分错了要么把它们合并要么把公共的部分抽出来放进更底层的模块。我在做项目结构评审的时候经常干一件事随便画一张模块依赖图看看箭头有没有从下往上的。只要发现反向依赖基本可以断定那一段迟早出 bug。这个习惯比任何 linter 都管用。3.2 配置和业务代码分离几乎每个项目都需要配置数据库地址、Redis 密码、第三方 API key、环境标识。很多人习惯在代码里直接写os.environ[DB_URL]甚至直接硬编码字符串——这是项目结构里最隐蔽的坑。我的建议是配置应该集中管理并且和业务代码完全隔离。小的项目可以在包内建一个config.py用pydantic-settings或简单的 dataclass 来封装稍微大一点的项目可以用.env文件加环境变量.env本身不进版本库只提交.env.example作为模板。这样做的意义不只是安全更重要的是可测试性。如果你的代码到处直接读环境变量那么你想在测试里构造“不同的配置”就会非常痛苦。所有的配置都从一个config对象读取测试时只需要替换这一个点其他所有模块都不需要感知。3.3__init__.py的正确用法Python 的包结构里__init__.py是一个被严重低估或者严重误用的文件。很多人只是把空文件放在目录里让包成立然后在from xxx import yyy时依赖 Python 的默认行为。这没什么问题但我建议你学会主动使用__init__.py来控制包对外暴露的接口。一个常见的做法是在__init__.py里定义__all__声明哪些名字是这个包对外承诺的公共 API。这样使用者可以清晰地知道只有__all__里的东西才稳定其他内部模块都是实现细节。同时也可以在__init__.py里做好对外导入的“捷径”比如from .core import create_app __all__ [create_app]这样外部只需要写from myproject import create_app而不必深入到myproject.core.create_app。这也是包结构优雅的地方真实文件层次可以很深入但对外的接口可以很浅。3.4 测试目录和代码目录同构项目结构里测试怎么放也是一个很见功力的事。最无脑的方案是建一个超大tests目录里面所有测试平铺名字带前缀区分模块。这个方案在小项目里可行但一旦测试多了你就会发现找测试文件和找代码文件一样费劲。我推荐的做法是让测试目录和源码目录保持同构mirror也就是源码里myproject/fetcher/api_client.py对应测试路径tests/fetcher/test_api_client.py。这样从测试文件名立刻能反推被测模块反之亦然。这在重构时极其重要你想改parser/下的逻辑只需要跑tests/parser/下的测试不用猜测哪些测试和它相关。另一个小技巧是测试文件的命名用test_前缀或_test.py后缀保证pytest默认收集规则能命中。测试不要连数据库、不要依赖真实的网络能 mock 就 mock。你会发现当测试代码也拥有清晰的结构时项目整体的可维护性会再上一个台阶。3.5 用 pyproject.toml 把结构固定下来现在已经是 2025 年了如果你还在用requirements.txt里堆着一堆裸依赖版本号我不会说你错但我会推荐你了解一下pyproject.toml。这是 Python 社区目前公认的工程化元数据标准一个文件同时管理包元数据、依赖、构建配置甚至是工具配置。pyproject.toml对项目结构最大的价值在于它把“我的项目是这么组织起来的”这件事固化成了机器可读的声明。比如你的包入口在哪里、有哪些公开的依赖、需要用哪个版本都清楚地写在文件里。配合pip install -e .你就可以在当前环境里以“已安装包”的方式运行项目而不是靠 PYTHONPATH 和运气。我建议即使是最简单的项目也尽早初始化一个pyproject.toml。它相当于给项目画了一条边界线边界内的源码才是项目本身其他杂七杂八的脚本、配置、缓存都不算。这个心理边界比技术本身更有价值。[project] name myproject version 0.1.0 requires-python 3.10 [build-system] requires [setuptools68] build-backend setuptools.build_meta [tool.setuptools.packages.find] where [src]4. 实操把混乱的脚本项目重构成标准结构4.1 重构前先盘清楚这些事很多人拿到一团乱麻的代码就想直接开拆结果拆到一半发现依赖根本理不清只能回滚。我的经验是动手之前先花半天做盘点比盲目重构快得多。第一步把所有 .py 文件列出来标注每个文件大概做了什么、被谁引用。第二步画一张简单的依赖表弄清楚哪些模块是“根”也就是被最多人依赖的底层能力。第三步找出明显的“坏味道”全局变量、重复代码、长函数、跨模块的互相引用。第四步确认当前有哪些外部接口不能破坏比如命令行参数、配置项、导入路径。做完这四步你对项目的全貌就有数了。重构的本质是“移动代码而不改变行为”所以先盘点、后动手、边改边验证是最稳的节奏。4.2 按依赖顺序拆包实际的拆包顺序我强烈建议从底层依赖开始而不是从入口开始。什么意思呢比如你的脚本里有一个utils.py它不依赖任何项目内部模块只是提供文件读取、时间处理之类的基础能力那就先把这一类代码抽成utils/包并立刻跑一遍全项目确认所有调用它的地方都正常。然后再处理下一层比如db.py它只依赖utils和第三方库那么把它挪进db/包也相对安全。最后才处理业务模块它们通常依赖所有底层模块所以放最后动。这个顺序的好处是每一步完成后项目都是可运行的你不需要“断点式”地一次重构到底风险被拆散了。抽包的时候还有一个细节保持新旧导入方式的兼容。比如旧代码是from db import get_connection新代码可能变成from myproject.db import get_connection如果你不想一次性改所有调用点可以在新包的__init__.py里做一个转发# myproject/db/__init__.py from .connection import get_connection __all__ [get_connection]然后在旧的db.py里改成from myproject.db import get_connection这样旧文件成了一个“临时桥”所有引用它的代码不用立刻改后续再逐步迁移。这种渐进式重构的方法比一次性大手术可靠得多。4.3 路径与导入问题拆包后最常见的翻车点就是导入路径和文件路径。先说导入如果你是在项目根目录下直接执行python scripts/run.py那么 Python 会把scripts/放进 sys.path而不是项目根目录。这时候from myproject import xxx很可能直接报 ModuleNotFoundError。解决的办法有两个一是永远使用python -m的方式执行比如python -m myproject.cli这样 sys.path 的入口是当前目录包自然能被找到二是如果项目配置了 pyproject.toml就pip install -e .让包以安装的方式存在。再说文件路径很多人喜欢用相对路径访问项目里的配置文件或数据文件比如open(data/config.json)。这种写法在某个目录下运行没问题换个启动方式就完蛋。正确的做法是访问项目内部静态文件时用pathlib基于当前文件的位置来计算路径from pathlib import Path BASE_DIR Path(__file__).resolve().parent config_path BASE_DIR / config / settings.yaml这样你不管从哪里启动程序路径都不会变。路径问题看起来小但它是重构后“明明代码一样却跑不起来”的头号元凶。4.4 验证重构结果重构完成不等于结束你还要系统性地验证一下。第一步跑一遍项目自带的所有测试没有测试的话至少把核心业务入口每条路径都手动执行一遍。第二步在干净环境下重新安装项目依赖确认所有导入路径都能正常工作。第三步检查一下代码里是否还有硬编码的相对路径、是否还有跨包的循环导入可以用python -c import myproject来快速验证整个包能否一次性导入成功。我自己的习惯是重构完对应的包之后立刻跑一个“冒烟脚本”把该包的每个公开函数都调一遍输入用最小化的样例输出和重构前对比。这个动作每次花几分钟但能帮我省掉无数个“半夜上线的 bug”。5. 常见问题与排查实录5.1 ModuleNotFoundError 的三种病因从我见过的项目来看ModuleNotFoundError 九成以上是三种原因。第一种包根本没被安装你只是把代码放在某个目录里又没有把它加入 sys.path。第二种导入路径写错了比如把from myproject.db import get_connection写成了from db import get_connection而db不再是一个可以直接从根目录导入的模块。第三种执行入口不对你在scripts/目录下直接python run.py那我的项目根目录的包自然搜索不到。排查思路也很固定先看报错信息里模块名是什么然后终端里执行import sys; print(sys.path)确认当前项目根目录在不在搜索路径里。如果不在最省事的修复是pip install -e .一劳永逸。如果不想装包最少在项目根目录执行python -m scripts.run也能解决问题。5.2 循环导入最容易踩的坑循环导入在重构拆包的时候几乎一定会遇到。它的典型报错虽然是 ImportError但本质原因是你有两个模块互相 import导致 Python 在初始化其中一个模块的半路上去加载另一个模块而另一个模块又反向加载回来于是两边都找不到对方。最常见的触发点是类型注解和全局变量。比如user_service.py里 import 了schemas/user.py的一个类而schemas/user.py又为了某个函数 import 了user_service.py的另一段逻辑。打破循环的办法有三类第一把共用的类型定义挪到一个独立的底层模块两个模块都依赖它而不是互相依赖第二把 import 延迟到函数内部也就是用“局部导入”不推荐长期用但紧急解除循环很有效第三检查是否真的需要互相 import很多时候你会发现 A 依赖 B 只是一个类型注解完全可以用TYPE_CHECKING来解决from typing import TYPE_CHECKING if TYPE_CHECKING: from .user_service import UserService这样运行时不会有真实的 import但类型检查器依然能正确解析循环导入就被绕开了。5.3 相对导入的两难选择再一个高频问题就是相对导入和绝对导入之争。包内部使用相对导入from .core import create_app确实代码更简洁但一旦模块被当作脚本直接执行python -m myproject.core相对导入就会爆炸因为 Python 把myproject.core当作 main 模块它不知道自己相对于哪个包。我的经验是模块之间相互引用尽量用相对导入因为包内部重组时相对路径不容易断但想清楚哪些模块有可能被直接当作脚本运行这些模块用绝对导入更安全。说到底最好的规避方式是尽量不让任何内部模块直接可执行把“入口”统一放在项目顶层或__main__.py里内部模块都只作为模块被导入。5.4 常见问题速查表我把这些年常见的问题和解决思路整理成了一个速查表适合贴在项目文档里症状可能原因优先排查方向ModuleNotFoundError包未安装或不在 sys.path执行pip install -e .或在项目根目录用python -m运行运行时 import 失败但 IDE 正常IDE 自动加了路径终端没有检查 sys.path不要依赖 IDE 的隐式路径相对导入报错 attempted relative import模块被当作脚本运行放弃直接执行模块统一走入口两个模块互相依赖需合并或抽公共底层模块用TYPE_CHECKING解类型注解再考虑结构调整换目录后找不到配置文件代码用了相对路径改用Path(__file__).resolve().parent计算别人 clone 后跑不起来依赖未锁定或 Python 版本不合适用 pyproject.toml 固定依赖和 requires-python测试跑得到处都是测试目录结构和源码不一致让 tests 目录 mirror 源码目录5.5 一个让我印象深刻的实际案例最后讲一个真实项目。有个朋友的项目是一个数据处理工具最初是三个脚本文件跑了半年膨胀到接近一万行。他来找我的时候代码里已经没有清晰的模块边界了utils.py三千多行main.py里既有命令行解析又有数据处理逻辑还嵌套了好几层 if else。我们花了两个周末重构第一周只做盘点和画依赖图不动代码。第二周按依赖顺序拆包从最底层的文件读取工具开始一层层往上抽同时给每层写冒烟脚本验证。最后形成的结构是五个包加一个入口文件代码量差不多但任何一个新同事接手时打开目录五分钟就能找到想找的东西。那个朋友后来跟我说最大的变化不是代码变短了而是“终于敢动代码了”——以前改一个函数要担心三个地方跟着炸现在每个包的边界清清楚楚改之前就能预判影响范围。我个人在组织 Python 项目结构这条路上踩过不少坑现在最深的体会是结构不是一次性的设计而是持续的小决策。你今天写一个新模块立刻就想清楚它属于哪一层、依赖谁、测试放在哪你今天发现一个 import 有点绕立刻记录下来哪怕不马上改。项目结构就像存款你每次只存一点点看不出差异但半年后回头看你已经在一堆混乱里攒出了一个肉眼可见的秩序。这套方法不一定适合所有项目但对绝大多数正在成长的 Python 项目来说早点搭好结构的收益远比晚点再补回来的成本低得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进