Spring Boot集成Aviator实现动态规则引擎,从硬编码到在线配置
时间倒回上个月我在一个 Spring Boot 项目里接到一个特别烦人的需求运营的营销规则每周要调三五次每次都是改代码、走评审、发版本改一次要花半天还要担心影响其他功能。项目里那些写得又长又密的 if-else已经没人敢碰了。最后我们决定把规则从代码里彻底抽出来做成一套动态规则引擎。选型时对比了几种方案最终落到了 Aviator 5.3.3 上——一个可以用字符串描述表达式的 Java 表达式引擎。Aviator 本身不算大众但真要解决“硬编码规则”这件事它比很多人想的更适合 Spring Boot 实战。这篇教程不写空话直接给你一套能跑起来的设计从依赖引入、核心 API、规则外置存储到缓存策略和坑点排查照着做就能落地。1. 硬编码规则的痛和你为什么该用 Aviator 而不是 Groovy1.1 三个真实到不能再真实的痛点先说说我为什么要折腾这件事。项目里的规则最早确实很简单比如“订单金额满 100 减 20”那时候在 Service 里写一个 if 就够了。但业务一跑起来规则就变复杂了不同用户等级、不同商品类目、不同时间段、是否会员、是否首单……于是代码里开始出现这种嵌套if (order.getAmount() 100) { if (user.getLevel() 2 || user.isVip()) { if (coupon ! null coupon.used false) { ... } } }这还算能忍再往后规则开始和业务逻辑纠缠在一起没人愿意动这一层代码。三个最明显的痛点发版成本高运营改一个阈值开发要改代码、测试要回归、运维要发版一次变更最快也得半天。规则不透明规则写死在代码里业务方看不见、摸不着线上出了一单异常研发对着日志翻半天才找到是哪个 if 分支的问题。无法灰度回滚规则改坏了只能重新发版回滚没有“改一条配置立刻恢复”的能力。这些痛点不是靠“代码写得优雅一点”能解决的本质上是规则的存续周期和代码的发布周期绑在了一起。要让规则活起来就必须把它从 Java 里拿出来变成数据。这也是动态规则引擎的核心价值。1.2 表达式引擎选型对比Aviator 的独特定位我一开始想到的不只是 Aviator还有几个常见选手简单对比一下你就会明白为什么最后选了它。方案优点缺点Groovy功能强能写完整脚本太重安全隔离难规则表达式复杂时容易失控MVELSpring 生态有集成支持表达式和脚本文档少社区热度一般部分语法有学习成本QLExpress阿里开源中文文档友好功能多但 API 略重规则引擎场景有点“杀鸡用牛刀”Drools老牌规则引擎支持复杂规则流学习曲线陡依赖重简单规则不值得上它Aviator轻量、性能好、语法接近 Java/表达式不支持完整脚本复杂流程需要自己编排Aviator 的核心定位就是“高性能的轻量级 Java 表达式求值器”。它不打算让你写完整业务逻辑它的目标就是处理“一个表达式给你一个结果”这件事。而动态规则引擎里最频繁、最基础的诉求恰恰就是这个给一堆参数根据条件表达式判断应该走哪个分支应该返回什么值。Aviator 5.3.3 在性能上做了很多优化比如表达式编译缓存、避免对象装箱拆箱等。而且在安全方面它不像 Groovy 那样可以直接调用系统类默认情况下的能力边界基本可控。对于“规则由非技术同事维护”这种场景限制是好事不是坏事。2. Spring Boot 项目接入 Aviator 5.3.3依赖、Bean 封装和第一个表达式2.1 版本选择和 Maven 依赖我用的版本是 5.3.3这个版本在 5.x 序列里比较稳定JDK 8 以上的 Spring Boot 项目可以直接引入不需要额外配置。依赖只需要一行dependency groupIdcom.googlecode.aviator/groupId artifactIdaviator/artifactId version5.3.3/version /dependency如果项目用的是 Maven 多模块结构建议放在一个公共模块里后面规则引擎相关的扩展函数也能复用。Aviator 的依赖非常干净不会给你拖一大批传递依赖进来这点对 Spring Boot 项目很友好。2.2 封装 Engine 配置类避免到处 newAviator 5.x 的入口是AviatorEvaluatorInstance不要每次用的时候都去创建实例应该把它做成 Spring 容器里的一个单例 Bean。一方面是为了复用表达式缓存另一方面是后续要统一注册自定义函数、统一配置有一个入口管理起来才方便。我在项目里是这样封装的import com.googlecode.aviator.AviatorEvaluator; import com.googlecode.aviator.AviatorEvaluatorInstance; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class AviatorConfig { Bean public AviatorEvaluatorInstance aviatorEvaluator() { AviatorEvaluatorInstance instance AviatorEvaluator.getInstance(); // 默认缓存编译后的表达式避免相同的规则字符串反复编译 instance.setCachedExpressionByDefault(true); // 如果规则特别多可以调大缓存上限默认值一般也够用 // instance.setMaxSizeOfCache(2000); return instance; } }这里有个细节值得注意AviatorEvaluator.getInstance()返回的是全局默认实例。如果你有多个业务模块每个模块都想注册不同的自定义函数为了避免函数名冲突建议按模块创建独立的AviatorEvaluatorInstance不要都往默认实例里塞。不过大多数项目维护一套规则引擎就够了全局单例反而是最简单省事的选择。2.3 跑通第一个表达式接入之后我们先不看业务就来验证 Aviator 最基础的能力编译并执行一个数学表达式。import com.googlecode.aviator.AviatorEvaluator; import com.googlecode.aviator.Expression; public class SimpleTest { public static void main(String[] args) { Expression expression AviatorEvaluator.compile(1 2 * 3); Object result expression.execute(); System.out.println(result); } }控制台会输出 7。这里要强调一下compile是编译阶段execute是执行阶段。编译后的Expression对象可以反复执行不需要每次重新解析字符串。表达式里也可以传变量环境变量用Map传进去MapString, Object env new HashMap(); env.put(amount, 1200); env.put(riskLevel, low); Boolean pass (Boolean) AviatorEvaluator.execute( amount 1000 riskLevel low, env); System.out.println(pass); // trueAviator 的字符串可以用单引号这比 Java 原生的写法更贴近“规则配置”的直觉运营配置规则时不用时刻担心转义问题。实际做规则引擎时我们基本都在跟布尔表达式打交道这种表达能力已经覆盖绝大多数场景了。3. 动态规则引擎的核心设计把规则从 Java 代码里拿出来存成数据3.1 规则引擎的整体架构规则引擎说白了就一句话规则是数据引擎是解释器。在 Spring Boot 项目里最常见的做法是这样的链路规则存储在数据库或配置中心由运营/管理员维护。服务启动时或定期从存储中加载规则。调用 Aviator 把规则字符串编译成Expression。每次业务请求进来把当前业务参数组装成Map作为环境变量传给表达式。表达式执行后返回布尔值引擎根据布尔值决定是否命中规则。这个链路里最关键的一点是规则和代码彻底分离。Java 代码只负责“怎么执行规则”而不再关心“规则具体是什么”。运营改一条规则本质上就是改了一条数据库记录服务端代码一行都不用动。3.2 规则表设计和持久化规则表的设计不用太复杂但要留出扩展空间。我用的表结构大致长这样CREATE TABLE t_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_code VARCHAR(64) NOT NULL COMMENT 规则编码, scene VARCHAR(64) NOT NULL COMMENT 业务场景如 ORDER_RISK, expression TEXT NOT NULL COMMENT Aviator 表达式, priority INT DEFAULT 0 COMMENT 命中优先级数值越小越靠前, status TINYINT DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_scene_status (scene, status) ) COMMENT动态规则表;字段不多但足够覆盖大部分场景。scene用来区分业务域比如支付风控走ORDER_RISK营销优惠走PROMOTION互不干扰。expression是核心里面存的就是 Aviator 表达式字符串。如果规则体系以后要复杂化可以再加版本号、生效时间范围、命中后的动作配置等字段。持久化层我用的是 MyBatis-Plus查启用规则很简单Component public class RuleRepository { Resource private RuleMapper ruleMapper; public ListRule listByScene(String scene) { LambdaQueryWrapperRule wrapper new LambdaQueryWrapper(); wrapper.eq(Rule::getScene, scene) .eq(Rule::getStatus, 1) .orderByAsc(Rule::getPriority); return ruleMapper.selectList(wrapper); } }3.3 从数据库加载规则并通过 Spring 上下文执行有了规则表接下来就是引擎服务。我写的是一个通用RuleEngine核心逻辑就三步查规则、遍历执行、返回命中的规则。Service public class RuleEngineService { private final AviatorEvaluatorInstance aviatorEvaluator; private final RuleRepository ruleRepository; public RuleEngineService(AviatorEvaluatorInstance aviatorEvaluator, RuleRepository ruleRepository) { this.aviatorEvaluator aviatorEvaluator; this.ruleRepository ruleRepository; } public ListRule matchRules(String scene, MapString, Object context) { ListRule rules ruleRepository.listByScene(scene); ListRule hitRules new ArrayList(); for (Rule rule : rules) { Expression expression aviatorEvaluator.compile(rule.getExpression()); Object result expression.execute(context); if (result instanceof Boolean (Boolean) result) { hitRules.add(rule); } } return hitRules; } }这段代码看着简单但已经能跑通动态规则引擎的主流程了。值得注意的是aviatorEvaluator.compile()这一步Aviator 本身有表达式缓存同一个规则字符串重复编译时会直接命缓存不会真的重新解析。所以不要在每次调用前自己再拼一个“缓存层”避免重复造轮子。如果要做到“修改规则后立即生效”只要保证下次调用matchRules时去数据库查的是最新记录就行。最简单的方式就是每次请求都查库规则量不大时完全扛得住。如果规则量大到查库成为瓶颈可以再加一层本地缓存然后在规则更新的接口里主动刷新缓存。4. 实战一个可运行的会员积分风控规则引擎4.1 场景和规则定义光说架构有点飘我直接用项目里的一个真实场景来拆会员积分兑换风控。用户每天可以操作积分兑换但一个账号短时间内频繁兑换或者积分消耗异常需要风控拦截。规则由运营维护必须能随时调整阈值。先定义几条规则例子规则编码表达式优先级BLACKLISTblacklist true1HIGH_FREQconsumePoints 5000 timesInHour 52NEW_USER_PASSuserAgeDays 30 firstExchange true3RISK_HIGHscore 800 riskLevel high4这些规则的含义很直观黑名单直接拒绝短时间内高频兑换且积分消耗过大要拦截新用户首次兑换可以放行风险评分高且风险等级高的要关注。规则顺序按优先级排列实际判断时从前往后执行。4.2 完整代码实现控制层不需要写规则逻辑只把参数传进来让规则引擎判断就行。RestController RequestMapping(/risk) public class RiskController { Resource private RuleEngineService ruleEngineService; PostMapping(/check) public RListRule check(RequestBody RiskContext riskContext) { MapString, Object context new HashMap(); context.put(userId, riskContext.getUserId()); context.put(blacklist, riskContext.isBlacklist()); context.put(consumePoints, riskContext.getConsumePoints()); context.put(timesInHour, riskContext.getTimesInHour()); context.put(userAgeDays, riskContext.getUserAgeDays()); context.put(firstExchange, riskContext.isFirstExchange()); context.put(score, riskContext.getScore()); context.put(riskLevel, riskContext.getRiskLevel()); ListRule rules ruleEngineService.matchRules(POINT_EXCHANGE, context); return R.ok(rules); } }匹配的规则列表会原样返回给前端前端可以根据规则编码展示对应的提示文案比如命中了BLACKLIST就提示“账号已被限制”命中了HIGH_FREQ就提示“操作过于频繁请稍后再试”。这些提示信息也可以放在规则表里扩展一个message字段这里不展开讲。4.3 验证动态效果启动 Spring Boot 项目后先用正常的参数调接口{ userId: 1024, blacklist: false, consumePoints: 3000, timesInHour: 3, userAgeDays: 200, firstExchange: false, score: 700, riskLevel: low }这个用户既不是黑名单频率也不算高没有命中任何规则返回结果为空。然后我们把数据库里HIGH_FREQ这条规则的表达式改成consumePoints 2000 timesInHour 2再调一次同一个接口这次就会命中HIGH_FREQ。全程没有改一行 Java 代码、没有重新发版只是更新了一条数据库记录。所谓“动态规则引擎”核心体验就在这里规则离线调整在线生效。5. Aviator 5.3.3 在 Spring Boot 实战中容易踩的坑5.1 表达式缓存和内存问题Aviator 5.3.3 默认会缓存编译后的表达式这是好事情但如果你规则表里塞了成千上万条规则而且每条规则每次都不一样缓存就可能膨胀。比如有人把“用户的输入值拼进规则字符串”当接口调用比如engine.compile(amount userInput);这种做法极不建议。一方面它不是真正的规则配置另一方面会造成缓存无限增长。正确的姿势是规则表达式保持稳定变化的是执行时的环境变量也就是那个MapString, Object。如果确实需要支持动态阈值可以把阈值也放在环境变量里比如表达式写成amount limitAmount调用时把limitAmount放进Map。这样规则字符串是固定的缓存才能发挥作用。5.2 Spring Bean 与自定义函数的集成Aviator 表达式里是拿不到 Spring 容器里的 Bean 的因为表达式本身只是字符串没有对象引用。但实际业务里经常需要调用服务方法比如“判断一个用户是不是 VIP”“查一下用户最近是否已经参与过活动”。解决办法是注册自定义函数。比如我要在表达式里用isVip(userId)这个函数可以这样写Component public class VipFunction extends AbstractFunction { Resource private UserService userService; Override public String getName() { return isVip; } Override public AviatorObject call(MapString, Object env, AviatorObject arg1) { Object arg arg1.getValue(env); Long userId Long.valueOf(String.valueOf(arg)); return AviatorBoolean.valueOf(userService.isVip(userId)); } }然后把这个函数注册到 Aviator 实例上。如果用的是我前面写的配置类可以在 Spring Boot 启动时自动注入所有AbstractFunction的子类Bean public AviatorEvaluatorInstance aviatorEvaluator(ListAbstractFunction functions) { AviatorEvaluatorInstance instance AviatorEvaluator.getInstance(); functions.forEach(instance::addFunction); instance.setCachedExpressionByDefault(true); return instance; }这样表达式里就可以直接写isVip(userId) blacklist false了。要注意的是自定义函数是在容器线程里执行的如果你的函数里有耗时远程调用它同样会阻塞执行线程评估规则时一定要控制好函数体的性能和可观测性。5.3 Aviator 与 Java 类型、空值、泛型的兼容问题表达式引擎最隐蔽的坑是类型和空值。Aviator 对null的处理有自己的规则。Java 里null null是 true但 Aviator 里空值叫nil用nil来比较。比如你在 Java 侧往Map里塞了一个null表达式里如果用a null判断可能得到 false正确写法是a nil。我在项目里就吃过这个亏上游接口某个字段没返回我在 Java 代码里判断a ! null是 false但在 Aviator 表达式里出现了a ! nil结果判断逻辑完全反了流量跑到误判分支上去了。所以规则参数的标准化非常重要进入引擎之前要保证关键参数已经被默认值填充不要让空值干扰规则判断。另外数字的精度也要注意。Aviator 的整数运算默认可能返回Long计算大金额时如果用了浮点数会有精度问题。建议涉及金额比较的地方用 BigDecimal或者把金额单位换算成分用整数运算。6. 规则引擎上线后我建议你立刻补上的三个能力6.1 规则依赖参数标准化规则引擎最怕的事情是“同一个参数在不同规则里含义不一样”。你想想amount在优惠规则里是订单金额在风控规则里可能变成了累计金额。上线第一天可能没事等规则多了参数名混乱会让你想骂人。我的建议是给每个场景定一个标准的入参结构。比如风控场景固定用RiskContext字段名、类型、单位全部写清楚。在调用matchRules之前统一把业务对象转换成MapString, Object转换成什么字段名、什么值类型都在这一个地方定好后面维护规则表达式的人只认这个 Map不认业务对象。这样规则表达式才能真的做到“数据驱动”不会因为代码层的对象变化而断掉。6.2 规则执行的监控与日志规则引擎一旦承载关键业务线上问题排查必须靠日志和监控。我每次执行规则时都会记录场景、规则编码、入参摘要不能打全量可能有用户隐私、命中结果、耗时。一开始可以简单打印日志规则多了以后可以埋点上报到监控系统。这里有一个很实用的经验每次把规则命中结果和命中的规则编码写进业务日志里等出了问题直接通过链路 ID 一查就能知道用户走到哪一步被拦住了。如果没做这步线上被风控误伤的反馈来了你会花很久去复现但规则引擎的输入往往很难完全复现。所以日志不是可选项是规则引擎的基本配置。6.3 规则灰度与回滚以及我的最终体会动态规则引擎虽然灵活但它不是没有风险。运营改一条规则如果没仔细验证可能直接影响线上流量。我现在的做法是在规则表里加一个version字段每次修改规则不是直接 update而是插入一条新版本记录生效时间到了才切换到新版本。出问题的时候可以通过配置中心或者后台一键切回上一版本规则集这比重新部署快得多。最后说点个人感受。硬编码不是罪有些万年不变的规则写在代码里反而最稳定动态化不应该为了“炫技”而做。但当规则的生命周期明显短于版本发布周期时动态规则引擎就非常值得投入。Aviator 5.3.3 在这个场景里给我的感觉是轻巧、够用、够快而且和 Spring Boot 的集成成本很低。我个人最满意的是这套东西没有改变业务主流程规则却从每周发三次版变成了随时在后台配置。如果你也在考虑把硬编码规则改成动态化我的建议是从一个简单的布尔判断场景切入不要一上来就搞规则编排。先用 Aviator 把“规则即数据”这条路跑通再逐步补上缓存、监控、灰度这些能力你会发现业务方和开发同学都会轻松很多。