Rust 项目想做插件扩展?4 大方案权衡,告别功能局限与安全风险!
软件早已成为驱动世界运转的核心支撑,而工作中最令人懊恼、最影响效率的,莫过于被软件的固有功能束缚手脚——要么是开发者未能预判到用户的实际使用场景,要么是开发团队暂不具备落地某类功能的资源与精力。
正因如此,几乎所有主流软件都会搭建专属的插件系统,让产品功能突破基础版本的限制,实现灵活拓展。从浏览器插件、Excel宏命令,到WordPress扩展工具,插件早已融入各类产品的使用场景,成为功能延伸的标配。
但搭建一套成熟的插件系统,绝非易事。既要为开发者设计简洁易用的API,又要打造兼顾安全与性能的运行体系,每一个环节都需严谨把控,稍有疏忽就可能引发各类问题。
本文将结合实操经验,详细拆解Rust项目插件系统的搭建思路与选型考量。你会发现,与多数技术选型场景不同,插件系统并没有能适配80%使用场景的通用方案,唯有结合自身项目的独特需求,认真权衡各方案的优劣,才能做出最贴合实际的取舍。
原生库方案
作为计算机领域沿用至今的初代方案,原生库插件的核心逻辑是运行时加载共享库,并通过原生方式调用库中的代码。
但在Rust中,这套方案的落地远非如此简单——因为Rust并不具备稳定的应用二进制接口(ABI)。
具体来说,Rust编译器无法保证结构体在内存中的存储布局固定不变,比如以下这个结构体:
structSomeStruct { some_i32: i32, some_bool: bool, another_i32: bool, another_bool: bool, some_i16: i16, some_i64: i64,}
出于性能优化等考量,编译器可能会随意调整结构体中字段的内存排列顺序。
若要让结构体的内存布局保持稳定,我们需要为其添加repr(C)属性,强制遵循C语言的内存布局规则:
#[repr(C)]structSomeStruct { some_i32: i32, some_bool: bool, another_i32: bool, another_bool: bool, some_i16: i16, some_i64: i64,}
但这仅仅是开始,我们还需要让整个插件接口适配外部函数接口(FFI),这意味着要大量编写如下的不安全代码:
unsafeextern"C" {// 各类函数声明}
这类代码会让整个系统的逻辑变得极度晦涩,难以梳理和维护。
此外,原生库方案完全不具备沙箱隔离能力。这就意味着,恶意插件可能会侵入用户的设备,造成安全风险;而存在bug的插件,轻则导致整个应用崩溃,重则悄悄破坏内存,引发程序异常和数据丢失——但出了问题,用户最终只会归咎于主程序的开发者。
最后,还有一个很少被提及但在我看来足以一票否决原生库方案的问题:动态库插件以编译后的二进制代码分发。
一方面,二进制代码中极易隐藏后门程序,将用户置于安全风险中;另一方面,相较于简单的脚本文件,二进制代码在即时通讯、社交媒体、论坛等平台的分享门槛更高,不利于用户间的交流与传播。
综合以上种种原因,我不推荐将动态库作为插件系统的实现方案。
唯一适合使用原生库做插件的场景,是构建模块化的渗透测试平台(各类功能模块以动态库形式实现),如果你想了解更多相关内容,可以参考《黑帽Rust》一书。
脚本语言方案
第二种实现插件系统的方案,是嵌入脚本语言。
目前最主流的两款嵌入式脚本语言,当属Lua和JavaScript/TypeScript。但事实上,很少有开发者愿意编写Lua代码——尤其是它的数组索引从1开始这一反直觉设计,更是让人诟病。因此,JavaScript成为了最优选择,毕竟全球几乎所有开发者都熟悉这门语言。
在Rust程序中嵌入JavaScript,主要有两种实现方式。
第一种是基于轻量级的QuickJS引擎开发绑定,比如rquickjs库。亚马逊云科技的LLRT(低延迟运行时)就是Rust集成QuickJS的高级实践案例,可供参考。
第二种则是基于重量级的V8引擎开发封装,比如deno_core库。
除非你正在构建无服务器平台这类特殊项目,否则我强烈推荐使用QuickJS而非V8:QuickJS体积轻量,不依赖即时编译器(JIT),因此安全性更高;冷启动速度更快,插件的运行效率也会随之提升;同时它的集成难度更低,开发成本更小。
更何况,QuickJS的轻量程度超乎想象——编译后的体积仅约210KiB,而V8编译后约40MiB,且QuickJS的性能足以满足绝大多数场景的需求。即便在少数QuickJS性能不足的场景(比如大规模数值计算),也能轻松与原生Rust代码集成,补足性能短板。
以下是基于QuickJS构建极简交互式解释器(REPL)的完整代码,可供参考:
use std::io::Write;use rquickjs::{CatchResultExt, Context, Function, Object, Result, Runtime, Value};fnprint(s: String) {println!("{s}");}fnmain() -> Result<()> {let rt = Runtime::new()?;let ctx = Context::full(&rt)?; ctx.with(|ctx| -> Result<()> {let global = ctx.globals();// 向JS环境注入打印函数 global.set("__print", Function::new(ctx.clone(), print)?.with_name("__print")?, )?;// 定义console.log方法 ctx.eval::<(), _>(r#"globalThis.console = { log(...v) { globalThis.__print(`${v.join(" ")}`) }}"#, )?;let console: Object = global.get("console")?;let js_log: Function = console.get("log")?;// 启动交互循环loop {letmut input = String::new();print!("> "); std::io::stdout().flush()?; std::io::stdin().read_line(&mut input)?;// 执行输入的JS代码并打印结果 ctx.eval::<Value, _>(input.as_bytes()) .and_then(|ret| js_log.call::<(Value<'_>,), ()>((ret,))) .catch(&ctx) .unwrap_or_else(|err| println!("{err}")); } })?;Ok(())}
在我的编程理念中,开发体系应尽可能简化为两门语言:一门是功能强大、安全高效的编译型语言,用于计算机栈的底层开发——这就是Rust;另一门是功能相对轻量化、运行速度稍慢的语言,用于高层脚本编写和用户界面开发——这就是TypeScript。
综合以上所有原因,我将嵌入QuickJS构建插件系统作为默认推荐方案,只有当该方案在你的特定场景中存在过多弊端时,再考虑其他实现方式。
WebAssembly方案
接下来是WebAssembly(简称WASM),这是一种字节码格式,旨在让编译后的程序能在任意CPU架构上实现跨平台运行,同时提供强大的系统沙箱隔离和内存隔离能力。
从理论上来说,我们可以在应用中集成WASM解释器或运行时,让用户将WASM模块作为插件运行。插件只能调用主应用提供的API,即便插件崩溃,也不会影响主应用的运行;借助沙箱隔离,WASM插件无法执行任何未被明确授权的操作。
这一切听起来都很完美,但这只是理论层面。
在实际开发中,不同编程语言对WASM的支持程度参差不齐。如果用户想开发功能完善的WASM插件,最终还是得用Rust编译为WASM字节码——但并非所有开发者都愿意学习和编写Rust代码。
更何况,WASM的工具链和编译目标还在不断迭代更新(比如WASI p1、WASI p2等版本),生态尚未稳定,开发和维护成本居高不下。
如果你执意要将WASM作为插件系统的实现方案,我推荐使用wasmi解释器。大多数情况下,解释器的运行效率已经足够,且相较于将WASM字节码编译为原生代码的运行时,解释器的安全性更高;同时它能在任意平台运行(比如禁止JIT编译器的iOS),资源占用也更少。
以下是WASM插件的极简实现代码,可供参考:
use wasmi::*;fnmain() -> Result<(), wasmi::Error> {// 定义WASM模块let wasm = r#" (module (import "host" "hello" (func $host_hello (param i32))) (func (export "hello") (call $host_hello (i32.const 3)) ) ) "#;let engine = Engine::default();let module = Module::new(&engine, wasm)?;// 定义宿主状态typeHostState = u32;letmut store = Store::new(&engine, 42);// 链接宿主函数letmut linker = <Linker<HostState>>::new(&engine); linker.func_wrap("host", "hello", |caller: Caller<'_, HostState>, param: i32| {println!("从WASM获取参数:{param},宿主状态为:{}", caller.data()); });// 实例化并运行WASM模块let instance = linker .instantiate(&mut store, &module)? .start(&mut store)?; instance .get_typed_func::<(), ()>(&store, "hello")? .call(&mut store, ())?;Ok(())}
与原生库方案类似,WASM插件同样以不透明的编译后二进制文件分发,因此也存在前文提及的所有弊端。
尽管WASM方案优于原生库,但在我看来,目前WASM的生态仍过于不成熟,并不适合用于构建插件系统,会大幅提升插件开发者的开发难度。
规则引擎(表达式引擎)方案
最后一种方案,是功能最弱但最易自行实现的表达式引擎。
表达式语言属于非图灵完备语言,其核心是计算出一个单一的结果值,通常是布尔值,部分场景也会返回字符串或64位整数。
比如,以下是一个用于过滤HTTP请求的表达式示例:
request.headers["user-agent"].length() > 500 || blocked_countries.contains(client.country) || blocked_ips.contains(client.ip)
用户可以将这类表达式与具体操作关联,上述表达式对应的操作就是拦截该HTTP请求。
目前Rust生态中最成熟的两款表达式引擎,分别是谷歌制定的通用表达式语言(CEL),以及Cloudflare开发的wirefilter。
以下是CEL的极简使用代码:
use cel::{Context, Program};fnmain() {// 编译表达式let program = Program::compile("[1, 2, 3].contains(3) == 'hello'.startsWith('h')").unwrap();let context = Context::default();// 执行表达式并获取结果let value = program.execute(&context).unwrap();assert_eq!(value, true.into());}
这两款引擎的设计并非尽善尽美,语法和函数也存在不一致的问题,但胜在能满足基础的业务需求。表达式语言的一大优势在于,因其非图灵完备的特性,不存在循环语句,因此在解析阶段就能大致估算出脚本的最大运行时长——这一特性在执行用户提供的不可信代码时,尤为重要。
总结
如果要为这几种插件系统实现方案做一个综合排名,我的结果如下: 脚本语言 > 表达式引擎 > WebAssembly > 原生库
在我看来,将脚本和表达式以纯文本形式分发和分享,是一项巨大的优势;而编译后的二进制代码,不仅需要复杂的工具链和开发环境,还存在严重的安全隐患,这也是我不推荐原生库和WASM的重要原因。
针对我自己的项目,由于目前仅需实现数据过滤功能,因此我选择复刻CEL引擎并做定制化改造,移除其中不一致的函数和语法——仅返回布尔值的表达式,是实现该需求最简单、最安全的方式。
但对于绝大多数项目而言,集成QuickJS仍是构建插件系统的最优选择。
夜雨聆风
