组件怎么划:共同变化、无环依赖与稳定方向
项目按功能分了包:
order、billing、common。半年后想把账单拆成独立的模块,却发现拆不动:订单调用账单开票,账单又引用了订单的类;common里有个「金额格式化」,顺手引用了订单。三个包实际上是一个整体。
类的设计原则管的是一个类内部和几个类之间的关系;当代码多到要分成包、模块、jar 时,问题变成了:哪些类应该放在一起,组件之间的依赖应该朝哪个方向。这组问题有一套成熟的原则:放在一起的,应该是一起变化、一起复用的东西;组件之间的依赖不能有环,并且应该从容易变化的组件指向不容易变化的组件。
本文用一个三个包的小代码库,在 JDK 25 上用 jdeps 和 ArchUnit 1.5.1 检查三个版本的依赖,看环是怎样形成又怎样被打破的。实验见文末。
一、什么放在一起:按变化的原因
决定一个类放在哪个组件,有三条互相拉扯的原则:
- 共同闭包:因为同一个原因、在同一时间变化的类放在一起。改一个需求只动一个组件。
- 共同复用:被一起使用的类放在一起;不会被一起使用的,不要放在一起。否则用户被迫依赖他们用不到的东西,那些东西一变,他们也要跟着重新发布。
- 复用与发布等价:作为一个整体复用的单元,就是一个整体发布、带版本号的单元。
前两条方向相反:共同闭包倾向于把组件做大(一个需求的所有改动都在里面),共同复用倾向于把组件做小(只放真正一起用的东西)。项目早期变化多,偏向共同闭包更省事;组件开始被别的团队复用之后,再按共同复用拆分。
按技术层分包(controller、service、dao)违反的是共同闭包:一个需求要同时改三个包。按业务能力分包(order、billing)更接近「一起变化」。但这只是第一步,下面的问题是这些包之间怎样依赖。
二、依赖不能有环
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)。两个包互相依赖,实际上就成了一个组件:改了其中一个,另一个要重新编译、重新测试;想单独发布、单独拆成服务,都做不到。环越大,牵连的范围越大,最后整个系统只能一起构建、一起发布。
打破环的标准做法是依赖倒置:让需要能力的一方定义接口,提供能力的一方实现它。
package shop.order;
/** 订单完成时需要的能力,由订单这一侧按自己的需要定义 */
public interface InvoiceIssuer { String issue(Order o); }
package shop.billing;
public class InvoiceService implements InvoiceIssuer { ... }v2 包依赖 {billing=[order]};不稳定度 billing I=1.00 order I=0.00
slices().matching("shop.(*)..").should().beFreeOfCycles():通过订单不再知道账单的存在,它只依赖自己定义的 InvoiceIssuer。运行时由组装代码把 InvoiceService 注入进去。依赖变成了单向的 billing → order。
端口的名字和方法要按需要方的业务能力来定:订单需要的是「开具发票」,所以接口叫 InvoiceIssuer、方法只有 issue,而不是把账单服务的全部方法搬过来做成一个 BillingService 接口。按提供方的实现来定义的接口,只是把依赖藏了起来,账单一改,接口还是要跟着改。
三、common 包:最容易被忽略的环
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 commonv3 在 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 冻结现有的违反,只阻止新增的环,再逐个打破。
六、检查清单
- 包按业务能力和变化原因划分,而不是按技术层。
- 包之间没有环;需要反向调用时,由需要方定义按自己业务能力命名的端口。
- 通用包不依赖任何业务包。
- 依赖指向更稳定的组件;被大量依赖的组件主要由抽象组成。
- 依赖规则写成构建中的测试,老代码先冻结已有违反。
配套实验
- codesphere-labs/design/component-dependencies:JDK 25.0.4.1、ArchUnit 1.5.1:互相依赖、端口反转与引用业务包的 common 包,jdeps 的包依赖与不稳定度、ArchUnit 的无环规则(验证记录)
参考资料
- Robert C. Martin,《Clean Architecture》第 13、14 章(组件内聚与组件耦合)
- ArchUnit 用户指南
- jdeps 文档(JDK 25)