写给前端看的Rust教程(21)WebAssembly实战三

5,278 阅读8分钟

原文:24 days from node.js to Rust

前言

如今WebAssembly是一个激动人心的技术,这是一个真正的开发平台,很多人都认为JavaScript会变成通用编译语言,做到编写一次处处运行,甚至我们有“Univeral JavaScript” 和 “Isomorphic JavaScript.”这样的概念

我们已经接近了这个目标,但还有些许的差距,这主要在于JavaScript不善于处理CPU密集型问题。WebAssembly是为了web而创造,可以字节码的形式到处运行,最酷的是浏览器支持运行WebAssembly

正文

创建WebAssembly模块

将高级语言代码编译成WebAssembly并不困难,难的是如何选择用它来实现什么有用的功能。因为WebAssembly只能接受并返回数字,这看起来似乎WebAssembly只擅长处理数学,不过其实对计算机你也可以这么说。计算机本质上就是一台自动算数机,但它可以处理很多事情

waPC

waPC 定义了WebAssembly过程调用(Procedure Calls)协议,它类似是一个WebAssembly插件框架。在waPC世界里,一个实现者是主机,WebAssembly模块是客户端。在Rust中你可以创建一个WebAssembly模块

我们这里有三篇关于waPC的文章,第二篇介绍在Rust中构建WebAssembly模块,第三篇则是介绍如何在Node.js中运行

测试模块

我们提前设置好了一个 测试模块 ,该模块有一个公开的接口hello,以一个字符串为参数并返回一个字符串

建立waPC主机

我们在my_lib中创建项目,我们测试了Module::from_file函数,现在要对其进行完善。

读取文件

你可以通过std::fs::readstd::fs::read_to_string实现基本的读操作,因为我们加载的是二进制数据,所以我们需要用fs::read获取到Vec<u8>类型的字节列表

pub fn from_file<T: AsRef<Path>>(path: T) -> Result<Self, std::io::Error> {
    debug!("Loading wasm file from {:?}", path.as_ref());
    let bytes = fs::read(path.as_ref())?;
    // ...
}

现在我们获取到了字节,下面我们该如何处理他们呢?我们的Module应该有一个构造函数,有一个new方法,接受字节,让我们继续改造下

crates/my-lib/src/lib.rs:

pub fn from_file<T: AsRef<Path>>(path: T) -> Result<Self, std::io::Error> {
    debug!("Loading wasm file from {:?}", path.as_ref());
    let bytes = fs::read(path.as_ref())?;
    Self::new(&bytes)
}
pub fn new(bytes: &[u8]) -> Result<Self, ???> {
  // ...
}

现在我们会遇到和之前 教程14 一样的问题,我们知道new()会返回有个Result,但其失败存在各种可能,我们需要一个通用错误来捕获各种类型的情况。是时候创建一个自定义错误了,我们在my-lib项目的Cargo.toml文件中依赖中添加thiserror

[dependencies]
log = "0.4"
thiserror = "1.0"

创建一个error.rs文件,新建一个Error枚举

crates/my-lib/src/error.rs:

use std::path::PathBuf;

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("Could not read file {0}: {1}")]
    FileNotReadable(PathBuf, String),
}

相比于封装io::Error,我们增加了一个自定义信息以及多个参数以便可以定制错误信息。为了使用这个Error,首先声明error模块然后导入Error

pub mod error;
use error::Error;

现在我们可以将Result类型改为返回错误并且使用.map_err()来将io::Error映射为Error

pub fn from_file<T: AsRef<Path>>(path: T) -> Result<Self, Error> {
    debug!("Loading wasm file from {:?}", path.as_ref());
    let bytes = fs::read(path.as_ref())
        .map_err(|e| Error::FileNotReadable(path.as_ref().to_path_buf(), e.to_string()))?;
    Self::new(&bytes)
}

.map_err()是一个转化错误类型的常见方法,特别是和?运算符结合在一起。我们没有添加From<io::Error>的实现是因为我们希望对错误信息有更多的控制,需要手动来做映射

