责任链模式如何做单元测试和链路可观测性?
简化版
责任链测试要分两层:单个处理器测试职责逻辑,整条链测试顺序、短路、异常和兜底。可观测性要记录链名、版本、处理器顺序、每个节点耗时、决策结果和短路原因,否则线上很难解释请求为什么被拦截或漏处理。
详细版
责任链比普通方法更需要测试和观测,因为它的行为由多个处理器共同决定。单测只测某个 Handler 不够,还要有集成式链路测试:合法请求走完整链,非法请求在指定节点短路,异常请求返回统一错误,空链或无人处理时有兜底。
可观测性方面,链容器应该统一埋点,而不是让每个处理器随意打日志。每次请求至少要知道:走的是哪条链、链路版本是多少、执行了哪些节点、每个节点耗时多少、最终结果是什么。如果责任链是动态配置的,还要能关联配置版本。
面试里可以回答“处理器单测 + 链路契约测试 + 顺序断言 + 短路断言 + 统一日志指标”。这比只说“写 JUnit”更完整。
完整版教学
一、为什么责任链特别需要测试
责任链的代码往往被拆成多个小处理器,每个处理器看起来都很简单,但组合后的行为可能复杂。比如 Auth -> Param -> Risk -> Stock -> Submit,单看每个节点都没问题,顺序调换后就可能出错:风控在参数规范化前执行,读到的字段为空;库存冻结在风控前执行,风控拒绝后还要补偿。
正确顺序:
Auth -> ParamNormalize -> Risk -> Stock -> Submit
错误顺序:
Auth -> Risk -> ParamNormalize -> Stock -> Submit
测试的目标不是追求覆盖率数字,而是把链路契约固定下来。假设 6 个处理器有 2 个短路节点、1 个异常节点,如果只测最终成功路径,至少会漏掉 3 类风险。
记忆钩子:责任链的 bug 经常不在单个处理器,而在处理器之间的顺序、短路和共享上下文。
二、单个处理器要测职责边界
每个处理器应该只测自己的职责,不要在单测里把整条链都拉进来。比如 AuthHandler 只负责未登录时停止、已登录时继续,并写入用户信息。可以用假的 Chain 判断它是否调用了下一步。
@Test
void shouldStopWhenUserNotLogin() {
OrderContext context = new OrderContext(requestWithoutToken());
FakeChain chain = new FakeChain();
ChainResult result = authHandler.handle(context, chain);
assertEquals(Status.STOP, result.status());
assertFalse(chain.called());
}
单处理器测试至少覆盖 3 种情况:正常通过、业务短路、异常或边界输入。比如参数校验处理器要测缺字段、字段格式错误和合法参数;风控处理器要测低风险通过、高风险拒绝、风控服务超时。
三、整条链要测执行顺序和短路位置
链路测试重点是组合行为。可以使用记录型处理器,把每个节点执行时往列表里写名字,然后断言顺序。这样新增处理器或调整顺序时,测试能明确提示链路契约变化。
@Test
void shouldExecuteHandlersInExpectedOrder() {
List<String> executed = new ArrayList<>();
HandlerChain chain = new HandlerChain(List.of(
new RecordingHandler("Auth", executed),
new RecordingHandler("Param", executed),
new RecordingHandler("Risk", executed)
));
chain.handle(new OrderContext(validRequest()));
assertEquals(List.of("Auth", "Param", "Risk"), executed);
}
短路测试也很重要。比如未登录请求应该只执行 Auth,不能继续执行 Param 和 Risk。如果断言只看最终结果是失败,就无法发现多执行了后续节点的问题。
未登录期望: Auth -> stop
实际错误: Auth -> Param -> Risk -> stop
四、异常和兜底要有专门测试
责任链里最容易漏的是异常路径和链尾兜底。比如所有处理器都选择 pass,最后没人处理请求,系统应该返回明确错误还是默认通过?这个要通过测试固定下来。异常处理也要验证链容器是否记录当前处理器、是否返回统一错误码。
| 测试场景 | 期望 |
|---|---|
| 空链 | 返回配置错误或默认通过,按业务契约 |
| 无人处理 | 触发链尾兜底 |
| 中间节点抛异常 | 返回统一 CHAIN_ERROR |
| 补偿节点失败 | 记录补偿任务 |
@Test
void shouldReturnChainErrorWhenHandlerThrows() {
HandlerChain chain = new HandlerChain(List.of(new ThrowingHandler("Risk")));
ChainResult result = chain.handle(new OrderContext(validRequest()));
assertEquals("CHAIN_ERROR", result.code());
}
五、可观测性要放在链容器里
如果每个处理器自己打日志,格式很难统一,漏打也很常见。链容器天然知道当前执行的是哪个处理器、它前后耗时、返回结果和异常,所以统一埋点最合适。处理器只需要返回业务结果。
traceId=abc chain=orderSubmit version=v3 handler=Auth result=PASS costMs=2
traceId=abc chain=orderSubmit version=v3 handler=Param result=PASS costMs=1
traceId=abc chain=orderSubmit version=v3 handler=Risk result=STOP code=RISK_REJECT costMs=18
这类日志要能回答 4 个问题:请求走了哪条链,走到哪个节点停了,为什么停,每个节点慢不慢。没有这些信息,线上排查只能靠猜。
六、指标要区分链路级和节点级
日志适合排查单个请求,指标适合看整体趋势。链路级指标包括 QPS、成功率、短路率、错误率、耗时分位数;节点级指标包括每个处理器的通过数、拒绝数、异常数、平均耗时和 P95 耗时。
| 指标 | 粒度 | 示例用途 |
|---|---|---|
chain_total | 链路 | 看订单链请求量 |
chain_stop_total | 链路 | 看拦截率是否突增 |
handler_cost_ms | 节点 | 找慢处理器 |
handler_error_total | 节点 | 定位异常节点 |
handler_decision_total | 节点 | 分析拒绝原因 |
假设风控节点 P95 从 20ms 涨到 200ms,而链路总 P95 从 80ms 涨到 260ms,就能判断主要瓶颈在风控节点,而不是整条链都慢。
七、动态链还要测试配置版本
动态装配的责任链需要额外测试配置解析和版本变更。比如配置里写了 Auth, Param, Risk,测试应断言最终生成的链就是这 3 个节点,并且顺序一致。配置缺少必选节点时,要在启动或加载阶段失败,而不是等请求进来才失败。
配置:
orderSubmit v5 = Auth(100), Param(200), Risk(300)
测试断言:
chain.name == orderSubmit
chain.version == v5
handlers == [Auth, Param, Risk]
如果支持灰度,还要测试同一个场景下不同用户命中不同版本。比如 10% 用户走 v6,其余走 v5,日志里必须带版本,否则排查结果会混在一起。
八、常见误区与追问
- 误区:每个 Handler 单测过了,整条链就没问题。 责任链的核心风险在顺序、短路和上下文协作,必须有链路级测试。
- 误区:只测成功路径。 短路、异常、空链、无人处理、补偿失败都属于高频问题。
- 误区:日志放在每个处理器里更灵活。 统一链容器埋点才能保证格式、耗时和异常记录一致。
- 追问:怎么测试处理器是否调用 next? 使用假的
Chain或记录型处理器,断言后续节点是否被执行。 - 追问:怎么定位线上被哪个节点拦截? 日志里记录
chainName/version/handler/decision/code/costMs,按 traceId 串起来。 - 追问:指标会不会太多? 处理器数量可控时按节点打指标很有价值,标签要限制在链名、节点名、结果这类低基数字段。
九、加强记忆
责任链测试和观测记住“两层测试、两级观测”:单处理器测职责边界,整条链测顺序短路;链路级观测看整体成功、失败和耗时,节点级观测定位哪个处理器慢、拒绝或异常。这样责任链不是一个看不见内部的黑箱,而是每次请求都能被解释的流程。