הטמעת קטעי 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.

כך משתמשים בו עם .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 עצמו, גם ל-WebAssembly for Containers יש מגבלה של תמיכה רק בארגומנטים ובערכים חזרה מספריים. כדי להעביר ערכים אחרים, צריך להמיר אותם באופן ידני באמצעות ממשקי ה-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>();

עדיין יש לנו כמה המרות מפורשות בנקודות הכניסה והיציאה של הפונקציה, אבל שאר הקוד הוא עכשיו קוד JavaScript רגיל. בניגוד ל-val המקביל, עכשיו אפשר לבצע אופטימיזציה של הפונקציה הזו באמצעות מנוע JavaScript, וצריך להשהות את הצד של C++ רק פעם אחת לכל הפעולות האסינכרוניות.

מאקרו EM_ASYNC_JS

החלק היחיד שנשאר ולא נראה יפה הוא העטיפה Asyncify.handleAsync – המטרה היחידה שלה היא לאפשר להריץ פונקציות JavaScript של async באמצעות 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);

אחרי שמצהירים על הספרייה בשני הצדדים, אפשר לקשר אותה לקוד הראשי באמצעות --js-library option, כדי לחבר אב טיפוס להטמעות JavaScript תואמות.

עם זאת, פורמט המודול הזה לא סטנדרטי ודורש הערות זהירות לגבי יחסי התלות. לכן, הוא מיועד בעיקר לתרחישי שימוש מתקדמים.

סיכום

בפוסט הזה התייחסנו לדרכים שונות לשילוב קוד JavaScript ב-C++ כשעובדים עם WebAssembly.

הכללת קטעי קוד כאלה מאפשרת להביע רצפים ארוכים של פעולות בצורה נקייה ויעילה יותר, וליהנות מספריות של צד שלישי, ממשקי API חדשים של JavaScript ואפילו תכונות של תחביר JavaScript שעדיין לא ניתן להביע באמצעות C++‎ או Embind.