ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Aviator 表达式求值引擎语法手册与性能优化实战

Aviator 表达式求值引擎语法手册与性能优化实战 1. 为什么我要认真写一份 Aviator 语法手册第一次接触 Aviator 是在一个规则引擎项目里。业务方要求把“满减、折扣、阶梯计价”这类逻辑从 Java 代码里抽出来做成运营同学能自己改的配置。当时团队里有人提议用脚本语言有人提议用 Drools最后选来选去落在了 Google Aviator 上。原因很朴素它足够轻jar 包小表达式求值快语法接近 Java学习成本低而且不需要引入一整套规则引擎的重型设施。但真正用起来之后我发现一个问题官方文档虽然准确却偏“参考手册”风格很多实际开发中会踩的坑、会纠结的选型点文档里不会展开讲。比如nil和null到底怎么区分比如自定义函数怎么注册才安全比如表达式里能不能写多行比如性能到底在什么量级。这些问题在项目里一个个冒出来逼着我把 Aviator 的语法和实现细节啃了一遍。所以这篇内容我想以“语法手册”为主线把 Aviator 的核心语法、求值机制、自定义扩展、性能考量和实战避坑经验串起来。它适合正在做规则引擎、动态配置、风控表达式、指标计算的 Java 开发者也适合准备 Java 面试、想补一补表达式求值引擎这块知识点的同学。读完你至少能做到看懂 Aviator 表达式、自己写自定义函数、知道哪些写法有性能陷阱、能判断什么场景该用它、什么场景不该用它。Aviator 本质是一个“表达式求值引擎”不是完整的脚本语言。它没有循环、没有类定义、没有复杂的控制流核心就是“给一个表达式字符串和一组变量算出一个结果”。这个定位决定了它的语法边界也决定了它的使用姿势。理解这一点后面很多设计取舍就顺了。2. Aviator 核心语法全拆解2.1 字面量与数据类型从 nil 说起Aviator 支持的字面量类型包括数字、字符串、布尔值、nil、正则表达式。数字统一用long和double表示整数默认是long带小数点的是double。字符串用单引号或双引号都行转义规则和 Java 类似。这里最容易被问到的就是nil。Aviator 里的nil是一个特殊值表示“空”它和 Java 的null不是一回事。nil在 Aviator 内部是一个Nil对象参与运算时有自己的规则。比如nil nil是truenil 1是false。而 Java 的null传进表达式后Aviator 会把它当作nil处理但如果你在自定义函数里返回nullAviator 也会转成nil。// Java 侧传入变量 MapString, Object env new HashMap(); env.put(a, null); env.put(b, 1); // 表达式 Object result AviatorEvaluator.execute(a nil, env); // true Object result2 AviatorEvaluator.execute(a b, env); // false注意nil只能用于比较和判空不要试图对nil做算术运算否则会抛异常。实际项目里我一般会在表达式入口做一层变量清洗把业务上的null统一转成nil或者默认值避免表达式里到处写判空。布尔值就是true和false没什么好说的。正则表达式字面量用/pattern/表示比如/^\d$/这个在字符串匹配场景里挺方便但要注意 Aviator 的正则底层用的是 Java 的Pattern性能上和 Java 一致。2.2 运算符优先级与结合性Aviator 的运算符基本照搬 Java优先级从高到低大致是一元运算符、乘除取模、加减、比较、相等、逻辑与、逻辑或、三元、赋值。这里有个细节Aviator 支持~和!~做正则匹配这两个运算符的优先级和比较运算符同级。实际写表达式时我强烈建议不要依赖优先级记忆该加括号就加括号。原因很简单表达式是给运营或配置人员看的可读性比省几个字符重要得多。我见过一个线上事故就是因为a || b c的优先级理解偏差导致风控规则放行了不该放行的订单。后来我们团队定了个规矩所有逻辑组合必须显式加括号。// 不推荐 AviatorEvaluator.execute(a 1 || b 2 c 3, env); // 推荐 AviatorEvaluator.execute((a 1) || ((b 2) (c 3)), env);三元运算符condition ? a : b在 Aviator 里是支持的而且可以嵌套。但嵌套超过两层就会很难读我一般会拆成多个表达式或者用自定义函数封装。2.3 变量引用与属性访问Aviator 的变量引用很直接直接写变量名就行。如果变量是 Java Bean可以用obj.field访问属性也可以用obj.method()调用方法。数组和 List 用arr[0]访问Map 用map.key或map[key]访问。这里有个性能点Aviator 对属性访问做了缓存第一次访问会反射解析后续会走缓存。但如果你用的是map[key]这种动态 key缓存效果会差一些。所以在高频求值场景尽量用固定属性名。// 假设 env 里有个 User 对象 env.put(user, new User(张三, 25)); AviatorEvaluator.execute(user.name, env); // 张三 AviatorEvaluator.execute(user.age 18, env); // true AviatorEvaluator.execute(user.getName(), env); // 张三提示Aviator 默认不允许调用任意方法需要在编译时开启Feature.AllowAllMethod或者用AviatorEvaluator.setFunctionMissing做白名单。生产环境千万别直接开全量方法调用这是个安全口子。2.4 集合与序列操作Aviator 支持seq库可以对集合做map、filter、reduce等操作。语法是seq.map(list, lambda)这种形式。lambda 用-定义比如x - x 1。ListInteger list Arrays.asList(1, 2, 3, 4, 5); env.put(list, list); // 过滤出大于 2 的元素 Object result AviatorEvaluator.execute(seq.filter(list, x - x 2), env); // [3, 4, 5] // 求和 Object sum AviatorEvaluator.execute(seq.reduce(list, 0, (acc, x) - acc x), env); // 15这个功能在指标计算场景里很好用比如从一批订单里筛选出有效订单再求和。但要注意seq库的函数是内置的如果你用了自定义的AviatorEvaluatorInstance需要确认seq库已经加载。2.5 字符串处理与常用函数Aviator 内置了string库提供substring、length、contains、startsWith、endsWith、split、trim、toUpperCase、toLowerCase等函数。用法是string.xxx(str, args...)。AviatorEvaluator.execute(string.contains(hello world, world), env); // true AviatorEvaluator.execute(string.length(hello), env); // 5 AviatorEvaluator.execute(string.substring(hello, 1, 3), env); // el数学函数在math库里比如math.abs、math.round、math.floor、math.ceil、math.max、math.min、math.pow、math.sqrt。这些在计价、评分场景里很常用。AviatorEvaluator.execute(math.round(3.14159 * 100) / 100.0, env); // 3.14 AviatorEvaluator.execute(math.max(a, b), env);注意Aviator 的数字运算默认是long和double没有BigDecimal。如果你做金额计算直接用它算会有精度问题。我的做法是表达式里只做逻辑判断金额计算走自定义函数内部用BigDecimal。3. 自定义函数与扩展机制3.1 为什么需要自定义函数内置函数覆盖不了业务逻辑。比如“判断用户是否在黑名单”“计算两个日期相差天数”“根据用户等级返回折扣率”这些都得自己写。Aviator 提供了AviatorFunction接口实现call方法就行。public class IsBlackListFunction extends AbstractFunction { Override public String getName() { return isBlackList; } Override public AviatorObject call(MapString, Object env, AviatorObject arg1) { String userId FunctionUtils.getStringValue(arg1, env); boolean result BlackListService.contains(userId); return result ? AviatorBoolean.TRUE : AviatorBoolean.FALSE; } }注册方式有两种全局注册和实例注册。全局注册用AviatorEvaluator.addFunction实例注册用AviatorEvaluatorInstance.addFunction。生产环境我建议用实例注册避免不同业务模块的函数互相污染。AviatorEvaluatorInstance instance AviatorEvaluator.newInstance(); instance.addFunction(new IsBlackListFunction());3.2 函数参数处理与类型转换Aviator 的函数参数都是AviatorObject需要自己转成 Java 类型。FunctionUtils提供了一堆工具方法比如getStringValue、getNumberValue、getBooleanValue。这里有个坑如果参数是nilgetStringValue会返回null你得自己判空。Override public AviatorObject call(MapString, Object env, AviatorObject arg1, AviatorObject arg2) { String str FunctionUtils.getStringValue(arg1, env); if (str null) { return AviatorNil.NIL; } int num FunctionUtils.getNumberValue(arg2, env).intValue(); // ... }提示自定义函数里不要抛异常尽量返回nil或默认值。因为表达式求值过程中抛异常会中断整个流程而业务上往往希望“某个条件不满足就跳过”。我一般会在函数内部 catch 所有异常记录日志后返回nil。3.3 自定义函数的性能考量自定义函数的调用开销主要在参数装箱和类型转换上。如果函数在热点路径上被频繁调用可以考虑用AviatorFunction的call重载版本减少AviatorObject的创建。另外函数内部不要做重操作比如查数据库、调远程接口。如果必须做加缓存。我做过一个压测一个简单的自定义函数每秒能调用 100 万次左右如果函数内部有一次 Redis 查询每秒就降到几千次。所以表达式求值本身很快慢的是你塞进去的业务逻辑。4. 编译模式与性能优化实战4.1 解释执行 vs 编译执行Aviator 默认是解释执行每次execute都会解析表达式、生成 AST、然后求值。如果同一个表达式要反复求值应该用compile编译一次然后反复execute。Expression expression AviatorEvaluator.compile(a b 100); for (int i 0; i 10000; i) { MapString, Object env new HashMap(); env.put(a, i); env.put(b, i * 2); expression.execute(env); }编译后的Expression对象是线程安全的可以缓存起来复用。我一般用ConcurrentHashMap做表达式缓存key 是表达式字符串value 是编译后的Expression。4.2 编译缓存与失效策略Aviator 自身有编译缓存AviatorEvaluator.compile默认会缓存。但缓存大小有限可以通过AviatorEvaluator.setExpressionCacheSize调整。如果表达式是动态生成的缓存命中率会很低这时候要考虑自己控制缓存。// 设置缓存大小 AviatorEvaluator.setExpressionCacheSize(10000); // 或者自己管理缓存 private final ConcurrentHashMapString, Expression cache new ConcurrentHashMap(); public Object eval(String expr, MapString, Object env) { Expression expression cache.computeIfAbsent(expr, AviatorEvaluator::compile); return expression.execute(env); }注意如果表达式里引用了自定义函数而函数逻辑变了缓存里的表达式不会自动失效。所以函数变更后要清缓存或者用版本号做 key。4.3 性能实测数据与调优建议我在一台 4 核 8G 的机器上做过简单压测表达式是a b * c 100 d test变量都是基本类型。结果大致是解释执行每秒约 50 万次编译执行每秒约 200 万次。如果表达式里有属性访问性能会降到 20 万次左右。如果用了seq库做集合操作性能取决于集合大小。调优建议就几条热点表达式必须编译变量尽量用基本类型避免在表达式里做复杂集合操作自定义函数加缓存表达式长度控制在合理范围太长的表达式解析开销大。5. 常见问题与避坑指南5.1 类型转换异常怎么排查最常见的异常是ClassCastException和NumberFormatException。原因通常是变量类型和表达式预期不符。比如表达式里写a 10但a传的是字符串abc就会抛异常。排查方法在execute外面包一层 try-catch把表达式和 env 都打出来。另外可以用AviatorEvaluator.execute的validate方法先校验表达式语法。try { Object result expression.execute(env); } catch (Exception e) { log.error(表达式求值失败, expr{}, env{}, expr, env, e); throw new BizException(规则计算失败); }5.2 nil 与 null 的坑前面提过nil和null在 Aviator 里是两套东西。Java 传null进去Aviator 会转成nil。但如果你在自定义函数里返回nullAviator 也会转成nil。问题出在比较上nil null在 Aviator 里是false因为null不是 Aviator 的字面量。所以表达式里判空统一用nil不要用null。另外nil参与字符串拼接会变成空字符串参与数字运算会抛异常。5.3 表达式注入与安全控制Aviator 默认不允许调用任意方法但如果你开了Feature.AllowAllMethod就等于把 Java 方法调用权交给了表达式编写者。如果表达式来源不可信这是严重的安全漏洞。我的做法是永远不开全量方法调用自定义函数做白名单表达式来源做校验只允许特定字符集求值超时做限制。Aviator 本身没有超时机制需要自己在外部用 Future 做超时控制。ExecutorService executor Executors.newSingleThreadExecutor(); FutureObject future executor.submit(() - expression.execute(env)); try { return future.get(100, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); throw new BizException(表达式求值超时); }5.4 常见问题速查表问题现象可能原因解决方法ClassCastException变量类型与表达式预期不符检查 env 中变量类型必要时做转换NumberFormatException字符串转数字失败用自定义函数做安全转换表达式求值结果为 nil变量不存在或函数返回 nil检查变量名拼写函数返回值性能突然下降表达式未编译或缓存失效检查编译缓存热点表达式预编译自定义函数不生效注册到了错误的实例确认注册的 instance 与求值的一致正则匹配失败正则语法错误或转义问题用 Java Pattern 先验证正则提示Aviator 的异常信息有时候不够具体比如只告诉你“求值失败”不告诉你是哪个变量。我的经验是在开发阶段把日志级别调到 DEBUGAviator 会打印更多细节。生产环境则要做好兜底表达式失败不能影响主流程。6. 实际项目中的使用体会我在项目里用 Aviator 主要做三件事一是营销活动的满减折扣计算二是风控规则的条件判断三是数据指标的动态聚合。这三类场景对 Aviator 的要求不一样。营销计算场景表达式相对固定但金额精度要求高。我的做法是表达式只做条件判断比如user.level VIP order.amount 100具体折扣金额走自定义函数内部用BigDecimal算。这样既利用了 Aviator 的灵活性又避开了浮点精度问题。风控场景表达式变化频繁运营同学会自己改。这时候安全控制最重要。我们做了三层防护表达式字符白名单、自定义函数白名单、求值超时限制。另外所有表达式变更都要走审核流程上线前用历史数据回跑验证。指标聚合场景表达式里会用到seq库。这里要注意集合大小如果集合有几十万条数据seq.filter会遍历整个集合性能很差。我的做法是先在 Java 侧做粗筛把数据量降下来再用 Aviator 做精细计算。最后分享一个小技巧Aviator 的Expression对象可以拿到getVariableNames()用来分析表达式依赖了哪些变量。这个在排查“变量未传”问题时特别有用。我一般会在编译后打印一次变量名列表和实际传入的 env key 做对比能快速发现遗漏。Expression expression AviatorEvaluator.compile(expr); System.out.println(expression.getVariableNames()); // [a, b, c]这个功能还可以用来做依赖分析比如一个规则依赖了哪些用户属性提前做数据准备。踩过几次坑之后我现在养成了习惯任何动态表达式上线前先用getVariableNames过一遍确认变量来源都覆盖了再放行。
返回列表