Skip to content

组件怎么划:共同变化、无环依赖与稳定方向 ​

项目按功能分了包:order、billing、common。半年后想把账单拆成独立的模块,却发现拆不动:订单调用账单开票,账单又引用了订单的类;common 里有个「金额格式化」,顺手引用了订单。三个包实际上是一个整体。

类的设计原则管的是一个类内部和几个类之间的关系;当代码多到要分成包、模块、jar 时,问题变成了:哪些类应该放在一起,组件之间的依赖应该朝哪个方向。这组问题有一套成熟的原则:放在一起的,应该是一起变化、一起复用的东西;组件之间的依赖不能有环,并且应该从容易变化的组件指向不容易变化的组件。

本文用一个三个包的小代码库,在 JDK 25 上用 jdeps 和 ArchUnit 1.5.1 检查三个版本的依赖,看环是怎样形成又怎样被打破的。实验见文末。

v1 互相依赖v2 订单定义端口v3 加入 commonorderI = 0.50billingI = 0.50orderI = 0.00billingI = 1.00implements InvoiceIssuerorderI = 0.33billingI = 1.00commonI = 0.50ArchUnit:billing → order → billingArchUnit:通过ArchUnit:common → order → commonI = Ce / (Ca + Ce):依赖应从 I 大的组件指向 I 小的组件;通用包不依赖任何业务包
图 1 · 实测(JDK 25.0.4.1、jdeps、ArchUnit 1.5.1):v1 订单与账单互相依赖,ArchUnit 报环,两个包的不稳定度都是 0.50。v2 由订单定义 InvoiceIssuer 端口、账单实现它,依赖变成单向,订单 I = 0(只被依赖)、账单 I = 1。v3 加了一个顺手引用订单的 common 包,common 与 order 之间又形成了环。

一、什么放在一起:按变化的原因 ​

决定一个类放在哪个组件,有三条互相拉扯的原则:

  • 共同闭包:因为同一个原因、在同一时间变化的类放在一起。改一个需求只动一个组件。
  • 共同复用:被一起使用的类放在一起;不会被一起使用的,不要放在一起。否则用户被迫依赖他们用不到的东西,那些东西一变,他们也要跟着重新发布。
  • 复用与发布等价:作为一个整体复用的单元,就是一个整体发布、带版本号的单元。

前两条方向相反:共同闭包倾向于把组件做大(一个需求的所有改动都在里面),共同复用倾向于把组件做小(只放真正一起用的东西)。项目早期变化多,偏向共同闭包更省事;组件开始被别的团队复用之后,再按共同复用拆分。

按技术层分包(controller、service、dao)违反的是共同闭包:一个需求要同时改三个包。按业务能力分包(order、billing)更接近「一起变化」。但这只是第一步,下面的问题是这些包之间怎样依赖。

二、依赖不能有环 ​

text
v1 包依赖 {billing=[order], order=[billing]};不稳定度 billing I=0.50 order I=0.50
slices().matching("shop.(*)..").should().beFreeOfCycles():违反:Cycle detected: Slice billing -> Slice order -> Slice billing

订单完成时调用账单开票(order → billing),账单开票要读订单的信息(billing → order)。两个包互相依赖,实际上就成了一个组件:改了其中一个,另一个要重新编译、重新测试;想单独发布、单独拆成服务,都做不到。环越大,牵连的范围越大,最后整个系统只能一起构建、一起发布。

打破环的标准做法是依赖倒置:让需要能力的一方定义接口,提供能力的一方实现它。

java
package shop.order;
/** 订单完成时需要的能力,由订单这一侧按自己的需要定义 */
public interface InvoiceIssuer { String issue(Order o); }

package shop.billing;
public class InvoiceService implements InvoiceIssuer { ... }
text
v2 包依赖 {billing=[order]};不稳定度 billing I=1.00 order I=0.00
slices().matching("shop.(*)..").should().beFreeOfCycles():通过

