资讯动态

策略模式+工厂模式:单接口搞定多审核节点,告别if-else

发布时间:2026/10/16 10:51:49 来源:尧图企业网站定制
审核流程这玩意儿刚接手的时候觉得就一个状态字段的事真等业务方说我们要加一个审核节点的时候才发现原来的代码像一锅粥——一个接口方法里塞了十来个if (type ...)每加一个节点就要动老代码线上出问题还得从一堆分支里翻炸的是哪一段。我之前在某个中台项目里做统一审核服务最初的需求其实很简单所有审核动作都走同一个入口但内部要根据业务类型和节点编号路由到不同的审核处理器——有的节点要做内容安全校验有的节点要做金额复核有的节点是人工审批以后回写状态。业务方明确说了后续还会有新的审核节点陆续接入。这种入口固定、执行逻辑经常变的场景用策略模式加工厂模式做一个单接口适配多审核节点的骨架是最稳、扩展成本最低的做法。这篇文章把当时的完整设计、核心代码和踩过的坑整理出来适合正在做审批流、审核中台、风控回调这类系统的后端开发参考也适合觉得if-else越写越难受、想找重构思路的人。1. 审核系统的多节点困境从一坨if-else说起1.1 一个非常典型的业务场景先还原一下当时的处境。系统里所有审核动作都从前端一个按钮触发请求打到后端同一个接口带上业务单号、当前节点编号、审核动作通过/驳回、审核意见这些参数。后端要根据现在走到哪个节点来决定接下来执行什么逻辑。当时接进来的审核节点大概有这么几个内容初审节点校验提交材料是否齐全调用外部风控服务判断有没有违规风险结果回写业务单。金额复核节点把订单金额、已用优惠券、账户可抵扣余额重新算一遍校验财务数据一致性。负责人终审节点金额超过阈值时需要负责人审批审批通过后落库并触发生成后续单据。第一版实现大家都会这样写在 Service 方法里按节点编号堆分支。if (CONTENT_SAFETY.equals(nodeCode)) { // 内容安全校验一堆逻辑 } else if (AMOUNT_REVIEW.equals(nodeCode)) { // 金额核对一堆逻辑 } else if (BOSS_REVIEW.equals(nodeCode)) { // 负责人审批逻辑 }前两个节点确实跑得挺顺。等到第三个节点上线方法开始变长。等第四、第五个节点提需求时整个方法已经四百多行光看一遍就觉得头疼——因为每个节点内还有自己的前置校验、调用服务和回写逻辑全部挤在同一个方法里。1.2 分支堆叠方案的三个致命问题把审核节点用if-else串起来问题不是代码难看这么简单而是会实打实影响交付质量和排期。我把踩过的问题总结成一张表问题具体表现扩展代价分支爆炸每新增一个节点都要打开核心 Service 继续往后面加 else if每次都在改老代码回归风险高依赖混乱风控客户端、财务核对服务、审批回调服务全部注入到一个类类加载时初始化一堆无关 Bean改一个节点影响整条链路测试困难单测要走通完整方法要为所有依赖做 mockmock 逻辑比业务逻辑还长跑一次全量回归非常痛苦更根本的问题是它违反了开闭原则——系统对扩展不是开放的每次扩展都必须修改已经稳定运行的代码。审核类业务恰恰是节点只增不减的典型领域线上跑个半年大概率会有新节点冒出来到时候谁接手谁头疼。我当时的结论很明确要做一个入口固定、实现隔离的骨架新节点来了只加新类不碰老逻辑。这就把问题指向了策略模式和工厂模式的组合。2. 策略工厂组合拳的基本盘谁负责路由谁负责干活2.1 策略模式在这里解决什么问题策略模式解决的是同一种动作、多种实现方案的封装问题。放到审核场景里所有审核节点本质上都在做同一件事拿到审核上下文执行判断返回审核结果。只是每个节点的具体判断规则不同。用一句话概括把每个审核节点的核心逻辑封装成独立的策略类类与类之间互不感知调用方只面向统一接口编程。这跟餐厅点菜很像。菜单上的菜都是可点的菜后厨每个菜有单独的厨师负责客人不会跑到后厨去指挥厨师只需要跟服务员说要什么菜就行。新增一道菜只需要后厨多一个负责的人菜单多一行字前厅流程完全不动。把策略接口定义好让初审金额复核终审变成三个互不依赖的类这是第一步。2.2 工厂模式在这里解决什么问题策略模式只解决了每个节点怎么做但还没有解决来一个请求系统怎么知道该用哪个策略。如果把这个判断逻辑继续散落在 Controller 或 Service 里你只是把分支从 Service 挪了个地方而已。工厂模式解决的就是谁来做选择。工厂内部维护一张节点编号到策略实例的映射表根据请求里的节点编号返回对应的策略对象。调用方不需要知道策略的构造函数、依赖哪些组件、是不是单例只需要跟工厂要一个能干活的东西。还是拿餐厅举例工厂就是前厅的领班客人点完菜领班根据菜名分配到对应的厨师组客人不需要认识后厨任何一个人。2.3 为什么必须两个一起用而不是二选一只上策略模式结果是客户端代码依然要写一堆判断来挑选策略判断逻辑本身还是散的只是把if-else里的业务代码换成了new各种策略对象。只上工厂模式把策略实例的创建集中到了工厂但如果工厂内部还是用一堆if-else来选择创建哪个对象等于把 Service 里的分支原封不动搬到了工厂里问题没解决只是换了个房间。策略模式和工厂模式是天然的搭档策略模式负责做事——每个节点一个类逻辑互不干扰。工厂模式负责找人——按请求参数返回正确的策略实例。只有两个一起用才能同时把执行逻辑和选择逻辑隔离出去。调用入口只剩下三件事构建上下文、问工厂要策略、执行策略。3. 单接口适配多审核节点的实战落地接口、工厂、入口三段式拆解3.1 先定策略接口别急着写实现动手写代码的第一步是把所有审核节点抽象出一个共同接口。这个接口我建议至少包含三样东西节点标识、可选匹配条件、执行方法。public interface AuditNodeStrategy { // 当前策略支持的节点编号 String supportNode(); // 二次匹配同一个节点在特殊业务场景下可能要走不同实现 default boolean match(AuditContext ctx) { return true; } // 执行审核逻辑 AuditResult execute(AuditContext ctx); }supportNode()是工厂路由的主键返回的是节点编号字符串比如CONTENT_SAFETY。match()是给同节点多实现留的扩展位比如同为BOSS_REVIEW普通订单和加急订单可能走不同审批策略这时候除了节点编号还要额外判断上下文里的业务标记。execute()是策略的核心处理逻辑入参统一是AuditContext出参统一是AuditResult。AuditContext我建议做成一个相对通用的载体里面放业务单号、节点编号、操作人、审核动作、审核意见、业务扩展字段这样新策略加入时不需要频繁修改上下文类的结构。AuditResult包含是否通过、结果码、提示信息、下一节点编号方便后续做节点链式流转。3.2 两个具体审核节点的实现长什么样接口定完实现类就简单了。每个节点独立一个类只关心自己的规则。比如内容初审节点Component public class ContentSafetyAuditNode implements AuditNodeStrategy { Override public String supportNode() { return CONTENT_SAFETY; } Override public AuditResult execute(AuditContext ctx) { // 1. 校验提交材料完整性 contentValidator.validate(ctx.getPayload()); // 2. 调用风控服务判断内容风险 RiskCheckResult risk riskControlService.check( ctx.getBizId(), ctx.getPayload()); // 3. 记录审核流水 auditLogService.record(ctx, risk.isPassed()); // 4. 返回结果并给出下一节点 if (!risk.isPassed()) { return AuditResult.reject(RISK_DETECTED, 内容存在风险); } return AuditResult.passWithNext(AMOUNT_REVIEW); } }金额复核节点的结构也类似只是内部逻辑换成了财务核对。节点之间是完全解耦的内容初审节点不知道下次要走到哪它只负责告诉调用方我这边通过了建议去金额复核。至于到底要不要继续往下走那是编排器的事后面会讲到。3.3 工厂注册表从if-else到Map的一步替换策略类写好了工厂的核心就是一张映射表。第一版不强依赖 Spring 容器的话手动注册也完全够用public class AuditNodeFactory { private final MapString, AuditNodeStrategy strategyMap new HashMap(); private final AuditNodeStrategy defaultStrategy new DefaultAuditNodeStrategy(); public AuditNodeFactory() { ListAuditNodeStrategy strategies List.of( new ContentSafetyAuditNode(), new AmountReviewAuditNode(), new BossAuditNode() ); for (AuditNodeStrategy strategy : strategies) { strategyMap.put(strategy.supportNode(), strategy); } } public AuditNodeStrategy getStrategy(String nodeCode) { return strategyMap.getOrDefault(nodeCode, defaultStrategy); } }注意最后一行我用了getOrDefault而不是直接get。工厂要处理的不仅是能匹配到的节点还包括没匹配到的节点一个稳健的工厂必须对未知节点给出兜底结果而不是抛空指针让调用方猜原因。3.4 单接口入口Controller只做三件事入口层保持单薄Controller 的核心逻辑收敛成三行——构建上下文、取策略、执行RestController RequestMapping(/api/audit) public class AuditController { private final AuditNodeFactory auditNodeFactory; public AuditController(AuditNodeFactory auditNodeFactory) { this.auditNodeFactory auditNodeFactory; } PostMapping(/node/execute) public AuditResult execute(Valid RequestBody AuditRequest request) { AuditContext ctx AuditContextBuilder.build(request); AuditNodeStrategy strategy auditNodeFactory.getStrategy(request.getNodeCode()); return strategy.execute(ctx); } }到这里单接口适配多审核节点的雏形就出来了。业务方要新增一个节点时核心 Service、Controller、工厂代码一行都不用改只需要新增一个策略实现类并注册进工厂这就是开闭原则落地后的真实手感。4. 工程化进阶Spring容器接住策略、节点链式编排与兜底设计4.1 手工注册策略的时代该结束了上一节的工厂里手写了一个策略列表节点少的时候没问题节点到了十几个以后忘记把新策略放进注册列表就会成为一个新的低级事故来源。用 Spring 容器后可以让容器自动收集所有策略 Bean工厂只做一次转发。这是目前最推荐的写法Service public class AuditNodeFactory { private final MapString, AuditNodeStrategy strategyMap; public AuditNodeFactory(ListAuditNodeStrategy strategies) { strategyMap strategies.stream() .collect(Collectors.toMap( AuditNodeStrategy::supportNode, strategy - strategy, (existing, replacement) - replacement )); } public AuditNodeStrategy getStrategy(String nodeCode) { return strategyMap.getOrDefault( nodeCode, new DefaultAuditNodeStrategy()); } }新增节点时只需要写一个实现类并打上Component注解Spring 会自动把它收进ListAuditNodeStrategy工厂自然就能路由到它。toMap的第三个参数是合并函数用来处理两个策略返回同一个节点编号的冲突情况——这种情况应该越早暴露越好我建议在这个位置打印一条启动警告日志而不是静默覆盖。Spring 里还有一种更省事的注入方式直接Autowired MapString, AuditNodeStrategy strategyMapMap 的 key 是 Bean 名称。这个方案的缺点是路由键和类名绑定一旦有人改了类名或者Component的名称路由就悄悄失效了。我还是更喜欢在策略接口里显式声明supportNode()让路由规则跟着代码走重构时编译器能帮忙把问题揪出来。4.2 从一个节点到一条审核链路编排器和策略的分工单接口适配多审核节点除了一次请求只路由一个节点之外更常见的场景是一次请求要流转完整条审核链路——初审通过后自动进入金额复核金额复核通过后再进入终审。很多人在这一步会纠结要不要在策略内部调用下一个策略千万别。策略之间互相调用很快又会出现耦合。正确做法是单独抽一个编排器让策略只回答两件事当前节点是否通过建议的下一节点是谁。链路怎么走由编排器和上下文状态一起决定。Component public class AuditFlowDispatcher { private final AuditNodeFactory auditNodeFactory; public AuditFlowDispatcher(AuditNodeFactory auditNodeFactory) { this.auditNodeFactory auditNodeFactory; } public AuditResult dispatch(AuditContext ctx) { while (ctx.hasNextNode()) { String currentNodeCode ctx.currentNode(); AuditNodeStrategy strategy auditNodeFactory.getStrategy(currentNodeCode); AuditResult result strategy.execute(ctx); if (result.isRejected()) { return result; } ctx.moveToNext(result.getNextNode()); } return AuditResult.finish(); } }这里有个容易被忽略的细节审核流程如果跨了很长时间比如人工终审要等负责人第二天才点通过节点的流转状态不能只存在内存里一定要落库。上下文里持有的应该是从数据库恢复出来的流程记录而不是一个只活在请求线程里的临时对象。把编排逻辑放在单独组件里好处是策略类始终只关心自己的业务规则将来就算要引入更复杂的并行审核、会签审核也只是在编排器层面扩展策略实现不需要跟着动。4.3 兜底策略和异常边界工厂路由不到策略时我前面已经说了要走兜底策略。这个兜底不能只是随便返回一个失败结果它还需要把问题暴露出来方便监控发现Component public class DefaultAuditNodeStrategy implements AuditNodeStrategy { Override public String supportNode() { return __DEFAULT__; } Override public AuditResult execute(AuditContext ctx) { log.warn(未注册的审核节点: {}, 业务单号: {}, ctx.getNodeCode(), ctx.getBizId()); return AuditResult.reject(UNSUPPORTED_NODE, 该审核节点尚未注册); } }除了兜底还要在入口层加上全局异常捕获。策略执行过程中可能抛出各种运行时异常比如外部风控服务超时、数据库连接断开。如果这些异常直接抛给前端前端无法区分业务上不允许和系统内部故障这两种完全不同的情况。我的做法是统一转成标准结果结构并让监控系统从日志里抓异常堆栈告警。5. 真实踩过的坑单例状态污染、校验散落和空指针兜底5.1 策略实例里的共享状态一个让我排查了三小时的bug这是我在这套架构里踩得最狠的一个坑。Spring 管理的 Bean 默认是单例也就是所有请求线程共用同一个策略实例。有人写代码的时候图省事把当前请求的上下文放到了策略的成员变量里Component public class BossAuditNode implements AuditNodeStrategy { private AuditContext currentCtx; // 反面教材共享可变状态 Override public AuditResult execute(AuditContext ctx) { this.currentCtx ctx; // 线程A写进去 // 执行中间某一步时线程B覆盖了currentCtx // 后续读取到的数据就是别人的单子 } }并发一上来不同请求互相覆盖字段审核结果张冠李戴。这种问题非常难排查因为单测不并发是测不出来的只有线上流量大了才会偶发出现。经验教训就一条策略类必须是无状态的所有数据都通过方法参数传递。如果实在有每个请求独占一份数据的需求用ThreadLocal或者把 Bean 的作用域改成RequestScope但最稳妥的做法还是老老实实传参数。5.2 参数校验和上下文构建别散落到每个策略里第二个容易踩的坑是校验逻辑越写越散。一开始每个策略为了省事都自己校验参数的合法性结果同一个业务单号不存在的错误三个策略返回了三种不同的错误码前端对接的人快疯了。后来我把校验分成两层校验层职责放置位置基础校验节点编号非空、业务单号存在、操作人有权限Controller 前的统一校验器业务校验金额是否超过阈值、材料是否齐全各策略内部基础校验放在AuditContextBuilder里统一执行只有基础校验通过才允许进入工厂路由。策略内部只做业务规则判断这样每个策略的代码量都很薄错误码也统一可控。5.3 拿不到策略的时候别直接NPE接口刚上线那阵子生产环境出现过一次空指针。原因是新版配置里新增了一个节点编号但策略类还没部署上去请求进来后工厂返回了 nullController 调用strategy.execute()直接炸。从那以后我定了条规矩工厂的getStrategy永远不许返回 null要么返回匹配到的策略要么返回兜底策略。宁可让业务方看到节点未注册的明确错误也不能让监控里出现无头无尾的空指针堆栈。单元测试也要把这条规矩固化下来Test void factoryShouldRouteToTargetStrategy() { AuditNodeFactory factory new AuditNodeFactory( List.of(new ContentSafetyAuditNode())); assertTrue(factory.getStrategy(CONTENT_SAFETY) instanceof ContentSafetyAuditNode); } Test void unknownNodeShouldFallbackToDefault() { AuditNodeFactory factory new AuditNodeFactory(List.of()); assertNotNull(factory.getStrategy(NOT_EXIST_NODE)); assertEquals(UNSUPPORTED_NODE, factory.getStrategy(NOT_EXIST_NODE).execute(buildCtx()).getCode()); }6. 这套方案的边界什么时候不该硬套策略加工厂解决的是按条件找实现这一类问题但它不是万能的。我后来在项目复盘里专门列过它的适用边界。如果系统里只有两三个固定的审核节点而且没有明确的扩展计划那直接用if-else反而更简单硬套模式只会让结构变复杂收益为零。模式是为了应对变化不是为了好看。如果审核节点之间的流转关系非常复杂比如存在并行会签、驳回重新流转、节点之间有回退依赖那光是策略加工厂就不够了需要引入状态机或者更完整的流程引擎来做编排。策略加工厂解决的是定位到某个节点的处理器它不负责管理整个流程的生命周期。如果各节点需要的请求参数差异极大一个通用上下文要想承载全部数据很可能会变成塞满各种字段的万能对象。这种情况下我建议上下文内部用一个灵活的数据载体或者按节点类型分别定义自己的输入模型工厂路由后再做一次数据适配。说到底单接口的核心价值是统一入口、分布式实现而不是一个 DTO 扛下所有。最后分享一个实际操作中的体会这套骨架搭完以后后面每一次新增审核节点流程基本就是三步——写一个策略类、打上组件注解、在流程配置表里加一条记录。主流程代码已经好几个月没动过了业务方再来提新节点需求的时候排期基本都能控制在半天以内。做审核类的后端服务追求的就是这种加需求不慌的感觉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