Joshua Mo 2024-02-29
你好世界!在今天的这篇文章中,我们将探讨如何使用 Rust 编写一个 WebAssembly 模块。WebAssembly 是一种可移植的编译目标,允许编程语言方便地与 Web 上的 JavaScript 互操作。Rust 能够利用这一特性,使其在许多场景中变得极为有用,例如:
- CPU 密集型工作负载(如加密)
- GPU 密集型工作负载(如图像/视频处理、图像识别)
本文将重点介绍如何编写一个可用于后端的图像处理 WASM 模块,并探索部署 WASM 及其目标平台的常见方式。
入门准备
要开始,你需要先安装 Rust。如果尚未安装,可以在此处进行安装。
我们将尝试通过三种不同的方式来编写 WASM 模块:
- 使用
wasm-bindgenCLI - 使用
wasm-pack - 使用
napi-rs
我们将首先使用 wasm-bindgen-cli 创建应用程序,然后了解如何使用 wasm-pack。本文的重点是创建一个简单的图像处理模块。字节数组操作和数据处理正是 Rust 能显著提升应用性能的领域。
在开始之前,请确保你已安装 wasm32-unknown-unknown 目标。如果没有,可以通过以下命令添加:
rustup target add wasm32-unknown-unknown
注意:为了测试我们的模块,你还需要安装 npm(或任何替代工具)。
编写 WASM 模块
基础设置
首先,我们使用以下命令创建一个名为 wasm-example 的新库项目:
cargo init --lib wasm-example
接着,通过以下 shell 命令安装依赖项:
cargo add wasm-bindgen@0.2.91
cargo add js-sys@0.3.68
cargo add image@0.24.9
我们还需要在 Cargo.toml 文件中添加动态库标志。通常,这会告诉 Cargo 我们希望构建一个动态系统库;但在使用 WebAssembly 目标时,它仅仅表示“生成一个没有启动函数的 *.wasm 文件”。为此,可以在 Cargo.toml 中添加以下片段:
[lib]
crate-type = ["cdylib"]
在 Rust 中使用 JavaScript 类型
为了在 Rust 中使用 JavaScript 类型,我们需要结合 extern "C" 和 wasm-bindgen 宏。这使我们能够直接从 JavaScript 导入函数并在 Rust 中调用!
WASM 中的 “Hello World” 应用如下所示(摘自官方书籍):
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
#[wasm_bindgen]
pub fn greet(name: &str) {
alert(&format!("Hello, {}!", name));
}
注意:extern "C" 块中的 alert 函数直接来自 JavaScript,这使得我们可以在 Rust 函数中调用它。如果我们将其编译并在 JavaScript 文件中执行,效果就等同于在普通 JavaScript 中调用 alert()。
我们可以将相同的逻辑应用于其他类型和函数——尤其是缓冲区(buffers)。在 JavaScript 中,Rust 的 Vec<u8> 可以通过以下两种方式之一表示:
Uint8Array类型(Vec<u8>的直接 JavaScript 等价物)Buffer类型
Buffer 是 Uint8Array 的子类。这是因为 Node.js 最初发布时还没有 Uint8Array 类型,因此创建了 Buffer 类型。后来随着 ES6 引入了 Uint8Array,两者最终被合并。如今许多 JavaScript 库仍在使用 Buffer。通过 js-sys,我们可以在 JavaScript 和 Rust 之间实现互操作性。如下所示,我们定义 Buffer 类型并提供 buffer() 方法:
use js_sys::ArrayBuffer;
// 定义 Node.js 的 Buffer 类型
#[wasm_bindgen]
extern "C" {
pub type Buffer;
#[wasm_bindgen(method, getter)]
fn buffer(this: &Buffer) -> ArrayBuffer;
#[wasm_bindgen(method, getter, js_name = byteOffset)]
fn byte_offset(this: &Buffer) -> u32;
#[wasm_bindgen(method, getter)]
fn length(this: &Buffer) -> u32;
}
现在,当我们编写 WASM 函数时,就可以直接引用 Buffer 类型了!
让我们编写一个用于转换图像文件格式的 Rust 函数。我们将要求传入一个 Buffer,并返回 Vec<u8>。当我们通过 wasm-pack 或其他编译器编译时,它会自动转换为 Uint8Array。
use js_sys::{ArrayBuffer, Uint8Array};
use wasm_bindgen::prelude::wasm_bindgen;
use image::ImageFormat;
use image::io::Reader;
use std::io::Cursor;
// ... extern C 部分放在这里
#[wasm_bindgen]
pub fn convert_image(buffer: &Buffer) -> Vec<u8> {
// 将 Node.js Buffer 转换为 Vec<u8>
let bytes: Vec<u8> = Uint8Array::new_with_byte_offset_and_length(
&buffer.buffer(),
buffer.byte_offset(),
buffer.length()
).to_vec();
let img2 = Reader::new(Cursor::new(bytes)).with_guessed_format().unwrap().decode().unwrap();
let mut new_vec: Vec<u8> = Vec::new();
img2.write_to(&mut Cursor::new(&mut new_vec), ImageFormat::Jpeg).unwrap();
new_vec
}
通过 wasm-bindgen-cli 构建
这里,我们需要将 Rust 代码编译为 WASM,方法是为 wasm32-unknown-unknown 目标构建包:
cargo build --target=wasm32-unknown-unknown
接下来,我们需要使用 wasm-bindgen 生成 JavaScript 胶水代码(glue code),使其正常工作。我们将使用 nodejs 目标,它会生成一个 CommonJS 模块,并将其放入 ./pkg 文件夹中,以便我们在任何地方使用:
wasm-bindgen --target nodejs --out-dir ./pkg \
./target/wasm32-unknown-unknown/release/wasm_example.wasm
现在,我们可以将我们的 WASM 代码作为包发布,或者嵌入到任何需要使用的地方!
我不想使用 CommonJS!
如果你因为使用 ESM(EcmaScript 模块,即 ES6 模块)而不希望使用 CommonJS,没问题!CLI 当前支持多种目标:
bundler:生成适用于 Webpack 等打包工具的代码web:可直接在浏览器中加载nodejs:可通过require作为 CommonJS Node.js 模块加载deno:可作为 Deno 模块使用no-modules:类似于web目标,但不使用 ES 模块
关于 ES 模块的具体使用文档已有详细说明。就编译器选择而言,最简单的方式通常是使用 Webpack,因为它兼容性最好。你也可以在不使用打包工具的情况下编译为 ES6 模块,但这需要在运行前手动初始化 WASM 模块,会带来一些额外开销。
测试我们的新模块
现在我们已经编写好代码,让我们试一试!我们将使用 Express.js 启动一个 JavaScript 后端服务器。为方便起见,我们假设你在 Rust 项目的同一目录下运行以下命令。
首先,使用以下 shell 命令初始化 npm 项目并安装依赖:
npm init -y
npm i express express-fileupload
接着,在根目录创建一个 server.js 文件,并插入以下代码:
const fileUpload = require("express-fileupload");
const express = require("express");
const { convert_image } = require("./pkg/wasm_example");
const app = express();
app.use(fileUpload());
const port = 3030;
app.get("/", (req, res) => {
res.send(`
<h2>With <code>"express"</code> npm package</h2>
<form action="/api/upload" enctype="multipart/form-data" method="post">
<div>Text field title: <input type="text" name="title" /></div>
<div>File: <input type="file" name="file"/></div>
<input type="submit" value="Upload" />
</form>
`);
});
app.post("/api/upload", (req, res, next) => {
const image = convert_image(req.files.file.data);
res.setHeader("Content-disposition", 'attachment; filename="meme.jpeg"');
res.setHeader("Content-type", "image/jpg");
res.send(image);
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
});
这段代码的作用如下:
- 在 3030 端口启动一个 Express 服务器
- 访问
/路由时,浏览器会显示一个 HTML 表单 - 提供一个 API 路由,用于获取上传文件的数据,将其转换为新格式,设置正确的响应头,并返回新图像
运行 node server.js,然后在浏览器中访问 http://localhost:3030,填写表单并上传一张图片,你应该会收到一个图像下载响应!
注意:根据你设置的图像格式转换参数,转换后的文件大小可能会增加;这是因为你可能使用了无损转换。如果你希望通过有损压缩减小文件大小,可以在 Rust 代码中实例化图像编码器时使用
new_with_quality方法。
使用其他 CLI 工具构建应用
虽然 wasm-bindgen-cli 很有用,但它也是我们选项中最底层的 CLI 工具,使用过程中可能会遇到一些奇怪的问题,比如 wasm-bindgen 版本不兼容。还有一些生活质量改进值得考虑,例如自动版本管理和 wasm-opt 优化。让我们快速看看其他选项,并比较它们的优劣。
Wasm-pack
wasm-pack 是一个旨在成为 Rust 到 WASM 编译一站式解决方案的工具。你可以安装它的 CLI。与 wasm-bindgen-cli 相比,它提供了多项生活质量改进:
- 内置
wee_alloc:一个 WebAssembly 分配器,代码体积(压缩前)仅 1kB。 - 内置 panic hook:允许你在浏览器中调试 Rust 的 panic 信息。
要初始化项目,可以运行:
wasm-pack new wasm-example
这将为你完成所有设置。从代码角度看,我们的主函数(以及 C/JS 绑定)将保持不变,因为 wasm-pack 主要提供的是工具链增强,而非库代码。
napi-rs
napi-rs 是一个用于使用 Rust 构建预编译 Node.js 插件的框架。如果你觉得 wasm-bindgen 太复杂,只想编写 Node.js 插件,这是一个很好的替代方案。使用它需要 Node v0.10.0 或更高版本。你可以通过以下命令安装(需要 npm 或其替代品):
npm install -g @napi-rs/cli
安装完成后,可以使用以下命令创建新的 NAPI 项目:
napi new wasm-example
napi-rs 会带来一些代码上的变化,如下所示:我们终于可以摆脱 extern "C" 块,转而使用 napi 的 bindgen_prelude 来引入所需内容。
use napi::bindgen_prelude::*;
use image::io::Reader;
use image::ImageFormat;
use image::ImageOutputFormat;
use std::io::Cursor;
#[macro_use]
extern crate napi_derive;
#[napi]
pub fn convert_image(buffer: Buffer) -> Result<Buffer> {
let bytes: Vec<u8> = buffer.into();
let img2 = Reader::new(Cursor::new(bytes)).with_guessed_format().unwrap().decode().unwrap();
let mut new_vec: Vec<u8> = Vec::new();
img2.write_to(&mut Cursor::new(&mut new_vec), ImageFormat::Jpeg).unwrap();
Ok(new_vec.into())
}
其优势显而易见:
- 无需手动通过
extern "C"导入任何内容 - 可轻松使用 Node.js 内部功能,毫无障碍
当然,尽管有诸多优点,napi-rs 仅兼容 Node.js。如果你想为浏览器编写 WASM 代码,则仍需使用 wasm-pack 或 wasm-bindgen。此外,你还必须依赖 Node 生态系统来保持 CLI 更新,从 Rust 优先的角度来看,这有点奇怪。但不可否认,napi-rs 是开始用 Rust 编写 Node.js 插件的一种非常简便的方式。
总结
感谢阅读!Rust 与 WebAssembly 具有出色的互操作性,我们完全有理由利用这一点来提升与其他语言协作时的开发效率和性能表现。