cargo-componentを使わずwasip2なwasmを作るチュートリアル

ここでは、cargo-component
の力には頼らずにWASI Preview2
なwasm
を作り上げていく手順を記述する。
実装言語はRust
。
バージョンは、wasm32-wasip2
ターゲットをサポートするv1.82
以降。
あらかじめwasm32-wasip2
ターゲットをインストールしておく必要がある。
rustup target add wasm32-wasip2
まず最初に以下のツールをインストールしておく
- wit-bindgen-cli
- cargo install wit-bindgen-cli
- witの定義から
Rust
のバインディングのソースを自動生成するためのツール-
Rust
以外には、moonbit
,C言語
,C#
,TinyGo
,Java (TeeVMベース)
の自動生成をサポート
-
- wit-deps
- cargo install wit-deps
- witの依存を更新をサポートしてくれるツール
- wac
- cargo install wac
- 各
wasm
コンポーネントをニコイチしてくれるツール
- wasmtime
- インストールはお好きな方法で
このチュートリアルでは、wit-bindgen
のマクロを介したコード生成ではなく、wit-bindgen-cli
による明示的な生成で進める。
-
wit-bindgen
のマクロの場合、wit
のコンポーネント定義をRust-Analyzer
が追従しきれずにしばらくエラー扱いになり、精神衛生上良くない - ただし、将来くる
Async
対応はwit-bindgen
のマクロの方が書きやすくてぐぬぬ

プロジェクト作成
cargo new app
cargo new --lib crates/greeting
管理を楽にするため、ルートのCargo.toml
でワークスペース化をしておく
+ [workspace]
+ members = ["crates/greeting"]

コンポーネント側(greeting)の実装
以下カレントディレクトリをcrates/greeting
にいると想定する。
lib
セクションの追加
Cargo.tomlにwasm
コンポーネントとしてビルドするためにはこの指定が必須。
+ [lib]
+ crate-type = ["cdylib"]
依存の追加
cargo add wit-bindgen
cargo add wit-bindgen-rt --features bitflags
このチュートリアルではwit-bindgen
なくてもビルド通ったけどついで
WIT (Wasm Interface Type)定義
- ファイルを用意する。
mkdir wit
touch wit/world.wit
-
wit/world.wit
に以下の内容を記述する。
/// 先頭は必ずpackageセクション
/// <ベンダープレフィックス>:<パッケージ名>@<SemVer>で記述
package anonymous:greeting@0.0.1;
/// インターフェース定義
/// wit内に少なくとも一つは必要(だったはず)
interface say {
/// コンポーネントで定義する関数
/// <関数名>: func(<引数>, ...) -> <戻り値>
hello: func() -> string;
}
/// インターフェース定義の親玉
/// ここが全ての起点となる
world greeting {
/// sayインターフェースのexport宣言
/// 別のwasmから呼べるようになる
export say;
}
コード生成
wit-bindgen rust \
--async none \
--out-dir src \
--default-bindings-module "crate::greeting" \
--generate-all \
--world greeting \
./wit
--default-bindings-module
オプションは world
名と一致させておくこと。binding
定義が出力されるモジュールパスを完全名で記述する。
具体的には、--out-dir src/gindings
、--world greeting-world
となっている場合、
出力先のモジュールパスはcrate::bindings::greeting_world
となる。
ワールド名にハイフンを含む場合、アンダースコアに置換されることに注意が必要。
ここ間違うとビルドが通らなくなる。
-
src/greeting.rs
が生成される
コンポーネント本体の実装
src/lib.rs
に以下の記述を書く
mod greeting;
use greeting::exports::anonymous::greeting::say;
struct Component;
/// witの`interface say`で宣言した関数を実装する
/// 関数の列挙は`Rust-Analyzer`に任せるのが楽
impl say::Guest for Component {
// _rt::Stringで作成されるけど単なるstd::string::Stringのエイリアス
fn hello() -> String {
"Hello World !!".into()
}
}
// コンポーネントの場合必ず最後にexportマクロを呼ぶ
greeting::export!(Component with_types_in greeting);

アプリケーション側(app)を実装する
以下カレントディレクトリをappにいると想定する。
依存の追加
cargo add wit-bindgen
cargo add wit-bindgen-rt --features bitflags
- コンポーネントとは異なり
wit-bindgen
は必須だった。
WIT (Wasm Interface Type)定義
- ファイルを用意する。
- コンポーネントを依存に加えるため、
deps.toml
も用意している
mkdir wit
touch wit/world.wit
touch wit/deps.toml
- 依存コンポーネントを
wit/deps.toml
に書き下す
// 相対パスで記述する場合はwitフォルダが起点となることに注意
greeting = "../crates/greeting/wit"
- 依存を取り込む
wit-deps update
wit/deps
フォルダの下に書き下した依存コンポーネントのwit
が展開される。
wit.deps.lock
も作成される。
- appのworld定義を
wit/world.wit
に書く
/// ここはコンポーネントの時と同じ
package anonymous:app@0.0.1;
world app {
/// インポートするインターフェース
/// バイブパッケージの場合、<ベンダープレフィックス>:<パッケージ名>/<インターフェース名>@<SemVer>
import anonymous:greeting/say@0.0.1;
}
コード生成
wit-bindgen rust \
--async none \
--out-dir src \
--default-bindings-module "crate::app" \
--generate-all \
--world app \
./wit
- エクスポートしないから
--default-bindings-module
は不要かもしれないけどついで。
本体の実装
main.rs
に以下のコードを書く
mod app;
use app::anonymous::greeting::say;
fn main() {
println!("{}", say::hello());
}
- 単に呼び出してるだけ

