模块设计:抽象看扇入,约束看扇出

解耦、接口化,说到底是在设计层面做分治:把一张任意连接的网,压成一条条单向的链。这样只要每个单元是对的,组合起来就没问题;出问题也只需看相邻两个节点,不用全图搜索。

但”该在哪里抽象、该在哪里约束”这两个问题,光靠”高内聚低耦合”回答不了。我现在更倾向于用两个方向性指标来定:抽象看扇入,约束看扇出。

  • 扇入:有多少地方指向它、依赖它。扇入越大,改动的影响面越大,越值得抽象,也越必须稳定。
  • 扇出:它自己指向多少外部东西。扇出越大,越容易被外部牵着走,越需要在出口处收口。

一个反复出现的样板:用数据控制流程

代码评审时最常见的毛病,是拿业务数据直接做流程控制:

if (order.getAmount().compareTo(new BigDecimal("10000")) > 0) {
approvalFlow.start(order); // A 路线
} else {
autoApprove(order); // B 路线
}

这段代码能跑,很多人也不觉得有问题。但那个 10000 是个隐患点:它把实际业务数据直接焊进了控制流,等于把运行时的可变性堵死了。阈值一旦调整——往往还是合规或风控要求的调整——就得改代码、走测试、发版本。

拆开看,这一行里其实塞了三件不同性质的东西:

  1. 一条判定规则:”超过阈值需要审批”;
  2. 规则里的参数:10000;
  3. 判定结果之后的两条路线:审批流、自动通过。

三样东西耦合在一个 if 里,于是它们只能共享同一个变更周期。而现实是,规则的逻辑可能几年不变,规则的参数可能一个季度调一次,路线则会随着业务不断新增。变更频率差一个数量级的东西被绑在一起,每次调整都得按最慢的那个走。

从扇入看,这个 if 的扇入通常还特别高:订单创建、退款、调额,凡是能做金额操作的地方,都要复制一遍同样的判断。复制三份之后,阈值就有三个副本,改的时候漏一个,就是一笔该审批的单子自动通过了。

扇入决定抽不抽

扇入高,说明很多人依赖同一个决定。这时候把决定收敛成一个点,收益是线性的:一次修改,所有调用方一起生效;判断错了,也只有一个地方要改。

扇入低,只有一个调用点,抽象就没有收益,只是把一次调用变成三次跳转。所以”未来可能会有第二个实现”这种理由,通常不成立——等第二个实现真的来了再抽,比提前维护两套并行结构便宜。

有个粗略的算式可以帮判断:抽象收益 ≈ 扇入 × 变化频率。扇入是三,一年变两次,值得抽;扇入是一,一天变十次,也值得抽——因为它会反复咬你;扇入是一,几年不变,写成方法就够了。

回到那个 if。它扇入高(多个入口复制),参数变化频率也高(阈值会调),所以三样东西都该被拆出来。

规则与规则数据,是两种东西

第一件事:判定不能再裸写 if,要抽象成规则。第二件事,也是最容易漏的:规则里的参数不能留在代码里,要变成可外部调整的数据接口。

这两件事的性质完全不同:

// 规则:只表达"按什么判定",不持有具体数值
public interface ApprovalRule {
RuleResult evaluate(ApprovalContext context);
}

// 规则参数:外部可配、可版本化、可灰度、可回溯
public interface RuleConfig {
RuleConfigSnapshot current();
}

RuleConfigSnapshot 里装着阈值、生效时间、版本号、适用业务线。它从配置中心或数据库来,改动不需要发版,但必须能回答”这笔单子当时用的是哪一版规则”。

这条数据接口的约束要写死几件事:口径(是含税金额还是不含税)、单位与精度、比较边界是大于还是大于等于、变更是否立刻生效还是次日生效、生效期间正在飞行的单子按哪一版算。这些问题在硬编码 10000 的时候都不存在——因为根本没有”变更”这回事,也就没人去想它。

