知序
返回
保存草稿
发布
H1
H2
H3
1
2
3
Excalidraw 图
AI 写作
AI 优化
目录
输入内容后显示目录
Markdown
# 02 - ScriptEngine 兼容层 > 这篇解决什么问题:很多老项目的代码里全是 `javax.script.ScriptEngine`——当年跑在 Nashorn 上、用 `getEngineByName("nashorn")` 拿引擎、一顿 `eval` / `put` / `get`。JDK 15 把 Nashorn 移除了,这些代码会不会一夜废掉?要不要全部重写成新接口?这篇把这条"老代码救生通道"讲透:`javax.script` 是什么、GraalJS 怎么用同名兼容层接住它、迁移要改什么、舍不得扔的接口又在牺牲什么。 > 读完你会知道:`javax.script`(JSR 223)的底层机制、为什么 GraalJS 要单独做一个"兼容层"而不是直接复用、`graal.js` 引擎名的来历、`eval`/`put`/`get` 三件套怎么用、以及它替你省掉迁移成本的同时逼你放弃了什么(类型、沙箱、异常)。 --- ## 1. 先搞懂 `javax.script` 是个什么东西 ### 1.1 一个"统一的门面",而不是"一套引擎" `javax.script`(JSR 223)是 **JDK 6 起内置的一套通用脚本接口规范**。注意关键词是"通用"——它刻意做成**不绑定任何具体脚本语言**。 你要抓住它的本质:`javax.script` 是一套**抽象**,真正干活的引擎(Nashorn、Rhino、各类脚本语言实现)都在它底下。"Java 侧只认识接口,不关心底层是谁"——这正是它能一锅端接住 Nashorn 和 GraalJS 的根本原因。 **把这种"门面"思想再往前推一步**:它其实和 JDBC 是同一套路子。JDBC 让你写 `Connection` / `Statement`,底层连的是 MySQL 还是 Oracle 无所谓;`javax.script` 让你写 `ScriptEngine` / `eval`,底层是 Nashorn 还是 GraalJS 也无所谓。**接口负责"稳定",实现负责"替换"**——这正是老项目敢把 Nashorn 随手换成 GraalJS 的底气,因为 `javax.script` 这层门面十年来根本没变过。  *图:ScriptEngine 兼容层调用链* ``` ┌───────────────────────────────────────────────┐ │ Java 代码(只依赖 javax.script) │ │ │ │ ScriptEngineManager │ │ │ getEngineByName("graal.js") │ │ ▼ │ │ ScriptEngine ← 引擎名发现(ServiceLoader) │ │ │ │ │ ▼ │ │ js-scriptengine 兼容层 │ │ │ │ │ ▼ │ │ GraalJS 引擎(同一颗引擎) │ └───────────────────────────────────────────────┘ ``` **读这张图的三个关键点**: 1. **`ScriptEngineManager` 是"发现引擎"的入口**。它用 JDK 的 `ServiceLoader` 机制,去 classpath 里找所有实现了 `ScriptEngineFactory` 的类,然后按引擎名匹配。所以"Java 侧只写 `javax.script` 的接口"并不是魔法——真正把 GraalJS 挂上来的是 classpath 里那个响应 `ServiceLoader` 的 jar。 2. **JS 侧多了一层"兼容层"**。GraalJS 并没有直接去实现 `javax.script` 的 `ScriptEngine` 接口,而是在自己之上再包了一层 `js-scriptengine`。这一层存在的意义后面 1.2 会专门讲。 3. **最底下是"同一颗 GraalJS 引擎"**。无论你从兼容层进,还是从前一篇预告的 `Context` 进,最终都是同一颗引擎在干活——引擎本身只有一个,变的只是"门面"。 > 一句话:**`javax.script` 是 JDK 的标准脚本门面,GraalJS 通过 `js-scriptengine` 兼容层接在它下面,Java 侧与引擎解耦。** ### 1.2 为什么 GraalJS 要"多做一层兼容层",而不是直接把引擎伪装成 ScriptEngine 这是个值得停下来想清楚的设计问题:既然底层就是 GraalJS,为什么不直接让它实现 `ScriptEngine` 接口完事? 因为**"接口形态"和"引擎能力"不是一回事**。`javax.script` 的接口是为 2006 年前后的脚本场景设计的,它限定死了交互方式:拿到 `Object`、靠名字 `eval`、靠 `put`/`get` 交换全局变量。而 GraalJS 真正强大的能力(`Value` 类型系统、细粒度沙箱、Proxy 数据共享、`PolyglotException` 精确异常)**根本无法塞进这个老接口里**。 所以 GraalJS 的取舍是: | 路线 | 做法 | 你得到 / 你失去 | |---|---|---| | 直接实现 `ScriptEngine` | 把引擎塞进旧接口 | 老代码能跑,但 GraalJS 的特性被接口"卡死" | | **额外做兼容层** | 引擎保持原生能力,另包一层翻译 | 老代码能跑,又不牺牲引擎本身;损失集中在"兼容层"这一面上 | > 一句话:**兼容层不是"削足适履"把 GraalJS 塞进旧接口,而是"保留引擎,另开一扇窗"——原生能力全在,损失的只是通过这扇老窗所能表达的东西。** --- ## 2. 兼容层的代价:你在为"方便迁移"付什么钱 理解了它是个"翻译层",就可以把话说明白:**用 ScriptEngine 看似省事,实际上把 GraalJS 最有价值的三样东西挡在了门外**。 | 能力 | Context API(原生) | ScriptEngine 兼容层 | |---|---|---| | 拿到的对象 | `Value`(可精确判断/转换类型) | 普通 `Object`(**类型信息丢失**) | | 沙箱控制 | 可细粒度配置权限 | **无法精细控制**(基本裸奔) | | 异常类型 | `PolyglotException`(精确、可分类) | 通用 `ScriptException`(信息糊成一片) | | 数组/对象操作 | `getArrayElement` / `getMember` 等直接操作 | 不可用,只能绕 | 逐条拆开看"失去"的到底是什么: 1. **类型信息丢失**:兼容层的 `eval` 返回 `Object`,你得靠 `instanceof` + 强制类型转换去猜它到底是个 `Integer` 还是个自定义对象。而原生接口里拿到的是 `Value`,`isNumber()`、`asInt()` 一套判断转换(第 04 篇详述),明确得多。 2. **沙箱控制弱**:兼容层是基于最简单的"拿引擎就跑"设计的,它不暴露权限开关。这意味着一旦要处理**不可信脚本**(这正是内嵌脚本最危险的场景),兼容层几乎给不了保护,必须回到 `Context`。 3. **异常是通用的 `ScriptException`**:脚本哪一行错、是哪种错,都揉在一起,调试体验远不如 `PolyglotException`(第 09 篇展开)。 **所以结论一开始就要立住**:兼容层是**迁移期的拐杖**,不是长期方案。它存在的全部意义,是让存量老代码"几乎不改"就能从 Nashorn 平滑搬过来;一旦你有新的能力诉求(沙箱、精确类型、精确异常),就该往 `Context` 走。 > 一句话:**兼容层保你用旧接口立刻能跑,但类型精确性、沙箱精细度、异常可诊断性这三大块能力,都只属于原生 `Context` 接口。** --- ## 3. 引擎名:`getEngineByName` 到底在查什么 `ScriptEngineManager` 是按**引擎名**去找工厂的,所以"叫什么"很关键。Nashorn 时代你写 `getEngineByName("nashorn")`;GraalJS 兼容层的注册名是 **`graal.js`**(它也接受 `js`、`JavaScript` 这两个别名)。迁移清单其实是这样的: ```text Nashorn 时代 → GraalJS 时代(迁移清单) getEngineByName("nashorn") → getEngineByName("graal.js") 依赖 jdk.scripting.nashorn → 依赖 org.graalvm.js 下的 js-scriptengine 能力 ``` **一个容易懵的点**:为什么叫 `graal.js`,而源码示例里有时又能看到 `js`?因为 `ScriptEngineFactory` 注册时会提供多个别名,`getEngineByName` 帮你做了归一化匹配。但这属于"能用即可"的细节——统一写 `graal.js` 最稳,别依赖那些别名猜来猜去。 > 一句话:**迁移启动时唯一必须改的代码,往往就是把 `"nashorn"` 换成 `"graal.js"`——其余老写法几乎原样保留。** --- ## 4. 拿到引擎:获取 + 兜底检查 先看拿到引擎的最小骨架: ```java ScriptEngineManager manager = new ScriptEngineManager(); ScriptEngine engine = manager.getEngineByName("graal.js"); if (engine == null) { System.err.println("未找到 graal.js 引擎,请确认 js-scriptengine 依赖已引入"); return; } ``` **这段代码背后的机制值得看清**: 1. `ScriptEngineManager manager = new ScriptEngineManager()`——这一步背后会用 `ServiceLoader` 扫描 classpath,把所有 `ScriptEngineFactory` 实现加载进来。 2. `manager.getEngineByName("graal.js")`——在已注册的工厂里按名字匹配,命中就拿到 `ScriptEngine`;**没命中返回 `null` 而不是抛异常**。 3. 那个 `if (engine == null)` 兜底绝对别删。它是整个工程里最容易出问题的一环:当 classpath 里缺了 `js-scriptengine` 对应的 jar 时,`getEngineByName` 就会返回 `null`,没有这个检查你会一脸懵地去调试一个其实根本"没装引擎"的问题(这条在踩坑篇里有完整记录)。 > 一句话:**拿到引擎靠 `ServiceLoader` 自动发现 + 按名匹配;找不到返回 `null`,所以兜底检查是必须的。** --- ## 5. 三件套上手:eval / put / get 这是 `javax.script` 的核心能力,就三个方向。先把"方向感"立起来,再逐段看实现差。 | 方法 | 方向 | 干的事 | 典型场景 | |---|---|---|---| | `eval(code)` | Java → JS | 执行表达式/语句,拿返回值 | 让 JS 算个结果 | | `put(name, obj)` | Java → JS | 把 Java 对象暴露为 JS 全局变量 | 把配置/对象交给脚本用 | | `get(name)` | JS → Java | 读回 JS 全局变量的值 | 拿脚本算完或改完的结果 | ### 5.1 eval:执行并把结果拿回来 ```java Object result = engine.eval("40 + 2"); System.out.println("40 + 2 = " + result); // 40 + 2 = 42 ``` `eval("40 + 2")` 是表达式,返回值就是结果——注意回传给你的是 **普通 `Object`**(这里是 `Integer`),而上一篇预告的 `Context.eval` 返回的是 **`Value`**。这是兼容层与原生接口在"返回值形态"上最直白的差异:一个给 `Object`、一个给 `Value`。 ``` 40 + 2 = 42 ← eval("40 + 2") 的返回值,Object(Integer) ``` ### 5.2 put:把 Java 对象绑定成 JS 全局变量 ```java engine.put("name", "GraalVM"); engine.put("version", "24.1.0"); engine.eval("print('Hello, ' + name + ' ' + version)"); // Hello, GraalVM 24.1.0 ``` `put("name", "GraalVM")` 做的事,**等价于在 JS 全局作用域里执行了一句 `var name = "GraalVM";`**。之后脚本里直接就能 `name`、`version` 地引用——它是把 Java 侧的对象"铺"进 JS 的名字空间。 ``` Hello, GraalVM 24.1.0 ← put 注入 name/version 后,JS 直接拼接 ``` ### 5.3 eval 注册函数,再 eval 调用 ```java engine.eval("function square(n) { return n * n; }"); Object square = engine.eval("square(9)"); System.out.println("square(9) = " + square); // square(9) = 81 ``` 这里藏着一个容易误会的点,必须拆开讲: - 第一句 `eval("function square(n){...}")` 是**声明语句**,它的效果只是把 `square` **注册进全局作用域**——此时函数体一行都没执行。 - 第二句 `eval("square(9)")` 才是**真正调用**,触发函数体执行并返回结果。 所以"声明"和"调用"是两步,写在两次 `eval` 里。这也带出一个本质认知:**`eval` 既执行表达式、也执行声明,区别只在于要不要返回值**。 ``` square(9) = 81 ← 第一次 eval 只注册,第二次 eval 才调用得到 81 ``` ### 5.4 get:从 JS 全局作用域读回变量 ```java engine.eval("var counter = 42;"); Object counter = engine.get("counter"); System.out.println("counter = " + counter); // counter = 42 ``` `engine.get("counter")` 与 `put` 方向相反:`put` 是 Java 侧把变量铺给 JS,`get` 是 JS 侧执行完后把全局变量读回 Java。这里 `counter` 读回来也是 `Object`(内部的 `Integer`)。 > 一句话:**eval 执行表达式、put 铺入全局、get 读回全局——三个方向就把"Java ↔ JS"的最基本交换打通了,与 Nashorn 时代的模型完全一致,所以老代码迁移几乎不用改。** --- ## 6. 底层发生了什么:这套模型的"线程与状态"心智 光会调方法还不够,得理解这套模型隐藏的两个前提——**单线程**和**引擎级状态**。想清楚它们,很多"为什么这么怪"就有了答案。 ### 6.1 它是"单线程阻塞"模型 `eval` 一旦开始,当前 Java 线程就**阻塞**在那里等 JS 算完,算完才轮到下一行。这跟"异步回调""多线程并行"完全是两套范式。对绝大多数"Java 里跑一段规则拿个结果"的场景,阻塞式恰恰最简单、最可控——你不需要担心并发。 但这也意味着:**你得自己对并发负责**。如果多个请求要同时 `eval`,要么串行化访问同一个引擎,要么各建各的引擎。兼容层不帮你做并发,它的模型前提就是"同一时刻一个线程在跑"。 ### 6.2 状态是"引擎级"的,跨 eval 累积 第 5.3 节 `square` 之所以"声明一次后面能一直用",靠的是**引擎保存了全局作用域**——`put` 铺进去的、脚本注册的函数,都留在引擎里,下一次 `eval` 还看得见。这正是"有记忆"的体现。 这个特性既方便也危险:方便在"一次搭环境、多次用";危险在——**你铺进去的全局状态会泄漏到后续所有脚本**。假设一个请求往全局 `put("user", 张三)`,下一个请求 `eval` 时也能读到 `user`,这就是典型的状态串扰。所以用兼容层跑"不可信/多租户"脚本时要非常克制全局状态。 > 一句话:**兼容层是"单线程阻塞 + 引擎级状态累积"的模型——简单可控,但并发与全局污染都得自己踩好刹车。** --- ## 7. 一个必须想清楚的分界:eval 与 put/get 各管哪一头 把三件套串起来,它们分属"代码执行"和"状态交换"两个层面,别混着用: ```text Java → JS JS → Java put(name, obj) 铺全局 get(name) 读回全局 eval(code) 执行代码/表达式 ◀────────── 就用它拿结果 ``` **决策口诀**:你要"让 JS 跑一段逻辑拿结果"→ 用 `eval`;你要"先把一个 Java 对象交给脚本用"→ 用 `put`;你要"把脚本算完/改完的某个全局变量要回来"→ 用 `get`。三者正交,按需组合。 > 💬 **Q:`engine.eval` 与 `engine.put/get` 分别什么时候用?** > > | 方法 | 方向 | 场景 | > |---|---|---| > | `eval(code)` | Java → JS | 执行表达式/语句,拿返回值 | > | `put(name, obj)` | Java → JS | 把 Java 对象暴露为 JS 全局变量 | > | `get(name)` | JS → Java | 读回 JS 全局变量的值 | > > 这套「Java 单线程阻塞调用」的模型与 Nashorn 完全一致,因此老代码迁移时**几乎不用改**——这正是兼容层存在的全部意义。 > 一句话:**eval 负责"跑代码拿值",put/get 负责"铺进去 / 读回来",三件套模型与 Nashorn 相同,迁移成本被压到最低。** --- ## 8. 迁移决策:什么时候用它,什么时候该上 Context 现在把话收拢。兼容层不是"更差的选择",而是"不同阶段的选择": ```text 存量大量老 ScriptEngine 代码(跑在 Nashorn 上) │ ▼ 先让它活着 → getEngineByName("graal.js") 平滑搬过来 │ ▼ 有沙箱 / 精确类型 / 精确异常诉求 │ ▼ 迁往 Context API(原生接口,能力完整) ``` **决策建议**: - **老项目、codebase 里已是一堆 `javax.script`、只想先把 Nashorn 换成能跑的** → 用兼容层,几乎零成本。 - **新项目、或打算认真做脚本化(要控制沙箱、要类型安全、要处理不可信脚本)** → 直接上 `Context`,别在兼容层上二次改造,那是往反方向使劲。 > 一句话:**兼容层是 Nashorn→GraalJS 的"最短迁移路径";一旦你的需求往前走了(沙箱/类型/异常),投资应转向 `Context`,而不是在旧接口上加补丁。** --- ## 9. 本篇小结 ```text ┌──────────────────────────────────────────────────────┐ │ 02 ScriptEngine 兼容层 要带走的话 │ ├──────────────────────────────────────────────────────┤ │ 1. javax.script = JDK 标准脚本门面(JSR 223) │ │ 2. GraalJS 用 js-scriptengine 提供同名兼容层 │ │ 3. 引擎名:grace.js;迁移唯一必改 = 换引擎名 │ │ 4. 三件套:eval / put / get,模型与 Nashorn 相同 │ │ 5. 代价:类型丢成 Object、沙箱弱、异常是 ScriptException│ │ 6. 决策:老代码迁移用兼容层,新/认真做用 Context │ └──────────────────────────────────────────────────────┘ │ v 已经能"用老接口跑起一段 JS",下一步认识官方推荐的新入口 ──→ 下一篇:03 - Context API 核心 ``` ## 思考题(自测) 1. `engine.get("counter")` 返回的 `Object` 底层是什么类型?如果你要拿 JS 数组的某个元素,兼容层该怎么操作?(提示:这个老接口没有 `getArrayElement`) 2. ScriptEngine 的 `put` 和下一篇要讲的 `Context.getBindings("js").putMember(name, v)` 有什么异同?它们在"铺 Java 对象给 JS"这件事上等价吗? 3. 为什么兼容层拿不到 `Value`?如果你确实需要精细类型控制,怎么在"兼容层"和"Context"两套 API 之间做迁移过渡?
预览
发布文章
添加摘要让读者更快了解文章内容
取消
确认发布
AI 写作助手
输入一个主题,AI 将为你生成一篇 Markdown 博客文章
AI 正在写作中...
取消
开始生成
AI 优化助手
描述你的修改需求,AI 将在原文基础上进行补充或微调
AI 正在优化中...
取消
开始优化
画布
从已有图里选
✕
图名
保存后生成 /images/excalidraw/<图名>.svg(只能用字母、数字、短横线、下划线、点)
取消
保存并插入