050、模块与包组织结构:单文件到大型项目的目录演进与 main

发布时间:2026/6/26 13:22:15
050、模块与包组织结构:单文件到大型项目的目录演进与 main 050、模块与包组织结构单文件到大型项目的目录演进与main上周帮一个朋友调试他的爬虫项目代码全堆在一个spider.py里三千多行。他跑起来没问题但想加个定时任务就炸了——if __name__ __main__那段逻辑被重复执行日志刷屏数据库连接池直接爆掉。我扫了一眼他压根没理解模块导入时 Python 到底在干什么。这其实是个很典型的坑从单文件写到多文件再到包结构很多人卡在“为什么我的代码跑了两遍”这个问题上。单文件时代的“舒适区”刚学 Python 时我们都在一个.py文件里写所有东西。函数、类、全局变量、执行逻辑全挤在一起。这没什么不对对于几十行的小脚本单文件反而是最清晰的。# 一个典型的单文件脚本importrequestsdeffetch_data(url):returnrequests.get(url).json()defprocess_data(data):return[item[name]foritemindata]# 这里踩过坑直接写执行代码urlhttps://api.example.com/datarawfetch_data(url)resultprocess_data(raw)print(result)问题在于当你把这个文件作为模块导入到另一个文件时最后那三行代码会立刻执行。这就是if __name__ __main__存在的意义——它像一道门只有当你直接运行这个文件时门才打开作为模块导入时门是关着的。别这样写把执行逻辑裸写在文件底部没有任何保护。你永远不知道哪天别人会from your_script import fetch_data然后你的脚本就自顾自跑起来了。从单文件到多文件模块的诞生当代码超过两百行你就该考虑拆分了。拆分的依据不是“按文件大小”而是“按职责”。比如爬虫项目可以拆成fetcher.py、parser.py、storage.py。# fetcher.pyimportrequestsdeffetch(url):print(f正在请求:{url})# 调试用别删returnrequests.get(url,timeout10).text# parser.pyfrombs4importBeautifulSoupdefparse_title(html):soupBeautifulSoup(html,html.parser)returnsoup.title.stringifsoup.titleelse无标题# main.pyfromfetcherimportfetchfromparserimportparse_titleif__name____main__:htmlfetch(https://example.com)titleparse_title(html)print(title)这里有个隐藏细节from fetcher import fetch这行代码执行时Python 会从头到尾执行fetcher.py。如果fetcher.py底部有if __name__ __main__保护的代码那不会执行但如果没有保护就会执行。这就是为什么我强调“执行逻辑必须放在__main__块里”。包目录即模块当文件多起来平铺在同一个目录下会变得混乱。这时候需要包——本质上就是一个包含__init__.py的目录。my_project/ ├── __init__.py ├── fetcher/ │ ├── __init__.py │ ├── http.py │ └── selenium.py ├── parser/ │ ├── __init__.py │ ├── html_parser.py │ └── json_parser.py └── main.py__init__.py可以是空文件但别真的留空。我习惯在里面写包的文档字符串和__all__列表这样from package import *时不会把内部函数全暴露出去。# fetcher/__init__.py 网络请求模块 提供 HTTP 和 Selenium 两种抓取方式 __all__[fetch_http,fetch_selenium]from.httpimportfetch_httpfrom.seleniumimportfetch_selenium注意这里的.http是相对导入。相对导入只能在包内部使用不能在main.py里用from .fetcher import ...因为main.py不是包的一部分。这个限制让很多人困惑——简单记包内部的模块之间用相对导入包外部的入口文件用绝对导入。__main__的两种形态if __name__ __main__这个写法大家都会但它的行为在不同场景下有微妙差异。场景一直接运行文件python main.py此时__name__等于__main__块内代码执行。场景二作为模块运行python-mmy_project.main此时__name__等于my_project.main块内代码不执行。但注意-m方式会把当前目录加入sys.path所以包内的相对导入能正常工作。场景三被其他模块导入frommy_project.mainimportsome_function此时__name__是my_project.main块内代码不执行。我见过最离谱的 bug 是有人把测试代码写在if __name__ __main__外面然后 CI 跑测试时测试框架导入模块那些测试代码就自动执行了导致测试结果全是假的。大型项目的目录演进当项目超过十个模块目录结构需要更精细的设计。我常用的模式是这样的project/ ├── src/ │ ├── __init__.py │ ├── core/ # 核心业务逻辑 │ ├── utils/ # 工具函数 │ ├── models/ # 数据模型 │ └── services/ # 服务层 ├── tests/ │ ├── __init__.py │ ├── test_core/ │ └── test_utils/ ├── scripts/ # 运维脚本 │ ├── deploy.py │ └── migrate.py ├── config/ │ ├── __init__.py │ ├── dev.py │ └── prod.py ├── setup.py └── requirements.txt关键点src目录下的代码是“可导入的”scripts目录下的代码是“可执行的”。scripts里的脚本通常直接写执行逻辑不需要__main__保护因为它们就是被直接运行的。别这样写把scripts里的脚本也加上if __name__ __main__然后期望别人python -m scripts.deploy来运行。这反而增加了心智负担。脚本就是脚本直接python scripts/deploy.py就好。一个真实的调试案例回到开头那个朋友的爬虫项目。他的目录结构是这样的spider/ ├── __init__.py ├── spider.py # 三千行包含所有逻辑 ├── config.py └── run.py # 只有一行from spider import run; run()问题出在spider.py里# spider.py 底部if__name____main__:run()# 启动爬虫而run.py里from spider import run这行会执行spider.py的顶层代码。如果spider.py里有全局变量初始化、数据库连接等操作这些都会在导入时执行。更糟的是他用了multiprocessing子进程又会重新导入spider.py导致__main__块里的代码在子进程里也执行了。解决方案很简单把spider.py拆成多个模块run.py只负责导入和调用所有执行逻辑都放在if __name__ __main__里。但更根本的问题是他一开始就没想清楚“哪些代码是定义哪些代码是执行”。个人经验性建议每个.py文件都应该能被安全导入。意思是即使这个文件底部有if __name__ __main__块导入它时也不应该产生副作用。全局变量初始化、日志配置、数据库连接这些要么放在__main__块里要么用懒加载。__init__.py不是摆设。我见过太多人留空文件。至少写上__all__和包级别的导入这样from package import *时不会把内部模块全暴露出来。更讲究一点可以在__init__.py里做版本检查或环境初始化。相对导入只用在包内部。from . import sibling这种写法只能在包内的模块里用。入口文件比如main.py、run.py永远用绝对导入。这个规则能避免 90% 的导入错误。别在__main__块里写太多逻辑。if __name__ __main__里应该只调用一个main()函数或者解析命令行参数后调用main()。把具体逻辑写在函数里方便测试也方便复用。测试代码不要写在__main__块里。写测试就用pytest或unittest别图省事把测试代码写在if __name__ __main__里。你永远不知道什么时候测试代码会被意外执行。最后记住一个原则模块是定义脚本是执行。一个.py文件要么是模块被导入要么是脚本被运行不要试图同时做好两件事。if __name__ __main__只是给了你一个选择的机会但选择权在你手里。

月新闻