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

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

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

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

  • emscripten::val: C++에서 JavaScript 값을 저장하고 작업하기 위한 용도입니다.
  • 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 유형 간에 값을 변환할 수 있습니다.

다음은 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로 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은 맞춤 라이브러리 형식으로 된 별도의 파일에서 자바스크립트 코드를 선언할 수 있도록 지원합니다.

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++에 통합하는 다양한 방법을 살펴보았습니다.

이러한 스니펫을 포함하면 긴 시퀀스의 작업을 더 명확하고 효율적인 방식으로 표현하고, C++ 또는 Embind를 통해 아직 표현할 수 없는 서드 파티 라이브러리, 새로운 JavaScript API, 심지어 JavaScript 구문 기능을 활용할 수 있습니다.