☰
责任链模式实战:用一条链替代if-else审批逻辑
2026/10/15 1:33:35 网站建设 项目流程

前两天在重构一套内部 OA 系统的审批模块时,我被一大段嵌套的 if-else 搞得有点头疼。一个请假申请,先判断请假类型,再判断天数范围,然后找对应的部门负责人,找不到就继续往上报。表面上看逻辑很完整,可当组织架构一调整,审批角色变了,我得在同一段代码里反复查找、修改分支条件,光是梳理逻辑就花了半天。那一刻我脑子里蹦出来的词,就是“设计模式之责任链模式”。这类模式在我日常工作里出现频率其实很高,只是不少人未必叫得出名字。

责任链模式不追求花哨的技巧,也不像单例、工厂那样天天挂在嘴边,但它特别适合处理“多级审批”“多层过滤”“多种处理策略按优先级尝试”这类场景。它能把一团乱麻的条件分支,整理成一条清晰、可扩展、可编排的流水线。这篇文章我就用一次真实的审批需求做例子,把责任链模式从概念、设计到落地实现逐步拆开,代码和步骤你都能直接参考。

1. 责任链模式到底在解决什么问题

1.1 一段典型的三层嵌套代码

先看一段我经常见到的业务代码,伪代码如下:

if (request.days <= 3) { if ("REGULAR".equals(request.type)) { teamLeader.handle(request); } else { director.handle(request); } } else if (request.days <= 7) { if (request.needFinanceCheck) { financeManager.handle(request); } else { departmentManager.handle(request); } } else { hrManager.handle(request); }

这种代码初看没有问题,运行也正常。但真正要命的是后续修改。公司出台新规:连续请假超过 5 天必须经过 COO 审批,或者研发部门请假需要技术总监先签字。这时候你要怎么办?在两个 if 分支里各加一段判断,还要小心别把原有的逻辑顺序弄乱。测试用例稍微漏一个边界条件,线上就会出现“请假单莫名其妙审批通过”的故障。

这种困境的本质,是“请求本身”和“处理者的选择逻辑”耦合在了一起。调用方必须知道链路上所有可能出现的角色,并且自己完成路线规划。一旦路线规则变化,调用方代码就要跟着膨胀。而责任链模式的思路恰恰相反:调用方只需要把请求交给链首,至于由谁来处理、按什么顺序处理,那是链内部的事情。

1.2 责任链模式的核心套路

责任链模式的核心其实就是一件事:为请求创建一条由多个处理对象组成的链,每个对象都有机会处理这个请求,直到有对象接住并处理完成为止。

你可以把它想成一个“客服工单流转系统”。用户提交一个工单,先经过智能机器人:能识别的问题直接答复;机器人解决不了,转给人工客服;人工客服处理不了技术问题,再转给技术专家;专家也解决不了,升级到值班主管。每个环节只判断“这个工单我能不能处理”,能就处理,不能就传给下一个节点。这套流程在生活里到处都有,请假审批、报销打款、工单升级,甚至服务器异常告警的逐级上报,本质上都是责任链。

1.3 模式中的三个关键角色

责任链模式的参与者非常标准,永远只有三类:

  • Handler(抽象处理者):定义处理请求的统一接口,一般还会持有下一个处理者的引用。
  • ConcreteHandler(具体处理者):实现自己的判断逻辑。能处理就处理,不能处理就把请求传给下一个。
  • Client(客户端):负责组装链,并触发第一个处理者。

难点不在这三个角色本身,而在几个容易被忽略的设计决策上。我见过很多同学把类建好了,链也搭好了,结果运行起来和预想不一致,就是因为没有提前把请求对象设计、构建方式、终止策略这三件事想清楚。这也是下一节要展开的内容。

2. 动手之前:三件容易被忽略的设计决策

2.1 请求对象怎么设计

责任链上的每个节点,都需要从请求对象中获取信息来做判断。请求对象设计得太“薄”,比如只有一个类型字段,后面想根据金额、天数、申请人级别做更细粒度判断,就得改对象结构,很痛苦。设计得太“厚”,什么字段都往里塞,又会让每个节点都面对一堆用不到的数据。

比较好的做法是:请求对象作为一个完整上下文,带上当前这笔业务涉及到的所有关键信息。以请假审批为例,至少应该包含员工编号、请假类型、请假天数、申请原因,甚至还可以预留一个扩展字段,比如订单金额、项目等级、审核备注。

