Osadzanie fragmentów kodu JavaScript w C++ za pomocą Emscripten

Dowiedz się, jak umieszczać kod JavaScript w bibliotece WebAssembly, aby komunikować się ze światem zewnętrznym.

Pracując nad integracją WebAssembly z internetem, potrzebujesz sposobu na wywoływanie zewnętrznych interfejsów API, takich jak internetowe interfejsy API i biblioteki innych firm. Musisz następnie znaleźć sposób na przechowywanie wartości i instancji obiektów zwracanych przez te interfejsy API, a także sposób na przekazanie tych przechowywanych wartości do innych interfejsów API. W przypadku asynchronicznych interfejsów API konieczne może być też czekanie na obietnice w synchronicznym kodzie C/C++ za pomocą narzędzia Asyncify i odczytywanie wyniku po zakończeniu operacji.

Emscripten udostępnia kilka narzędzi do takich interakcji:

  • emscripten::val do przechowywania i używania wartości JavaScript w C++.
  • EM_JS do umieszczania fragmentów kodu JavaScript i wiązania ich jako funkcji C/C++.
  • EM_ASYNC_JS podobny do EM_JS, ale ułatwia umieszczanie asynchronicznych fragmentów kodu JavaScript.
  • EM_ASM do umieszczania krótkich fragmentów i wykonywania ich w tekście bez deklarowania funkcji.
  • --js-library na potrzeby zaawansowanych scenariuszy, w których chcesz zadeklarować wiele funkcji JavaScriptu razem jako jedną bibliotekę.

Z tego posta dowiesz się, jak używać ich wszystkich do podobnych zadań.

emscripten::val klasa

Zajęcia emcripten::val są dostarczane przez Embind. Może wywoływać globalne interfejsy API, wiązać wartości JavaScript z instancjami C++ oraz konwertować wartości między typami C++ i JavaScript.

Aby użyć go razem z .await() Asyncify do pobrania i przeanalizowania kodu 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>();

Taki kod działa dobrze, ale wykonuje wiele kroków pośrednich. Każda operacja na val musi wykonać te czynności:

  1. Konwertowanie wartości C++ przekazywanych jako argumenty na format pośredni.
  2. Przejdź do JavaScriptu, odczytuj i konwertuj argumenty na wartości JavaScript.
  3. Wykonaj funkcję
  4. Przekonwertuj wynik z kodu JavaScript na format pośredni.
  5. Zwróć przekonwertowany wynik na język C++, który następnie odczyta go w języku C++.

Każdy element await() musi też wstrzymać działanie strony C++ przez rozwinięcie całego stosu wywołań modułu WebAssembly, powrót do JavaScriptu, oczekiwanie i przywrócenie stosu WebAssembly po zakończeniu operacji.

Taki kod nie wymaga niczego z C++. Kod w C++ działa tylko jako napęd dla serii operacji JavaScript. A gdyby można było jednocześnie przenieść fetch_json do JavaScriptu i jednocześnie ograniczyć nakład pracy związany z krokami pośrednimi?

Makro EM_JS

Element EM_JS macro umożliwia przeniesienie witryny fetch_json do JavaScriptu. EM_JS w Emscripten umożliwia zadeklarowanie funkcji C/C++ zaimplementowanej przez fragment kodu JavaScript.

Podobnie jak w przypadku rozwiązania WebAssembly, ma on ograniczenie dotyczące obsługi argumentów liczbowych i zwracanych wartości. Aby przekazywać inne wartości, musisz je przekonwertować ręcznie za pomocą odpowiednich interfejsów API. Oto kilka przykładów.

Przekazywanie numerów nie wymaga żadnej konwersji:

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

int x = add_one(41);

Podczas przekazywania ciągów znaków do i z JavaScriptu musisz używać odpowiednich funkcji konwersji i alokacji z pliku 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);
});

W przypadku bardziej złożonych, arbitralnych typów wartości możesz też użyć interfejsu JavaScript API dla wcześniej wspomnianej klasy val. Za jego pomocą możesz konwertować wartości JavaScript i klasy C++ na uchwyty pośrednie i z powrotem:

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>();

