☰
大型Python项目如何设计分层架构?五年不塌的结构骨架与依赖边界实战
2026/10/6 17:21:49 网站建设 项目流程

做Python十多年,带过的项目从几千行到几十万行都有。如果问我什么指标最能预示一个项目的未来,不是用了多潮的框架,不是测试覆盖率多高,而是它的结构。结构乱的项目,哪怕现在跑得好好的,三个月后也会变成谁都不敢碰的泥潭;结构清楚的项目,哪怕代码水平一般,改起来也顺手,新人上手也快。

这篇文章不聊具体某个框架怎么用,也不劝你上某个重型脚手架,就聊我在大型Python项目里踩过的坑、总结出来的分层思路、边界划分和依赖管理方法。我相信很多读者正处在一个分岔路口:项目在膨胀,开始出现循环导入、臃肿的utils包、改一处崩三处的现象,但还没烂到不能收拾。这篇文章就是给这个阶段的你准备的。

1. 从“跑通就行”到无人敢碰:一个实际项目的膨胀现场

先讲一个我真实经历过的项目。四万多行代码,技术栈不差,Django加Celery加PostgreSQL,该有的都有。但我接手时,整个项目只有入口文件和服务层是清楚的,再往里走就是一团乱麻:models文件三千行,views里写业务逻辑,service层互相调用,工具函数散落在五个叫utils_xxx.py的文件里。改一个订单状态的功能,我拉了十几个文件,最后发现自己改的是已经被废弃的第二套实现。

这种项目的膨胀路径几乎一模一样。一开始是个人项目,跑通就行,目录就按Django默认的app结构堆。后来加功能,新需求往现有文件里塞,塞不下了就新建一个services2.py或者utils_final.py。再后来团队协作,每个人对模块的理解不一样,有人从core进,有人从services进,还有人在models里写对外API。导入关系变成了蜘蛛网。

我整理过一个典型的恶性信号清单,你项目里占三个以上,就该正视结构问题了:

信号直接原因后续危害
出现utils2.py、helpers_final.py这类文件没有收敛公共代码的规则到处是重复逻辑,改一个漏一个
每次启动项目都要靠PYTHONPATH硬凑包结构没有按可安装的Python包设计换台机器跑不起来,Ci里全是环境问题
改一个模型字段要全项目搜索引用模型层和业务层没有边界一次改动影响面失控
测试文件里大量sys.path操作测试目录和源码目录结构不对齐测试跑不过,后来干脆不跑了
模块之间互相import形成环分层的依赖方向没定死重构时根本不知道从哪里下手
配置文件里有大量if env == 'prod'分支配置和业务逻辑耦合新增环境要动代码,不敢发版

为什么Python项目特别容易乱?因为Python太“灵活”。动态类型、随处可用的import、monkey patch、隐式的全局单例,这些东西在小项目里是效率神器,在大型项目里就是结构腐蚀的加速器。Java有package和访问修饰符逼着你思考边界,Go有循环依赖编译错误直接拦住你,Python什么都没拦。它就像装修时用的软管,怎么弯都行,但你把一整栋楼的水管都做成软管,水流就乱了。

所以,大型Python项目的结构设计,本质上不是在写代码,而是给团队立一套“建筑规范”。规范越明确,每个人往里面加砖的时候就越不容易跑偏。下一节我直接给出我这几年用得最顺手的一套骨架。

2. 五年不塌的分层骨架:五层就够了

网上关于Python项目结构的方案很多,有按MVC分的,有按DDD分的,有按技术栈分的。我用过一圈之后,沉淀下来的是下面这套五层结构。它以业务为核心、技术细节往外围辐射,依赖方向是单向的,从外向内。

