问题现象
在基于 Activiti 的工作流引擎开发与运维过程中,当流程实例流转至排他网关、包容网关或带有条件判断的序列流节点时,引擎底层会对配置的条件表达式进行实时解析。若表达式语法不符合规范,控制台通常会抛出如下异常堆栈:
text
de.odysseus.el.tree.TreeBuilderException: Error parsing '${...}': lexical error at position X, encountered invalid character '{', expected expression token
at de.odysseus.el.tree.impl.Builder.build(Builder.java:XXX)
at de.odysseus.el.tree.TreeStore.get(TreeStore.java:XXX)
at de.odysseus.el.TreeValueExpression.<init>(TreeValueExpression.java:XXX)
at de.odysseus.el.ExpressionFactoryImpl.createValueExpression(ExpressionFactoryImpl.java:XXX)
at org.activiti.engine.impl.el.ExpressionManager.createExpression(ExpressionManager.java:XXX)
at org.activiti.engine.impl.util.condition.ConditionUtil.hasTrueCondition(ConditionUtil.java:XXX)
at org.activiti.engine.impl.agenda.TakeOutgoingSequenceFlowsOperation.leaveFlowNode(TakeOutgoingSequenceFlowsOperation.java:XXX)
...
该异常的核心特征为 TreeBuilderException,且错误信息中明确提示词法分析阶段遇到非法字符 { 或期望表达式令牌(Token)。这表明 Activiti 底层的 UEL(Unified Expression Language)解析器在构建表达式抽象语法树时,遇到了无法识别的词法符号,导致解析流程中断,流程实例无法继续流转。
原因分析
Activiti 引擎底层依赖 JUEL(Java Unified Expression Language)实现 UEL 表达式的解析与执行。UEL 作为 Java EE 标准规范的一部分,其语法有严格定义:整个表达式必须且只能被一对 ${} 或 #{} 包裹,内部不允许再次出现表达式定界符。
核心根因:表达式定界符嵌套
导致该报错最常见、最直接的原因是在同一个条件表达式中嵌套使用了 ${}。
部分开发者在组合多个条件变量时,习惯性地将每个变量都用 ${} 包裹,写出如下错误语法:
xml
<!-- ❌ 错误写法:内部嵌套了 ${} -->
${(varA <= 4) && ${varB}}
解析器内部行为拆解:
- 解析器扫描到第一个
${,进入表达式解析模式,开始构建语法树; - 逐字符读取
(varA <= 4) &&,该片段符合 UEL 逻辑运算语法,解析正常; - 继续读取到第二个
${时,解析器当前处于表达式内部上下文,期望接收操作数(变量名、字面量)或运算符,但遇到了字符{; - 词法分析器(Lexer)无法将
{映射为任何合法 Token,判定为词法错误,立即抛出lexical error,终止语法树构建,最终向上层抛出TreeBuilderException。
其他潜在诱因
除嵌套定界符外,以下情况也可能触发同类或相似异常:
- XML 特殊字符未转义 :表达式中的
<、>、&&等符号是 XML 保留字符,直接书写会导致 XML 解析器先于 UEL 解析器报错,或产生截断后的残缺表达式传入 UEL 解析器; - 字符串字面量引号错误:使用双引号包裹字符串可能与 XML 属性引号冲突,导致表达式被截断;
- 表达式内容为空或格式残缺 :复制粘贴过程中丢失闭合的
},导致解析器读到末尾仍未找到结束标记。
解决方案
1. 移除内部嵌套的定界符(核心修复)
UEL 表达式只要最外层存在 ${},内部所有变量引用、方法调用、属性访问均会自动解析,无需也不允许再次使用 ${}。
xml
<!-- ✅ 正确写法:仅保留最外层 ${} -->
${varA <= 4 && varB}
2. 使用 CDATA 规避 XML 特殊字符问题
在 BPMN 2.0 XML 文件中,强烈建议使用 CDATA 块包裹条件表达式,彻底避免 XML 转义带来的隐性风险:
xml
<sequenceFlow id="flow1" sourceRef="gateway1" targetRef="task1">
<conditionExpression xsi:type="tFormalExpression">
<![CDATA[${varA <= 4 && varB}]]>
</conditionExpression>
</sequenceFlow>
若不使用 CDATA,则必须手动转义:${varA <= 4 && varB}。
3. 确保表达式返回布尔类型
修复语法后,需验证表达式最终计算结果为 Boolean 类型(true / false)。若返回 null、数值或字符串,Activiti 会抛出 condition expression returns non-Boolean 异常,虽不是本文讨论的解析异常,但属于同一环节的关联故障。
排查与预防规范
为从根源上杜绝此类问题,建议在开发、Code Review 及测试阶段建立以下标准化规范:
静态检查
- 正则全局扫描 :在项目 BPMN 资源目录中执行正则搜索
\$\{.*\$\{,可一键定位所有嵌套定界符的错误写法; - IDE 插件辅助:启用 Activiti / Flowable 官方 IDE 插件,多数插件可在编辑时对 UEL 表达式进行实时语法校验,提前标红错误。
编码规范
- 字符串比较统一使用单引号 :
${status == 'approved'},禁止使用双引号; - 防御性变量访问 :对可能未赋值的流程变量,使用
empty运算符或execution.hasVariable()前置校验,例如${!empty varA && varA <= 4},避免空指针导致的运行时异常; - 复杂逻辑外置到 Service Bean :当条件判断涉及多字段组合、日期计算、外部数据查询等复杂逻辑时,禁止在 UEL 中硬编码。应封装为 Spring Bean 方法,通过方法表达式调用:
${approvalService.checkCondition(execution)}。此举既规避语法风险,又提升可测试性与可维护性。
测试验证
- 单元测试覆盖:对关键网关条件编写独立单元测试,模拟不同变量组合验证表达式返回值;
- 流程定义部署预检:在 CI/CD 流水线中增加流程定义合法性校验步骤,部署前自动解析所有 BPMN 文件,拦截包含语法错误的流程定义进入生产环境。