用 Rust 玩一玩 WASM

更新于 2024-03-01。主要是 wasm-pack-cli 被弃用。

WASM 是一种虚拟机上的二进制格式,不限语言,跨平台,能够运行在 Web 页面上。相对于 js 而言,WASM 的效率更高,更适合处理高计算量任务。至于为什么会有在浏览器做高计算量的工作…你总会找到场景的,压缩、渲染、游戏计算……

Rust 是对 WASM 支持良好的语言之一,并且从 Rust 本身继承了不少的好处:

  • Predictable performance
  • Small code size
  • Modern amenities

具体可参见 https://www.rust-lang.org/what/wasm

在本文中我们将简单入门和使用 Rust 编写一个娱乐性的项目,并编译为 WASM 部署到网页上。前置知识将尽量限制于 Rust,不会过多涉及 Node.js。

配置环境

建议直接使用 rustup 完成这一步:

1
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

基本是自动的,不多做介绍。详细步骤和特殊需求可以参考官方文档。

而后我们需要安装 wasm 的 target 和 wasm-pack。

1
2
3
$ rustup target add wasm32-unknown-unknown
$ cargo install wasm-pack
$ cargo install cargo-generate

Rust 的环境配置到此就结束了。一般情况而言,你可能还想继续配置 Node.js 一侧的环境,但这已经超出了本文内容。下文中所有的操作都不需要 node 环境。

创建项目

我们准备写一个简单的娱乐项目,仅作为学习使用。这个小程序能够把输入的文本使用语气词编码和译码。大概效果就像:

1
2
3
4
5
# 输入
已经没有什么可怕的了。

# 编码结果
呼!啊啊~呜..啊啊!呼~呜呜..呼..呜呜!呜呜!呜呜!咿!唔..咿啊..啊..咿~啊嗯!唔..呜啊..嗯啊!咿呜..呼啊..咿呜~呼~咿..啊嗯~嗯啊!咿呜..咿呜..呜嗯..唔!咿!啊啊..啊嗯!咿呜..啊..啊~啊嗯!哈啊..呼啊..啊嗯~嗯!呼!咿啊~啊~哈啊!啊啊!呼~咿啊..呜..呜嗯!啊嗯~啊啊!啊啊~呜..嗯!呼啊!啊啊..咿呜!呜呜!呜!呼~呜啊..唔!呜嗯!呜~呜啊..

我们创建一个 lib 项目,并删除默认的 Cargo.toml 内容,写入给 core lib 和 wasm binding 预留的 workspace。

1
$ cargo generate --git https://github.com/rustwasm/wasm-pack-template
1
2
3
4
5
[workspace]
members=[
"modal_particles",
"modal_particles_wasm",
]
1
2
$ cargo new modal_particles --lib
$ cargo new modal_particles_wasm --lib

我们将在 modal particles 里编写主要逻辑,然后在 modal particles wasm 里编写预留给 WASM 的 API。之后如果有兴趣,可以方便地在这基础上继续增加 CLI 等。

modal_particles_wasmCargo.toml 配置如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[package]
name = "modal_particles_wasm"
version = "0.1.0"
edition = "2018"

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

[features]
default = ["console_error_panic_hook"]

[dependencies]
wasm-bindgen = "0.2.84"

# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1.7", optional = true }

modal_particles = { path = "../modal_particles"}

[dev-dependencies]
wasm-bindgen-test = "0.3.34"