把这些信息集中在一个请求对象里,至少有两个好处:一是每个处理者都可以按需读取字段,不需要另外做“查库”或者“参数传递”的动作;二是后续如果要在链上补充“消息通知”“审计日志”这类横切逻辑,上下文已经具备了基础数据,不需要再做大改动。

我习惯给请求对象加一个applyLevel或者priority之类的辅助字段,表示当前请求在链路中应该命中哪个层级。这个字段可以由客户端在构造请求时计算好,也可以让每个处理者在判断后回写。这样能显著简化处理器的判断逻辑,避免每个节点都重复算一遍类似“该员工职级是多少”的规则。

2.2 链路构建方式怎么选

责任链的“链”只是一种逻辑概念,具体怎么构建,有几种常见路线。

  • 方式一:每个具体处理者内部持有下一个节点。这也是最经典的写法。处理者实现handle(request)时,如果自己处理不了,就调用next.handle(request)。优点是直观、结构清晰;缺点是链路的组装关系散落在各个节点内部,要看全貌得把所有类都看一遍。
  • 方式二:在客户端或工厂类里统一组装。每个处理者只关心自己能不能处理,next引用由外部注入。运行时的链路顺序一眼就能看出,修改顺序也只需要改动一处。我推荐大多数项目采用这种方式,尤其在节点数量不多、链路相对固定的场景。
  • 方式三:基于配置文件或注册表自动装配。把处理者注册为 Bean,运行时按注解顺序、权重或配置项排序。这种方式适合处理者数量多、经常动态调整顺序的系统,但调试时链路会隐晦一些,需要在启动日志中打印最终链路顺序。
  • 方式四:用列表来模拟链。不建 child 引用,而是把所有处理者放进一个 List,逐个遍历,谁处理完谁 break。这种写法在一些轻量场景里很顺手,几乎能替代责任链原生结构。

下面用一个表格把各自特点做对比:

构建方式优点缺点适合场景
内部持有 next直观、经典组装关系分散节点少且相对固定
客户端/工厂统一组装链路全貌清晰需要把节点按顺序逐个注入大多数标准业务链
配置文件/注册表装配动态调整能力强调试相对复杂节点多、需要灰度切换
用 List 遍历模拟代码量小、实现简单难以表达复杂的跳过/终止语义轻量级场景

2.3 处理终止策略怎么定

这是责任链模式最容易被忽略的一个决策,也是最容易出 bug 的地方。到底应该“处理完就停”,还是“处理完继续往后传”?两种语义在不同业务里都有道理,但混用就会产生问题。

纯责任链模式要求只有一个处理者能处理请求,处理完就终止。典型场景就是审批:只要有人批了,就不再继续往上递。不纯责任链模式则允许请求在链上被多个节点依次处理,每个节点都能插手,比如日志过滤器、埋点上报、黑名单校验。这类节点处理完之后要把请求继续传下去,后面的节点才能继续处理。

我在项目里的习惯是:在建链之前,先明确做一张表格,写出每个节点是需要“终止链路”,还是“继续放行”。打印到一个文档里,或者直接作为代码注释放在链首类的顶部。这能避免后人加节点时理解错语义,把整条链的流程搞乱。

3. 从零实现一个请假审批链

3.1 第一步:定义请求类

我要实现的场景是一个内部审批系统的“请假申请”。请求对象长这样:

public class LeaveRequest { private final String employeeNo; private final String leaveType; private final int days; private final String reason; public LeaveRequest(String employeeNo, String leaveType, int days, String reason) { this.employeeNo = employeeNo; this.leaveType = leaveType; this.days = days; this.reason = reason; } public String getEmployeeNo() { return employeeNo; } public String getLeaveType() { return leaveType; } public int getDays() { return days; } public String getReason() { return reason; } }

你可能觉得这个类太普通了。确实,责任链模式的请求对象本来就不需要复杂。关键是字段信息齐全,后面每个处理者都能靠它做判断。

3.2 第二步:定义抽象处理器

抽象处理者的设计我比较推荐模板方法风格。外层handle统一处理“能否处理、能否传递、没人处理怎么办”这些流程,每个具体处理者只需要实现两件事:判断条件和处理动作。

public abstract class Approver { protected Approver next; public void setNext(Approver next) { this.next = next; } public void handle(LeaveRequest request) { if (canHandle(request)) { doApprove(request); } else if (next != null) { next.handle(request); } else { throw new IllegalStateException("没有合适的审批人处理该请求"); } } protected abstract boolean canHandle(LeaveRequest request); protected abstract void doApprove(LeaveRequest request); }

