ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

无类型条件工作流:用Map+SpEL实现动态规则引擎

无类型条件工作流:用Map+SpEL实现动态规则引擎 条件工作流在很多系统中都存在比如订单审批、风控规则链、营销活动编排、数据稽核等场景。刚接触这类需求时最容易走的路子是为每一个条件都定义一套强类型模型订单金额条件写一个类、会员等级条件写一个类、风险分条件再写一个类然后通过接口组合。问题在于业务条件变化非常快今天加一个“金额大于1万且风险分大于70”明天改成“金额大于1万或会员等级为VIP”如果每次改动都要新建 DTO、编译、发版维护成本会迅速失控。本文要聊的“条件工作流的无类型写法”就是针对这类问题的一种轻量方案不定义大量强类型业务类而是用MapString, Object保存业务上下文用表达式字符串描述条件由统一引擎动态求值。读完本文你将理解无类型写法适合什么场景、如何用 Spring ExpressionSpEL实现一个最小可运行的条件工作流以及在实际项目中应该注意哪些坑。本文适合两类读者一类是后端 Java 开发正在设计流程编排或规则判断模块想找一个配置化程度更高、扩展成本更低的方案另一类是刚接触规则引擎分不清“强类型模型”和“表达式配置”的优劣希望有一个清晰落地案例的新手。为了便于理解接下来的示例会先从最基础的 SpEL 求值开始再逐渐构建出完整的工作流引擎。如果你希望在真实项目中使用建议先掌握 Java 集合、Lambda 表达式和 Maven 基础这样读代码会顺很多。1. 什么是条件工作流与无类型写法1.1 条件工作流的工作场景条件工作流本质上就是“一个节点执行完后根据某个条件的真或假选择下一个节点”的流程模型。一个最简单的例子是订单审批订单提交后系统先判断订单金额是否超过1万元如果超过则进入人工复核节点否则进入自动通过节点。这个“金额是否超过1万元”的判断就是条件根据条件结果选择下一个节点就是工作流。在实际业务中条件工作流通常不止一个节点而是由多个判定节点组成的一条链路。比如订单先经过金额校验再经过风控校验然后再进入放行或拒绝环节。每个节点都可能引用订单上下文中的不同字段有的判断数值有的判断字符串有的判断集合是否包含某个元素。这种模型非常适合用规则配置来实现而不是把每一步都写死在 Java 代码里。因为流程和条件往往由业务方频繁调整开发人员如果每次都改代码不仅交付周期长还容易引入回归问题。1.2 类型化写法的痛点用 Java 实现条件判断最直观的写法是设计一个接口比如Condition接口然后为每个条件创建一个实现类。订单金额大于1万就创建一个AmountOverTenThousandCondition内部放着BigDecimal minAmountevaluate(OrderContext context)时从 context 中取出金额再比较。会员等级为 VIP就创建一个VipLevelCondition内部放着String level。这种类型化写法有它的好处编译期类型安全、方法名清晰、IDE 补全友好。但它同样有明显的痛点。第一是类数量膨胀业务条件一多工程里会出现大量只负责一个判断条件的类阅读成本很高。第二是条件变更成本高业务方把阈值从1万改成2万必须改 Java 代码重新编译、测试、发布无法做到动态调整。第三是条件组合困难如果业务方要求“金额大于1万且风险分大于70”和“金额大于1万或风险分大于70”两种组合往往需要为组合关系新增代码而不是在配置层解决。1.3 无类型写法的核心思路无类型写法的核心思路是把业务上下文统一放到一个MapString, Object中把条件表示成一段表达式字符串在运行时交给表达式引擎求值。条件判断不再依赖某一个特定的 Java Bean而是依赖 Map 中的 key。例如#amount 10000表示“上下文中的 amount 字段是否大于等于 10000”#level vip表示“上下文中的 level 字段是否等于字符串 vip”。这种写法的关键不是“完全不写类型”而是“不为业务字段声明固定类型”。在临时业务上下文中数值、字符串、集合都可以通过 Map 的 value 统一承载在表达式边界再做一次转换和校验。这样做的好处很明显业务条件可以存储在数据库或配置中心改动后无需发版条件组合可以用and、or、!等表达式直接完成新增一个条件只需要新增一条配置不需要新增一个类。代价也很直接类型安全需要依赖表达式和数据校验来保障而不再依赖编译器因此对测试和规范的依赖更高。2. 环境准备与版本说明在开始写代码之前先明确一下环境。本文示例使用 Java 8 以上版本因为表达式求值和集合操作要求比较现代的语法。构建工具使用 MavenIDE 使用 IDEA 或 Eclipse 都可以核心代码只需要一个普通 Java 工程不依赖 Spring Boot 容器也能运行。表达式解析部分我们使用 Spring 框架中的spring-expression模块也就是常说的 SpEL。如果你已经有一个 Spring Boot 工程直接引入spring-expression依赖即可版本由 Spring Boot 父工程统一管理。如果你只想在不引入完整 Spring 的情况下跑通示例可以创建一个普通 Maven 工程单独添加 Spring Framework 对应的spring-expression依赖。由于不同项目的 Spring 版本可能不同这里不把一个固定版本号写死建议以你工程中已有的 Spring 或 Spring Boot 版本为准。下面是一个基于 Spring Boot 2.7.x 的 Maven 依赖片段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework/groupId artifactIdspring-expression/artifactId /dependency /dependencies如果你的项目不是 Spring Boot也可以手动指定 Spring Framework 的版本但要注意版本与 JDK 的兼容性。本文示例重点在于 SpEL API 的使用所以版本差异不会影响核心设计。运行示例时直接编写一个包含main方法的类即可不需要启动 Web 容器。3. 从一个最简单的无类型条件开始3.1 SpEL 基础概念SpEL 的全称是 Spring Expression Language它是 Spring 框架中用于表达式求值的一套 API。与 Java 代码不同表达式可以在运行时动态解析因此非常适合做规则配置。一个 SpEL 表达式可以是一个简单的值、一个变量引用、一个方法调用也可以是一段逻辑运算。在我们的条件工作流中主要用到的是变量引用和比较运算。变量的表示方式是#变量名比如#amount表示从上下文中读取名为 amount 的变量。字符串字面量必须使用单引号例如#level vip。逻辑运算支持and、or、!也支持、||、!。数值比较、字符串比较、集合操作都可以在表达式中完成。需要注意的是SpEL 不是 JavaScript字符串不能使用双引号否则会被解析成其他语义。3.2 引入依赖并实现第一个表达式求值打开你的 Maven 工程在pom.xml中确认已经引入spring-expression依赖后创建一个测试类SpelConditionDemo.java。代码如下import org.springframework.expression.Expression; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import java.util.HashMap; import java.util.Map; public class SpelConditionDemo { public static void main(String[] args) { SpelExpressionParser parser new SpelExpressionParser(); // 模拟一个无类型的业务上下文 MapString, Object context new HashMap(); context.put(amount, 12000); context.put(level, vip); // 把 context 中的字段绑定到 SpEL 变量 StandardEvaluationContext evalContext new StandardEvaluationContext(); context.forEach(evalContext::setVariable); // 条件表达式 Expression expression parser.parseExpression(#amount 10000 and #level vip); Boolean result expression.getValue(evalContext, Boolean.class); System.out.println(条件结果 result); } }运行这段代码控制台会输出“条件结果true”。这里我们并没有为订单金额和会员等级定义任何 Java Bean也没有专门编写条件判断类而是直接使用 Map 中的字段完成判断这就是一个典型的无类型写法。3.3 关键代码解释上面的示例中SpelExpressionParser负责把字符串解析成Expression对象。StandardEvaluationContext是表达式的求值上下文我们通过setVariable把 Map 中的 key 变成 SpEL 变量。最后调用getValue(evalContext, Boolean.class)告诉表达式引擎我们希望把结果转换成 Boolean 类型。如果表达式的返回值和目标类型不兼容SpEL 会尝试做类型转换转换失败时抛出异常。这里的条件表达式使用了and也可以改成。为了配置可读性建议在规则配置中统一使用and、or、not这类英文单词这样非 Java 开发也能大致读懂。另一个值得注意的地方是求值结果可能为 null比如表达式只返回字符串或数字时。因此在条件工作流中最好写一个工具方法把结果统一包装成“是否满足条件”避免在分支判断时出现空指针。4. 设计一个无类型的条件工作流4.1 工作流的最小模型有了基础的 SpEL 求值能力下一步我们把条件串成工作流。一个最小可运行的条件工作流模型至少需要三个元素步骤、条件、分支目标。步骤是工作流中的节点条件是这个节点要执行的判断分支目标是在条件为 true 或 false 时分别跳转到的下一个节点。为了让流程最终结束还需要一个动作节点动作节点没有条件只负责执行某个操作比如打印日志、调用外部接口、发送通知等。在无类型写法中步骤对象本身可以非常薄只保留流程属性不包含业务字段。业务字段全部放在 Map 上下文中。下面是一个最小步骤模型import java.util.Map; import java.util.function.Consumer; public class WorkflowStep { /** 节点名称全局唯一 */ private String name; /** SpEL 条件表达式动作节点可以为空 */ private String condition; /** 条件为 true 时跳转的节点 */ private String nextTrue; /** 条件为 false 时跳转的节点 */ private String nextFalse; /** 动作节点的操作条件节点可以为空 */ private ConsumerMapString, Object action; // 省略构造方法和 getter/setter }这个类没有订单、用户、金额等任何业务字段。步骤是通用流程组件真正的业务数据放在运行时的MapString, Object中。4.2 条件求值器封装接下来我们封装一个带缓存的条件求值器。SpEL 表达式每次解析都有一定开销如果流程执行频繁建议把已经解析过的表达式缓存起来。使用ConcurrentHashMap可以保证并发安全。import org.springframework.expression.Expression; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class SpelConditionEvaluator { private final SpelExpressionParser parser new SpelExpressionParser(); private final ConcurrentHashMapString, Expression expressionCache new ConcurrentHashMap(); public boolean evaluate(String expression, MapString, Object context) { Expression exp expressionCache.computeIfAbsent(expression, parser::parseExpression); StandardEvaluationContext evalContext new StandardEvaluationContext(); context.forEach(evalContext::setVariable); return Boolean.TRUE.equals(exp.getValue(evalContext, Boolean.class)); } }这里使用Boolean.TRUE.equals做判空保护。如果一个表达式返回 nullgetValue会返回 null而Boolean.TRUE.equals(null)的结果是 false这样不会抛异常但对于一个条件节点来说null 视为不满足条件是比较合理的默认行为。当然如果你希望表达式结果异常时直接中止流程也可以改成更严格的判断。4.3 条件工作流引擎有了步骤和求值器我们可以写一个简单的工作流引擎。引擎通过步骤名称维护一张 Map启动时从起始步骤开始不断取当前节点的 condition 求值然后根据结果跳转到下一个节点。如果是动作节点就执行 action 并结束。import java.util.HashMap; import java.util.Map; public class ConditionWorkflow { private final MapString, WorkflowStep steps new HashMap(); private final SpelConditionEvaluator evaluator new SpelConditionEvaluator(); public void addStep(WorkflowStep step) { steps.put(step.getName(), step); } public void run(String startStepName, MapString, Object context) { WorkflowStep current steps.get(startStepName); if (current null) { throw new IllegalArgumentException(起始步骤不存在 startStepName); } while (current ! null) { // 动作节点没有条件执行后直接结束流程 if (current.getCondition() null || current.getCondition().isEmpty()) { if (current.getAction() ! null) { current.getAction().accept(context); } return; } boolean result evaluator.evaluate(current.getCondition(), context); String nextName result ? current.getNextTrue() : current.getNextFalse(); System.out.println(节点[ current.getName() ] 条件结果 result 下一节点 nextName); current steps.get(nextName); if (current null) { System.out.println(警告节点[ nextName ]未注册流程结束); } } } }上面的实现保留了基本的容错如果某个分支指向的节点在 Map 中不存在会打印警告并结束。在实际项目中更推荐在流程启动前做一次完整校验把可能出现的“悬空节点”直接暴露在配置阶段。4.4 完整示例订单自动审批流程下面我们用一个订单流程来验证引擎。流程如下金额校验如果amount 10000跳转人工复核否则跳转风险校验风险校验如果riskScore 70跳转人工复核否则跳转自动通过人工复核打印人工复核日志结束自动通过打印自动通过日志结束。注意这里的人工复核和自动通过节点都是动作节点没有 condition需要在构建时设置 action。import java.util.HashMap; import java.util.Map; public class WorkflowDemo { public static void main(String[] args) { ConditionWorkflow workflow new ConditionWorkflow(); // 金额校验节点 workflow.addStep(new WorkflowStep(AMOUNT_CHECK, #amount 10000, MANUAL_REVIEW, RISK_CHECK, null)); // 风险校验节点 workflow.addStep(new WorkflowStep(RISK_CHECK, #riskScore 70, MANUAL_REVIEW, AUTO_PASS, null)); // 人工复核动作节点 workflow.addStep(new WorkflowStep(MANUAL_REVIEW, null, null, null, ctx - System.out.println(订单进入人工复核订单号 ctx.get(orderId)))); // 自动通过动作节点 workflow.addStep(new WorkflowStep(AUTO_PASS, null, null, null, ctx - System.out.println(订单自动通过订单号 ctx.get(orderId)))); // 场景一金额大直接人工复核 MapString, Object order1 new HashMap(); order1.put(orderId, 1001); order1.put(amount, 12000); order1.put(riskScore, 30); System.out.println( 订单1001 ); workflow.run(AMOUNT_CHECK, order1); // 场景二金额不大但风险分高 MapString, Object order2 new HashMap(); order2.put(orderId, 1002); order2.put(amount, 5000); order2.put(riskScore, 80); System.out.println( 订单1002 ); workflow.run(AMOUNT_CHECK, order2); // 场景三金额和风险分都低 MapString, Object order3 new HashMap(); order3.put(orderId, 1003); order3.put(amount, 5000); order3.put(riskScore, 40); System.out.println( 订单1003 ); workflow.run(AMOUNT_CHECK, order3); } }运行后的输出大致如下 订单1001 节点[AMOUNT_CHECK] 条件结果true下一节点MANUAL_REVIEW 订单进入人工复核订单号1001 订单1002 节点[AMOUNT_CHECK] 条件结果false下一节点RISK_CHECK 节点[RISK_CHECK] 条件结果true下一节点MANUAL_REVIEW 订单进入人工复核订单号1002 订单1003 节点[AMOUNT_CHECK] 条件结果false下一节点RISK_CHECK 节点[RISK_CHECK] 条件结果false下一节点AUTO_PASS 订单自动通过订单号1003因为amount和riskScore都是通过 Map 传入所以这个流程天然支持添加新的参数。如果以后需要增加一个“用户等级校验”只需要新增一个节点并在上下文 Map 中放入level字段不需要改动现有代码的结构。4.5 如何把规则配置化上面的示例中步骤是用 Java 代码构建的。如果想让业务方能够动态调整流程可以把步骤转换成 JSON 或 YAML落在配置中心或数据库中。下面是一段对应的 JSON 配置为了方便阅读只展示核心结构[ { name: AMOUNT_CHECK, condition: #amount 10000, nextTrue: MANUAL_REVIEW, nextFalse: RISK_CHECK }, { name: RISK_CHECK, condition: #riskScore 70, nextTrue: MANUAL_REVIEW, nextFalse: AUTO_PASS }, { name: MANUAL_REVIEW, action: manualReview }, { name: AUTO_PASS, action: autoPass } ]在启动时通过 Jackson 或 Gson 把 JSON 反序列化为WorkflowStep对象列表再注册到ConditionWorkflow中。这样流程调整就完全不需要改代码了。需要注意action在 JSON 中只能用字符串表示你需要维护一个 action 名称到ConsumerMapString, Object的映射在注册步骤时把字符串解析成真正的函数对象。5. 无类型写法和类型化写法对比无类型写法看起来省去了很多类但它并不是万能方案。为了帮助你做技术选型这里从几个维度做一个对比。对比维度类型化写法无类型写法Map SpEL代码可读性类名和方法名可以表达业务语义表达式语义依赖命名规范和注释编译期检查强字段类型错误在编译期发现弱字段拼写错误在运行时暴露条件变更成本需要改代码、编译、发版修改配置即可适合快速迭代跨系统复用通常需要定义公共依赖模型规则可以存储为 JSON/文本跨系统复用方便安全性相对可控代码调用明确需要防范表达式注入复杂逻辑实现适合复杂算法可读性好表达式过长后难以维护性能直接方法调用性能高表达式解析和求值有一定开销测试难度单元测试直观需要覆盖表达式和上下文组合从表格可以看出无类型写法更适合条件简单、变化频繁、需要动态配置的场景类型化写法更适合条件复杂、对性能要求高、需要强类型约束的场景。在实际工程中两者也可以结合引擎层用类型化 Java 模型负责流程调度业务条件层用无类型表达式承载高频变化。6. 常见问题与排查思路6.1 常见问题对照表问题现象常见原因解决思路表达式报错EL1007E 变量不存在上下文 Map 中没有放入对应的 key检查 Map 的 key 是否与表达式变量名一致注意大小写字符串比较结果总是 falseSpEL 字符串字面量用了双引号字符串必须使用单引号例如vip数值比较时抛出类型转换异常上下文中存的是字符串数值表达式期望数字在放入上下文前统一转成 BigDecimal、Double 等数值类型结果返回 null表达式没有产生布尔值使用Boolean.TRUE.equals包装结果流程突然结束nextTrue/nextFalse 指向的节点没有注册启动时校验所有分支目标缺失则报错性能较差每次执行都解析表达式使用 ConcurrentHashMap 缓存 Expression表达式执行到外部代码SpEL 默认能力过于强大严格控制表达式来源或使用受限表达式引擎6.2 调试方法遇到表达式求值不明白的问题可以在代码中临时打印表达式和求值上下文。一种简单的方式是把 Map 的 keySet 打印出来确认变量名拼写。另一种方式是在表达式求值处捕获异常打印表达式原文、上下文 key 和异常栈。日志模板可以参考下面的代码try { return evaluator.evaluate(condition, context); } catch (Exception e) { throw new RuntimeException(条件表达式执行失败condition condition , context context, e); }这样即使规则配错日志里也能直接看到是哪条表达式、数据长什么样排查效率会高很多。7. 最佳实践与工程建议无类型写法最大的风险来自“失控”字段拼写没有编译期保护表达式来源不受控制规则版本难以追溯。因此在实际项目中不能只图一时方便建议从以下几个方面做好工程约束。7.1 上下文字段统一命名所有进入MapString, Object的字段命名要遵循统一规范。比如金额统一叫amount风险分统一叫riskScore用户级别统一叫userLevel。不要在金额校验里叫totalAmount在另一个节点里又叫amount。字段名一致性是规则配置可维护性的基础。可以在项目里维护一份上下文字段字典把每个字段的类型、含义、来源写清楚供配置人员和维护人员对照。7.2 表达式必须做白名单控制SpEL 功能很强大默认支持调用静态方法、创建对象等能力。如果规则字符串来自不可信的用户输入攻击者可能利用 SpEL 执行任意代码这是非常严重的安全风险。在生产环境中条件表达式应该只来自可信配置源例如配置中心、数据库后台管理页面并且要有严格的编辑权限控制。如果必须开放给用户输入不要使用原生 SpEL应该先做一层表达式白名单校验或者改用专用规则引擎。7.3 规则配置要带版本和灰度既然规则存储在配置中心或数据库就需要像代码一样管理版本。业务方修改条件后如果出现问题能快速回滚到上一个版本。推荐在规则表中加入version字段在发布时对规则版本进行记录。大范围修改规则时可以先灰度一部分订单观察结果后再全量发布。这个做法和实施成本不高但能明显降低配置变更带来的线上风险。7.4 每个节点都要考虑空值无类型上下文中的字段不一定都存在。比如新增规则后老的数据没有新字段表达式就会抛变量不存在异常。建议在上下文放入阶段统一赋予默认值或者确保表达式开始前做空值判断。一个比较简单的兜底方式是在求值前扫描所有上下文中的 key为缺失的关键字段设置默认值。但更推荐的做法是规则表达式尽量写成空值安全的格式例如#amount ! null and #amount 10000。7.5 做好流程完整性校验工作流步骤可以动态配置后悬空节点和死循环的问题会变得常见。启动流程前应该遍历所有步骤检查每个分支目标是否都能在步骤表中找到。如果发现目标节点不存在直接启动失败。对于动作节点也要确保配置了对应的 action 处理器。过程校验可以在规则加载阶段完成写成自动化测试更好。7.6 保持代码与配置的可追踪性虽然无类型写法允许我们把规则放在配置中但这不等于代码库和配置库完全割裂。建议把常用的、核心的业务规则在代码仓库中也保留一份“默认配置”方便开发同学做代码审查和版本对比。对规则变更记录日志尤其是谁在什么时间改了什么内容审计记录是风控类业务的基本要求。日志至少包括订单号、上下文关键字段、表达式结果和最终流向。7.7 不要把所有逻辑都塞进表达式无类型写法的优势是轻量而不是让表达式承载所有业务逻辑。如果某个规则表达式超过三行或者出现了大量的嵌套括号和类型转换就应该考虑拆分成多个节点或者改回类型化写法。表达式是给人看的如果写出来后连维护者都读不懂那么再灵活也会变成负担。建议在团队中约定一个表达式长度上限超过后需要通过代码审查。8. 总结与学习路线本文围绕条件工作流这一场景介绍了无类型写法的设计思路和实现方式。我们从 SpEL 基础求值开始逐步实现了步骤模型、条件求值器、工作流引擎并用一个订单审批流程演示了完整链路。通过 Map 承载业务上下文、表达式字符串描述条件我们可以把条件工作流的调整从“改代码发版”变成“改配置生效”在高频变化的业务场景下能明显降低维护成本。和所有技术方案一样无类型写法不是银弹。它牺牲了一部分编译期安全换来了更灵活的配置能力。因此使用时要特别注意表达式来源、空值处理、版本管理和流程校验这些工程细节才是线上稳定的关键。如果这篇文章对你有帮助可以收藏备用。接下来你可以继续学习三块内容第一块是 Spring Expression 的完整语法比如集合选择、方法调用和类型转换第二块是专门的 Java 规则引擎比如 Drools、Aviator、QLExpress理解它们和无类型写法的异同第三块是流程编排框架例如 Camunda、Flowable它们把“条件工作流”提升到了可视化流程定义的高度。建议你先把本文的最小引擎跑通再逐步往配置中心、规则校验、审计日志方向扩展自然就能形成一套适合自己的实战方案。
返回列表