小类随手记

在 Rust 中运行js,用QuickJS+ JavaScript 打造插件引擎

使用 rquickjs 在 Rust 应用中嵌入 QuickJS,实现以 JavaScript 为插件的可扩展引擎。记录异步桥接、线程模型、沙箱兼容层等完整技术方案。

本文记录在 Rust 应用中嵌入 QuickJS(通过 rquickjs 绑定)实现 JS 插件引擎的完整技术方案与思路,包括异步运行时、宿主与脚本的双向桥接、CommonJS 兼容层、原生能力注入、线程模型与编码处理等关键设计。

为什么用 JS 做插件语言

一个长期维护的 Rust 应用,业务能力需要持续扩展,但不可能每个新能力都改 Rust 源码重新编译。插件化的常见方案有:

  • 动态库(cdylib):性能好,但 ABI 不稳定、跨平台编译麻烦、版本兼容噩梦
  • WASM:生态成熟,但插件作者需要懂 Rust/Go 等语言,学习曲线陡
  • 内嵌脚本引擎(Lua/JS/Python):插件只是一份文本文件,改完即生效,无需编译

选择 JavaScript 的理由很直接:

  • 语法亲和,前端开发者零成本上手
  • JSON 原生支持,与 HTTP API 数据天然契合
  • 社区有大量现成插件生态可参考,比如各大音乐聚合类应用的插件都是 JS 写的,接口约定成熟
  • 不需要打包分发,插件就是一个 .js 文件

脚本引擎里 QuickJS 是体积与性能平衡最好的选择:单个二进制几百 KB,无 JIT(天然规避了 iOS 审核等场景对 JIT 的限制),内存占用低,适合作为嵌入式运行时。

技术选型:rquickjs

Rust 侧绑定选 rquickjs,相比 other 的绑定库:

  • 对 QuickJS 的封装完整,支持运行时快照、模块加载
  • 原生支持 async/await(AsyncRuntime / AsyncContext),这是关键
  • 提供 bindgen 宏(声明式导出 Rust 函数给 JS)与 futures feature(Promise 与 Rust Future 互转)

Cargo 依赖:

1
2
rquickjs = { version = "0.12.1", features = ["bindgen", "futures"] }
rquickjs-serde = "0.6.1"

架构总览

整体分三层:

  • 宿主侧(Rust):定义插件接口 trait,管理插件生命周期,调用脚本
  • 桥接层(IIFE 模板 + 注入的原生函数):负责 JS 与 Rust 的互操作
  • 脚本侧(JS):插件本体 + 内置 polyfill 包(sandbox.js)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
┌─────────────────────────────────────────┐
  Rust 宿主                              
  ┌───────────┐   ┌──────────────────┐   
   trait API     AsyncRuntime        
   (业务接口)     AsyncContext        
  └─────┬─────┘   └────────┬─────────┘   
└────────┼──────────────────┼─────────────┘
          eval IIFE         Function::new(Async(fn))
                           
┌─────────────────────────────────────────┐
  QuickJS 运行时                         
  ┌───────────────────────────────────┐ 
   sandbox.js (axios/cheerio/qs...)  
   插件代码 (module.exports)          
  └───────────────────────────────────┘ 
└─────────────────────────────────────────┘

宿主调用协议:IIFE 模板

QuickJS 的 Ctx::eval 直接执行一段代码字符串。插件加载与调用都通过预定义的 IIFE 模板完成,把插件源码内联进模板再 eval。

加载插件时只读取元数据(平台名、版本、作者、支持的方法列表),不执行任何业务逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
(function() {
    var module = { exports: {} };
    var exports = module.exports;
    var require = function(n) {
        var p = __musicfree_packages[n];
        if (!p) throw Error('no module: ' + n);
        p.default = p;
        return p;
    };
    var __load_err = null;
    try { /* 插件源码内联在此 */ } catch(e) { __load_err = String(e); }
    var inst = module.exports.default || module.exports;
    var names = Object.keys(inst).filter(function(k) { return typeof inst[k] === 'function'; });
    return JSON.stringify({ p: inst.platform, v: inst.version, a: inst.author, m: names, e: __load_err });
})();

