译:如何用 Rust 构建插件系统

原文:https://www.arroyo.dev/blog/rust-plugin-systems
作者:Micah Wylde
译者:ChatGPT 4 Turbo

Arroyo 是一个流处理引擎;用户编写 SQL 来构建实时数据管道。现在 SQL 对于数据处理来说是一种很棒的语言,支持大量的函数,并且可以适应包括流数据在内的各种用例。

但是,尽管 SQL 非常强大,有时候有些计算并不容易(或者不可能)用 SQL 表达式来表达。也许你需要解析一个自定义的二进制数据格式、实现一个复杂的聚合策略,或者调用一些已有的业务逻辑。

对于这些情况,许多 SQL 引擎支持_用户定义的函数_(UDFs),这些函数有几种形式:标量 UDFs、UDAFs(聚合函数,它们操作多行)和 UDWFs(窗口函数,可以引用其他行)。

UDFs 使 SQL 引擎_可扩展_。它们允许用户根据自己的需求定制系统。这对我们来说特别重要,作为一个小型创业公司,我们无法自己构建每个用户可能想要的所有功能。但是 Arroyo 是用 Rust 构建的——它喜欢构建静态二进制文件。我们怎样才能支持动态的、用户定义的行为?

历史上,Arroyo 通过静态的、提前编译(AoT)的方式支持 UDFs,但随着 Arroyo 0.10 放弃 AoT 转而使用基于解释型 SQL 运行时,该运行时围绕 Apache ArrowDataFusion 构建,我们需要一种新的策略。结果是一个基于 FFI 的、支持同步和异步函数的动态链接插件系统。

你可以从用户的角度在我们的文档中看到这看起来如何,或跳转进入代码。这篇文章的其余部分将深入探讨我们如何得出这种设计、它背后的技术细节,以及你需要了解什么才能在 Rust 中构建自己的基于 FFI 的插件接口。

目录

历史背景

0.3 版本开始,Arroyo 就支持了 UDFs,并且从 0.6 版本开始支持 UDAFs,这成为了我们最广泛使用的功能之一。早期我们之所以支持 UDFs,部分原因是它们很容易构建到我们的架构中。直到我们最近的 0.10 发布,我们依赖于提前编译来处理我们的流水线。简而言之,我们会生成 Rust 代码,包含数据类型、表达式和流水线的图结构,然后将其编译成一个二进制文件,在数据流上执行。

在这种范式中,支持 UDFs 是直截了当的。例如,一个 SQL 表达式像

LENGTH(CAST((counter + 5) as TEXT))

会被编译成类似以下的 Rust 代码:

(counter + 5).to_string().len()

既然我们只是生成 Rust 代码,我们可以轻松地嵌入用户提供的 Rust 函数。

Arroyo UDF 编辑器

Arroyo UDF 编辑器

它的工作方式是这样的:Arroyo UI 提供了一个编辑器,用户可以在其中用 Rust 代码编写 UDF 函数。UDF 函数看起来像这样:

pub fn square(x: i64) -> i64 {
    x * x
}

UDFs 还可以在顶部包含一个特殊的评论,以添加额外的依赖关系,格式为 Cargo.toml:

/*
[dependencies]
regex = "1"
*/

当用户运行带有此 UDF 的管道时,系统会解析函数定义,提取参数和返回类型,将它们映射到相应的 SQL 数据类型 并将其注册到 SQL 计划器中。

计划之后是代码生成。UDFs 源代码将直接写入到一个 crate 中的 lib.rs 文件中,与另一个包含生成的包装代码的 crate 一起,然后被添加为管道 crate 的依赖。此时,它是一个正常的、静态链接的 Rust 函数,可以像生成的表达式代码中的任何其他函数一样被调用。

生活得更灵活

对于 Arroyo 0.10,我们知道我们将需要一个不同的策略,因为我们不再构建一个静态的管道二进制文件。相反,表达式将在运行时由树遍历解释器执行,这需要一种方法在编译时不可用时调用 UDF 代码。

