🎛

Rustで学ぶWebAssembly Interface Type入門

2023/12/15に公開

TL;DR

  • Wasmコンポーネントのインターフェースを定義する言語です
  • パッケージという形で名前空間を提供します
  • インポートとエキスポートの定義のことをワールド(world)と呼びます

注意:仕様策定中の技術を扱っています。仕様作成の進行によっては、内容が正しくない場合があります。

背景

Wasmモジュールにはi32、i64, f32, f64の4種類のデータ型しか存在しません。また文字列やユーザー定義型のような構造を持つデーターの表現にも標準が存在せず、データをどのようにメモリ上に配置方法はプログラミング言語の処理系、またはプログラマーが決めるものとされていました。

例えば、次のようなデータ構造と、それに対する操作があったとします。

pub struct Point {
    x: i32,
    y: u8,
    z: u16,
}

#[no_mangle]
pub fn hash(point: &Point) -> u64 {
    (point.x as u64) << 32 | (point.y as u64) << 16 | point.z as u64
}

Wasmでは、hashの型は次のようになっています。i32の値を受け取ってi64の値を返します。これはPointのメモリー上への配置方法が変わっても、変化しません。Pointオブジェクトのメモリー配置は関数の中に隠蔽されるためです。

(type (;0;) (func (param i32) (result i64)))

一方で、Rustでは構造体ごとにメモリー上の配置方法を変更できます。次の2つの構造体は、メンバーの数や型は同じです。

pub struct Point {
    x: i32,
    y: u8,
    z: u16,
}

#[repr(C)]
pub struct Point {
    x: i32,
    y: u8,
    z: u16,
}

しかし、この2つのメモリー上での配置は異なります。

repr(C)をつけるとメンバーの宣言順に配置されているが、つけない場合はzの方がyよりも先に配置されている。

メモリー配置は、プログラミング言語やコンパイラー、コンパイル時の設定、ソースコード上のプラグマの有無などによって異なります。これはWasmを利用する側からすると、エキスポートされた関数やシンボルだけでなく、利用するWasmがデーター構造をどのようにメモリー上へ配置しているかについて知る必要があることを意味します。例えば次のようなオブジェクトを作成する関数がWasmを通じて提供されていた場合、作成されたオブジェクトのメモリー配置を知らなければ、直接メモリーにアクセスしてy属性の値を参照できないからです。

#[no_mangle]
pub fn new(x: i32, y: u8, z: u16) -> Point {
    Point { x, y, z }
}

さらに面倒なことに、メモリー上の配置方法を記述するためのセクションが、仕様上Wasmには存在しません。customセクションに書くなどしてヒントを残すこともできますが、現実にはドキュメントで規定されることがほとんどでした。そして、その規定を守ることは開発者の責務とされていました。この状況は次の2点でWasmにとって不利なものとなっていました。

  • 相互運用性
  • 開発体験

そこで相互運用性と開発体験を高めたものとしてWasmコンポーネントモデルが定義されました(2023年12月の時点では、仕様として固まりきってはいません)。コンポーネントモデルは、コンポーネントの構造に加えて、次の2つの仕様で構成されています。

CanonicalABIは、データ構造のリニアメモリー上の配置と、メモリーからの読み取り方法を規定しています。これによって上述した相互運用性の面での問題を克服しています。

これに対して開発体験を高めるための仕様がWebAssembly Interface Type(WIT)です。WITはWasmコンポーネント間のインターフェースを定義するための言語(インタ-フェース定義言語;IDL)を定めています。

開発者はIDLを利用して、Wasmコンポーネントのインターフェースを定義します。定義されたインターフェースは、開発ツールが処理をしてグルーコードを生成するために利用されます。これによってWasmを提供する側はメモリー配置が適切かどうかを気にすることなく開発を行えますし、利用する側もメモリー上の配置がどうなっているかを気にすることなくWasmが提供する機能を利用できます。

現在利用できるコードジェネレーター

現在利用できるコードジェネレーターは次の3つです。いずれもコンポーネントモデルの仕様策定を進めているBytecode Allianceによって管理されています。

コードジェネレーター 対応するプログラミング言語
wit-beindgen Rust, C, C++, C#, Java, Go
jco JavaScript
wasmtime.py Python

なお構文解析を行うためのクレ-ト(wit-parser)もあります。このクレートを利用して、wit-bindgenはコードの生成を行っています。

WIT入門

ここからはWitを概観していこうと思います。具体的には次のような関数をエキスポートするWasmコンポーネントを作成します。

pub fn format(message: String) -> Result<String, FormatError>

cargo-component

Wasmコンポーネントを1から作成するなら、cargo componentを利用すると良いでしょう。これはWasmコンポーネントの作成するために便利なCargoのサブコマンドを追加します。

