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

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

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

Emscripten には、そのようなやり取りを行うためのツールがいくつか用意されています。

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

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

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++ 側を一度一時停止するだけで済みます。

EM_ASYNC_JS マクロ

残っているのは Asyncify.handleAsync ラッパーです。その目的は Asyncify で async JavaScript 関数を実行できるようにすることだけです。実際、このユースケースは非常に一般的であるため、現在は、これらを結合する専用の 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 構文機能を活用できるようになります。