此时,我们开始查看 Rust 世界中的先前艺术作品,并遇到了 Mario Ortiz Manero 的这个关于 Rust 插件的精彩博客系列,我们非常感激。不幸的是,很快就变得清楚,这在 Rust 中并不是一个已解决的问题。

有许多选项可以在运行时调用用户代码:

嵌入脚本语言

像 Python 和 JavaScript 这样的语言可以动态运行,无需任何预编译,并且它的解释器可以托管在引擎内。Arroyo 使用跨语言兼容的 Apache Arrow 数据格式,因此不需要跨语言界限进行序列化。然而,与我们的原生 Rust 函数相比,这仍然会有显著的性能成本,并且这会破坏与现有 Rust UDFs 的向后兼容性。

这种方法在插件性能不是关键因素的应用中很常见,例如在创意应用中的 GUI 插件,其中插件代码主要负责编排而非自身处理。

2. 将 UDF 作为一个独立进程运行,通过 RPC 调用

每个 UDF 都可以编译为一个单独的服务(或者一个管道的所有 UDF 可以一起编译成一个单一服务),作为主工作进程的边车(sidecar)运行。当在表达式评估期间调用 UDF 时,引擎将通过 RPC 调用发送输入数据并获取返回值。

这种方法有几个优点,它将用户编写的代码与引擎进程隔离。它能够独立崩溃,并且拥有自己的 CPU 和内存资源限制。通过在一个单独的、锁定的容器或 VM 中运行,我们甚至可以在云环境中运行潜在的恶意 UDF,而不用担心它们会危及共享基础设施。

然而,缺点又一次是性能。即使进行了批处理,RPC 调用的开销仍然比纯函数调用要高得多,并且需要数据的序列化和反序列化。它还增加了部署的复杂性,因为这将是需要被管理的其他服务。

插件使用 RPC 的方法经常用于当宿主希望将自己与可能有 bug 的插件代码隔离开,并且易于支持多种语言;例如,今天的文本编辑器通过 Language Server Protocol 使用 RPC,而在数据领域 Apache Beam 使用 RPC 来支持跨语言 UDF。

3. 编译为 Wasm

现在我们开始进入正题了。Web 汇编 —— Wasm —— 是跨语言执行中最热门的事物。用像 Rust 这样的语言编写的 UDF 代码可以编译成 Wasm 二进制文件,然后可以由 Wasm 运行时,如 WasmtimeWasmer 动态执行。

Wasm 是一项真正激动人心的技术;它是一种汇编类语言,可以跨语言、操作系统和 CPU 架构移植。理论上这意味着宿主不需要知道或关心插件的原始语言是什么。除了可移植性之外,Wasm 运行时还被设计为沙箱,理论上允许不受信任的用户代码运行而不会危及其宿主。最后,Wasm 支持细粒度的资源管理;宿主可以限制插件可以使用的 CPU 和内存量。

所有这些都是 UDF 系统的绝佳属性,我毫不怀疑 Wasm 将是引擎在未来解决这个问题的部分方式。然而,今天仍存在一些局限性。

首先,性能。Wasm 代码仍然比原生代码慢(根据任务的不同,慢 1.5x-3x)。除了运行时成本外,将数据发送到 Wasm 函数通常需要将其复制到 Wasm 内存中,这对于简单操作来说可能是相当大的开销。

其次,兼容性。并非所有 Rust 代码都可以轻松编译为 Wasm。例如,任何需要链接到 C 库的东西都无法开箱即用。Rust 的许多其他特性(线程、系统调用、网络等)也不是直接支持的。对于其他语言,情况要糟糕得多。对于动态的、GC’d 语言(如 Python),今天最好的选择是将解释器本身编译为 Wasm,但这意味着大多数依赖于 C 扩展的库(如 numpy、scipy、pandas)在没有特殊支持的情况下将无法工作。