读wapc、wasmtime-provider

wapc包提供了WapcHost数据结构和WebAssemblyEngineProvider traitWebAssemblyEngineProvider允许我们可以轻松的交换多个WebAssembly引擎,我们接下来会使用 wasmtime 引擎,不过你可以简单的使用 wasm3 或对任何一个新引擎实现一个新的WebAssemblyEngineProvider

Cargo.toml中添加wapcwasmtime-provider依赖:

crates/my-lib/Cargo.toml

[dependencies]
log = "0.4"
thiserror = "1.0"
wapc = "0.10.1"
wasmtime-provider = "0.0.7"

在我们创建WapcHost之前,首先需要初始化引擎:

let engine = wasmtime_provider::WasmtimeEngineProvider::new(bytes, None);

第二个参数是我们的WASI配置,现在先忽略

WapcHost构造函数接受两个参数一个是引擎,另一个是WebAssembly客户端调用时运行的函数,现在我们没什么特殊的需要,只返回一个错误:

let host = WapcHost::new(Box::new(engine), |_id, binding, ns, operation, payload| {
    trace!(
        "Guest called: binding={}, namespace={}, operation={}, payload={:?}",
        binding,
        ns,
        operation,
        payload
    );
    Err("Not implemented".into())
})

构造函数返回了一个含有新错误类型的Result,所以我们须臾奥添加一个新的错误枚举:

crates/my-lib/src/error.rs

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error(transparent)]
    WapcError(#[from] wapc::errors::Error),
    #[error("Could not read file {0}: {1}")]
    FileNotReadable(PathBuf, String),
}

我们还需要在Module结构体中存储host

pub struct Module {
    host: WapcHost,
}

最终代码如下所示:

impl Module {
  pub fn from_file<T: AsRef<Path>>(path: T) -> Result<Self, Error> {
      debug!("Loading wasm file from {:?}", path.as_ref());
      let bytes = fs::read(path.as_ref())
          .map_err(|e| Error::FileNotReadable(path.as_ref().to_path_buf(), e.to_string()))?;
      Self::new(&bytes)
  }
  pub fn new(bytes: &[u8]) -> Result<Self, Error> {
    let engine = wasmtime_provider::WasmtimeEngineProvider::new(bytes, None);

    let host = WapcHost::new(Box::new(engine), |_id, binding, ns, operation, payload| {
        trace!(
            "Guest called: binding={}, namespace={}, operation={}, payload={:?}",
            binding,
            ns,
            operation,
            payload
        );
        Err("Not implemented".into())
    })?;
    Ok(Module { host })
  }
}

现在我们实现了from_file读取文件并实例化一个新的Module,如果我们运行cargo test可以看到测试通过:

$ cargo test
[snipped]
     Running unittests (target/debug/deps/my_lib-afb9e0792e0763e4)

running 1 test
test tests::loads_wasm_file ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.84s
[snipped]

下一步是运行wasm

调用WebAssembly方法

将数据转进转出WebAssembly就是序列化与反序列化,waPC不需要特定得序列化格式,它的默认代码生成器用的是MessagePack,这是我们后面会用到的,不过你也可以自由的更改它

Cargo.toml中添加rmp-serde依赖:

[dev-dependencies]
rmp-serde = "0.15"
设计测试

在实现之前我们先设计测试,我们的测试模块包含一个导出的函数hello,功能是返回一个字符串,如果传入的是"World"则返回的是"Hello, World."

crates/my-lib/src/lib.rs

#[cfg(test)]
mod tests {
  // ...snipped
  #[test]
  fn runs_operation() -> Result<(), Error> {
      let module = Module::from_file("./tests/test.wasm")?;

      let bytes = rmp_serde::to_vec("World").unwrap();
      let payload = module.run("hello", &bytes)?;
      let unpacked: String = rmp_serde::decode::from_read_ref(&payload).unwrap();
      assert_eq!(unpacked, "Hello, World.");
      Ok(())
  }
}

