☎️

TypeScriptからRustのコードを呼び出す

に公開

以前の記事でTypeScriptからC++のコードを呼び出す方法を紹介しました.しばらくしてRustで書いたコードをViteで管理しているWebアプリから呼び出す機会があったため,今回はTypeScriptからRustのコードを呼び出す方法をまとめました.

サンプル

今回の記事で実装したコードは以下のリポジトリにまとめています.

https://github.com/rerrahkr/rust-wasm-example

呼び出すコード

今回は以下のクレートをライブラリーrust_wasmとして準備しました.

rust_wasm/src/lib.rc
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のコードではこのライブラリーを以下のように呼び出せます.

rust_wasm/src/main.rc
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にあるSupporterbecome_supporterをTypeScriptのコードで呼び出せるようにします.

公式ドキュメント

以前TypeScriptでC++のコードを呼び出したときと同様,今回もRustのコードをWASMにコンパイルすることでTypeScriptのコードからでも呼び出せるようになります.

Rustの公式サイトやMDNにはRustのコードをWASMにコンパイルする方法やWASMをWebアプリで読みこむ方法が説明されています.今回はおおむねこれに従って作業していきます.

https://rustwasm.github.io/docs/book/introduction.html

https://developer.mozilla.org/ja/docs/WebAssembly/Guides/Rust_to_Wasm

Rust側の準備

wasm-bindgenの使用

まずはじめに,RustとTypeScriptの間でデータのやり取りを簡単に行うためにクレートwasm-bindgenを使用します.Cargoを使ってこのクレートをインストールします.

cargo add wasm-bindgen

またlib.rcではこのクレートのプレリュードをインポートしておきます.

rust_wasm/src/lib.rc
use wasm_bindgen::prelude::*;

構造体や関数をWASMにコンパイルする対象とするには,当該コードにwasm_bindgenアトリビュートを付加します.

rust_wasm/src/lib.rc
+#[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" というエラーが発生します.これは構造体のなかにあるnamesupporting_clubの2つのフィールドが原因です.

wasm_bindgenアトリビュートを付与した構造体が持つフィールドのうち,pubによって外部スコープに公開しているものは,WASMへのコンパイル時に自動でgetter/setterメソッドが実装されます.このうちgetterメソッドではフィールドの値のコピー(シャローコピー)を戻り値とします.そのためフィールドの型はCopyトレイトを実装している必要があります.今回の場合,namefavorite_clubはともにString型です.そしてString型はCopyトレイトを実装していないため,先述のエラーが発生します.

Copyトレイトを実装していない型のフィールドの値をgetterで返すには,2つの手段が考えられます.1つ目はフィールドの型にCopyトレイトを実装すること,2つ目はCloneトレイトをgetterの戻り値の型に設定することです.今回の場合,String型はCloneトレイトを実装しているため,2つ目の方法を採ることにします.

getterメソッドの戻り値の型をCloneトレイトにするには,wasm_bindgenアトリビュートの引数にclone_with_getterを与えます.

rust_wasm/src/lib.rc
+#[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も設定しておきます.

rust_wasm/Cargo.toml
[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のことになります.

https://doc.rust-lang.org/reference/linkage.html

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の公式ドキュメントに記載されています.

https://rustwasm.github.io/docs/wasm-pack/commands/build.html#target

また,--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ライブラリーを読み込むには,単純にライブラリーのパッケージをインポートすればよいです.

ts-node/main.ts
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で公開している構造体や関数がエクスポートされています.

ts-deno/main.ts
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で公開している関数を使用しました.

vite-web/src/main.ts
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つをワークスペースとして登録します.

package.json
{
  "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を修正します.

vite-bundler/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で公開している構造体や関数を直接使用できます.

vite-bundler/src/main.ts
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あたりを使って試してみようかなと思います.他にも言語の壁を越えたデータのやり取りといえば,gRPCProtocol Buffersを使ったサービス間通信もあるので,こちらも調べてみようと思います.

Discussion