Emscripten を使用して C++ に JavaScript スニペットを埋め込む

WebAssembly ライブラリに JavaScript コードを埋め込んで外部と通信する方法について説明します。

WebAssembly をウェブと統合する場合、ウェブ API やサードパーティ ライブラリなどの外部 API を呼び出す方法が必要です。次に、これらの API から返される値とオブジェクト インスタンスを保存する方法と、保存された値を後で他の API に渡す方法が必要になります。非同期 API の場合は、Asyncify を使用して同期 C/C++ コードで Promise を待機し、オペレーションの完了後に結果を読み取ることが必要になる場合もあります。

Emscripten は、そのようなインタラクションのためにいくつかのツールを提供しています。

  • emscripten::val: C++ で JavaScript 値を格納、操作します。
  • EM_JS: JavaScript スニペットを埋め込み、C/C++ 関数としてバインドします。
  • EM_ASYNC_JSEM_JS に似ていますが、非同期の JavaScript スニペットを簡単に埋め込めるようになります。
  • EM_ASM: 関数を宣言せずに短いスニペットを埋め込んでインラインで実行します。
  • --js-library: 多数の JavaScript 関数を 1 つのライブラリとしてまとめて宣言する高度なシナリオ向け。

この投稿では、同様のタスクでそれらすべてを使用する方法について説明します。

emscripten::val クラス

emcripten::val クラスは Embind によって提供されます。グローバル API の呼び出し、JavaScript 値の C++ インスタンスへのバインド、C++ 型と JavaScript 型間の値の変換が可能です。

これを Asyncify の .await() と併用して、JSON の取得と解析を行う方法を示します。

#include <emscripten/val.h>

using namespace emscripten;

val fetch_json(const char *url) {
  // Get and cache a binding to the global `fetch` API in each thread.
  thread_local const val fetch = val::global("fetch");
  // Invoke fetch and await the returned `Promise<Response>`.
  val response = fetch(url).await();
  // Ask to read the response body as JSON and await the returned `Promise<any>`.
  val json = response.call<val>("json").await();
  // Return the JSON object.
  return json;
}

// Example URL.
val example_json = fetch_json("https://httpbin.org/json");

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

このコードは適切に機能しますが、多くの中間ステップを実行します。val に対する各オペレーションでは、次の手順を実行する必要があります。

  1. 引数として渡された C++ 値を中間形式に変換します。
  2. JavaScript に移動し、引数を読み取り、JavaScript 値に変換します。
  3. 関数を実行する
  4. 結果を JavaScript から中間形式に変換します。
  5. 変換された結果を C++ に返し、C++ が最終的にそれを読み取ります。

また、各 await() は、WebAssembly モジュールのコールスタック全体をアンワインドして JavaScript に戻り、操作が完了したら WebAssembly スタックの待機と復元を行うことで、C++ 側を一時停止する必要があります。

このようなコードには C++ のものは不要です。C++ コードは一連の JavaScript 操作のドライバとしてのみ機能します。fetch_json を JavaScript に移行し、同時に中間ステップのオーバーヘッドを削減できるとしたらどうでしょうか。

EM_JS マクロ

EM_JS macro を使用すると、fetch_json を JavaScript に移行できます。Emscripten の EM_JS を使用すると、JavaScript スニペットによって実装される C/C++ 関数を宣言できます。

WebAssembly 自体と同様に、数値引数と戻り値のみをサポートするという制限があります。その他の値を渡すには、対応する API を介して手動で変換する必要があります。次に例を示します。

数値の引き渡しには変換は必要ありません。

// Passing numbers, doesn't need any conversion.
EM_JS(int, add_one, (int x), {
  return x + 1;
});

int x = add_one(41);

JavaScript との間で文字列を渡すには、preamble.js の対応する変換関数と割り当て関数を使用する必要があります。

EM_JS(void, log_string, (const char *msg), {
  console.log(UTF8ToString(msg));
});

EM_JS(const char *, get_input, (), {
  let str = document.getElementById('myinput').value;
  // Returns heap-allocated string.
  // C/C++ code is responsible for calling `free` once unused.
  return allocate(intArrayFromString(str), 'i8', ALLOC_NORMAL);
});

より複雑で任意の値の型については、前述の val クラスに JavaScript API を使用できます。これを使用して、JavaScript 値と C++ クラスを中間ハンドルに変換し、その逆に変換できます。

EM_JS(void, log_value, (EM_VAL val_handle), {
  let value = Emval.toValue(val_handle);
  console.log(value);
});

EM_JS(EM_VAL, find_myinput, (), {
  let input = document.getElementById('myinput');
  return Emval.toHandle(input);
});

val obj = val::object();
obj.set("x", 1);
obj.set("y", 2);
log_value(obj.as_handle()); // logs { x: 1, y: 2 }

val myinput = val::take_ownership(find_input());
// Now you can store the `find_myinput` DOM element for as long as you like, and access it later like:
std::string value = input["value"].as<std::string>();

