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

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

เมื่อทําการผสานรวม WebAssembly กับเว็บ คุณต้องมีวิธีเรียกใช้ API ภายนอก เช่น Web 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

วิธีใช้กับ .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 ปกติแล้ว ซึ่งแตกต่างจาก val ตรงที่ตอนนี้เครื่องมือนี้จะเพิ่มประสิทธิภาพโดยเครื่องมือ JavaScript และจะต้องหยุดด้าน C++ ไว้เพียงครั้งเดียวสําหรับการดำเนินการแบบไม่สอดคล้องกันทั้งหมด

มาโคร EM_ASYNC_JS

เหลือเพียงส่วนเดียวที่ดูไม่สวยนักคือ Asyncify.handleAsync wrapper ซึ่งมีวัตถุประสงค์เพียงอย่างเดียวคืออนุญาตให้เรียกใช้ฟังก์ชัน async JavaScript ด้วย Asyncify อันที่จริงแล้ว Use Case นี้พบได้ทั่วไปจนตอนนี้มีมาโคร EM_ASYNC_JS เฉพาะที่รวมมาโคร 2 รายการเข้าด้วยกัน

วิธีใช้ตัวอย่าง 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

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