ARTICLE DETAIL

资讯详情

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

Apache Commons JEXL 实战:用表达式引擎替代硬编码规则

Apache Commons JEXL 实战:用表达式引擎替代硬编码规则 运营又改规则了。这句话在我这几年做交易系统的日子里几乎每周都能听到几次。今天想聊的主角——Apache Commons JEXL就是我在跟这类需求反复拉扯之后沉淀下来的一个顺手工具。JEXL 全称 Java Expression Language是 Apache Commons 下的轻量级表达式求值库核心能力就一句话把字符串形式的表达式在运行时编译并求值。比如user.level 2 order.amount 300这样的规则可以直接存进配置、写进数据库让产品运营去维护后端不再需要为了一个折扣位次反复发版。这篇文章我会从选型对比、核心 API、语法细节、一个订单折扣规则的真实重构案例再到生产环境容易踩的性能和安全隐患把 JEXL 一次讲透。不管是刚接触表达式引擎的新手还是已经在项目里用了 JEXL 但停留在“能跑就行”阶段的人应该都能从这里拿走点东西。1. JEXL 适合干什么从硬编码规则的痛点说起1.1 一段让人头皮发麻的折扣代码先描述一个我见过无数次的场景。运营提需求“订单满 300 减 50会员再打 9.5 折特殊商品不参与。”第一版代码很简单几个 if 就写完了。问题出在后续叠加“新客首单折上折”“大促期间平台补贴”“部分城市不参与”于是代码开始变成这样。public BigDecimal calcDiscount(Order order) { BigDecimal amount order.getAmount(); if (amount.compareTo(new BigDecimal(300)) 0 !order.hasSpecialItem()) { amount amount.subtract(new BigDecimal(50)); if (order.getUserLevel() 2) { amount amount.multiply(new BigDecimal(0.95)); } if (isBigPromotion() order.isFirstOrder()) { amount amount.multiply(new BigDecimal(0.90)); } if (blackCityList.contains(order.getCityId())) { amount order.getAmount(); // 黑名单城市不参与 } } return amount; }这段代码的痛点不在复杂度——逻辑本身没几行——而在于规则变化频率。运营说“满 300 减 50”改成“满 500 减 80”你需要改一个常量、发一次版、再走一遍回归。如果规则还要按城市、按商品类目、按用户标签组合if-else 会迅速膨胀成几百行。更麻烦的是很多规则是临时的两周后又改回去代码仓库里全是“临时方案”的历史包袱。这个场景的本质是业务规则在变但规则引擎不该变。我们需要一个机制把规则从代码里剥离出来变成配置在运行时动态判断。JEXL 就是冲着这个场景来的。1.2 表达式引擎的取舍JEXL 与 Groovy、SpEL、自研解析器的对比决定引入表达式引擎之前很多人会纠结JEXL、Groovy、SpEL 看着都能做到底选哪个我自己的判断标准是先搞清楚各自的定位。维度JEXLGroovySpEL自研解析器定位轻量表达式语言完整脚本语言Spring 表达式语言完全自定义学习成本低像简化版 Java中需要懂 Groovy 语法中与 Spring 概念绑定高所有语法自己设计依赖体积小大中依赖 Spring 生态无依赖但要长期维护调用 Java 方法支持支持支持受 EvaluationContext 限制看实现通常很弱安全机制有沙箱选项需要自己严格管控有但默认不安全完全没有适合场景规则表达式、配置项、判断条件动态脚本、复杂业务流程Spring 生态内的表达式处理极简单、稳定的运算我见过不少团队一上来就上 Groovy最后发现项目里多了一个“能用脚本写业务逻辑”的口子规则倒是动态了但代码库变成了两门语言在维护测试和 code review 的成本直线上升。Groovy 本身很好但用它做轻量规则判断属于杀鸡用牛刀而且 Groovy 脚本的能力边界更难控制。SpEL 的问题在于它和 Spring 绑定得太深。如果项目已经是 Spring Boot用 SpEL 确实顺手但它的默认能力并不安全——网上关于 SpEL 表达式注入的案例不少。JEXL 恰恰卡在一个舒服的位置语法比 Java 简单体积小能直接调用 Java 对象的方法又不会放大到“随便写脚本”的程度。至于自研解析器我的态度很明确除非你的表达式只有四则运算否则不要自己写。一旦表达式里出现方法调用、属性访问、null 处理自研解析器的复杂度会指数级上升最后变成项目里最大的技术债。1.3 决定用之前先问自己三个问题JEXL 虽好但不是什么场景都该上。我在立项之前通常会先问三个问题。第一个问题规则变化频率有多高如果业务规则半年才动一次改代码发版完全能接受引入表达式引擎反而多了一个维护点。JEXL 解决的是“高频变化”的问题不是为了炫技。第二个问题表达式由谁来维护如果改规则的还是开发那 JEXL 只是换了一种写代码的方式收益有限。真正发挥价值的地方在于运营或产品可以直接在配置后台维护表达式开发只需要保证求值器稳定。但前提是对方有意愿也有能力理解表达式语法。如果完全不会那需要做的不是 JEXL而是一个简单的规则配置界面。第三个问题表达式的来源可信吗这是最重要的安全问题。JEXL 表达式能调用对象的公有方法如果来源不可控等于给攻击者开了一扇门。这个问题我会在后面的安全章节专门展开但选型阶段就必须想清楚。这三个问题都想明白再决定用不用 JEXL才不会出现“上了框架反而更痛苦”的结果。2. 从依赖到第一个表达式核心 API 的完整认知2.1 Maven 依赖与最简求值代码JEXL 目前的主流版本是 3.xMaven 坐标如下。dependency groupIdorg.apache.commons/groupId artifactIdcommons-jexl3/artifactId version3.4/version /dependency一个最简的求值过程只需要三步创建引擎、创建表达式、准备上下文并执行。import org.apache.commons.jexl3.*; import org.apache.commons.jexl3.MapContext; public class JexlQuickStart { public static void main(String[] args) { JexlEngine engine new JexlBuilder().cache(512).strict(true).create(); JexlExpression expression engine.createExpression( user.level 2 order.amount 300 ); JexlContext context new MapContext(); User user new User(); user.setLevel(2); context.set(user, user); Order order new Order(); order.setAmount(new BigDecimal(329.00)); context.set(order, order); Object result expression.evaluate(context); System.out.println(result); // true } }这段代码是 JEXL 最核心的骨架。JexlBuilder负责构建引擎createExpression负责把字符串编译成表达式对象MapContext负责提供变量环境evaluate执行求值。整个链路非常直观。2.2 引擎、上下文、表达式JEXL 的三个角色刚接触 JEXL 的人很容易把三个核心概念搞混。我用一个简单的类比来解释表达式是菜谱上下文是冰箱里的食材引擎是厨师。JexlEngine是重量级对象负责词法解析、语法分析、编译和缓存。它本身是线程安全的一个应用里创建一次就够了不要每次求值都 new 一个。JexlBuilder就是用来配置这个引擎的入口可以设置缓存大小、严格模式、自定义命名空间等。JexlExpression是编译产物相当于已经切好配比的菜谱。它本身不带状态可以安全地被多线程共享和复用。同一个表达式对象用不同的上下文去执行会得到不同的结果。JexlContext是每次求值时的变量环境最常用的实现是MapContext本质就是一个MapString, Object的封装。你可以往里面塞任意 Java 对象表达式里通过变量名访问。理解这三个角色的关系之后就明白一个常见的性能误区了不要在一次请求里反复调用createExpression。创建表达式的成本远高于执行evaluate正确做法是在应用启动或首次使用时创建并缓存JexlExpression。这也是后面实战案例里重点强调的地方。2.3 配置化玩法把表达式写进配置文件JEXL 最基本的玩法是硬编码表达式这不比直接写 if-else 高级。它真正的价值在配置化表达式存放在数据库、配置中心或 YAML 文件里运行时加载并求值。比如在配置中心里放一段规则rule: memberDiscount: expression: order.amount 300 order.userLevel 2 description: 会员订单满300参与折扣后端代码只需要按规则 ID 加载表达式再和业务数据一起求值。String expressionStr configCenter.get(rule.memberDiscount.expression); JexlExpression expression engine.createExpression(expressionStr); JexlContext context new MapContext(); context.set(order, order); Object result expression.evaluate(context);这种模式下运营改规则不需要经过开发排期只需要在配置后台改一段字符串配置中心推送后立即生效。后端代码从此不再关心具体规则是什么只负责“执行规则”。有一次我把一个促销系统的规则全部抽成 JEXL 表达式放到配置后台后运营就能自己调整活动门槛和优惠力度了那段时间我工单量肉眼可见地下降。3. 表达式语法实战这些写法要会用更要会避坑3.1 变量、运算符和三目日常 80% 的场景在这JEXL 的语法比 Java 简化了不少但核心运算符基本都有。日常用得最多的是算术运算、比较运算、逻辑运算和三目表达式。// 算术 a b * 2 total % 10 0 price / 100 * 0.8 // 比较与逻辑 score 60 type exam !(flag category ! fruit) // 三目 age 18 ? adult : minor order.amount 500 ? order.amount - 100 : order.amountJEXL 里的字符串可以用单引号也可以双引号这点对从 JSON 或 YAML 里直接复制表达式特别友好。变量名直接引用上下文中的 key不需要加前缀。三目表达式是规则引擎里最常用的结构它能把“如果满足条件就做 A否则做 B”的逻辑浓缩成一行配置。我在实际项目里遇到过一个有意思的用法用三目表达式实现不同城市的运费计算。原来是一个 switch-case 方法后来改成了 JEXL 配置表每行一个城市规则表达式形如cityId 110100 ? 6 : (cityId 310100 ? 8 : 10)。虽然逻辑本身不算复杂但能直接在配置里看到并修改维护体验完全不同。这里有一个容易踩的坑JEXL 在数值运算时遵循 Java 的数值类型提升规则但如果你混用了BigDecimal和基本类型结果可能出乎意料。金额类计算不要在表达式里做加减乘除再比较建议提前在 Java 侧算出结果表达式只负责做判断或者使用JexlArithmetic定制数值运算行为。3.2 属性访问和方法调用要时刻记得 JEXL 不是 JavaJEXL 一个很强的地方是可以在表达式里直接访问对象的属性和调用公有方法。访问属性的本质是调用 getter。context.set(user, user); // 等价于 user.getName() user.name Tom // 可以调用对象的公有方法 user.orders.size() 2 user.getFullAddress().contains(Beijing) // 集合和数组按下标访问 order.items[0].price 100这在做规则判断时非常方便因为上下文里塞进去的是完整的 Java 对象表达式不需要做数据转换。但也正因为能力太强必须时刻提醒自己JEXL 不是 Java不要试图在表达式里写复杂业务逻辑。我见过有人把一段完整的数据清洗逻辑塞进 JEXL 表达式从字符串截取到循环遍历全在表达式里完成结果表达式长达两百多个字符既难读又难调试。JEXL 的定位是“判断和计算”不是“业务流程脚本”。如果逻辑复杂到需要循环和临时变量它更适合放到 Java 服务里预先处理好或者了解 JEXL 3 的脚本能力后再考虑而不是硬塞进一个表达式。还要注意JEXL 通过反射调用方法方法名和参数类型必须与运行时的对象完全匹配。方法重载的解析虽然有但不如 Java 编译器那么宽容遇到Integer和int混用的场景容易踩坑。最稳妥的办法是上下文里对象的属性尽量用包装类型并且方法入参明确。3.3 空值处理Elvis 操作符和 lenient/strict 模式这一小节是 JEXL 新人最容易翻车的地方。默认严格模式下如果你访问一个 null 对象上的属性或方法JEXL 会直接抛异常。// 如果 user 为 null下面这句在 strict 模式下会抛异常 user.name TomJEXL 3 提供了两个非常实用的操作符来解决这类问题。第一个是安全导航操作符?.它会在对象为 null 时直接返回 null 而不是继续访问。// user 为 null 时user?.name 返回 null不会抛异常 user?.name Tom // 多层空值保护 user?.address?.city Beijing第二个是 Elvis 操作符?:它可以为 null 或 false 的结果提供一个默认值。// user?.name 为 null 时返回 guest user?.name ?: guest // 三元表达式可以简化为同样的效果 score 60 ? pass : fail实际使用中?.和?:组合起来能写出很健壮的表达式。比如你有一个人员对象人员可能有上级上级可能有姓名你想取“上级的姓名没有就算 unknown”person?.superior?.name ?: unknown这行表达式无论哪一层是 null都能安全返回不会让规则引擎因为一条脏数据崩掉。另外JEXL 的JexlBuilder支持strict(true)或strict(false)。严格模式下未定义变量、访问 null 属性等都会立刻报错宽松模式下这些问题会被容忍返回 null 或 false。我的建议是生产环境一定用strict(true)因为宽松模式虽然省事但它会把“表达式拼写错误”这种问题也一起吞掉。变量名少写一个字母在宽松模式下不会报错只会导致规则判断永远为 false——这种 bug 极其隐蔽我宁可让它在测试阶段直接崩出来也不要线上悄悄失效。3.4 JEXL 3 的新特性脚本、lambda 与命名空间JEXL 3 相比老版本增加了不少脚本化能力这也让它从“单行表达式工具”进化成“轻量脚本引擎”。首先是JexlScript它支持多行语句、变量定义和流程控制。JexlScript script engine.createScript( var total 0; for (item : order.items) { total total item.price; } return total 300; ); Object result script.execute(context);这只是个很简单的例子但它的意义在于当规则引擎需要做稍微复杂一点的聚合判断时不必在 Java 侧写一堆准备代码脚本自身就能完成部分数据整理。当然脚本越复杂越要谨慎别为了炫技把 JEXL 写成另类的 Groovy。JEXL 3 还支持 lambda 表达式可以把它看作一个匿名函数。JexlExpression expr engine.createExpression(var double x - x * 2; double(21));lambda 最大的价值是可以作为一种参数传递方便在表达式里做集合的批量处理。不过如果你的集合处理已经复杂到需要 lambda先想想是不是应该用 Java 8 的 Stream 在服务端提前处理好而不是在表达式里折腾。命名空间Namespace是个被我低估了很久的功能。它允许你把自定义的工具类挂到表达式引擎上通过ns:method()语法调用。MapString, Object namespaces new HashMap(); namespaces.put(string, new StringUtils()); JexlEngine engine new JexlBuilder().namespaces(namespaces).create(); // 表达式里可以直接调用 StringUtils 的方法 JexlExpression expr engine.createExpression(string:trim(name) ! );这个能力非常适合做规则引擎里的一些通用函数。比如日期格式化、金额单位转换、敏感词替换都可以做成命名空间工具让表达式直接调用而不是在 Java 侧写一堆辅助代码。4. 实战复盘订单折扣规则引擎的重构过程4.1 需求拆解把规则抽象成可配置的表达式模板再回到开头的折扣场景。我和团队做过一次重构把原来的 if-else 折扣逻辑改成 JEXL 配置。当时先做了一件事把规则盘点清楚拆成独立的规则项。规则ID规则说明JEXL 表达式优先级discount_50满300减50order.amount 300 !order.hasSpecialItem10member_95会员等级2以上打95折order.userLevel 220first_order新客首单打9折order.isFirstOrder promo.open30这里最关键的设计思路是表达式只负责输出 boolean 判断不负责计算优惠后的金额。很多人一开始会把“满300减50”直接写成一个表达式输出order.amount - 50这会让表达式越来越难维护。把“判断”和“计算”分离之后规则引擎只关心某个规则是否命中至于命中了怎么算仍然由 Java 侧处理。这样拆完之后后端的计算逻辑基本稳定变化的部分全部收敛到规则配置表里。运营想调整满减门槛只需要改order.amount 300里的数字不用等开发发版。4.2 落地实现规则配置表 JEXL 求值器代码结构很清晰核心是一个 RuleEngine 类public class RuleEngine { private final JexlEngine jexl; private final MapString, JexlExpression cache new ConcurrentHashMap(); private final MapString, RuleConfig configs; public RuleEngine(MapString, RuleConfig configs) { this.configs configs; // 生产环境用 strict 模式宁可报错也不要静默失败 this.jexl new JexlBuilder().cache(1024).strict(true).create(); } public boolean evaluate(String ruleId, Order order) { RuleConfig config configs.get(ruleId); if (config null || !config.isEnabled()) { return false; } // 表达式缓存到应用层避免反复解析 JexlExpression expression cache.computeIfAbsent(ruleId, id - jexl.createExpression(config.getExpression())); JexlContext context new MapContext(); context.set(order, order); try { Object result expression.evaluate(context); return result instanceof Boolean (Boolean) result; } catch (JexlException e) { // 这里必须把 ruleId 和原始表达式打到日志里否则线上很难排查 throw new BizException(规则执行异常: ruleId , 表达式: config.getExpression(), e); } } }注意我在代码里做了一个双缓存JexlEngine内部自己有一层编译缓存我在应用层又加了一层ConcurrentHashMap。有人可能会问引擎不是已经有缓存了吗为什么还要自己存原因是engine.createExpression()每次调用都会返回一个新的JexlExpression包装对象虽然引擎内部可能不会重复编译但包装对象、字符解析等操作仍然有成本。通过computeIfAbsent把表达式对象直接缓存住每次请求只做一次Map查询效率更高也方便统一管理。配置表的数据可以从数据库、Apollo、Nacos 或本地 YAML 加载这里就不展开了。核心点是整个 RuleEngine 的职责边界非常清晰加载配置、缓存表达式、执行求值、统一异常处理。业务方只需要调用evaluate(ruleId, order)完全感知不到 JEXL 的存在。4.3 上线后遇到的两个真实问题这套方案上线后也踩过坑我说两个印象最深的。第一个是配置里的全角字符问题。运营在配置后台编辑表达式时可能从某个文档里复制文本不小心带进来中文括号、全角逗号或者中文引号。JEXL 解析器碰到这些字符直接报语法错误而且报错信息是英文的运营根本看不懂。后来我们在配置写入接口上加了一道校验先把表达式用白名单正则过滤一遍再调用engine.createExpression(expr)做一次预编译能通过才允许保存从入口把错误挡在门外。第二个问题是属性名拼写错误。这种错误在 strict 模式下会立刻抛JexlException.Variable还算好排查。但如果有人把引擎切成了 lenient 模式拼错属性名不会报错只会返回 null 或 false规则静默失效——这种 bug 不仔细查根本发现不了。我的排查经验是上线前加一道自检任务启动时把一批已知样例数据跑一遍所有规则预期结果不对就直接启动失败。这样每次改完配置发布流程就能自动校验表达式的正确性不用等线上用户来当测试员。这套组合拳打完之后规则配置出错的情况大幅度减少运营那边也因为配置保存前就能看到校验反馈逐渐养成了自查习惯。5. 生产环境使用 JEXL 必须盯住的几条底线5.1 表达式缓存性能差距往往只差一个 createExpressionJEXL 的性能问题90% 出在表达式的创建方式上。createExpression需要走一遍解析和编译流程成本比执行evaluate高一个数量级。如果在一次请求里频繁创建同一个表达式TPS 跑高之后很容易成为瓶颈。正确的做法是复用JexlExpression对象。前面实战案例里的ConcurrentHashMap缓存是其中一种方案更简单的做法是直接利用JexlEngine自带的缓存能力重复调用createExpression时大部分情况下引擎会直接命中内部缓存。不过为了稳定性我还是建议在应用层做一次显式缓存一方面可以控制缓存数量和淘汰策略另一方面也能给表达式加上版本号、最后修改时间等业务管理信息。如果规则数量特别庞大比如成千上万条动态规则建议给表达式缓存加一个 LRU 淘汰策略避免缓存无限膨胀。JexlBuilder 的cache(1024)参数就是用来配置引擎内部缓存大小的可以根据项目实际情况调整。5.2 线程安全无状态表达式基本安全但别写有状态脚本JEXL 的官方文档明确说明JexlEngine和JexlExpression都是线程安全的。这也意味着你可以放心地在一个多线程 Web 服务里共享同一个 Engine 和同一批表达式对象。但这个线程安全有一个隐含前提表达式本身不能持有可变状态。如果表达式引用了一个全局的、会被其他线程修改的变量或者脚本内部有跨请求共享的静态状态那就另当别论了。我的建议是把表达式当作“纯函数”来使用输入只来自上下文里的对象输出只依赖这些对象的属性不产生副作用不修改无关数据。这样无论多少线程并发求值都不会互相干扰。如果某个规则确实需要写状态比如修改一个累计计数器那应该把计数逻辑放到 Java 侧的独立组件里通过命名空间注入到表达式而不是靠表达式自己维护。还有一个容易被忽略的问题上下文里塞进去的对象如果在求值过程中被其他线程修改结果也会不稳定。实践中我会把每个请求的上下文变量都做一次浅拷贝或者直接使用请求内的新对象而不是复用共享对象。5.3 安全红线JEXL 不能跑不可信输入除非你做好沙箱这一条必须单独拿出来说而且说得严肃一点。注意JEXL 官方文档明确提醒不要对不可信输入执行 JEXL 表达式。因为表达式可以调用对象的公有方法默认的类加载范围又比较宽攻击者完全可以通过精心构造的表达式访问底层类造成严重的安全问题。“不可信输入”是什么意思就是用户直接提交的、你没有做任何校验的字符串。如果让用户输入一段 JEXL 表达式然后你的服务去执行它那几乎等于给用户开了一个后门。哪怕限制只能操作业务对象也有各种绕过的方法。如果你确实需要开放表达式能力给低信任方JEXL 3 提供了JexlSandbox沙箱机制可以限制表达式能访问哪些类、调用哪些方法。用法大概是这样的思路JexlSandbox sandbox new JexlSandbox(false); // 只允许白名单类名 sandbox.allow(com.example.Order); sandbox.allow(com.example.User); JexlEngine engine new JexlBuilder().sandbox(sandbox).create();沙箱能挡住一部分攻击但我不建议把它当万灵丹。更稳妥的组合拳是表达式来源必须是可信的后台配置不允许最终用户直接提交。配置文件写入接口做严格的权限控制。表达式保存时做白名单校验和长度限制。求值调用包一层超时机制避免恶意复杂表达式拖垮线程。安全不是某一个功能的事而是一整套边界控制。JEXL 本身的默认行为偏向“能干更多事”生产环境要做的恰恰是给它套上约束。5.4 JEXL 常见异常速查表最后整理一个 JEXL 常见异常速查表都是我实际排查过程中遇到过的大概率你也会碰到。异常类型常见原因排查思路JexlException.Parsing表达式语法错误比如括号不匹配、非法字符检查是否混入全角符号用预编译接口在配置保存时拦截JexlException.Variable表达式中引用了上下文中不存在的变量检查context.set的变量名和表达式里的名字是否一致JexlException.Method调用了对象上不存在的方法确认对象类型检查方法是否是 public参数类型是否匹配JexlException通用求值过程中的其他 JEXL 异常打印完整表达式和 context 里的变量名定位具体哪一步出错ClassCastException表达式返回类型和预期不一致JEXL 对Object不做强转断言返回值时要instanceof判断NullPointerExceptionstrict 模式下访问了 null 对象的属性用?.安全导航或在上游做好 null 校验遇到 JEXL 异常时我的第一动作永远是“复现表达式、固定上下文”。在异常日志里把 ruleId、表达式字符串、上下文对象的 toString 都打出来大部分问题一眼就能定位。如果日志里只有“规则执行失败”五个字那排查成本至少翻倍。我自己用了三年多 JEXL最大的体会是表达式引擎救了你改代码的命也会要了你不管测试的命。规则动态化之后出 bug 的位置从代码转移到了配置而配置的错误往往比代码错误更隐蔽、更难以察觉。所以不管规则看起来多简单都建议在启动阶段用一批样例数据把表达式全部跑一遍把问题暴露在最前面。可以在测试环境准备一份“表达式回归用例集”每次改配置都自动执行一遍确保不会影响存量逻辑。另外一个经验是规则里能用判断型表达式解决的就不要去写脚本型表达式。判断型表达式简单、直观、好测试脚本型表达式虽然灵活但每多一点语法就多一个出错的维度。JEXL 的价值在于以最低的成本把动态规则从代码里解放出来而不是让所有人都变成表达式专家。把握好这个度JEXL 会是你工具箱里非常顺手的一把工具。
返回列表