[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"

在正式开始之前,切换到 modal_particles_wasm 目录,编译测试一下环境是否配置正确:

1
2
# 如果你准备将编译生成的 WASM 使用在 NPM 项目中,那就不要加 `--target web`
$ wasm-pack build --target web

理想情况下将在同目录中生成 pkg 文件夹。

基本思路

我们的想法非常简单:把输入使用 UTF-8 编码,将 byte 按照一定规则对应到多个语气词上。译码时反向映射即可。

在思来想去之后我找到了 16 个还算合适的语气词,能够编码一个 4 位 bit。

  • 在把输入编码为 UTF-8 后,我们把一个 byte b 拆分成 b&0xF b>>4&0xF 2 部分,分别对应到 16 个语气词上。因为选词时并没有考虑前缀重复的问题,所以继续在每个映射结果间插入一些间隔符号,以方便在译码时作为划分依据。
  • 译码时先对输入划分,然后查询相邻两个语气词对应的编号 a b,并按照 a|b<<4 拼起来就得到了原始的 UTF-8 编码。

编码的主要部分如下,没什么可说的,就是简单的查表输出……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 编码
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut idx=0usize;
for byte in buf.iter() {
let (mut ai,mut bi) = (byte & 0xF, (byte >> 4) & 0xF);

let (a, b) = (
ID2WORD.get(&(ai as usize)).unwrap(),
ID2WORD.get(&(bi as usize)).unwrap(),
);
self.writer.write_all(a.as_bytes())?;
self.writer
.write_all(WORD_DICT2.get(idx).unwrap().as_bytes())?;
idx = (idx + ai as usize) % WORD_DICT2.len();
self.writer.write_all(b.as_bytes())?;
self.writer
.write_all(WORD_DICT2.get(idx).unwrap().as_bytes())?;
idx = (idx + bi as usize) % WORD_DICT2.len();
}
Ok(buf.len())
}

同样流式地实现译码过程,节省内存。因为有着 2 对 1 的关系,需要暂存没能解码完全的内容,所以解码器需要一个 buffer。其他也没什么可说的……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 解码
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// 因为输入的 buf 照样有可能不是完整的 UTF-8,必要时应截断
let (s, len) = match std::str::from_utf8(buf) {
Ok(s) => (s, buf.len()),
Err(err) => unsafe {
let valid_len = err.valid_up_to();
(std::str::from_utf8_unchecked(buf), valid_len)
},
};
// 差不多就行了,别在意
let mut s = s.to_string();
for d in WORD_DICT2 {
s = s.replace(d, "/");
}
let u8_list: Vec<_> = s
.split("/")
.filter(|x| !x.is_empty())
.map(|x| WORD2ID.get(x).unwrap())
.map(|x| *x as u8)
.collect();
self.buffer.write_all(&u8_list)?;

while self.buffer.len() >= 2 {
let mut t: [u8; 2] = [0, 0];
self.buffer.read(&mut t)?;
let t = t[0] | (t[1] << 4);
self.writer.write(&[t])?;
}
Ok(len)
}

除此之外其他部分就都是比较普通的代码了。完成 modal_particles 的编写后,在 wasm 里封装为编码和译码两个 API encodedecode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::io::Write;

use wasm_bindgen::prelude::*;
use modal_particles::{Encoder, Decoder};

#[wasm_bindgen]
pub fn encode(str: &str) -> String {
let mut encoder = Encoder::new(Vec::new());
encoder.write_all(str.as_bytes()).unwrap();

String::from_utf8(encoder.get_writer()).unwrap()
}

#[wasm_bindgen]
pub fn decode(str: &str) -> String {
let mut decoder = Decoder::new(Vec::new());
decoder.write_all(str.as_bytes()).unwrap();

String::from_utf8(decoder.get_writer()).unwrap()
}

编译 & 使用

运行

1
$ wasm-pack build --target web

目录下将生成 pkg 文件夹,内部有如下文件:

1
2
3
4
5
modal_particles_wasm.js
modal_particles_wasm.d.ts
modal_particles_wasm_bg.wasm
modal_particles_wasm_bg.wasm.d.ts
package.json

关于这几个文件其实有一些可说道的。

  • package.json 是给 NPM 包使用的,因为我们指定编译为网页可使用的代码,所以不必管这个东西。
  • 在理想情况下,应该可以直接 import * from <a wasm file>
  • 可惜事实并没有这么美好,所以 wasm-pack 准备了简单的 js 封装以暴露我们在 Rust 中事先定义的接口。

在 HTML 里用以下代码就能加载这个 WASM 并使用了。

1
2
3
4
5
6
7
8
9
<script type="module">
import init,{encode,decode} from "modal_particles_wasm.js";
async function run(){
// Init first before using other methods.
await init();
// Use the exported API...
}
run();
</script>

试一试:语"气"


用 Rust 玩一玩 WASM
https://blog.chenc.me/2023/06/24/hello-world-to-rust-wasm/
作者
CC
发布于
2023年6月24日
许可协议