知序
返回
保存草稿
发布
H1
H2
H3
1
2
3
Excalidraw 图
AI 写作
AI 优化
目录
输入内容后显示目录
Markdown
# 10 - 实战:规则引擎 > 这篇解决什么问题:你的业务规则(折扣、风控、会员门槛)散落在 Java 的 if/else 里,改一条规则就要改代码、走发版、重启,动辄半天。你想让"改规则"这件事从"改代码"变成"改一条数据库记录",立刻生效、不用发版。GraalJS 能不能把规则做成"数据"存起来、运行期再取出来求值?事实怎么"喂"进去、结果怎么"拿"回来、还怎么保证它安全? > 读完你会知道:为什么说"规则即数据"、它与决策表/SPEL 相比的取舍在哪里、怎么用最小权限 Context 承接"规则是不可信输入"这个前提、Java 只做 `putMember` 注入 facts 与 `eval(rule).asBoolean()` 求值这两件事、复用 Context 与每条规则新建之间的性能/隔离权衡,以及规则求值的完整链路。 --- ## 1. 规则引擎到底在解决什么:从"代码即规则"到"规则即数据" ### 1.1 传统方式的痛点 把业务规则直接写成 Java 条件分支,是最朴素的做法: ```java if (order.amount >= 1000 && order.vipLevel >= 2) { // 打 VIP 折扣 } else if (order.region.equals("BLACKLISTED")) { // 拦截 } ``` 它的问题不是"不能跑",而是**规则的生命周期绑死在代码里**:运营想调一下门槛,走一遍改代码→编译→发版→重启,一次改动的物理成本高、响应慢。规则越多,这段逻辑越像一锅"蒸不烂的硬骨头"。 ### 1.2 把规则变成数据 规则引擎的思路是彻底换一个模型:**规则本身是数据(一段字符串),存放在数据库或配置中心;Java 只负责"把事实注入进去、执行求值、拿回结果"。** 这样"改规则"就等于"改一条记录",应用无需重启即刻生效。 ``` 传统方式 规则引擎方式 ┌──────────────┐ ┌──────────────┐ │ Java 代码 │ │ 数据库/配置中心│ │ if (amount │ │ 存 JS 表达式 │ │ >=1000){ │ 改规则 = 改代码发版 │ "facts.amount│ │ ... } │ │ >= 1000" │ └──────────────┘ └──────┬───────┘ │ 加载 ▼ ┌────────────────┐ │ GraalJS 求值 │ │ facts + 规则 │ │ → boolean │ └────────────────┘ ``` - **热更新**:改规则 = 改一条数据库记录,应用无需重启 - **事实与字符串分离**:规则只认识注入进来的 `facts`,不认识 Java 对象 下面这张图把"规则即数据、存库取用、热更新"的闭环画出来:  *图:规则引擎:规则即数据,热更新* > 一句话:**规则引擎把"该执行什么"从代码里抽出来、存成数据,Java 的职责被简化为"喂数据 + 求值"两件事。** ### 1.3 决策表 / SPEL / JS 表达式,怎么选 既然"规则作数据"不只 GraalJS 一种做法,先对三个方案做取舍定位: | 方案 | 语法 | 学习成本 | 灵活度 | 环境 | |---|---|---|---|---| | JS 表达式(GraalJS) | JS 语法 | 高(JS 工程师友好) | 高(可调函数) | 需引入引擎依赖 | | 决策表 | 行列配置 | 低 | 低(固定结构) | 一般无需脚本引擎 | | SPEL(Spring) | Spring 表达式 | 中 | 中 | 需 Spring | 读这张表的重点:**灵活度和配置成本是跷跷板。** 决策表"傻瓜但死板":适合稳定的、结构固定的规则。JS 表达式"灵活但需要引擎和约束":规则可复杂到调函数,但要先练好沙箱这门功夫。SPEL 卡在中间:规则接近表达式语法,但绑定了 Spring 生态。给你的规则一个合适的主——将来规则变简单就回归决策表,变复杂才上 JS 表达式。 > 一句话:**要固定规则用决策表、要 Spring 生态用 SPEL、要灵活到"规则还能调函数"才轮到 JS 表达式 + 沙箱。** --- ## 2. 安全前提:规则是不可信输入,必须用最小权限 Context ### 2.1 为什么规则要按"不可信"处理 规则可能来自运营配置,甚至来自某个用户提交的筛选表达式。它**不能被当成自己的代码来信任**。所以承接规则的 Context 必须按上一页的沙箱思路收紧,规则这头不暴露任何 Java 对象: ```java Context.newBuilder("js") .allowHostAccess(HostAccess.NONE) // 规则拿不到任何 Java 对象 .option("engine.WarnInterpreterOnly", "false") .build() ``` `HostAccess.NONE`:规则不能访问任何 Java 对象——这正是 09 篇"默认全禁"的一个子集。注意一个细节:这里**不需要**再开 `allowHostClassLookup`——因为规则只操作注入的 `facts`,压根不该有查类的能力。 ### 2.2 事实与规则的接口契约 规则能触碰的,就只有你**显式喂进去的那一个 `facts` 对象**——其它一概不存在: ``` Java 侧 JS 侧 Map facts ──ProxyObject──▶ facts 对象(只读数据) String rule ──eval──▶ facts.amount >= 1000 && ... ↑ 规则只认识 facts ``` 事实与规则是**分离**的:事实是 Java 拿 `Map` 组装、经 `ProxyObject` 桥过去的;规则是从存储里读出来的字符串表达式。两边只在求值那一刻相遇。 > 一句话:**规则 Context 给到最小权限,规则只认 `facts` 一个入口——能碰的、不能碰的,边界画得清清楚楚。** --- ## 3. 完整实现:三条规则 + 一个求值方法 ### 3.1 运行结果(实测演示) 对这样一组事实 `facts(amount=3600, vipLevel=3, hasCoupon=false, region=CN, riskScore=25)`,三条规则的判定结果是: ``` 规则 1 [facts.amount >= 1000 && facts.vipLevel >= 2] -> true 规则 2 [facts.amount >= 5000 || facts.hasCoupon] -> false 规则 3 [facts.region != 'BLACKLISTED' && facts.riskScore < 60] -> true ``` ### 3.2 规则清单:就是一堆字符串 ```java private static final List<String> RULES = List.of( "facts.amount >= 1000 && facts.vipLevel >= 2", "facts.amount >= 5000 || facts.hasCoupon", "facts.region != 'BLACKLISTED' && facts.riskScore < 60" ); ``` 注意看:**规则就是纯字符串。** 没有任何编译、没有任何 Java 类型。生产环境中,这个 `List` 不是写死在代码里的,而是从数据库/配置中心读出来的——改动只需要更新记录,应用下次取值自然拿到新规则。 ### 3.3 最小权限 Context ```java try (Context context = Context.newBuilder("js") .allowHostAccess(HostAccess.NONE) .option("engine.WarnInterpreterOnly", "false") .build()) { // 在这个 context 里反复求值 } ``` `HostAccess.NONE`:规则不能访问任何 Java 对象(09 篇的沙箱子集)。**不需要** `allowHostClassLookup`——规则只操作注入的 `facts`。 ### 3.4 注入事实并逐条求值 ```java Map<String, Object> order = Map.of( "amount", 3600, "vipLevel", 3, "hasCoupon", false, "region", "CN", "riskScore", 25 ); for (int i = 0; i < RULES.size(); i++) { String rule = RULES.get(i); boolean passed = evaluate(context, rule, order); System.out.println("规则 " + (i + 1) + " [" + rule + "] -> " + passed); } ``` 事实是普通 `Map`,逐条规则复用同一个 Context 求值——**循环每次都调 `evaluate`,但 Context 只建了一次。** ### 3.5 核心求值方法:两行 ```java private static boolean evaluate(Context context, String rule, Map<String, Object> facts) { context.getBindings("js").putMember("facts", ProxyObject.fromMap(facts)); return context.eval("js", rule).asBoolean(); } ``` 一条规则就是两行,这两行是整套规则引擎的引擎(没有更花哨的东西了): - **注入事实**:`putMember("facts", ProxyObject.fromMap(facts))`——JS 里 `facts` 就成了那个对象(Proxy 桥接,见数据共享篇)。 - **执行求值**:`context.eval("js", rule).asBoolean()`——执行表达式,`asBoolean()` 取回布尔结果。 **三条规则的逐步判定**(把数值代进去验证一遍): ``` 规则 1:3600 >= 1000 (true) && 3 >= 2 (true) → true 规则 2:3600 >= 5000 (false) || false → false 规则 3:'CN' != 'BLACKLISTED' (true) && 25 < 60 (true) → true ``` > 一句话:**规则引擎的 Java 侧就一个 `evaluate` 方法两行——注入 facts、eval 求值、取回 boolean,再无其他。** --- ## 4. 三个必须想清楚的设计问题 ### 4.1 复用同一个 Context,还是每条规则新建? **Q:为什么要复用同一个 Context,而不是每条规则新建一个?** 每轮 `putMember("facts", ...)` 会覆盖上一次的 `facts`,同一个 Context 连续求值即可,不必重复建。复用的好处: - **性能**:引擎(Truffle AST / JIT profile)只初始化一次,预热成本被摊薄——正是 01 篇讲的"长跑型才显 JIT 优势"的实践落地。 - **状态简单**:全局作用域里只有 `facts` 一个变量被轮换,逻辑刻意保持极简。 代价是**全局状态共享**:如果规则之间需要完全隔离、互不串扰(比如 A 规则在全局 scope 里留了东西、B 规则不该看到),才需要考虑每条规则独立 Context——这是思考题 1 调研的方向。 ### 4.2 `facts.amount >= 1000` 里 `amount` 凭什么能这么访问? **Q:`amount` 是 Java Map 的 key,为什么 `facts.amount` 能直接访问?** 因为 `ProxyObject.fromMap(facts)` 把 Java 的 `Map` 变成了「JS 眼中的一个对象」,`facts.amount` 的语义等价于 `map.get("amount")`。这正是"事实注入"的核心通道——JS 侧无需了解 Java 集合的细节,用最自然的"点对象字段"语法就能取到值。 ### 4.3 规则引擎的边界在哪里看齐沙箱 别忘了前一篇的三层叠加:最小权限 + 超时/资源限制 + 输入校验。这里 Context 已经做了最小权限(`HostAccess.NONE`);但 `facts.amount / 0`、`while(true)` 这类表达式仍然会跑你不想跑的运算——超时与输入校验这两层,是你在接手这套引擎后要补上的功课(思考题 2)。 > 一句话:**复用 Context 换性能和极简状态,代价是共享全局 scope;`facts.amount` 靠 `ProxyObject.fromMap` 这架桥;安全上别只靠 `HostAccess.NONE` 一层。** --- ## 5. 本篇小结 ```text ┌──────────────────────────────────────────────────────┐ │ 10 规则引擎 要带走的话 │ ├──────────────────────────────────────────────────────┤ │ 1. 规则即数据:JS 表达式存库,热更新无需发版 │ │ 2. 决策表/SPEL/JS:固定用决策表,灵活才用 JS │ │ 3. Context 最小权限(HostAccess.NONE)承接不可信规则 │ │ 4. Java 只做两件事:putMember 注入 + eval 求值 │ │ 5. 复用 Context 获性能与极简状态,代价是共享 scope │ │ 6. 别忘了三层:沙箱 + 超时/资源 + 输入校验 │ └──────────────────────────────────────────────────────┘ │ v 你已经能做出一个"规则可热更新、结果可求值"的引擎 ──→ 下一步:用同样的思路做模板渲染 ``` > 一句话收束:**让规则成为可热更新的数据,Java 退到"喂数据、要结果"的位置,再补上沙箱和超时。** ## 思考题(自测) 1. 规则之间需要完全隔离(互不共享全局变量)时,是复用 Context 还是每条规则新建?各自在性能与内存上代价如何? 2. 规则里出现 `facts.amount / 0`、`while(true)` 这类表达式时,当前示例会怎样?你会怎么给这个引擎加上超时与单位时间求值次数的限制? 3. 如果规则需要支持"调用 JS 函数"而不是单一表达式(如 `isVip(facts) && facts.amount > 1000`),设计上要做哪些调整——函数定义放哪、怎么和规则一起管理?
预览
发布文章
添加摘要让读者更快了解文章内容
取消
确认发布
AI 写作助手
输入一个主题,AI 将为你生成一篇 Markdown 博客文章
AI 正在写作中...
取消
开始生成
AI 优化助手
描述你的修改需求,AI 将在原文基础上进行补充或微调
AI 正在优化中...
取消
开始优化
画布
从已有图里选
✕
图名
保存后生成 /images/excalidraw/<图名>.svg(只能用字母、数字、短横线、下划线、点)
取消
保存并插入