项目结构不是用来应付人的,是用来救命的。刚开始用Python写项目时,我也曾把一堆脚本扔在同一个文件夹里,靠文件名区分功能,三个月后再打开,自己都不敢改。后来经过多次重构和被线上故障毒打,我摸索出一套适合中小型Python项目的组织方式,核心原则只有一条:让代码的意图在目录结构中就能被看见,而不是靠注释和回忆。
先分清楚“入口”和“逻辑”
我的项目根目录永远只有几样东西:一个包目录(比如myapp/)、一个tests/目录、一个pyproject.toml、一个README.md,以及偶尔出现的scripts/目录。很多初学者喜欢把main.py放在根目录,这没有错,但我会更进一步——入口文件只负责启动,不允许写业务逻辑。根目录下的main.py通常只有两三行:
from myapp.main import run if __name__ == "__main__": run()
真正的东西全部在包内。包内第一层就是模块边界,我会按照“基础设施—领域逻辑—应用服务—接口层”四层来切分。myapp/下面至少有config.py、models/、services/、repositories/、api/、core/,其中core/放配置读取、日志初始化、数据库连接等横切关注点。如果一个文件既能干数据库查询又能算业务金额,那这个文件就是一个定时炸弹,迟早会在某个深夜爆掉。
用配置模块代替散落的魔法常量
我见过太多项目把数据库链接、超时时间、重试次数直接硬写在业务函数里,改一个参数要全局搜索。我的做法是让config.py成为唯一的配置入口,它从环境变量和.env文件中读取值,并提供类型安全的访问接口。
from pydantic_settings import BaseSettings class Settings(BaseSettings): app_name: str = "MyApp" database_url: str = "sqlite:///./app.db" debug: bool = False max_retries: int = 3
任何模块都不应该直接导入os.environ,而是from myapp.config import settings。当“配置”成为一个对象,而不是一堆裸字符串时,你会发现自己再也不怕改需求了。配置和业务代码的隔离,是代码结构的第一层免疫力。
模块划分要符合“稳定依赖方向”
我遵循一条铁律:依赖只能从外层指向内层,绝不允许反向依赖。具体来说,api/层依赖services/层,services/层依赖repositories/层,repositories/层只依赖models/和core/。这样划分后,每个模块都能独立测试,因为它的依赖都是更稳定的模块。
举个例子,一个用户注册流程:
api/routes.py负责解析HTTP请求,把参数交给服务层;
services/user_service.py处理业务规则,比如检查邮箱是否重复;
repositories/user_repo.py只做数据操作;
models/user.py定义数据结构和ORM映射。
如果某天要换数据库,只改repositories和core/db.py,业务层和接口层完全不受影响。这种分层带来的最大好处不是“整洁”,而是把变化的范围压缩到最小——你不需要重写半个项目就能切换技术栈。
用包来隐藏内部实现,而不是用下划线
很多人喜欢用_utils.py、_helpers.py来放公共函数,这其实是一种偷懒。模块名一旦叫“utils”,就会变成垃圾场。我倾向于把工具函数按领域归位,比如services/validators.py、services/formatters.py、core/time_utils.py。如果某个函数确实很通用,而且被多个业务模块使用,我会把它提升到core/下,而不是让业务模块直接互相调用。命名一个包,就是声明一种边界;如果边界模糊,你就等着代码里去数不清的import xxx_utils吧。
另外,包的__init__.py不只是凑数文件。我会在__init__.py中重新导出该包对外的主要接口,这样外部调用时from myapp.services import create_user,而不是from myapp.services.user_service import create_user。这能让依赖关系更清晰,也方便未来内部重构而不影响调用方。
数据模型和业务对象要分开
许多框架(尤其是Django)容易让人把数据库模型当成业务对象,直接在模型上写方法、加属性、存临时状态。我在项目里坚持将数据持久层模型和领域模型分离,即使麻烦一点也要分开。models/目录下放ORM模型,只负责字段定义和基础关系;services/层使用数据模型,但会组装成简单的dataclass或TypedDict作为返回对象。
这样操作的好处是:测试时不需要数据库也能构造业务对象,而且避免模型类越来越臃肿。虽然会增加一些转换代码,但长期来看,每一次在模型上添加与数据库无关的方法,都是在给ORM挖坑,也是在给未来的自己增加负重。
异常处理要建立自己的异常层次
我自己的项目里,业务代码禁止直接抛出ValueError或裸的Exception,因为这样会让调用方无法区分“参数不对”和“余额不足”。我会在core/exceptions.py中定义基础的AppError,然后派生出NotFoundError、ValidationError、ConflictError等。
class AppError(Exception): pass class NotFoundError(AppError): pass class ValidationError(AppError): pass
在服务层,业务方法只抛出这些自定义异常;在接口层,统一捕获AppError并转为对应的HTTP状态码或错误响应。没有自定义异常体系的项目,错误处理迟早会变成一团乱麻,因为每个模块都在按自己的方式表达失败。
日志是项目结构的灵魂
我组织代码时,会把日志设置放在core/logging_config.py,但不会让每个模块自己写logging.basicConfig。模块中使用logger = logging.getLogger(__name__),这样日志天然带有模块路径信息,排查问题时一眼就能定位。日志级别和输出格式是全局配置,而不是模块内部的事。我还会在core/logging_config.py中定义过滤器,比如屏蔽健康检查的请求日志,避免噪音。
有一句话我深有体会:项目结构混乱时,日志是最后的安全网;项目结构清晰时,日志只是辅助工具。但无论如何,日志必须在第一天就设计好,否则重构时你会对着一堆没有上下文的信息发呆。
依赖注入用最简单的方式:构造函数
对于中小型项目,我不会引入复杂的依赖注入框架,而是使用朴素的构造函数注入。比如UserService需要仓库和缓存,就显式在__init__里接收:
class UserService: def __init__(self, user_repo: UserRepo, cache: RedisCache): self._repo = user_repo self._cache = cache
然后在api/routes.py中一次性组装依赖树。这样做虽然有点样板化,但优点突出:每个对象的依赖一目了然,测试时可以轻松替换成Mock。显式依赖比隐式全局变量好一万倍,哪怕多写几行代码也值得。使用全局单例对象在早期很爽,但项目变大后,你会陷入“这个状态是谁改的”的深渊。
测试目录镜像包结构
我的tests/目录和myapp/目录保持完全对应的结构。tests/test_services/test_user_service.py对应myapp/services/user_service.py。这样写测试时不用思考“该放哪里”,而且跑测试时也能快速覆盖新增代码。测试代码也是代码,它应当被同样严格地组织。
对于每个服务类,我会写两个测试文件:一个是正常的单元测试(用Mock替换数据库),另一个是集成测试(连接真实测试库)。集成测试放到tests/integration/下,单元测试直接镜像模块路径。此外,conftest.py中只放fixture,不放测试逻辑。这只是一种习惯,但能让整个测试套件看起来清爽且容易定位失败。
使用pyproject.toml统一管理工具链
项目根目录的pyproject.toml是我最重视的文件之一。它不仅声明依赖,还管理运行工具:ruff用来做代码检查,pytest配置测试路径和覆盖范围,mypy开启严格模式。让工具配置跟着项目走,而不是靠每个人的编辑器设置。这样团队协作时,不管谁拉下来代码,都能跑出一致的风格检查结果。
我在[tool.mypy]中设置strict = true,虽然初期会触发很多类型报错,但坚持下来后,代码的作者、调用者和维护者都能快速理解数据流动。类型提示不是给解释器看的,是给未来的你和其他开发者看的地图。
小心循环导入:通过模块层次解决
循环导入是Python项目结构混乱最常见的症状。根源往往是模块职责不清,比如A需要用到B的类型,而B又需要用到A的函数。我的解决策略是:把共享的类型或常量放到更底层的模块中,或者将其中一个依赖延迟到方法内部导入。但更根本的做法是:如果发现两个模块互相依赖,立刻把它们依赖的那部分拆分到第三模块,比如core/types.py。
另外,对于from myapp.x import y的导入,我倾向于在每个模块顶部集中导入,不写函数内导入,除非是在处理真正的循环引用场景。循环导入往往是一次重构的警报——提示你模块之间的层级已经乱了。
资源文件和数据文件单独存放
如果项目需要读取模板、SQL脚本或静态资源,我不会把它们放在Python包内,而是放在assets/或resources/目录,并在core/paths.py中定义路径解析函数。这样避免打包时把资源文件混进代码包,也方便部署时挂载数据卷。代码和数据分离,是项目管理中容易被忽视却非常关键的一环,尤其当项目需要作为Docker镜像发布时。
不要忽视scripts/目录
我保留一个scripts/目录,专门存放管理命令行工具的脚本,比如初始化数据库、生成测试数据、数据迁移辅助脚本。这些脚本通常只做一件事:调用包内的核心函数。我不会允许这些脚本写复杂的SQL或业务逻辑,否则它们会成为测试盲区,也会变成新的“垃圾堆”。脚本的目的是让人机交互变简单,而不是把业务逻辑再复制一遍。
面对实际项目的柔性调整
上面这套结构不是万能的。如果你的项目是数据管道,可能不需要api/层,但你会需要pipelines/和datasources/;如果是机器学习项目,则可能有features/和models/,但核心的分层思想仍然适用:稳定的底层不依赖易变的上层。我会在每个项目初始化时花二十分钟画一个简单的依赖图,然后问自己:哪个模块应该最稳定?哪个模块最容易变化?把这些答案反映到目录结构里,比写十页设计文档都管用。
组织代码结构,其实是在管理复杂度的氧化过程。每天合并代码都会引入新的连接,如果不刻意维护边界,这些连接会迅速生长成藤蔓,把整个项目绑成一团。好的结构不是一蹴而就的,而是每次修改代码时都要问一句:“这个新依赖方向对吗?这个模块是不是塞了太多职责?”这样的习惯,才是我能持续在Python项目中保持清醒的真正秘诀。