👩‍👧

Rust の中で TypeScript を書くには

2024/07/22に公開

はじめに

先日ユニークビジョン株式会社の UV Study というイベントで Rust に関する LT 登壇を行いました。
https://uniquevision.connpass.com/event/323686/

この記事はそれを zenn 用にまとめ直したものです。

当日の発表は 10 分と短かったため、当日の発表で話せなかったところも補足しています。

作ったもの

https://github.com/hotwatermorning/poc-rust-ts-block

FFI は面倒

あるプログラミング言語で書かれたプログラムの中から、別のプログラミング言語で書かれた処理を呼び出したいことがあります。

それぞれプログラミング言語は文法やライブラリだけでなく、内部でどのようにリソースを管理しているかの仕組みも異なるため、そのままでは相互に関数を呼び出せません。(例えば呼び出し先のプログラムでなにかデータを生成してそれを呼び出し元に返そうとしても、その生成されたデータは誰がどうやって面倒を見るべきかという問題が生じます。)

このようなことを可能にするための仕組みを FFI (Foreign Function Interface) と呼びます。

FFI 実現する方法はいくつも方法がありますが、よく採用されるのが次のような方法です。

  1. C言語の API を用意する方法
  2. GRPC, OpenAPI などのツールを使う方法

1. C言語の API を用意する方法

この方法では、処理を呼び出される側のプログラムでその言語が提供している C言語のバインディングを利用して関数をエクスポートしておき、処理を呼び出す側のプログラムでもその言語で用意している C言語のバインディングを利用して、エクスポートされた関数を呼び出します。

大抵の言語は C言語のバインディングを提供しているので、この方法はもっともポータブルです。

ただし C言語で利用可能なデータ構造は整数型やポインタや、メモリ上にシリアライズ可能な構造体などに限られるため、複雑なデータ構造を受け渡しするためには C言語で利用可能な形にデータ構造を変換してから値を受け渡しする手間がかかります。

また特に呼び出し先の処理を動的リンクライブラリとして提供する場合、そのライブラリからエクスポート関数には関数名の情報のみが含まれていて引数や戻り値がどんな型かという情報を含んでいません。そのため、動的リンクライブラリのビルド時に期待していたのと異なる構造のデータを渡すと原因を特定しにくいタイプのバグを引き起こす可能性があり、セキュリティ上の問題があります。

2. GRPC, OpenAPI などのツールを使う方法

C言語の API を用意する以外の方法として GRPC, OpenAPI などの別のツールを使う方法もあります。

各ツールはそれぞれに長所/短所があるため、ユースケースに合わせてそれらを使い分けることになりますが、愚直に C言語の API を用意するよりは少ない手間で FFI を実現できます。とはいえ、 FFI のために専用の関数を定義してそれを呼び出す必要があるという手間は依然として残っています。

上記に書いた方法方法はいずれも結局 FFI のために専用の関数を定義して、呼び出し元がその関数名を指定して処理を呼び出す必要があります。わざわざ FFI のための関数を定義せずに、もっと直感的に別の言語を呼び出せると便利そうですがそのような方法はあるでしょうか。

cpp Crate について

cpp Crate という、 @mystor 氏が開発した、Rust の中に C++ のコードを書けるようにする Crate があります。

https://docs.rs/cpp/0.5.9/cpp/ [1]

この Crate は cpp!() マクロを定義していて、このマクロのなかに C++ のコードを書くとそれが実行時に C++ のコードとして実行されます。

use cpp::cpp;

cpp!{{
    #include <iostream>
    #include <vector>
}}

fn main() {
    let name = std::ffi::CString::new("World").unwrap();
    let name_ptr = name.as_ptr(); // --(1)
    let r = unsafe {
        cpp!([name_ptr as "const char *"] -> u32 as "int32_t" {
            std::cout << "Hello, " << name_ptr << std::endl; // --(2)
            std::vector<std::int32_t> xs { 100 };
            std::cout << xs[0] << std::endl;
            return 42;
        })
    };
    assert_eq!(r, 42) // --(3)
}

このプログラムを実行すると、正しく (1), (2), (3) の順で処理が進み、 (3) の時点で r には 42 が代入されています。

ここで注目すべき点は cpp!() マクロの中は C++ に似せたコードが書けるということではなく、本当に C++ のコードが書けるようになっていて、標準ライブラリ関数を呼び出したりテンプレートを利用したりできるということです。[2]

TypeScript in Rust Land

cpp Crate のように Rust コードの中に直接別の言語のコードを埋め込むスタイルは、わざわざ FFI のために関数を定義してそれを呼び出す必要がなく、手軽で便利そうです。

これを他の言語でもできるかどうかを試してみたくなったので、今回は cpp Crate の実装を参考に TypeScript のソースコードを実行する仕組みを実装してみました。[3]

https://github.com/hotwatermorning/poc-rust-ts-block

ts_block!() というマクロの中に TypeScript のコードを書くと、 TypeScript としてそれを実行できます。

main.rs
use ts_macro::ts_block;

fn main() {
    let r1 = ts_block!({
        let str: number[] = ["Hello", "TS", "World"];
        return str.join(" ");
    });
    println!("{}", r1);

    let r2 = ts_block! {{
        let now = new Date();
        return now.toISOString();
    }};
    println!("{}", r2);
}

これを cargo run した結果は以下のようになる。

実行結果(抜粋)
Hello TS World
2024-07-21T22:24:59.502Z

あくまで Proof of Concept 程度のものですが、実際に Rust の中に TypeScript のコードを埋め込んでその処理を呼び出すことができています。