my_project/ ├── pyproject.toml ├── README.md ├── src/ │ └── your_app/ │ ├── __init__.py │ ├── api/ # 对外入口层:HTTP API、CLI、消息消费者 │ ├── application/ # 用例层:编排业务场景、事务控制 │ ├── domain/ # 领域层:业务实体、业务规则、领域服务 │ ├── infrastructure/ # 基础设施层:DB、缓存、第三方SDK封装 │ └── shared/ # 全项目共享的小工具,必须精简 ├── tests/ │ ├── unit/ │ ├── integration/ │ └── e2e/ ├── scripts/ # 运维脚本、数据迁移脚本、CI辅助脚本 └── docs/

2.1 每一层只做一件事

api层只负责“翻译”。HTTP请求进来,它负责解析参数、校验格式、调用application层的方法、把结果转成响应。这一层里不写业务规则。如果一个函数里出现“如果订单金额大于1000就打折”这类代码,就是越层了。api层是整栋楼的门厅,门厅再脏乱差,也不该在地下室施工。

application层承载的是“场景编排”。比如“用户下单”这个用例,它要调用库存判断、价格计算、订单创建、发送通知。它知道整个流程怎么串,但不知道这些操作背后的技术细节。它是这个故事的总导演,不是演员。

domain层是整个架构的心脏,里面放业务实体、值对象和业务规则。订单、金额、库存这些概念在这里定义,订单总价=商品单价×数量+运费-折扣这个公式只允许写在这里。domain层不依赖Django或Flask,不依赖数据库连接,甚至不依赖任何第三方库。它要的是纯Python逻辑,这样你随时能把整个业务核心抽出去做单元测试。

2.2 依赖方向画出来是单向箭头

这个架构能撑住的关键在于依赖方向:api → application → domain,同时infrastructure在另一侧依赖domain,但domain不依赖任何人。

api → application → domain ← infrastructure

稍微解释一下这个箭头。api层可以直接调用application层,application层可以直接调用domain层,但反过来不行。domain层不知道api层的存在,这是保证它稳定独立的前提。infrastructure层实现一些数据访问接口,这些接口抽象定义在domain层,比如一个OrderRepository的抽象基类。infrastructure里的Django模型去实现这个接口。这样,业务层面向抽象编程,不关心数据库到底是谁。

有一回我把项目里的数据库从PostgreSQL切到MySQL,刚开始以为会伤筋动骨,结果只改了infrastructure层的几个实现文件,domain和application一行没动。那个瞬间我真正理解了这句话:架构的价值,是让变化顺着你预设的方向走,而不是让变化牵着你的鼻子走。

2.3 为什么我坚持用src布局

很多人习惯项目根目录下直接放包,变成:

my_project/ ├── models.py ├── views.py └── utils.py

这在Django项目里尤其常见,项目建出来就是app在根目录下。但项目一多你马上就遇到问题:包之间的import全凭目录位置,PYTHONPATH不配就各种ModuleNotFoundError,测试目录也找不到源码。

换成src/布局之后,你的包变成了一个真正可以被pip安装的包。你只要在pyproject.toml里配好构建规则,装进虚拟环境后,项目本身就是环境里的一个依赖。这带来的直接好处是:所有import都是基于包名的,不再依赖你当前在哪个目录下运行命令。测试也好、命令行工具也好、CI流水线也好,行为完全一致。

有些老手会觉得src布局麻烦,因为要配置setuptools的package-dir。但现在pyproject.toml已经很成熟了,Hatch、Poetry、uv都默认支持,搭起来不超过三分钟。这个成本换来的稳定性能管项目好几年,值。

3. 给包立规矩:边界不是靠自觉,是靠命名和约定

分好层只是第一步,真正让代码不腐的是包内部的命名和边界规则。我见过很多项目分层了,但包的内容和划分完全是乱的。这不叫有架构,只能叫有目录。

3.1 别再用utils这个名字了

utils是Python项目里最臭名昭著的包名。它是一个垃圾桶,什么都能往里扔。日期格式化、字符串截断、请求重试、状态码转换、随机数生成……最后这个包变成几千行,谁也不敢动,因为谁也不知道它被哪些模块依赖。