简而言之,今天插件作者需要非常熟悉 Wasm 及其限制,才能成功构建更复杂的 UDF。

4. 共享库

共享对象(Windows 上的 .dll,Linux 上的 .so 文件,MacOS 上的 .dylib)是分发库代码的常见方式。它们可以动态链接,意味着应用程序代码只需要在编译时知道接口,而不是实际的代码。它们甚至可以在程序启动后_动态加载_。

动态链接也是实现插件系统的传统方式。例如,数字音频工作站如 Logic Pro 和 Ableton 支持共享对象插件来实现效果和乐器,采用了 VSTAU 等标准。其他创意应用,如照片编辑器和 3D 图形工具,同样通过共享库提供了插件接口。

在许多方面,这是一个显而易见的选择:共享库中的函数与本地函数的性能几乎相同 2,而且我们可以对广泛的 crates 生态系统进行完全兼容。

不幸的是,在 Rust 中动态链接相当具有挑战性。该语言缺乏稳定的 ABI(应用 二进制 接口),这是函数调用者和被调用者之间关于如何在内存中布局变量、如何布局结构体以及其他低级详细信息的契约,这些信息是正确调用外部代码所需要的。这意味着共享库需要用与宿主二进制文件完全相同的编译器版本(可能还包括编译器选项)编译,才能被加载。

但有一个变通方法:使用 C ABI。不同于 Rust,C 确实 在每个主要的操作系统和处理器架构上都有一个稳定的 ABI。所以,如果我们能将插件接口限制为只使用与 C 兼容的数据结构和函数,我们就可以安全地链接任何 Rust 编译器编译的插件。更好的是:由于 C ABI 是系统世界的通用语言,许多其他语言都能够生成它,这为支持多种编译语言的 UDF 打开了大门。

这是我们在 Arroyo 中对 UDFs 采取的路径。

这里讨论的四种插件架构

实现一个 C 接口

那么,如何在 Rust 中构建一个 C ABI 呢?在 C FFI 边界上安全调用函数有许多限制和规则需要遵循。

设计我们的类型

我们需要首先考虑的是数据——在宿主和插件代码之间传递的数据。Rust 为我们提供了许多强大的工具来建模数据,包括结构体、枚举、元组、各种数据结构……而我们几乎要放弃它们中的大多数。为了正确且可靠地通过 C FFI 边界传递数据,我们必须遵循一些非常限制性的规则。

规则 1:repr©

我们的第一个问题是 Rust 的数据布局依赖于 ABI,并且会(实际上也确实会)随着不同版本的编译器而改变。因此,我们得到构建 C 接口的第一条规则:所有数据都需要是 #[repr(C)]

在这一点上,我想介绍一个对于那些从安全的 Rust 舒适小屋出发,走向 unsafe 的深夜黑暗极为有用的资源:Rustonomicon。它有助于免责声明,可能会“释放无法言喻的恐怖,这些恐怖会粉碎你的心灵,并让你的思维在不可知的无限宇宙中漂流。”

记住这个警告,它关于数据布局和 repr 的章节可以在这里找到。

Repr 注解允许开发者为结构体和枚举指定特定的数据布局,其中默认值是“Rust 编译器想要做什么以及认为有效的事情。”有几种支持的布局,但是 repr(C) 的规则非常简单:只做 C 语言所做的。

A lovecraftian monster

长时间凝视 unsafe Rust 的深渊会有趣的后果

要使用替代表示形式,我们可以创建一个数据类型(结构体或枚举)并像这样注释它:

#[repr(C)]
struct MyData {
  a: f32,
  b: i64,
  c: u8
}

这个结构体展示了为什么 #[repr(C)] 是重要的。使用夜间版特有的 rustc 选项 -Zprint-type-sizes 编译这段代码,我们可以看到对于 #[repr(Rust)]#[repr(C)]: 最终得到了完全不同的布局。