ビルド & リンク(合成)
各クレートをビルドしてwasm
を作成し、それらを合成して一つのwasm
にする。
ビルド
cargo build --package greeting --target wasm32-wasip2 --release
cargo build --target wasm32-wasip2 --release
-
target/wasm32-wasip2/release
フォルダ下にapp.wasm
とgreeting.wasm
が存在している(はず)
リンク
wac plug target/wasm32-wasip2/release/app.wasm \
--plug target/wasm32-wasip2/release/greeting.wasm \
-o target/wasm32-wasip2/release/composed.wasm
- 最初の引数が依存元の
wasm
-
--plug
で渡しているのが依存先のwasm
-
-o
で出力先を指定

実行
現状wasmtime
くらいしかサポートしてなさげ
$ wasmtime target/wasm32-wasip2/release/composed.wasm
Hello World !!
いじょ!

出来上がりの品
「ビルド & リンク(合成)」の前までの状態。

jcoでesmなtypescriptの型定義を生成する
インストール
npm install -D @bytecodealliance/jco
wasmから生成
npx jco transpile <WASMのパス> -o <出力先のパス>
wasm
のバイナリから出力するため、コメントはつかないことに注意。
また、wasi:cli
関連の型定義も出力してくるのでウザい。
wit定義から出力する
npm jco types <WITのパス> -o <出力先のパス>
wit
ファイルで、インターフェースにコメントをつけている場合、その内容も反映してくれる。
その際、/* 〜 */
のようなブロックコメントではなく、// 〜
のような行コメントを使うこと。
これは、ブロックコメントの中にブロックコメントを出力され不恰好となるため。
また、出力される型定義もwit
に定義されたものになるのでシンプル。
しかしjco types
による出力は致命的な問題がある。
それはjs
ファイルを出力してくれないこと。
回避策はjco transpile
でwit
定義を入力として渡し、jsのバインディグも合わせて作成すればよい。
↑これではいけません!
wit
から作成するとrealloc
などがundefindで構築される。
明示的にlibcがらみの関数をinport
すればいけるかもだけど、そんなことするくらいならwasm
からtranspile
したほうが百億倍マシ。
(witから作成しようとした残骸)
npx jco transpile <WITのパス> --stub --name <バインディング名> -o <出力先のパス>
--name
を省略した場合はwit
ファイルの拡張子を抜いた名前が使用される。(world.wit
ならworld.js
が作成される)~~
ただし、この方法でバインディングを出力した場合、なーぜーかーwit
に記述したコメントを出力してくれない。
一応、jco transpile
の後にjco types
で型定義を上書きすることでコメント付きにすることはできなくはない。
wasm
からtranspile
する際の嫌なところは、wasi:cli
までtypescriptの型定義を作ってくるのでウザい。
回避策は
# typescripyの型定義なしでtranspile
npx jco transpile <WASMのパス> -o <出力先> --no-typescript
# typescriptの型定義だけ別途作成
npx jco types <WITのパス> -o <出力先>
これで、libc
がらみの関数は作りつつ、余計な型定義の出力は抑制できる。
ブラウザで実行する
@bytecodealliance/preview2-shim
をパッケージとして加えておく。
vite
での実行の際に、wasi/cli
等のライタイムをいい感じにインポートしてくれる。

