TypeScriptからRustのコードを呼び出す
以前の記事でTypeScriptからC++のコードを呼び出す方法を紹介しました.しばらくしてRustで書いたコードをViteで管理しているWebアプリから呼び出す機会があったため,今回はTypeScriptからRustのコードを呼び出す方法をまとめました.
サンプル
今回の記事で実装したコードは以下のリポジトリにまとめています.
呼び出すコード
今回は以下のクレートをライブラリーrust_wasmとして準備しました.
pub struct Supporter {
pub name: String,
pub supporting_club: String,
pub year: u32,
}
impl Supporter {
pub fn say(&self) -> String {
format!(
"{} has been a supporter of {} since {}!",
self.name, self.supporting_club, self.year
)
}
}
pub fn become_supporter(name: &str, year: u32) -> Supporter {
Supporter {
name: String::from(name),
supporting_club: String::from("Vissel Kobe"),
year,
}
}
Rustのコードではこのライブラリーを以下のように呼び出せます.
use rust_wasm::become_supporter;
fn main() {
let supporter1 = become_supporter("John", 2018);
// John has been a supporter of Vissel Kobe since 2018!
println!("{}", supporter1.say());
let supporter2 = become_supporter("Jane", 1998);
// Jane has been a supporter of Vissel Kobe since 1998!
println!("{}", supporter2.say());
}
このrust_wasmにあるSupporter
とbecome_supporter
をTypeScriptのコードで呼び出せるようにします.
公式ドキュメント
以前TypeScriptでC++のコードを呼び出したときと同様,今回もRustのコードをWASMにコンパイルすることでTypeScriptのコードからでも呼び出せるようになります.
Rustの公式サイトやMDNにはRustのコードをWASMにコンパイルする方法やWASMをWebアプリで読みこむ方法が説明されています.今回はおおむねこれに従って作業していきます.
Rust側の準備
wasm-bindgenの使用
まずはじめに,RustとTypeScriptの間でデータのやり取りを簡単に行うためにクレートwasm-bindgenを使用します.Cargoを使ってこのクレートをインストールします.
cargo add wasm-bindgen
またlib.rcではこのクレートのプレリュードをインポートしておきます.
use wasm_bindgen::prelude::*;
構造体や関数をWASMにコンパイルする対象とするには,当該コードにwasm_bindgen
アトリビュートを付加します.
+#[wasm_bindgen]
impl Supporter {
pub fn say(&self) -> String {
format!(
"{} has been a supporter of {} since {}!",
self.name, self.supporting_club, self.year
)
}
}
+#[wasm_bindgen]
pub fn become_supporter(name: &str, year: u32) -> Supporter {
Supporter {
name: String::from(name),
supporting_club: String::from("Vissel Kobe"),
year,
}
}
しかし今回の例では,struct Supporter
に対して同様にアトリビュートを付与すると,"the trait bound `String: std::marker::Copy` is not satisfied" というエラーが発生します.これは構造体のなかにあるname
とsupporting_club
の2つのフィールドが原因です.
wasm_bindgen
アトリビュートを付与した構造体が持つフィールドのうち,pub
によって外部スコープに公開しているものは,WASMへのコンパイル時に自動でgetter/setterメソッドが実装されます.このうちgetterメソッドではフィールドの値のコピー(シャローコピー)を戻り値とします.そのためフィールドの型はCopy
トレイトを実装している必要があります.今回の場合,name
とfavorite_club
はともにString
型です.そしてString
型はCopy
トレイトを実装していないため,先述のエラーが発生します.
Copy
トレイトを実装していない型のフィールドの値をgetterで返すには,2つの手段が考えられます.1つ目はフィールドの型にCopy
トレイトを実装すること,2つ目はClone
トレイトをgetterの戻り値の型に設定することです.今回の場合,String
型はClone
トレイトを実装しているため,2つ目の方法を採ることにします.
getterメソッドの戻り値の型をClone
トレイトにするには,wasm_bindgen
アトリビュートの引数にclone_with_getter
を与えます.
+#[wasm_bindgen(getter_with_clone)]
pub struct Supporter {
pub name: String,
pub supporting_club: String,
pub year: u32,
}
これでコードの修正は完了です.
あとはWASMにコンパイルするために,Cargo.tomlでライブラリーのビルドターゲットの種類をcdylib
に設定しておきます.ただし今回の例ではmain.rsでもライブラリーを使用しているため,rlib
も設定しておきます.
[package]
name = "rust-wasm"
version = "0.1.0"
edition = "2021"
+[lib]
+# "rlib" is not necessary if this package does not have main.rs.
+crate-type = ["cdylib", "rlib"]
+
[dependencies]
wasm-bindgen = "0.2.100"
crate-typeについて
crate-type
ではRustのコンパイルによる生成物のリンケージの種類を指定します.ライブラリーのコンパイル時には,デフォルトでlib
(他のRustのコードで利用できるライブラリー)が設定されています.
これをcdylib
とすると,Rustのコードは別のプログラミング言語で利用できる動的システムライブラリーとしてコンパイルされます.WASM向けのコンパイルの実行時,この動的システムライブラリーはWASMのことになります.
wasm-packによるWASM生成
今回はRustのコードからWASMを作成するコンパイラーとしてwasm-packを使います.Cargoを使ってwasm-packをインストールします.
cargo install wasm-pack
そしてwasm-pack build
コマンドで,カレントディレクトリのCargo.tomlの設定をもとにRustのコードをWASMにコンパイルします.このコマンドを実行すると,デフォルトではコンパイルされたWASMやそれを呼び出すJavaScriptコード,package.jsonが入ったpkgディレクトリーが生成されます.
wasm-pack build
コマンドの--target
オプションでは,WASMが利用されるケースを引数に指定することで,WASMの利用用途に適した設定でファイルを生成します.設定できる値は以下のwasm-packの公式ドキュメントに記載されています.
また,--output-dir
オプションを使うと,コンパイルしたファイルの出力先パスをpkgから変更することができます.今回は4つのターゲットに分けて出力するため,以下のようにコマンドを実行しました.
# WASMをTypeScript (Node.js) 向けにpkg-nodeディレクトリーに出力
wasm-pack build --target nodejs --output-dir pkg-node
# WASMをTypeScript (Deno) 向けにpkg-denoディレクトリーに出力
wasm-pack build --target deno --output-dir pkg-deno
# WASMをWebブラウザー向けにpkg-webディレクトリーに出力
wasm-pack build --target web --output-dir pkg-web
# WASMをバンドラー向けにpkg-bundlerディレクトリーに出力
wasm-pack build --target bundler --output-dir pkg-bundler
TypeScript側での呼び出しかた
Node.jsの場合
rust_wasmと同じディレクトリー階層にNode.jsでTypeScriptを実行するts-nodeプロジェクトを用意しました.このプロジェクトの中にあるmain.tsで,先ほどwasm-packで生成したNode.js向けWASMライブラリーを読み込むには,単純にライブラリーのパッケージをインポートすればよいです.
import { become_supporter } from "../rust_wasm/pkg-node";
const supporter = become_supporter("Todd", 2002);
// Todd has been a supporter of Vissel Kobe since 2002!
console.log(supporter.say());
Denoの場合
rust_wasmと同じディレクトリー階層にDenoでTypeScriptを実行するts-denoプロジェクトを用意しました.このプロジェクトの中にあるmain.tsで,先ほどwasm-packで生成したDeno向けWASMを読み込むには,ライブラリーのJavaScriptファイルをインポートします.このJavaScriptファイルはES Modulesとなっており,WASMで公開している構造体や関数がエクスポートされています.
import { become_supporter } from "../rust_wasm/pkg-deno/rust_wasm.js";
if (import.meta.main) {
const supporter = become_supporter("Alan", 2015);
// Alan has been a supporter of Vissel Kobe since 2015!
console.log(supporter.say());
}
このときインポートするJavaScriptファイル (pkg-deno/rust_wasm.js) の内部では,WASMの読み込み処理にDeno.readFile()
を利用しています.deno run
によるスクリプトの実行時には--allow-read
オプションでファイル読み込みのパーミッションを与える必要があります.
Webブラウザーの場合
rust_wasmと同じディレクトリー階層にViteで作成したTypeScript製Webアプリであるvite-webプロジェクトを用意しました.このプロジェクトの中にあるsrc/main.tsで,先ほどwasm-packで生成したWebブラウザー向けWASMを読み込むには,ライブラリーのJavaScriptファイルをインポートします.
読み込むJavaScriptファイルはES Modulesとなっており,WASMで公開している構造体や関数がエクスポートされています.さらにモジュールはWASMの初期化関数がデフォルトエクスポートとして取得できます.初期化関数はPromise
型を返すようになっており,WASMの読み込みが完了した後の処理を非同期処理として実行します.
今回はPromise.then()
メソッドを使って,WASMの初期化が完了したあとにWASMで公開している関数を使用しました.
import initWasm, { become_supporter } from "../../rust_wasm/pkg-web/rust_wasm";
initWasm().then(() => {
const supporter = become_supporter("Amy", 2006);
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<p>${supporter.say()}</p>
`;
});
バンドラーの場合
rust_wasmと同じディレクトリー階層にvite-webと同じくViteで作成したWebアプリプロジェクトとしてvite-bundlerを用意しました.こちらではWASMを扱うモジュールを直接インポートするのではなく,WASMを扱うライブラリーのパッケージをバンドラーで管理して扱います.
なおこのディレクトリー構成はモノレポですので,今回はYarn Workspacesを使ってプロジェクト間のパッケージ依存を管理します.
Yarn Workspacesによるモノレポ管理
Yarn Workspacesではモノレポ内の各プロジェクトを「ワークスペース」として管理します.ワークスペースとして登録されたプロジェクト間では,共通のパッケージを一元管理したり,依存する別のプロジェクトを簡単に紐づけることができます.
ワークスペースはルートディレクトリにpackage.jsonを作成し,パッケージの依存関係のあるプロジェクトをworkspaces
に設定することで登録されます.今回はvite-bundlerがrust_wasm/pkg-bundlerのパッケージ(パッケージ名は"rust-wasm")を利用するので,この2つをワークスペースとして登録します.
{
"private": true,
"workspaces": [
"rust_wasm/pkg-bundler",
"vite-bundler"
]
}
そのあとルートディレクトリーでyarn install
を実行すると,それぞれのワークスペースのpackage.jsonで記載されている依存パッケージの情報を収集し,それらをまとめてルート直下にパッケージをインストールします.
まずはワークスペースvite-bundlerでwasm-packによって生成されたパッケージ"rust-wasm"を依存パッケージに追加します.rust-wasmはワークスペースrust_wasm/pkg-bundlerとしてに管理しているため,Yarnは自動的にパッケージをワークスペースから検索し,シンボリックリンクで参照できるようにします.
yarn workspace vite-bundler add rust-wasm
wasm-packで作成したWASMを読み込むには,さらに以下2つのViteプラグインをインストールする必要があります.
- vite-plugin-wasm: wasm-packで生成されるパッケージはwebpack向けのコードになっています.これをViteで扱えるようにします.
-
vite-plugin-top-level-await: vite-plugin-wasmで変換されたコードはtop level await(
async
関数の外でawait
)を使います.古いブラウザーではこれに対応していないため,このプラグインでコードを修正します.
yarn workspace vite-bundler add -D vite-plugin-wasm vite-plugin-top-level-await
そしてインストールしたプラグインをViteが使用するようにvite.config.jsを修正します.
import { defineConfig } from "vite";
+import wasm from "vite-plugin-wasm";
+import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({
base: "./",
+ plugins: [
+ wasm(),
+ topLevelAwait()
+ ],
});
このプロジェクトの中にあるsrc/main.tsでWASMのパッケージであるrust-wasmを読み込むには,単純にパッケージをインポートすればよいです.先ほどのWebブラウザー向けのWASMとは違い,WASMの初期化はバンドラー側でよしなにやってくれるので,TypeScriptのコードではWASMで公開している構造体や関数を直接使用できます.
import { become_supporter } from "rust-wasm";
const supporter = become_supporter("Ken", 2022);
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<p>${supporter.say()}</p>
`;
まとめ
今回はwasm-bindgenとwasm-packを用いてRustで実装した構造体や関数をTypeScriptで実行してみました.同様のことをEmscriptenでC++のコードに対して行ったときに比べて,より少ない作業数で実現できました.
WASM経由でTypeScriptから処理を呼び出す記事がシリーズ化しそうな気がしてきたので,次はGoあたりを使って試してみようかなと思います.他にも言語の壁を越えたデータのやり取りといえば,gRPCとProtocol Buffersを使ったサービス間通信もあるので,こちらも調べてみようと思います.
Discussion