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

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

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

Emscripten には、このようなインタラクション用のツールがいくつか用意されています。

  • emscripten::val: C++ で JavaScript 値を保存して操作します。
  • EM_JS: JavaScript スニペットを埋め込み、C/C++ 関数としてバインドします。
  • EM_ASYNC_JS: EM_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 関数を実行できるようにすることです。実際、このユースケースは非常に一般的であるため、これらを組み合わせる専用の 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 構文機能を活用できるようになります。