コンポーネント間の連携
wit-deps
等で、依存コンポーネントを追加した上で、wit
定義でuse
ステートメントを使うことで、依存先の方を使用することができる
use username:dep-component/types@0.0.1.{foo, bar};
record Baz {
foo: foo,
bar: bar,
}
use
を使用する際は、wit-bindgen-cli
やwit-bindgen
クレートのgenerate
マクロで、ソースコードを自動生成する場合、with
オプションで、wit
のインターフェースとrust
のモジュールを関連づける必要がある。
以下は、依存先のコンポーネント(dep-component
クレート)において、crate::bindings
にソースコードを生成した場合の例。
wit-bindgen \
....
--with username:dep-component/type@0.0.1="dep_component::bindings::exports::username::dep_world::dep_component::types"
一応、以下のように定義を取り込むこともできるが、依存先のコンポーネントで作成したFrom
トレイトの実装も使えなくなってしまい悲しい目に遭う。
## 指定したインターフェースも自身のコンポーネントの一部として作成する
--with username:dep-component/type@0.0.1="generate"
## もしくは全部入れる
--generate-all
export
する際の注意事項
コンポーネントをuse
で別コンポーネントの型を取り込みexport
するということは、利用側からその取り込んだ型を参照できなければならないことを意味する。
引数で渡したり、戻り値で受け取ったりなど。
そのため、なんらかの作成したインターフェース等をexport
する場合は、use
した型も 明示的に export
する必要がある。
これは、WIT
仕様の以下の記述から読み取れる。
For exported interfaces, any transitively used interface is assumed to be an import unless it's explicitly listed as an export.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/WIT.md#wit-packages-and-use
use
したインターフェースをexport
しなくてもwit-bindgen
でエラーになることはないが、wasm32-wasip2
としてビルドした際に、リンクエラーとなる。
エラー内容は以下のような感じ。
Caused by:
0: updating metadata for section component-type:wit-bindgen:0.41.0:username:some-component@0.0.1:some-world:encoded world
1: failed to merge worlds from two documents
2: failed to add export `username:dep-component/types@0.0.1`
3: export `username:some-component/some-interface@0.0.1` depends on `username:dep-component/types@0.0.1` previously as an import which will ch
import
とexport
がコンフリクトしててリンクができない的に読み取れる。

同じインターフェースのコンポーネントをplugする
コンポーネント間にA <-- B
な依存関係があり、また[B1, B2, ...] ∈ B
なようにインターフェースBから種々のコンポーネントを作成するケース。
構成図は以下
実現方法
コンポーネントA
とコンポーネントB
は個別にエクスポートできなければならない。
そのため以下のように、一つのwit
ファイル内に複数のworld
を定義する。
world world-b {
export b;
}
world world-a {
export a;
// 注意:インターフェースA、その中のリソースで引数や戻り値として使用するのであれば
// インターフェースBのエクスポートは必須
// 指定がないと実際にwasm32-wasip2でビルドする際にビルドエラーとなる
export b;
}
こうすることで、ソケットとなるコンポーネントA
は基盤側でexport
、プラグとなるコンポーネントB
の各実装は利用側でexport
と分離できるようになる。

WASM Component をexportするマクロを公開する際のTips
wit-bindgen-cli
でrust
のコードを生成する際、特に指定がなければ
#[allow(unused_macros)]
#[doc(hidden)]
macro_rules! __export_xxxxxxx {
....
}
pub(crate) use __export_xxxxxxx as export;
なコードが生成される。
wit-bindgen-cli
のCLIオプションとして--pub-export-macro
が提供されており、これを指定すると
-
__export_xxxxxxx
マクロ宣言にmacro_export
属性が付与され外部に公開される。 -
pub use __export_xxxxxxx as export;
として生成される。
macro_export
が付与されることで、依存元から__export_xxxxxxx
でコンポーネントのエクスポートが可能になる。
後者については、依存元からは__export_xxxxxxx
しか見えないためあまり意味はない。
ただし、そのまま使わせるには名前がアレでアレなので、lib.rs
で別途再エクスポートしておくとQOLが上がると思われる。
// lib.rs
pub use __export_xxxxxxx as export;
加えて、もう一つ重要なことが、生成されるバインディングのモジュールの完全名。
バインディングを作成したクレートでコンポーネントをエクスポートする場合は、wit-bindgen-cli
のオプションで
wit-bindgen rust
--default-bindings-module "crate::bindings::some_world" \
...
としても影響はないが、外部公開する場合は依存元から見て完全名として解決できなければならない。
これはexportマクロの定義で、指定した値がそのまま出力されるから。
そのため以下のようにクレート名を明示的に指定する必要がある。
# クレート名がsome_crateの場合
wit-bindgen rust
--default-bindings-module "some_crate::bindings::some_world" \
...

recordやenum等の値型しかないコンポーネントを作成する際の注意事項
wit
定義で以下のような定義を行うことができる。
interface types {
record rec-a {
....
}
enum enum-x {
...
}
}
world types-world {
export types;
}
この状態でバインディングを生成するともれなくexport
マクロも付いてくる。
実装すべきトレイトが存在しないためexport
は不要なのだけれども、使わないとRust-Analyzer
が警告出してきて精神衛生上よろしくない。
かといって自動生成されたもののため、allow(unused)
属性を付与することもできない。
生成されるマクロの中身を見たら、巡り巡って何も出力しない結果となっていたので以下のようにすれば回避できる。
#[allow(unused)]
pub enum TypesComponent {}
types_world::export(TypesComponent);
Zero Sized Type
を用意してexport
マクロを実行。
その結果、今度はZST
な型が未使用だと言われるので、allow(unused)
で黙らせる。