顺带说,扇出在这里开始起作用。ApprovalRule 如果自己去拉配置、查数据库、读当前用户,它的扇出就变大了,规则实现会被外部状态绑住,测试也难写。正确做法是把所需数据放进 ApprovalContext,由调用方一次性准备好,规则只做纯判定,扇出控制在一两个值对象以内。

路线是策略,不是 if 的另一个分支

else 后面那两条路线,本质是”根据判定结果选择不同处理方式”,这就是策略模式最标准的场景:一个接口,两个实现,一个选择器。

public interface ApprovalHandler {
void handle(Order order);
}

ManualApprovalHandler 和 AutoApprovalHandler 各实现一份。将来加”超额分级审批”或”白名单免审”,只是多一个实现加一条映射,不改调用方。

这里仍然可以用扇入/扇出看:策略实现的扇入是 1(只被选择器调用),所以它不需要过度抽象;但它的扇出必须小,不能反过来去查规则、读全局上下文、调别的 handler——一旦策略之间开始互相调用,所谓”策略”就退化成了一堆带 if 的服务。

返回值要泛化,约束才立得住

上面这段最常见的写法是返回 boolean:if (rule.evaluate(ctx)),然后调用方自己分叉。这是把判定结果的含义留在了调用方脑子里,等于规则抽了一半。

返回值应该泛化成一个决策对象:它描述”该走哪条路、为什么、依据哪一版规则”,而不是”真或假”。

public final class RouteDecision {
private final String ruleId;
private final String ruleVersion;
private final String route;
private final String reason;
}

这样做的收益有三层:

  • 调用方不感知路线数量。 返回值是 boolean 时,路线只有两条,扩展必然改调用方;是枚举时,调用方要 switch,新增取值虽然编译期能报警,但仍要改调用方;是决策对象时,调用方只负责拿着 route 去执行,新增路线它一无所知。
  • 决策可回溯。 ruleId + ruleVersion + reason 一起来的,事后能回答”这笔单子为什么走了自动通过”,也能在规则调整后解释历史单子为什么和现在的口径不一样。
  • 约束被写进类型。 决策对象不可变,route 只能是选择器认识的取值,调用方没有机会自己拼一个分支,也没有机会在中间插一步自己的判断。

“返回值体现约束”这句话,落到代码上就是:返回值携带的信息量,决定了调用方能做多少自主解释。返回的信息越少,调用方就越得靠猜测和约定来补,补出来的东西一定散落在各处。

那个 if 最后长什么样

三样东西各归各位之后,原来那段判断只剩两句:

ApprovalPolicy policy = policySource.at(order.getBusinessLine(), order.getSubmittedAt());
RouteDecision decision = rule.decide(ApprovalContext.of(order), policy);
handlers.get(decision.route()).handle(order);

阈值不在代码里,在 policy 里;两条路线不在 if/else 里,在 handlers 这张策略表里;判定结果也不是 boolean,而是一个带 ruleId、ruleVersion、reason 的 decision。

调用方那边更短:原来要把同一段 if 抄进订单、退款、调额的入口,现在各调用一次 router.route(order) 就行,规则怎么变、路线怎么加,都与它无关。

这就是我要的那个”审美”:一眼看过去,代码里没有业务数字,也没有硬编码的分叉。

还有一层不太显眼的收益:没有硬分叉,热路径上就少了基于业务数据的条件跳转。这类分支恰恰是 CPU 分支预测器最难猜的——金额分布没有规律,猜错一次就要冲刷整条流水线。判断被收进规则里一次性算完,执行侧只剩按结果取策略的一次查表,路径更直更稳定,CPU 更容易全速直链跑下去。

扇出决定约束放在哪

上面这个例子里,约束集中在三个出口:规则接口(只能拿到 ApprovalContext)、规则数据接口(只有快照,没有写入)、决策对象(只读且自带依据)。这些出口的共同点是:把模块能碰到的东西列清楚,多一样都不给。

