做后端开发久了总会遇到一种很挠头的需求业务规则一直在变而每次改动都伴随着“提需求、改代码、走发布、等上线”这条漫长流水线。就拿我最近做的营销系统来说满减门槛、会员积分倍率、运费计算规则几乎每周都在调运营那边催得急开发这边只能靠加班硬顶。后来我引入了Apache Commons JEXL这套表达式工具把规则从Java代码里剥离出来配成表达式存进数据库运行时动态求值总算把“规则变更”这件事从小时级压到了分钟级。Apache Commons JEXL的全称是Java Expression Language是Apache Commons家族里一个轻量级的表达式求值引擎。它不是模板引擎也不是脚本语言而是一套专门用来“计算表达式”的工具。JEXL允许你在运行时解析并执行一段表达式文本比如“a b * 2”再配合动态上下文变量来完成计算、过滤、逻辑判断等任务。这篇文章我会从底层原理、语法细节、Spring Boot集成、安全沙箱、性能调优这几个维度展开最后再把我实际踩过的坑和选型对比一块儿聊透。1. JEXL到底是什么一个能跑表达式的“计算器”1.1 从一段真实业务代码说起去年的一个项目里我们接了一个渠道订单分账的需求。不同渠道、不同商品类目、不同用户等级对应的分账比例完全不同最开始的实现是标准的硬编码public BigDecimal calcRate(String channel, String category, int userLevel) { if (JD.equals(channel)) { if (3C.equals(category)) { return new BigDecimal(0.05); } return new BigDecimal(0.08); } else if (PDD.equals(channel)) { if (userLevel 3) { return new BigDecimal(0.02); } return new BigDecimal(0.06); } // 后面还有几十行 if-else }这种代码看第一眼没问题看多了就头疼。新渠道要加新类目要加用户等级规则要变每次都要改代码走发布。关键是运营根本不理解为什么改一个比例要等一整天。后来我把这部分逻辑全换成了JEXL表达式比如channel JD category 3C ? 0.05 : (channel PDD userLevel 3 ? 0.02 : 0.08)表达式直接存在配置中心里运营改完配置即时生效开发这边再也不需要为“改数字”写代码了。这就是JEXL这类表达式工具最典型的价值把“逻辑决策”从代码里抽出来交给配置和运行时动态计算。1.2 JEXL的能力边界能做什么、不该做什么JEXL能做的事情远超一般人的想象动态计算加减乘除、取模、幂运算还可以调用Java对象的方法规则过滤结合条件表达式可以做成一个极轻量的规则引擎数据转换从List、Map、对象里提取字段进行计算或拼装配置模板化把复杂的判断条件收敛成一段可读性高的文本权限与策略判断会员等级、优惠策略、灰度规则都可以用表达式描述。但JEXL也有它的边界。它再强大也绝不是为了替代复杂业务编排而生的。首先它不适合做长流程的工作流引擎因为表达式本身不维护状态你很难在里面体现“步骤一、步骤二”这种过程语义。其次它也不适合做重度数据处理比如上万行记录的分组统计和复杂聚合这种场景应该交给SQL或流式计算框架。最后它虽然是图灵完备的语言但脚本里面写大量逻辑会让排查问题的成本直线上升。一句话总结JEXL适合的是“规则表达式”而不是“业务程序”。把它用在规则计算、条件判断这些轻量场景上能发挥最大价值。2. 核心语法与底层机制搞懂表达式是怎么跑起来的2.1 表达式语法速览JEXL的语法和Java表达式非常接近但又比Java更自由特别是它简化了属性访问和空值处理的写法。先看一段覆盖大部分常用语法的示例// 算术与比较 total price * count * (1 - discountRate) total 100 vipLevel 2 // 三元表达式 status 1 ? 正常 : 冻结 // 方法调用访问对象属性 user.name.length() 3 user.isActive() // 数组、集合构造 [a, b, c] {name: 张三, age: 18} // 循环 for (item : orderList) { total item.price } // 空安全处理3.x user.address??暂无地址 // 如果 user.address 为 null则返回 暂无地址还有几个需要注意的小细节字符串用单引号或双引号都行但变量名不需要加前缀属性访问支持两种写法user.name和user.getName()JEXL会自动适配如果上下文里没有某个变量默认会抛异常但可以通过配置调整行为??是JEXL 3.x新增的空安全运算符老版本不支持。JEXL底层是把表达式文本解析成一棵语法树然后逐节点执行。你完全不需要用反射去调“用户对象”的getterJEXL通过统一的属性访问器去处理对象、Map、数组和List这也是它用起来“无感”的原因之一。2.2 表达式编译与执行的生命周期理解JEXL要先把三个核心类搞清楚JexlEngine、JexlExpression、JexlContext。这三个类基本上就是JEXL的全部入口。JexlEngine表达式引擎负责解析表达式、创建表达式对象它也承担了缓存职责JexlExpression编译后的表达式是真正能执行的东西内部持有了语法树JexlContext执行上下文也就是变量的来源JEXL在执行表达式时从Context里读取变量值。用一段最小代码来演示生命周期// 1. 创建引擎应用全局只创建一次 JexlEngine engine new JexlBuilder().create(); // 2. 编译表达式得到表达式对象 JexlExpression expression engine.createExpression(a b * 2); // 3. 构造上下文放入变量 JexlContext context new MapContext(); context.set(a, 3); context.set(b, 4); // 4. 执行表达式 Object result expression.evaluate(context); System.out.println(result); // 输出 11从解析角度看createExpression方法做了语法分析把字符串变成token流、构建抽象语法树、对语法树做必要的优化。这个过程是有成本的也是后面讲性能优化时最关键的切入点。而evaluate的过程则是对语法树做一次深度遍历计算结果并返回。如果表达式里访问了对象属性JEXL会通过PropertyAccessor来读取对于Java Bean它内部已经预置了基于反射的高效访问器。2.3 为什么性能是关键预热与缓存很多第一次接触JEXL的人上来就在业务方法里写new JexlBuilder().create().createExpression(...)然后每次请求都重新构建引擎和表达式。这是最典型的性能杀手。createExpression是解析构建语法树的过程成本相当高。如果把“创建引擎创建表达式”类比成“写代码编译”那么每次请求都重复编译一遍性能自然惨不忍睹。正确做法是JexlEngine全局唯一JexlExpression按需缓存。对频繁执行的表达式建议在应用启动阶段完成预热Component public class JexlRuleEvaluator { private final JexlEngine engine; private final ConcurrentHashMapString, JexlExpression expressionCache new ConcurrentHashMap(); public JexlRuleEvaluator() { // 引擎只在构造时创建一次 this.engine new JexlBuilder().cache(512).create(); // 预热常用表达式避免第一个请求去解析 warmUp(channel JD category 3C ? 0.05 : 0.08); } private void warmUp(String rule) { getExpression(rule); } public JexlExpression getExpression(String rule) { return expressionCache.computeIfAbsent(rule, engine::createExpression); } public Object evaluate(String rule, MapString, Object vars) { JexlExpression expression getExpression(rule); JexlContext context new MapContext(vars); return expression.evaluate(context); } }这样处理后一次请求里的动作基本只剩evaluate的语法树遍历耗时能降到微秒量级。后面“性能调优”章节我会给出一组实测数据用于说明缓存带来的提升。3. 从引入到落地真实项目中的集成实操3.1 依赖集成与最小Demo先解决依赖问题。JEXL有两个大版本2.x和3.x。如果项目还在用老代码可能遇到2.x但新项目一律推荐3.x3.x在性能、语法和API设计上都有明显改进。Maven依赖dependency groupIdorg.apache.commons/groupId artifactIdcommons-jexl3/artifactId version3.3/version /dependencyGradle写法implementation org.apache.commons:commons-jexl3:3.3加完依赖写一个最简单的API请求处理逻辑前端传一个表达式和一组参数后端执行并返回结果。这一步看着简单却是后面所有复杂场景的地基。RestController public class CalcController { private final JexlRuleEvaluator evaluator; public CalcController(JexlRuleEvaluator evaluator) { this.evaluator evaluator; } PostMapping(/calc) public Object calc(RequestBody CalcRequest request) { return evaluator.evaluate(request.getExpression(), request.getParams()); } }这个接口能把表达式求值能力直接暴露给前端当然生产环境里不建议随便暴露给用户这涉及到后面要说的安全收口。3.2 结合Spring Boot做配置化规则引擎真正用来解决业务问题的是一个完整的“配置化规则引擎”。流程非常简单规则表达式存到配置中心或数据库应用启动时加载每次执行通过MapContext传参。我这里以MySQL存储规则为例设计一张简单的规则表CREATE TABLE t_scene_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scene_code VARCHAR(64) NOT NULL COMMENT 场景编码, rule_expression VARCHAR(512) NOT NULL COMMENT 规则表达式, result_value VARCHAR(128) COMMENT 规则命中后的结果值, status TINYINT DEFAULT 1, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );在Service层完成规则加载与求值Service public class RuleService { Resource private RuleMapper ruleMapper; Resource private JexlRuleEvaluator jexlEvaluator; public BigDecimal matchRate(String sceneCode, String channel, String category) { ListSceneRule rules ruleMapper.selectByScene(sceneCode); MapString, Object vars new HashMap(); vars.put(channel, channel); vars.put(category, category); for (SceneRule rule : rules) { Object matched jexlEvaluator.evaluate(rule.getRuleExpression(), vars); if (matched instanceof Boolean (Boolean) matched) { return new BigDecimal(rule.getResultValue()); } } // 兜底默认值 return new BigDecimal(0.1); } }这段代码的意义在于规则的增删改完全不需要发版。新渠道上线时运维或运营只需在后台管理页加一条规则记录即可。我实测下来从规则变更到全量生效不需要重启缓存失效策略做一下或者不做因为表达式文本变了key就变了就能感知。3.3 参数传递、作用域与全局函数JEXL的变量全部来自JexlContext。默认实现是MapContext底层就是一个Map。它同时也支持嵌套属性访问、List下标访问、方法调用这些不需要额外配置JEXL内置的访问器已经处理好了。实际项目中如果每个字段都要手动往Map里塞调用方会非常痛苦。建议用一个工具方法把Java对象转成上下文可见的变量public static JexlContext buildContext(Object... keyValues) { MapContext context new MapContext(); int i 0; while (i keyValues.length) { context.set(String.valueOf(keyValues[i]), keyValues[i]); } return context; }调用方式JexlContext ctx buildContext( user, user, order, order, now, LocalDateTime.now() ); Object result expression.evaluate(ctx);除了变量JEXL还支持注册全局函数。这个能力特别适合把一些公共的、复杂的计算逻辑封装成函数然后在表达式里“函数化”调用。比如JexlEngine engine new JexlBuilder() .namespaces(Collections.singletonMap(utils, new CommonUtils())) .create(); // 表达式里就可以写 utils:max(10, 20);例子中表达式utils:max(10, 20)会调用CommonUtils.max(10, 20)方法。这种方式对表达式开发者来说极其友好尤其是遇到字符串截取、金额格式化、日期比较这类公共逻辑时不必把一堆方法调用堆在表达式里。4. 安全与能力收口生产环境必须处理的几件事4.1 JEXL的沙箱机制限制危险类与方法把表达式能力暴露给非开发人员之后安全问题就是悬在头上的一把刀。JEXL表达式本身是图灵完备的语法上允许通过Java类名创建对象、调用静态方法。如果不加限制理论上可以在表达式里执行java.lang.Runtime.getRuntime().exec(rm -rf /)这类危险命令。所以生产环境一定要启用沙箱。JEXL 3.x 提供了JexlSandbox可以限制表达式能访问哪些类、哪些方法JexlSandbox sandbox new JexlSandbox(false); sandbox.white(java.util.regex.Pattern); sandbox.white(java.lang.Math).method(max); sandbox.white(java.lang.String).method(*); JexlEngine engine new JexlBuilder() .sandbox(sandbox) .create();这里的white是白名单模式new JexlSandbox(false)表示默认拒绝所有未列入白名单的类。一旦表达式尝试访问白名单外的类执行时会直接报错。更稳妥的做法是只放行业务函数和少量工具类像System、Runtime、Class、Thread、ProcessBuilder这类高危对象一律拒绝访问。不要心存侥幸觉得表达式只有内部人才能改风险模型要按“最坏情况”来设计。4.2 拒绝服务与超时控制表达式也要限流表达式执行本身是非常快的但人为写的表达式可以故意构造死循环比如while (true) { x 1; }这种表达式如果被提交到线上执行线程会直接卡死造成线程池耗尽。生产环境必须对表达式执行做“超时控制和资源限制”。超时控制的通用做法是用线程池加Future设置最长执行时间。简单示例ExecutorService executor Executors.newSingleThreadExecutor(); FutureObject future executor.submit(() - expression.evaluate(context)); try { Object result future.get(100, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); log.error(expression execute timeout: {}, expression.getSourceText()); } catch (Exception e) { log.error(expression execute error, e); }除此之外还可以在应用层做几道防线表达式长度限制超过512字符直接拒绝正常人不会写超长表达式循环次数限制JEXL本身没有内置的循环上限但你可以通过重写JexlUberspect或在包装函数里对迭代器做计数截断引擎实例隔离不同业务线使用不同的JexlEngine避免一个引擎被拖垮影响全局。记住一点表达式能力越开放防御就必须做得越严密。别等生产事故出来了再补收益和风险是并存的。4.3 日志与审计排查诡异表达式问题的关键表达式是配置化的一旦出问题你都不知道是哪条规则、在什么输入下导致的。所以日志和审计很关键。我排查过一个诡异问题同样的表达式在某些订单参数下返回结果错误但本地单测怎么都复现不了。最后查日志发现表达式执行时依赖的MapContext里缺了一个字段JEXL默认把缺失变量当成null后续对该值的运算直接抛了异常而异常被上层某个 catch 吞掉了只返回了一个默认值。如果没有完整日志这种问题根本无从下手。建议每次执行表达式时至少记录以下信息log.info([jexl] scene{}, rule{}, params{}, result{}, cost{}ms, sceneCode, expression.getSourceText(), JSON.toJSONString(vars), result, costMs);必要时再增加审计表记录操作人、规则版本、变更前后内容。配置化的东西一旦可以随意改就要能随时追溯是谁改的、改了什么。5. 常见问题与性能对比真实踩坑记录5.1 常见异常速查表JEXL的异常信息有时候比较抽象尤其是表达式语法出错时报错信息往往让人摸不着头脑。我整理了几个高频异常以及对应的排查思路异常类型触发场景处理思路JexlException.Parse表达式语法错误比如括号不匹配、关键字写错用JEXL自带的小工具先把表达式放到本地跑一遍利用它的错误行列号定位JexlException.Variable表达式引用了上下文中不存在的变量检查JexlContext中的变量名是否和表达式一致注意大小写JexlException.Method表达式调用了对象不存在的方法或沙箱白名单外的方法确认对象类型检查方法名和参数类型在沙箱模式下要确认该方法在白名单内NullPointerException表达式里某个对象为null后续访问了它的属性升级到JEXL 3.x用??空安全运算符或在上下文里给该字段设置默认值JexlException父类运行时各种异常被统一包装一定仔细看getCause()很多时候真正的异常原因藏在内部异常链里解决表达式问题最简单的工具就是用本地main方法复现不要直接在线上环境猜。本地写个几十行的测试类把表达式、变量、上下文都放进去很快就能定位问题。5.2 JEXL与SpEL、OGNL、Groovy的选型对比很多人在做规则引擎选型时会在JEXL、SpEL、OGNL、MVEL、Groovy之间纠结。这几个工具都支持表达式求值但侧重点不同特性JEXLSpELOGNLMVELGroovy体积轻量中等依赖Spring轻量轻量重学习成本低中中低高性能高高中高中与Spring集成手动原生手动手动手动类型安全弱中弱弱强适用场景规则计算、轻量动态逻辑Spring应用内的JSON、配置表达式Struts2/Old项目动态取值规则引擎、事件处理完整脚本、复杂逻辑以我的实际经验来看如果项目本身就是Spring生态想做配置里的动态表达式优先考虑SpEL因为它和Spring容器无缝衔接如果希望表达式引擎独立、轻量、API简洁JEXL是更好的选择它的沙箱配置比SpEL更直接如果表达式复杂度极高需要定义类、写方法、做复杂集合操作那就不该用表达式工具了直接用Groovy写脚本OGNL如今更多是历史包袱新项目不必考虑。JEXL的最大优势是把“表达式”这个能力做到足够小而美。它不像Groovy那样引入一个完整的脚本运行时也不像SpEL那样和Spring强绑定。你只需要一个JAR包就能获得一套高效、沙箱可控的表达式求值能力。5.3 性能调优与压测缓存复用、并发安全、内存控制性能是表达式工具选型时逃不开的指标。我这里给出一组在我本机普通8核开发机上测得的数据对比“每次创建引擎每次创建表达式”和“复用引擎缓存表达式”两种模式跑1万次表达式求值// 模式一每次请求都 new long start System.currentTimeMillis(); for (int i 0; i 10000; i) { JexlEngine engine new JexlBuilder().create(); JexlExpression exp engine.createExpression(a b * 2); JexlContext ctx new MapContext(); ctx.set(a, i); ctx.set(b, 2); exp.evaluate(ctx); } System.out.println(no cache cost: (System.currentTimeMillis() - start) ms); // 模式二复用引擎、缓存表达式 JexlEngine engine new JexlBuilder().create(); JexlExpression exp engine.createExpression(a b * 2); long start System.currentTimeMillis(); for (int i 0; i 10000; i) { JexlContext ctx new MapContext(); ctx.set(a, i); ctx.set(b, 2); exp.evaluate(ctx); } System.out.println(cache cost: (System.currentTimeMillis() - start) ms);在这台机器上模式一耗时通常在800ms以上模式二可以压到30ms以内性能差距接近30倍。所以说“JEXL性能差”很多时候是把create和createExpression写在了循环里。并发安全方面JexlEngine和JexlExpression都是线程安全的可以放心在多线程环境下复用。但JexlContext不是线程安全的不要在多个线程之间共享同一个Context。每个任务创建自己的MapContext才是正确姿势这个Context的创建成本极低不用心疼。内存控制上表达式对象会持有语法树如果规则数量庞大比如上万条需要给缓存加上容量限制。new JexlBuilder().cache(512)可以设置缓存大小超过容量的表达式会被淘汰。我通常配合computeIfAbsent做一个本地ConcurrentHashMap缓存然后依赖JEXL内部缓存来控制语法树的个数。另外还要注意JEXL表达式里如果大量使用字符串拼接也会增加内存压力。建议预编译重复拼接的模板或者用函数封装复杂字符串处理逻辑减少表达式自身的复杂度。回到那个分账场景自从引入JEXL后我最大的感受不是“性能有多快”而是“变更成本被打下来了”。以前改一条规则要经历完整的开发、测试、联调、发布流程现在改一段表达式字符串秒级生效。这种“把决定权还给业务”的感觉用一次就回不去了。最后再分享一个小技巧上线前给表达式做一次“空跑校验”非常管用。我写了一个PostConstruct方法启动时把数据库里所有规则表达式拉出来逐个传入构造的空上下文执行一遍。如果语法错误应用直接启动失败并给出具体行号。这样做的好处是配置错误不会等到线上流量进来才暴露而是在发布瞬间就被拦截下来。这个习惯帮我挡住了好几次因为“运营手滑写错表达式”导致的线上事故强烈建议你也留着。
阅读完成 · 觉得有帮助?