// #[repr(Rust)]
print-type-size 类型:`MyData`16 字节,对齐:8 字节
print-type-size 字段 `.b`8 字节
print-type-size 字段 `.a`4 字节
print-type-size 字段 `.c`1 字节
 
// #[repr(C)]
print-type-size 类型:`MyData`24 字节,对齐:8 字节
print-type-size 字段 `.a`4 字节
print-type-size 填充:4 字节
print-type-size 字段 `.b`8 字节,对齐:8 字节
print-type-size 字段 `.c`1 字节
print-type-size 末尾填充:7 字节

事实上,Rust 表示方式更加高效,只占用了 16 字节而不是 C 表示方式的 24 字节。这是因为 Rust 可以自由地重排字段以减少为了达到 8 字节对齐所需的填充字节数。另一方面,C 总是按顺序排列字段,并应用可预测的填充规则,我们在第二个版本中看到了这一点。

该示例结构坚持使用简单的原始数据类型。这就引入了规则号 2:

规则 2:所有数据必须是 FFI 安全的

虽然 #[repr(C)] 允许我们创建可以跨 FFI 边界传递的结构体和枚举,但这种属性不是递归的——也就是说,它控制结构体内字段的布局,但不影响这些字段本身的表示。

事实上,我们在 FFI 安全的数据类型上相当受限。这不是 Rust 文档中记录得很详尽的领域,但 FFI 安全类型的不完全列表包括:

  1. 原始类型u8u16u32u64i8i16i32i64usizeisizef32f64bool
  2. 指针:原始指针 const Tmut T;对可空指针的安全包装,如 Option<NonNull<T>>
  3. 与 C 兼容的枚举:带有明确定义的 repr(C) 的枚举。
  4. 与 C 兼容的结构体:带有 repr(C) 并且只包含 FFI 安全类型的结构体。
  5. 切片[T]const [T]mut [T],当提供了长度时。

所以不能传递 StringVecHashMap 或来自你最喜爱的 Rust 包中的随机数据类型。然而,我们确实有工具可以在某种程度的转换后传递一些有用的数据类型。std::ffi 包括了 CString 和 CStr,这些是拥有和借用的以 null 结尾的 C 风格字符串。类似地,我们可以通过将 Vec 转换成原始指针 + 长度 + 容量,然后再转换回来来传递它。

导出函数

一旦我们确定了数据类型,就可以通过导出 C 兼容函数来设计我们的实际 API 了。C FFI 函数是一个裸 Rust 函数,具有以下特征:

  • #[no_mangle] 注解,告诉 rustc 精确地使用函数名来命名符号,而不是重写(或“改变”)它以确保唯一性并包含有用的元数据
  • extern "C" 关键字,告诉 rustc 使用 C ABI 将函数导出以供外部使用
  • 所有参数和返回类型都是 FFI 安全的3,如上所述

所以将这些结合起来,我们可以用下面的定义来导出一个 C FFI 函数

#[no_mangle]
extern "C" fn add(a: u32, b: u32) -> u32 {
  a + b
}

插件代码中,我们几乎可以使用任何 Rust 构造或特性,只要没有任何东西泄露到类型签名中。

最大的例外是 panic。Rust 的默认恐慌行为是展开,这意味着我们沿着调用栈向上移动,直到我们遇到 catch_unwind 调用(这会停止展开)或线程的顶栈帧,在这种情况下线程退出。但这是 Rust 特有的特性,是不稳定 Rust ABI 的一部分。展开不能穿越 C FFI 边界,否则会有未定义行为的风险。

The words 'Don't Panic'

穿越银河系和跨越 FFI 边界的重要规则

处理这个问题有两种方式:我们需要用 panic = 'abort' 编译我们的插件代码(这会在 panic 时终止进程)或我们需要确保插件不能 panic。但即使我们可以确保我们自己的代码没有 panic4,我们如何确保我们的插件编写者做到同样?

一个答案是使用具有顶层 catch_unwind 调用的插件接口,该调用将 panic 转化为跨 FFI 边界的错误枚举。

