Встраивание фрагментов JavaScript в C++ с помощью Emscripten

Узнайте, как встроить код JavaScript в вашу библиотеку WebAssembly для связи с внешним миром.

При работе над интеграцией WebAssembly с Интернетом вам нужен способ вызова внешних API, таких как веб-API и сторонние библиотеки. Затем вам понадобится способ хранения значений и экземпляров объектов, возвращаемых этими API, а также способ передачи этих сохраненных значений другим API позже. Для асинхронных API вам также может потребоваться дождаться обещаний в синхронном коде C/C++ с помощью Asyncify и прочитать результат после завершения операции.

Emscripten предоставляет несколько инструментов для такого взаимодействия:

  • emscripten::val для хранения значений JavaScript и работы с ними в C++.
  • EM_JS для встраивания фрагментов JavaScript и привязки их как функций C/C++.
  • EM_ASYNC_JS похож на EM_JS , но упрощает встраивание асинхронных фрагментов JavaScript.
  • EM_ASM для встраивания коротких фрагментов и их встроенного выполнения без объявления функции.
  • --js-library для сложных сценариев, в которых вы хотите объявить множество функций JavaScript вместе как одну библиотеку.

В этом посте вы узнаете, как использовать их все для аналогичных задач.

emscripten::val класс

Класс emcripten::val предоставляется Embind. Он может вызывать глобальные API, привязывать значения JavaScript к экземплярам C++ и преобразовывать значения между типами C++ и JavaScript.

Вот как использовать его с .await() Asyncify для получения и анализа 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() также должен приостанавливать сторону C++, разматывая весь стек вызовов модуля WebAssembly, возвращаясь к JavaScript, ожидая и восстанавливая стек WebAssembly после завершения операции.

Такой код не требует ничего от C++. Код C++ действует только как драйвер для ряда операций JavaScript. Что, если бы вы могли перенести fetch_json на JavaScript и одновременно сократить накладные расходы на промежуточные шаги?

Макрос EM_JS

EM_JS macro позволяет переместить fetch_json в JavaScript. EM_JS в Emscripten позволяет объявить функцию C/C++, реализуемую фрагментом JavaScript.

Как и сам 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);
});

Наконец, для более сложных, произвольных типов значений вы можете использовать API JavaScript для ранее упомянутого класса val . С его помощью вы можете конвертировать значения 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 — ее единственная цель — разрешить выполнение async функций JavaScript с помощью Asyncify. Фактически, этот вариант использования настолько распространен, что теперь существует специализированный макрос 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

EM_JS — рекомендуемый способ объявления фрагментов JavaScript. Это эффективно, поскольку связывает объявленные фрагменты напрямую, как и любой другой импорт функций JavaScript. Он также обеспечивает хорошую эргономику, позволяя явно объявлять все типы и имена параметров.

Однако в некоторых случаях вы хотите вставить быстрый фрагмент для вызова console.log , debugger; оператор или что-то подобное и не хочу заморачиваться с объявлением целой отдельной функции. В таких редких случаях EM_ASM macros family ( EM_ASM , EM_ASM_INT и EM_ASM_DOUBLE ) может быть более простым выбором. Эти макросы похожи на макрос EM_JS , но они выполняют код внутри места вставки, а не определяют функцию.

Поскольку они не объявляют прототип функции, им нужен другой способ указания типа возвращаемого значения и доступа к аргументам.

Вам необходимо использовать правильное имя макроса, чтобы выбрать тип возвращаемого значения. Ожидается, что блоки EM_ASM будут действовать как функции void , блоки EM_ASM_INT могут возвращать целочисленное значение, а блоки EM_ASM_DOUBLE соответственно возвращают числа с плавающей запятой.

Любые переданные аргументы будут доступны под именами $0 , $1 и т. д. в теле JavaScript. Как и в случае с 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-библиотека

Наконец, 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.

Однако этот формат модуля нестандартен и требует тщательного аннотирования зависимостей. Таким образом, он в основном зарезервирован для продвинутых сценариев.

Заключение

В этом посте мы рассмотрели различные способы интеграции кода JavaScript в C++ при работе с WebAssembly.

Включение таких фрагментов позволяет выражать длинные последовательности операций более чистым и эффективным способом, а также использовать сторонние библиотеки, новые API-интерфейсы JavaScript и даже функции синтаксиса JavaScript, которые еще невозможно выразить с помощью C++ или Embind.