Open12

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

ktz_aliasktz_alias

ここでは、cargo-componentの力には頼らずにWASI Preview2wasmを作り上げていく手順を記述する。
実装言語は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のマクロの方が書きやすくてぐぬぬ
ktz_aliasktz_alias

プロジェクト作成

cargo new app
cargo new --lib crates/greeting

管理を楽にするため、ルートのCargo.tomlでワークスペース化をしておく

+ [workspace]
+ members = ["crates/greeting"]
ktz_aliasktz_alias

コンポーネント側(greeting)の実装

以下カレントディレクトリをcrates/greetingにいると想定する。

Cargo.tomlにlibセクションの追加

wasmコンポーネントとしてビルドするためにはこの指定が必須。

+ [lib]
+ crate-type = ["cdylib"]

依存の追加

cargo add wit-bindgen
cargo add wit-bindgen-rt --features bitflags

このチュートリアルではwit-bindgenなくてもビルド通ったけどついで

WIT (Wasm Interface Type)定義

  1. ファイルを用意する。
mkdir wit
touch wit/world.wit
  1. 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);
ktz_aliasktz_alias

アプリケーション側(app)を実装する

以下カレントディレクトリをappにいると想定する。

依存の追加

cargo add wit-bindgen
cargo add wit-bindgen-rt --features bitflags
  • コンポーネントとは異なりwit-bindgenは必須だった。

WIT (Wasm Interface Type)定義

  1. ファイルを用意する。
  • コンポーネントを依存に加えるため、deps.tomlも用意している
mkdir wit
touch wit/world.wit
touch wit/deps.toml
  1. 依存コンポーネントをwit/deps.tomlに書き下す
// 相対パスで記述する場合はwitフォルダが起点となることに注意
greeting = "../crates/greeting/wit"
  1. 依存を取り込む
wit-deps update

wit/depsフォルダの下に書き下した依存コンポーネントのwitが展開される。
wit.deps.lockも作成される。

  1. 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());
}
  • 単に呼び出してるだけ
ktz_aliasktz_alias

ビルド & リンク(合成)

各クレートをビルドしてwasmを作成し、それらを合成して一つのwasmにする。

ビルド

cargo build --package greeting --target wasm32-wasip2 --release 
cargo build --target wasm32-wasip2 --release
  • target/wasm32-wasip2/releaseフォルダ下にapp.wasmgreeting.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で出力先を指定
ktz_aliasktz_alias

実行

現状wasmtimeくらいしかサポートしてなさげ

$ wasmtime target/wasm32-wasip2/release/composed.wasm 
Hello World !!

いじょ!

ktz_aliasktz_alias

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 transpilewit定義を入力として渡し、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等のライタイムをいい感じにインポートしてくれる。

ktz_aliasktz_alias

コンポーネント間の連携

wit-deps等で、依存コンポーネントを追加した上で、wit定義でuseステートメントを使うことで、依存先の方を使用することができる

use username:dep-component/types@0.0.1.{foo, bar};

record Baz {
    foo: foo,
    bar: bar,
}

useを使用する際は、wit-bindgen-cliwit-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

importexportがコンフリクトしててリンクができない的に読み取れる。

ktz_aliasktz_alias

同じインターフェースのコンポーネントを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と分離できるようになる。

ktz_aliasktz_alias

WASM Component をexportするマクロを公開する際のTips

wit-bindgen-clirustのコードを生成する際、特に指定がなければ

#[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" \
    ...
ktz_aliasktz_alias

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)で黙らせる。