可扩展流程怎样设计:策略、回调与责任链
「让流程可扩展」至少有三种不同的意思:同一个决策点换一种算法、固定流程里换掉某一步、在请求进入之前加一道检查。它们分别对应策略、回调和责任链,经常出现在同一个请求里,但各自出错的方式完全不同。
以活动报名请求为例,一次报名要经过这些步骤:
租户 → 鉴权 → 限流 → 幂等 → 校验 ← 责任链:任何一个节点可以拒绝
加载活动 → 计算容量 → 保存 → 返回 ← 固定流程:「计算容量」由调用方提供
计算容量:固定座位 / 超卖 10% / 线上不限 ← 策略:按活动类型选一个三个扩展点实测各出了一次错:两条策略都声明处理工作坊,容量是 20 还是 22 取决于注册顺序;回调换到线程池执行后,读到的租户是 null;限流排在鉴权前面,合法请求只通过了一半。本文用 JDK 21 的实测,逐个说明它们的边界。
一、先说结论
- 三种扩展点处理三种变化:策略是「同一个决策点选一个算法」,回调是「流程骨架不变、某一步由外部提供」,责任链是「多个节点依次判断、可以提前结束」。
- 策略注册要在启动时检查重复与缺失。 「第一个匹配的策略胜出」会让结果取决于注册顺序,实测同一个工作坊的容量是 20 或 22。
- 回调要写清楚在哪个线程、哪个事务里执行。 实测回调交给线程池后,
ThreadLocal里的租户变成null,异常也被包成CompletionException。 - 责任链的顺序本身就是业务规则。 实测先限流后鉴权时,未登录请求耗掉了配额,合法请求只通过 20 / 40;先鉴权时全部通过。
- 节点返回「继续」之前不要偷偷改状态。 实测幂等节点排在校验之前,被校验拒绝的请求也登记了幂等键,修正参数后的重试被当作重复请求拒绝。
二、三种扩展点各管一件事
| 策略 | 固定流程 + 回调 | 责任链 | |
|---|---|---|---|
| 变化的是什么 | 某一步用哪个算法 | 某一步具体怎么做 | 进入前要经过哪些检查 |
| 执行几个 | 一个(或一组选中的) | 流程里的每一步都执行 | 依次执行,任何一个可以短路 |
| 由谁决定 | 路由逻辑按键选择 | 流程的所有者调用回调 | 链的顺序 |
| 典型例子 | 运费、容量、渠道选择 | 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。确实需要多条规则同时匹配时(按优先级叠加),就把优先级写成数据并检查同优先级的冲突,渐进发布规则的做法见 可复用的服务端组件。
写法的选择:
- 规则无状态、只有一个方法、不需要注入依赖 →
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(重复请求)用户修正了参数,却因为第一次失败请求留下的幂等键被拒绝。把幂等节点放到校验之后就正常了;更一般的做法是节点只做判断,把需要持久化的状态放到请求真正成功之后再提交,或者在失败时释放。幂等键在执行失败、超时和崩溃时怎样处理,见 可复用的服务端组件。
让短路可观测。 实验里每个节点都记录了自己的决定,被拒绝的请求能直接看出停在哪个节点、原因是什么。链上节点超过五六个时,建议给每个节点记录放行、拒绝和耗时。
六、常见误区
- 「策略模式就是注入 List 然后 findFirst」:两个策略同时匹配时,结果取决于注册顺序,实测容量是 20 或 22。
- 「回调就是传一个函数」:它运行在别人的线程和事务里,实测换线程后租户变成
null。 - 「责任链的节点可以随意排序」:实测限流与鉴权交换顺序后,合法请求的通过数减半。
- 「节点返回继续就没有影响」:实测幂等节点登记了失败请求的键,合法重试被拒绝。
- 「JdbcTemplate 是模板方法」:它是固定流程加回调,不需要继承。
小结
设计扩展点之前,先分清楚变化在哪里:是选一个算法、换一步做法,还是在入口加一道检查。策略要在启动时验证选择逻辑是完整且唯一的;回调要说明执行的线程、事务和异常;责任链要显式写出顺序,并让节点在确认成功之前不改变状态。
一次状态变化需要通知多方时,观察者、事件与消息的边界见 一次状态变化怎样通知多方。
配套实验
- 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