☰
架构的本质是依赖治理:从树形思维到图式现实的工程实践
2026/10/3 3:04:16 网站建设 项目流程

我见过太多架构评审会上的PPT:一张依赖图画得明明白白,主干是树,叶子是业务模块,箭头全部规规矩矩朝下。然后评审人追问一句"这两个模块之间为什么有一条灰色实线",全场沉默三秒钟。这个沉默的瞬间,基本就是架构从"树形思维"滑向"图式现实"的节点。

这些年我做过不少模块拆分、服务治理、依赖重构的活,最大的体会是:人脑天然用树来理解架构,但代码里的依赖关系几乎不会老老实实长成一棵树。架构的本质,不是画一张漂亮的分层图,而是在图式现实中持续做划界、定向与剪枝。这个词现在被用得很宽——微服务架构、DDD架构、六边形架构、Agent架构、智驾架构,大家都在谈,但落到本质上,答案只有一个:系统里谁依赖谁,依赖朝哪个方向走,这个方向能不能稳定地保持住。依赖治理也不是消灭依赖,而是把依赖约束成"影响半径可预测"的样子。这篇文章就聊聊我眼中的架构本质,以及依赖治理中真正能落地的做法。

1. 先谈本质:架构的核心是划界和定向,不是画层级

1.1 树形思维是人类认知的默认路径

人天生喜欢树。目录是树,组织架构图是树,代码里的包也是树。树的优势极其明显:任意节点只有一个父节点,沿着父链往上走就能理解全局;你不需要缓存整棵树的形状,只要记住当前分支就能推导出上下文。这种认知负担的低开销,让几乎每个架构师在脑海中对系统的第一反应都是"分几层、每层放什么、上层调下层"。

经典的Clean Architecture、洋葱架构、三层架构,本质都是这个模型的变体:外层依赖内层,依赖方向永远朝圆心收敛。这种模型对中小型系统非常有效——我早期做的几个项目,模块数量十个以内,依赖图确实能保持成树的样子,偶尔有一两条反向依赖,手工一拧就回来了。问题出在规模上:当一个系统有三五百个模块、几十个团队在同时维护时,树形思维就开始失真。

还有一个现象值得注意:架构这个词近几年泛化到了几乎所有技术领域。微服务架构、Agent架构、智驾方案里的规则架构,大家都在画架构图。图好看没用,真正决定架构命运的是那条依赖边。我把依赖治理单独拎出来讲,就是因为它比架构样式更底层——DDD不解决你模块间反向依赖的问题,六边形架构也不解决,它们只是在提醒你"边界应该存在",而依赖治理才是真的把边界焊死的工艺。

1.2 真实依赖图为什么总是"弯"的

先看看代码里真实发生的事。你有一个订单模块和一个库存模块,画图的时候是 order -> inventory 一条单向实线。后来库存服务希望订单失败时通知它回滚预留库存,于是有人加了一条 inventory -> order 的消息通道。再后来一份共享的订单状态枚举被放在 common 包里,两个模块同时引用,common 又被 app 层引用,app 层还引用了 domain 里的某个工具类。不知不觉,图就弯了。

真实依赖图总是弯的,原因有两个。第一,依赖不是静态的,它是几百个工程师在几百个迭代周期里逐个写下的 import、autowired、HTTP调用、消息订阅累计出来的结果。任何一个单点的决策在当时都是合理的,但单独合理不意味着全局成树。第二,系统的生命周期有自己的逻辑:为了快速上线,先绕一段;为了性能,直接把私有实现暴露出去;为了复用,把工具类堆进 common 模块让所有人引用——这些都是把树扭成图的推手。

我做过一次统计,一个约两百个Maven模块的系统里,模块级依赖图中有环的数量是十三个。类级依赖就不用提了,基本是一张密集的网。树的判据是"无环且任意节点只有一个父级",这个判据在规模上去之后几乎不可能靠人工维护。

1.3 树的用处依然存在:它是治理后的目标态

这不是说树形思维没用了。恰恰相反,树是所有工程结构里最可解释的形态,依赖治理的本质就是把一张复杂的图"修剪"成树形的DAG——有向无环图。DAG允许一个节点有多个子级和多个父级,但依赖方向一致、永不回头,这已经比严格意义的树宽松很多;在DAG之上再挑出主干依赖路径,让它保持树形骨架,才谈得上架构可控。

