🦍

Denoを使ってRustでNodeモジュールを使う

2022/12/02に公開

はじめに

仕事で、TypeScriptで書かれたNodeモジュールをRustで使えるようにした際に色々と知見を得たので、備忘録も兼ねて残しておこうと思います。
こういうことをやろうとする人はあんまり居ないと思いますが、誰かの役に立てればと思います。

背景

Nodeモジュールの一部の関数をそのままRustのバックエンドAPIで使いたい、というのがことの始まりでした。

NodeモジュールをRustに移植して、そこからwasmを生成してNodeで動かすのが一番きれいでメンテもしやすいのです。
しかし、モジュールの実装が魔境なのとこの検証結果がうまく行けば他にも活用できる場面がありそうとのことで、調査が始まりました。

方針

現時点、RustでNodeモジュールを動かすのに、最適解はDenoだと思っています。
Denoは全部ではないがNodeのコードを動かすことができて、かつRust製でコア実装は次のクレートとして公開されています。

なので、Rustから上記のクレートを使ってNodeモジュールを動かせるようになれば、目標達成です。

ちなみに、今回使用したクレートのバージョンは次のとおりです。

deno_core = "0.153.0"
deno_runtime = "0.79.0"

調査

基本方針は決まっているので、あとは課題を洗い出しひとつずつ調査を進めていくだけです。
が、これが中々大変でした。

大きく、次の課題がありました。

  1. NodeモジュールをDenoで動かす
  2. deno_runtimeを使ってNodeモジュールを動かす
  3. dneo_runtime経由で実行したJSの関数の入力と出力の取得
  4. Nodeモジュールの型定義をRustの型に変換する

それぞれについて、説明していきます。

NodeモジュールをDenoで動かす

まず、「NodeモジュールをDenoで動かせる」というのが大前提なので、それができるかの調査をしていきました。
調査開始当時、Denoがnpmモジュール対応をしたばかりだったので、それが使えないかなと調べていたんですが、次の問題で無理という結論になりました。

  • プライベートモジュールをそもそもダウンロードできない
  • モジュールは内部でwasmを使っていて、node_modulesありきで動作する
    • --node-modules-dirを使っても動かせたとしても、APIでnode_modulesディレクトリを必須にするのは厳しい

上記の問題に対して、少し工夫が必要ですが、esbuildでモジュールをバンドルして単一JSファイルを生成することで解決しました。
ビルドスクリプトは次の感じになります。

import esbuild from 'esbuild';
import textReplace from 'esbuild-plugin-text-replace';
import { wasmLoader } from 'esbuild-plugin-wasm';
import { readFileSync, writeFileSync } from 'fs';

const outfile = process.argv.length > 2 ? process.argv[2] : 'foo.js';

await esbuild
  .build({
    entryPoints: ['path/to/entrypoint.ts'],
    bundle: true,
    outfile: outfile,
    platform: 'browser',
    format: 'esm',
    plugins: [
      wasmLoader({
        mode: 'embedded',
      }),
      textReplace({
        include: /\.js$/,
        pattern: [['global.', `globalThis.`]],
      }),
    ]
  })
  .then(_ => {
    const data = readFileSync(outfile);
    writeFileSync(outfile, `globalThis.process = { env: {} };\n${data.toString()}`);
  }).catch(e => {
    console.error(e);
  });

主なポイントは以下となります。

  • wasmLoaderでwasmをJSに埋め込む
  • 出力をES Moduleにする
    • DenoはES Moduleのみサポートしているため
  • platformをbrowserにする
    • DenoはWeb標準に準拠しているため、動く可能性が高いため
    • nodeにしてしまうと、node_modulesありきのコードが生成されてしまうため

これで、ひとまずNodeのコードをDenoで動かせるようになりました。
変換後のJSをimportして、対象の関数を実行してすんなり動いたときはちょっと感動しました。

deno_runtimeを使ってNodeモジュールを動かす

esbuildで出力したjsをDenoで動かせることが確認できたので、次はdeno_runtimeクレートを使ってRustから動かせるかを検証していきました。
検証当時はCommonJSを使うという方針だったので、次のcreateRequireを使ったコードを動かせることを目標としていました。

