Aviator 自定义 ADD 函数实战:从设计到注册的完整指南 1. 从内置函数到自定义扩展为什么需要自己写ADDAviator 这个轻量级表达式引擎在规则计算、动态脚本、风控表达式这些场景里出镜率很高原因就一个够小、够快、够简单。它自带了一大批内置函数字符串处理、数学运算、日期格式化基本都覆盖了日常写个a b、string.length(s)完全够用。但真正落到业务里你会发现内置函数总有不够用的时候——比如金额计算需要统一保留两位小数并做四舍五入、比如某些业务字段需要做特殊的空值兜底、比如两个数相加之前要先做单位换算。这时候就得自己动手扩展函数了。这篇内容就围绕一个最小但最典型的例子展开在 Aviator 中自定义一个 ADD 函数。别看它简单加法这个动作背后牵扯的东西一点都不少——参数类型怎么处理、返回值怎么定、空值怎么办、精度怎么保证、注册到引擎的哪个环节、多个函数怎么批量管理。把 ADD 这一个函数吃透你就能照着套路扩展出 SUB、MUL、DIV、ROUND 等一整族自定义函数。适合谁看如果你正在用 Aviator 做规则引擎、在做动态表达式求值、或者单纯想搞清楚这个引擎的扩展机制这篇都能直接抄作业。我会从设计思路讲到代码实现再讲到踩过的坑尽量把每一步的“为什么”都说清楚而不是只丢一段代码让你自己悟。2. 自定义函数整体设计与思路拆解2.1 为什么选自定义函数而不是拼表达式很多人第一反应是加法而已表达式里直接写a b不就行了何必自定义一个 ADD 函数这个疑问很合理但实际业务里直接写运算符有几个绕不开的问题。第一是类型不可控。Aviator 的运算符在遇到字符串时会做拼接遇到 null 时行为也不一定符合预期。如果你的表达式是从数据库或配置中心动态下发的写表达式的人未必清楚传进来的到底是数字还是字符串一个就可能把1和2拼成12而不是算出3。自定义函数可以在内部强制做类型转换和校验把这种不确定性挡在函数内部。第二是逻辑无法复用。如果加法之前还要做单位换算、精度处理、边界判断这些逻辑写在表达式里会变得又长又难维护而且每个表达式都要重复写一遍。封装成函数之后表达式里只留一个ADD(a, b)逻辑集中在 Java 代码里改一处全局生效。第三是可读性和可审计性。业务人员看ADD(amount, tax)比看amount tax更容易理解意图尤其是在复杂表达式里函数名本身就是一种语义标注。风控、财务这类需要审计的场景函数化的表达式更容易做白名单校验。所以结论是简单运算用内置运算符没问题但一旦涉及类型安全、逻辑复用、精度控制就该上自定义函数。ADD 只是最小的切入点。2.2 Aviator 函数扩展机制的核心原理Aviator 的函数扩展走的是“函数名到函数对象”的映射。引擎内部维护了一个函数注册表表达式解析到某个函数调用时会拿函数名去注册表里查查到就执行对应的逻辑。你要做的就是实现一个函数接口然后把这个实现注册进去。这里有个关键点需要理解Aviator 的函数是无状态的每次调用都是独立的函数对象本身不保存调用之间的数据。这意味着你的 ADD 函数不能依赖成员变量来传递状态所有需要的数据都得从参数里拿。这个约束看起来是限制其实是好事——它保证了函数在并发场景下是安全的多个线程同时调用同一个函数对象不会互相干扰。另一个关键点是参数是变长的。Aviator 的函数接口接收的是一个ListObject而不是固定数量的参数。这给了你很大的灵活性ADD 可以设计成只接受两个参数也可以设计成接受任意多个参数做累加。设计时要根据业务需求决定但要注意参数个数校验必须自己做引擎不会帮你检查。2.3 ADD 函数的功能边界定义动手写代码之前先把 ADD 的边界定清楚这一步最容易被跳过但恰恰最重要。我一般会问自己几个问题接受几个参数两个是最常见的但累加场景可能需要 N 个。我倾向于设计成至少两个、支持更多这样既能满足ADD(a, b)也能满足ADD(a, b, c)。参数类型是什么只接受数字还是也接受数字字符串实际业务里从配置读出来的往往是字符串所以支持字符串转数字很有必要。空值怎么处理遇到 null 是抛异常、当 0 处理、还是返回 null这个必须明确否则调用方会踩坑。返回值类型是什么统一返回BigDecimal还是Double涉及金额的场景强烈建议BigDecimal避免浮点误差。精度怎么控制是否要指定小数位数是否要指定舍入模式把这些定清楚代码写起来就是水到渠成的事。下面这张表是我给 ADD 定的行为规范你可以直接参考维度设计决策理由参数个数2 到 N 个兼容两数相加和多数累加参数类型Number 或数字字符串兼容配置下发场景null 处理视为 0避免表达式因空值中断返回值BigDecimal保证精度避免浮点误差精度控制默认保留原精度可选指定灵活适配不同业务3. 核心细节解析与实操要点3.1 函数接口的实现要点Aviator 的函数接口核心就是一个方法接收参数列表返回计算结果。实现的时候有几个细节必须注意。首先是参数个数校验。前面说了引擎不帮你检查所以你得自己判断list.size()是否满足要求。少于两个参数直接抛异常异常信息要写清楚方便排查。这里有个经验异常信息里带上实际收到的参数个数比单纯说“参数不足”有用得多。其次是类型转换的健壮性。参数传进来是Object可能是Integer、Long、Double、BigDecimal也可能是String。转换逻辑要覆盖这些情况遇到无法转换的类型要给出明确异常而不是让它悄悄变成 0。我见过有人图省事转换失败就返回 0结果线上出现一堆莫名其妙的计算结果排查了半天才发现是类型转换把脏数据吞了。第三是BigDecimal 的构造方式。从Double转BigDecimal时千万别用new BigDecimal(double)因为 double 本身就有精度问题这么转会把这个误差带进来。正确做法是用BigDecimal.valueOf(double)它内部走的是Double.toString()能拿到更符合直觉的结果。这个坑我在金额计算里踩过差一分钱查了一下午。3.2 空值与异常的处理策略空值处理是自定义函数里最容易出分歧的地方。ADD 函数遇到 null 参数到底该怎么办我的建议是把 null 当 0 处理理由是表达式引擎的使用场景里字段缺失是常态如果每个 null 都抛异常表达式会变得极其脆弱调用方得写一堆判空逻辑。当 0 处理能让表达式更健壮符合“表达式应该尽量算出结果”的直觉。但这里有个前提只有当参数确实是 null 时才当 0类型转换失败不能当 0。这两者必须区分开。null 是“没有值”转换失败是“有值但格式不对”后者是数据质量问题应该暴露出来而不是被吞掉。异常处理上我倾向于快速失败。参数个数不对、类型无法转换直接抛IllegalArgumentException让问题在开发测试阶段就暴露。不要用 try-catch 把异常吞掉返回默认值那样只会把问题推迟到线上。提示如果你的表达式是面向业务人员配置的异常信息要写得通俗一点比如“ADD 函数至少需要两个参数当前只收到 1 个”而不是丢一串堆栈。3.3 精度与舍入的实操细节金额场景下精度是绕不开的话题。ADD 函数本身做加法不会引入精度问题BigDecimal 的加法是精确的但结果的小数位数需要关注。比如1.5 2.25 3.75这个没问题。但如果业务要求结果统一保留两位小数那3.75是合规的3.755就需要舍入。这时候有两种设计一是 ADD 函数不管精度由调用方在表达式里再套一个 ROUND 函数二是 ADD 函数支持一个可选的精度参数。我倾向于职责单一ADD 只管加法精度交给专门的 ROUND 函数。这样函数职责清晰组合起来也灵活。但如果你的业务里加法几乎总是要保留两位那给 ADD 加个可选精度参数也合理能少写一层嵌套。舍入模式上金融场景常用HALF_UP四舍五入但要注意HALF_UP和HALF_EVEN银行家舍入的区别。前者是大家熟悉的四舍五入后者在 .5 时会向偶数舍入能减少统计偏差。选哪个取决于业务规范没有绝对的对错但一定要和业务方确认清楚别自己拍脑袋。4. 实操过程与核心环节实现4.1 环境准备与依赖引入先把环境搭起来。Aviator 的依赖很轻Maven 里加一行就行。版本选择上建议用较新的稳定版老版本在函数扩展的 API 上可能有差异。dependency groupIdcom.googlecode.aviator/groupId artifactIdaviator/artifactId version5.4.3/version /dependency引入之后先写个最简单的表达式跑通确认环境没问题import com.googlecode.aviator.AviatorEvaluator; public class QuickStart { public static void main(String[] args) { Object result AviatorEvaluator.execute(1 2); System.out.println(result); // 输出 3 } }这一步能跑通说明依赖没问题可以开始写自定义函数了。如果这一步就报错先检查 JDK 版本和依赖冲突别急着往下走。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.function.FunctionUtils; import com.googlecode.aviator.runtime.type.AviatorRuntimeJavaType; import java.math.BigDecimal; import java.util.List; import java.util.Map; public class AddFunction extends AbstractFunction { Override public String getName() { return ADD; } Override public AviatorObject call(MapString, Object env, AviatorObject... args) { if (args null || args.length 2) { throw new IllegalArgumentException( ADD 函数至少需要两个参数当前收到 (args null ? 0 : args.length) 个); } BigDecimal sum BigDecimal.ZERO; for (AviatorObject arg : args) { BigDecimal value toBigDecimal(arg, env); sum sum.add(value); } return new AviatorDecimal(sum); } private BigDecimal toBigDecimal(AviatorObject arg, MapString, Object env) { Object value arg.getValue(env); if (value 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 Number) { return new BigDecimal(value.toString()); } if (value instanceof String) { String str ((String) value).trim(); if (str.isEmpty()) { return BigDecimal.ZERO; } try { return new BigDecimal(str); } catch (NumberFormatException e) { throw new IllegalArgumentException(ADD 函数无法将字符串转换为数字: str); } } throw new IllegalArgumentException(ADD 函数不支持的类型: value.getClass().getName()); } }几个关键点展开说。getName()返回的函数名就是表达式里用的名字大小写敏感建议统一用大写和内置函数风格保持一致。call方法里先做参数个数校验这是第一道防线。然后循环累加每个参数都走toBigDecimal做统一转换。toBigDecimal里对 null 返回BigDecimal.ZERO对空字符串也返回 0这是“空值当 0”策略的落地。对字符串转换失败则抛异常区分了“没有值”和“值不对”两种情况。对Double和Float用BigDecimal.valueOf而不是new BigDecimal避免精度问题。4.3 注册函数与表达式调用函数写好了得注册到引擎里才能用。Aviator 提供了几种注册方式最直接的是全局注册AviatorEvaluator.addFunction(new AddFunction());注册之后表达式里就能直接调用了MapString, Object env new HashMap(); env.put(a, 10); env.put(b, 20); env.put(c, 30.5); Object result AviatorEvaluator.execute(ADD(a, b, c), env); System.out.println(result); // 输出 60.5注意这里c传的是字符串30.5函数内部会自动转成BigDecimal参与计算最终结果是60.5。这就是类型兼容带来的便利——调用方不用关心传进来的是数字还是字符串。如果你不想全局注册比如不同业务用不同的函数集可以用AviatorEvaluatorInstance做实例级注册AviatorEvaluatorInstance instance AviatorEvaluator.newInstance(); instance.addFunction(new AddFunction()); Object result instance.execute(ADD(1, 2));实例级注册的好处是隔离性好不同实例的函数互不影响适合多租户或者插件化的场景。4.4 参数计算过程与结果验证光跑通还不够得验证各种边界情况。我一般会准备一组测试用例覆盖正常值、空值、字符串、大数、负数这些场景。下面这张表是我实际验证过的结果表达式传入值预期结果说明ADD(1, 2)无3基本整数相加ADD(a, b)a1.5, b2.253.75小数相加精度保留ADD(a, b)anull, b55null 当 0 处理ADD(a, b)a10, b2030字符串自动转换ADD(a, b, c)a1, b2, c36多参数累加ADD(a, b)aabc, b1抛异常非法字符串明确报错ADD(a)a1抛异常参数不足明确报错验证的时候有个小技巧用AviatorEvaluator.execute配合断言把上面这些用例写成单元测试每次改函数都跑一遍能挡住大部分回归问题。尤其是类型转换那块改动很容易引入新问题有测试兜底心里踏实。5. 常见问题与排查技巧实录5.1 函数注册后找不到的排查思路最常见的问题就是注册了函数表达式执行时报“找不到函数”。排查顺序我一般是这样的先确认注册时机。函数必须在表达式执行之前注册如果你用的是静态初始化块或者懒加载可能注册还没执行表达式就跑了。全局注册建议放在应用启动阶段实例注册就在创建实例之后立刻注册。再确认函数名大小写。Aviator 的函数名是大小写敏感的ADD和add是两个不同的函数。表达式里写的是ADD注册的getName()返回的也必须是ADD差一个字母都不行。然后确认注册的实例和执行的实例是不是同一个。如果你用AviatorEvaluator.newInstance()创建了新实例函数注册在全局实例上那新实例里是找不到的。这个坑在多实例场景里很常见排查时容易被忽略。最后确认依赖版本。不同版本的 Aviator 在函数 API 上有差异老版本的接口签名可能和新版本不一样导致编译通过但运行异常。确认版本和文档匹配。5.2 类型转换异常的定位方法类型转换异常通常表现为IllegalArgumentException信息里会带上无法转换的值。定位的时候重点看这个值是从哪来的。如果值来自环境变量env检查上游传参的地方看是不是把非数字字段传进来了。如果值来自表达式内部的中间结果那可能是前面的函数返回了非预期类型。我遇到过一次前面一个自定义函数返回了字符串后面 ADD 拿到字符串转不了排查了半天才发现是前一个函数的问题。一个实用的技巧是在异常信息里带上参数的位置。比如“第 2 个参数无法转换为数字”这样一眼就能定位是哪个参数出的问题比只说“无法转换”高效得多。5.3 精度问题的识别与规避精度问题最隐蔽因为结果往往“看起来差不多”但就是差那么一点点。识别方法很简单用 BigDecimal 做对照。如果你用 Double 算出来的结果和 BigDecimal 算出来的不一致那就是精度问题。规避的核心原则是全程用 BigDecimal。从参数转换开始就用 BigDecimal中间计算用 BigDecimal返回也用 BigDecimal。不要中途转成 double 再转回来那样精度就丢了。还有一个容易忽略的点是除法。ADD 不涉及除法但如果你照着这个套路写 DIV 函数除不尽的情况必须指定精度和舍入模式否则 BigDecimal 会抛ArithmeticException。这是 DIV 函数和 ADD 函数最大的区别写 DIV 的时候一定要处理。5.4 常见问题速查表把上面这些整理成一张表方便你排查时对照现象可能原因解决方向找不到函数未注册/注册时机晚/大小写不符检查注册代码和函数名参数个数异常表达式传参数量不对检查表达式和函数参数校验类型转换失败传入了非数字字符串检查上游数据质量结果精度不对中途用了 double全程改用 BigDecimal多实例下函数失效注册实例和执行实例不一致统一实例或分别注册并发下结果错乱函数有共享状态确保函数无状态注意自定义函数一定要保证无状态。我见过有人在函数里缓存中间结果单线程测试没问题一上并发就出乱子。函数对象是共享的任何成员变量都可能被多个线程同时读写。6. 从 ADD 扩展到函数族的设计经验6.1 抽公共基类减少重复代码写完 ADD 你会发现SUB、MUL、DIV 的类型转换逻辑和 ADD 几乎一模一样区别只在最后的运算。这时候就该抽个基类把类型转换、参数校验这些公共逻辑放进去子类只实现具体的运算。public abstract class AbstractMathFunction extends AbstractFunction { protected BigDecimal[] toBigDecimals(MapString, Object env, AviatorObject... args) { BigDecimal[] values new BigDecimal[args.length]; for (int i 0; i args.length; i) { values[i] toBigDecimal(args[i], env, i); } return values; } protected BigDecimal toBigDecimal(AviatorObject arg, MapString, Object env, int index) { // 类型转换逻辑异常信息带上 index } protected void checkArgs(AviatorObject... args) { if (args null || args.length 2) { throw new IllegalArgumentException(getName() 函数至少需要两个参数); } } }这样 ADD 的实现就精简成几行SUB、MUL 也能快速写出来。基类里把异常信息带上参数位置排查效率也上去了。6.2 函数命名与版本管理函数名一旦上线改起来就很麻烦因为表达式里都写死了。所以命名要慎重建议遵循几个原则全大写、语义明确、避免缩写歧义。ADD没问题A就不行ROUND没问题RD就不行。如果函数逻辑有重大变更不要直接改原函数而是新增一个带版本后缀的函数比如ADD_V2让调用方逐步迁移。这样老表达式不受影响新表达式用新函数平滑过渡。6.3 性能考量与缓存策略自定义函数的性能开销主要在类型转换上。如果表达式被高频执行类型转换的次数会很多。优化方向有两个一是减少不必要的转换比如参数已经是 BigDecimal 就直接用二是缓存编译后的表达式Aviator 支持编译表达式后重复执行能省掉解析开销。Expression compiled AviatorEvaluator.compile(ADD(a, b)); for (int i 0; i 10000; i) { MapString, Object env new HashMap(); env.put(a, i); env.put(b, i 1); compiled.execute(env); }编译一次、执行多次在高频场景下性能提升很明显。但要注意编译后的表达式是线程安全的可以放心共享。6.4 测试覆盖与上线检查自定义函数上线前测试覆盖一定要做足。我的习惯是至少覆盖这几类正常值、边界值0、负数、极大值、空值、非法类型、参数个数异常。每类至少一个用例用单元测试固化下来。上线检查清单我也列一下供你对照函数名和表达式里写的是否一致参数个数校验是否覆盖null 处理策略是否符合预期类型转换异常信息是否清晰精度处理是否全程 BigDecimal函数是否无状态注册时机是否在表达式执行之前多实例场景是否注册到了正确的实例这套流程走下来ADD 函数基本不会出问题。照着这个套路你可以继续扩展出 SUB、MUL、DIV、ROUND、SUM 等一整族函数把表达式引擎的能力补齐。我个人在实际项目里的体会是自定义函数的价值不在于函数本身多复杂而在于它把业务逻辑从表达式里抽出来放到了可测试、可维护、可复用的 Java 代码里。表达式负责编排函数负责计算职责清晰了整个规则体系才好维护。ADD 虽小但它是理解这套机制的最好入口。