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

הדרך המומלצת להצהיר על קטעי קוד ב-JavaScript היא 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 שמחזירים מספרים עם נקודה צפה (floating-point).

כל הארגומנטים שהועברו יהיו זמינים בשמות $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.