逐行分析:

  • 第6行:加载我们的测试模块
  • 第8行:将"World"编码成MessagePack字节格式
  • 第9行:调用.run()方法,接收两个参数
  • 第10行:解码返回的结果
  • 第11行:判断返回结果是否是"Hello, World."

当你进行测试的时候,上述代码不会通过编译,因为我们没有实现.run()。实现.run()需要用到创建的WapcHost来调用WebAssembly并返回结果,具体会用到.call()函数:

pub fn run(&self, operation: &str, payload: &[u8]) -> Result<Vec<u8>, Error> {
    debug!("Invoking {}", operation);
    let result = self.host.call(operation, payload)?;
    Ok(result)
}

现在再次运行测试,现在就可以通过了

$ cargo test
[snipped]
     Running unittests (target/debug/deps/my_lib-afb9e0792e0763e4)

running 2 tests
test tests::runs_operation ... ok
test tests::loads_wasm_file ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.84s
[snipped]

升级命令行

现在我们的库可以加载并运行WebAssembly了,我们还需要将这种能力导出到命令行,现在我们需要在CliOptions中增加两个额外的新参数,一个是操作的名称,另一个数传输的数据:

crates/cli/src/main.rs

struct CliOptions {
    /// The WebAssembly file to load.
    #[structopt(parse(from_os_str))]
    pub(crate) file_path: PathBuf,

    /// The operation to invoke in the WASM file.
    #[structopt()]
    pub(crate) operation: String,

    /// The data to pass to the operation
    #[structopt()]
    pub(crate) data: String,
}

一开始我们对错误处理不足,导致如果main()函数报错,我们收到的错误信息比较令人困惑和不专业。下面将业务逻辑提取到一个run()函数中,该函数生成一个Result,我们可以在main()中测试它。如果我们得到了错误结果,则将其打印然后并以一个非0值退出表示程序运行失败

crates/cli/src/main.rs

fn main() {
    env_logger::init();
    debug!("Initialized logger");

    let options = CliOptions::from_args();

    match run(options) {
        Ok(output) => {
            println!("{}", output);
            info!("Done");
        }
        Err(e) => {
            error!("Module failed to load: {}", e);
            std::process::exit(1);
        }
    };
}

fn run(options: CliOptions) -> anyhow::Result<String> {
  //...
}

注意我们这里用到了anyhow,如果你仔细阅读了 教程14 那你对anyhow包应该还有印象。除了anyhow,我们还需要添加rmp-serde在发送给WebAssembly之前做数据的序列化

[dependencies]
my-lib = { path = "../my-lib" }
log = "0.4"
env_logger = "0.9"
structopt = "0.3"
rmp-serde = "0.15"
anyhow = "1.0"

.run()函数和测试很像,区别只是数据来自于CliOptions而非硬编码:

fn run(options: CliOptions) -> anyhow::Result<String> {
    let module = Module::from_file(&options.file_path)?;
    info!("Module loaded");

    let bytes = rmp_serde::to_vec(&options.data)?;
    let result = module.run(&options.operation, &bytes)?;
    let unpacked: String = rmp_serde::from_read_ref(&result)?;

    Ok(unpacked)
}

现在我们可以用命令行来运行我们的程序了

» cargo run -p cli -- crates/my-lib/tests/test.wasm hello "Potter"
[snipped]
Hello, Potter.

现在我们已经实现了一个很棒的程序,不过它现在只能传递和返回字符串,我们接下来还可以做的更好

相关阅读

总结

这节教程有点长,跟起来会不那么容易。开人员利用WebAssembly又很广阔的应用场景, waPC 只是其中之一,除此之外 Wasm-bindgen 也是很有名的。无论你选择哪条路线,你都不可避免地需要在危险的地形中开辟出自己的道路,WebAssembly很稳定,但目前还并未成为主流,最佳实践也远未形成

在下一篇教程里,我们将会拓展我们命令行程序的能力,让它可以接收JSON数据以便可以处理任何的WASM模块

更多