我的做法很简单:除非项目真的有一个“公共小工具”的集合,否则不使用utils。需要什么就建一个语义明确的包,比如:

  • 日期相关的,叫time_utils或者dates;
  • 字符串处理的,叫text;
  • 网络请求相关的,叫http_clients;
  • 数据处理类的,叫io_utils。

你会发现,语义明确的包天然有边界。text包不会偷偷放一个数据库连接池,http_clients不会出现业务折扣逻辑。因为名字让人一目了然,大家加代码的时候就会想:这行代码放这里合不合适?

shared包我一样严格限制。它只放那种全项目都在用、改动极少的东西,比如日志初始化、通用异常类、常量定义。凡是某个业务模块专用的辅助函数,就直接放在那个模块自己的子包里,不要上提到shared。很多时候模块之间的“共享代码”其实是伪共享,只是两三个地方用同一个函数,你就该先复制一份各自的,等真正出现三个以上消费者且逻辑一致,再提取出来。过早提取公共代码,是造出utils垃圾场的开始。

3.2 文件的行数和import关系要设限

我说两条我在团队里定的硬规矩,你可以直接抄过去用。

第一条,单文件原则上不超过300行。超过就说明这个文件塞了太多东西,该拆了。比较长的业务逻辑拆成多个函数、多个类、多个模块,不是可耻的事。可耻的是把一个三百行的文件硬憋成一个两千行的怪物,然后告诉别人“这文件很稳定”,其实只是没人敢碰。

第二条,一个文件顶部的import行数不要超过10行。看到文件头有十几行import,而且来源五花八门,基本可以判断这个文件的依赖关系已经乱得不像样了。依赖越多的模块,它复用的可能性越低,它在整个系统里就越接近“上帝模块”。上帝模块最终会成为重构最大的一座山。

3.3__init__.py里少做再导出

很多初学者喜欢在包的__init__.py里做大量再导出,方便外部直接from xxx import Service。这在库项目里是好事,但在业务系统里是麻烦。

原因很简单:再导出会模糊真正的owner。比如domain/order.py定义了Order模型,domain/__init__.py里把它再导出,然后业务代码里到处写from domain import Order。突然有一天你发现order.py该拆成order.py和order_item.py,这一改动会让所有import了Order的地方都炸。

我的习惯是,包的__init__.py只做两件事:定义__all__来限制外部可见符号,或者保持空文件作为包标记。真正的import路径写得越显眼越好,from your_app.domain.order import Order虽然长了点,但它把代码的真实位置暴露得明明白白——维护代码的人永远知道去哪里找东西。

3.4 业务代码和框架代码的边界,用项目实例演示

我给你一个具体的落地例子。假设我们做的是一个在线商城项目,订单创建的需求是:计算商品总价、检查库存、扣减库存、创建订单、发通知。

在没边界的项目里,这个流程可能分布在views.py里三百行、models.py里两百行。在我推荐的架构里是这样分的:

# api/handlers/order_handler.py from your_app.application.order_service import create_order from your_app.api.schemas import CreateOrderRequest, OrderResponse def handle_create_order(request): payload = CreateOrderRequest(**request.json) order_id = create_order( user_id=payload.user_id, items=[item.dict() for item in payload.items], ) return OrderResponse(order_id=order_id).model_dump()
# application/order_service.py from your_app.domain.order import Order, OrderItem from your_app.domain.events import order_created from your_app.domain.repositories import OrderRepository, InventoryRepository def create_order(user_id, items): order = Order.create(user_id=user_id) for item_data in items: product_id = item_data["product_id"] quantity = item_data["quantity"] if not InventoryRepository.is_available(product_id, quantity): raise InventoryShortageError(product_id) InventoryRepository.deduct(product_id, quantity) order.add_item(OrderItem(product_id=product_id, quantity=quantity)) OrderRepository.save(order) order_created.send(order.id) return order.id
# domain/order.py from dataclasses import dataclass, field from decimal import Decimal @dataclass class OrderItem: product_id: int quantity: int unit_price: Decimal = Decimal("0") @property def subtotal(self) -> Decimal: return self.unit_price * self.quantity @dataclass class Order: user_id: int items: list = field(default_factory=list) id: int | None = None @property def total_amount(self) -> Decimal: return sum((item.subtotal for item in self.items), Decimal("0"))