调用具体方法时使用 async IIFE,支持插件内部 await 网络请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
(async function() {
    var module = { exports: {} };
    /* ... 同上 require 兼容 ... */
    try { /* 插件源码内联在此 */ } catch(e) { return JSON.stringify({'__plugin_load_error': String(e)}); }
    var inst = module.exports.default || module.exports;
    var fn = inst['__FN__'];
    var a = JSON.parse('__ARGS__');
    try {
        var r = await fn.apply(null, a);
        return JSON.stringify(r);
    } catch(e) {
        return JSON.stringify({'__plugin_error': String(e) + ' | ' + (e.stack || '')});
    }
})();

这个设计的要点:

  • 插件源码每次调用都会重新内联执行,因此天然无状态、无残留全局变量,不需要复杂的沙箱隔离
  • CommonJS 兼容:手工模拟 module.exports / exports / require,插件按 CommonJS 风格编写即可
  • 返回值统一走 JSON 字符串,Rust 侧反序列化,边界干净
  • 插件抛错被捕获后包装成 __plugin_error 字段,宿主侧统一转成错误枚举,不 panic

异步桥接:两个方向的 Promise

QuickJS 本身是同步引擎,rquickjs 的 futures feature 把 JS Promise 和 Rust Future 打通,这是整个引擎能支持 async/await 的基础。

Rust 原生函数注入 JS(Rust → JS)

Function::new 包一个 async 函数,注册到全局对象上。JS 侧调用它得到的是一个真正的 Promise:

1
2
3
4
5
6
7
8
9
use rquickjs::prelude::Async;

// 原生 HTTP 函数:接收 JSON 字符串配置,返回 JSON 字符串
async fn native_http_js(config: String) -> String {
    // ... reqwest 发请求,返回 {"status":..,"headers":..,"data":..}
}

let f = Function::new(ctx.clone(), Async(native_http_js))?;
ctx.globals().set("__native_http_async", f)?;

MD5、SHA256、HMAC、Base64 等同步函数同样注入,但不需要 Async 包装:

1
2
3
4
let f = Function::new(ctx.clone(), |s: String| -> String {
    hex::encode(Md5::digest(s.as_bytes()))
})?;
ctx.globals().set("__native_md5", f)?;

JS Promise 返回值取回(JS → Rust)

ctx.eval 返回的 Promise 通过 into_future 转成 Rust Future 直接 await:

1
2
let promise: Promise<'_> = ctx.eval(&*code)?;
let result_json: String = promise.into_future::<String>().await?;

这样插件内部可以自由地 await axios.get(...)Promise.all([...]) 并发请求,宿主完全感知不到区别。

为什么用 JSON 字符串做参数边界

跨语言传参有两条路:直接传 JS 值(JsValue)或序列化成 JSON 字符串。项目选择了后者:

  • 参数/返回值全部 serde_json::Value 序列化,类型边界简单,宿主侧代码不用碰 JsValue 的借用来
  • 插件数据天然是 JSON(网络接口返回的就是 JSON),零转换成本
  • 出错时能拿到完整的字符串便于日志

线程模型:非 Send 的 QuickJS 与 tokio

QuickJS 运行时不是 Send,无法直接跨 tokio 任务移动。rquickjs 的 AsyncRuntime 内部用独立线程跑事件循环解决了一部分问题,但 Ctx 仍然不能跨线程。实际采用的模式是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
tokio::task::spawn_blocking(move || {
    // QuickJS 非 Send,必须独占线程
    let local_rt = tokio::runtime::Runtime::new()?;
    local_rt.block_on(async {
        let rt = AsyncRuntime::new()?;
        let ctx = AsyncContext::full(&rt).await?;
        ctx.async_with(async |ctx| {
            // 注册原生函数、eval IIFE、await Promise
        }).await
    })
}).await?