import { createRequire } from "https://deno.land/std@0.165.0/node/module.ts";
const require = createRequire(import.meta.url);
const foo = require("path/to/module.js");
foo.bar();

ちょうど、Denoが公式ブログにRoll your own JavaScript runtimeというタイトルで、
deno_coreの使用例を載せていたのでこれを参考にしつつdeno_runtimeを使って動かしてみたところ、モジュールロードの部分でエラーが起きました。

error: Provided module specifier "https://deno.land/std@0.165.0/node/module.ts" is not a file 発生する

この時点ではDenoの内部実装もそうですが、Deno全体の仕組みもあんまり分かっていませんでした。
特に次の2点が良く分からず、調べても恐らくここまで詳細に解説している記事はないだろうと思い、Deno本体の実装を追ってくことにしました。

  • モジュールロードとキャッシュは誰がどこでどうやっているのか
  • TSをどうJSにトランスパイルしているのか

結果、モジュールのロード処理を行っているはそれぞれ次の部分ということがわかりました。

FileFecther(モジュールのダウンロードとキャッシュ)
https://github.com/denoland/deno/blob/449b1317c87087173eda7f782770da44f99c1739/cli/file_fetcher.rs#L557-L563

CliModuleLoader(トランスパイル)
https://github.com/denoland/deno/blob/449b1317c87087173eda7f782770da44f99c1739/cli/emit.rs#L175-L183

ここまでわかれば、あとはそれらと同等の処理を実装すれば動きます。
実際に、次のようにモジュールのダウンロードとトランスパイルの処理を実装したら、Rustで動かすことができました。

https://github.com/skanehira/deno_runtime_example/blob/32c9f6675474ea02bd2cdcb06a41091ea4efae87/src/module_loader.rs#L23-L79

しかし、ES Moduleで出力したJSをそのまま動かせればモジュールを管理(ダウンロード、キャッシュ、トランスパイル)は不要なので、最終的にES Moduleで動かせるようにしました。
なのでこの案はボツになりました。

dneo_runtime経由で実行したJSの関数の入出力の受け渡し

今回使いたい関数は入出力があって、Rustで受け取ったデータをdeno_runtime経由でV8に渡し、出力をdeno_runtime経由でRustで受け取る必要がありました。

Rust <-> deno_runtime <-> V8

出力に関しては、モジュールを評価したあと、exportした変数をmodule namespaceから次のように取得できます。

#[derive(Debug, Default, serde::Deserialize)]
pub struct Object {
    pub name: Option<String>,
}

async fn run_js(file_path: &str) -> Result<(), AnyError> {
...
    let module_id = runtime
        .load_main_module(
            &main_module,
            Some(r#"
const obj = {name: "gorilla"};
export { obj };
"#.into()),
        )
        .await?;

    let _ = runtime.mod_evaluate(module_id);
    runtime.run_event_loop(false).await?;

    let module_handle_scope = runtime.get_module_namespace(module_id)?;

    let global_handle_scope = &mut runtime.handle_scope();
    let local_handle_scope = v8::Local::<v8::Object>::new(global_handle_scope, module_handle_scope);

    let export_name = v8::String::new(global_handle_scope, "obj").context("failed to get obj")?;
    let binding = local_handle_scope.get(global_handle_scope, export_name.into());
    let object = binding.context("not found obj")?;
    let obj: Object = serde_v8::from_v8(global_handle_scope, object)?;
...
}

ここらへんはV8のドメインが絡んでいて、調べても正直理解できなかったのでdeno_coreのテスト実装を参考にしました。

https://github.com/denoland/deno/blob/449b1317c87087173eda7f782770da44f99c1739/core/runtime.rs#L2975-L2979

ちなみに、deno_runtime <-> V8のデータのやり取りはserde_v8というクレートが使われています。
上記の例でいうと、V8から取得したオブジェクトをRustの構造体にデコードする部分で使っています。

let obj: Object = serde_v8::from_v8(global_handle_scope, object)?;

そして、入力に関しては最終的にJS側のDeno.args経由で受け取って処理するようにしました。

Deno起動時に初期処理(bootstrap.mainRuntime())を行っているんですが、
そこでコマンドライン引数を含めたBootstrapOptions構造体がJSON文字列としてJSスクリプトに埋め込まれ実行されます。
そのため、コマンドライン引数をDeno.argsから受け取れる様になっています。
実装は次の部分になります。

https://github.com/denoland/deno/blob/449b1317c87087173eda7f782770da44f99c1739/runtime/worker.rs#L261-L266

このようにして、入出力の受け渡しができるようになりました。

Nodeモジュールの型定義をRustの型に変換する

最後の関門として、出力をRustの構造体にデシリアライズする必要があります。
次のコードでいうとObjectに当たる部分ですね。

let obj: Object = serde_v8::from_v8(global_handle_scope, object)?;

結論からいうと、最終的にはTypeScript Compiler APIのライブラリを使って型変換スクリプトを実装しました。

最悪serde_json:Valueanyみたいなやつ)にしても開発はなんとかなるかも知れませんが、正直Rustで型が不確定のままデータを扱うのはしんどいので、なんとしても避けたいところです。
こういった言語間の型変換は大体、中間表現を挟むとできるイメージがあったので、まずは次の方法で変換できないか調査をしていきました。

  • TypeScript -> OpenAPI -> Rust
  • TypeScript -> JSON Schema -> Rust