这里的canHandle就是每个节点的“拦截条件”,doApprove是真正业务动作。模板方法把链路的流转逻辑收敛到一处,后续不管新增多少个节点,都不会不小心改坏流程控制。

有人可能疑问,如果链尾没人处理,抛异常是不是太粗暴?我在后面的“问题排查”部分会再讨论这个问题。这里先保留异常,因为至少比“请求静默丢失”要好。

3.3 第三步:具体处理器实现

假设审批规则是这样的:

  • 3 天以内(含)由班长审批;
  • 3 天以上、7 天以内(含)由部门经理审批;
  • 超过 7 天,需要人事经理审批并同步考勤。

三个具体处理者的代码:

public class TeamLeader extends Approver { @Override protected boolean canHandle(LeaveRequest request) { return request.getDays() <= 3; } @Override protected void doApprove(LeaveRequest request) { System.out.println("班长审批通过:" + request.getEmployeeNo() + " 请假 " + request.getDays() + " 天,原因:" + request.getReason()); } }
public class DepartmentManager extends Approver { @Override protected boolean canHandle(LeaveRequest request) { return request.getDays() > 3 && request.getDays() <= 7; } @Override protected void doApprove(LeaveRequest request) { System.out.println("部门经理审批通过:" + request.getEmployeeNo() + " 请假 " + request.getDays() + " 天,原因:" + request.getReason()); } }
public class HrManager extends Approver { @Override protected boolean canHandle(LeaveRequest request) { return true; } @Override protected void doApprove(LeaveRequest request) { System.out.println("人事经理审批通过:" + request.getEmployeeNo() + " 请假 " + request.getDays() + " 天,并将记录同步到考勤系统"); } }

看到没有,每个处理者都只管属于自己的那段逻辑。判断条件是局部规则,处理动作也不依赖其他节点。更重要的是,新增一个“技术总监”节点,不需要改动任何已有节点类,只要写一个新的子类,然后组装时插到对应位置就行。

3.4 第四步:构建链路并触发请求

组装链路的代码,我建议放在工厂或者配置类里,不要在业务调用处散落很多setNext。下面是最简单的客户端写法:

public class ApproveClient { public static void main(String[] args) { Approver leader = new TeamLeader(); Approver manager = new DepartmentManager(); Approver hr = new HrManager(); leader.setNext(manager); manager.setNext(hr); LeaveRequest request = new LeaveRequest("E10086", "年假", 5, "外出旅行"); leader.handle(request); } }

运行结果也很直接:

部门经理审批通过:E10086 请假 5 天,原因:外出旅行

请求从班长这一级发起,班长判断 5 天不在自己职责内,于是传给部门经理;部门经理判断符合自己的规则,处理完,链路终止。

3.5 第五步:把链路构建收拢起来

如果链路再长一点,比如 5 个节点、10 个节点,逐个setNext就很啰嗦。我会在工厂里写一个构建器,这样链路的顺序一目了然。

public class ApproveChainFactory { public Approver buildChain() { TeamLeader leader = new TeamLeader(); DepartmentManager manager = new DepartmentManager(); HrManager hr = new HrManager(); leader.setNext(manager); manager.setNext(hr); return leader; } }

调用处只需要拿到链首,传入请求,完全不关心内部的节点顺序:

Approver head = new ApproveChainFactory().buildChain(); head.handle(new LeaveRequest("E10086", "年假", 12, "婚假"));

这种做法带来的好处是:如果某天组织架构调整,部门经理不再审批 7 天之内的假,而是改成 5 天之内的假由直属主管审批,你只需要修改部门经理的canHandle以及工厂里的组装顺序,业务调用处一行都不用改。这就是责任链模式真正的价值——把变化隔离开。

4. 实操中踩过的坑与排查实录

责任链模式看起来简单,实际运行起来坑也不少。下面这些问题我都在项目里真实遇到过。

4.1 链尾没人接住请求

最典型的坑就是一不小心把链尾处理者删掉了,或者新业务请求符合不了链路末尾的条件,结果请求传着传着没人处理。我在最初版本的抽象处理者里用了抛异常的方式:

throw new IllegalStateException("没有合适的审批人处理该请求");

这样可以及时暴露异常,线上日志也能快速定位。但也有业务方觉得太暴力,希望未接住的请求走一个默认策略。那我通常会加一个兜底节点,放在链尾:

public class DefaultFinalHandler extends Approver { @Override protected boolean canHandle(LeaveRequest request) { return true; } @Override protected void doApprove(LeaveRequest request) { System.out.println("默认兜底处理,进入人工审核池"); } }