仕組みについて

poc-rust-ts-block プロジェクトは、 cpp Crate の仕組みを参考にしていて、ビルド時に build.rs というビルドスクリプトを使用します。

build.rs は コードの自動生成やビルド時の前処理などを Rust のコードを使って行うためのソースファイルで、ほかのソースコードがコンパイルされるのに先立って実行されます。(この時に、ビルドに必要なビルドディレクトリのパス情報なども環境変数として渡されます。)

https://doc.rust-jp.rs/rust-by-example-ja/cargo/build_scripts.html

cargo build を実行するとはじめに build.rs が実行されます。 build.rs は main.rs から ts_block()! の中身を抽出し、 autogen.ts という名前の TypeScript のファイルを自動生成します。

その後 main.rs がコンパイルされる時に ts_block()! マクロの中身が、 autogen.ts の処理を呼び出すようなコードに展開されます。

これによって main.rs でコンパイルした実行ファイルを実行したときに、 main.rs 中から TypeScript のコードを呼び出せるようになっています。

より少し詳しい仕組みの説明は以下の通りです。

build.rs の処理

build.rs は以下のような内容になっています。

use ts_macro_builder;

fn main() {
    ts_macro_builder::build("src/main.rs");
}

ts_macro_builder::build() 関数は、 Rust の syn Crate などを使って指定されたソースコード(ここでは main.rs)を構文解析し、 ts_block!() マクロの中を抽出します。そしてそれを OUT_DIR 環境変数で指定された出力ディレクトリの中に autogen.ts として書き出します。

autogen.ts の中身は以下のようになります。

autogen.ts(抜粋)
const __ts_block_closure_12798480021838532303 = () => {
    //line 10 "src/main.rs"
                         
        let now = new Date();
        return now.toISOString();
    
}
/*line 308 "builder/src/lib.rs"*/

const invoke = (func_name: string) => {
/*line 314 "builder/src/lib.rs"*/

    if(func_name === "__ts_block_closure_5124132947723173973") { console.log(__ts_block_closure_5124132947723173973()); }/*line 314 "builder/src/lib.rs"*/

    if(func_name === "__ts_block_closure_12798480021838532303") { console.log(__ts_block_closure_12798480021838532303()); }/*line 318 "builder/src/lib.rs"*/

    return "";
}

import { argv } from 'node:process';
invoke(argv[2]);

autogen.ts には __ts_block_closure_XXX のような名前の関数が定義されていて、さらに autogen.ts の実行時にコマンドライン引数で関数名を指定しておくことでその関数を呼び出せる仕組みが実装されています。この関数名は ts_block!() マクロの内容から算出されたハッシュ値によって決まります。

main.rs のコンパイル

build.rs による前処理が終わると、次いで main.rs がコンパイルされます。 ts_block()! マクロはこのタイミングで展開されます。

ts_block!()マクロは、その中身を、tsx コマンドを使って autogen.ts ファイルを呼び出すようなコードに変換します。

具体的には以下のようなコードが

    let r2 = ts_block! {{
        let now = new Date();
        return now.toISOString();
    }};

以下のように変換されます。

// Recursive expansion of ts_block! macro
// =======================================

{
    #[allow(unused)]
    #[derive($crate::__ts_block_internal_closure)]
    enum TsClosureInput {
        Input = ("let now = new Date () ; return now . toISOString () ;", 0).1,
    }
    {
        let output = std::process::Command::new("tsx")
            .args([
                std::env::var("TS_AUTOGEN_FILE").unwrap(),
                "__ts_block_closure_12798480021838532303".to_string(),
            ])
            .output()
            .expect("failed to execute process");
        String::from_utf8(output.stdout).unwrap().trim().to_string()
    }
}

build.rs は autogen.ts 生成時に ts_block()! の中身に応じたハッシュ値で TypeScript の関数を定義していました。 ts_block!() マクロはそれと同じハッシュ値を算出し、いまの ts_block!() マクロの中身に対応する autogen.ts の関数を tsx コマンドを利用して呼び出すようにコードを変換します。

このようにして、 main.rs の実行時に TypeScript のコードを実行できるようになっています。

おわりに

今回実装した仕組みはあくまで Proof of Concept レベルのものであって、これがそのまま実用できるわけではありません。とはいえ実際にやってみて C++ ではなく TypeScript でも cpp Crate と同じようなことが実現できることが分かりました。

build.rs を使ってマクロの中身を別の自動生成ファイルに抽出する方法は、他の言語や何らかの設定ファイルを埋め込むためにも使用できる可能性があり、さまざまな応用ができそうです。

また、 Rust が用意しているマクロや構文解析の仕組みはとても強力で、自分が書いたプログラム自体をいじれる面白さがあります。

株式会社LabBase では Rust や TypeScript でもりもりコードを書きたいエンジニアを募集しています!
https://labbase.co.jp/recruit/

脚注
  1. cpp Crate は単なる実験的なコードではなく Slint (https://slint.dev/) という新しい UI ライブラリで実際に採用されています。 ↩︎

  2. cpp Crate はさらに cpp!() マクロの中に書いた C++ の処理の中から逆に Rust の処理を呼び出す仕組みも提供しています。これは C++ の処理からさらに Rust 側の処理をコールバックで呼び出すために使用できます。 ↩︎

  3. 今回実装したものは Proof of Concept なので、 cpp Crate にあるような、 rust から呼び出し先の言語に引数を渡す仕組みや、呼び出し先の処理の中で更に rust 側のコードを実行する仕組みなどは未実装です。 ↩︎

Discussion