要点:

  • spawn_blocking 保证 QuickJS 每次调用独占一个线程,闭包捕获的数据要求 Send + 'static
  • 闭包内部再建一个本地 tokio runtime,block_on 驱动 async 上下文,这样 promise.into_future().await 才能跑起来
  • 每次调用都是新建 runtime + context,用完即弃。对低频插件调用(一次搜索/一次详情请求)完全够用,还顺带获得了天然隔离——插件状态不会泄漏到下一次调用
  • 代价是每次调用都有上下文创建开销,不适合高频短调用场景。如果业务需要,可以改为线程池 + 常驻 context

沙箱与兼容层:一个文件内嵌的 npm 生态

插件生态里大量使用 axioscheeriocrypto-jsqshedayjs 这些包。QuickJS 没有 npm,方案是在 sandbox.js 里用原生 JS 手写这些包的迷你实现,注册到 require 能查到的包表里:

1
2
3
4
5
// sandbox.js 片段
__musicfree_packages['axios'] = axios;
__musicfree_packages['cheerio'] = cheerio;
__musicfree_packages['crypto-js'] = cryptoJs;
__musicfree_packages['qs'] = qs;
  • axios:完整实现 get/post/request/create、params 拼接、POST JSON 自动加 Content-Type、错误响应包装,底层转发到 __native_http_async
  • cheerio:正则实现的轻量 HTML 解析,支持选择器、find/text/attr/children 等常用方法
  • crypto-js:MD5/SHA256/HmacSHA256/Base64 直接转发 Rust 原生函数(__native_md5 等),AES 预留了原生转发位
  • qs / he / dayjs:纯 JS 迷你实现

关键收益:插件作者按熟悉的 API 写代码,宿主侧用原生实现兜底性能敏感的加密哈希,两全其美。

编码处理:从 BOM 到 GBK 乱码

网络数据编码问题在中国区 API 上非常普遍,做了三层防御:

  1. 剥离 UTF-8 BOM:响应体前 3 字节是 EF BB BF 时去掉
  2. UTF-8 解码失败回退 GBK:encoding_rs::GBK 兜底,覆盖大量老接口
  3. 歌词乱码修复:如果文本中 Latin-1 区字符(0x80-0xFF)占比超过阈值,说明是 GBK 字节被按 Latin-1 误读了,重新按 GBK 解码
1
2
3
4
5
6
fn decode_body(bytes: &[u8]) -> String {
    let bytes = strip_bom(bytes);
    if let Ok(s) = std::str::from_utf8(bytes) { return s.to_string(); }
    let (cow, _, _) = encoding_rs::GBK.decode(bytes);
    cow.to_string()
}

踩坑记录

  • AsyncRuntime 不等于无脑跨线程:Ctx 依旧借用运行时,必须小心作用域,async_with 闭包内完成所有操作再返回
  • Function::new 的 async 闭包要求 Send + 'static,捕获的 reqwest client 要用 OnceLock 静态化,避免每次调用重建连接池
  • 插件里 Object.assignencodeURIComponent 在 QuickJS 中可能缺失,sandbox.js 里补了 polyfill
  • eval 大字符串(插件源码几十 KB)性能可以接受,但注意 replace("__JS_CODE__", js) 时如果插件源码本身含有模板占位符字符串会误替换,占位符要选足够独特的名字
  • 每次调用新建 context 的隔离方案意味着插件不能用全局变量跨调用缓存 token,需要插件侧自己把状态放进返回数据里(业务上一般也足够)

总结

整套方案的最终形态:

  • 插件 = 一个 JS 文件,按 CommonJS 风格写 module.exports,实现约定好的方法名即可
  • 宿主侧用 trait 抽象插件能力,JS 引擎只作为其中一个实现
  • 双向异步桥接靠 rquickjs 的 futures feature,参数边界统一 JSON 字符串
  • 隔离靠"每次调用新建 context"而非沙箱,简单且可靠
  • 生态兼容靠 sandbox.js 手写迷你包 + 原生函数转发

对于"Rust 应用需要快速扩展能力、又不想频繁发版"的场景,这套 QuickJS 插件方案值得参考。

comments powered by Disqus
Theme Stack