可扩展流程怎样设计:策略、回调与责任链
「让流程可扩展」至少有三种不同的意思:同一个决策点换一种算法、固定流程里换掉某一步、在请求进入之前加一道检查。它们分别对应策略、回调和责任链,经常出现在同一个请求里,但各自出错的方式完全不同。
以活动报名请求为例,一次报名要经过这些步骤:
租户 → 鉴权 → 限流 → 幂等 → 校验 ← 责任链:任何一个节点可以拒绝
加载活动 → 计算容量 → 保存 → 返回 ← 固定流程:「计算容量」由调用方提供
计算容量:固定座位 / 超卖 10% / 线上不限 ← 策略:按活动类型选一个三个扩展点实测各出了一次错:两条策略都声明处理工作坊,容量是 20 还是 22 取决于注册顺序;回调换到线程池执行后,读到的租户是 null;限流排在鉴权前面,合法请求只通过了一半。本文用 JDK 21 的实测,逐个说明它们的边界。
一、三种扩展点各管一件事
| 策略 | 固定流程 + 回调 | 责任链 | |
|---|---|---|---|
| 变化的是什么 | 某一步用哪个算法 | 某一步具体怎么做 | 进入前要经过哪些检查 |
| 执行几个 | 一个(或一组选中的) | 流程里的每一步都执行 | 依次执行,任何一个可以短路 |
| 由谁决定 | 路由逻辑按键选择 | 流程的所有者调用回调 | 链的顺序 |
| 典型例子 | 运费、容量、渠道选择 | JdbcTemplate 加 RowMapper | Servlet Filter、Spring Security、Netty ChannelPipeline |
一两个稳定的步骤不需要建框架。只有变化真实存在(新的活动类型会出现、检查会增减)时,才值得引入对应的扩展点。
二、策略:选择逻辑要能在启动时验证
最常见的写法是注入所有实现,按 supports 过滤,取第一个:
CapacityRule rule = rules.stream().filter(r -> r.type() == type).findFirst().orElseThrow();它在只有一个实现声明某个类型时没有问题。实测注册了两条都声明 WORKSHOP 的规则(固定座位、超卖 10%),20 个座位的工作坊在一种注册顺序下容量是 20,顺序反过来是 22。Spring 注入 List 的顺序取决于 @Order 和 Bean 定义顺序,一次无关的重构就可能改变它。
更稳妥的是在启动时建立索引,重复与缺失都立即失败:
static Map<ActivityType, CapacityRule> strictIndex(List<CapacityRule> rules) {
Map<ActivityType, CapacityRule> index = new EnumMap<>(ActivityType.class);
for (CapacityRule r : rules) {
CapacityRule prev = index.putIfAbsent(r.type(), r);
if (prev != null) throw new IllegalStateException("重复的容量规则 " + r.type() + ":" + prev + " 与 " + r);
}
Set<ActivityType> missing = EnumSet.allOf(ActivityType.class);
missing.removeAll(index.keySet());
if (!missing.isEmpty()) throw new IllegalStateException("缺少容量规则 " + missing);
return index;
}实测它报出了重复的 WORKSHOP,也报出了缺少的 WEBINAR。确实需要多条规则同时匹配时(按优先级叠加),就把优先级写成数据并检查同优先级的冲突,渐进发布规则的做法见 可复用的服务端组件。
策略选出来之后,「在多个候选里选最优」这一步本身也常写错。例如对每条优惠规则试算优惠额,选优惠最大的一条:
long best = Long.MIN_VALUE; Rule chosen = null;
for (Rule r : rules) {
long discount = r.trial(order);
if (discount < best) { best = discount; chosen = r; } // 比较方向写反了
}初始值是「最小」,比较却是「更小才更新」,任何候选都比不过 Long.MIN_VALUE,chosen 永远是 null。把初始值和比较方向写成一对是这类代码最容易错的地方;更稳妥的写法是让标准库表达意图:rules.stream().max(Comparator.comparingLong(r -> r.trial(order))),再明确处理「没有任何规则适用」的空结果。还要为同额的规则定一个确定的决胜顺序,否则结果取决于列表顺序,和上面「第一个匹配的策略胜出」是同一个问题。试算本身应该是没有副作用的纯计算,选定之后再由应用服务执行状态变更(扣减额度、记录使用),两者分开,试算才能放心地多次调用。
写法的选择:
- 规则无状态、只有一个方法、不需要注入依赖 →
Map<Key, Function>; - 规则需要配置、有多个方法或需要代理增强 → 类加容器注入;
- 类型集合封闭、由自己掌控 → 密封类型加穷举
switch,新增类型时编译器会指出漏处理的地方,见 封装、组合与多态; - 开放的插件集合 → 运行时注册,并做上面的启动检查。
三、回调:写清楚在哪个线程、哪个事务里执行
固定流程加回调的典型例子是 JdbcTemplate:获取连接、执行、处理异常、释放资源的流程固定,调用方只提供 SQL 和 RowMapper。它不需要继承,这是它和经典「模板方法」的区别,见 设计模式决策图。
回调的风险在于它运行在流程所有者选定的环境里。实测同一个回调读取调用方设置的租户:
同线程执行: activity-1 的租户:tenant-a
线程池中执行: activity-1 的租户:null
回调抛出 IllegalStateException:调用方收到 CompletionException,原因才是 IllegalStateExceptionThreadLocal 只是一个例子,Spring 的事务、安全上下文、日志的 MDC 在换线程时都有同样的问题,事务的实测见 Spring AOP 为什么会失效。提供回调的一方要在接口上写明:
- 回调在调用方线程还是其他线程执行;
- 是否在事务内,事务在回调前后何时提交;
- 回调抛出的异常以什么形式回到调用方;
- 回调能否被调用多次(重试时)。
四、责任链:顺序与副作用
责任链的每个节点返回「继续」或「拒绝」,拒绝时链条短路。实测两个结论。
顺序是业务规则。 40 个合法请求与 60 个未登录请求交替到达,全局配额 50:
| 顺序 | 合法请求通过 | 拒绝原因 |
|---|---|---|
| 鉴权 → 限流 | 40 / 40 | 未登录 60 |
| 限流 → 鉴权 | 20 / 40 | 超过限流 50、未登录 30 |
限流在前时,未登录请求也消耗配额。反过来,有些场景恰恰需要限流在前,比如要保护鉴权服务本身不被压垮。无论哪种,顺序都应该显式配置,并写明理由。
「继续」之前不要改状态。 幂等节点把请求的幂等键记下来,然后返回「继续」;如果它排在校验之前:
第一次(6 人): auth=pass, idempotency=pass, validation=reject(同行人数必须在 1—4 之间)
改成 2 人后重试: auth=pass, idempotency=reject(重复请求)用户修正了参数,却因为第一次失败请求留下的幂等键被拒绝。把幂等节点放到校验之后就正常了;更一般的做法是节点只做判断,把需要持久化的状态放到请求真正成功之后再提交,或者在失败时释放。幂等键在执行失败、超时和崩溃时怎样处理,见 可复用的服务端组件。
让短路可观测。 实验里每个节点都记录了自己的决定,被拒绝的请求能直接看出停在哪个节点、原因是什么。链上节点超过五六个时,建议给每个节点记录放行、拒绝和耗时。
设计扩展点之前,先分清楚变化在哪里:是选一个算法、换一步做法,还是在入口加一道检查。策略要在启动时验证选择逻辑是完整且唯一的;回调要说明执行的线程、事务和异常;责任链要显式写出顺序,并让节点在确认成功之前不改变状态。
一次状态变化需要通知多方时,观察者、事件与消息的边界见 一次状态变化怎样通知多方。
配套实验
- codesphere-labs/design/extensible-processing-pipeline:策略的重复与缺失、回调换线程后的上下文与异常、责任链的顺序与节点副作用(验证记录)
参考资料
- Gamma 等,《Design Patterns: Elements of Reusable Object-Oriented Software》(Strategy、Template Method、Chain of Responsibility)
- Spring Framework API:JdbcTemplate
- Spring Framework Reference:Ordering of injected collections(@Order)
- JDK 21 API:CompletableFuture