これらの API を念頭に置くと、fetch_json の例を書き直すと、JavaScript を離れることなくほとんどの処理を実行できます。

EM_JS(EM_VAL, fetch_json, (const char *url), {
  return Asyncify.handleAsync(async () => {
    url = UTF8ToString(url);
    // Invoke fetch and await the returned `Promise<Response>`.
    let response = await fetch(url);
    // Ask to read the response body as JSON and await the returned `Promise<any>`.
    let json = await response.json();
    // Convert JSON into a handle and return it.
    return Emval.toHandle(json);
  });
});

// Example URL.
val example_json = val::take_ownership(fetch_json("https://httpbin.org/json"));

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

関数の開始点と終了点でまだいくつか明示的な変換を行っていますが、残りは通常の JavaScript コードになりました。val と同等のものとは異なり、JavaScript エンジンで最適化できるようになり、すべての非同期処理で C++ 側を 1 回一時停止するだけで済みます。

EM_ASYNC_JS マクロ

見た目が良くないのは Asyncify.handleAsync ラッパーだけです。その唯一の目的は、Asyncify で async JavaScript 関数を実行できるようにすることです。実際、このユースケースは非常に一般的であるため、それらを 1 つにまとめる専用の EM_ASYNC_JS マクロが用意されています。

これを使用して fetch サンプルの最終バージョンを生成する方法を以下に示します。

EM_ASYNC_JS(EM_VAL, fetch_json, (const char *url), {
  url = UTF8ToString(url);
  // Invoke fetch and await the returned `Promise<Response>`.
  let response = await fetch(url);
  // Ask to read the response body as JSON and await the returned `Promise<any>`.
  let json = await response.json();
  // Convert JSON into a handle and return it.
  return Emval.toHandle(json);
});

// Example URL.
val example_json = val::take_ownership(fetch_json("https://httpbin.org/json"));

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

EM_ASM

JavaScript スニペットを宣言する際は、EM_JS の使用をおすすめします。他の JavaScript 関数のインポートと同様に、宣言されたスニペットを直接バインドするため、効率的です。また、すべてのパラメータの型と名前を明示的に宣言できるため、優れたエルゴノミクスを実現します。

ただし、場合によっては、console.log 呼び出しのクイック スニペットや debugger; ステートメントなどを挿入し、関数全体を宣言する手間をかけたくないこともあります。このようなまれなケースでは、EM_ASM macros familyEM_ASMEM_ASM_INTEM_ASM_DOUBLE)を使用するほうがよい場合があります。これらのマクロは EM_JS マクロと似ていますが、関数を定義するのではなく、挿入された場所でコードをインラインで実行します。

関数のプロトタイプを宣言していないため、戻り値の型と引数にアクセスする別の方法が必要になります。

戻り値の型を選択するには、正しいマクロ名を使用する必要があります。EM_ASM ブロックは void 関数のように動作し、EM_ASM_INT ブロックは整数値を返し、EM_ASM_DOUBLE ブロックはそれに応じて浮動小数点数を返すことが想定されています。

渡された引数はすべて、JavaScript の本文で $0$1 などという名前で使用できます。EM_JS や WebAssembly 全般と同様に、引数は数値(整数、浮動小数点数、ポインタ、ハンドル)のみに制限されます。

次の例は、EM_ASM マクロを使用して任意の JS 値をコンソールに記録する方法を示しています。

val obj = val::object();
obj.set("x", 1);
obj.set("y", 2);
// executes inline immediately
EM_ASM({
  // convert handle passed under $0 into a JavaScript value
  let obj = Emval.fromHandle($0);
  console.log(obj); // logs { x: 1, y: 2 }
}, obj.as_handle());

--js-library

最後に、Emscripten は、独自のライブラリ形式の別のファイルで JavaScript コードを宣言することをサポートしています。

mergeInto(LibraryManager.library, {
  log_value: function (val_handle) {
    let value = Emval.toValue(val_handle);
    console.log(value);
  }
});

次に、C++ 側で対応するプロトタイプを手動で宣言する必要があります。

extern "C" void log_value(EM_VAL val_handle);

JavaScript ライブラリを両側で宣言すると、--js-library option を介してメインコードとともにリンクされ、プロトタイプを対応する JavaScript の実装に接続できます。

ただし、このモジュール形式は非標準であるため、依存関係のアノテーションを慎重に指定する必要があります。そのため、ほとんどの場合は高度なシナリオ向けです。

まとめ

この投稿では、WebAssembly を使用する際に JavaScript コードを C++ に統合するさまざまな方法について説明しました。

このようなスニペットを含めることで、長いシーケンスをよりクリーンで効率的な方法で表現し、サードパーティのライブラリ、新しい JavaScript API、さらには C++ や Embind ではまだ表現できない JavaScript 構文機能を活用できるようになります。