订单不再知道账单的存在,它只依赖自己定义的 InvoiceIssuer。运行时由组装代码把 InvoiceService 注入进去。依赖变成了单向的 billing → order。

端口的名字和方法要按需要方的业务能力来定:订单需要的是「开具发票」,所以接口叫 InvoiceIssuer、方法只有 issue,而不是把账单服务的全部方法搬过来做成一个 BillingService 接口。按提供方的实现来定义的接口,只是把依赖藏了起来,账单一改,接口还是要跟着改。

三、common 包:最容易被忽略的环 ​

text
v3 包依赖 {billing=[order], common=[order], order=[common]};不稳定度 billing I=1.00 common I=0.50 order I=0.33
slices().matching("shop.(*)..").should().beFreeOfCycles():违反:Cycle detected: Slice common -> Slice order -> Slice common

v3 在 v2 的基础上加了一个 common 包,放金额格式化这类工具方法。有人为了方便,在里面加了一个 Money.of(Order o),于是 common → order;订单又用 Money 格式化金额,order → common。环又回来了。

common、util、shared 这类包的问题在于它们没有明确的边界:什么都可以往里放,于是迟早会有东西引用业务包。规则很简单:通用包不能依赖任何业务包。它里面只放与业务无关、几乎不变的东西(金额的格式化可以,「订单金额的格式化」不行)。和业务相关的辅助方法,放回它所属的业务包里。把这条规则写成 ArchUnit 测试(noClasses().that().resideInAPackage("..common..").should().dependOnClassesThat().resideInAPackage("..order..")),比靠评审记住可靠得多。分层之间的依赖规则怎样写成测试,见分层、应用服务与仓储。

四、依赖朝向稳定的方向 ​

不稳定度 I = Ce / (Ca + Ce),Ce 是这个组件依赖了多少别的组件(向外),Ca 是有多少组件依赖它(向内)。I = 0 表示只被依赖、不依赖别人,最稳定,改它的代价最大;I = 1 表示只依赖别人、没人依赖它,最容易改。

稳定依赖原则说,依赖应该指向更稳定的组件:I 大的依赖 I 小的。v2 里账单(I = 1)依赖订单(I = 0),方向正确:订单是核心业务,被依赖,不应该因为账单的变化而变化。如果反过来,订单依赖账单,账单的每次变化都会波及订单。

稳定抽象原则补充说,越稳定的组件应该越抽象。被大量依赖的组件如果全是具体实现,它一变,依赖它的组件全要改;如果它主要由接口和值对象组成,变化就少得多。v2 里订单包包含了 InvoiceIssuer 这个接口,正是这个原则的体现:稳定的一方持有抽象,不稳定的一方提供实现。

这两个指标不是目标,而是检查依赖方向的工具。一个领域核心包 I 很高(依赖了很多外部的东西),就值得看一看它是不是依赖了不该依赖的基础设施。

五、把规则写成可执行的检查 ​

规则检查方式
包之间无环ArchUnit slices().should().beFreeOfCycles()
通用包不依赖业务包ArchUnit noClasses().that().resideInAPackage(..)
领域包不依赖框架ArchUnit noClasses()…dependOnClassesThat().resideInAPackage("org.springframework..")
查看实际依赖jdeps -verbose:package,或 IDE 的依赖矩阵
模块边界JPMS 的 exports,或者拆成独立的构建模块,让编译器拒绝不允许的依赖

把这些检查放进构建,比画在设计文档里的依赖图有效得多:图会过时,测试不会。已经存在环的老代码,可以先用 ArchUnit 的 FreezingArchRule 冻结现有的违反,只阻止新增的环,再逐个打破。

六、检查清单 ​

  • 包按业务能力和变化原因划分,而不是按技术层。
  • 包之间没有环;需要反向调用时,由需要方定义按自己业务能力命名的端口。
  • 通用包不依赖任何业务包。
  • 依赖指向更稳定的组件;被大量依赖的组件主要由抽象组成。
  • 依赖规则写成构建中的测试,老代码先冻结已有违反。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权