📕

Rust で WebAssembly から console.log する

2022/10/13に公開約7,000字

はじめに

趣味でしか使わないので覚えてもすぐ揮発してしまうのですが、やっぱり Rust の事はもうちょっと知っておきたいと思って、最近少し触っています。

入門に素晴らしいドキュメント[1] があるのですが、以前門を叩いた際は謎のモチベーションから英語版でトライして、結局最後まで読み切れなかったです(当時まだ日本語訳が無かったのかも・・・)。今回は、日本語版[2] で手を動かしながら最後まで辿り着きました。素晴らしいドキュメントと翻訳に感謝です。

https://doc.rust-jp.rs/book-ja/

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 を使う場合
#[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 というものがあります。

https://developer.mozilla.org/ja/docs/WebAssembly/JavaScript_interface/instantiate

ここに、WebAssembly 側から呼び出したい関数を含めることで、WebAssembly 側から呼ぶことが可能になります。env というオブジェクトで括っています(Rust 側のデフォルト)が、この階層は任意に設定可能です。

JavaScript 側の準備
const wasmbin; // .wasm バイナリの ArrayBuffer

const imports = {
    env: {
        console_log: () => {
            console.log("Hello WebAssembly!");
        },
    },
};

await WebAssembly.instantiate(wasmbin, imports);

Rust 側の準備

Rust 側では、extern ブロック内に同じプロトタイプの関数を宣言することで、インポートされた関数をリンクすることが出来る様になります。

Rust 側の準備
#[link(wasm_import_module = "env")]
extern "C" {
    fn console_log();
}

#[link(wasm_import_module)] でインポートするモジュールを指定していますが、Rust 側のデフォルトが env なので、このケースでは省略可能です。

https://rustwasm.github.io/docs/book/reference/js-ffi.html

Rust 側からの呼び出し

ここまで出来たら Rust 側から呼び出すだけなのですが、JavaScript 側からインポートした関数は unsafe になるため、こんな感じで unsafe ブロック内で呼び出します。

Rust 側からの呼び出し
unsafe {
    console_log();
}

実行するとこんな感じでコンソールにメッセージが出力されます。

screen0001

実際に動くものはこちらから。ボタンを押すとコンソール出力されます。

全体のソースコードは、↓ に置いています。フロントは SolidJS で作ってみました。

https://github.com/a24k/wasmple/tree/28c9eec21a17b3316874aa913e22c795ae1cd76e

関数に文字列を渡す

さて、これで Rust のコードから console に出力することは出来ました・・・ちがうね。もし許されるのであれば、Rust 側から何らか文字列を渡して、それを出力して欲しいところです。イメージはこんな感じ。

JavaScript 側のイメージ(動かない)
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(線形メモリ?)というものが用意されています。

https://rustwasm.github.io/docs/book/what-is-webassembly.html#linear-memory

その名の通り一直線に並んだシンプルなバイト列。ちょっと大きなメモリバッファという感じです。今いまの実装では WebAssembly の各インスタンスに 1 つずつ存在し、64 KiB 単位で拡張が可能な様になっています。拡張しか出来ないみたい。JavaScript 側で生成して前述の importObject に渡すことも可能ですが、今回は WebAssembly 側で(自動的に)用意されるものを利用します。

Rust 側でメモリを必要とする処理を行うと、この Linear Memory 上に領域が確保されます。何らかデータのポインタを取得すると、この Linear Memory 上のアドレスが返ります(なんか普通のこと言ってる)。シンプル。そして、JavaScript 側からは exports オブジェクト内にある memory.buffer にアクセスすることで、ArrayBuffer として Linear Memory を扱うことが可能になります。

JavaScript 側から Linear Memory にアクセス
this.wasm = (await WebAssembly.instantiate(wasmbin, imports)).instance.exports;
this.wasm.memory.buffer; // <- これが ArrayBuffer になっている

あとは、Rust 側から出力したい文字列の先頭アドレス ptr と長さ len を貰えれば、こんな感じでコンソールに出力が出来そうです。

JavaScript 側で文字列を受け取る
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 のコードユニットの列として解釈します。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/fromCharCode

一方、Rust 側の文字列エンコードは UTF-8 です。なるほど、時代の流れを感じます。

https://doc.rust-lang.org/std/string/struct.String.html

どちらで変換してもかまわないと思いますが、今回は Rust 側で変換しちゃいましょう。

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! の出力を渡したりすれば、良い感じに使えそうです。

screen0002

実際に動くものはこちらから。console.log 以外にも error とか warn とか対応しています。

screen0003

全体のソースコードは、↓ に置いています。

https://github.com/a24k/wasmple/tree/34a12cdc0e6378e06326294826ab03ce9db39c09

panic したら console.error したい

いい感じにコンソール出力が出来るようになりましたが、実はもうひとつ気になることがあります。panic です。試しに無理やり panic! したらどうなるか、見てみます。

パニックを起こす
panic!("OMG - あたふた・・・");

コンソール上にエラーは出て、Rust 側で panic したことは何となく分かるのですが、これだけではなかなか厳しいものがあります。

screen0004

幸いなことに、Rust には panic 時の処理にカスタムフックを仕掛ける機能があります。これを使って、panic 時に console.error 出力する様にしてみましょう。

https://doc.rust-lang.org/std/panic/fn.set_hook.html

カスタムフックを仕掛ける
use std::panic;

fn panic_hook(info: &panic::PanicInfo) {
    error(info.to_string());
}
panic::set_hook(Box::new(panic_hook));

こんな感じで、メッセージと発生箇所が出力されるようになりました。だいぶ良さそう。

screen0005

実際に動くものはこちらから。ソースコードは、↓ に置いています。

https://github.com/a24k/wasmple/tree/cc39624167b4f2f1b46804094de3f72f9bceebcd

おわりに

これで、Rust から JavaScript のコンソール出力が使える様になりました。JavaScript と WebAssembly とのやり取りについても、何となく掴めてきた気がします。

また今回は、GitHub Actions 上でビルドして Cloudflare Pages にデプロイするフローを組んでいます。これがなかなか便利で、途中のコミットもデプロイされた状態に出来るので、記事を書くのにも重宝しました。オススメ。

https://zenn.dev/a24k/articles/20221014-delivery-to-cloudflare

2022-11-15 追記)format! と組み合わせて使っていましたが、疲れて来たのでマクロを実装しました。こちらもご参考ください。

https://zenn.dev/link/articles/20221113-wasmple-define-macros

脚注
  1. The Rust Programming Language - by Steve Klabnik and Carol Nichols, with contributions from the Rust Community. ↩︎

  2. The Rust Programming Language 日本語版 ↩︎

  3. wasm-bindgen - Facilitating high-level interactions between Wasm modules and JavaScript. ↩︎

  4. いちおうサンプルがあるのですが、AudioWorkletGlobalScope への読み込みがなかなかトリッキーです。フロント側も Rust で描いていてお腹いっぱいな感じ。 ↩︎

Discussion

ログインするとコメントできます