הטמעת קטעי 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 — המטרה היחידה שלו היא לאפשר ביצוע 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

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

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