致虚极 守静笃
通过WebAssembly实现插件机制
2021-02-22发布 0

插件

前两天在鼓捣coco的插件系统,我们常说要面向接口开发而不是面向实现,插件这个东西,就像后端框架里的中间件,我们按照框架定义的接口实现中间件,这也可以算一种插件,我们有很多机制实现“编译前插件”,但是像coco这样要编译发布的二进制程序,有什么办法让用户定义插件来补充功能呢?不能在运行期间插入用户的代码再重新编译整个程序吧?有办法在运行时加载用户的库文件吗?有的,这项技术被称为Dynamic Loading

Plugin

在coco中借助dlopen这个crate实现了动态加载,但是在Rust中动态加载似乎必须要unsafe,就在为coco实现插件机制的某个瞬间,我突然有了个新的想法。

WebAssembly

WebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如C / C ++等语言提供一个编译目标,以便它们可以在Web上运行。它也被设计为可以与JavaScript共存,允许两者一起工作。——MDN

WebAssembly最开始设计是在浏览器中运行的,不过就像JS的node一样,现在WebAssembly也有了独立于浏览器之外的运行时,比如wasmtimewasmer,并且它们都提供了嵌入各个主流语言的辅助库,也就是说,我可以在这个运行时支持的语言内自由地调用wasm二进制文件内的函数咯?试试看!

wasmtime

我尝试用wasmtime做了个demo,首先新建一个crate:

cargo new adder --lib

打开src/lib.rs文件,写一个简单的求和函数:

#[no_mangle]
pub extern "C" fn adder(a: i32, b: i32) -> i32 {
    a + b
}

no_mangle告诉Rust编译器不要修改函数名称,以便后续调用。另外还要修改Cargo.toml文件:

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

接着就可以这个命令编译:

# rustup target add wasm32-wasi
# 如果没有设置target要用上面的命令设置下
cargo build --target wasm32-wasi

这下就可以在项目下的target目录里找到对应的adder.wasm文件了。

接着再创建一个crate:

cargo new wasm_test

main.rs写入如下代码:

use std::error::Error;
use wasmtime::*;

fn main() -> Result<(), Box<dyn Error>> {
    let engine = Engine::default();
    let store = Store::new(&engine);
    let module = Module::from_file(&engine, "adder.wasm")?;
    let instance = Instance::new(&store, &module, &[])?;
    let adder = instance
        .get_func("adder")
        .expect("adder was not an exported function");
    let adder = adder.get2::<i32, i32, i32>()?;
    let result = adder(2, 4)?;
    println!("result is {}", result);
    Ok(())
}

不要忘了引入wasmtime依赖,不过现在运行会直接报错:

Error: wrong number of imports provided, 0 != 4

调试后发现错误出在创建instance的地方,浏览文档发现如果wasm中有import依赖项,那么这里第三个参数就不能是空数组,而是包括所有依赖的数组,不过这么简单的函数哪来的依赖呢?通过wasm2wat工具将wasm文件转成文本格式,再用grep搜索一下,果然,编译后自动添加了一些依赖:

❯ wasm2wat adder.wasm | grep import
  (import "wasi_snapshot_preview1" "fd_write" (func $_ZN4wasi13lib_generated22wasi_snapshot_preview18fd_write17ha0aef7cef0a152b0E (type 6)))
  (import "wasi_snapshot_preview1" "environ_sizes_get" (func $__wasi_environ_sizes_get (type 2)))
  (import "wasi_snapshot_preview1" "proc_exit" (func $__wasi_proc_exit (type 0)))
  (import "wasi_snapshot_preview1" "environ_get" (func $__wasi_environ_get (type 2)))

因为我们编译的目标是wasm32-wasi,wasi全称是WebAssembly System Interface,是一个标准化的WebAssembly系统接口,而wasmtime在说明如何在Rust中使用的部分给的代码却是适用于wasm32-unknown-unknown的(不得不说文档质量不太好),这里如果把编译target改成wasm32-unknown-unknown就可以直接运行。

不过不改编译目标就要稍微修改下程序,根据搜到的issue,我改了下代码:

use std::error::Error;
use wasi_cap_std_sync::WasiCtxBuilder;
use wasmtime::*;
use wasmtime_wasi::Wasi;

fn main() -> Result<(), Box<dyn Error>> {
    let engine = Engine::default();
    let store = Store::new(&engine);

    let mut linker = Linker::new(&store);
    let wasi = Wasi::new(
        &store,
        WasiCtxBuilder::new()
            .inherit_stdio()
            .inherit_args()?
            .build()?,
    );
    wasi.add_to_linker(&mut linker)?;

    let module = Module::from_file(&engine, "adder.wasm")?;
    let instance = linker.instantiate(&module)?;
    let adder = instance
        .get_func("adder")
        .expect("adder was not an exported function");
    let adder = adder.get2::<i32, i32, i32>()?;
    let answer = adder(1, 7)?;
    println!("the answer is {}", answer);
    Ok(())
}

搞定!wasmtime目前支持五种语言,wasmer支持更多,这样用户可以用C/C++或者Go来写插件啦,我们可以在Rust程序中调用,并且不用写unsafe了。

总结

通过WebAssembly我们可以在Rust中调用其它语言写的库,反过来其实也可以,WebAssembly成为了一种中间语言或者说虚拟机,例如在Python中,由于动态类型特性,可以这样使用:

import wasmtime.loader
import adder  # 直接引入adder.wasm


print(adder.adder(1, 7))

我很看好WebAssembly的前景,独立运行时的出现,使得WebAssembly成为一个通用的公共语言运行时,实现“Run any code on any client”。