做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_idpayload.user_id, items[item.dict() for item in payload.items], ) return OrderResponse(order_idorder_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_iduser_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_idproduct_id, quantityquantity)) 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_factorylist) 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 [ fastapi0.110,1.0, sqlalchemy2.0,3.0, pydantic2.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_prefixAPP_, env_file.env, extraignore) 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 BB又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 modelCI立刻报错。配置示例如下[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__, prefixyour_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. 真要拆微服务先回答一个问题模块边界够不够干净聊到大型项目绕不开“微服务”这个词。我不反对微服务但我见过太多人把架构问题归到“单体不行要拆微服务”。结果拆完原来单体里的泥团被拆成了十个互相调用的泥团问题一个没少反而多了服务发现、分布式事务、链路追踪这些新麻烦。微服务有效的唯一前提是你已经能清晰地说出模块边界知道哪些数据属于哪个服务、哪些改动应该只影响哪个模块。如果你连单体内的模块边界都画不清楚那么拆微服务只是在把混乱分散化。我建议按这个顺序来评估先把单体的模块边界做利索。用前面说的五层结构把架构约束写进CI跑一段时间看依赖关系是否稳定。观察有没有真正的“独立演进”压力。比如某个子域频繁发布却因为和主应用一起发布导致上线窗口被卡死。比如某些模块对资源的需求完全不同CPU密集型和IO密集型住在同一个进程里互相拖累。从模块内部先定义接口。哪怕还在一个工程里也用protocol或abstract class定义好模块之间的契约然后让同工程的不同模块只依赖接口。这样将来拆出去的时候只是把实现换成一个远程调用的适配器而已。最后才考虑拆分技术设施。消息队列、独立数据库、独立部署单元这些都是最后一步而很多人一上来就跳到了这一步。我见过最成功的微服务改造案例不是一夜之间拆完的。他们花了三个月先优化单体的模块边界然后定义一个核心模块的接口协议再把这个模块抽成单独的Python包最后才把包部署成独立服务。整个过程业务代码改动的比例极小大部分工作是在搬家和接线。说回结构本身。你要理解架构设计不是为了炫技也不是为了画出漂亮的架构图给领导看。架构设计是为了让项目在被十个人、二十个人不断修改的前提下仍然保持可理解、可测试、可演进。这个目标靠的不是某一层技术的精妙而是持续性的纪律和工具化约束。最后分享一个我个人的习惯每个季度我会单独挑一天把项目当前的目录结构、依赖关系图、核心模块的import关系重新看一遍。往往一个季度就够了就能发现一两处正在腐化的小苗头顺手清掉。大型项目的健康不是一劳永逸的它需要你像养植物一样定期看看根有没有烂叶子有没有黄而不是等它彻底枯萎了再想办法。希望这篇文章能给你一个开始动手的切入点。