OpenAPI
typeconvでTypeScriptからOpenAPI Specを生成するopenapi-generator-cliを使って、
Specからmodelのコードのみ自動生成すれば行けそうと思ったので、実際に簡単な型定義を試してみたところ良さそうでした。

:::details実行ログ

$ cat to_openapi.mjs
import {
  getTypeScriptReader,
  getOpenApiWriter,
  makeConverter,
} from 'typeconv'

const reader = getTypeScriptReader({ nonExported: 'include-if-referenced', unsupported: 'error' });
const writer = getOpenApiWriter({ format: 'yaml', title: 'My API', version: 'v3' });
const { convert } = makeConverter(reader, writer);

const { out } = await convert({ data: "export type Animal = {name: string; age: number;}" }, { filename: "types.yaml" });
console.log(out);
$ node to_openapi.mjs
{ convertedTypes: [ 'Animal' ], notConvertedTypes: [] }
$ docker run --rm -v "${PWD}:/local" -w "/local" openapitools/openapi-generator-cli generate \
    -i ./types.yaml \
    -g rust \
    -o /local/out

[main] INFO  o.o.codegen.DefaultGenerator - Generating with dryRun=false
[main] INFO  o.o.codegen.DefaultGenerator - OpenAPI Generator: rust (client)
[main] INFO  o.o.codegen.DefaultGenerator - Generator 'rust' is considered stable.
[main] INFO  o.o.codegen.utils.URLPathUtils - 'host' (OAS 2.0) or 'servers' (OAS 3.0) not defined in the spec. Default to [http://localhost] for server URL [http://localhost/]
[main] INFO  o.o.codegen.utils.URLPathUtils - 'host' (OAS 2.0) or 'servers' (OAS 3.0) not defined in the spec. Default to [http://localhost] for server URL [http://localhost/]
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/src/models/animal.rs
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/docs/Animal.md
[main] INFO  o.o.codegen.utils.URLPathUtils - 'host' (OAS 2.0) or 'servers' (OAS 3.0) not defined in the spec. Default to [http://localhost] for server URL [http://localhost/]
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/README.md
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/git_push.sh
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/.gitignore
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/.travis.yml
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/src/models/mod.rs
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/src/lib.rs
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/Cargo.toml
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/src/apis/mod.rs
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/src/apis/configuration.rs
[main] INFO  o.o.codegen.TemplateManager - Skipped /local/out/.openapi-generator-ignore (Skipped by supportingFiles options supplied by user.)
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/.openapi-generator/VERSION
[main] INFO  o.o.codegen.TemplateManager - writing file /local/out/.openapi-generator/FILES
################################################################################
# Thanks for using OpenAPI Generator.                                          #
# Please consider donation to help us maintain this project 🙏                 #
# https://opencollective.com/openapi_generator/donate                          #
################################################################################
$ cat out/src/models/animal.rs 
/*
 * My API
 *
 * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
 *
 * The version of the OpenAPI document: v3
 * 
 * Generated by: https://openapi-generator.tech
 */




#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Animal {
    #[serde(rename = "name")]
    pub name: String,
    #[serde(rename = "age")]
    pub age: f32,
}

impl Animal {
    pub fn new(name: String, age: f32) -> Animal {
        Animal {
            name,
            age,
        }
    }
}

:::

しかし、typeconvはimport先の型をOpenAPI Specに出力してくれないという問題がありました。
たとえば、Fooをimportしている使っている場合、Fooの定義は出力されないので、この案はボツとなりました。

JSON Schema
OpenAPIが要件を満たさなかったため、次にJSON Schemaを使った型変換について調査しました。
typescript-json-schemaでTypeScriptからJSON Schemaを生成して、
quicktypeを使って簡単な型定義を変換してみたところ、import先もちゃんとJSON Schemaに出力してくれました。

$ npx typescript-json-schema --esModuleInterop path/to/types.d.ts '*' -o schema.json
$ npx quicktype -s schema --density dense --derive-debug --visibility public schema.json -o types.rs

これで行けるのでは?と最初は思いましたが、実際に変換対象の型定義を使ってみると次のエラーになってしまいました。

Error: Trying to make an empty union - do you have an impossible type in your schema?.

詳細を追っておくと、TypeScriptで使われているPhantomパターンの型定義を変換できなかったのが原因でした。

type Phantom<T, U extends string> = T & { [key in U]: never };

上記の問題を一時的に回避して変換できたとしても、Rustの型が想定と違うものになっていて実際に使えないものもありました。
Typescriptの型システムはとても柔軟のため、こういった細かい型定義をTypeScriptほど柔軟ではない言語の型に変換するのは、やはり一筋縄には行かないなと改めて思いました。

余談ですがquicktypeはJSON Schemaを挟まずにTypeScriptからRustの型変換ができるけど、これもPhantomパターンといった型定義は変換がうまく行かないです。
ただ、シンプルな型定義であれば比較的に使えそうかなと思うので、今後使えそうな場面があれば使おうと思っています。

自前実装
Oepn APIとJSON Schema以外に型変換する方法を思いつかず、調べてもこれといった情報がなかったため、結局自前で実装することにしました。
型変換する対象は限定的だったため、すべてのパターンを網羅する必要がなく、対応が大変な型の場合はいったんserde_json::Valueにする、という方針だったのでなんとかなりました。

それでも次のようなパターンを考慮する必要があって中々大変でした。

  • 再帰的にimport先の型を解析

    • importした型のフィールドがさらにimportしている型になっている
  • 別名import

    • import { Foo as InnnerFoo } from ...;のような、型名が変わる場合
  • Rustのキーワードと被っている場合

    • typeなどはRustのキーワードなので、別名にする必要がある
  • union型はEnumを作成するが、名前が被らないようにする必要がある

    // ts
    type foo {
      bar: {
        baz: string | number;
      }
    }
    
    // rust
    enum FooBarBaz {
        String(String),
        Number(f64),
    }
    
    struct Foo {
        bar: FooBarBaz,
    }
    
  • Omitを使った型定義は対象フィールドを除外した型を作る必要がある

こんな感じであれこれ考えながら実装するので、
作業が1、2回だけなら型変換を手書きで頑張るのが効率がよいかなと思いました。

余談ですが、変換スクリプトはswc.rsを使ってRustで書いても良かったかも知れないなと思いました。
次回はswcでチャレンジしてみたいところです。

さいごに

色々と大変でしたが、最終的にはなんとか目的を達成できました。
そしてDenoやRustについて知識を深めることができたので、ハードではありますが、個人的にとてもやりがいがあって楽しかったです。
今後も、こういう難しいことをチャレンジしていきたいなと思いました。

みなさんにとってこの記事が役に立つかわかりませんが、読んで面白かったと思ってもらえたら嬉しいです。

Discussion