การฝังข้อมูลโค้ด JavaScript ใน C++ ด้วย Emscripten

เรียนรู้วิธีฝังโค้ด JavaScript ในไลบรารี WebAssembly ของคุณเพื่อสื่อสารกับโลกภายนอก

เมื่อต้องผสานรวม WebAssembly กับเว็บ คุณต้องมีวิธีเรียกใช้ API ภายนอก เช่น API ของเว็บและไลบรารีของบุคคลที่สาม จากนั้นคุณต้องมีวิธีจัดเก็บค่าและอินสแตนซ์ออบเจ็กต์ที่ API เหล่านั้นแสดงผล และวิธีส่งค่าที่เก็บไว้เหล่านั้นไปยัง API อื่นๆ ในภายหลัง สำหรับ API แบบอะซิงโครนัส คุณอาจต้องรอคำสัญญาในโค้ด C/C++ แบบซิงโครนัสที่ใช้ Asyncify และอ่านผลลัพธ์เมื่อการดำเนินการเสร็จสิ้น

Emscripten มีเครื่องมือมากมายสำหรับการโต้ตอบดังกล่าว ดังนี้

  • emscripten::val สำหรับการจัดเก็บและเรียกใช้ค่า JavaScript ใน C++
  • 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() ของ Asyncify เพื่อดึงข้อมูลและแยกวิเคราะห์ 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() แต่ละรายการต้องหยุดฝั่ง C++ ชั่วคราวด้วยการคลายชุดการเรียกใช้ทั้งหมดของโมดูล WebAssembly แล้วกลับมาที่ JavaScript รอ และกู้คืนสแต็ก WebAssembly เมื่อการดำเนินการเสร็จสมบูรณ์

โค้ดดังกล่าวไม่ต้องการสิ่งใดจาก C++ เนื่องจากโค้ด C++ ทำหน้าที่เป็นเพียงไดรเวอร์สำหรับชุดการดำเนินการ JavaScript เท่านั้น จะเกิดอะไรขึ้นหากคุณย้าย fetch_json ไปใช้ JavaScript และลดค่าใช้จ่ายของขั้นตอนกลางได้ในเวลาเดียวกัน

มาโคร EM_JS

EM_JS macro ช่วยให้คุณย้าย fetch_json ไปที่ JavaScript ได้ EM_JS ใน Emscripten ช่วยให้คุณประกาศฟังก์ชัน C/C++ ที่ข้อมูลโค้ด JavaScript ติดตั้งใช้งาน

โดยมีข้อจำกัดในการรองรับเฉพาะอาร์กิวเมนต์ตัวเลขและส่งกลับค่า เช่นเดียวกับ 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);
});

ขั้นตอนสุดท้าย สำหรับประเภทค่าที่ซับซ้อนขึ้นเองและกำหนดเอง คุณสามารถใช้ JavaScript API สำหรับคลาส val ที่กล่าวถึงก่อนหน้านี้ได้ ซึ่งคุณจะแปลงค่า 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>();

เรายังมี Conversion ที่ชัดเจน 2-3 รายการอยู่ที่จุดเข้าและออกจากฟังก์ชัน แต่ที่เหลือจะเป็นโค้ด JavaScript ปกติ เครื่องมือ JavaScript สามารถเพิ่มประสิทธิภาพซึ่งต่างจาก val ที่เทียบเท่ากัน เพียงแต่ต้องหยุดด้าน C++ ชั่วคราวเพียงครั้งเดียวสำหรับการดำเนินการแบบไม่พร้อมกันทั้งหมด

มาโคร EM_ASYNC_JS

สิ่งเดียวที่ดูไม่สวยเลยก็คือ Wrapper ของ Asyncify.handleAsync จุดประสงค์เพียงอย่างเดียวคืออนุญาตให้เรียกใช้ฟังก์ชัน async ของ JavaScript ด้วย Asyncify อันที่จริง กรณีการใช้งานนี้พบได้บ่อยมากจนตอนนี้มีมาโคร 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 ฟีเจอร์นี้มีประสิทธิภาพเพราะเชื่อมโยงข้อมูลโค้ดที่ประกาศไว้โดยตรงเหมือนกับการนำเข้าฟังก์ชัน 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 จะแสดงผลจำนวนจุดลอยตัวที่สอดคล้องกัน

อาร์กิวเมนต์ที่ส่งผ่านจะใช้ได้ภายใต้ชื่อ $0, $1 และอื่นๆ ในส่วนเนื้อหาของ JavaScript เช่นเดียวกับ 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);

เมื่อประกาศทั้ง 2 ฝั่งแล้ว ไลบรารี JavaScript จะลิงก์เข้ากับโค้ดหลักผ่าน --js-library option ซึ่งจะเชื่อมต่อต้นแบบกับการติดตั้งใช้งาน JavaScript ที่สัมพันธ์กัน

อย่างไรก็ตาม รูปแบบโมดูลนี้ไม่ใช่มาตรฐานและจำเป็นต้องมีคำอธิบายประกอบการอ้างอิงอย่างระมัดระวัง ด้วยเหตุนี้ ระบบจึงสงวนไว้สำหรับสถานการณ์ขั้นสูงเป็นส่วนใหญ่

บทสรุป

ในโพสต์นี้ เราพูดถึงวิธีต่างๆ ในการผสานรวมโค้ด JavaScript ลงใน C++ เมื่อทำงานกับ WebAssembly

การใส่ตัวอย่างดังกล่าวจะช่วยให้คุณแสดงลำดับการดำเนินการที่ยาวนานขึ้นได้อย่างสะอาดตาและมีประสิทธิภาพมากขึ้น และใช้ประโยชน์จากไลบรารีของบุคคลที่สาม, JavaScript API ใหม่ และแม้แต่ฟีเจอร์ไวยากรณ์ JavaScript ที่ยังไม่แสดงผ่าน C++ หรือ Embind ได้