乐于分享
好东西不私藏

Rust 项目想做插件扩展?4 大方案权衡,告别功能局限与安全风险!

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仍是构建插件系统的最优选择。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » Rust 项目想做插件扩展?4 大方案权衡,告别功能局限与安全风险!

评论 抢沙发

1 + 6 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