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

המחלקה 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>();

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

מאקרו EM_ASYNC_JS

החלק היחיד שלא נראה יפה הוא ה-wrapper של 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 באופן כללי, הארגומנטים מוגבלים רק לערכים מספריים – מספרים שלמים, מספרים עם נקודה צפה (floating-point), מצביעים וכינויים.

דוגמה לשימוש במאקרו 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.

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

סיכום

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

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