空圈容错落进Python:用架构约束把R4焊死在CI里
定位这不是一篇“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 和 CM 和 C 不能依赖 P以某项目公开的架构决策记录ADR为例跑通的依赖示意是entrypoints → infra → application → domain → shared单向流动没有任何一层能 import 上面一层。⚠️重要提醒防误读entrypoints → infra只是 layers 工具线性排序带来的假象业务架构上entrypoints与infra为并列 P 层二者均可向下依赖applicationentrypoints不强制 importinfra。layers 契约只能表达线性序所以配置时把两个外层合并成一个“高层”具体层序按项目实际架构调整——不要把“entrypoints 依赖 infra”当成架构规则。这不是风格建议是你写进配置的硬性契约高层可向下依赖低层一旦向上看CI 直接红。三、工具机制layers 契约就是 R4 在 CI 层的拦截器import-linter 的 layers 契约做的事情极其单一给你一个有序列表它自动检查“低层有没有 import 高层”包括间接 import 链。它不关心你具体代码长什么样只关心依赖箭头的方向对不对。这正好对应空圈 R4 的命中形态——跨层跳步不需要“代码写错”只需要“import 写反”。domain里写了一句from app.application import XR4 就已经发生了代码可能还能跑但架构已经腐化。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不能 importapplicationapplication不能 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 先分型——是契约被破还是配置/版本/包名写错后者参见 QA。挂进 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的东西放进了infralayers 契约会放行——因为方向没反只是放错了位置。空圈给的是“方向判据”不是“内容判据”。第三它需要项目先有“层”的自觉。如果代码本来就是扁平脚本堆没有domain/application/infra的分层layers 契约无层可查。这时候要做的是先划层不是先上工具——空圈的 M 层约束前提是 M 层本身存在。第四它只管静态 import不管运行时动态依赖。通过getattr、字符串导入、依赖注入容器动态加载的依赖layers 契约查不到——这些需要额外的运行时检查和测试来补。静态方向检查是必要条件不是充分条件。七、落地路径从“零”到“CI 门禁”的四步第一步画依赖图。不看代码先画“谁 import 谁”的箭头图。如果画不出来说明项目还没有清晰的分层——这是第一步要解决的问题不是工具能解决的。第二步定层名。按 C/M/P 映射给每个包定一个层名domainC 层、applicationM 层、entrypoints/infraP 层。层名不用多三到五层就够。第三步写 layers 契约。把层名按“高层在上、低层在下”的顺序写进配置的 layers 列表。先只写 layers不加 forbidden——layers 是零维护的forbidden 需要逐条枚举先让方向检查跑起来。第四步挂 CI。把lint-imports挂进 pre-commit 和 CI 流水线非 0 就阻断合并。非 0 先分型是契约被破还是配置/版本/包名写错。第一次跑可能会红一片这是正常的——它不是在挑刺是在告诉你“依赖方向已经焊反了多少处”。红了之后两条路新项目全部修掉再合并——新项目没有历史包袱趁早焊死方向最省事存量项目先加exclude把已知违规暂时跳过、逐步还债——但exclude字段本身不支持写备注或设过期时间“待还债”标签和截止期需要靠 PR 注释、issue、文档人工维护。工程上可以再补一道轻量门禁在注释里写 ISSUE 编号与截止期如# TODO(tech-debt, expires2026-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的对象被注入进shareddomain只 importshared的接口代码里没有任何 importinfra语句静态图看不见。layers 管静态 import 链不管运行时注入。重检触发如果项目重构了包结构、新增了层、或者shared内核膨胀到承载了不该承载的东西配置文件必须重检——工具不会自己知道“层变了”人得告诉它。配置文件必须进 git和代码一起版本管理——它本身是架构约束的“宪法”改了必须留痕不能只在某个人的本地环境里跑。九、常见踩坑 QAQ1我的项目没有domain/application这套包名还能用 layers 吗可以。layers 只认你的实际包名C/M/P 是思想不是固定文件夹名。把 layers 列表里的包名换成你项目实际的包名就行。Q2为什么第一次执行lint-imports大片红色是历史存量依赖倒置不是工具 bug。新项目直接修掉再合并老项目加exclude逐步还债但exclude不支持元数据截止期靠 PR 注释 / issue / 独立过期检查人工维护。Q3layers 和 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 约束——上了工具不等于架构不会腐化只等于“方向焊反了会被拦住”。