编译我们的插件

我们的插件将是一个作为共享对象构建的库 crate,在我们的操作系统上有一个依赖的二进制格式(Linux 上是 .so,Windows 上是 .dll,MacOS 上是 .dylib)。

默认情况下,Rust 将库编译为 rlib 格式的文件,这是 Rust 特有的静态库格式。若要让其构建成可以被其他语言链接的动态系统库,我们将使用 cdylib 类型。可以通过在 Cargo.toml 中设置 lib.crate-type 选项来指定,如下所示:

[package]
name = "my-plugin"
version = "1.0.0"
edition = "2021"
 
[lib]
crate-type = ["cdylib"]

调用 FFI 函数

我们可能通过两种不同的方式在 FFI 中链接并调用插件代码:在程序启动时,或当程序执行时动态地进行。在任一种情况下,我们将再次使用 extern 关键字,但不带函数体,以便告诉宿主端函数签名是什么。

如果我们在编译时知道库的名称,Rust 提供了内置支持,使用 [link] 注释加载系统库。它看起来像这样:

#[link(name = "my_plugin")]
extern "C" {
    fn add(a: u32, b: u32) -> u32;
}
 
fn main() {
    unsafe { add(1, 5) };
}

Rust 会查找名为 my_plugin 的共享库(例如,在 Linux 上,它会查找 /usr/lib/my_plugin.so/usr/local/lib/my_plugin.so 等),并尝试在程序启动时链接它,如果找不到库,则会失败。函数可以像任何其他(不安全的)Rust 函数一样被调用。

但对于插件系统来说,在编译时必须知道库的名称(并确保其已安装在系统位置)有些限制性。相反,我们可以转向动态加载。

动态库加载的接口是特定于操作系统的,但有几个 crate 可以为我们处理跨平台的样板代码。两个最流行的是 libloadingdlopen2。对于 Arroyo,我们决定使用 dlopen2,它具有更好的界面和更强的线程安全保证。

在 dlopen2 中,我们可以为我们的每个插件接口定义结构体。它们看起来像这样:

#[derive(WrapperApi)]
pub struct PluginInterface {
    add: extern "C" fn(a: u32, b: u32) -> u32,
}

插件可以像这样被加载和调用:

let container: Container<PluginInterface> = unsafe {
	Container::load(dylib_path).unwrap()
};
 
unsafe { container.add(1, 3) }

把所有的东西综合起来

所以,这是很多理论知识。让我们用一个完整的例子把它付诸实践吧!我们将基于一个(非常简化的)示例插件系统来工作,该系统可以在 这个仓库 中找到。克隆它到本地试试吧。

代码分为两部分:插件,编译为共享对象,和_宿主_,加载插件。(在一个真实系统中,你可能想要更多的组件,包括一个共享定义的公共库,以便插件和宿主之间共享,以及一个用于代码生成的宏,但我们将保持这相对简单。)

设计接口

在我们开始编写代码之前,我们需要决定插件和宿主之间的契约。对于这个示例,我们将采用一个灵活的契约,支持各种常见数据类型的可变数量参数,正如 UDF 系统所需要的。

插件接口有两个方法:

extern "C" fn plugin_metadata() -> PluginMetadata,
 
extern "C" fn plugin_entrypoint(
    args: *const PluginValue, args_len: usize) -> PluginResult

host 调用元数据函数以确定插件期望的参数数量和类型,以及它返回的数据类型,而入口点函数被调用来实际执行插件的逻辑。

如上所述,我们所有的数据类型都需要是 FFI 安全的。例如,PluginMetadata 类型如下所示:

#[repr(C)]
#[derive(Copy, Clone)]
pub enum PluginType {
    Bool,
    Int,
    UInt,
    Double,
    String,
}
 
#[repr(C)]
pub struct PluginMetadata {
    // 应有一个静态生命周期
    pub name: *const i8,
    pub arg_types: *const PluginType,
    pub arg_types_len: usize,
    pub return_type: PluginType,
}