所以要分清两件事:表示现状用图,规划目标用树。你脑中的架构图应该是一棵树,但你面前的依赖可视化工具显示的应该是图。如果你用树去看图,会本能地觉得"这些弯弯绕绕都是异常";如果你承认图才是现实,你才会踏踏实实做治理:找出环、标记方向违规、设计裁剪路径。我见过不少团队花了很大力气把依赖图"画成"树——把反向依赖涂层颜色或者干脆隐藏掉——那是最没有意义的事情,因为现实不会因为你隐藏就变好。

2. 依赖腐化不是"技术债",是图式现实的必然

2.1 依赖从清晰走向腐化的三条典型路径

很多人把依赖腐化归咎为技术债,但我更愿意把它看成系统演化的必然产物。技术债强调的是"还",而依赖腐化是持续发生的熵增,你不治理它就积累。具体走腐化路线,最常见的就这三条。

第一条叫增量式权宜。团队定好了模块边界:A只能通过B的公开API调用B。某次赶版本,发现B的API少了一个参数,如果走接口变更流程至少要排两个迭代;于是有人直接引用了B的内部实现类,几分钟搞定。这个决策从流程上是违规的,但从交付压力上是合理的。问题不在这一次,而在于这一条实线留下后,后续所有在新实线上开发的代码都把违规当成常态,边界就失守了。

第二条叫工具类黑洞。大家都觉得小工具到处都该用,于是 utils、common、share 模块不断膨胀;时间一长,common 被所有模块引用,它成了扇入度最高的模块。但它里面的内容五花八门,既有真正的底层基础库,也有业务相关的常量、工具、缓存。按稳定依赖原则,一个被所有人依赖的模块必须非常稳定,但common里的业务工具总是变,每次变都要牵连几十个模块重新回归,这就是典型的依赖方向与稳定性不匹配。

第三条叫事件回环。微服务场景里,两个服务为了数据一致性互相订阅对方的消息,A发布"订单已创建",B消费后更新库存再发布"库存已变更",A又消费它做后续流程。从消息层面看,A和B形成了事件依赖环。这种环在链路日志里看起来很正常,但出故障时就会出现"你等我回滚、我等你提交"的僵局。

2.2 判断依赖坏不坏的四个信号

给团队做依赖治理培训时,我不太喜欢空讲原则,我一般直接给出四个可自查的信号。

第一个信号是越基础的模块越频繁被改动。如果 domain 模块平均每周都有两个story touch,说明有人在把业务规则塞进基础层,依赖方向正在崩溃。第二个信号是改一个模块的私有实现,其他模块的测试要重跑。这说明依赖粒度过粗——你只是内部重构,别人倒是被迫回归,典型的"接口边界没划清"。第三个信号是每次升级底层库,波及范围都超出预估。旧版升新版,你预期只改基础设施模块,结果业务模块里一堆直接调用底层API的代码冒出来。第四个信号是测试里需要 mock 的类越来越多。当一个模块的单测要准备五个MockBean时,说明被测模块对具体实现的依赖远多于对抽象接口的依赖。

这四个信号对应两个原则:依赖应该指向稳定方向,结构应该允许独立部署和独立测试。判断方法也很朴素——如果我想把这个模块换掉或者下线,需要动到多少其他模块?这个数字越小,依赖治理越好。反过来,如果下线任何模块都要"牵一发动全身",那不叫组件化,那叫一团耦合的代码。

2.3 别急着删依赖:方向、版本、传递性三个维度

做依赖治理最容易翻车的动作是直接把依赖删掉。依赖本身不是敌人,乱掉的依赖才是。所以我在做治理前会先把依赖拆成三个维度分别看。

方向维度:这条依赖是从外层指向内层,还是从内层反向指向外层?内层依赖外层几乎永远是坏味道。版本维度:两个模块依赖同一个库的不同大版本,这比只有一个版本但被滥用更危险,因为可能产生同类型不兼容的"双头"问题。传递维度:A依赖B,B依赖C,A虽然没直接依赖C,但C的行为会通过B传到A——传递依赖在运行时根本绕不开,你需要知道A对C的间接依赖路径一共有几条、有多长。

很多团队一上来就盯着"依赖数量",想尽办法减少依赖项。我的经验是依赖数量略微超标不是致命问题,依赖方向错误才是。十个依赖全部单向朝内,系统依然清晰;三个依赖里有一个反向,足够在下一次升级时点燃全场。所以治理的第一刀应该砍向方向违规,第二刀砍向环,第三刀才是消减冗余依赖。

3. 把依赖治理做成可执行的工程实践