看到没有,application层的create_order是一个纯粹的编排函数,它不接触HTTP请求,不接触数据库。domain层就是纯Python数据结构和业务规则。将来哪怕你把Django换成FastAPI,或者把数据库从PostgreSQL换成MongoDB,核心流程照样跑。这就是边界给项目带来的抗风险能力。

4. 依赖、配置和导入路径:大型项目里最坑的三个暗礁

很多人做结构设计,只盯着目录长什么样,忽略了后面这三件事。但你只要在一个大型项目里待过半年,你一定会遇到它们。

4.1 依赖固定:requirements.txt不是写给人看的,是写给机器看的

项目里最经典的一个场景:新同事clone代码,按照README配好环境,run起来,报错。查了半小时,发现是某第三方库升级了,API变了。然后大家就陷入“要不要把版本写在requirements.txt里啊”这种极其低级的讨论。

答案是:必须写,而且要把依赖分为运行时依赖和开发依赖。

运行时依赖,就是业务跑起来必须要的库,放在pyproject.toml的dependencies里。开发依赖,比如pytest、ruff、mypy这类只在开发和CI阶段用的工具,放在dependency-groups或[tool.poetry.group.dev.dependencies]里。

版本策略我推荐“下限加上限”的做法,比如:

dependencies = [ "fastapi>=0.110,<1.0", "sqlalchemy>=2.0,<3.0", "pydantic>=2.5,<3.0", ]

为什么写下限?因为能跑通你的代码的库版本至少要这个版本。为什么写上限?为了阻止大版本升级带来的破坏性变更。很多人会骂这不够“随缘”,但大型项目求的就不是新鲜,是可重复。你今天跑通的环境,三个月后拉下来必须还能跑通。如果你用了uv或者Poetry,还应该把uv.lock或poetry.lock这种锁文件提交到仓库里去。锁文件锁的是完整依赖树,比requirements.txt只锁顶层依赖更精确。

这话题说起来简单,但我见过太多项目,requirements.txt里写的是django不带版本号,鬼知道下次依赖解析的时候拉出来什么。你项目的崩溃往往不是某一刻你写错了一段代码,而是某一天一个依赖库悄悄升级了,你还在用老用法。依赖管理就是给这次“悄悄升级”上把锁。

4.2 配置文件:别再在config.py里堆if env == 'dev'

大型项目常见的配置写法是这样的:

# 不好:配置和代码分支耦合 env = os.getenv("APP_ENV", "dev") if env == "prod": DB_HOST = "prod-db.internal" DEBUG = False elif env == "staging": DB_HOST = "staging-db.internal" DEBUG = False else: DB_HOST = "localhost" DEBUG = True

这个写法前期很爽,但项目一复杂你就发现,配置文件里堆了几百行环境分支,新增一个环境要改这段,改一个变量开一次发布会。正确的思路是:一份配置库,一套环境变量映射,代码里不出现环境判断。

# 推荐:pydantic-settings 方案 from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env", extra="ignore") database_url: str = "postgresql://localhost:5432/myapp" redis_url: str = "redis://localhost:6379/0" debug: bool = False

实际跑的时候,你在不同的环境里设置不同的APP_DATABASE_URL环境变量即可。项目代码里统一from your_app.shared.config import get_settings,框架启动时加载一次,后面所有的配置读取都走一个入口。环境差异被赶到了“环境变量”这一层,代码本身只认变量名,不再关心这是什么环境。

这对架构的意义在于:**环境相关的变量一多,你的部署策略和代码就可以分离了。**改了数据库地址不用重新发版,CI里想跑一套测试环境直接注入环境变量就行。我之前在项目里推进这个改造,一周之后,第一件让我欣慰的事是:.env文件终于不用提交到git里了,配置泄漏的风险小了不少。