インストールはcargoコマンドを利用して行います。インストールすると、comopnentというサブコマンドが利用できるようになります。

% cargo install cargo-component

cargo-componentには、いくつかのサブコマンドがあります。この記事では次の2つを利用します。

サブコマンド 説明 利用例
new Component開発用Rustパッケージの作成 cargo component new hello-world
build Wasmコンポーネントをビルド cargo component build -r

なお、buildには通常のビルドで利用できるオプションをそのまま利用できます。上記の例では、リリース向けビルドを行っています。

それでは、この記事の説明のために、hello-worldという名前のパッケージを作成します。reactorというオプションを指定すると、他のプログラムから利用されるWasmコンポーネント(ゲストコード)を作成するためのパッケージを作成します。このオプションをつけない場合は、CLI向けWasmコンポーネントを作成します。cargo new --libcargo newのような関係といえばわかりやすいかもしれません。

% cargo component new --reactor hello-world

作成されたパッケージは次のような構造をしています。インターフェースを定義するwitファイルも作成されています。また、wit-bindgenがdependenciesに追加されているなど、Cargo.tomlも適切に設定されています。

.
├── Cargo.toml
├── src
│  └── lib.rs
└── wit
   └── world.wit

作成したパッケージからWasmコンポーネントをビルドします。ビルドは次のようにcargo component buildコマンドで行います。デフォルトではwasm32-wasiをターゲットにビルドします。ビルドされたWasmファイルはtarget/wasm32-wasi/debugに出力されます。

% cd hello-world
% cargo component build
% ls target/wasm32-wasi/debug/hello_world.wasm
target/wasm32-wasi/debug/hello_world.wasm

witファイル

wit/world.witに作成されたhello_world.wasmのインターフェースが、次のように定義されています。

package component:hello-world;

world example {
    export hello-world: func() -> string;
}

このwitファイルには2つの要素があります。1つはパッケージの宣言、もう1つはexampleワールドの定義です。

要素 説明 witファイル中の記述
パッケージ宣言 名前空間の宣言 package component:hello-world
ワールド定義 コンポーネントのインターフェースの定義 パッケージ宣言以外の部分

exampleワールドには次のことが定義されています。

  • hello-worldというシンボルがエクスポートされること
  • hello-worldは引数がなく、文字列を返す関数であること

これを次のように変更します。変更点はエクスポートする関数の名前と、その引数です。

package component:hello-world;

world example {
    export id: func(value: string) -> string;
}

変更後cargo component buildコマンドを実行すると、次のようなビルドエラーが表示されます。これは次の2つが原因です。

  • コンポーネントの実装に、witファイルに定義されていない関数hello_worldが含まれていること
  • コンポーネントの実装に、witファイルに定義されてる関数idが含まれていないこと
error[E0407]: method `hello_world` is not a member of trait `Guest`
  --> src/lib.rs:9:5
   |
