1. 从内置函数不够用说起为什么需要自定义ADDAviator 这个轻量级表达式引擎在规则计算、动态配置、指标加工这些场景里出镜率很高尤其是需要把业务逻辑从代码里抽出来交给运营或配置人员维护的时候它比引入一整套脚本引擎要轻得多。但真正把它用到生产环境里你很快会撞上一个现实问题内置函数库覆盖的是通用能力比如字符串截取、数学运算、集合判断可业务里那些看起来很简单的计算它偏偏没有。我最早遇到这个需求是在一个费用分摊的规则配置里。业务方希望表达式写成ADD(base, extra, adjust)这种形式而不是base extra adjust。原因很朴素他们从表格里复制粘贴过来的字段数量不固定用加号拼接容易漏掉一个而且一旦某个字段为空整个表达式直接抛异常。他们想要一个能自动跳过空值、能接受任意个数参数、还能对结果做兜底的加法函数。内置的运算符做不到这些于是自定义函数就成了唯一出路。这就是本篇要聊的核心在 Aviator 里实现一个自定义的ADD函数。别看名字简单它牵扯到的知识点一点都不少——函数注册的时机、参数个数的处理、空值与类型转换的边界、可变参数的实现方式、以及最容易被忽略的线程安全问题。我会把整个实现过程拆开讲透包括我踩过的几个坑让你看完能直接把这套代码搬进自己的项目里。适合谁看如果你已经在项目里引入了 Aviator或者正准备用它做规则引擎、动态计算、配置化指标那这篇就是给你写的。哪怕你只是好奇一个表达式引擎的扩展点长什么样跟着走一遍也能有收获。下面从环境准备开始一步步把ADD函数落地。2. 动手前的环境确认与依赖梳理2.1 版本选择与依赖引入Aviator 的版本迭代里自定义函数的 API 基本保持稳定但不同大版本之间还是有一些细节差异。我目前主力用的是 5.x 系列它的AviatorFunction接口和函数加载机制都比较成熟。如果你用的是更早的 4.x接口名和包路径会有出入建议先确认版本再动手。Maven 项目里引入依赖很直接dependency groupIdcom.googlecode.aviator/groupId artifactIdaviator/artifactId version5.4.3/version /dependency版本号按你项目实际情况来我这里写的是我验证过的稳定版本。Gradle 的话对应换成implementation com.googlecode.aviator:aviator:5.4.3即可。这里有个小提醒Aviator 本身依赖很少基本不会和你项目里的其他库冲突这一点比引入完整脚本引擎要省心得多。2.2 自定义函数的两种注册路径在写代码之前得先搞清楚 Aviator 提供了哪几种扩展方式因为选错了路径后面会很难受。我梳理下来主要是两条路第一条是全局注册通过AviatorEvaluator.addFunction把函数挂到全局的评估器上。这种方式最简单注册一次之后所有表达式都能用。缺点是全局状态如果你的应用里有多个互不干扰的规则场景函数名可能会打架。第二条是实例级注册自己 new 一个AviatorEvaluatorInstance在实例上注册函数。这样不同实例之间的函数互不影响适合多租户或者多规则集的场景。代价是你得自己管理实例的生命周期。我个人的选择是如果整个应用只有一套规则体系用全局注册就够了代码最少如果存在多套规则或者需要做隔离果断用实例级。本篇为了讲清楚原理先用全局注册演示后面会补充实例级的写法。2.3 一个最小可运行的验证骨架在正式写ADD之前我习惯先搭一个最小骨架确认环境通了再往里填逻辑。这样一旦出问题能快速定位是环境问题还是代码问题import com.googlecode.aviator.AviatorEvaluator; public class AddFunctionDemo { public static void main(String[] args) { Object result AviatorEvaluator.execute(1 2); System.out.println(result); } }跑出来是3说明依赖没问题。这一步看着多余但我见过太多人一上来就写复杂函数结果报错时分不清是环境没配对还是函数写错了白白浪费时间。先把地基踩实后面才稳。3. ADD函数的接口实现与参数处理逻辑3.1 AviatorFunction接口的核心方法自定义函数的入口是实现AviatorFunction接口它要求你提供两个关键信息函数名和调用逻辑。核心方法签名大致是这样public interface AviatorFunction { String getName(); AviatorObject call(MapString, Object env, AviatorObject... args); }getName()返回函数在表达式里被调用的名字这里就是ADD。call方法是真正的执行体env是当前表达式的上下文变量args是调用时传入的参数类型是AviatorObject的可变数组。注意这个AviatorObject它是 Aviator 对所有值的统一封装你不能直接当 Java 的Object用得先转换。很多人第一次写会卡在这里明明传进来的是数字为什么拿到的是AviatorObject因为 Aviator 需要统一处理它自己的类型体系包括它特有的nil、BigInt、Decimal等。理解这一点后面的类型转换就顺理成章了。3.2 可变参数的接收与个数校验ADD的定位是接受任意个数参数所以args的长度是不固定的。这里有个设计决策要想清楚参数个数为 0 或者 1 的时候怎么办我的处理原则是0 个参数直接返回 01 个参数直接返回它本身。这样ADD()得到 0ADD(x)得到 x语义上自洽也避免了调用方因为参数个数不确定而写一堆判断。这个约定看起来随意但在实际配置场景里非常有用——运营配的字段数量是动态的有了这个兜底表达式永远不会因为参数个数问题报错。Override public AviatorObject call(MapString, Object env, AviatorObject... args) { if (args null || args.length 0) { return AviatorDouble.valueOf(0); } if (args.length 1) { return args[0]; } // 多参数累加逻辑 ... }3.3 空值跳过与类型转换的边界这是ADD区别于运算符的灵魂所在。业务数据里空值是常态遇到nil会直接抛异常而ADD应该优雅地跳过。所以累加的时候要先判断每个参数是不是nildouble sum 0; for (AviatorObject arg : args) { if (arg null || arg instanceof AviatorNil) { continue; } sum arg.doubleValue(env); } return AviatorDouble.valueOf(sum);这里用doubleValue(env)做统一转换好处是无论传进来的是整数、浮点还是字符串形式的数字都能转成 double 参与计算。但要注意doubleValue对非数字字符串会抛异常所以如果你的场景里可能出现abc这种脏数据得先做类型判断或者 try-catch 兜底。我一般会在函数内部加一层保护遇到无法转换的值就跳过并记录日志而不是让整个表达式崩掉。提示AviatorNil是 Aviator 里表示空值的类型判断空值一定要用它不要用 null因为 Aviator 的 nil 是一个单例对象不是 Java 的 null。4. 注册方式、调用验证与踩坑记录4.1 全局注册与实例注册的代码差异全局注册的写法最省事AviatorEvaluator.addFunction(new AddFunction());AddFunction就是你实现的类。注册之后任何表达式都能调用ADDObject r1 AviatorEvaluator.execute(ADD(1, 2, 3)); Object r2 AviatorEvaluator.execute(ADD(a, b, c), MapUtil.of(a, 10, b, null, c, 5));r1是 6r2是 15跳过了 null 的 b。实例注册则是AviatorEvaluatorInstance instance AviatorEvaluator.newInstance(); instance.addFunction(new AddFunction()); Object r3 instance.execute(ADD(1, 2));两种方式函数逻辑完全一样区别只在作用域。我建议在项目里统一一种风格别混着用否则排查问题时容易搞混某个函数到底注册到哪去了。4.2 我踩过的三个坑第一个坑函数名大小写。Aviator 的函数名是大小写敏感的注册成ADD就只能用ADD调用写add会报找不到函数。我一开始没注意配置里写了小写排查了半天。建议注册时就用全大写和内置函数风格保持一致配置人员也不容易写错。第二个坑参数类型不匹配导致的静默错误。早期我的实现里直接用了arg.intValue(env)结果遇到小数就被截断了ADD(1.5, 2.5)得到的是 3 而不是 4。后来统一改成doubleValue才正常。这个坑的教训是加法场景一定要用浮点类型做中间计算最后再按需决定返回类型。第三个坑线程安全。如果你的函数实现里持有可变状态比如一个成员变量做累加器在多线程环境下会出大问题。Aviator 的函数实例是共享的call方法会被并发调用。所以函数实现必须是无状态的所有中间变量都放在方法内部。我见过有人把 sum 写成成员变量压测时结果全乱套了。4.3 验证用例的设计写完函数别急着上线设计几个边界用例跑一遍用例表达式期望结果说明正常累加ADD(1, 2, 3)6基础功能含空值ADD(1, nil, 3)4跳过空值单参数ADD(5)5单参数兜底无参数ADD()0空参数兜底小数ADD(1.5, 2.5)4.0浮点精度字符串数字ADD(1, 2)3.0类型转换这几条覆盖了绝大多数实际场景。特别是含空值和字符串数字这两条是业务数据里最容易出问题的点一定要验证到位。5. 从ADD延伸出去自定义函数的通用设计思路5.1 参数校验的通用模式ADD只是一个例子你迟早会写SUB、MUL、DIV甚至更复杂的业务函数。这些函数在参数处理上有共性可以抽出一套通用模式先判空、再判个数、然后逐个转换、最后计算。把这套逻辑封装成一个抽象基类子类只需要实现如何计算这一部分能省下大量重复代码。public abstract class BaseMathFunction implements AviatorFunction { Override public AviatorObject call(MapString, Object env, AviatorObject... args) { if (args null || args.length 0) { return defaultValue(); } ListDouble values new ArrayList(); for (AviatorObject arg : args) { if (arg null || arg instanceof AviatorNil) { continue; } values.add(arg.doubleValue(env)); } return AviatorDouble.valueOf(compute(values)); } protected abstract double compute(ListDouble values); protected abstract AviatorObject defaultValue(); }这样ADD的compute就是求和MUL就是求积DIV就是依次相除。代码复用率一下就上来了。5.2 返回值类型的选择Aviator 支持多种数值类型返回什么类型是有讲究的。全用AviatorDouble最简单但如果你明确知道结果是整数返回AviatorLong会更精确也能避免浮点误差在后续计算里累积。我的经验是涉及金额、计数这类不能有误差的场景尽量用整数类型涉及比率、平均值这类天然带小数的场景用 double。如果拿不准double 是安全的默认选择。5.3 错误处理与日志函数内部抛异常会直接中断整个表达式求值这在生产环境里可能是灾难性的。我的做法是能兜底的就兜底兜不住的要给出清晰的错误信息。比如除零与其让它抛ArithmeticException不如返回一个约定的特殊值或者抛出带业务含义的异常让调用方能识别。日志方面函数内部不要打太多日志尤其是循环里否则高频调用时日志会拖垮性能。关键的错误路径打一条就够了。6. 性能考量与生产环境注意事项6.1 函数调用的开销自定义函数的调用比内置运算符要慢因为它多了一层方法调用和类型转换。在单次计算里这点开销可以忽略但如果是每秒几十万次的规则匹配就得留意了。我做过一个粗略的对比同样的累加逻辑用运算符比用自定义ADD函数快大概 2 到 3 倍。所以如果性能是硬指标能用内置运算符就别用自定义函数。但反过来如果自定义函数带来的是可维护性和健壮性的提升这点性能损失通常是值得的。关键是要清楚代价在哪别盲目用。6.2 缓存编译结果Aviator 支持把表达式编译成可复用的对象避免每次执行都重新解析Expression compiled AviatorEvaluator.compile(ADD(a, b, c), true); Object result compiled.execute(MapUtil.of(a, 1, b, 2, c, 3));第二个参数true表示缓存编译结果。在规则频繁执行的场景里这个优化能带来数量级的提升。我强烈建议所有生产环境的表达式都走编译缓存这条路别每次execute字符串。6.3 上线前的检查清单最后列一份我自己的上线检查清单供你对照函数是否无状态能否安全并发调用空值、单参数、无参数是否都有兜底类型转换是否会因为脏数据抛异常是否使用了编译缓存边界用例是否全部通过函数名是否和现有函数冲突这几条看着简单但每一条我都见过有人栽在上面。尤其是并发和空值这两条出问题时往往很隐蔽等到线上数据对不上才发现就晚了。把ADD这个看似简单的函数从头到尾走一遍你会发现 Aviator 的扩展机制其实设计得很克制——接口小、职责清晰但正因为克制很多边界得靠你自己想周全。我个人的体会是写自定义函数最花时间的从来不是主逻辑而是那些万一的情况万一参数是空的呢万一传进来的是字符串呢万一并发调用呢。把这些想清楚了函数才算真正能用。