1. 迁移动机:Aviator 是好引擎,但它终究不是 Java
规则引擎这个东西,一旦跑起来,就很难再动它。我们线上的规则配置中心里维护着几千条 Aviator 表达式,每天在风控和营销策略里被调用几百万次,团队里听到"换引擎"三个字,第一反应都是别动。但后来的需求逼着我们不得不重新评估:新业务要支持一段包含复杂分支、循环和自定义工具类调用的逻辑,而这类逻辑在 Aviator 里要么写不出来,要么写到你已经不认识它。
这次项目最终把 Aviator 迁到了 Janino,过程比想象中顺利,但坑也不少。这篇文章把迁移动机、语法对照、转换器实现、回归验证和踩坑记录一次讲清楚,给同样在考虑换表达式引擎、或者正在跟一堆存量表达式搏斗的团队一个完整参考。
1.1 原来的场景
我们的规则是这样存的:每条规则是一行字符串,放在配置中心,字段有规则 ID、表达式、状态、优先级。表达式的形式大概是这样:
age >= 18 && (city == 'Beijing' || city == 'Shanghai') && amount <= 5000规则服务启动时把全量表达式拉下来,请求进来后按优先级逐条用AviatorEvaluator.execute(expr, env)求值。Aviator 的优势非常明显:
- 轻量,依赖极小,对 CTO 来说引入成本几乎没有。
- 语法温和,运营同学自己都能看懂,
'Beijing'这种字符串写法很直觉。 - 内置了
string.*、math.*、seq.*一批函数,写规则时不用自己造轮子。
这个状态维持了很长一段时间,直到业务开始要更"Java"的东西。
1.2 Aviator 的优势,先说清楚
在做迁移之前,我们认真复盘过 Aviator 到底给我们带来了什么,不然就是耍流氓。
第一,它足够简单。规则本质是"输入一堆变量,输出一个布尔值",Aviator 把这一步压缩成了一行 API。第二,它对非 Java 背景的同学友好,这是它最大的护城河。第三,它性能不差,虽然解释执行,但表达式本身很短,单次求值在微秒级,普通业务量完全吃得消。
但问题也恰恰出在"它不是 Java"上。Aviator 有自己的类型体系、自己的运算符语义、自己的函数注册机制。想在表达式里直接调用一个 Java 工具方法?在早期版本里需要先写Function实现类再注册进AviatorEvaluator;想把一段现成的 Java 分支逻辑贴进规则?几乎不可能。Aviator 5 支持了更多脚本能力,但这恰恰让复杂度变高了——从一个"表达式引擎"变成了"半个脚本语言",而我们需要的是一个能直接执行 Java 语法的东西。
1.3 为什么最终选择 Janino
Janino 这个东西,很多 Java 程序员用过但没意识到自己用过:很多日志组件的条件判断(比如<if condition="...">)背后就是 Janino。它是一个嵌入式 Java 编译器,不调javac,直接在内存在把 Java 源码编译成字节码,并通过ExpressionEvaluator和ScriptEvaluator两个入口求值。
我们选它有几个非常现实的理由:
- 表达式本身就是 Java 语法。
a + b、"abc".equals(city)、list.size() > 2,怎么写 Java 就怎么配规则,不用学第二门 DSL。 - 编译一次,执行多次。表达式在
cook()阶段编译成字节码,之后每次求值就是一次普通的方法调用,没有解释器逐节点遍历的开销,这在表达式复用率极高的场景下性能提升非常明显。 - 静态类型检查在编译期兜底。Aviator 很多错误要到运行期踩到这条规则才发现,Janino 在启动编译时就能把类型不匹配、方法不存在这类问题全部暴露。
- 依赖早就存在。由于日志组件已经依赖 Janino,我们的服务里本来就有它的 jar,不用额外引一堆东西。
1.4 什么情况下不建议迁移
这里必须泼一盆冷水。Janino 不是银弹,如果你的项目和下面几种情况匹配,那不要迁:
- 表达式使用者不是 Java 背景,他们已经能熟练写 AviatorScript 的
let、if、循环甚至自定义函数,迁移等于让他们学一门新语言。 - 请求场景是"每次来一个全新表达式",编译开销会吃掉执行收益,Aviator 反而合适。
- 表达式来自不可信输入、需要严格沙箱隔离,执行完整 Java 语法风险更高,Aviator 这类 DSL 反而更安全。
我们之所以能迁,是因为规则维护者是工程团队本身,而且表达式复用率极高,规则量虽多但全集基本稳定。想清楚这几条,再往下做。
2. 语法对照:从 Aviator 到 Janino 的核心差异清单
迁移的第一步不是写代码,而是把两边语法差异彻底摸清。Aviator 是"长得像 Java 但不是 Java",Janino 是"就是 Java",这中间的差距比想象中大得多。
2.1 字面量:单双引号、nil、数字类型
最直观的差异是字符串引号。Aviator 里'abc'和"abc"都是字符串,Janino 里只认"abc",'a'是 char 类型。这个差异看起来人畜无害,实际上埋着一颗大雷,后面踩坑部分我会专门讲。
nil对应 Java 的null,但语义上 Aviator 更宽松,比如nil == nil是合法的,而 Java 里null == null也是合法的,这一点倒没有坑。
数字类型差异容易被忽略。Aviator 5 里整数默认按Long处理,而 Java 的整数字面量默认是int。举例来说,表达式里写一个超过 21 亿的常量3000000000,Aviator 直接认,Java 字面量不加L后缀直接编译报错。参数声明的时候也要注意,DB 里BIGINT字段在 Java 侧是Long,如果你把 Janino 参数声明成int,一旦值超范围就静默截断,这在规则引擎里属于致命错误。
| 含义 | Aviator | Janino | 注意事项 |
|---|---|---|---|
| 字符串 | 'abc'或"abc" | "abc" | 单引号在 Java 里是 char,多字符直接编译失败 |
| 空值 | nil | null | 方法调用前要判空 |
| 布尔 | true/false | 相同 | 无差异 |
| 大整数 | 3000000000 | 3000000000L | 必须加L,否则编译报错 |
| 小数 | 1.5 | 1.5 | 默认Double,两边一致 |
2.2 运算符:==、=~、整数除法
这是整个迁移中最凶险的区域,因为表面看起来运算符一模一样,实际语义却在悄悄地漂移。
==是第一个重灾区。Aviator 的==是值比较,两个 String 内容一样就是 true。Java 的==对基本类型做值比较,对对象做引用比较。city == "Shanghai"这段代码在 Java 里能不能返回 true,完全取决于city这个字符串是否走了常量池的intern。如果city是从 HTTP 请求体解析出来的,那它在堆上是新对象,==返回 false,规则静默失效。Aviator 迁移到 Janino,所有字符串比较必须换成"Shanghai".equals(city)或者city != null && city.equals("Shanghai")。
=~正则匹配运算符在 Java 里没有对应物。Aviator 直接写name =~ '^VIP.*',Java 里一般用name.matches("^VIP.*")或者Pattern.compile(...).matcher(name).find()表达。这里还有个语义细节:String.matches()要求全量匹配,而某些 Aviator 版本中=~可能是"部分匹配"语义,这个差异会在踩坑部分细说。
整数除法也要打醒十二分精神。Java 里5 / 2结果是 2,整除截断;5.0 / 2才是 2.5。Aviator 在不同版本里对/的处理有变化,有些版本对整数对也做浮点除法。这是"不能靠文档推断、必须靠回归测试确认"的典型场景。
补充一张运算符速查表:
| 运算 | Aviator | Janino | 注意 |
|---|---|---|---|
| 字符串相等 | s == 'a' | "a".equals(s) | 千万别直接用== |
| 字符串不等 | s != 'a' | !"a".equals(s) | 注意判空 |
| 正则匹配 | s =~ '^\\d+$' | s.matches("^\\\\d+$") | 注意全匹配/部分匹配差异 |
| 逻辑与或非 | &&||! | 相同 | 无差异 |
| 三元运算 | c ? a : b | 相同 | 类型需一致,否则编译失败 |
| 整除 | / | / | 版本差异,回归实测 |
2.3 内置函数映射表
Aviator 的内置函数是它易用性的关键,但迁到 Janino 后这些函数名全部失效,必须映射成 Java 方法调用。映射思路很简单:Aviator 的函数是"函数名(参数...)"风格,Java 里大多数变成"对象.方法(剩余参数)"风格。
下面是我们整理的核心映射表,基本覆盖了线上 90% 的存量表达式:
| Aviator | Janino | 说明 |
|---|---|---|
math.abs(x) | Math.abs(x) | |
math.round(x) | Math.round(x) | 注意返回类型,double 返回 long |
math.sqrt(x) | Math.sqrt(x) | |
math.pow(x, y) | Math.pow(x, y) | |
math.max(a, b) | Math.max(a, b) | |
string.length(s) | s.length() | 对象前置 |
string.substring(s, a, b) | s.substring(a, b) | 参数顺序变化 |
string.contains(s, v) | s.contains(v) | |
string.upper(s) | s.toUpperCase() | |
string.lower(s) | s.toLowerCase() | |
string.trim(s) | s.trim() | |
string.split(s, r) | s.split(r) | r 是正则表达式 |
seq.size(c)/count(c) | c.size() | 集合/数组 |
seq.get(c, i) | c.get(i) | 数组要用下标 |
seq.list(a, b, c) | java.util.Arrays.asList(a, b, c) | |
seq.map('k', v) | java.util.Map.of("k", v) | Java 9+,低版本手动 new HashMap |
println(x) | System.out.println(x) | 规则里不建议用,影响性能 |
映射表看着简单,但自动转换时有个隐藏难点:string.substring(s, a, b)转换成s.substring(a, b)不只是替换函数名,还要把第一个参数"提升"为调用主体。如果参数本身是复杂表达式,比如string.substring(string.trim(name), 0, 2),转换逻辑就要处理好嵌套。
2.4 集合与 Map 的写法差异
Aviator 里构造一个 List 或 Map 有专门函数,Janino 里直接走 Java 集合体系。这不仅仅是写法差异,还涉及参数类型注册。
Aviator 侧:
seq.list(1, 2, 3) -- List<Object> seq.map('a', 1, 'b', 2) -- MapJanino 侧:
java.util.Arrays.asList(1, 2, 3) java.util.Map.of("a", 1, "b", 2)注意两个问题:第一,如果表达式需要遍历或按下标取seq.get(list, 0),转换后要保证参数类型注册成java.util.List而不是Object,否则list.get(0)编译不过。第二,如果规则里允许传入空集合,Java 侧参数类型用List接口而不要用ArrayList具体类,否则环境变量里传进来的是其他 List 实现,Janino 参数赋值时会类型不符。
在我们的规则里,集合操作高频出现,所以类型注册时把java.util.List、java.util.Map、java.util.Set都提前做了 import。
2.5 完整示例:一行里的全部变换
把上面的差异集中到一行对比更直观。这是规则库里真实出现过的组合表达式:
Aviator 原版:
age >= 18 && (city == 'Beijing' || city == 'Shanghai') && amount <= 5000 && string.contains(level, 'PLUS') && seq.size(tags) >= 2转换后的 Janino:
age >= 18 && ("Beijing".equals(city) || "Shanghai".equals(city)) && amount <= 5000 && level.contains("PLUS") && tags.size() >= 2这一行里同时体现了:单引号转双引号、==转equals且常量前置防空指针、string.contains(s, v)转s.contains(v)、seq.size(tags)转tags.size()。看清楚这几个变换,后面的转换器设计就有一个明确靶子。
3. 迁移实操:从人工改写升级为半自动转换器
语法对照完,接下来就是动手。我们的存量表达式有几千条,不可能全靠人肉逐条改,也不放心全自动无脑替换,所以方案是"半自动转换器 + 人工复核 + 双跑回归",这个组合最后证明是效率最高的。
3.1 先盘点:把表达式收拢分类
动手前第一件事是把散落在各处的表达式收拢。我们的表达式来源主要有三个:配置中心、代码硬编码、历史测试用例。先写脚本全量导出到一个文件,然后按特征做分类统计。
分类维度建议看这几种:
- 纯数值运算型:
amount * ratio > 100,这类风险最低,基本只涉及类型声明。 - 纯字符串比较型:
city == 'Beijing',这类必须做==到equals的转换。 - 集合判断型:
seq.size(x)、seq.contains(x, y),涉及参数类型注册。 - 混合复杂型:三元嵌套、正则、
math函数,这类建议人工优先复核。
分类结果出来后,我们按"规则覆盖了线上多少调用量"而不是"规则数量"排优先级,因为有些规则虽然只有几十条,但请求流量极大,它们的正确性优先级最高。
3.2 转换器设计:分词、引号处理、函数映射
转换器是整个迁移的核心工具。最朴素的想法是正则库替换,但正则处理嵌套结构天生缺陷。一个简单的例子,string.substring(string.trim(name), 0, 2)用正则string\.substring\(([^)]*)\)去匹配,遇到嵌套括号直接乱套。
我的建议是写一个很薄的词法扫描器,核心就三件事:一,区分普通代码区和字符串区,字符串内部的nil、=~、引号都不要动;二,在普通代码区把单引号字符串替换成双引号;三,识别已知函数名,用括号配对的方式提取参数,按映射规则重组表达式。
下面是一个核心逻辑的示意,真正落地可以在这个基础上补健壮性:
public class AviatorToJaninoConverter { public String convert(String aviatorExpr) { // 1. 先做词法扫描,把单引号字符串替换为双引号字符串 String expr = replaceSingleQuotedStrings(aviatorExpr); // 2. 字面量替换 expr = expr.replaceAll("\\bnil\\b", "null"); // 3. 运算符替换(注意只处理普通代码区,字符串内不处理) expr = replaceRegexOperator(expr); // 4. 函数映射,括号配对提取参数 expr = replaceKnownFunctions(expr); return expr; } private String replaceSingleQuotedStrings(String src) { // 示意图:真正的实现要处理转义字符 \' 和双引号字符串内的单引号 StringBuilder sb = new StringBuilder(); boolean inSingleQuote = false; for (int i = 0; i < src.length(); i++) { char c = src.charAt(i); if (c == '\'' && !inSingleQuote) { inSingleQuote = true; sb.append('"'); } else if (c == '\'' && inSingleQuote) { inSingleQuote = false; sb.append('"'); } else { sb.append(c); } } return sb.toString(); } private String replaceKnownFunctions(String expr) { // 映射表驱动,用括号配对找到函数参数区间 // "string.length(" -> 提取参数 arg0 -> "$arg0.length()" // "math.abs(" -> 直接替换前缀为 "Math.abs(" return expr; } }这里必须强调:上面这段只是核心思路,不是生产级代码。真实转换器还要处理\'转义、双引号字符串内部的单引号、字符串拼接里的函数调用、大小写敏感等问题。我个人强烈建议在转换器外面包一层单测,把规则库里每一类表达式各抽 5 条当测试用例,保证任何一次转换器迭代不会破坏存量。
转换和人工的比例我们最后统计大概是这样的:80% 的表达式可以全自动转换,15% 需要微调(主要是类型声明和参数顺序),5% 需要完全手写(主要是包含 AviatorScript 流程控制的复杂脚本)。
3.3 Janino 接入层封装与编译缓存
表达式转换完成后,需要一个统一入口对接规则服务。这里最关键的设计是编译缓存。
Janino 的cook()是编译过程,比 Aviator 的 execute 要重。如果每条请求都现场 cook,性能反而会变差。好在规则场景下表达式全集基本稳定,完全可以在启动时把所有表达式批量编译,之后每次请求只做方法调用。
我们的接入层大概长这样:
public class JaninoRuleEngine { private final ConcurrentHashMap<String, ExpressionEvaluator> cache = new ConcurrentHashMap<>(); private final AviatorToJaninoConverter converter = new AviatorToJaninoConverter(); private final VariableRegistry registry; public JaninoRuleEngine(VariableRegistry registry) { this.registry = registry; } public Object eval(String rule, Map<String, Object> env) { ExpressionEvaluator evaluator = cache.computeIfAbsent(rule, key -> { String javaExpr = converter.convert(key); ExpressionEvaluator ee = new ExpressionEvaluator(); // 规则引擎大部分规则最终返回布尔,显式声明返回类型 ee.setReturnType(boolean.class); ee.setParameters(registry.names(), registry.types()); ee.setExpression(javaExpr); ee.cook(); // 编译期错误都在这里集中暴露 return ee; }); return evaluator.evaluate(registry.buildArgs(env)); } }几个设计细节值得说明:
- 用
ConcurrentHashMap做缓存,等于把"表达式是否编译过"这件事交给 map 处理,天然支持并发读。 setReturnType(boolean.class)让 Janino 在 cook 阶段就尝试把表达式结果转换为布尔,规则引擎里大量==之外的表达式可以提前发现类型问题。- 参数名和参数类型来自统一的
VariableRegistry,不直接拿 env 的 key 动态设置。原因是参数顺序必须稳定,否则同一个 Evaluator 复用时会传错参数。
3.4 参数类型注册:迁移中最容易翻车的一环
参数类型声明是整个迁移中我们栽过最多跟头的地方,值得单独说。
Aviator 的 env 是一个Map<String, Object>,值的类型天然宽松。传入一个 Integer、传一个 Long、传一个 BigDecimal,Aviator 在求值时都能凑合比较。Janino 不行,setParameters时类型就定死了,int参数传Long值会直接报类型不匹配。
我们的解决思路是建立一套变量注册表,每个变量固定映射到一种类型。关键决策有三个:
第一,可能为 null 的数字字段不要用基本类型。用int声明参数,求值传 null 会直接空指针。规则里很多字段本来就是"未设置",应该声明成Integer,然后在表达式里显式判断:
// 推荐:声明 Integer,表达式中先判空再比较 age != null && age >= 18 // 错误示范:声明 int,age 为 null 时直接异常 age >= 18第二,DB 里的长整型字段一律按long或Long注册,不要图省事用int。我们线上出过一次事故,一个订单金额字段在 DB 是 BIGINT,注册表里写成了 int,某天金额超过 21 亿后规则静默判错,排查了很久。
第三,集合类型的注册尽量用接口。参数声明成java.util.List,别用ArrayList,这样调用方传任何 List 实现都能兼容。Map 同理。
类型注册表还可以加一层"启动自检":启动时把每个表达式编译成功后,再用几组内置的 mock 数据跑一遍 smoke test,任何类型问题在发布前就暴露。
4. 语义对齐:双跑、回归与灰度上线的完整方案
转换器写完后,最紧张的时刻来了:怎么证明"转换后的 Janino 表达式"和"原 Aviator 表达式"在一切输入下结果一致?这里没有任何捷径,必须做双跑对比回归,并且灰度节奏要克制。
4.1 双跑对比测试怎么设计
双跑的核心思路是:同一个表达式、同一份环境变量,分别用 Aviator 老引擎和 Janino 新引擎求值,然后比对结果。
测试框架的骨架很简单:
public class ExpressionMigrationTest { private final AviatorEvaluator aviator = AviatorEvaluator.getInstance(); private final JaninoRuleEngine janino = new JaninoRuleEngine(registry); @Test public void compareAllExpressions() { List<String> rules = loadAllRules(); List<Map<String, Object>> samples = loadTestSamples(); for (String rule : rules) { for (Map<String, Object> env : samples) { Object oldResult = runOnAviator(rule, env); Object newResult = runOnJanino(rule, env); compareResult(rule, env, oldResult, newResult); } } } private void compareResult(String rule, Map<String, Object> env, Object oldResult, Object newResult) { // 归一化比较,不能用 equals 直接比 // 因为 Aviator 可能返回 Long 而 Janino 返回 Integer } }double-run 比对有个细节:不能直接.equals()。Aviator 返回的整数是Long,Janino 返回的可能是Integer,两者内容相同但类型不同,直接用 equals 会误报。我们的compareResult里做了一层归一化:数字类型的统转成BigDecimal之后比较数值,字符串用equals,布尔直接比较,null 对 null。
4.2 边界用例清单
双跑测试的价值取决于样本集,样本不是随便抽几条,而要按边界去构造。我们最终沉淀了一张边界用例清单,每个规则至少跑下面这些场景:
| 场景 | 样例值 | 目的 |
|---|---|---|
| 空值 | age = null,city = null | 检查空指针和 nil 语义差异 |
| 零值 | amount = 0,count = 0 | 检查除零和边界判断 |
| 负值 | score = -1 | 检查负数比较 |
| 大数 | amount = 3000000000 | 检查 int/long 溢出 |
| 数字字符串 | age = "18" | 检查 Aviator 自动转型语义 |
| 空集合 | tags = [] | 检查集合函数空处理 |
| 超长字符串 | city = "Shanghai "(带空格) | 检查是否涉及 trim |
| 特殊正则字符 | name = "a.b" | 检查正则语义 |
最耗时间的是"数字字符串"这类场景。Aviator 在某些上下文中会把数字字符串自动转成数值比较,比如"18" == 18在你的版本里可能返回 true,而 Java 直接编译不过。这种规则不能强转,只能人工判断业务意图,看是要比较字符串还是数值,然后显式改写成Integer.valueOf(ageStr) == 18或"18".equals(ageStr)。
4.3 灰度上线的节奏
双跑通过不代表可以直接切换。我们的灰度分了三个大阶段:
第一阶段是影子模式。规则服务加一个配置开关rule.engine=shadow,该模式下所有规则继续用 Aviator 求值,但与此同时用 Janino 也跑一遍,结果只记录不返回。每次请求附带一个日志采样,收集两侧结果不一致的 case。这一步能覆盖测试用例想不到的线上真实分布。
第二阶段是分批切换。按规则分类和流量大小,先把纯数值运算、低流量规则切到 Janino 跑真实流量,观察错误率、时延、结果分布。这一步跑两周没有 diff 后,再切高流量规则。
第三阶段才是全量下线 Aviator。这里有个容易被忽略的问题:Aviator 的 jar 不引了,但历史规则文件里还留着旧的 Aviator 版表达式。我们的处理是在配置中心里做一次全量刷新,把所有规则的新旧表达式都存下来留审计,同时清理掉依赖 Aviator 的代码路径。
灰度期间建议盯的不是"平均时延",而是"P99 时延"和"结果分布曲线"。表达式引擎切换最常见的隐性故障不是崩溃,而是某一条规则在特定输入下静默返回了相反的结果,这个只有靠双跑 diff 和灰度数据能发现。
5. 踩坑实录:来自真实迁移的六条经验
这一节是整篇文章的精华。双跑测试和灰度帮我们拦下了大部分问题,但有一些坑是在线上真实流量里才暴露的,都是在测试集覆盖不到的地方。
5.1 单引号字符串在 Java 里悄悄变成了 char
这个坑的隐蔽之处在于:大多数单引号字符串转换后都没问题,因为'abc'转成"abc"是合法字符串,编译能过。但单个字符的字符串,比如'A',转成"A"也合法,真正的问题是用户可能写的是string.upper('a')这种,转换后变成"a".toUpperCase(),也没问题。
真正翻车的是字符参与运算的场景。Aviator 里'a' + userId,+如果遇到字符串会做拼接,结果是"a123"。转换到 Java 后如果被误处理成'a' + userId,单引号没替换,那'a'就是 char,char 加 int 直接变成整数运算,得到 97 + 123 = 220。这种 bug 不会崩溃,但结果完全错误,测试样例如果没有覆盖"单字符拼接"就发现不了。
排查经验:双跑 diff 里一旦出现"新结果是数字、老结果是字符串"的情况,优先怀疑单引号字符。解决方案不是全局替换,而是转换器在词法扫描时把"单引号包一个任意字符"的情况拿出来人工确认。
5.2 字符串比较运算符的语义漂移
这个前面提过,但线上踩到的版本比想象的更隐蔽。规则里有这么一条:
city == 'SHANGHAI'双跑测试环境里 env 里的 city 值来自测试代码,用的是字符串常量"SHANGHAI",恰好走了常量池,Java 里==返回 true,双跑通过了。但线上请求的 city 是从请求体反序列化出来的新字符串对象,不在常量池,==返回 false,规则失效。
这就是为什么字符串比较的转换必须是无条件的、强制性的,不能依赖"碰巧 intern 成功"。我们后来在转换器里加了一条硬性规则:凡是左侧或右侧出现字符串字面量且两侧类型为 String 的==,一律改写成字面量前置的equals。!=同理改写成!"Shanghai".equals(city)。
5.3 整数除法,版本不同结果可能不同
规则里出现过这样的表达式:
actualAmount / maxAmount > 0.5双跑测试用的是 Aviator 5.x,/返回浮点,跟 Java 的(double) actualAmount / maxAmount一致,测试通过。但翻看历史 commit 后我们发现,同样这条规则在 Aviator 旧版本里/是整数截断,意味着"业务在某个时期看到的线上行为"和"我们用新版本 Aviator 双跑得到的结果"可能本来就不同。
这件事给我最大的教训是:迁移的"正确基线"应该是线上当前真实行为,而不是"新版本 Aviator 的行为"。所以做双跑时,最好用线上实际运行的 Aviator 版本作为 oracle,而不是随手引一个最新版。除法涉及精度时,在转换结果中显式加(double)强转,把语义钉死。
// 推荐:显式声明浮点除法 (double) actualAmount / maxAmount > 0.5 // 如果业务要的就是截断除法 Math.floorDiv(actualAmount, maxAmount) > 05.4 null 与 nil 的宽松处理差异
Aviator 对nil的态度比较柔和,很多函数遇到 nil 不会炸,而是给一个默认值或 false。Janino 是 Java,直接调用方法遇到 null 就是裸奔的NullPointerException。
典型场景:
seq.contains(userTags, 'VIP')如果userTags是nil,Aviator 里seq.contains返回 false,规则正常往下走。转换到 Java 后,userTags.contains("VIP")直接 NPE,一条规则崩溃导致整个请求失败。
我们的修复方案分两层。第一层,转换器把seq.contains(x, y)这种函数统一转换成x != null && x.contains(y),天然规避空指针。第二层,对线上崩溃日志做监控,凡是在表达式里出现的 NPE,反向去补对应变量的判空。这个策略执行下来,规则表达式反而比原来的 Aviator 版本更健壮了。
5.5 大整数必须小心 int 与 long
这个坑差点酿成线上事故。规则里有一个字段是订单编号orderId,DB 里是 BIGINT,Aviator 环境变量里传的是Long。我们做参数注册时,看到表达式里写的是orderId % 2 == 0,想当然给它声明成了int类型。
双跑测试用的 orderId 都是正常的小值,没暴露问题。灰度期间某天某个大订单进来,orderId 超过 Integer 范围,Java 参数赋值把 Long 转为 int 时发生了溢出,取模结果错了,规则把奇数订单判成了偶数,直接影响了策略命中。
修复很简单,把参数类型改成long。但这件事的启发是:参数类型注册表不能靠"看表达式猜",必须对照数据源头字段类型。我们后来在 VariableRegistry 里加了一个字段类型来源配置,从 DAO 的 entity 定义里自动生成,不再手工维护。
5.6 正则匹配是全匹配还是部分匹配
这是最后一个上线的坑,也是对存量规则语义影响最大的一个。规则里有一条:
level =~ 'VIP\\d+'这条规则的本意是匹配"VIP 开头加数字"的等级。Aviator 里=~底层的正则匹配走了Pattern.matcher(str).find(),即部分匹配,所以VIPabcVIP123这种字符串也能命中,因为中间有VIP123。转换到 Java 后,我们按直觉写成了:
level.matches("VIP\\d+")String.matches()是全量匹配,VI这种就完全匹配不上,规则永远返回 false。
这个问题的解法不是一刀切,而是转换器把=~统一暴露成两种形态:如果原正则本身带^...$锚点,说明作者明确要全量匹配,用matches();如果没带锚点,就按 find 语义用Pattern.compile(regex).matcher(str).find()转换。更重要的是,这类规则必须人工复核一遍,因为正则语义和 Aviator 版本强相关,自动化只能减少概率,不能根除。
我把这些坑整理成了一张速查表,后续任何新规则的转换评审都对着它看:
| 症状 | 原因 | 一键修复 |
|---|---|---|
| 新结果是数字,老结果是字符串 | 单字符单引号被当 char | 单引号转双引号时逐个确认 |
| 规则在线上静默失效 | ==比较字符串引用 | 无条件改equals |
| 除法结果对不上 | 整数除法语义随 Aviator 版本漂移 | 显式(double)或Math.floorDiv |
| 表达式 NPE | Aviator nil 宽松语义丢失 | 转换后自动补判空 |
| 大字段值异常 | int/long 参数类型声明错误 | 类型注册表对齐数据源 |
| 正则规则永远 false | matches全匹配 vsfind部分匹配 | 根据锚点选匹配方式 |
6. 迁移完成后的几点体会
整个迁移从启动到全量切换大约持续了六周,实际写转换器只用了四天,大部分时间都花在双跑回归和灰度观察上。回头总结,有三件事如果能重来,我会一开始就做。
第一,先把双跑框架搭起来,再动转换器。我们一开始先写了 80% 的转换规则,结果发现语义基线没定清楚,很多转换规则是建立在错误假设上的,后面返工浪费了不少时间。正确的顺序应该是:先抽一批代表性表达式,把 Aviator 的线上行为摸清楚,再写转换器。
第二,不要追求 100% 自动转换。有些规则里混着 AviatorScript 的let、if甚至自定义函数,强行自动转换只会制造一堆需要人工 review 的不确定代码。我们最后对这类规则走了"人工重写 Java 逻辑 + 双跑验证"的路径,反而更稳。
第三,转换器本身要当成一等公民来维护。它不是一次性的脚本,而是以后新增规则的必经之路。每次有新规则上线,都先走一遍"Aviator 原版存留、转换器生成 Java 版、双跑测试通过"的流程,这样引擎切换带来的语法迁移,变成了一个可持续的规范化过程。
最后分享一个我们保留到现在的小技巧:在代码仓库的 CI 流程里挂了一个检查,凡是在配置中心新增或修改 Aviator 表达式,都会自动触发转换器生成 Java 表达式,并且对产物做一次 Janino 编译。编译不过直接拦截提交。这个检查让"表达式可编译"从运行期问题提前到了提交期,省掉的线上事故比想象中多得多。