最近接到一个很典型的业务需求:订单金额超过 1000 元的走总监审批,低于 1000 元的自动通过。放在两三年前,我会直接在 Java 代码里写一个if (order.getAmount() > 1000),然后调用审批流程接口。但现在再这么写,问题就大了。
原因是:这类条件判断不是只出现一次。今天加一个“会员等级为 VIP 且下单次数大于 5 次走专属客服”,明天加一个“风控评分小于 60 走人工复核”,后天还要调整 1000 这个阈值。每一次改动都要改代码、走发布流程、运维上线,流程和需求互相卡脖子,业务方等不起,后端也烦。
条件工作流的无类型写法,解决的就是这个痛点:把“条件判断逻辑”从代码里抽出来,变成一份可配置、可热更新、可审计的数据。我们用 JSON 描述条件,用表达式引擎动态求值,业务主干代码不用再跟着规则频繁变动。这是本系列的第 140 个主题,今天我会把这种写法的原理、实现和坑一次性讲清楚。
一个比较关键的理解是:无类型写法的核心价值,不是省去类型声明那么表面,而是把“条件”从代码资产变成了数据资产。但代价也很明显——编译期类型检查没了,跨系统传参更容易出错,表达式执行还有安全风险。所以这篇文章不只要给你能跑的代码,还会把该补的工程手段一起说清楚。
1. 这篇文章真正要解决的问题
先说一个真实的开发场景。你负责一个审批平台,上游业务方提交订单后,系统要根据订单属性决定审批路径。最朴素的做法是写一个 Service 方法:
public String decideApprovalPath(Order order) { if (order.getAmount() > 1000) { return "director_approval"; } if ("VIP".equals(order.getUserLevel()) && order.getOrderCount() > 5) { return "vip_service_approval"; } return "auto_pass"; }这段代码本身没有问题,问题出在它进入了业务频繁变动的链路里。业务方的需求是“这个分支条件能不能由我们自己配”,而后端的痛苦是“每条规则都意味着一次代码变更”。随着规则越来越多,这个方法会变成一长串 if/else,可读性下降,测试用例膨胀,版本发布频率也被迫提高。
条件工作流的无类型写法,就是为了把这类“条件分支”从硬编码中解放出来。它的本质是:条件是一段字符串,数据是一张 Map,执行引擎负责把两者结合起来求值。生产环境里即使规则变化,也不用重新编译 Java 代码,只要更新配置或数据库里的规则记录。
这篇文章适合以下读者:
- 正在做审批流、工单流、规则引擎的后端开发;
- 想把业务规则从代码中剥离、交给配置中心管理的团队;
- 遇到“规则天天变、发版跟不上”问题的同学;
- 想学习表达式引擎、动态条件判断写法的入门者。
读完你会得到三样东西:一套可直接运行的极简条件工作流示例;一份无类型写法的设计模型;一份表达式的安全与性能排查清单。
2. 基础概念与核心原理
2.1 什么是条件工作流
工作流里最常见的节点有三种:开始节点、审批任务节点、条件分支节点。条件分支节点就是根据上下文数据判断“下一步走哪条路”。比如订单金额大走总监审批,金额小自动通过,这就是一个最简条件工作流。
在流程引擎里,条件节点通常有一个表达式属性,引擎根据表达式求值结果决定流转到哪个后续节点。表达式越灵活,流程的可编排性越强;表达式越死板,流程就越容易变成写死的状态机。
2.2 什么是无类型写法
无类型写法,指的是在定义和执行条件时,不预先声明字段的数据类型,数据统一用 Map、JSON 这类结构承载,表达式的参数类型在运行期动态匹配。例如:
{ "field": "amount", "operator": ">", "value": 1000 }这段配置里的amount不需要在 Java 类里定义对应字段,value也不需要在编译期指定是 Integer、Long 还是 BigDecimal。运行时把订单数据转成Map<String, Object>,取amount的值与value比较即可。
对比强类型写法,同样是“金额大于 1000”这个条件:
| 对比维度 | 强类型写法 | 无类型写法 |
|---|---|---|
| 示例 | order.getAmount() > 1000 | "amount > 1000" |
| 类型检查 | 编译期检查,类型安全 | 运行期动态匹配 |
| 变更成本 | 改代码、重新编译、发版 | 改配置,可立即生效 |
| 可读性 | 需要懂 Java 语法 | 业务人员也能看懂表达式 |
| 可测试性 | 单元测试覆盖 | 需要表达式测试用例和规则校验 |
| 安全性 | 无表达式注入问题 | 需防范表达式注入与恶意脚本 |
| 性能 | 方法调用开销低 | 表达式解析有额外开销,需缓存 |
这里要澄清一个容易误解的点:无类型不代表“没有类型”,而是“类型在运行期才确定”。amount > 1000这个表达式,如果amount传进来的是字符串"abc",求值时会报类型错误。所以无类型写法必须配套“入参校验”和“表达式语法校验”,否则问题会被推迟到线上才暴露。
2.3 无类型写法的三个典型场景
第一类是规则引擎和条件工作流,也就是本文的主场景。第二类是 MyBatis 动态 SQL 中的<if test="...">,它在 Mapper XML 里写的status != null并不会声明 status 的类型,底层也是从参数对象或 Map 里动态取值判断。第三类是 JavaScript 这类动态类型语言中的箭头函数写法,比如list.filter(item => item.amount > 1000),参数 item 不需要声明类型。
这三类场景的共同点是一致的:判断条件的字段不预先绑定强类型,而是在运行时从上下文动态获取。这正是无类型写法在不同技术栈里的不同面貌。
3. 环境准备与前置条件
下面示例采用 Java 8 + Maven 工程,表达式引擎使用 Aviator。Aviator 是一个轻量级 Java 表达式引擎,对 Map 类型支持很好,表达式写法接近自然语言,适合做无类型条件求值。版本请以实际项目依赖为准,本文重点演示通用思路,不要直接拷贝一个不确定的版本号用于生产。
在 pom.xml 中引入依赖:
<dependency> <groupId>com.googlecode.aviator</groupId> <artifactId>aviator</artifactId> <version>5.3.3</version> </dependency>如果 Maven 拉取失败或团队不使用 Aviator,也可以换成 Spring 的 SpEL 或阿里巴巴 QlExpress,核心思路一致,只是表达式语法和 API 略有差异。
版本准备说明:
- JDK 8 及以上版本均可;
- 不需要额外安装数据库,示例使用本地文件加载规则;
- 需要 Lombok 的话可以自行引入,本文为了避免额外依赖,统一使用手写 getter/setter;
- 推荐使用 IDEA 或 Eclipse,普通命令行编译也能运行。
4. 核心设计:把条件工作流拆成数据
在设计条件工作流前,先明确目标:我们希望流程条件不要写在 Java 代码里,而是通过配置文件或数据库记录来维护。因此需要一套数据结构来描述“条件”和“分支”。
4.1 规则模型设计
最简的三张表模型如下:
- 规则 ID:标识一条分支条件;
- 条件表达式:一段字符串,例如
amount > 1000 && status == 'PAID'; - 目标节点编码:条件命中后流转到哪个节点。
落到 Java 对象上:
public class RuleNode { private String ruleId; private String expression; private String targetNode; // getter / setter 省略 }如果需要在多个条件之间做多分支匹配,就使用List<RuleNode>,引擎按顺序匹配,命中第一条就返回对应分支。
4.2 完整的条件节点 JSON 示例
下面是一个典型的多分支条件工作流配置。以订单审批为例,包含三个分支:自动通过、总监审批、人工复核。
{ "processId": "order_approval", "rules": [ { "ruleId": "rule_auto_pass", "expression": "amount <= 1000 && status == 'PAID'", "targetNode": "auto_pass" }, { "ruleId": "rule_director_approval", "expression": "amount > 1000 && userLevel == 'VIP'", "targetNode": "director_approval" }, { "ruleId": "rule_manual_review", "expression": "riskScore < 60", "targetNode": "manual_review" } ], "defaultNode": "auto_pass" }这一段 JSON 就是“无类型”的直观体现:amount、userLevel、riskScore都没有在 Java 里声明类型,它们只是表达式中的变量名;运行时我们传入一个Map<String, Object>,引擎会把自己能取到的变量填充进表达式。
4.3 表达式设计规范
表达式不是 SQL,也不是 Java 完整语法,它只需要解决“判断”这一个问题。因此建议收敛表达式能力集合,避免让规则编写者写出过于复杂的逻辑。
个人经验:
- 只允许比较运算
>、>=、<、<=、==、!=; - 只允许逻辑运算
&&、||、!; - 变量名统一使用驼峰命名,避免和 Java 关键字冲突;
- 字符串字面量使用单引号,Aviator 中单引号和双引号都支持,但统一规范更利于排查;
- 禁止在表达式里调用自定义方法,除非你明确知道自己在做什么。
5. 完整示例与代码实现
下面给出一个可运行的极简条件工作流。整个工程只需要三个文件:规则加载器、流程引擎、测试入口。
5.1 工程结构
src/main/java/com/example/flow/ ├── FlowEngine.java ├── RuleLoader.java └── Main.java src/main/resources/rules/order_approval.json5.2 条件规则加载器
// 文件路径:src/main/java/com/example/flow/RuleLoader.java package com.example.flow; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; public class RuleLoader { public static List<RuleNode> loadRules(String resourcePath) throws Exception { InputStream is = RuleLoader.class.getClassLoader() .getResourceAsStream(resourcePath); if (is == null) { throw new IllegalArgumentException("Cannot find rule file: " + resourcePath); } String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); JSONObject obj = JSON.parseObject(content); return obj.getJSONArray("rules").toJavaList(RuleNode.class); } }这段代码负责把 JSON 规则文件解析成 Java 对象列表。如果你不想引入 fastjson2,也可以换成 Jackson 或 Gson,只要能把rules数组映射成List<RuleNode>即可。
5.3 条件节点对象
// 文件路径:src/main/java/com/example/flow/RuleNode.java package com.example.flow; public class RuleNode { private String ruleId; private String expression; private String targetNode; public String getRuleId() { return ruleId; } public void setRuleId(String ruleId) { this.ruleId = ruleId; } public String getExpression() { return expression; } public void setExpression(String expression) { this.expression = expression; } public String getTargetNode() { return targetNode; } public void setTargetNode(String targetNode) { this.targetNode = targetNode; } }5.4 核心流程引擎
这是最关键的部分。引擎接收业务数据和规则列表,将业务对象转成无类型的Map<String, Object>,然后逐条计算表达式,命中即返回目标节点。
// 文件路径:src/main/java/com/example/flow/FlowEngine.java package com.example.flow; import com.googlecode.aviator.AviatorEvaluator; import com.googlecode.aviator.Expression; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class FlowEngine { private static final Map<String, Expression> CACHE = new ConcurrentHashMap<>(); public String decide(Map<String, Object> bizData, java.util.List<RuleNode> rules, String defaultNode) { for (RuleNode rule : rules) { if (evaluate(rule.getExpression(), bizData)) { return rule.getTargetNode(); } } return defaultNode; } private boolean evaluate(String expression, Map<String, Object> bizData) { try { Expression compiled = CACHE.computeIfAbsent(expression, AviatorEvaluator::compile); return Boolean.TRUE.equals(compiled.execute(bizData)); } catch (Exception e) { // 生产环境请换成标准日志框架 System.err.println("evaluate error, expression = " + expression + ", data = " + bizData); return false; } } }代码里做了两件重要的事:一是把表达式编译结果缓存起来,避免每次求值都重新解析语法树,性能损失会小很多;二是求值异常时不要直接抛出,先记录日志再返回默认分支,这与“流程不能因单条规则异常而中断”的容错思路有关。
5.5 业务数据与测试入口
// 文件路径:src/main/java/com/example/flow/Main.java package com.example.flow; import java.util.HashMap; import java.util.List; import java.util.Map; public class Main { public static void main(String[] args) throws Exception { List<RuleNode> rules = RuleLoader.loadRules("rules/order_approval.json"); FlowEngine engine = new FlowEngine(); String defaultNode = "auto_pass"; // 场景1:普通订单,金额 500,已支付 Map<String, Object> order1 = new HashMap<>(); order1.put("amount", 500); order1.put("status", "PAID"); order1.put("userLevel", "NORMAL"); order1.put("riskScore", 80); System.out.println("order1 -> " + engine.decide(order1, rules, defaultNode)); // 场景2:VIP 用户大额订单,金额 5000 Map<String, Object> order2 = new HashMap<>(); order2.put("amount", 5000); order2.put("status", "PAID"); order2.put("userLevel", "VIP"); order2.put("riskScore", 90); System.out.println("order2 -> " + engine.decide(order2, rules, defaultNode)); // 场景3:风控分低,需要人工复核 Map<String, Object> order3 = new HashMap<>(); order3.put("amount", 800); order3.put("status", "PAID"); order3.put("userLevel", "NORMAL"); order3.put("riskScore", 40); System.out.println("order3 -> " + engine.decide(order3, rules, defaultNode)); } }这里传入的是一个普通的HashMap,没有专门定义Order类,这就是无类型写法的落地形态:业务数据以 Map 传递,表达式中出现的变量名必须和 Map 的 key 对得上。
5.6 对照:MyBatis 动态 SQL 的无类型写法
条件工作流的无类型写法和 MyBatis 动态 SQL 里<if test="...">的写法,底层思想是相通的。比如一个订单查询接口,筛选条件可能有状态、最小金额,这些条件不是固定的,用 Java 拼接 SQL 很容易出错,MyBatis 的写法是:
<select id="selectOrders" resultType="map"> SELECT order_id, amount, status, user_level FROM orders WHERE 1 = 1 <if test="status != null and status != ''"> AND status = #{status} </if> <if test="minAmount != null"> AND amount >= #{minAmount} </if> </select>这里的status、minAmount来自查询参数,可能是 POJO 的属性,也可能是 Map 的 key。MyBatis 并不要求在编译期声明这些参数的类型,而是运行时从参数对象中反射取值,作用机制与无类型条件表达式的思路一致。
这里有一个新手常踩的坑:XML 的test属性里写amount >= #{minAmount}是错的,因为 XML 中>如果不转义,会被解析成标签闭合符。需要写成amount >= #{minAmount},或者用amount ge #{minAmount}这类 OGNL 支持的形式。
5.7 进一步:IDEA 等环境中如何运行
在 IDEA 中直接运行Main类即可。如果你使用 Maven 命令行:
mvn compile exec:java -Dexec.mainClass="com.example.flow.Main"也可以把工程打包成可执行 jar 后再运行,这取决于你的项目构建方式。
6. 运行结果与效果验证
运行Main后,预期输出如下:
order1 -> auto_pass order2 -> director_approval order3 -> manual_review如何判断运行成功:
- 场景 1 金额 500,不满足
amount > 1000,也不满足riskScore < 60,所以进入默认分支auto_pass; - 场景 2 金额 5000 且是 VIP,命中
amount > 1000 && userLevel == 'VIP',进入director_approval; - 场景 3 风控分 40,命中
riskScore < 60,进入manual_review。
建议再增加两个验证角度:
第一,验证“条件顺序影响结果”。如果把第一条规则改成riskScore < 60,场景 3 先被这条规则匹配,返回manual_review前其他规则不会再执行,这是顺序匹配的预期行为。
第二,验证“表达式中变量缺失”时的容错。把场景 1 的riskScore去掉再运行,因为表达式涉及riskScore < 60,引擎会抛变量缺失异常。生产环境里这种情况很常见,所以引擎兜底返回默认节点,但日志里必须能追踪到。
如果运行失败,第一件事先看控制台是否出现evaluate error日志。出现错误后检查三个方面:JSON 文件路径是否正确、表达式变量名是否和 Map 的 key 一致、表达式语法是否合法。Aviator 对空值比较敏感,amount如果是字符串类型,直接和数字比较会报错。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 表达式解析异常 | 表达式语法错误 | 查看异常堆栈和规则 ID | 使用 Aviator 官方语法校验工具或写单元测试 |
| 变量缺失报错 | Map 中缺少表达式里的变量 | 打印本次求值 bizData | 统一在入口处校验必填变量 |
| 数字比较结果异常 | amount 是 String 类型 | 打印变量实际类型 | 入参转换为同类型后再求值 |
| 命中错误分支 | 条件顺序不对 | 查看规则优先级 | 明确规则按序匹配,把最严格条件放前面 |
| XML 动态 SQL 报错 | >未转义 | 打开 XML 查看是否被解析成标签 | 使用>或ge |
| 性能下降 | 表达式每次都重新编译 | 查看是否使用缓存 | 使用 Expression 缓存 |
| 每条规则都返回默认节点 | 所有表达式都没命中 | 开启引擎日志 | 逐条规则打印表达式与求值结果 |
真正麻烦的是类型比较问题。比如金额在 JSON 里写的是1000,解析时可能变成 Integer;从数据库查出来的 SUM 结果可能是 BigDecimal;前端传过来的金额又可能是 String"1000.00"。无类型写法把这些差异全部暴露在运行期,所以入参规范转换在实践里比表达式本身更重要。
8. 最佳实践与工程建议
8.1 表达式安全是第一优先级
无类型写法引入了一个风险点:如果把表达式配置暴露给不可信用户,对方可以构造恶意表达式。即使只开放比较和逻辑运算,也要防止变量名注入、长表达式拖垮内存。生产环境建议遵循:
- 规则配置只允许后台管理员修改;
- 表达式长度限制在 200 字符以内;
- 不在表达式中开放
env、sys这类内置变量; - 使用独立账号或安全组限制规则读写的接口权限;
- 对表达式下发做变更审批,保留操作审计日志。
8.2 参数校验与数据转换
引擎内部虽然是无类型 Map,但入口处必须有明确的参数契约。建议在业务调用方先把业务对象转成 Map,并做一层“字段是否缺失、类型是否合法”的校验。例如:
public Map<String, Object> toContext(Order order) { Map<String, Object> ctx = new HashMap<>(); ctx.put("amount", order.getAmount() == null ? BigDecimal.ZERO : order.getAmount()); ctx.put("status", order.getStatus()); ctx.put("userLevel", order.getUserLevel()); ctx.put("riskScore", order.getRiskScore()); return ctx; }这一步看起来多写了几行代码,但能避免大量运行期类型异常。
8.3 规则版本管理与回滚
条件工作流的无类型写法让配置可以热更新,但也意味着“一个坏配置可能让全量流程走错分支”。所以必须给规则加上版本号。推荐方案:
- 规则表增加
version字段和effective_time生效时间; - 每次修改不覆盖旧记录,而是新增一条新版本;
- 引擎加载时优先读取生效时间最新的版本;
- 发布新版本后观察一段时间,发现问题可秒级回滚到旧版本。
8.4 日志与链路追踪
表达式求值失败时,只打印“evaluate error”是不够的。生产环境里你根本不知道是哪一笔订单、哪条规则出了问题。建议日志里至少包含:processId、ruleId、expression、bizData的关键摘要、耗时。如果要全量打印 Map,注意脱敏,避免把手机号、身份证号写进日志。
8.5 性能优化
表达式编译是有开销的,务必缓存编译结果。Aviator 的AviatorEvaluator.compile返回的Expression是线程安全的,可以像示例一样放入ConcurrentHashMap。另外,规则数量控制在几百条内,单次请求遍历求值的耗时基本可以忽略;如果规则上十万条,就要考虑用规则树或索引结构来剪枝。
8.6 团队协作约定
无类型写法降低了后端介入成本,也提高了规则编写自由度。团队里最好定一份简单的“规则编写规范”:变量命名统一、布尔值不要写入表达式、字符串用单引号、表达式不允许包含分号。这些约定能在团队扩大后减少大量排错时间。
9. 总结与后续学习方向
条件工作流的无类型写法,把所有条件判断从 Java 代码中迁移到 JSON 配置和表达式字符串里。它改变的不只是代码结构,更是流程的变更模式:以前改规则要发版,现在改配置即生效;以前规则逻辑散落在多个 Service 中,现在集中在一份配置里,可以审计、可以回滚、可以对比版本。
需要注意的是,无类型写法适合“条件变化频繁、业务人员参与配置”的场景,并不是所有地方都该用。如果一段条件的业务逻辑极其稳定、性能和类型安全要求极高,老老实实写 Java 代码仍然是最优解。所谓无类型,本质上是一种取舍:用一部分运行期风险,换取流程配置化的敏捷性。
下一步建议按这个顺序深入:
- 把本文示例接一个真实场景,比如审批流或工单流转;
- 了解 Aviator 的变量绑定、函数注册、异常处理细节;
- 研究规则引擎的成熟方案,比如 Drools,对比表达式配置和规则文件的差异;
- 如果你的项目已经在用 MyBatis 动态 SQL,可以顺手把
<if test>的 OGNL 原理搞明白,两者底层思维很接近。
条件工作流的本质是“让条件成为数据”。理解这一点后,你会发现很多看起来复杂的技术——规则引擎、流程编排、DSL 设计——底层都是同一个思路:把代码里的判断逻辑,慢慢变成可描述、可传递、可变化的数据。这也是本文想传递的核心判断。