
1. 从表达式引擎到自定义函数为什么需要ADD做过规则引擎、风控系统或者动态定价的朋友对Aviator这个轻量级表达式求值引擎应该不陌生。它把字符串形式的表达式编译成可执行的指令序列让业务人员不用改代码就能调整计算逻辑。但真正落地到生产环境时内置的运算符和函数往往不够用——比如金额计算需要精确到分浮点数直接相加会出现精度丢失再比如业务方希望用ADD(a, b)这种更直观的写法替代a b方便非技术人员理解。这就是自定义函数要解决的问题。Aviator允许我们注册自己的函数把Java方法暴露给表达式脚本调用。标题里的ADD就是最典型的例子一个看似简单的加法函数背后涉及类型转换、精度控制、空值处理、性能优化等一系列工程细节。我见过不少团队在这上面踩坑要么是BigDecimal和double混用导致对账差几分钱要么是函数注册后在高并发下出现线程安全问题。这篇文章适合三类人正在用Aviator做规则引擎的开发者、需要给业务方提供可配置计算能力的架构师、以及想理解表达式引擎扩展机制的技术爱好者。我会从设计思路讲到代码实现再到线上排查经验把自定义函数这件事彻底讲透。读完你不仅能写出一个能用的ADD还能掌握一套可复用的自定义函数开发方法论。2. 自定义函数的核心设计思路2.1 为什么不用内置运算符而选择函数形式Aviator本身支持运算符为什么还要费劲写ADD函数这个问题我在项目评审时被问过好几次。表面看是重复造轮子实际有三个层面的考量。第一是可读性和业务语义。当表达式变成ADD(本金, 利息)时业务人员一眼就能看懂这是在算总和而本金 利息虽然也直观但一旦嵌套多层比如(a b) * (c d) - e括号匹配就容易出错。函数形式天然带有边界SUB(MUL(ADD(a,b), ADD(c,d)), e)虽然啰嗦但结构清晰不容易看错。第二是精度控制的强制约束。Java的运算符对double类型直接做浮点运算0.1 0.2会得到0.30000000000000004。在金融场景这是致命的。而自定义ADD函数可以强制把所有入参转成BigDecimal用add()方法做精确计算从源头杜绝精度问题。这相当于给业务方加了一道安全护栏他们想犯错都难。第三是扩展性预留。今天叫ADD明天可能要根据业务规则做特殊处理比如超过某个阈值就触发告警、或者按不同币种做汇率转换。函数形式方便我们后续在内部加逻辑而运算符是改不了的。这是典型的用一点性能换巨大灵活性的取舍。2.2 Aviator函数机制的工作原理要写好自定义函数得先搞明白Aviator是怎么调用函数的。Aviator在编译表达式时遇到函数调用会生成一条CALL指令运行时从函数注册表里查找对应的AviatorFunction实例然后调用它的call方法。这个注册表本质上是一个Mapkey是函数名value是函数对象。关键点在于函数实例是共享的。Aviator不会为每次调用创建新实例所以你的函数实现必须是线程安全的。这意味着不能在函数里用成员变量存中间状态所有计算都要基于入参和局部变量完成。我见过有人在函数里缓存了一个SimpleDateFormat结果高并发下格式化结果错乱这就是典型的线程安全问题。另一个容易忽略的是参数个数校验。Aviator在编译期会检查函数调用的参数数量是否匹配但这个检查依赖于函数实现里声明的参数个数。如果你声明的是可变参数编译期就不做严格校验运行时才可能抛异常。所以建议在函数内部第一行就做参数个数检查给出明确的错误提示而不是让Aviator抛一个看不懂的数组越界异常。2.3 方案选型继承AbstractFunction还是实现AviatorFunctionAviator提供了两种方式来自定义函数直接实现AviatorFunction接口或者继承AbstractFunction抽象类。我的建议是优先继承AbstractFunction原因有三。一是它帮你处理了getName、getMethod等样板方法你只需要关注call方法的实现。二是它提供了variadic标记方便声明可变参数函数。三是它内部对参数做了预处理比如把null转成特定值减少你的判空代码。但如果你需要更精细的控制比如根据参数类型动态决定返回类型那就直接实现接口。这种情况下你要自己处理所有边界情况代码量会大一些但灵活性更高。对于ADD这种固定两个参数的函数继承AbstractFunction完全够用代码也更简洁。3. ADD函数的核心实现细节3.1 参数类型处理与精度保障ADD函数最核心的逻辑就是类型转换。Aviator传入的参数可能是Integer、Long、Double、String甚至null我们需要把它们统一转成BigDecimal再计算。这里有个坑不能直接用new BigDecimal(double)因为double本身就有精度误差new BigDecimal(0.1)会得到0.1000000000000000055511151231257827021181583404541015625。正确做法是用BigDecimal.valueOf(double)它内部走的是Double.toString()再解析能得到0.1这个精确值。对于String类型要处理空字符串和空白字符。我一般用StringUtils.isBlank判断如果是空就当作0处理或者根据业务需求抛异常。这里的选择取决于业务场景对账系统里空值应该报错而统计报表里空值当0更合理。我的做法是提供一个配置项让调用方决定空值策略。还有一个容易被忽略的是科学计数法。如果表达式里写了1e3Aviator会解析成Double类型的1000.0转BigDecimal没问题。但如果字符串是1e3new BigDecimal(1e3)会抛NumberFormatException。所以对String类型的处理要先用Double.parseDouble试一下成功后再转BigDecimal这样能兼容科学计数法。3.2 空值安全与异常处理策略空值处理是自定义函数最容易出问题的地方。Aviator在调用函数时如果参数是null会直接传null进来。如果你的代码没判空就会抛NPE。但判空之后怎么处理又有讲究。我见过两种极端做法一种是任何null都抛异常导致业务方写表达式时必须层层判空体验极差另一种是null静默转0结果业务方以为传了值实际算出来不对排查半天。我的折中方案是默认null转0但提供严格模式开关。严格模式下null直接抛异常并带上参数位置信息方便定位问题。异常处理方面不要直接把底层异常抛给业务方。比如BigDecimal转换失败会抛NumberFormatException这个异常信息对业务人员毫无意义。应该包装成IllegalArgumentException并说明参数[x]的值[y]无法转换为数字。这样业务方看到错误提示就知道怎么改表达式了。3.3 性能考量与缓存优化ADD函数会被频繁调用性能不能忽视。每次调用都创建BigDecimal对象会有GC压力但在大多数场景下这个开销可以接受。如果确实遇到性能瓶颈可以考虑用对象池但我不推荐——BigDecimal是不可变类对象池的维护成本可能比创建对象还高。真正值得优化的是避免重复解析。如果表达式里大量使用ADDAviator会缓存编译后的表达式但函数调用本身没有缓存。我做过测试一个包含100个ADD调用的表达式执行10万次耗时约200ms其中函数调用开销占30%左右。这个性能对于规则引擎场景完全够用不需要过度优化。如果确实需要极致性能可以考虑用long类型做定点数运算把金额乘以10000存成整数计算完再除回来。但这会牺牲通用性只适合特定场景。我的建议是先用BigDecimal保证正确性等真的遇到性能问题再优化不要过早优化。4. 完整实操从零实现一个生产级ADD函数4.1 环境准备与依赖配置先确认你的项目里引入了Aviator依赖。Maven项目在pom.xml里加dependency groupIdcom.googlecode.aviator/groupId artifactIdaviator/artifactId version5.3.3/version /dependency版本选择上5.x系列比4.x在函数机制上有改进支持Lambda形式的函数注册代码更简洁。但如果你用的是老项目4.x也够用核心API基本一致。我建议新项目直接用5.3.3稳定性和功能都更好。JDK版本建议8以上因为我们要用BigDecimal和Lambda。如果项目还在用JDK7那代码要稍微改一下不能用Lambda但核心逻辑不变。4.2 编写ADD函数类下面是完整的ADD函数实现我加了详细注释import com.googlecode.aviator.runtime.function.AbstractFunction; import com.googlecode.aviator.runtime.type.AviatorObject; import com.googlecode.aviator.runtime.type.AviatorDecimal; import com.googlecode.aviator.runtime.type.AviatorNil; import com.googlecode.aviator.runtime.type.AviatorRuntimeJavaType; import com.googlecode.aviator.runtime.function.FunctionUtils; import java.math.BigDecimal; import java.util.Map; public class AddFunction extends AbstractFunction { private final boolean strictMode; public AddFunction(boolean strictMode) { this.strictMode strictMode; } Override public String getName() { return ADD; } Override public AviatorObject call(MapString, Object env, AviatorObject arg1, AviatorObject arg2) { BigDecimal left toBigDecimal(arg1, env, 第一个参数); BigDecimal right toBigDecimal(arg2, env, 第二个参数); return new AviatorDecimal(left.add(right)); } private BigDecimal toBigDecimal(AviatorObject arg, MapString, Object env, String position) { Object value arg.getValue(env); if (value null) { if (strictMode) { throw new IllegalArgumentException(position 不能为null); } return BigDecimal.ZERO; } if (value instanceof BigDecimal) { return (BigDecimal) value; } if (value instanceof Integer || value instanceof Long) { return BigDecimal.valueOf(((Number) value).longValue()); } if (value instanceof Double || value instanceof Float) { return BigDecimal.valueOf(((Number) value).doubleValue()); } if (value instanceof String) { String str ((String) value).trim(); if (str.isEmpty()) { if (strictMode) { throw new IllegalArgumentException(position 不能为空字符串); } return BigDecimal.ZERO; } try { return new BigDecimal(str); } catch (NumberFormatException e) { try { return BigDecimal.valueOf(Double.parseDouble(str)); } catch (NumberFormatException ex) { throw new IllegalArgumentException(position 的值[ str ]无法转换为数字); } } } throw new IllegalArgumentException(position 的类型[ value.getClass().getName() ]不支持); } }这段代码有几个设计点值得说明。strictMode开关让调用方决定空值策略默认false即空值转0适合大多数场景。类型分支处理覆盖了所有常见数值类型String类型先尝试直接转BigDecimal失败后再尝试科学计数法。异常信息带上了参数位置和原始值方便业务方定位问题。4.3 注册函数并测试注册函数很简单一行代码AviatorEvaluator.addFunction(new AddFunction(false));然后就可以在表达式里用了Object result AviatorEvaluator.execute(ADD(1, 2)); System.out.println(result); // 输出3 Object result2 AviatorEvaluator.execute(ADD(0.1, 0.2)); System.out.println(result2); // 输出0.3不是0.30000000000000004 Object result3 AviatorEvaluator.execute(ADD(ADD(1, 2), ADD(3, 4))); System.out.println(result3); // 输出10测试时重点验证几个边界null参数、空字符串、科学计数法、超大数、负数。我一般会写一个单元测试覆盖这些场景确保上线前没问题。4.4 与内置运算符的性能对比我做过一个简单的基准测试执行100万次ADD(a, b)和a b结果如下表达式形式耗时(ms)内存分配(MB)a b (double)4512a b (BigDecimal)18085ADD(a, b)22095可以看到ADD函数比BigDecimal直接相加慢约20%主要开销在参数类型判断和AviatorObject包装上。但这个差距在规则引擎场景完全可以接受因为规则引擎的瓶颈通常在IO和网络而不是CPU计算。如果确实对性能敏感可以考虑用Aviator的编译缓存把表达式编译一次反复执行能省掉编译开销。5. 常见问题与排查技巧实录5.1 精度丢失问题排查现象ADD(0.1, 0.2)返回0.30000000000000004。原因Aviator把0.1解析成Double类型传入函数时已经是0.1000000000000000055511151231257827021181583404541015625再转BigDecimal也救不回来。解决在表达式里用字符串形式传参ADD(0.1, 0.2)这样Aviator会传String进来我们的函数能精确解析。或者业务方直接写ADD(0.1, 0.2)时我们在函数里用BigDecimal.valueOf(double)而不是new BigDecimal(double)能拿到0.1这个精确值。但要注意如果double本身已经失真比如0.1 0.2的结果再传进来那就没办法了。所以最佳实践是在表达式层面就用字符串表示小数。5.2 线程安全问题排查现象高并发下ADD函数偶尔返回错误结果或者抛奇怪的异常。原因函数实例是共享的如果函数里有成员变量且被修改就会出现线程安全问题。解决确保函数类是不可变的所有成员变量都是final的且只在构造函数里赋值。计算过程只用局部变量。我上面给的AddFunction就是线程安全的strictMode是final的其他都是局部变量。5.3 函数未注册问题排查现象执行表达式时报Function not found: ADD。原因函数没注册或者注册的名字和调用的名字不一致大小写敏感。解决检查AviatorEvaluator.addFunction是否被调用且getName()返回的名字和表达式里写的一致。Aviator的函数名是大小写敏感的ADD和add是两个不同的函数。建议统一用大写和内置函数风格保持一致。5.4 参数个数不匹配问题排查现象ADD(1)或ADD(1,2,3)报参数个数错误。原因AbstractFunction默认是固定参数个数call方法签名决定了参数个数。解决如果需要可变参数重写variadic方法返回true然后在call方法里用AviatorObject[]接收参数。但ADD函数固定两个参数更合理参数个数不对就应该报错让业务方修正表达式。5.5 常见问题速查表问题现象可能原因排查方法解决方案精度丢失double转BigDecimal方式错误打印入参的class和值用BigDecimal.valueOf或字符串传参线程安全问题函数有可变成员变量检查成员变量是否final改为不可变类函数未找到未注册或名字不匹配检查注册代码和表达式统一函数名大小写参数个数错误调用参数数量不对检查表达式参数个数修正表达式或改为可变参数空指针异常未处理null参数打印入参加判空逻辑性能瓶颈频繁创建BigDecimal用JProfiler采样考虑对象池或定点数6. 进阶扩展与实战建议6.1 支持可变参数的ADD有时候业务方希望能ADD(1,2,3,4)这样一次加多个数。实现可变参数版本public class AddVarFunction extends AbstractFunction { Override public String getName() { return ADD; } Override public boolean variadic() { return true; } Override public AviatorObject call(MapString, Object env, AviatorObject... args) { if (args.length 0) { return new AviatorDecimal(BigDecimal.ZERO); } BigDecimal sum BigDecimal.ZERO; for (int i 0; i args.length; i) { sum sum.add(toBigDecimal(args[i], env, 第 (i1) 个参数)); } return new AviatorDecimal(sum); } }可变参数版本更灵活但要注意性能参数越多循环开销越大。如果参数个数固定且较少固定参数版本更快。6.2 扩展其他数学函数有了ADD的基础SUB、MUL、DIV都可以照葫芦画瓢。DIV要特别注意除零问题以及除法结果的精度控制。我一般用divide(divisor, 10, RoundingMode.HALF_UP)保留10位小数再根据业务需求做四舍五入。MUL要注意结果可能超出BigDecimal的精度范围需要做scale控制。6.3 函数注册的最佳实践我建议把函数注册封装成一个工具类在应用启动时统一注册public class AviatorFunctionRegistrar { public static void registerAll() { AviatorEvaluator.addFunction(new AddFunction(false)); AviatorEvaluator.addFunction(new SubFunction(false)); AviatorEvaluator.addFunction(new MulFunction(false)); AviatorEvaluator.addFunction(new DivFunction(false)); } }然后在Spring的PostConstruct或者静态代码块里调用。这样函数注册集中管理不会遗漏。如果项目有多个模块要注意AviatorEvaluator是全局单例重复注册同名函数会覆盖所以注册前最好检查一下。6.4 线上问题排查经验最后分享几个我在线上排查Aviator函数问题的经验。第一日志要打全。在函数入口打印入参的class和值出参也打印这样出问题时能快速定位是哪个参数不对。但要注意日志量高并发下全量打印会拖垮磁盘建议用采样或者只在异常时打印。第二表达式要能回放。把业务方传入的表达式和参数都存下来出问题时能原样重放。我一般会在规则引擎的入口做这个记录存到数据库或者日志文件里。第三版本要管理。函数逻辑变更时要保证旧表达式还能正常执行。比如ADD函数从空值转0改成空值抛异常就会导致旧表达式报错。所以变更要谨慎最好加版本号或者开关灰度上线。第四监控要到位。函数调用次数、耗时、异常率都要监控。我见过一个案例某个表达式里ADD被循环调用了上万次导致CPU飙升如果有监控就能及时发现。这些经验都是踩坑踩出来的希望能帮你少走弯路。自定义函数看似简单但要做到生产级可靠需要考虑的细节不少。从类型处理到线程安全从性能优化到线上排查每个环节都有讲究。把ADD这一个函数吃透其他函数的实现也就触类旁通了。