请注意,我们传递的不是字符串类型的名称,而是 *const i8,它代表了一个以空字符结尾的 C 风格字符串。我们传递的不是 Vec<PluginType> 类型的参数,而是一个指向某段内存的指针和它的长度。

对于入口点函数,我们需要将实际数据传递给插件。这依赖于一个 PluginValue 类型值的数组:

#[repr(C)]
pub enum PluginValue {
    Bool(bool),
    Int(i64),
    UInt(u64),
    Double(f64),
    // 所有字符串都由宿主拥有
    String(*const i8),
}

对于基本数据类型,我们可以按原样使用,因为所有 Rust 基本数据类型都是 FFI 安全的。然而,字符串再次需要特别注意。我们有两种传递字符串的典型选项:我们可以传递 Rust 风格的字符串(带有一系列字符和长度)或 C 风格的字符串(其结束由空字节决定)。虽然前者在现代 API 中更安全,通常更受欢迎,但对于 C 接口,后者更常见,因为它更容易被具有 C FFI 的语言支持。

除了数据的格式外,我们还需要考虑所有权。一旦内存被分配,我们的代码中的确切一个部分(在这种情况下,是宿主/插件之间的一侧)需要拥有该内存。PluginValues 既由宿主创建(提供数据)也由插件创建(返回其结果),但为了简化内存管理,我们已经记录在案,在这两种情况下,宿主都拥有内存并负责释放它。这确实意味着插件代码需要小心,永远不要从内存中创建一个拥有对象(在这种情况下,是一个 CString),这将在释放时自由它。

最后,我们的返回类型,只是一个带有 CString 错误消息的 FFI 安全的 Result 类型:

#[repr(C)]
pub enum PluginResult {
    Ok(PluginValue),
    // 以空字符结尾的 c 字符串;宿主负责
    // 释放这个值
    Err(*mut i8),
}

既然我们有了我们的通用接口,让我们看看它们是如何被使用的。我们将从插件开始。

我们在这里使用了两种不同类型的裸指针:*mut*const。那是为什么?有什么区别吗?在 FFI 的上下文中,答案是:不多。mutconst 的选择不影响生成的代码,你可以自由地在它们之间进行转换。

然而,它们对于记录 FFI 边界的意图和所有权很有用。使用 *const 告诉调用代码他们不应该修改指针后面的数据,并且可能不应该释放它,而 *mut 表示可以修改数据并且也可以传达所有权。

我们在这里有点随意行事,因为我们对我们的参数(宿主创建并拥有)和我们的返回值(插件创建但转移给宿主)使用了单一数据类型(PluginValue),所以我们选择 *const 来告诉插件不要修改或释放其参数。然而,在宿主端,我们然后必须将其转换为 *mut,以便我们可以取得所有权。

插件

我们将要实现一个简单的插件,接受两个参数,一个字符串和一个数字,并将返回该数字次数的字符串重复:f("cool", 3)"coolcoolcool"

插件负责构建共享库,所以我们需要告诉 Cargo 我们想要的是这个。我们通过将 crate 类型指定为 cdylib,一个 C 兼容的动态库,来实现这一点。我们的 Cargo.toml 看起来是这样的:

[package]
name = "plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

接下来是我们的 src/lib.rs 文件。这将包含上面记录的插件接口的实现。

我们需要实现的第一个函数是 plugin_metadata(),这是很简单的,告诉宿主关于我们的参数和返回类型:

#[no_mangle]
pub extern "C" fn plugin_metadata() -> PluginMetadata {
    PluginMetadata {
        name: "repeat\0".as_ptr() as *const i8,
        arg_types: [PluginType::String, PluginType::UInt].as_ptr(),
        arg_types_len: 2,
        return_type: PluginType::String,
    }
}

