WITとRust:リソース編
WebAssembly Interface Type(WIT)と呼ばれるインターフェース定義言語には「リソース」と呼ばれる要素があります。これを利用することで、状態を持つものを定義できます。この記事では、数を数えるもの(カウンター)を例に次の3つの事を解説します:
- WITでのリソースの定義方法
- Rustでのリソースの実装方法
- 作成されたリソースのTypeScriptからの利用方法
状態を持つものの例:カウンター
状態を持つものを、Rustでは自身の値を変更するメソッドを持つ構造体として表現します。例えば次のHostCounter構造体はup()やdown()メソッドを呼ぶことで、value属性の値が変わります。
リソースとは
Wasmコンポーネント(もしくはそれが動作するシステム)で管理されているもののことを「リソース」と呼ぶ、というのが私の理解です。なお、仕様には次のように定義されています:
A resource statement defines a new abstract type for a resource, which is an entity with a lifetime that can only be passed around indirectly via handle values. Resource types are used in interfaces to describe things that can't or shouldn't be copied by value.
上記をまとめると、リソースとは次のような特徴を持つとされています:
- ライフタイムを持っている
- ハンドルによって間接的に参照される
- インターフェースによって表現され、値のコピーによってそのものを自身をコピーすることはできない
リソースの例:ファイル
WITの仕様にはリソースの例としファイルが挙げられています。ファイルは前述したリソースの特徴を全て満たしています:
- ライフタイム:作成から削除までの間
- ハンドル:ファイルディスクリプター
- 値のコピーによる複製:不可能
ファイルはファイルシステムによって管理されているため、アクセスするためにはファイルディスクリプター、もしくはファイルハンドルと呼ばれるキーを利用します。
実際、Rustでファイルを表現するデーター構造であるFile型は、内部にファイルディスクリプターを保持しています。ファイルを操作するread_lines()やwrite()のようなメソッドの内部では、ファイルハンドル使ってデータを読み書きするファイルを指定しています。
このようにファイルへのアクセスはファイルディスクリプターという「ハンドル」を利用して行われます。ファイルディスクリプターはファイルのオープンによって作成され、クローズによって無効になります。このようにファイルディスクリプターには有効期間、つまりライフタイムが存在しています。
またファイルの中身のバイト列や、ファイルのメタデータをコピーしたところで、ファイルシステム上にファイルを複製することはできません。複製するためにはcopy()のような関数を通じて、ファイルシステムにファイルの複製を依頼する必要があります。
リソースの定義方法
WITではリソースをresource文で定義します。resourceというキーワードに続いて、リソースの名前を指定します。リソースに対して行える操作は、メソッドを列挙することで定義します:
resource resource-a {
action-a: func();
action-b: func();
action-c: func() -> list<u8>;
}
上記の例では、resource-aという名前のリソースをaction-a、action-b、action-cの3つの操作が行えるものとして定義しています。
メソッドの定義にはfuncキーワードを利用します。上記の例ではaction-a、action-bは引数を取らず、値も返さない関数として定義されています。一方、action-cはバイト列を返す関数として定義されています。
静的関数
リソースの定義には、funcキーワードで定義されるメソッド以外にstatic funcキーワードで定義される静的関数を利用できます。静的関数は次のように定義されています:
static functions, which do not have an implicit self parameter but are meant to be lexically nested in the scope of the resource type.
funcで定義されるメソッドはそのリソースを表すselfが暗黙的に与えられるのに対し、
static funcで表される静的関数にはselfが与えられません。ちょうどRustにおけるメソッド関連関数のような関係にあるとも言えます:
| キーワード | 定義するもの | Rustの用語 |
|---|---|---|
func |
メソッド | メソッド |
static func |
静的関数 | 関連関数 |
使い分けも、Rustにおけるメソッドと関連関数のそれと同様に行えるように思います。つまり、状態に依存して振る舞いが変わる関数はfuncで、リソースの取得や作成のように状態に依存しないが該当のリソースに関連する操作はstatic funcで表すと良さそうです。
カウンターをWITで定義すると
上記のHostCounterには3つのメソッドと、1つの関連関数が定義されていました:
- メソッド:
up()、down()、valueOf() - 関連関数:
new()
これをWITでは、次のように表現できるように思います:
-
up()、down()、valueOf():funcキーワードを使って、メソッドとして表現 -
new():static funcキーワードを使って、静的関数として表現
以上を踏まえて、カウンターをWITで定義すると次のようになります:
なおこの定義を含むWITパッケージをchikoski:wit-exampleとして公開しています。今後はこちらを使って、Rustでリソースを実装してゆきます。
Rustでのリソースの実装
cargo-caomponentを使って、Wasmコンポーネント作成のためのプロジェクトを作成します:
% cargo component new --lib --target chikoski:wit-example/counter counter
作成したプロジェクトは次のような構造をしています:
counter
├── Cargo.lock
├── Cargo.toml
└── src
└── lib.rs
コード生成
実装を始める前に、WITの定義からスケルトンコードを生成します。次のようにプロジェクトをビルドすることで、コードを生成できます:
% cargo component build -r
生成されたコードはsrc/bindings.rsに保存されます。こちらに生成されたクレートを実装することで、リソースとリソースをエキスポートするワールドをRustで実装します。
リソースの実装
生成さえれたコードには、次の2つのデータ型が含まれます:
CounterGuestCounter
前者はWasmを利用する側(ゲストコード)に露出されるデータ型です。ゲストコードはこの型に定義されるメソッドや、関連関数を呼びます。なおCounterはGuestCounterのラッパーとして定義されており、各メソッドの実態はGuestCounterによって提供されます。
一方、GuestCounterはトレイトとして定義されています。つまりリソースを実装するには、生成されたGuestCounterトレイトを実装すれば良いことになります。
私は次のように実装しました:
実装上のポイントは、次の2点です。
-
HostCounterのラッパーとして定義する -
HostCounterオブジェクトをRefCelでラップして保存する
どちらもGuestCounterクレートが次のように定義されていることに起因します。
pub trait GuestCounter: 'static {
// 実装が必要な関数のみを抽出しています
fn new() -> Counter;
fn up(&self);
fn donw(&self);
fn valueOf(&self) -> u32;
}
up()やdown()は状態の変化を引き起こす関数ですが、&selfが変更できない形で与えられています。内部可変性パターンを利用して状態変化を実現するため、RefCelで実際に状態を保持しているオブジェクトをラップしています。
またGuestCounter::newはCounter型の値を返すことを期待されています。前述したようにCounter型の値はGuestCounterのラッパーです。以下のようにGuestCounterオブジェクトを引数にnew()関数を呼ぶことで、オブジェクトを作成できます:
コンポーネントの定義
実装したリソースをエキスポートするように、Wasmコンポーネントを実装します。コンポーネントはGuestトレイトを実装する形で実装します。その中で、タイプエイリアスを利用して実装する型を指定することで、リソースの実装をエキスポートします:
TypeScriptでの利用方法
次の手順で作成したリソースをTypeScriptから利用し、Denoで実行します:
-
wasm32-unknown-unknownをターゲットにビルドします - jcoを使って、Wasmコンポーネントをトランスパイルします
- トランスパイルされた結果を利用するTypeScriptを作成します
- 作成したTypeScriptをDenoで実行します
作成したカウンターは標準出力の利用するといった、wasi:cli/commandに依存する処理を行っていません。それでもwasm32-wasip1をターゲットにビルドすると、コンポーネントのインスタンス化にはwasi:cli/commandの実装を与える必要があります。この手間を省くために、wasm32-unknown-unknownをターゲットにビルドしています。
下記のコマンドでWasmコンポーネントをビルドし、それをトランスパイルして、TypeScriptから利用する準備を行ないます:
# 準備: jcoをインストールします。インストールにはnpmが必要です。
% npm i -g @bytecodealliance/jco
# ステップ1
% cargo component build -r --target wasm32-unknown-unknown
# ステップ2
# Denoはfectch関数でファイルをロードすることができるので、NodeJSのサポートを切っています
% jco transpile -o ./ts --no-nodejs-compat target/wasm32-unknown-unknown/release/counter.wasm
トランスパイルした結果は、./ts/counter.jsとして保存されています。JSファイルか参照されているWasmファイルや、型情報を収めたcounter.d.tsも./tsフォルダーに保存されています。
次のようにcounter.jsをインポートします。counterbleというのはカウンターを定義しているインターフェースの名前です。
import { countable } from "./ts/counter.js";
function main() {
const counter = countable.Counter.new();
console.log(counter.valueOf());
counter.up();
counter.up();
counter.up();
console.log(counter.valueOf());
counter.down();
console.log(counter.valueOf());
}
main();
上記のプログラムをDenoで実行すると、次のような結果が得られます:
# 上記のTypeScriptをrun-counter.tsとして保存しています
# ./ts/counter.jsは、fetch関数を利用してWasmファイルをロードしています
# -A オプションをつけているのは、Wasmファイルへのアクセスを許可するためです
% ./deno run -A ./run-counter.ts
0 # カウンターの初期値
3 # upメソッドを3回呼んだ後の状態
2 # downメソッドを呼んだ後の状態
まとめ
この記事ではWITでリソース定義し、Rustで実装しました。
Wasmコンポーネントが管理している資源をリソースと呼び、それをWITで表現するにはresource文を利用します。resource文には属性を直接記述することはできません。その代わり、そのリソースに対する操作を列挙することでリソースを表現します。
cargo-componentはWITで記述されたリソースの定義から、2つのデータ型を生成します。1つはWasmを利用するコード向けのデータ型、もう1つはリソース定義に列挙された操作を実装するためのトレイトです。後者を実装することでリソースを実装します。
実際にはすでに用意されている資源と、WITに定義された操作とをマッピングするブリッジコードを書くことになるように思います。内部可変性パターンを利用することも多いのではないでしょうか。
実装したリソースをもつWasmファイルはjcoコマンドを利用することで、WebブラウザーやNode、Denoから利用しやすい形に変換できます。変換されたJavaScriptをインポートすると、リソースはJavaScirptのオブジェクトとして透過的に利用できます。
resource文を利用することで、資源に関する操作を1箇所にまとめることができます。適切に利用することで、より使いやすいインターフェースを定義できるかと思います。
Discussion