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

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

Podczas pracy nad integracją WebAssembly z internetem potrzebujesz sposobu na wywoływanie zewnętrznych interfejsów API, takich jak internetowe interfejsy API i biblioteki innych firm. Potrzebny jest też sposób przechowywania wartości i instancji obiektów zwróconych przez te interfejsy API oraz sposób na późniejsze przekazanie tych wartości do innych interfejsów API. W przypadku asynchronicznych interfejsów API może też być konieczne czekanie na obietnice w synchronicznym kodzie C/C++ z użyciem funkcji Asyncify i odczytanie wyniku po zakończeniu operacji.

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

  • emscripten::val do przechowywania wartości JavaScriptu i operowania nimi w C++.
  • EM_JS do umieszczania fragmentów kodu JavaScript i powią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 JavaScript jako jedną bibliotekę.

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

emscripten::val klasa

Klasa emcripten::val jest świadczona 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 pobrać i przeanalizować zawartość pliku JSON razem z .await() usługi Asyncify:

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

Ten kod działa dobrze, ale wykonuje wiele kroków średniozaawansowanych. Każda operacja na zasobie val musi obejmować następujące kroki:

  1. Przekonwertuj wartości C++ przekazywane jako argumenty na format pośredni.
  2. Otwórz JavaScript, a potem odczytaj i przekształć argumenty w wartości JavaScript.
  3. Wykonaj funkcję
  4. Przekonwertuj wynik z JavaScriptu na format pośredni.
  5. Zwróć przekonwertowany wynik do C++, a C++ na koniec odczyta go z powrotem.

Każdy obiekt await() musi też wstrzymać stronę C++ przez cofnię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 C++ działa jedynie jako sterownik serii operacji JavaScript. A gdyby można było przenieść fetch_json do JavaScriptu i jednocześnie zmniejszyć nakład pracy związany z krokami pośrednimi?

Makro EM_JS

EM_JS macro umożliwia przeniesienie fetch_json do JavaScriptu. Tag EM_JS w Emscripten pozwala zadeklarować funkcję w języku C/C++ zaimplementowaną przez fragment kodu JavaScript.

Podobnie jak ona WebAssembly, ma ona ograniczenie dotyczące obsługi tylko argumentów liczbowych i zwracanych wartości. Jeśli chcesz przekazać inne wartości, musisz przekonwertować je ręcznie za pomocą odpowiednich interfejsów API. Oto kilka przykładów.

Przekazane numery nie wymagają 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);
});

Na koniec, aby uzyskać bardziej złożone, dowolne typy wartości, możesz użyć interfejsu JavaScript API dla wspomnianej wcześniej klasy val. Umożliwia on konwersję wartości JavaScript i klas 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 zmodyfikować przykład fetch_json w taki sposób, aby wykorzystywał 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>();

W punktach wejścia i wyjścia funkcji nadal mamy kilka jawnych konwersji, ale reszta to zwykły kod JavaScript. W przeciwieństwie do odpowiednika val można ją teraz optymalizować przez mechanizm JavaScript. Wymaga tylko jednego wstrzymania strony C++ w przypadku wszystkich operacji asynchronicznych.

Makro EM_ASYNC_JS

Jedynym fragmentem, który nie wygląda dobrze, jest otoka Asyncify.handleAsync – jej jedynym przeznaczeniem jest umożliwienie wykonywania funkcji JavaScript async za pomocą Asyncify. Ten przypadek użycia jest tak powszechny, że teraz istnieje specjalne makro EM_ASYNC_JS, które łączy je ze sobą.

Aby utworzyć ostateczną wersję przykładu fetch, możesz go użyć w ten sposób:

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 to wydajne, bo wiąże zadeklarowane fragmenty bezpośrednio w taki sam sposób jak każda inna funkcja JavaScriptu importowana. Zapewnia też dobrą ergonomię, umożliwiając jawne deklarowanie wszystkich typów i nazw parametrów.

W niektórych przypadkach możesz jednak chcieć wstawić krótki fragment kodu dla wywołania console.log, instrukcji debugger; lub podobnej i nie chcesz zadeklarować całej oddzielnej funkcji. W tych rzadkich przypadkach prostszym wyborem 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 wykonują kod w miejscu, w którym zostały wstawione, ale nie definiują funkcji.

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

Aby wybrać zwracany typ, musisz użyć odpowiedniej nazwy makra. Bloki EM_ASM powinny działać jak funkcje void, bloki EM_ASM_INT mogą zwracać wartość całkowitą, a bloki EM_ASM_DOUBLE odpowiadają liczbom zmiennoprzecinkowym.

Wszystkie przekazane argumenty będą dostępne pod nazwami $0, $1 itd. w treści JavaScriptu. Podobnie jak w przypadku EM_JS lub WebAssembly argumenty są ograniczone tylko do wartości liczbowych – liczb całkowitych, liczb zmiennoprzecinkowych, wskaźników i uchwytów.

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

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

Na koniec Emscripten obsługuje deklarowanie kodu JavaScript w osobnym pliku we własnym formacie biblioteki:

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

Następnie zadeklaruj odpowiednie prototypy ręcznie po stronie C++:

extern "C" void log_value(EM_VAL val_handle);

Po zadeklarowaniu po obu stronach biblioteki JavaScript można połączyć z kodem głównym za pomocą --js-library option, łącząc prototypy z odpowiadającymi im implementacjami JavaScript.

Ten format modułu jest jednak niestandardowy i wymaga szczegółowych adnotacji dotyczących zależności. Dlatego jest on zwykle zarezerwowany dla scenariuszy zaawansowanych.

Podsumowanie

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

Uwzględnienie takich fragmentów kodu pozwala wyrazić długie sekwencje operacji w czytelniejszy i efektywniejszy sposób oraz korzystać z bibliotek innych firm, nowych interfejsów API JavaScript, a nawet funkcji składni JavaScript, których jeszcze nie da się wyrazić w C++ lub Embind.