ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python项目结构详解:从脚本到可维护项目的代码组织之道

Python项目结构详解:从脚本到可维护项目的代码组织之道 上周帮一个网友排查爬虫项目从下载、登录到解析全部写在三个文件里主文件一千六百行。我打开代码的那一刻就意识到问题比报错信息本身大得多——不是这个报错难修而是他连该从哪里开始找问题都做不到。这是我在很多 Python 初学者身上看到的现象学 Python 的第一年基本都在写脚本等脚本慢慢长成项目代码组织方式还停留在脚本阶段。Python 项目结构这件事说难不难说简单也不简单。难的不是把文件分开而是搞清楚为什么要这样分每个文件到底该放什么。很多人不是不想好好组织是真的不知道该按什么标准来。这篇内容就是把我这些年从写脚本到维护大型项目的经验沉淀下来讲清楚什么样的结构是好的、为什么好、不同项目该怎么选以及最常见的坑都踩在哪。适合刚入门想建立正确习惯的人也适合项目已经开始混乱、想系统重构一轮的朋友。1. 从能跑到能维护代码组织的分水岭1.1 结构是认知负担的分配方案聊项目结构之前先理解一个底层问题代码组织到底在解决什么代码首先是写给机器执行的但代码更是写给人看的。一个脚本只有你能看到你怎么组织都无所谓一旦有人要接手、要扩展、要排查问题代码的组织方式就直接决定了这件事的难度。好的项目结构本质上是一种认知负担的分配方案——把复杂的业务拆成一个个小的、独立的认知单元让读代码的人一次只需要理解一小块而不是面对一个一千六百行的大杂烩。我见过不少项目功能全部能跑但改一个需求要花半天时间在文件里来回跳转找代码。那不是技术能力问题是结构把人的精力全耗在了寻路上。结构清晰的项目你看到一个目录就知道这里面是什么业务看到一个模块名就能猜到它大概有什么函数看到 tests 目录就能知道这个项目的行为是怎么被验证的。1.2 以后再整理是个陷阱结构腐化的真实过程几乎每个混乱的项目都不是一开始就混乱的。第一个文件是main.py写着写着登录逻辑想统一复用于是抽出一个login.py后来又发现数据库工具函数到处都要用于是放了个db.py再后来爬虫越来越多建了spiders/包有一天首页要展示数据又写了analysis.py。等到有一天你想整理的时候发现db.py里既有数据库连接、又有数据清洗、还混着三个定时任务的调度函数——已经拧不成一个整块了。这个过程的本质是每次先随便放一下的决定都在为未来累积重构成本。它不是一次性的重写成本而是每次修改时额外付出的理解成本日积月累比重构一次贵得多。所以我一直主张一个观点结构不是等代码多了再整理的事而是从你决定这段代码可能不止用一次的那一刻起就要开始考虑的事。不需要一步到位但每写一个文件都要有明确的目的和边界。1.3 好结构的三个可检验标准怎么判断一个项目结构到底好不好我总结了三条可操作的标准不是抽象审美是能直接拿去检验的入口清晰不看文档随便找一个新人让他找出这个项目的入口在哪里、主要流程怎么跑三分钟内能不能找到。职责孤立修改某一个功能时需要动到的文件是否尽量少。动登录逻辑不需要去翻爬虫代码动数据库连接不需要翻 API 路由。可测试性结构能不能让你方便地写测试。如果为了测试一个函数你得把整个项目启动起来、连上数据库、mock 半天外部服务那说明模块拆分的边界有问题。如果你手头的项目在这三条上都不达标那不用怀疑结构需要调整了。接下来我按我自己惯用的骨架把每个部分的用途和原因拆开讲清楚。2. 我惯用的 Python 项目骨架每个文件都不是摆设2.1 顶层该放什么README、许可证与依赖声明先看一个我比较常用的项目骨架这里以一个库/服务型项目为例myproject/ ├── README.md ├── LICENSE ├── pyproject.toml ├── .gitignore ├── .env.example ├── src/ │ └── myproject/ │ ├── __init__.py │ ├── config.py │ ├── models.py │ ├── services/ │ │ ├── __init__.py │ │ └── crawler.py │ ├── api/ │ │ ├── __init__.py │ │ └── routes.py │ └── main.py ├── tests/ │ ├── conftest.py │ └── test_crawler.py ├── docs/ └── scripts/很多人觉得 README 是给开源项目用的自己写代码不用写。这是个误解。README 最重要的人其实是三个月后的你自己。你只需写清楚三件事这个项目解决什么问题、怎么安装/启动、目录结构大概是怎么样的。就这三件事能帮你和你的协作者节省大量时间。LICENSE这个文件只要项目有可能公开、开源、被同事复用尽早加上。它不是法律洁癖是明确别人到底能不能用你的代码、能怎么用省得以后被人问你这个能商用吗的时候还要纠结。依赖声明现在我用pyproject.toml而不是requirements.txt。原因后面细说但至少你要知道pyproject.toml已经是 Python 官方推荐的打包与依赖声明标准它能同时覆盖项目怎么装和项目依赖哪些库两个问题。requirements.txt并不是错的但对于需要打包成可安装包的项目来说pyproject.toml是更现代、更完整的方案。.env.example是个非常容易被忽略但是救命的文件。项目里所有需要的环境变量在这里列一个带注释的模板比如数据库地址、API Key 的键名。新同事接手时复制一份改成.env填上自己的值就能跑而不是去代码里一个一个找这个环境变量到底叫什么名字。2.2 src 布局与平面布局为什么我推荐 src很多 Python 项目是这么组织的myproject/ ├── main.py ├── utils.py ├── models.py └── tests/这就是平面布局——把你的包直接放在项目根目录下。它的问题在于当你用测试工具比如 pytest或从项目根目录运行.py文件时Python 会将当前目录加入sys.path模块搜索路径于是你能直接import myproject。在本地一切正常但只要到了 CI 环境、或者把项目当成一个真正的依赖装进别的项目里就非常容易碰到ModuleNotFoundError。src 布局把真实包放进src/目录下能强制你的项目经过安装这个动作才能在环境中被导入。也就是说你要在虚拟环境里执行pip install -e .之后无论从哪个目录运行都能正确导入。这样做看起来多了一步操作但它把导入失败这类问题提前拉到了开发阶段而不是等部署的时候才暴露。另一个实际的好处是src/里只有你的真实包项目的根目录不会因为__pycache__、生成的中间文件而变得混乱。测试、脚本、文档都放在外面一眼能看清代码本体和辅助文件的边界。如果你做的只是一个几十行的脚本用 src 布局是过度设计但只要你打算长期维护、写测试、或者给别人用我的建议是直接 src 布局起步省得后面搬迁。2.3 包内分模块的职责边界src/myproject/内部怎么划分是整个结构里最需要动脑子的部分。我常用一条准则按业务能力分模块而不是按技术类型分模块。什么意思很多人会自然地写出models.py、views.py、utils.py、helpers.py这种按类型划分的模块。结果就是所有业务模块都要往utils.py里塞私有函数models.py里既放数据库模型又放业务常量最后每个文件都变成了什么都有一点的大杂烩。我更推荐的做法是在项目内部先按业务域拆包比如services/放业务逻辑、api/放接口路由、models/放数据模型、config.py放配置加载。每个业务域内部再按类型组织。这样你看到一个目录列表就能直接说出这个项目的核心能力有哪些。config.py单独成模块的原因是配置这个东西几乎会被所有模块引用。把它独立出来以后其他模块只需要from myproject.config import settings不会产生任何循环导入的隐患也不会把os.getenv散落在代码的各个角落。至于main.py或app.py我把它当作组装工厂完成初始化、依赖注入、启动流程不承载具体业务函数。业务函数应该在它对应的业务模块里。如果你发现自己main.py里全是登录逻辑解析逻辑发邮件逻辑就该知道这些逻辑放错位置了。2.4 tests、docs、scripts 这些支撑目录的定位tests/目录的作用不用多说但我要强调conftest.py的位置。pytest 的conftest.py是从上往下逐级生效的放在tests/目录下的 fixture 只对 tests 目录内的用例生效放在项目根目录的conftest.py则对整个项目生效。如果只是测试需要的基础 fixture放在tests/conftest.py就够了如果有些 fixture 需要给测试之外的场景复用再考虑放根目录。docs/目录不一定非得是 sphinx 文档站。哪怕只是放一个architecture.md记录系统设计决策、一个troubleshooting.md记录已知问题和解决办法都能让项目长期受益。关键是记录为什么而不是记录是什么——代码本身已经说明了它是什么注释和文档应该解释为什么这么设计。scripts/目录放部署脚本、数据库迁移辅助命令、定时任务的启动脚本等工程维护性质的东西。它和src/的边界在于src/里的代码是产品的一部分会被测试、会被打包scripts/里的脚本是团队的运维工具不需要被别的模块导入。这样一拆别人不会把运维脚本误当成业务代码去看。3. 不同形态的项目结构逻辑完全不同3.1 脚本型项目克制不要过度设计如果你的项目本质上是一个能跑完、输出结果就结束的脚本比如数据清洗、批量重命名、爬一次就完事的爬虫那我不建议你套完整的工程骨架。脚本型项目过度设计反而会让简单任务变得离奇复杂。脚本项目我一般就三个文件入口文件、配置/常量文件、核心逻辑模块。入口文件只负责解析参数、调用核心逻辑、处理退出码配置常量放在独立文件里方便调整核心逻辑按功能拆成一两个模块。如果脚本逻辑超过三百行再考虑拆成包。关键是保持入口薄、逻辑独立、参数不硬编码这三个原则。哪怕以后脚本要演进成正式项目你的迁移成本也不会太高——因为你已经把逻辑和入口拆开了。3.2 Web 服务项目按 API 域拆而不是按文件类型拆Web 后端项目是结构混乱的高发地带尤其是 FastAPI、Flask 这类自由度高的框架没人帮你规定目录结构全靠自觉。很多人拿到框架直接往main.py里堆路由写了两百行还能忍写到一千行的时候就什么都找不到了。我常用的 Web 项目结构是这样app/ ├── main.py ├── api/ │ ├── __init__.py │ ├── deps.py │ └── routes/ │ ├── auth.py │ ├── users.py │ └── stats.py ├── core/ │ ├── config.py │ └── security.py ├── models/ │ └── db.py ├── schemas/ │ └── user.py └── services/ ├── auth_service.py └── stats_service.pyapi/routes/下按资源/业务域拆路由文件每个路由文件只负责 HTTP 层的参数解析、调用服务、返回响应真正的业务逻辑放在services/里。这样做的原因很简单路由和业务逻辑的耦合是 Web 项目后期最难拆的一坨。如果你用 FastAPI需要注意deps.py这类依赖注入文件要独立不要把所有Depends都写在路由文件里否则多个路由之间互相引用依赖时会非常痛苦。Django 用户虽然框架强约束了views、models等目录但我们同样会遇到views.py太厚的问题解法一样把业务逻辑抽到services.py或独立的 service 模块里让 views 层薄下来。3.3 库/包项目面向分发设计如果你在开发一个会被pip install安装的库结构的第一目的不是方便自己写而是方便别人用。这种情况下src布局是刚需pyproject.toml也是刚需而且要特别关注包的导出内容。__init__.py这时候就很重要了。它决定了一个人import myproject之后面对的是什么样的 API。我见过不少库__init__.py是空的用户只能自己翻阅文档找所有模块的路径。优秀的库会在__init__.py里明确导出核心类和函数比如from .client import Client、from .errors import APIError让用户只用记住一两个入口就能完成任务。库项目的 tests 要格外注意安装后再测试这一环不要依赖本地路径导入。因为你不能假设用户是把你的库下载到本地跑的他们是通过 pip 安装到你构建的包里的包的内容和测试时用的内容必须一致。3.4 数据与算法类项目代码、数据、产出物要分开数据类项目和纯 Web 项目结构逻辑不同它的核心资产是实验流程和数据管线。我在做这类项目时会额外强调配置和数据不可入代码。ml_project/ ├── configs/ ├── data/ │ ├── raw/ │ ├── processed/ │ └── output/ ├── notebooks/ ├── scripts/ └── src/ └── project/ ├── features/ ├── models/ └── utils/data/下面按 raw、processed、output 分是为了让数据管线有清晰的阶段感原始数据不经过处理不能直接用处理后的数据是给别人喂模型的输出是实验结果的沉淀。Git 里一般只追踪小样本数据和目录占位文件真正的数据放在远端存储。还有一个建议实验型代码和工程型代码要分层。notebooks 里做探索性分析和可视化没问题但一旦某个处理逻辑被验证有效就要固化到src/里的正式模块不要让它永远活在 notebook 的 cell 里。否则下次换机器或者换人谁也复现不了你的结果。4. 组织代码最常见的坑成因与对症解法4.1 循环导入表象是结构问题本质是职责问题Python 项目里几乎人人都会碰到ImportError: cannot import name xxx from partially initialized module这个报错就是循环导入的典型症状。看看这个场景# services/crawler.py from models import Task def run_task(task_id): task Task.get(task_id) ...# models/__init__.py from services.crawler import handle_resultmodels想用services的某个函数services又引用了models的模型。Python 加载模块时一个模块还没加载完就去加载另一个两边都等对方先完成直接死锁。很多人第一反应是把 import 放函数里面这确实能绕过去但绕不过去的是背后的问题你的模块职责边界互相渗透了。models 层应该只关心数据定义services 层才负责业务逻辑。正确的做法通常是把两个模块都依赖的公共逻辑下沉到第三层比如 by 独立的中间件、事件总线、或者更底层的模块让依赖关系变成单向的。我处理循环导入的经验是先别急着改代码画一张简化的依赖关系图看看谁依赖谁。如果出现了 A 依赖 B 且 B 依赖 A那通常不是导入时机问题而是模块边界划错了。4.2 sys.path 修改用打包安装代替路径 hack另一个我见到频率极高的坏味道是代码里手写这种import sys sys.path.append(/home/user/myproject) from myproject import crawler这种写法的本质是把当前项目手动塞进模块搜索路径。它能跑但坑非常深换一台机器路径不对就崩部署到服务器目录变了就找不到更严重的是它掩盖了项目没有被正确安装这个事实。正确做法是什么用pyproject.toml声明好包的结构然后在虚拟环境里执行一次pip install -e .-e表示可编辑安装editable install就是把项目链接到当前 Python 环境的 site-packages 里但你修改源码后不需要重新安装改动即时生效。之后你在项目的任何目录下、或者任何脚本里import myproject都不会有问题。这个习惯值得花十分钟养成。它不用再折腾 sys.path也能让测试、命令行工具、部署流程全都顺滑起来。4.3__init__.py里塞太多导入__init__.py的一个常见误用是把包内所有模块都 import 一遍# 不推荐 from .crawler import Crawler from .parser import Parser from .storage import Storage from .notifier import Notifier每次import myproject都会把这些全装上哪怕用户只需要其中一两个类。依赖重、启动慢某些情况下还会引入不必要的循环导入。__init__.py应该克制地导出公共 API而不是无脑聚合所有模块。基本原则是被 exports 的东西应该是这个包对外承诺支持的使用方式不是给别人用的内部实现就让它在__init__里保持沉默。Python 的内置机制里.py文件里的_前缀下划线就是为了标记内部实现请勿外部使用配合__init__.py做白名单导出这才是清晰的设计。4.4 配置硬编码在业务代码里很多时候项目结构看着挺合理但代码里有大量这种常量DATABASE_URL postgresql://user:passwordlocalhost/db API_TOKEN sk-xxxxx配置不是不能写在代码里但它应该集中在一个地方。如果配置散落在services/、api/、models/的各个角落那改一个环境配置要全局搜索替换而且根本没法安全地提交到 Git尤其是有密钥的情况下。我推荐的做法是所有配置都从环境变量/.env文件读取集中到一个config.py。用一个简单的pydantic-settings示例from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): database_url: str api_token: str debug: bool False model_config SettingsConfigDict(env_file.env) settings Settings()之后所有模块都from myproject.config import settings改配置只改.env提交代码时只提交.env.example模板。这个习惯能直接避免把密钥提交到公共仓库的灾难。4.5 utils.py 黑洞与测试导不进来的困局utils.py的问题是全行业普遍的所有不知道该放哪的代码最后都进了 utils导致这个文件变成一个从字符串处理到数据库操作到时间计算的无序堆积地。想找个函数没法靠文件名判断在不在里面。我的建议是宁可用十几个职责明确的小模块也不要一个包罗万象的 utils.py。字符串处理放text_utils.py时间处理放time_utils.py格式转换放converters.py。文件多了不可怕可怕的是文件里什么都有。再一个是测试目录的典型坑tests/里写好了测试一运行ModuleNotFoundError: No module named myproject。看到这个报错先别急着往代码里加 sys.path先确认两件事第一你的项目是否已经在当前虚拟环境里pip install -e .安装过第二conftest.py的位置是否合适。大多数情况下正确安装后这个报错就自然消失了。pytest 的导入机制在某些情况下确实让人困惑但那不是让你去 hack 路径的理由。5. 让项目结构可复现模板、工具与日常工作流5.1 用脚手架模板把结构固化下来结构这件事最怕的是每次新项目都从零开始手搓。我现在的做法是一旦确认了一种适合团队/自己的结构就把它固化成脚手架模板新项目直接生成。cookiecutter是目前最常见的项目脚手架工具它本质上是一个模板拉伸器你定义一个带变量的目录模板执行一条命令就能生成一份新的项目结构。pip install cookiecutter cookiecutter gh:your-name/your-python-project-template然后交互式填项目名、作者、包名等信息一个标准结构的项目就生成了。优势不只是省了建目录的时间更重要的是模板里可以预置好 .gitignore、pyproject.toml、CI 配置、README 模板团队里所有人写出来的项目都是一套规范接手成本低到可以忽略。如果你是个人项目也建议至少花半天时间做一个自己的模板把我认为好的结构固定下来。以后写新项目几分钟就能起步。5.2 快速生成/导出目录树分析或展示结构的时候手动写目录树太痛苦了。命令行一条命令就能解决tree -L 3 -I __pycache__|*.pyc|.git|.venv|*.egg-info-L 3表示只展示三层太深的目录会刷屏-I是排除哪些文件/目录__pycache__、.git、虚拟环境这类无关紧要的东西排除掉再看结构清爽很多。Windows 上没有 tree 命令的话用 PowerShell 的tree /F或者安装一个tree工具也可以直接在 IDE 里看项目树。重点是定期把项目的目录树找出来看一眼如果你发现自己都看不懂某个目录是干嘛的那就是该重构的信号。5.3 当我用 AI 工具梳理不熟悉的项目结构时我最近经常拿 AI 工具来帮我分析不熟悉、或者很久没碰的旧项目效果意外地好。关键是给它的探查 prompt要足够具体不是简单问一句帮我分析这个项目。一个比较实用的模板是这样请先以树状图展示这个项目的目录结构。然后按 src、tests、scripts 等维度逐个说明每个目录和关键文件的职责。接着标注出项目的入口点、核心数据流和模块之间的依赖关系。最后指出可能导致循环导入、模块职责混乱或过度耦合的代码组织问题并给出具体的重构建议。这样 AI 会先建立整体认知再逐层深入最后落到问题排查和建议上。你拿到的不只是一份目录介绍而是一份结构体检报告。当然 AI 的建议不一定全对尤其是对业务背景不熟的时候它会给出一些教科书式但未必契合实际的方案。所以我把 AI 当成第二双眼睛它能帮我在一堆文件里快速找出可疑点但最终要不要改、怎么改还是要结合项目实际情况判断。5.4 配合编辑器Python 环境与源码根目录的小事结构再合理如果编辑器的环境没配对开发和调试还是会乱。这里分享两个最基础但影响最大的配置一个是在 VS Code 里按CtrlShiftPMac 是CmdShiftP打开命令面板执行Python: Select Interpreter一定要选你创建虚拟环境.venv里的那个解释器。不然后果就是你命令行里pip install装了一大堆包编辑器里运行却全都导入失败。另一个是 PyCharm 用户如果用了src/布局需要在 Settings - Project - Project Structure 里把src/目录标记为 Sources Root。这样 IDE 的自动补全和导入解析才能正确识别src下的包。这些是小事但环境不统一会导致很多结构没问题但代码运行不了的假象。5.5 最后分享一个小经验结构是演进的不是一锤定音我不建议任何人一开始就上一套重型结构。一个二十行的脚本硬套 src 布局、包管理、CI 配置那是拿大炮打蚊子。我更推荐的节奏是脚本时期只保证入口薄、逻辑独立、参数不硬编码。开始写测试、准备长期维护、需要别人看代码时立刻引入src/布局 pyproject.tomlpip install -e .。模块职责开始模糊、文件相互引用变多时停下来画依赖图重新梳理边界必要时把大模块拆成包。团队统一或自己重复搭建多次后做模板固化结构。结构不是一次性设计出来的是在项目中不断演进出来的。关键是每一步都知道自己为什么要这样调而不是跟着感觉走。说实话我自己也踩过不少坑最值钱的教训就是那句老话代码结构是给下一个人看的而那个下一个人往往就是三个月后的你自己。你现在在组织上偷的懒之后都会在排查 bug 的时间里加倍还回来。
返回列表