9  | /     fn hello_world() -> String {
chikoski@beta hello-world % !ca
cargo component build
   Compiling hello-world v0.1.0 (/some/where/hello-world)
error[E0407]: method `hello_world` is not a member of trait `Guest`
  --> src/lib.rs:9:5
   |
9  | /     fn hello_world() -> String {
10 | |         "Hello, World!".to_string()
11 | |     }
   | |_____^ not a member of trait `Guest`

error[E0046]: not all trait items implemented, missing: `id`
  --> src/lib.rs:7:1
   |
7  | impl Guest for Component {
   | ^^^^^^^^^^^^^^^^^^^^^^^^ missing `id` in implementation
   |
  ::: /some/where/hello-world/target/bindings/hello-world/bindings.rs:51:3
   |
51 |   fn id(value: ::cargo_component_bindings::rt::string::String,) -> ::cargo_component_bindings::rt::string::String;
   |   ---------------------------------------------------------------------------------------------------------------- `id` from trait

実装はsrc/lib.rsで行われています。次の節では、そのファイルを編集し、エラーを修正します。

インターフェースの実装

src/lib.rsは次のようになっています。cargo_component_bindings::generate!はwit-bindgenの提供するマクロで、witファイルからトレイトを定義します。hello-worldパッケージの場合は、bindings::Guestというトレイトが定義されます。これを実装した構造体を定義することで、wit/world.witの定義に従ったコンポーネントが実装されます。

cargo_component_bindings::generate!();

use bindings::Guest;

struct Component;

impl Guest for Component {
    /// Say hello!
    fn hello_world() -> String {
        "Hello, World!".to_string()
    }
}

上述のビルドエラーは次の2ステップで修正できます。

  1. Guestトレイトの実装からhello_world関数を削除すること
  2. id関数をGuestトレイトの実装に追加すること

次の例は、上記の2ステップでビルドエラーを修正したものとなります。

cargo_component_bindings::generate!();

use bindings::Guest;

struct Component;

impl Guest for Component {
    fn id(value: String) -> String {
        value
    }
}

なおwitファイルが定めるのは関数の型だけであって、振る舞いまでは規定しません。id関数がどのように実装されていても、引数のパターンと返り値の型が合っている限り問題にはなりません。id関数が次のように実装されていても、同じインターフェースを持つコンポーネントとして取り扱われます。

cargo_component_bindings::generate!();

use bindings::Guest;

struct Component;

impl Guest for Component {
    fn id(value: String) -> String {
        value.clone() + &value
    }
}

result型とenum型

この節ではRustにあるReusltenumを扱う関数の定義方法について述べます。

まずsrc/world.witを編集し、次のように変更します。新たにWITインターフェースを定義し、そのインターフェースをエクスポートするようにexampleワールドが変更されています。

package component:hello-world;

interface formatter{
    enum format-error {
        unknown,
        utf8,
    }
    format: func(value: string) -> result<string, format-error>;
    id: func(value: string) -> string;
}

world example {
    export formatter;
}

インターフェース要素を追加した理由は、ワールド要素ではデータ型を定義できないためです。上記の例では、エラーの理由を表すenumを追加しています。

WITには成功・失敗を表すresultが組み込みの型として用意されています。Rustと似たgenericsのような記法で、成功時に保持されるデータの型と失敗時に保持されるデータの方を記述できます。上記の例では、format関数は次のようなresult型の値を返す関数として定義されています:

  • 成功時にはstring型のデータを保持する
  • 失敗時にはformat-error型の値を保持する

なお成功時の値がない(Rustでいうところの())の場合は、result<_, format-error>のように記述できます

上記の変更をwit/world.witに加え、ビルドすると次のようなエラーが出力されます。これはformetterインターフェースをエキスポートするようにwitファイルを変更したために、Guestトレイトのパスが変わったことに起因します。

error[E0432]: unresolved import `bindings::Guest`
 --> src/lib.rs:3:5
  |
3 | use bindings::Guest;
  |     ^^^^^^^^^^^^^^^ no `Guest` in `bindings`
  |
help: consider importing this trait instead
  |
3 | use crate::bindings::exports::component::hello_world::formatter::Guest;
  |     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

このエラーを修正した例は次のようになります。use宣言で参照するパスが変更されている点と、format関数が実装されている点が変更点となります。またFormatErrorがコード中で実装されていないにも関わらず、利用できている点も注意してください。これはwit-bindgenが自動生成しているためです。

cargo_component_bindings::generate!();

use bindings::exports::component::hello_world::formatter::{FormatError, Guest};

struct Component;

impl Guest for Component {
    fn id(value: String) -> String {
        value
    }

    fn format(value: String) -> Result<String, FormatError> {
        Ok(value)
    }
}

Rustのエラーと、WITで定義されたエラーとの関連付け

前節の実装ではformat関数をどのように呼んでもエラーが起きませんでした。これを変更してエラーが起きうる処理を行い、WITの定義と整合させるというのが、この節のテーマです。

まずエラーが起きうる処理ですが、ferris-saysというクレートを利用します。これはFerrisにメッセージを話させる関数sayを持っています。この関数はResult型を返します。

 __________________________
< Hello fellow Rustaceans! >
 --------------------------
        \
         \
            _~^~^~_
        \) /  o o  \ (/
          '_   -   _'
          / '-----' \

まずferris-saysを依存するクレートのリストに追加します。追加にはcargo addコマンドを利用します:

% cargo add ferris-says

次にformat関数の実装を変更します。エラーが起きる処理は次の2箇所です。

  • say関数を呼び出す時
  • sayが書き込んだバッファーから文字列を作成する時

say関数はWriterに文字列を書き込みます。書き込み時にエラーが発生した場合は、I/O Errorを返します。

今回は文字列を返すので、BufWriterに文字列を書き込みます。書き込まれたバッファーから、String::from_utf8を経由して、文字列オブジェクトを作成します。バッファーにUTF-8として解釈できないバイトがあった時は、FromUTF8Errorが返されます。

この2つのエラーをうまくハンドリングして、FormatErrorを返すようにformat関数の実装を変更します。次のスニペットは変更したコードの例です:

cargo_component_bindings::generate!();

use std::io::BufWriter;

use bindings::exports::component::hello_world::formatter::{FormatError, Guest};
use ferris_says::say;

struct Component;

impl Guest for Component {
    fn id(value: String) -> String {
        value
    }

    fn format(value: String) -> Result<String, FormatError> {
        let mut writer = BufWriter::new(Vec::new());
        if let Err(_) = say(&value, 70, &mut writer) {
            Err(FormatError::Unknown)
        } else {
            String::from_utf8(writer.buffer().to_vec()).map_err(|_| FormatError::Utf8)
        }
    }
}

複数のインターフェースをエクスポートした場合

ワールド要素には複数の関数やインターフェースをエクスポートできます。次の例では、formatterインターフェースに加えて、dimensionインターフェースもエキスポートしています。

package component:hello-world;

interface formatter{
    enum format-error {
        unknown,
        utf8,
    }
    format: func(value: string) -> result<string, format-error>;
    id: func(value: string) -> string;
}

interface dimension {
    record point {
        x: s32,
        y: s32,
    }
    new-point: func(x: s32, y: s32) -> point;
}

world example {
    export formatter;
    export dimension;
}

recordは構造体のような名前付きのパラメーターのリストを定義できます。上記の例では符号付き32ビットの整数2つからなるpointを定義しています。

これを実装した例が次のスニペットです。インターフェースごとに生成されるGuestトレイトを1つの構造体に実装します。それぞれのGuestクレートに別名をつけると、コードが読みやすくなるかもしれません。

cargo_component_bindings::generate!();

use std::io::BufWriter;

use bindings::exports::component::hello_world::formatter::{FormatError, Guest as Formatter};
use bindings::exports::component::hello_world::dimension::{Point, Guest as Dimension}
use ferris_says::say;

struct Component;

impl Formatter for Component {
    fn id(value: String) -> String {
        value
    }

    fn format(value: String) -> Result<String, FormatError> {
        let mut writer = BufWriter::new(Vec::new());
        if let Err(_) = say(&value, 70, &mut writer) {
            Err(FormatError::Unknown)
        } else {
            String::from_utf8(writer.buffer().to_vec()).map_err(|_| FormatError::Utf8)
        }
    }
}

impl Dimension for Component{
    fn new_point(x: i32, y: i32) -> Point {
        Point{x, y}
    }
}

なお、WITのs32は、Rustのi32に対応します。

他のインターフェースで定義されたデータ型を利用するuse

他のインターフェースで定義されたデータ型を利用することができます。use文を使うと、データ型の定義を他のインターフェースからインポートできます。

次の例では、Dimensionインターフェースで定義されているpointレコードを使ってFormatterインターフェースの関数format_pointを定義しています。

package component:hello-world;

interface formatter {
    use dimension.{point};

    enum format-error {
        unknown,
        utf8,
    }
    format: func(value: string) -> result<string, format-error>;
    format-point: func(p: point) -> result<string, format-error>;
    id: func(value: string) -> string;
}

interface dimension {
    record point {
        x: s32,
        y: s32,
    }
    new-point: func(x: s32, y: s32) -> point;
}

world example {
    export formatter;
    export dimension;
}

なお、関数のオーバーロードはできません。オーバーロードすると、Rustのコンパイル時に次のようなエラーが出力されます。

error: failed to create a target world for package `hello-world` (/some/where/hello-world/Cargo.toml)

Caused by:
    0: failed to parse local target from directory `/some/where/hello-world/wit`
    1: name `format` is defined more than once
            --> /some/where/hello-world/wit/world.wit:11:5
             |
          11 |     format: func(p: point) -> result<string, format-error>;
             |     ^-----

まとめ

この記事では、Rustで実装することでWITに入門しました。WITに関しては次の点を概観しました:

  • WITファイルの3要素:WITワールド、WITインターフェース、パッケージ宣言
    • WITワールドではインポートとエキスポートのリストを定義します
    • WITインターフェースは、データ構造と関数のシグネチャの定義を行います
    • パッケージ宣言では、インターフェースやワールドの所属する名前空間を定義します
  • 数値だけでなく、文字列やresult、enumのような型も組み込みで用意されています
  • 他のインターフェースで定義されたデータ型を利用して、インターフェースを定義できます

また、Rustでの開発に関しては2つのツールについて触れました:

  • wit-bindgenを利用すると、witファイルに書かれた定義に従ってトレートやデータ構造が自動生成されます。生成されたトレートを実装することで、Wasmコンポーネントを実装できます。
  • cargo-componentをインストールすると、cargoコマンドを使ってWasmコンポーネント作成用のパッケージ作成や、Wasmコンポーネントのビルドが簡単にできます。

作成されたコンポーネントの利用に関しては、bokuwebさんの書かれたwit-bindgenとjcoでWebAssembly Component Modelに入門するwasi-sdk,wit-bindgenとjcoでWebPに対応した画像diff wasm componentを作成するが参考になるかと思います。またWasmコンポーネントそのものについては乳牛さんのWasmコンポーネントモデルをまとめてみるが良いオーバービューを与えてくれるように感じます。また英語ではありますが、Bytecode Allianceの作成した公式のドキュメントもあります。

Discussion