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つのデータ型が含まれます:
Counter
GuestCounter
前者は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