Emscripten을 사용하여 C++에 자바스크립트 스니펫 삽입하기

WebAssembly 라이브러리에 JavaScript 코드를 삽입하여 외부 세계와 통신하는 방법을 알아봅니다.

잉바르 스테파니안
잉바르 스테파니안

WebAssembly와 웹 통합 작업 시 웹 API, 서드 파티 라이브러리와 같은 외부 API를 호출할 방법이 필요합니다. 그런 다음 API가 반환하는 값과 객체 인스턴스를 저장하고 저장된 값을 나중에 다른 API로 전달할 방법이 필요합니다. 비동기 API의 경우 Asyncify를 사용하여 동기 C/C++ 코드에서 프로미스를 기다리고 작업이 완료된 후 결과를 읽어야 할 수도 있습니다.

Emscripten은 이러한 상호작용을 위한 몇 가지 도구를 제공합니다.

  • emscripten::val: C++에서 JavaScript 값을 저장하고 작업합니다.
  • EM_JS - JavaScript 스니펫을 삽입하고 이를 C/C++ 함수로 결합합니다.
  • EM_ASYNC_JSEM_JS와 유사하지만 비동기 자바스크립트 스니펫을 더 쉽게 삽입할 수 있게 해 줍니다.
  • EM_ASM: 짧은 스니펫을 삽입하고 함수를 선언하지 않고 인라인으로 실행합니다.
  • --js-library: 많은 JavaScript 함수를 단일 라이브러리로 함께 선언해야 하는 고급 시나리오

이 게시물에서는 유사한 작업에 이러한 모든 도구를 사용하는 방법을 배우게 됩니다.

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. 결과를 자바스크립트에서 중간 형식으로 변환합니다.
  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를 염두에 두고 JavaScript를 종료하지 않고도 대부분의 작업을 실행하도록 fetch_json 예를 다시 작성할 수 있습니다.

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

EM_JS는 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 블록은 이에 상응하는 부동 소수점 숫자를 반환합니다.

전달된 인수는 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 구문 기능도 활용할 수 있습니다.