Rust で WebAssembly から console.log する
はじめに
趣味でしか使わないので覚えてもすぐ揮発してしまうのですが、やっぱり Rust の事はもうちょっと知っておきたいと思って、最近少し触っています。
入門に素晴らしいドキュメント[1] があるのですが、以前門を叩いた際は謎のモチベーションから英語版でトライして、結局最後まで読み切れなかったです(当時まだ日本語訳が無かったのかも・・・)。今回は、日本語版[2] で手を動かしながら最後まで辿り着きました。素晴らしいドキュメントと翻訳に感謝です。
Rust + WebAssembly + AudioWorklet の夢
さて、このままではまた揮発してしまうので何か作ってみようと思い、WebAssembly に手を出しています。音の鳴るおもちゃ 🥁 好きなので AudioWorklet を使って遊ぼうと思うのですが、Rust と JavaScript を繋ぐ際のほぼデファクトであろう wasm-bindgen[3] と AudioWorklet を繋ぐのが思いのほか[4] 難しそうです。モダンフロントエンドと繋ぐには、もう少しシンプルな作りの方が良さそうに思い、wasm_bindgen を使わずに自力で繋ぐ方向でイジっています。この辺りは、別で記事を書こうと思います。
Rust から console.log したい
WebAssembly で動かす場合、そのままだと Rust 側の出力を表示する場所が無いみたいです。さすがに闇雲なので、Rust から console に出力できる様にしたいです。wasm_bindgen を使う場合は web_sys を使ってこんな感じで log メソッドを持って来られるのですが、今回は自力で繋いでいきたいと思います。
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
Rust から JavaScript の関数を呼ぶ
当然ですが console.log は JavaScript の API なので、Rust のコードから直接呼ぶことは出来ません。まずは、Rust から JavaScript の関数を呼ぶ方法について見ていきましょう。
JavaScript 側の準備
JavaScript 側で WebAssembly.instantiate()
する際の引数に importObject
というものがあります。
ここに、WebAssembly 側から呼び出したい関数を含めることで、WebAssembly 側から呼ぶことが可能になります。env
というオブジェクトで括っています(Rust 側のデフォルト)が、この階層は任意に設定可能です。
const wasmbin; // .wasm バイナリの ArrayBuffer
const imports = {
env: {
console_log: () => {
console.log("Hello WebAssembly!");
},
},
};
await WebAssembly.instantiate(wasmbin, imports);
Rust 側の準備
Rust 側では、extern
ブロック内に同じプロトタイプの関数を宣言することで、インポートされた関数をリンクすることが出来る様になります。
#[link(wasm_import_module = "env")]
extern "C" {
fn console_log();
}
#[link(wasm_import_module)]
でインポートするモジュールを指定していますが、Rust 側のデフォルトが env
なので、このケースでは省略可能です。
Rust 側からの呼び出し
ここまで出来たら Rust 側から呼び出すだけなのですが、JavaScript 側からインポートした関数は unsafe になるため、こんな感じで unsafe ブロック内で呼び出します。
unsafe {
console_log();
}
実行するとこんな感じでコンソールにメッセージが出力されます。
実際に動くものはこちらから。ボタンを押すとコンソール出力されます。
全体のソースコードは、↓ に置いています。フロントは SolidJS で作ってみました。
関数に文字列を渡す
さて、これで Rust のコードから console に出力することは出来ました・・・ちがうね。もし許されるのであれば、Rust 側から何らか文字列を渡して、それを出力して欲しいところです。イメージはこんな感じ。
const imports = {
env: {
console_log: (msg) => {
console.log(msg);
},
},
};
文字列くらいサラッと渡させてよと思うのですが、↑のページにはこう書いてあります。
Because of wasm's limited value types, these functions must operate only on primitive numeric types.
和訳)wasm の値型が限られているため、関数はプリミティブな数値型のみ利用可能です。
へぇ・・・。まぁ、言語によって文字列の内部エンコードもバラバラだし、関わりたくないのは分かります。
memory.buffer によるデータ共有
WebAssembly と JavaScript との間で、「プリミティブな数値型」以上のデータをやりとりするために、Linear Memory(線形メモリ?)というものが用意されています。
その名の通り一直線に並んだシンプルなバイト列。ちょっと大きなメモリバッファという感じです。今いまの実装では WebAssembly の各インスタンスに 1 つずつ存在し、64 KiB 単位で拡張が可能な様になっています。拡張しか出来ないみたい。JavaScript 側で生成して前述の importObject に渡すことも可能ですが、今回は WebAssembly 側で(自動的に)用意されるものを利用します。
Rust 側でメモリを必要とする処理を行うと、この Linear Memory 上に領域が確保されます。何らかデータのポインタを取得すると、この Linear Memory 上のアドレスが返ります(なんか普通のこと言ってる)。シンプル。そして、JavaScript 側からは exports
オブジェクト内にある memory.buffer
にアクセスすることで、ArrayBuffer として Linear Memory を扱うことが可能になります。
this.wasm = (await WebAssembly.instantiate(wasmbin, imports)).instance.exports;
this.wasm.memory.buffer; // <- これが ArrayBuffer になっている
あとは、Rust 側から出力したい文字列の先頭アドレス ptr
と長さ len
を貰えれば、こんな感じでコンソールに出力が出来そうです。
const imports = {
env: {
console_log: (ptr, len) => {
const chars = new Uint16Array(this.wasm.memory.buffer, ptr, len);
console.log(String.fromCharCode(...chars));
},
},
};
文字列エンコーディングの変換(UTF-8 → UTF-16)
前項のスニペットで、Uint16Array
として Linear Memory を参照したのは、JavaScript 側の文字列の内部エンコードが UTF-16 だからです。String.fromCharCode()
は、値の列を UTF-16 のコードユニットの列として解釈します。
一方、Rust 側の文字列エンコードは UTF-8 です。なるほど、時代の流れを感じます。
どちらで変換してもかまわないと思いますが、今回は Rust 側で変換しちゃいましょう。
extern "C" {
fn console_log(ptr: *const u16, len: usize);
}
let utf16: Vec<u16> = String::from("こんにちわすむ!").encode_utf16().collect();
unsafe {
console_log(utf16.as_ptr(), utf16.len());
}
こんな感じですね。ちゃんと日本語で文字列を表示することが出来ました。format!
の出力を渡したりすれば、良い感じに使えそうです。
実際に動くものはこちらから。console.log
以外にも error
とか warn
とか対応しています。
全体のソースコードは、↓ に置いています。
panic したら console.error したい
いい感じにコンソール出力が出来るようになりましたが、実はもうひとつ気になることがあります。panic です。試しに無理やり panic!
したらどうなるか、見てみます。
panic!("OMG - あたふた・・・");
コンソール上にエラーは出て、Rust 側で panic したことは何となく分かるのですが、これだけではなかなか厳しいものがあります。
幸いなことに、Rust には panic 時の処理にカスタムフックを仕掛ける機能があります。これを使って、panic 時に console.error
出力する様にしてみましょう。
use std::panic;
fn panic_hook(info: &panic::PanicInfo) {
error(info.to_string());
}
panic::set_hook(Box::new(panic_hook));
こんな感じで、メッセージと発生箇所が出力されるようになりました。だいぶ良さそう。
実際に動くものはこちらから。ソースコードは、↓ に置いています。
おわりに
これで、Rust から JavaScript のコンソール出力が使える様になりました。JavaScript と WebAssembly とのやり取りについても、何となく掴めてきた気がします。
また今回は、GitHub Actions 上でビルドして Cloudflare Pages にデプロイするフローを組んでいます。これがなかなか便利で、途中のコミットもデプロイされた状態に出来るので、記事を書くのにも重宝しました。オススメ。
2022-11-15 追記)format!
と組み合わせて使っていましたが、疲れて来たのでマクロを実装しました。こちらもご参考ください。
-
The Rust Programming Language - by Steve Klabnik and Carol Nichols, with contributions from the Rust Community. ↩︎
-
wasm-bindgen - Facilitating high-level interactions between Wasm modules and JavaScript. ↩︎
-
いちおうサンプルがあるのですが、AudioWorkletGlobalScope への読み込みがなかなかトリッキーです。フロント側も Rust で描いていてお腹いっぱいな感じ。 ↩︎
Discussion