3.1 第一步永远是把依赖画出来

治理跟看病一样,不见图不下药。不同技术栈有不同工具,我用过的组合给你们参考。

Java生态最省事的是直接用JDK自带的jdeps分析jar依赖,结合 ArchUnit 在测试里跑架构断言。前端和Node/TS项目用 Madge 生成依赖图,用 dependency-cruiser 做规则校验。Go 项目用 go mod graph 看模块依赖,配合 go-callvis 看调用关系。如果做跨服务的运行时依赖分析,一般靠链路追踪系统把调用拓扑按月聚合出来,常见方案是打开 trace 服务,把 span 之间的父子关系导出来。

可视化不是目的,目的是让你看到约束,但人眼看图很难盯住持续变化的规则,所以依赖图必须配套自动化校验。我个人强烈建议把依赖图生成做成流水线的一部分:每次代码合并前自动生成变更模块的依赖快照,带着 diff 展示给评审人,比任何口头提醒都有效。

3.2 用架构适应度函数把规则写进CI

Neal Ford 在 Building Evolutionary Architectures 里提出的"适应度函数"概念,我认为是依赖治理能落地的最大功臣。字面理解就是:你不能只有架构图,你还要有一组自动执行、结果或红或绿的测试函数,用来度量当前系统架构与目标架构之间的差距。

ArchUnit 是个很好的例子,下面这段代码直接可以被 JUnit 跑起来:

@AnalyzeClasses(packages = "com.example") public class ArchitectureTest { @Test void domainShouldNotDependOnInfrastructure() { noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat() .resideInAPackage("..infrastructure..") .check(new ClassFileImporter().importPackages("com.example")); } @Test void webShouldOnlyDependOnApplicationLayer() { classes() .that().resideInAPackage("..web..") .should().onlyDependOnClassesThat() .resideInAnyPackage("..web..", "..application..") .check(new ClassFileImporter().importPackages("com.example")); } }

前端项目里的 dependency-cruiser 也能干类似的事,它在 package.json 旁边放一个 .dependency-cruiser.js 配置:

module.exports = { forbidden: [ { name: 'no-circular', severity: 'error', from: {}, to: { circular: true } }, { name: 'domain-must-not-import-infra', severity: 'error', from: { path: '^src/domain' }, to: { path: '^src/infrastructure' } } ] };

这两类测试放进CI以后,效果立竿见影:以前要靠架构师"提醒"才能维持的边界,现在变成提交代码时的硬约束。有人说这太严格,我认为正是这种严格让依赖治理从"感觉"变成了"事实"。

3.3 用依赖矩阵看清边界的真实样貌

除了规则校验,我还建议每季度生成一张模块间依赖矩阵。以Java为例,扫一遍所有类的引用关系,按模块聚合后,大概长这样:

模块(依赖方) \ 被依赖方webappinfradomain
web-1250
app0-318
infra00-7
domain000-

这个矩阵的信息量很大。数字表示依赖方类里指向被依赖方类的引用数量。比如 app 行 infra 列是3,就说明 app 有3处引用直接扎进了基础设施,按依赖倒置原则,这3处应该改成依赖 domain 里的接口、由 infra 实现。而 domain 行全为0,表示领域层足够干净。完成一轮治理后,你对矩阵的目标就是尽量让上半部分稀疏、下半部分密集。

反馈回路也很重要:我习惯每个迭代末跑一次架构适应度测试,把失败数量和矩阵右下角的违规引用数记成趋势线。这条线不是给管理层看的,是给团队成员看的——你们每砍掉一个反向依赖,这个数就降一点,成就感直观。

4. 微服务时代的依赖治理:从代码依赖扩展到运行时依赖

4.1 服务依赖至少分三层:代码、运行时、数据

有了微服务之后,依赖治理的维度变多了,很多团队只盯着代码依赖,以为Artifact之间干净就万事大吉。实际上服务之间的依赖至少分三层。

代码层依赖最容易发现,工具那一堆都能查。运行层依赖就隐蔽多了:服务A运行时通过HTTP/RPC调用服务B,尽管A的代码根本没引入B的SDK,依赖依然存在,而且会直接表现为可用性耦合。数据层依赖最麻烦:A和B共享一张表,或者A通过CDC监听B的变更事件做数据同步,表结构一变,两边都要跟着改。我评估一个服务边界是不是靠得住,先看这三层里是不是都有清晰的单向约束。

