コンテンツにスキップ

RustとWebAssembly

WebAssembly(よくWasmと略されます)を使うと、Rust などの言語で書いたコードを、Web ブラウザの中で JavaScript と並べて動かせます。ブラウザに積まれた2つ目のエンジンだと考えてください。ページを動かすのは今までどおり JavaScript ですが、画像フィルター、物理演算、大きなファイルの解析といった重い処理は、コンパイル済みの速い Rust のコードに任せられます。

このページでは、小さな Rust のライブラリを書いて Wasm にコンパイルし、Web ページから呼び出します。ここにあるものは Rust Playground では動きません(ブラウザと追加のツールが必要です)。なので、ほとんどの例は自分のマシンで試すためのふつうのコードブロックです。ただし、純粋な Rust の部分はすぐに試せます。

WebAssembly とは何か、なぜ Rust と相性がいいのか

Section titled “WebAssembly とは何か、なぜ Rust と相性がいいのか”

WebAssembly は、プログラムのためのコンパクトなバイナリ形式です。主要なブラウザはどれもこれを実行でき、JavaScript と同じセキュリティのサンドボックスの中で、ネイティブに近い速さで動きます。ブラウザ専用というわけではなく、サーバーやエッジのプラットフォームでも Wasm が動いています。

Rust は Wasm でとても人気のある言語の1つです。理由はいくつかあります。

  • ガベージコレクタや大きなランタイムがない。 Rust はコードと一緒に余計なしくみを配布する必要がないので、.wasm ファイルを小さくできます。
  • ツールがすばらしい。 wasm-bindgen が Rust と JavaScript をつなぐ「のり」のコードを書いてくれて、wasm-pack が1つのコマンドですべてをビルドしてくれます。
  • 安全で速い。 デスクトップで得られるのと同じメモリ安全性とパフォーマンスが手に入ります。

まず、Rust に Wasm のターゲット向けにコンパイルできるよう伝えます。ターゲットとは「コードが動くマシンの種類」のことです。wasm32-unknown-unknown は「32ビットの Wasm で、特定の OS はなし」という意味です。

Terminal window
rustup target add wasm32-unknown-unknown

次に wasm-pack をインストールします。wasm-pack はクレートをコンパイルし、wasm-bindgen を実行して、必要なものをすべて1つのフォルダにまとめてくれます。公式のインストーラーを使うか、Cargo でビルドしてください。

Terminal window
# wasm-pack のサイトにある Linux / macOS 向けインストーラー
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# または、Rust が入っていればどのシステムでも
cargo install wasm-pack --locked

Wasm のモジュールは、main を持つプログラムではなくライブラリです。ライブラリを作りましょう。

Terminal window
cargo new --lib hello-wasm
cd hello-wasm

次に Cargo.toml を編集します。crate-type の行は、.wasm に向いたライブラリ(cdylib)を作るよう Cargo に伝えます。wasm-bindgen は Rust と JavaScript をつなぐクレートです。

[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"

それでは、src/lib.rs に関数を2つ書きます。#[wasm_bindgen] 属性は、それぞれの関数に「JavaScript から呼び出せる」という印を付けます。

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("こんにちは、{name}さん!この文字列はRustで作られました。")
}

必要な Rust のコードはこれだけです。どちらもふつうの関数だという点に注目してください。JavaScript の文字列を &str に変換したり、返した String を JavaScript の文字列に戻したりするのは、wasm-bindgen がやってくれます。

まずはネイティブでロジックをテストする

Section titled “まずはネイティブでロジックをテストする”

ここで、時間を大きく節約できる習慣を紹介します。実際の処理はふつうの Rust の関数に書いておき、いつものコンピュータで cargo test やかんたんな main を使ってテストするのです。ブラウザでのデバッグは、ターミナルでのデバッグより時間がかかります。属性を外した同じロジックは、どこでも動きます。

fn add(a: i32, b: i32) -> i32 {
a + b
}
fn greet(name: &str) -> String {
format!("こんにちは、{name}さん!この文字列はRustで作られました。")
}
fn main() {
println!("{}", greet("Ferris"));
println!("2 + 3 = {}", add(2, 3));
}

実行結果:

こんにちは、Ferrisさん!この文字列はRustで作られました。
2 + 3 = 5
▶ 実際に試してみよう