4.3 循环导入:根因和拆解方法

循环导入是大型Python项目的“元凶级”问题。出现ImportError: cannot import name 'X' from partially initialized module,几乎所有Python开发者都见过。为什么会循环导入?直接原因是两个模块互相引用。但深层原因往往是职责边界没设计清楚。

比如模块A定义了User类,模块B定义了UserRepository,然后User里有个方法要调用UserRepository,导致A又import B,B又import A。这就是一个典型的循环。

破解方法我在团队里立了三条原则:

一,实体层datar不依赖仓库层,而是反过来。让User模型只负责自己的业务行为,不负责持久化。持久化逻辑放到infrastructure层,实体层与持久化实现之间只依赖抽象接口。这样domain层永远不会出现“为了查数据库而import infrastructure”的情况。

二,如果两个模块互相需要对方的东西,说明它们归属的层可能不对,或者该提取一个更底层的公共模块。比如A和B都用同一个枚举值,那就把它提到shared/constants.py里。别觉得提取麻烦,提取一次能省未来无数次debug。

三,延迟导入可以作为应急手段,但不能当长期方案。在函数内部import确实能让代码先跑起来,但它会掩盖真实的依赖结构。正确做法是:跑起来之后,立刻把你用延迟导入绕过的那段依赖关系画出来,重新归档到正确的层。

4.4 全局单例要收敛,别让数据库连接散落全项目

Python项目里很容易出现这种代码:

# 到处都是这种连接 conn = create_engine(settings.database_url)

然后你在项目里搜索create_engine,发现出现在九个文件里,每个文件各建各的连接池。轻则资源浪费,重则连接池被打爆。大型项目里的基础设施对象——数据库连接、Redis客户端、外部HTTP客户端、日志器——应该统一在infrastructure层初始化,然后通过依赖注入或明确的初始化入口传递给需要的模块。

不要走全局单例的捷径。全局单例最大的问题是隐藏依赖,让模块之间通过共享状态耦合。你写测试的时候想替换一个假的Redis客户端,全局单例让你不得不改全局状态,改完还得担心影响别的测试。依赖注入让依赖关系显式化,哪段代码用了什么依赖,看函数签名就清楚了。

如果你现在项目已经一堆全局连接,别慌。第一步,把所有连接创建收拢到infrastructure/connections.py里,每个连接封装成一个返回客户端的函数;第二步,把引入这些客户端的入口统一到几个工厂函数;第三步,把业务函数签名改造成接收客户端参数。三步走完,你的项目已经比绝大多数同行干净了。

5. 把“架构违规”拦在合并之前:不靠自觉,靠自动化

结构设计得再好,如果团队里每个人都有自己的一套风格,几个月后结构还是会烂掉。人不是机器,人会偷懒,会图省事,会在赶工时塞个临时逻辑。所以,大型项目一定要在工程链路上加“自动护栏”。

5.1 用import-linter锁死跨层依赖

import-linter是一个很小但极好用的工具。它允许你声明模块之间的依赖规则,然后作为CI的一环自动检查。比如,我可以写一段规则:domain层的包,不允许importinfrastructure层或者api层的任何模块。任何人写代码的时候不小心让domain层偷偷import了一个Django model,CI立刻报错。

配置示例如下:

[tool.importlinter] root_package = "your_app" [[tool.importlinter.contracts]] name = "domain必须独立" type = "layers" layers = ["api", "application", "domain", "infrastructure"] containers = ["your_app.api", "your_app.application", "your_app.domain", "your_app.infrastructure"]

一句话:架构图的单向依赖关系,变成了机器可读、可强制校验的规则。这比评审会上苦口婆心地叮嘱“老弟,你这层不能import那层”管用一万倍。

5.2 架构风格测试也留一手

大型项目还有一个隐藏的坑:测试代码本身也会乱。我见过很多项目的测试文件叫test_utils.py,里面既有单元测试又有集成测试,还有连数据库的真实验证。测试目录最好和目标代码目录一一对应,让测试结构成为代码结构的镜像。

