结账负责合计,运费负责免邮条件。原本结账调用运费包;后来运费为了复用“满 100 元”判断,反过来调用结账包。即使运行时没有无限递归,pricing → shipping → pricing 也使两个包的变更和编译方向缠在一起。下一次物流部门把免邮线调整到 80 元,哪一侧应该决定规则?

先分开运行时调用与包依赖

labs/14 的 Before 在结账端调用 Shipping.fee,运费端调用 Checkout.qualifiesForFreeShipping。它能算对 0、9_999、10_000 分的旧结果,但包层有环;不能仅从单次请求结果推导依赖图健康。最小修复让 Shipping 自己持有运费规则,让 Checkout 只依赖稳定的 ShippingRule,组装点传入具体运费实现。

1
2
3
ShippingRule shipping = new Shipping();
Checkout checkout = new Checkout(shipping);
int total = checkout.total(9_999);

实际包边由 jdeps -verbose:package labs/14/target/classes 报告:旧版 before.pricing → before.shipping 以及反向的 before.shipping → before.pricing;新版 after.pricing → after.core、after.shipping → after.core。这是包级静态依赖,不是 Maven 模块之间的环(本实验所有这些包仍在同一个 lab-14 模块内),也不是运行时吞吐量证据。组装点要知道两边的具体类型,不应误称整个应用完全没有对具体类的依赖。

新增 80 元免邮要求通过注入 subtotal -> subtotal >= 8_000 ? 0 : 500 验证 Checkout 不改;Before 保留 100 元作为旧合同。若项目只有一个结账入口和一个运费规则,Alternative 在单类方法里写条件也能满足旧合同;为这个微小例子单独拆包反而增加寻找代码的成本。

选择 谁依赖谁 改免邮规则要动的边界
Before 两个包相互指向 运费借用结账中的条件
After 结账、运费分别指向规则端口 替换运费实现并在入口组装
Alternative 单类,没有跨包边 修改本地条件

六条包原则分别约束什么

Robert C. Martin 的 Design Principles and Design Patterns 将包原则分成内聚和耦合两组。它们讨论发布与依赖单元;本例的 Java package 只用于观察静态边,并没有实现独立版本发布。

原则 含义 在订单工程中需要回答的问题
REP:发布与复用等价 复用单元要能作为版本化发布单元交付 外部复用金额类型时,是否必须同时升级报表实现?
CCP:共同封闭 由同类变化牵动的类宜放在一起 调整运费政策是否需要跨多个发布单元修改?
CRP:共同复用 不共同复用的类不宜被绑定到同一单元 只使用库存查询的调用方为何要接收通知实现变化?
ADP:无环依赖 发布单元的依赖图应避免环 报价与运费是否必须互相等待对方版本?
SDP:稳定依赖 依赖宜指向更难改动的单元 许多调用方依赖的规则接口是否反而依赖易变的渠道实现?
SAP:稳定抽象 难改动的单元宜通过抽象保留扩展能力 新增运费策略能否实现端口,而不改所有调用方?

CCP 倾向把一起变动的实现聚集起来;CRP 则避免外部使用者被不相关的变动牵连;REP 还要求版本与维护边界。若结账与 CSV 报表因同一次财务调整一起修改,但库存客户端从不使用 CSV,把它们放进一个大包可能降低内部协调成本,却扩大外部升级范围。必须区分“谁一起修改”和“谁一起使用”,不能仅按类名相近分组。

这里的稳定性关注改动的牵连程度,不等于近期很少提交。原文用输入、输出耦合讨论它;一个接口被许多模块依赖,修改签名会影响这些调用方,即使它只有几行代码。SAP 也不要求所有底层类都变成接口:金额值、固定算法等具体实现仍可以保留,只有已出现的变化边界需要扩展点。

本例能直接检查的是 ADP 对应的包环,以及运费端口带来的替换方向。REP、CCP、CRP 需要真实复用者、发布安排和变化记录;本次测试没有提供这些证据。jdeps 不会证明六条原则全部满足,也不能从依赖边数量直接得出设计好坏。

flowchart LR
    PricingBefore[before.pricing] --> ShippingBefore[before.shipping]
    ShippingBefore --> PricingBefore
    PricingAfter[after.pricing] --> Core[after.core ShippingRule]
    ShippingAfter[after.shipping] --> Core

验证与边界

执行 ./mvnw -B -ntp -pl labs/14 -am test、jdeps -verbose:package labs/14/target/classes 和累计 ./mvnw -B -ntp verify;原始命令、环境和退出码见 examples/design-patterns/evidence/14/RUN.md。测试含旧金额边界、替换免邮线的断言;没有模拟依赖管理器的模块发布,也没有运行并发请求。

练习:

  1. 新增“周末免邮 60 元”,先为原工作日规则和周末新增规则分别写边界断言,再替换组装点并运行完整回归;绘出新增的静态依赖边。
  2. 假设运费永远不会独立变化且只有一个调用者,移除端口写成 Alternative,说明删去抽象后仍满足哪些测试、失去哪个替换能力。

参考资料

上一节:13 重复与抽象哪个更贵;下一节:15 阅读模式先看变化。