运行时依赖建议直接使用服务网格或链路追踪系统的服务拓扑图,它能自动画出每个服务之间的实际调用关系和调用量。调用量比静态依赖关系更值得关注——B被A调一百万次和一百次是完全不同的治理选项。

4.2 传递依赖:A不直接调用C,但C能拖垮A

分布式环境下最容易被忽略的是运行时传递依赖的放大效应。从静态依赖图看,A只依赖B;但B在运行时依赖C。一旦C的响应时间飙升,B的线程池被打满,A发起的所有请求都会积压在B的队列里,A的调用方随之超时。整条链路往下追,A其实间接被C拖垮了。

这个现实对架构师提出了一个超出静态代码的要求:你要把每条关键链路的长度控制在可承受范围内,还要在链路的每一个节点设置超时、熔断和隔离。我一直觉得,微服务依赖治理不是把图画好,而是要让"SLO声明里的每个依赖都有韧性预案"——谁说A不依赖C,就先让A在C坏掉的时候还活得好好的给你看。

混沌工程里常见的故障注入(随机杀掉某个下游服务、人为拉长响应时间)就是专门用来验证传递依赖韧性的。定期做一次,你会发现自己以为"边界清晰"的服务链,实际上的脆弱点比想象的要多得多。

4.3 服务划分的依赖治理视角:DDD、六边形架构与组织通信

依赖治理到服务层面,绕不开一个老话题:服务边界怎么划才对。我见过不少人用DDD去划限界上下文,画出来很漂亮,边界也很清晰,但上线后两个服务之间的调用频率比模块内还高。这就说明划界的依据有问题——DDD聚焦的是业务语言一致性,但依赖治理问的是运行耦合频率。

我的实操经验是,两个服务如果每周有超过固定次数的双向调用,那多半是边界没划对,要么合并,要么把共享部分抽到更底层。别把六边形架构当成代码结构炫耀的资本,它真正解决的是外部依赖的可替换性。我甚至觉得,架构模式只是给我们提供了思考工具,依赖治理才是检验思考工具是否生效的标准。

还有一个绕不开的规律叫Conway定律:系统的结构最终会趋同于设计该系统的组织沟通结构。两个团队的沟通是紧密的,他们维护的模块之间就一定会长出密集依赖;反过来,模块间依赖混乱的,通常背后是组织接口混乱。依赖治理越做到后面,越像在治理组织协作方式。你要做的不是逼团队遵守一张架构图,而是让每个模块有明确的Owner,Owner有权力拒绝不合理的依赖请求。

5. 关于依赖治理,我踩过的一些坑和最近的想法

5.1 治理工具推不动,问题往往不在工具

我最早给团队上ArchUnit的时候,阻力特别大。大家觉得新增的测试规则是"形式主义"。后来我换了个打法:先统计连续三个季度因为模块间不兼容导致的上线事故,把它们和依赖矩阵里的违规引用一一对应起来,贴在周会看板上。规则的正当性立刻有了。工具的接受度从来不取决于工具本身,而取决于你是否把"为什么需要它"翻译成了大家对疼痛的共识。治理工具推不动的时候,先别怪工具,去翻一翻历史事故记录。

5.2 小步演进比一次性大重构成功率高得多

我踩过最大的坑是试图在一个大Release里完成全量依赖重构。结果显而易见:计划排了三个月,改到第八周的时候业务需求压过来,重构仓促收尾,依赖矩阵比原来还乱。后来我改成一次只治理一条链路:选一条涉及三个模块、影响最大的违规依赖,把它剪正,让对应的适应度函数从红变绿,合并、上线、回归。一个迭代治理一条,半年下来矩阵上层的脏引用就清得差不多了。这里的关键是:每一刀都要有可验证的结果,每一次合并都是独立的,可回滚的,团队对重构的恐惧才会下降。

5.3 依赖治理终局是组织和管理

说回到标题:树形思维,图式现实。这几年做了很多依赖治理的活,我越来越觉得"治理"的本质是把组织里随机的沟通模式转译成显式的技术边界。你可以在代码层面把依赖方向调正、把环剪开,但如果团队之间仍然说不清"谁对什么负责",边界迟早会被下一次急活击穿。

有的团队问我,依赖治理还要做多久?我的回答是,当你发现大家谈起"这个模块归属于谁、被谁依赖、允许依赖谁"时不再需要翻文档,而是像谈论自己工位一样自然,依赖治理才算真的结束了。它不是一个项目,不是一个里程碑,它是架构这门手艺里永远在执行的动作。

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

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

立即咨询