这个兜底节点什么都不判断,永远接住请求。这样既不出现神秘的业务静默丢失,也不会因为异常打断主流程,很适合那种“最终一定会有人处理”的场景。

4.2 处理完还想继续传

这个坑特别隐蔽。有些节点不仅要审批,还要记录日志、发通知、做埋点。比如我来写一个日志节点:

public class AuditLogger extends Approver { @Override protected boolean canHandle(LeaveRequest request) { return true; } @Override protected void doApprove(LeaveRequest request) { System.out.println("记录审计日志"); } }

如果把这个节点放在链首,它会判断“自己可以处理”,然后执行doApprove,链路在当前节点的handle就终止了,请求根本不会继续往后传。很多人第一次用模板方法写责任链,都会在这种地方懵住:日志打了,但审批没有执行。

解决思路有两个。一个是把这类节点设计成不终结节点,在外层的handle判断里加一个continueChain的语义,执行完自己的动作后继续调用next.handle(request)。另一个更简单,把日志、埋点这类横切逻辑放在真正的业务处理器内部来调用,不单独作为链上节点。我比较推荐后者,因为责任链的重点是“谁来处理”,而不是“处理前后要做什么”。

4.3 循环引用导致的死循环

责任链本质是单向链表,但如果你在设计时不小心让某个节点的next指回了前面的节点,就会形成环。请求会在链上绕圈,形成死循环,CPU 飙升,接口超时。

我在排查这类问题时,一般会在链首对象里做一个深度计数。比如在抽象处理者的handle方法里加一个最大深度限制:

private int depth = 0; private static final int MAX_DEPTH = 100; public void handle(LeaveRequest request) { if (++depth > MAX_DEPTH) { throw new IllegalStateException("责任链深度超过限制,疑似存在循环"); } ... }

这个判断只用于开发期或者疑难排错,上线前我会去掉,毕竟正常链路根本到不了这么深。也可以通过单元测试,在构建完链之后遍历一遍,确认没有节点成环。

4.4 链路顺序不同,结果完全不同

责任链节点是有顺序的,顺序错了,业务就错了。比如一个审批链,正常顺序是“普通审批”在“加急审批”之前。如果节点顺序颠倒,请求可能被加急节点提前拦截,普通审批节点反而没有机会处理。

我踩过一个很实际的坑:两个处理者的判断条件边界刚好相接,比如一个判断“小于等于 3 天”,另一个判断“大于 3 天”。如果因为业务需要,后面那个节点被排到了前面,所有 3 到 7 天的请求都会被它接住,但它的处理规则其实是错的。排查这个问题时,我重新测试了所有边界值,才定位到顺序问题。

现在我的习惯是:每个节点在canHandle里写清楚自己的职责边界,并且配套一组边界测试用例,覆盖“刚好 3 天”“刚好 7 天”“刚刚超过边界”这些输入。责任链改变顺序之后,跑一遍测试就知道有没有破坏边界条件。

4.5 链路过深与调试困难

理论上责任链可以很长,但链路太长会让问题排查变得费劲。一次请求到底走到了哪个节点,中间被谁处理了,日志要是不完整,排查起来全靠猜。

我现在的做法是在每个处理节点里都留一个可选的日志开关,打印当前节点名称和请求的关键字段。在生产环境里也可以用MDC给同一请求打上同一个跟踪标识,这样在日志系统里就能把一条请求的完整链路串联起来。另一个技巧是在工厂构建完链之后,打印一次最终节点顺序:

TeamLeader -> DepartmentManager -> HrManager

这样运维和排查的人打开日志就能看到当前生效的链路结构,避免“以为链里还有某个节点,实际上已经被人删掉了”的误会。

下面整理一个常见问题速查表:

现象可能原因处理建议
请求没人处理,也无报错链尾没有兜底节点增加兜底节点或抛异常
只打印了日志,业务没执行日志节点误用了终止语义横切逻辑移出链,或支持透传
接口卡死、CPU 高链接成环,请求死循环加最大深度限制,检查节点引用
边界请求审批到错误节点链路顺序和判断条件冲突补边界测试用例,核对排序
新增节点后原有流程变了新节点半路拦截请求确认节点顺序和终止语义

5. 责任链的变体与配合使用的思路

5.1 纯链与不纯链的正确选型

我在前面反复提到了“纯责任链”和“不纯责任链”。为了让你理解得更清楚,我用一个对照表说明:

类型处理方式适用场景典型代表
纯责任链请求被一个节点处理后就终止审批、路由、任务分发请假审批链
不纯责任链请求可以被多个节点依次处理过滤器、拦截器、中间件日志埋点链、鉴权链

实际系统里往往会把两者混用。比如一个下单请求,先经过参数校验节点,再经过风控判断节点,然后经过库存预占节点,最后才到支付节点。其中部分节点只是“检查并放行”,部分节点是“命中即终止”。这样的混合链路很常见,但你必须把每个节点的语义写清楚,否则后人很难维护。

5.2 责任链模式和过滤器、拦截器的关系

很多人会把责任链模式、过滤器模式、拦截器模式混为一谈。它们确实长得像,但侧重点不同。

责任链模式的关注点是“由哪个处理者来处理”。过滤器链的关注点是“在请求前和请求后做统一的横切处理”。拦截器则更强调方法调用层面的拦截,比如框架在调用目标方法前后插入逻辑。它们的底层实现常常借助了责任链的思想,但应用层级和语义有所不同。

我自己的判断标准很简单:如果业务逻辑是“几个角色抢着处理一份请求”,用责任链;如果业务逻辑是“在请求到达前统一洗一遍数据”,用过滤器或者拦截器更合适。

5.3 用函数式表达来简化轻量级链路

在节点逻辑不复杂的情况下,不一定要每个节点都建一个类。Java 8 之后的 Lambda 可以让链路写得更简洁。比如把责任链简化成一个函数列表:

List<Predicate<LeaveRequest>> checkers = new ArrayList<>(); checkers.add(req -> req.getDays() <= 3); checkers.add(req -> req.getDays() > 3 && req.getDays() <= 7); checkers.add(req -> "SPECIAL".equals(req.getLeaveType())); for (Predicate<LeaveRequest> checker : checkers) { if (checker.test(request)) { // 执行对应处理逻辑 break; } }

这种写法在体量小、节点固定、不需要每个节点都承载复杂业务动作时非常高效。它也体现出责任链模式的本质并不在类和继承,而在“按顺序判断、命中即停止”的这个过程。但一旦节点逻辑复杂、需要独立的单元测试,我还是建议回到面向对象的写法,拆成独立类更清晰。

5.4 责任链真的能替代所有 if-else 吗

不能。责任链擅长解决的问题是“多个处理者按顺序尝试”的场景。如果一段 if-else 只是简单的条件判断,强行拆成责任链反而会增加不必要的复杂度。盲目的“为了模式而模式”,会让代码多出很多抽象类,大家阅读成本反而变高。

我更推荐这么判断:你改一个需求,会不会导致在原有 if 分支之中再插入新的分支?如果会,并且这个变化是频繁的、动态的,那责任链就是值得投入的方案。如果判断条件一辈子不变,那普通的 if-else 完全够用。

6. 我实际使用责任链的一些习惯

6.1 从旧代码重构到责任链的顺序

我在项目里把一段老旧的 if-else 重构到责任链时,不是上来就直接写类。我习惯先做三件事:

  • 第一步,把旧代码里所有判断条件整理成一张表,列出“什么条件下交给谁处理”。
  • 第二步,按照这张表为每个处理者写出独立的判断方法和处理方法,先放在同一个类里,用小方法拆分。
  • 第三步,确定新链路没有遗漏条件之后,再拆成独立类,组合成链。

这个顺序能大大降低重构风险。因为每一步都有可运行的状态,不至于一下子变成一个大工程。实际运行时,先用旧的测试用例跑一遍,再补新的边界测试。

6.2 链首加日志节点,链尾加兜底节点

我现在基本形成了固定套路:责任链的第一个节点放一个轻量级记录节点,不管最终谁处理,先记录请求到达时间和关键字段。链尾则放兜底节点,防止新的请求类型导致无节点可接。中间节点按业务规则依次排列。这个结构运转下来,线上问题定位快很多,也不会出现请求神秘失踪的情况。

我在实际使用中还有一个体会:责任链模式最大的收益,不是“消掉了 if-else”这个表面结果,而是它把“变化”融到了链路本身上。以后出现新角色、新规则,我们不再去翻动一段又长又密的代码,而是在链上增加节点、调整顺序,像搭积木一样完成业务变更。代码的可读性、可测试性都提高了不少。如果你也正在面对层级审批、多策略路由、过滤器解释这类需求,不妨按这篇文章的思路试一次,体会一下链路拆分带来的清晰感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询