Rustで学ぶWebAssembly Interface Type入門
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つのメモリー上での配置は異なります。
メモリー配置は、プログラミング言語やコンパイラー、コンパイル時の設定、ソースコード上のプラグマの有無などによって異なります。これは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 --lib
とcargo 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ステップで修正できます。
-
Guest
トレイトの実装からhello_world
関数を削除すること -
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にあるReuslt
型とenum
を扱う関数の定義方法について述べます。
まず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