☰
用责任链模式重构业务校验:告别if-else堆砌,构建可复用校验流水线
2026/10/4 3:37:44 网站建设 项目流程

这大概是每个写业务代码的人都会经历的瞬间:一开始你的接口只有三个校验,用户名非空、密码非空、做个简单的格式匹配,顺手写三个if,世界很干净。过了两周,产品说密码至少八位,你往上叠一个else if。又过了一个月,前后端联调时甲方说手机号要支持最新号段,你捏着鼻子加了一段正则。等哪天线上告警,你点开那个createUser方法,发现校验逻辑早就长成了一坨七十多行的if-else嵌套,你甚至不敢挪动其中任何一段,因为你不知道它被哪个分支悄悄依赖着。

这个场景我太熟了。不止一个项目里见过类似的“校验屎山”,而且它不会消失,只会随着业务演进越来越壮。问题的症结不在于要不要校验,而在于我们把“校验规则”这种本来就适合沉淀和复用的东西,焊死在了service层的一长串判断里。如果你正在被这种代码折磨,今天我分享的责任链模式值得你认真看一眼——它能把一条条散落的校验规则串成流水线,让十个甚至二十个规则在业务方法里只留下一行调用。后面我会用可复现的代码,把整套思路和踩坑点完整拆开。

1. 先从真正的痛点开始:if-else不是不好,是太容易长残

1.1 业务代码里最常见的“校验膨胀”长什么样

随便打开一个真实的订单创建、用户注册、表单提交接口,大概率能看到这类代码:

public void createUser(UserRequest req) { if (req.getName() == null || req.getName().isEmpty()) { throw new IllegalArgumentException("用户名不能为空"); } if (req.getName().length() > 20) { throw new IllegalArgumentException("用户名长度不能超过20个字符"); } if (req.getPassword() == null || req.getPassword().length() < 8) { throw new IllegalArgumentException("密码长度不能少于8位"); } if (req.getPassword().length() > 32) { throw new IllegalArgumentException("密码长度不能超过32位"); } if (req.getEmail() != null && !EMAIL_REGEX.matcher(req.getEmail()).matches()) { throw new IllegalArgumentException("邮箱格式不正确"); } if (req.getPhone() != null && !PHONE_REGEX.matcher(req.getPhone()).matches()) { throw new IllegalArgumentException("手机号格式不正确"); } if (userService.countByLoginName(req.getLoginName()) > 0) { throw new IllegalArgumentException("登录名已被占用"); } // 到这里才算真正进入业务逻辑 userService.save(req); }

写的时候倒是不难,问题全在“之后”。刚上线时需求稳定还好,怕的是业务方每隔几周扔来一个新校验:注册渠道限制、邀请码有效期、密码不能和用户名相同、内部账号跳过部分校验……每来一条,你就得在这个方法中间找个位置再插一段。功能是正常跑,但代码越来越像一个没有抽屉的衣柜,所有东西都堆在同一个隔层里,找什么都得翻半天。

1.2 为什么说if-else校验代码有自己的“膨胀规律”

校验逻辑膨胀是有规律的:参数数量线性增长时,分支组合是近似指数增长的。一个新字段往往意味着“为空校验”“长度校验”“格式校验”“重复校验”中的好几个分支一起进来,更别说字段之间还会互相影响,比如编辑场景下部分字段允许为空、创建场景下又必须必填。这种互相纠缠的条件,写一次两次是直觉,写多了就是在给自己埋雷。

更麻烦的是,if-else校验和真正的业务逻辑是“绵”在一起的。读代码的人想搞清楚“这个接口到底做了什么”,却要先穿透那几十行校验分支;想改校验规则的人,又随时担心破坏掉包裹在里面的业务步骤。久而久之,谁都不敢碰这个方法,新人接手更是直接怀疑人生。

1.3 校验规则其实是“资产”,不是“临时代码”

我在重构这类代码时有一个强烈感受:大部分校验规则并不只属于某一个接口。手机号格式、用户名长度、密码强度、日期范围这类规则,在创建和更新场景、在不同接口里,往往会被反复用到。如果用if-else把它们写成每个方法里的私有逻辑,就等于把一笔可复用的资产变成了沉没成本。

所以核心思路应该倒过来:把每个校验规则从接口方法里提出来,变成能够独立命名、独立测试、独立组装的对象。在这种思路下,哪个接口需要哪几条规则,就按需组装;哪条规则要调整,就单独改那一个对象。这就是后文要聊的“责任链模式”所要解决的核心问题。

2. 责任链模式凭什么能把校验变成“流水线”

2.1 先理解责任链模式最朴素的形态

责任链模式,简单说就是让多个处理器对象按顺序排成一条链,数据从链头进入,逐个经过每个处理器,直到某个处理器决定终止流程,或者所有处理器都处理完。这个概念听起来有点绕,但放到工厂车间的流水线上,一秒就懂:每个质检工位就是一个处理器,产品从传送带进入,先过尺寸检测,再过外观检测,最后过功能检测。任何一个工位发现不合格,直接扣下,不再流向下一站。

这不就是我们想要的校验逻辑吗?参数进来以后,依次走过“非空校验”“长度校验”“格式校验”“唯一性校验”这些工位,任何一站不过,立刻抛出异常结束请求;全部通过,才放行进业务层。相比一长串if-else,这种形式最大的区别在于:规则与规则之间有了明确的边界和顺序,每个规则可以单独维护、单独测试。

2.2 为什么“责任链”天然就是一条“流水线”

很多人分不清责任链和普通for循环,其实关键在于“是否需要提前终止”。普通for循环遍历规则时,你也可以用break模拟短路,但代码会显得很死板:你依然需要在一个方法里维护规则数组,在循环里写各种判断。责任链模式的精髓在于把“执行规则”和“编排规则”分离——链上的每个节点只知道自己的检查逻辑和检查结果,完全不关心前后是谁;编排器只负责顺序执行并在失败时中止。

这与流水线的建模方式完全同构:规则节点是工位,数据是待检产品,执行过程是传送带。当你听到“流水线”这个词,脑子里应该浮现出的就是这种模型:输入进入,按顺序流过一系列处理单元,每个单元要么放行,要么拦截。是的,我们完全可以把校验扩展成更广义的处理流水线,比如先做数据清洗,再做格式转换,再做业务校验,再做落库前检查——每个环节都是独立的处理节点。

2.3 常见误区:选择责任链不等于彻底扔掉if-else

需要先说句实在话:责任链不是银弹,也不该被神话。如果接口只有一两个校验,直接写if反而更直观。我个人的判断标准是:校验规则稳定在4条以上,或者规则可能频繁调整,或者多个接口要复用同一套规则,这时候用责任链才有明显收益。

也不要以为用了责任链就再也见不到if-else。链上的每个节点内部还是会根据业务做条件判断,只是这些判断被收口到了单一职责的类里,不会再随着需求膨胀把整个业务方法塞满。总的目的不是消灭if-else,而是消灭“一长串难以维护、难以测试的if-else”。

3. 实战:一行代码把10条校验规则串进Pipeline

3.1 先说清楚第一种实现思路:List容器 + 顺序执行

责任链的经典教科书实现是用next指针把节点串成链表,但在业务代码里我更推荐用List容器加顺序遍历。原因有三个:

  • 用链表结构时,每个节点都要持有下一个节点的引用,调试链路关系时要一格格找,复杂度高。
  • List容器天然支持来自不同代码块的节点组合,想从几十个规则里挑十个组装,比手动改指针引用方便得多。
  • 后续要在节点间插入日志、耗时统计,只需要在容器遍历的统一入口做,不需要改动每个节点。

所以我常说,模式的精神比模式的教条代码更重要。下面的实现会以List容器为主,这也是我在项目里最常用的做法。

3.2 定义处理器接口,明确“一个节点该输出什么”

首先定义一个通用的处理器接口,我习惯叫Processor,它接收待校验数据,返回一个结果对象:

public interface Processor<T> { CheckResult process(T data); }

注意返回的是CheckResult而不是boolean,这一点在复杂业务里很重要:

public class CheckResult { private boolean success; private String message; public static CheckResult ok() { return new CheckResult(true, null); } public static CheckResult fail(String message) { return new CheckResult(false, message); } // 省略 getter/setter }

只返回true/false看起来更简单,但一旦失败,调用方根本不知道原因是什么,最后还是得回原方法里找是哪个if抛的错。把message带在结果里,链路也好、页面提示也好,都能直接复用。

为了减少样板代码,我还会提供一个抽象父类,把公共逻辑收拢:

public abstract class AbstractProcessor<T> implements Processor<T> { @Override public CheckResult process(T data) { if (!support(data)) { return CheckResult.ok(); } return doProcess(data); } // 当前节点是否支持处理这条数据,默认都支持 protected boolean support(T data) { return true; } protected abstract CheckResult doProcess(T data); }

support方法给了节点一个自我豁免的机会。比如某个规则只对“创建场景”生效,可以在support里判断场景字段,不满足时直接放行,不用跑到doProcess里去写复杂条件。这个设计在多个接口复用同一套规则链时特别有用。

3.3 用具体规则节点,把“校验规则”变成流水线工位

理论说完了,直接看节点怎么落地。以用户注册场景为例,我可以抽出这样一个Processor实现:

public class NotNullProcessor extends AbstractProcessor<UserRequest> { @Override protected CheckResult doProcess(UserRequest data) { if (data.getName() == null || data.getName().isEmpty()) { return CheckResult.fail("用户名不能为空"); } return CheckResult.ok(); } } public class MaxLengthProcessor extends AbstractProcessor<UserRequest> { @Override protected CheckResult doProcess(UserRequest data) { if (data.getName().length() > 20) { return CheckResult.fail("用户名长度不能超过20个字符"); } return CheckResult.ok(); } } public class PasswordStrengthProcessor extends AbstractProcessor<UserRequest> { @Override protected CheckResult doProcess(UserRequest data) { if (data.getPassword() == null || data.getPassword().length() < 8) { return CheckResult.fail("密码不能少于8位"); } return CheckResult.ok(); } } public class EmailFormatProcessor extends AbstractProcessor<UserRequest> { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w.-]+@[\\w.-]+\\.\\w+$"); @Override protected CheckResult doProcess(UserRequest data) { if (data.getEmail() != null && !EMAIL_PATTERN.matcher(data.getEmail()).matches()) { return CheckResult.fail("邮箱格式不正确"); } return CheckResult.ok(); } } public class LoginNameUniqueProcessor extends AbstractProcessor<UserRequest> { private final UserService userService; public LoginNameUniqueProcessor(UserService userService) { this.userService = userService; } @Override protected CheckResult doProcess(UserRequest data) { if (userService.countByLoginName(data.getLoginName()) > 0) { return CheckResult.fail("登录名已被占用"); } return CheckResult.ok(); } }

看到没,每个节点只关心一件事。以后要调整密码长度,只改PasswordStrengthProcessor;要加“密码不能包含用户名”,再新增一个节点。节点之间无依赖,改哪个都不影响旁边的工位。

如果规则到了十个以上,你可能会问:难道要写十个类?没错,而且这正是好事。十个类各自承担一个明确职责,总比一个方法里堆十条if清晰。你可以继续补上手机号格式校验、地址长度校验、年龄范围校验、邀请码有效性校验、内部账号白名单校验等节点,每个都是十几行的规模,加起来不复杂。

在IDE里看到类变多时不用慌,你可以按“规则节点”建一个package统一管理,类名本身就是最好的注释。更关键的是,每个类都能独立写单元测试,再也不需要为了测一条邮箱规则,去构造一个完整合法的用户请求了。

3.4 写一个流水线编排器,把节点串起来

节点有了,还差一个组装者。这个角色我通常命名为Pipeline,它只做两件事:往队列里添加节点,按顺序执行:

public class Pipeline<T> { private final List<Processor<T>> processors = new ArrayList<>(); public Pipeline<T> add(Processor<T> processor) { processors.add(processor); return this; } public void execute(T data) { for (Processor<T> processor : processors) { CheckResult result = processor.process(data); if (!result.isSuccess()) { throw new BizException(result.getMessage()); } } } }

这里我选择“失败即抛业务异常”的终止策略。这样调用方写起来最干净,业务代码里不用处理返回值,异常统一交给全局异常处理器转成用户提示。如果你的团队不喜欢异常控制流,也可以让execute返回第一个失败的CheckResult,由调用方判断是否终止,属于风格取舍,后文会展开聊。

为了让调用方代码更简介,我还会给Pipeline加一个静态入口:

public static <T> Pipeline<T> start() { return new Pipeline<>(); }

这样组装规则链的时候就可以这样写:

Pipeline<UserRequest> pipeline = Pipeline.start() .add(new NotNullProcessor()) .add(new MaxLengthProcessor()) .add(new PasswordStrengthProcessor()) .add(new EmailFormatProcessor()) .add(new LoginNameUniqueProcessor(userService));

3.5 业务代码终于只剩一行调用

组装工作可以在Controller或Service的初始化阶段完成,然后保持为一个成员字段。

比如注册接口里:

public void createUser(UserRequest req) { userCreatePipeline.execute(req); userService.save(req); }

你看,原来那十几条if横在那儿,现在只剩一行pipeline.execute(req)。后面产品再来十个新规则,你最多做的事情就是新写十个Processor类,然后在组装处加十行add调用,createUser方法一行不动。

这其实才是责任链模式真正值钱的地方:它改变了“加需求就必须改老代码”的默认节奏。新校验是增量式扩展,不是对已有逻辑的侵入式修改,这也正好呼应了设计原则里的开闭原则。下一个接手的人看这个方法时会很轻松,因为这里已经没有任何复杂分支了。

3.6 别忘了给流水线配上“状态”和“透传”

很多人用责任链只做“校验”,其实远可以更进一步。我在处理step验证时,习惯让CheckResult携带透传数据,让前面的处理节点把中间结果传给后面节点使用。例如:

  • 第一个节点做数据清洗,把手机号里的空格和横杠去掉。
  • 第二个节点做格式校验,验证清洗后的手机号是否符合规范。
  • 第三个节点做重复校验,用清洗后的手机号去数据库查询。

在这种场景里,每个节点的process方法返回的CheckResult里可以带一个processedData字段,Pipeline把上一次处理后的数据作为下一次的输入。这样一来,流水线就从“只拦截”升级成了“清洗+转换+拦截+落库”,价值又大了一截。

4. 流水线不是玩具:规则冲突、顺序意志与边界设计

4.1 节点顺序不只是一个风格问题,它直接影响正确性

用责任链之后,很多人第一反应是“规则随便排”。如果规则之间完全独立,顺序确实无所谓;但真实业务里,规则往往有隐藏依赖。最典型的例子:空值校验必须排在格式校验之前。你把手机号格式规则排在前面,当手机号字段为空时,Pattern.matches(null)直接就抛NullPointerException了,而用户真正应该看到的是“手机号不能为空”。

再比如,登录名的唯一性查询依赖前面的格式规则。如果格式都不合法,就没必要去数据库里查一遍;提前查,不仅白耗一次IO,还可能因为字符串太长导致SQL异常。所以节点顺序从来不是好看不好看的事,它是一条有向链路上的执行顺序,排错了就是线上事故。

我自己的实践是:在组装Pipeline时,把顺序策略写成一个独立的构建方法,并加上注释说明每段顺序的理由。改顺序的人必须同时更新注释,否则review时我会直接打回。

4.2 所谓“流水线中的冲突”:互斥规则和分组规则

“流水线与流水线中的冲突”这个词,放在责任链里指的是什么?我理解有两层意思:

第一层,单个流水线内的节点可能互相冲突。比如同一个接口里既要求“密码不能等于用户名”,又要求“密码长度至少8位”,这两个规则单独看都没问题,合在一起就要求密码必须在兼容用户名的情况下另选一个够长的组合。这种业务歧义必须在组装链路前说清楚,否则节点按顺序执行时,第二个节点的判断条件会把第一个节点的合法结果拦下来。每次遇到这种情况,我会把所有规则里涉及“互斥词”的业务描述单独整理出来,先找产品经理确认,再写进节点注释。

第二层,一个接口里可能跑着不止一条流水线。比如“创建用户”要三个校验,“更新密码”要另外五个校验,如果把它们全部塞进同一条链,链会变得臃肿且难以理解。更合理的方案是定义基础规则节点,然后按业务场景组装成多条Pipeline。每个场景维护自己的链,而不是让一条链承载所有历史规则。这一点,正好能解释为什么责任链模式能优雅地面对规则组合爆炸。

4.3 节点之间的“短路”与“额外透传”怎么设计

标准做法里,只要某个节点返回失败,整条链就终止,后面的节点不再执行。这个“短路”逻辑在Pipeline.execute里就是一行if判断,但有几个细节值得推敲。

一是失败信息要不要包装。我建议在execute里统一包一层“第几个规则不通过”之类的上下文,而不是直接把节点返回的message抛出去。否则线上看到“登录名已被占用”时,你根本不知道这个提示是在哪个流程的哪个位置出来的。二是要不要支持节点自己决定“跳过后续节点”。有些业务里出现了某类特殊账号后,不再继续执行注册相关校验,但普通账号必须全量走完。这时就需要在CheckResult里增加一个skipChain标记,表示“当前请求不再参与后续处理”。这个不是责任链模式标配,但业务里非常常见,属于按需演进。

4.4 责任链、策略模式、规则引擎之间的边界

有同事问过我,责任链和策略模式看起来都是组合多个类,区别在哪?策略模式的核心是“选择一条算法路径”,比如下单时根据用户等级选择不同折扣策略,是互斥的多选一;责任链的核心是“按序经过多个处理器”,节点之间是顺序与短路关系,是可能一个都不选的接力赛。

责任链又和规则引擎有重叠,很多规则引擎(比如Drools)也是把规则做成节点顺序执行。不过规则引擎更偏重“外部化”和“复杂事实推理”,适合几百上千条动态规则;而责任链更适合几十条以内、由开发人员直接维护的中型校验场景。引入规则引擎,意味着多一层学习成本和运行时依赖;用责任链,则是纯代码层面的轻量重构。我通常在规则数量增长到“改一个节点都要小心翼翼”之前,先用责任链撑着,等真的出现大规模动态配置需求,才考虑换成规则引擎。

5. 踩坑实录与排查技巧

5.1 我踩过的坑:空指针、重复执行与顺序颠倒

刚开始用责任链时,我在顺序上翻过车。把邮箱格式校验放在了非空校验前面,结果前端传了空邮箱,链上第一个节点直接抛NPE,返回给用户的提示变成了系统异常,产品差点以为是线上故障。这之后我学乖了:凡是可能为空的字段,所有格式类节点里都必须自己做好空值保护,不能寄希望于链路顺序。

另一个容易踩的坑是一条Pipeline被多个线程共用时,节点里有可变的临时状态。这里必须敲黑板:责任链的Processor节点是无状态的,如果有字段,一定要用final定义且逻辑上不可变。真要存临时结果,应该通过CheckResult透传,而不是塞在节点成员变量里。否则并发请求一多,数据互相串,排查起来非常痛苦。

5.2 失败时抛异常还是返回结果,怎么选更稳

这是个团队风格问题,我没有唯一答案,但说说我的取舍:如果项目里已经有成熟的全局异常体系,用抛异常最省事;如果没有统一拦截,或者调用方需要针对不同失败执行不同兜底逻辑,让execute返回失败结果更稳妥。

复用之前的结果对象时,可以把失败信息封装成FailResult或者Optional 。关键是全团队必须只用一种方案,不要有的节点抛异常有的节点返回结果,那样会把你逼疯。我见过混用的项目,线上日志里一会儿是业务错误码,一会儿是NullPointerException,兜了一圈才搞明白是不同人写了不同风格。

5.3 性能、日志与远程校验的优化心得

一条链十个节点,每个节点纯内存计算,性能开销完全可以忽略。但如果有些节点会查数据库、调外部服务,那就要注意了。我的做法是:给Pipeline的execute统一加个执行耗时统计,每个节点名前后的耗时落日志,线上用日志定位哪一环节慢。如果发现远程校验是瓶颈,优先考虑加缓存,或者把多个远程校验合并成一次批量调用。

另外推荐在开发阶段加一个“链路执行清单”日志:请求进来后,把每个通过的节点和每个失败的节点都打印出来。这张清单在联调和排查问题时简直就是救命的。有一次线上用户反馈“注册时总说登录名占用”,日志里看到前一个节点已经通过了格式检查,到了Unique节点才失败,说明不是格式问题,是数据库里真有了,排查范围瞬间缩小。

5.4 常见问题的排查速查表

现象可能原因处理思路
空字段报正则或NPE格式校验排到了空值校验前面调整链路顺序,或在格式节点内做空值保护
某个新规则没有生效组装Pipeline时漏了add调用检查Pipeline构建方法与测试用例覆盖
同一个链在并发下结果错乱节点里有可变成员变量把节点改成无状态,临时数据通过参数透传
改了某条规则后别的接口也变了两个接口复用了同一个节点对象确认节点语义是否通用,不通用则拆成两个子类
失败时用户看到的提示和预期不符节点message写错或异常被提前捕获统一在execute里包装失败信息,打日志核对

5.5 让流水线变得可测试、可观测

责任链模式最让我满意的一点,就是测试成本被压到了最低。每个节点是独立类,单测时构造一个UserRequest就能测完一个规则;链路测试也很简单,组装一条Pipeline,用异常数据断言抛出的BizException即可。

再分享一个小习惯:我在Pipeline里除了execute,还会额外提供executeWithListeners方法,支持传入一个回调接口,在每个节点执行前后收到通知。开发环境里我用它打印每个节点的耗时和结果,生产环境则可以切到安静模式。这个扩展不到二十行,但能让链路状态时刻可见,排查问题时不再靠猜。

收个尾,聊几句实在的

后来我接手过几个被if-else包围的老项目,凡是用责任链重构过校验逻辑的,改动成本都肉眼可见地降了下来。印象最深的一次,需求方一口气提了六条新的注册限制,我只花了一下午,全部做了新增节点和add调用,原业务方法一行没动。交付的时候既没影响线上,也没踩到老的逻辑,那种感觉就像把一抽屉乱线终于收进了有卡扣的理线器里。

如果你现在正对着一个大方法里密密麻麻的校验判断发愁,我建议别急着推翻全部重写,先从最乱的一个接口试水:抽出三五个规则节点,组一条最小Pipeline,跑通流程后再逐步扩大。责任链这个模式本身不难,难的是迈出第一步时克制住“我来写个更复杂的框架”的冲动。记住,它本质上就是给校验规则建一条流水线,而任何流水线,都从第一个工位开始。

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

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

立即咨询