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

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

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

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

  • C++에서 JavaScript 값을 저장하고 운영하기 위한 emscripten::val
  • JavaScript 스니펫을 삽입하고 C/C++ 함수로 바인딩하는 EM_JS
  • EM_JS와 유사하지만 비동기 JavaScript 스니펫을 더 쉽게 삽입할 수 있는 EM_ASYNC_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. 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++ 측을 한 번만 일시중지하면 됩니다.

EM_ASYNC_JS 매크로

Asyncify.handleAsync 래퍼만 보기 좋지 않습니다. 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 문법 기능까지 활용할 수 있습니다.