ロジックが正しく動けば、#[wasm_bindgen] のラッパーは、ブラウザへの薄い扉にすぎません。(#[test] を付けたテストも、cargo test でネイティブに実行できます。テストを見てください。)

クレートのフォルダで、次を実行します。

Terminal window
wasm-pack build --target web

--target web は「webpack のようなバンドラーなしで、ブラウザが <script type="module"> タグで直接読み込めるファイルを作る」という意味です。私たちが実行したときは、新しくできた pkg/ フォルダに次のファイルが入っていました。

pkg/
├── hello_wasm_bg.wasm コンパイルされた Rust のコード
├── hello_wasm.js それを読み込み、各関数を包む JavaScript の「のり」
├── hello_wasm.d.ts 関数の TypeScript の型
├── hello_wasm_bg.wasm.d.ts
└── package.json

hello-wasm のダッシュは、ファイル名ではアンダースコアになっています。wasm-pack build はデフォルトでリリース(最適化された)ビルドを作ります。試している間は、--dev を付けると、最適化なしでより速くビルドできます。

Cargo.toml と同じ場所に index.html を作ります(pkg/ がその隣に来るようにします)。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Rust からこんにちは</title>
</head>
<body>
<p id="message">読み込み中…</p>
<script type="module">
import init, { add, greet } from "./pkg/hello_wasm.js";
await init(); // .wasm ファイルをダウンロードして起動する
const text = greet("Ferris");
const sum = add(2, 3);
document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`;
</script>
</body>
</html>

デフォルトエクスポートの init() が、.wasm ファイルをダウンロードして起動します。その後は、add も greet もふつうの JavaScript の関数として使えます。

ブラウザは file:// のアドレスからはモジュールを読み込まないので、小さなローカル Web サーバーが必要です。Python が入っているなら、これがいちばん手軽です。

Terminal window
python3 -m http.server 8080

ブラウザで http://localhost:8080 を開きます。私たちが試したときは、ページにこう表示されました。

こんにちは、Ferrisさん!この文字列はRustで作られました。 2 + 3 = 5

この文は、ブラウザの中で動く Rust が作ったものです。🦀

境界を安く越えられるもの、そうでないもの

Section titled “境界を安く越えられるもの、そうでないもの”

Rust と JavaScript は、同じやり方でメモリを共有しているわけではありません。Wasm のモジュールは自分専用のメモリのかたまりを持っていて、値は2つの側の間を行き来する必要があります。タダで済む行き来もあれば、コピーのコストがかかるものもあります。

Rust での型 JavaScript での型 コスト
i32、u32、f64、bool number、boolean 安い:そのまま渡される
&str、String string コピーに加えて、UTF-16 から UTF-8 への変換(またはその逆)
&[u8]、Vec<u8> Uint8Array Wasm のメモリへの(またはメモリからの)コピー
&mut [u8] Uint8Array コピーして渡し、呼び出しの後でコピーして戻す

なので、鉄則は呼び出しは少なく、1回でたくさんの仕事をさせることです。画像全体を処理するために Rust を1回呼ぶのは速いです。ピクセルごとに1回ずつ、100万回 Rust を呼ぶと、時間のほとんどが境界を越えることに使われてしまいます。

実際の例を見てみましょう。次の関数は、ピクセルの値のバッファ全体を、1回の呼び出しで明るくします。

use wasm_bindgen::prelude::*;
/// すべてのピクセルを明るくする。JS からは Uint8Array が渡される
#[wasm_bindgen]
pub fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
*p = p.saturating_add(amount);
}
}

JavaScript からは、await init() の後で Uint8Array を渡して brighten(pixels, 20) と呼ぶと、配列がその場で更新されます。ループ自体は純粋な Rust なので、ネイティブでテストできます。

fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
// saturating_add はオーバーフローせず、255 で止まる
*p = p.saturating_add(amount);
}
}
fn main() {
let mut pixels = [10, 100, 250];
brighten(&mut pixels, 20);
println!("{pixels:?}");
}

実行結果:

[30, 120, 255]
▶ 実際に試してみよう

250 + 20 がクラッシュも折り返しもせず、255 になっている点に注目してください。saturating_add は最大値で止まります。色を扱うときには、まさにこれが欲しい動きです。

ページが .wasm ファイルを使えるようになるには、その全バイトをダウンロードしなければなりません。いくつかコツを紹介します。

  • 配布するのは必ずリリースビルドにする。 私たちの小さな例では、wasm-pack build --dev で作った .wasm ファイルは 58,591 バイト、デフォルトのリリースビルドは 21,530 バイトでした。(wasm-opt をオフにした私たちのマシンでの数字です。あなたの環境では変わります。)
  • wasm-pack に wasm-opt を実行させる。 リリースモードでは、wasm-pack がデフォルトで Binaryen の最適化ツール wasm-opt を出力にかけてくれます。たいていは、さらに小さくなります。
  • サーバーで圧縮を有効にする。 21,530 バイトのファイルは、gzip をかけると 8,464 バイトになりました。
  • サイズを優先する設定を試して、計測しましょう。
[profile.release]
opt-level = "z" # サイズを優先して最適化する("s" はもう少し控えめ)
lto = true
codegen-units = 1

自分をごまかさずに、ちゃんと計測しましょう。関数2つだけの私たちの例では、この設定は効果がありませんでした。ファイルは 22,817 バイトになり、少し大きくなったのです。こうした設定は、もっと大きなクレートで効いてくることが多いです。また、format! やパニックのメッセージもコードを引き込むので、それらを避けたクレートはずっと小さくなることがあります。

いくつかの例を、それぞれの会社自身が書いた記事と一緒に紹介します(記事はどれも英語です)。

crate-type = ["cdylib"] を忘れる。 これがないと、wasm-pack はすぐに止まってしまいます。うれしいことに、何を追加すればいいかをエラーがそのまま教えてくれます。

Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]

「WebAssembly にコンパイルするには crate-type が cdylib でなければならない。Cargo.toml に次を追加してください」という意味です。

index.html をダブルクリックして開く。 ページは「読み込み中…」のまま変わらず、ブラウザのコンソール(Chrome の場合)にはこう表示されます。

Access to script at 'file:///…/pkg/hello_wasm.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-extension, chrome-untrusted, data, http, https, isolated-app.

これは、file:// のスクリプトの読み込みが CORS ポリシーによってブロックされた、という意味です。直し方は、python3 -m http.server のようなローカル Web サーバーでフォルダを配信し、代わりに http://localhost のアドレスを開くことです。

await init() の前に関数を呼ぶ。 Wasm のモジュールがまだ読み込まれていないので、「のり」のコードには呼び出す相手がありません。私たちが init() の行を消してみたところ、Chrome には Cannot read properties of undefined (reading '__wbindgen_free') と表示されました。必ず最初に await init() しましょう。

次のステップ:Rust で Web アプリ全体を作る

Section titled “次のステップ:Rust で Web アプリ全体を作る”

JavaScript から Rust の関数をいくつか呼ぶのは、すばらしい第一歩です。Web アプリ全体を Rust で作りたいなら、次のフレームワークを見てみてください。どちらも Wasm にコンパイルされ、React のような感覚で、コンポーネントを Rust で書けます。

  • Leptos(英語):サーバーでページをレンダリングすることもできる、速くてモダンなフレームワークです。
  • Yew(英語):コンポーネントベースの、成熟したフレームワークです。
  • WebAssembly は、ブラウザ(やサーバー)が実行できる、速くて安全なバイナリ形式です。Rust はガベージコレクタがないので、Wasm にうまくコンパイルできます。
  • rustup target add wasm32-unknown-unknown でターゲットを追加し、wasm-pack をインストールします。
  • 関数に #[wasm_bindgen] を付け、crate-type = ["cdylib", "rlib"] を設定して、wasm-pack build --target web でビルドします。
  • JavaScript では、まず await init() してから関数を呼びます。ページは file:// ではなく http:// で配信しましょう。
  • 数値は境界を安く越えられますが、文字列やスライスはコピーされます。だから、呼び出しは少なく、1回で大きな仕事をさせましょう。
  • 配布するのは圧縮したリリースビルドにして、サイズの調整は計測してから信じましょう。

理解度チェック

1. ブラウザ向け WebAssembly の Rust ターゲットを追加するコマンドはどれですか?

2. #[wasm_bindgen] 属性は何をしますか?

3. ページが「読み込み中…」のまま変わらず、コンソールに file:/// のアドレスについての CORS エラーが出ています。どう直しますか?

4. JavaScript から Rust に渡すとき、いちばん安いのはどれですか?

スコア: 0 / 4