接下来我们将实现我们插件的独特逻辑,在这个案例中是重复 N 次一个字符串。我发现将这一点从涉及转换为和从 FFI 类型转换的模板代码中分离出来最为简单,这样当我们在开发逻辑时,我们可以保持在安全、正常的 Rust 语境中。

fn repeat_impl(input: &str, count: u64) -> String {
    input.repeat(count as usize)
}

简单明了。不幸的是,我们仍然需要复杂的代码来连接 FFI 和 Rust 世界。对于这个例子,它看起来像这样:

#[no_mangle]
pub extern "C" fn plugin_entrypoint(args: *const PluginValue,
                                    args_len: usize) -> PluginResult {
    // 首先我们需要检查参数是否有效
    if args_len != 2 {
        return plugin_error("args_len 应该为 2");
    }
 
    let PluginValue::String(string) = (unsafe { &*args.offset(0) }) else {
        return plugin_error("arg0 无效;期望 String");
    };
 
    let PluginValue::UInt(count) = (unsafe { &*args.offset(1) }) else {
        return plugin_error("arg1 无效;期望 UInt");
    };
 
    let string = match unsafe { CStr::from_ptr(*string) }.to_str() {
        Ok(value) => value,
        Err(_) => {
            return plugin_error("arg0 无效;期望有效的 UTF-8 字符串");
        }
    };
 
    // 然后我们可以用转换后的参数调用我们的逻辑,并将它们重新包装
    // 在我们的 Result 类型中,捕获可能发生的任何 panic,
    // 以防它们越过 FFI 边界
    match catch_unwind(|| repeat_impl(string, *count)) {
        Ok(value) => PluginResult::Ok(
          PluginValue::String(CString::new(value).unwrap().into_raw())),
        Err(_) => plugin_error("函数 panic 了"),
    }
}

让你的用户为每个插件编写所有这些不安全的样板代码不是一个很好的用户体验,所以你可能想要使用宏,或者只是包装代码(如果你不需要支持多种类型)。你可以在这里看到 Arroyo 插件系统的宏。

宿主

The Host from 'The Host'

(不是这个)

宿主是一个正常的 Rust 应用程序,使用 cargo new 创建。它有一个依赖项,dlopen2,我们将用它来动态加载我们的插件:

[dependencies]
dlopen2 = { version = "0.7.0", features = ["derive"] }

核心代码位于 src/main.rs,它构建了我们的二进制文件。我们需要重复定义(或从通用库中引入),但我们还会包含另一个定义,一个 PluginValue 的拥有版本:

pub enum OwnedPluginValue {
    Bool(bool),
    Int(i64),
    UInt(u64),
    Double(f64),
    String(CString),
}

impl PluginValue {
    pub fn to_owned(self) -> OwnedPluginValue {
        match self {
            PluginValue::Bool(b) => OwnedPluginValue::Bool(b),
            PluginValue::Int(i) => OwnedPluginValue::Int(i),
            PluginValue::UInt(u) => OwnedPluginValue::UInt(u),
            PluginValue::Double(d) => OwnedPluginValue::Double(d),
            PluginValue::String(s) => {
                OwnedPluginValue::String(
                    unsafe { CString::from_raw(s as *mut i8) })
            }
        }
    }
}

这个拥有的结构将允许我们确保从插件返回的值(以及我们发送给它的参数)最终被释放。

接下来,我们将使用 dlopen2 的 WrapperApi 宏来定义插件接口:

#[derive(WrapperApi)]
struct PluginApi {
    plugin_metadata: unsafe extern "C" fn() -> PluginMetadata,
    plugin_entrypoint:
        unsafe extern "C" fn(args: *const PluginValue, args_len: usize)
            -> PluginResult,
}

这让我们方便地将所有插件函数打包成一个结构体,我们可以在应用程序中存储和传递它。

现在我们准备好加载插件并调用它。CLI 参数处理的细节我就不赘述了(详细信息可见完整示例文件)。以下是核心内容:

// 通过 dlopen2 的 Container API 加载插件
let container: Container<PluginApi> =
    unsafe { Container::load(&args[1]) }.expect("无法加载插件");

// 获取元数据,这将告诉我们期望哪些参数
let metadata: PluginMetadata = unsafe { container.plugin_metadata() };

// 从命令行读取参数
let mut call_args: Vec<PluginValue> = vec![];
for (i, arg) in args[2..].iter().enumerate() {
    match unsafe { *metadata.arg_types.add(i) } {
        PluginType::Bool => {
            call_args.push(PluginValue::Bool(
                arg.parse().expect("无效的 bool")))
        }
        ...
    }
}

// 调用插件函数
let result = unsafe {
    container.plugin_entrypoint(call_args.as_ptr(), call_args.len())
};

// 取得所有权并丢弃参数以释放它们的内存
drop(call_args.into_iter().map(|t| t.to_owned()));

// 向用户打印出结果或错误
match result {
    PluginResult::Ok(value) => {
        println!("插件返回:{}", value.to_owned());
    }
    PluginResult::Err(err) => {
        eprintln!("{}", unsafe { CString::from_raw(err) }.to_string_lossy());
        std::process::exit(1);
    }
}

让我们插入一些东西吧!!!

我们来了。在写了 5000 多个单词之后,我们将要动态加载一些 Rust 代码。

一只手将插头插入插座

呃…… 不是那样的

如果你想跟着做,请查看示例仓库

$ git clone https://github.com/mwylde/rust-plugin-tutorial.git

然后我们将要构建插件和宿主

$ cd rust-plugin-tutorial/plugin && cargo build
$ cd ../host && cargo build

现在我们应该在 plugin/target/debug 中有一个动态库和在 host/target/debug 中有一个宿主二进制文件。动态库将被命名为 “libplugin.dylib”、“libplugin.so” 或 “libplugin.dll”,这取决于你的操作系统。记下它的名字,然后像这样调用宿主:

$ host/target/debug/host plugin/target/debug/libplugin.dylib cool 3
Loaded plugin repeat
Plugin returned: coolcoolcool

如果一切顺利,你应该会看到来自你插件代码的输出(没有烦人的段错误)。

总结

那么这就是我们如何构建我们的插件系统的背景,以及你如何可以构建你自己的。

稍微回顾一下:

  • 我们将我们的数据类型定义为 FFI 安全类型的枚举和结构体
  • 我们定义了一个插件接口,作为消费和返回那些数据类型的 #[no_mangle] extern "C" 函数
  • 我们使用了 dlopen2 来从宿主加载并调用我们的插件接口

在这个系列的第 2 部分,我们将介绍这在一个真实的、生产级别的插件系统中是如何工作的,包括对异步函数的支持。 (如果你不耐烦,所有的代码可以在这里找到。)

有问题?担忧?问题?虐待?你可以在 Arroyo Discord 上或通过 micah@arroyo.systems 联系我。

脚注

  1. 这有点简化了;实际的编译必须考虑到 SQL 的复杂空值规则。看一个更完整的例子这里
  2. 实际上,动态链接的函数可能会运行得稍慢一些,因为它们无法被内联或利用链接时或基于配置文件的优化。[^2]
  3. 如果你在 extern 函数中使用了非 FFI 安全类型,Rust 编译器会有帮助地提醒你。[^3]
  4. 这是……我们当然都会这么做。[^4]
  5. 所有 FFI 函数本质上都是不安全的,因为 Rust 无法保证 FFI 边界另一侧的任何事情。因此,通常会围绕 FFI 函数编写安全包装器,以确保库期望的不变量得到维护。[^5]
  6. Rust 的反对者抱怨生命周期和借用检查器系统,但仅仅因为你的编译器没有因此对你大喊大叫,并不意味着这些问题就消失了。在 C API 中,你总是需要知道谁拥有一块内存以及谁负责释放它。但是在没有语言支持的情况下,你只能记录下来并希望一切顺利。[^6]