定位:这不是一篇“Python架构最佳实践”,是用空圈容错理论的 R4(跨层跳步)和 R2(假异构)去诊断 Python 项目里“依赖方向悄悄焊反”的问题,并给出一个可落地的切入口——import-linter 的 layers 契约。
边界:本文不替代代码评审、不替代测试、不宣称“上了这个工具架构就不会腐化”。它只解决一件事:依赖箭头焊反了,CI 不放行。
一、先认病理:Python 的架构腐化,是“低频、倾向不自愈”型风险
Python 项目的架构腐化,不是“代码写得烂”——烂代码可以重构;架构腐化是依赖方向在无人察觉的情况下发生倒转,而且一旦转过去,倾向是不会自己转回来。
据 archy 项目公开的实测数据(具体口径以 archy 原文为准):
- 人类与 agent 都不高频引入循环依赖:1,072 次人类 commit 引入循环的比例只有 0.5%(跨 11 个仓库);25 次 agent 运行在 SWE-bench 任务上零结构性回归(95% 上界 12%);151 个 commit 对的契约违规率只有 0.66%;
- 但方向错误在 agent 任务中真实发生:25 个 agent 按指定架构建后端,12% 犯了依赖方向错误——布局全对,方向错了;
- 把检查器放进循环后,错误率归零:同样的任务,加了 CI 门禁,方向错误不再出现;
- 一旦落地,倾向不自愈:抽样语料库中零违规被修复,14 个仓库里有 2 个长期坐在破损契约上,没有修复。
以上数据为方向性引用,精确值以 archy 原文为准,本文只转述趋势,数字不作精确基准引用。另需注明:archy 作者本人明确说“没有人测量过一次违规的代价”,所以“永久”是一个基于观察的定性判断,不是量化结论;本文用“倾向不自愈”,不用“永久”。
这四组数据指向同一个结论:架构腐化不需要每天防,但一旦漏过去,倾向是不会自己好。它低频,但倾向不自愈。
这正是空圈 R4(跨层跳步)和 R2(假异构)在代码世界的显影——错误方向一旦焊进 import 图,后续每一行代码都在这个错误方向上叠加。
二、C/M/P 映射:空圈三层在 Python 里长什么样
空圈的 C/M/P 不是抽象概念,在 Python 里可以直接落成包结构和依赖箭头:
| 空圈层 | Python 落法 | 依赖规则 |
|---|---|---|
| C 层(现实基底) | domain/或core/——纯业务逻辑、实体、值对象 | 不依赖任何其他层,是依赖的终点 |
| M 层(模型/规则) | application/或use_cases/——编排领域逻辑 | 只能依赖 C 层,不能反向 |
| P 层(工程输出) | entrypoints/(CLI/API)、infra/(数据库/外部服务) | 可以依赖 M 和 C,M 和 C 不能依赖 P |
以某项目公开的架构决策记录(ADR)为例,跑通的依赖示意是:entrypoints → infra → application → domain → shared,单向流动,没有任何一层能 import 上面一层。
⚠️重要提醒(防误读):
entrypoints → infra只是 layers 工具线性排序带来的假象,业务架构上entrypoints与infra为并列 P 层,二者均可向下依赖application,entrypoints不强制 importinfra。layers 契约只能表达线性序,所以配置时把两个外层合并成一个“高层”,具体层序按项目实际架构调整——不要把“entrypoints 依赖 infra”当成架构规则。
这不是风格建议,是你写进配置的硬性契约:高层可向下依赖,低层一旦向上看,CI 直接红。
三、工具机制:layers 契约就是 R4 在 CI 层的拦截器
import-linter 的 layers 契约做的事情极其单一:给你一个有序列表,它自动检查“低层有没有 import 高层”,包括间接 import 链。
它不关心你具体代码长什么样,只关心依赖箭头的方向对不对。
这正好对应空圈 R4 的命中形态——跨层跳步不需要“代码写错”,只需要“import 写反”。domain里写了一句from app.application import X,R4 就已经发生了,代码可能还能跑,但架构已经腐化。layers 契约在 CI 阶段把这一行拦下来,不是因为它“丑”,是因为它把依赖方向焊反了。
以某项目公开的 ADR 为例,选 layers 而不是 forbidden 契约的理由也很干脆:forbidden 需要逐条枚举禁止路径,每加一个子模块就要加一条规则,脆且烦;layers 是一张有序列表,零维护,方向自动检查。
这就是“把 R4 做成基础设施”该有的样子——不是靠人记住规则,是靠工具强制执行规则。
四、配置示例:最小可配的 R4 门禁 + 深度兜底
格式说明:较新版本 import-linter 支持
pyproject.toml的 TOML 格式;老版本只认.importlinter的 INI 格式。以下示例放在pyproject.toml里,按所用版本选格式,复制配置不生效时优先核对工具版本。
# pyproject.toml [project] name = "your-project" version = "0.1.0" [tool.importlinter] root_package = "your_project" [[tool.importlinter.contracts]] name = "分层方向(R4:禁止低层import高层)" type = "layers" layers = [ "your_project.entrypoints", # P层:CLI/API "your_project.infra", # P层:数据库/外部 "your_project.application", # M层:用例编排 "your_project.domain", # C层:纯业务 "your_project.shared", # 共享内核(最底层) ] [[tool.importlinter.contracts]] name = "深度兜底:domain 不得触碰实现层" type = "forbidden" source_modules = ["your_project.domain"] forbidden_modules = ["your_project.infra", "your_project.entrypoints"]⚠️层名为示意:以上
entrypoints/infra/application/domain/shared是通用示意,不同项目层名不同,按项目实际包结构填root_package和层名。layers 只认实际包名,C/M/P 是思想,不是固定文件夹名。
第一段契约管R4(方向):domain不能 importapplication,application不能 importentrypoints——方向反了,CI 挂。
第二段契约是深度兜底:把domain到infra/entrypoints的跨层通道彻底焊死,哪怕未来 layers 的层序被误改,这条显式禁令仍是第二道闸。它不叫 R2,也不负责检测循环依赖——forbidden 只禁止显式枚举的单向路径;循环依赖要靠 layers 自身的无环检查或 independence / Acyclic siblings 类契约来管,不是 forbidden 的活。
⚠️两个契约有重叠,说明:forbidden 契约里“domain 不能 import infra 和 entrypoints”,layers 契约已经保证了(domain 在最底层,不能 import 任何上面的层)。forbidden 在这里是冗余兜底——layers 已经保证了方向,forbidden 是额外一道保险,防止未来 layers 配置被改错。两个都配不是必须,但配了更稳;如果只配一个,配 layers。
跑法:lint-imports。契约全部通过时正常退出(exit 0);方向违规(exit 1)、strict 模式违规(exit 2)、或配置本身有误(exit 3)均会以非 0 退出。因此 CI 里非 0 先分型——是契约被破,还是配置/版本/包名写错(后者参见 Q&A)。挂进 pre-commit 与 CI 流水线,合并门禁只看“绿/红”,排查时再看报错正文。
读者想自己验证,三步走:①pip install import-linter;② 把配置放进pyproject.toml(或按版本用.importlinterINI),按实际层名改layers列表;③ 跑lint-imports,非 0 先分型排查。这一步不需要额外脚本,import-linter 自己就是验证工具。
五、为什么这是 M 层架构约束,不是“又一个 lint”
普通的 pylint / flake8 管的是语法和风格,它们不关心domain有没有import infra——那行代码语法完全合法。
import-linter 管的是依赖拓扑,它检查的东西在所有单个文件里都“看不见”。
空圈 R4 和 R2 要抓的恰恰是这种“单文件合法、跨文件焊错”的问题——它不是代码质量工具,是架构方向工具。
archy 的数据也指向同一个结论:方向规则值得做,但不需要做多。它停止新功能开发的原因不是“方向规则没用”,而是“做多了没用”——方向错误本身低频,但一旦发生就倾向不自愈,所以一个可靠的 layers 契约就够了,不需要复杂化。
这正好对应空圈“给方向不给参数”的分工:工具给单向列表,具体的包名和层名由项目自己填。
六、边界:layers 契约管不到的四件事
第一,它只管依赖方向,不管“层内写得对不对”。domain内部逻辑写成一团乱麻,layers 契约不会管——那是代码评审和测试的事。R4 只管“跨层”,不管“层内质量”。
第二,它不管“层划得对不对”。如果你把本该在domain的东西放进了infra,layers 契约会放行——因为方向没反,只是放错了位置。空圈给的是“方向判据”,不是“内容判据”。
第三,它需要项目先有“层”的自觉。如果代码本来就是扁平脚本堆,没有domain/application/infra的分层,layers 契约无层可查。这时候要做的是先划层,不是先上工具——空圈的 M 层约束,前提是 M 层本身存在。
第四,它只管静态 import,不管运行时动态依赖。通过getattr、字符串导入、依赖注入容器动态加载的依赖,layers 契约查不到——这些需要额外的运行时检查和测试来补。静态方向检查是必要条件,不是充分条件。
七、落地路径:从“零”到“CI 门禁”的四步
第一步:画依赖图。不看代码,先画“谁 import 谁”的箭头图。如果画不出来,说明项目还没有清晰的分层——这是第一步要解决的问题,不是工具能解决的。
第二步:定层名。按 C/M/P 映射,给每个包定一个层名:domain(C 层)、application(M 层)、entrypoints/infra(P 层)。层名不用多,三到五层就够。
第三步:写 layers 契约。把层名按“高层在上、低层在下”的顺序写进配置的 layers 列表。先只写 layers,不加 forbidden——layers 是零维护的,forbidden 需要逐条枚举,先让方向检查跑起来。
第四步:挂 CI。把lint-imports挂进 pre-commit 和 CI 流水线,非 0 就阻断合并。非 0 先分型:是契约被破,还是配置/版本/包名写错。
第一次跑可能会红一片,这是正常的——它不是在挑刺,是在告诉你“依赖方向已经焊反了多少处”。红了之后两条路:
- 新项目:全部修掉再合并——新项目没有历史包袱,趁早焊死方向最省事;
- 存量项目:先加
exclude把已知违规暂时跳过、逐步还债——但exclude字段本身不支持写备注或设过期时间,“待还债”标签和截止期需要靠 PR 注释、issue、文档人工维护。工程上可以再补一道轻量门禁:在注释里写 ISSUE 编号与截止期(如# TODO(tech-debt, expires=2026-Q2): ...),并用一条独立的“todo 过期检查”卡在 CI 里——让豁免可被追踪,而不是靠自觉。不能让它变成永久豁免,否则 layers 契约就形同虚设。
八、自检与重检:这个方案自己也要挂 ∅_safe
自检一:这个方案有没有 R8a 风险?有——如果把它当成“上了 import-linter 架构就不会腐化”,就滑进了 R8a。layers 契约只管“方向有没有反”,不管“层划得对不对、层内写得好不好、架构设计合不合理”。它是门禁,不是架构师。
自检二:这个方案有没有 R10 余量幻觉?有——如果以为“加了 layers 契约就万事大吉”,就是 R10。方向检查是必要条件,不是充分条件。它拦住了“箭头焊反”,但拦不住“层内腐化、测试缺失、设计错误、运行时动态依赖”。
自检三:这个方案有没有 R2 假异构?有——R2 在代码里的真身不是“循环依赖”(循环依赖是拓扑环,由 layers 无环检查或 independence 契约管),而是名义上走独立通道、实质上同源耦合:比如domain通过shared间接 import 了infra——名义上走的是shared这条“独立通道”,实质上和直接 import 是同一个依赖,这才是假异构。layers 能追踪这条间接链;它查不到的是运行时依赖——infra的对象被注入进shared,domain只 importshared的接口,代码里没有任何 importinfra语句,静态图看不见。layers 管静态 import 链,不管运行时注入。
重检触发:如果项目重构了包结构、新增了层、或者shared内核膨胀到承载了不该承载的东西,配置文件必须重检——工具不会自己知道“层变了”,人得告诉它。
配置文件必须进 git,和代码一起版本管理——它本身是架构约束的“宪法”,改了必须留痕,不能只在某个人的本地环境里跑。
九、常见踩坑 Q&A
Q1:我的项目没有domain/application这套包名,还能用 layers 吗?
可以。layers 只认你的实际包名,C/M/P 是思想,不是固定文件夹名。把 layers 列表里的包名换成你项目实际的包名就行。
Q2:为什么第一次执行lint-imports大片红色?
是历史存量依赖倒置,不是工具 bug。新项目直接修掉再合并;老项目加exclude逐步还债,但exclude不支持元数据,截止期靠 PR 注释 / issue / 独立过期检查人工维护。
Q3:layers 和 forbidden 什么时候要同时配?
layers 是方向主检查,零维护;forbidden 是额外兜底,防 layers 配置被改错。两个都配更稳,只配一个就配 layers。注意:forbidden 不负责检测循环依赖——循环依赖靠 layers 无环检查或 independence / Acyclic siblings 契约。
Q4:复制配置跑不通怎么办?
先核对 import-linter 版本——较新版本支持pyproject.toml的 TOML 格式,老版本只认.importlinter的 INI 格式;格式不对,工具直接报错。非 0 退出不一定是方向违规,也可能是配置 / 版本 / 包名写错(exit 3),先分型再排查。
十、一句话收口
把空圈扎进 Python 的 M 层架构约束,最硬的切入口是:用 import-linter 的 layers 契约,把 C/M/P 的依赖方向写进 CI,让 R4(跨层跳步)和 R2(假异构)从“评审时靠人盯”变成“合并前自动红”。
forbidden 契约是深度兜底,不挂 R2 标签;R2 的真身在“名义独立通道、实质同源耦合”(如domain通过shared间接摸到infra),由 layers 的间接 import 追踪来捕获。它不负责层内质量、不负责层划得对不对、不替代代码评审、查不到运行时动态依赖——它只负责一件事:依赖箭头焊反了,门禁不放行。
方向错误低频但倾向不自愈,一道可靠的 layers 门禁刚好够用,不需要把它做复杂。工具不自豁免:它拦 R4 和 R2 的静态形态,但它自己也受 R8a 和 R10 约束——上了工具不等于架构不会腐化,只等于“方向焊反了会被拦住”。