Mając na uwadze te interfejsy API, można przepisać przykład fetch_json, aby obsługiwał większość zadań bez opuszczania JavaScriptu:

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>();

Nadal mamy kilka jawnych konwersji w punktach wejścia i wyjścia funkcji, ale reszta to zwykły kod JavaScript. W przeciwieństwie do odpowiednika val można ją teraz optymalizować przez mechanizm JavaScriptu. Wszystkie operacje asynchroniczne wymagają tylko jednego wstrzymania strony C++.

Makro EM_ASYNC_JS

Jedynym, który nie wygląda dobrze, jest otoka Asyncify.handleAsync – jej jedynym przeznaczeniem jest umożliwienie wykonywania funkcji JavaScriptu async przy użyciu Asyncify. Ten przypadek użycia jest tak powszechny, że powstało specjalne makro EM_ASYNC_JS, które łączy je ze sobą.

Oto w jaki sposób można użyć go do wygenerowania ostatecznej wersji przykładu 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

Zalecanym sposobem deklarowania fragmentów kodu JavaScript jest EM_JS. Jest wydajny, ponieważ wiąże zadeklarowane fragmenty bezpośrednio tak jak każdy inny import funkcji JavaScript. Zapewnia też wysoką ergonomię, ponieważ umożliwia jawne zadeklarowanie wszystkich typów i nazw parametrów.

W niektórych przypadkach jednak chcesz wstawić krótki fragment kodu dla wywołania console.log, instrukcji debugger; lub podobnej, i nie chcesz zaprzątać sobie głowy deklarowaniem zupełnie osobnej funkcji. W tych rzadkich przypadkach prostszym rozwiązaniem może być EM_ASM macros family (EM_ASM, EM_ASM_INT i EM_ASM_DOUBLE). Te makra są podobne do makra EM_JS, ale uruchamiają kod bezpośrednio w miejscu wstawienia, zamiast definiować funkcję.

Ponieważ nie deklarują prototypu funkcji, potrzebują innego sposobu określania typu zwracanego i uzyskiwania dostępu do argumentów.

Aby wybrać zwracany typ makra, musisz użyć odpowiedniej nazwy. Bloki EM_ASM powinny działać jak funkcje void, bloki (EM_ASM_INT) mogą zwracać wartość całkowitą, a bloki EM_ASM_DOUBLE – odpowiednio liczby zmiennoprzecinkowe.

Wszystkie przekazane argumenty będą dostępne pod nazwami $0, $1 itd. w treści JavaScriptu. Podobnie jak w przypadku EM_JS lub WebAssembly w ogóle argumenty są ograniczone do wartości liczbowych, takich jak liczby całkowite, liczby zmiennoprzecinkowe, wskaźniki i nick.

Oto przykład użycia makra EM_ASM do zarejestrowania w konsoli dowolnej wartości JavaScriptu:

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

Dodatkowo Emscripten obsługuje deklarowanie kodu JavaScript w osobnym pliku w własnym formacie biblioteki:

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

Następnie musisz zadeklarować odpowiednie prototypy ręcznie po stronie C++:

extern "C" void log_value(EM_VAL val_handle);

Po zadeklarowaniu tej biblioteki po obu stronach można połączyć bibliotekę JavaScript z kodem głównym za pomocą interfejsu --js-library option, łącząc prototypy z odpowiednimi implementacjami JavaScript.

Jest to jednak niestandardowy format modułu i wymaga ostrożnych adnotacji dotyczących zależności. Dlatego jest on zarezerwowany głównie na potrzeby zaawansowanych scenariuszy.

Podsumowanie

W tym poście pokazaliśmy różne sposoby integracji kodu JavaScript z C++ podczas pracy z WebAssembly.

Dołączenie takich fragmentów pozwala wyrażać długie sekwencje działań w czystszy i wydajniejszy sposób oraz korzystać z bibliotek innych firm, nowych interfejsów API JavaScript, a nawet funkcji składni JavaScript, których nie można jeszcze wyrazić w C++ lub Embind.