同时,给几个关键核心类建专门的“架构测试”,用测试断言verify重要的结构约束不会退化。比如:

def test_domain_layer_does_not_import_framework(): import pkgutil import your_app.domain for mod_info in pkgutil.walk_packages(your_app.domain.__path__, prefix="your_app.domain."): module = __import__(mod_info.name, fromlist=["*"]) imported_names = getattr(module, "__annotations__", {}) # 断言所有import里没有django/flask/fastapi 字样

这个测试可能写得粗糙,但它就像航空母舰上的一根锚链:平时不起眼,关键时刻能兜住全船的稳定。架构这条线,最怕的不是没人守,而是没有闸门。

5.3 代码评审里的“import审校点”

代码评审阶段,我会特意让团队关注每次diff里的import变化。有几种情况一定要拦下来:

  • domain层新增了第三方库 import,尤其requests、django.db这类——基本可以断定边界破了。
  • 一个文件顶部新增了一整组新import,甚至是从“同一层的兄弟模块”来的——大概率是挪用了本不该在这一层的功能。
  • 新增了from your_app.shared里的函数,而且这个函数原本是某个业务模块内部的——可能发生了“伪共享提取”。

这些点看起来小,但积少成多。一个大项目的腐烂永远是从一个个“小塞入”开始的,每一次都想着“先这样吧,以后再改”,三个月后你就再也找不到哪个“以后”了。

6. 真要拆微服务?先回答一个问题:模块边界够不够干净

聊到大型项目,绕不开“微服务”这个词。我不反对微服务,但我见过太多人把架构问题归到“单体不行,要拆微服务”。结果拆完,原来单体里的泥团被拆成了十个互相调用的泥团,问题一个没少,反而多了服务发现、分布式事务、链路追踪这些新麻烦。

微服务有效的唯一前提是:你已经能清晰地说出模块边界,知道哪些数据属于哪个服务、哪些改动应该只影响哪个模块。如果你连单体内的模块边界都画不清楚,那么拆微服务只是在把混乱分散化。

我建议按这个顺序来评估:

  1. 先把单体的模块边界做利索。用前面说的五层结构,把架构约束写进CI,跑一段时间看依赖关系是否稳定。
  2. 观察有没有真正的“独立演进”压力。比如某个子域频繁发布,却因为和主应用一起发布,导致上线窗口被卡死。比如某些模块对资源的需求完全不同,CPU密集型和IO密集型住在同一个进程里互相拖累。
  3. 从模块内部先定义接口。哪怕还在一个工程里,也用protocol或abstract class定义好模块之间的契约,然后让同工程的不同模块只依赖接口。这样将来拆出去的时候,只是把实现换成一个远程调用的适配器而已。
  4. 最后才考虑拆分技术设施。消息队列、独立数据库、独立部署单元,这些都是最后一步,而很多人一上来就跳到了这一步。

我见过最成功的微服务改造案例,不是一夜之间拆完的。他们花了三个月,先优化单体的模块边界,然后定义一个核心模块的接口协议,再把这个模块抽成单独的Python包,最后才把包部署成独立服务。整个过程,业务代码改动的比例极小,大部分工作是在搬家和接线。

说回结构本身。你要理解,架构设计不是为了炫技,也不是为了画出漂亮的架构图给领导看。架构设计是为了让项目在被十个人、二十个人不断修改的前提下,仍然保持可理解、可测试、可演进。这个目标,靠的不是某一层技术的精妙,而是持续性的纪律和工具化约束。

最后分享一个我个人的习惯:每个季度,我会单独挑一天,把项目当前的目录结构、依赖关系图、核心模块的import关系重新看一遍。往往一个季度就够了,就能发现一两处正在腐化的小苗头,顺手清掉。大型项目的健康不是一劳永逸的,它需要你像养植物一样,定期看看根有没有烂,叶子有没有黄,而不是等它彻底枯萎了再想办法。希望这篇文章能给你一个开始动手的切入点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询