扇出大的模块有个典型症状:构造函数里塞了一堆依赖,注入了一个 ApplicationContext 或者 SqlSession,需要什么自己去取。这种写法看起来灵活,实际上模块的扇出是”无界”的,它可能依赖任何东西,于是任何改动都可能影响它,任何测试都得把整个世界搭起来。

收口的办法不复杂:

  • 依赖在构造函数里显式列出,不传容器、不传全局上下文;
  • 参数收敛成值对象,一次传一个自校验的 Money,而不是金额、币种、精度三个参数各传一遍;
  • 需要外部数据就在入口准备好,往里传快照,不往里传”取数能力”;
  • 实现类用包级私有或 internal 可见性藏起来,让越界行为编译不过。

依赖方向也是约束的一部分。策略实现、规则实现都不该反向依赖调用方;确实需要通知,就由我方定义回调接口、对方注册进来,编译期依赖仍然是单向的。真正麻烦的不是环,是隐藏的环——用反射、全局注册表、静态字段绕过去,依赖图上干干净净,出事时却无从下手。

数据接口与行为接口

模块边界上的接口有两个维度:数据接口管”传什么”,行为接口管”能要求它做什么”。前者出错通常在运行时(序列化、空值、单位),后者出错通常在编译期(方法签名变了)。这个差别决定了纪律不同。

数据接口容易踩的坑比较集中:用 String 表示账号、用 BigDecimal 表示金额,语义只活在字段名里;拿持久化实体当接口数据,字段一改全员受影响,调用方还会开始依赖本不该依赖的字段;缺失、空集合、零值三态被同一个 if 处理掉;单位、精度、时区没写清,小数据量单时区下永远正确,跨国对账时必然出错;接口里的枚举新增取值后,老调用方要么忽略要么报错,这个策略得提前定;Map<String, Object> 和万能 DTO 把契约从编译期挪进了文档,而文档不会跟着代码更新。

数据接口之所以最容易失控,是因为它没有”调用”这个约束点。行为接口一改,调用方编译不过;数据接口一改,只要序列化还能跑通,错误就静默传到下游,直到有人把 null 当成零、把分当成元。

行为接口则要注意粒度和返回值语义。粒度应该等于一个完整用例:applyForRefund 可以,checkStatus + updateStatus + notify 不行,后者把流程控制权交出去了,模块从此保证不了任何不变式。

返回值至少得能区分三类结果:成功;业务拒绝(余额不足、风控拦截,重试没用);系统失败(依赖挂了,看幂等性决定重试)。实际系统里还有第四类,也最危险:结果未知——调用超时、连接断了,你不知道对方执行了没有。把它归进”失败”然后重试,就是重复扣款的标准剧本。根源不在重试机制,在契约里压根没定义这个状态。

怎么验证设计站得住

单测的边界应该等于接口边界。一个模块的测试里如果出现 mock(SomeInternalHelper.class),说明内部还有一条没被抽象出来的变化线。要 mock 的对象数量,直接反映扇出有多大。

判定规则这类东西,测试应该只喂 ApprovalContext 和快照,不碰数据库、不碰时钟。能这样测,说明扇出被收住了;不能,说明规则还在偷偷往外伸手。

还有个反向指标:改动在哪个阶段爆炸。编译期报错最便宜,启动时校验失败次之,运行时才出错最贵。前面那个 10000,改的时候不发版就不生效,这就是把变更成本推到了最贵的一档——只不过它以”稳定”的面目出现,容易被当成优点。

最后

模块设计落到操作上,其实就是两个方向的判断:扇入高的地方抽象,把它收成一个点;扇出大的地方约束,把它的出口列清楚。

那个金额阈值的例子值得反复看,因为它同时踩了三个坑:规则裸写在 if 里、规则参数焊死在代码里、路线用条件分支硬编码。修的时候也是三件事:规则成接口、数据成可配的快照、路线成策略。最后再用一个泛化的返回值把决策的含义固定下来,让调用方没有自行解释的余地。

做到这一步,模块才算真的能当黑盒用。否则所谓分治,只是把一张大网切成若干张小网,出事的时候还是得在每张小网里捞针。