Emscripten ו-npm

איך משלבים את WebAssembly בהגדרה הזו? במאמר הזה נסביר איך עושים את זה באמצעות C/C++ ו-Emscripten כדוגמה.

WebAssembly (wasm) מתואר לעיתים קרובות ככלי לשיפור הביצועים או כדרך להריץ את קוד ה-C++ הקיים באינטרנט. בעזרת squoosh.app, רצינו להראות שיש לפחות נקודת מבט שלישית ל-wasm: ניצול של הסביבות העסקיות העצומות של שפות תכנות אחרות. ב-Emscripten אפשר להשתמש בקוד C/C++, ב-Rust יש תמיכה מובנית ב-wasm וגם צוות Go עובד על כך. אני בטוח שעוד שפות יצטרפו בקרוב.

בתרחישים האלה, ‏wasm הוא לא החלק המרכזי באפליקציה, אלא עוד חלק בפאזל: מודול נוסף. האפליקציה שלכם כבר מכילה JavaScript, ‏ CSS, נכסי תמונות, מערכת build שמתמקדת באינטרנט ואולי אפילו framework כמו React. איך משתלבים WebAssembly בהגדרה הזו? במאמר הזה נראה איך עושים את זה באמצעות C/C++ ו-Emscripten כדוגמה.

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

ב-Docker Registry יש קובץ אימג' של Emscripten שנוצר על ידי trzeci, והשתמשתי בו הרבה.

שילוב עם npm

ברוב המקרים, נקודת הכניסה לפרויקט אינטרנט היא package.json של npm. לפי הסכמה, אפשר ליצור את רוב הפרויקטים באמצעות npm install && npm run build.

באופן כללי, צריך להתייחס לארטיפקטים של ה-build שנוצרו על ידי Emscripten (קובץ .js וקובץ .wasm) כאל מודול JavaScript נוסף וכאל נכס נוסף. קובץ JavaScript יכול להיות מטופל על ידי חבילה כמו webpack או rollup, וקובץ wasm צריך להתייחס אליו כמו לכל נכס בינארי גדול אחר, כמו תמונות.

לכן, צריך ליצור את ארטיפקטי ה-build של Emscripten לפני שתהליך ה-build 'הרגיל' מתחיל:

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

המשימה החדשה build:emscripten יכולה להפעיל את Emscripten ישירות, אבל כמו שציינתי קודם, מומלץ להשתמש ב-Docker כדי לוודא שסביבת ה-build עקבית.

הפקודה docker run ... trzeci/emscripten ./build.sh מורה ל-Docker להפעיל קונטיינר חדש באמצעות קובץ האימג' trzeci/emscripten ולהריץ את הפקודה ./build.sh. build.sh הוא סקריפט מעטפת שתכתבו בשלב הבא. הדגל --rm מציין ל-Docker למחוק את הקונטיינר אחרי שהוא מסיים לפעול. כך לא יצטברו לאורך זמן אוספים של קובצי אימג' לא מעודכנים של מכונות. -v $(pwd):/src מציין שרוצים ש-Docker 'ידלגו' על הספרייה הנוכחית ($(pwd)) אל /src בתוך הקונטיינר. כל שינוי שתבצעו בקבצים בספרייה /src בתוך הקונטיינר ישתקף בפרויקט בפועל. הספריות המשוכפלות האלה נקראות 'קישורי mount'.

בואו נבחן את build.sh:

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

יש כאן הרבה דברים שאפשר לנתח!

set -e מעביר את המעטפת למצב 'כישלון מהיר'. אם אחת מהפקודות בסקריפט מחזירה שגיאה, כל הסקריפט מבוטל באופן מיידי. האפשרות הזו יכולה להיות מאוד מועילה, כי הפלט האחרון של הסקריפט יהיה תמיד הודעת הצלחה או השגיאה שגרמה לכישלון ה-build.

באמצעות הצהרות export, מגדירים את הערכים של כמה משתני סביבה. הם מאפשרים להעביר פרמטרים נוספים של שורת פקודה למהדר C‏ (CFLAGS), למהדר C++‏ (CXXFLAGS) ולמקשר (LDFLAGS). כל אחד מהם מקבל את הגדרות האופטימיזציה דרך OPTIMIZE כדי לוודא שכל הקוד עבר אופטימיזציה באותו אופן. יש כמה ערכים אפשריים למשתנה OPTIMIZE:

  • -O0: לא מתבצעת אופטימיזציה. המערכת לא מסירה קוד לא פעיל, וגם לא מבצעת אופטימיזציה של קוד ה-JavaScript שהיא מנפיקה. מתאים לניפוי באגים.
  • -O3: אופטימיזציה אגרסיבית לשיפור הביצועים.
  • -Os: אופטימיזציה אגרסיבית לשיפור הביצועים והגודל כקריטריון משני.
  • -Oz: אופטימיזציה אגרסיבית של הגודל, תוך ויתור על הביצועים במקרה הצורך.

לאינטרנט, מומלץ בעיקר להשתמש ב--Os.

לפקודה emcc יש מגוון רחב של אפשרויות משלה. הערה: emcc אמור להיות "תחליף ללא התאמה למהדרים כמו GCC או clang". לכן, סביר להניח שכל הדגלים שאתם מכירים מ-GCC יופעלו גם ב-emcc. הדגל -s הוא מיוחד בכך שהוא מאפשר לנו להגדיר את Emscripten באופן ספציפי. כל האפשרויות הזמינות מפורטות בקובץ settings.js של Emscripten, אבל הוא יכול להיות מבלבל למדי. הנה רשימה של הדגלים של Emscripten שלדעתי הכי חשובים למפתחי אתרים:

  • --bind מאפשר embind.
  • ב--s STRICT=1 הופסקה התמיכה בכל אפשרויות ה-build שהוצאו משימוש. כך תוכלו לוודא שהקוד שלכם יתאים לשימוש בעתיד.
  • -s ALLOW_MEMORY_GROWTH=1 מאפשרת להגדיל את הזיכרון באופן אוטומטי במקרה הצורך. נכון למועד כתיבת המאמר, Emscripten מקצה 16MB של זיכרון בהתחלה. כשהקוד מקצה קטעי זיכרון, האפשרות הזו קובעת אם הפעולות האלה יגרמו לכשל של כל מודול ה-wasm כשהזיכרון ינוצל, או אם לקוד הדבק מותר להרחיב את נפח הזיכרון הכולל כדי להתאים את ההקצאה.
  • -s MALLOC=... בוחרת באיזו הטמעה של malloc() להשתמש. emmalloc היא הטמעה קטנה ומהירה של malloc() שמיועדת במיוחד ל-Emscripten. החלופה היא dlmalloc, הטמעה מלאה של malloc(). צריך לעבור ל-dlmalloc רק אם מקצים הרבה אובייקטים קטנים בתדירות גבוהה או אם רוצים להשתמש בשרשור.
  • -s EXPORT_ES6=1 יהפוך את קוד ה-JavaScript למודול ES6 עם ייצוא ברירת מחדל שעובד עם כל חבילה. צריך גם להגדיר את -s MODULARIZE=1.

הדגלים הבאים לא תמיד נדרשים או מועילים רק למטרות ניפוי באגים:

  • -s FILESYSTEM=0 הוא דגל שקשור ל-Emscripten וליכולת שלו לדמות מערכת קבצים כשקוד ה-C/C++ משתמש בפעולות של מערכת קבצים. הוא מבצע ניתוח מסוים של הקוד שהוא מקמפל כדי להחליט אם לכלול את הדמיית מערכת הקבצים בקוד הדבקה או לא. עם זאת, לפעמים הניתוח הזה יכול לטעות, ואתם עלולים לשלם 70KB לא מעט גדולים של קוד דבק נוסף להדמיה של מערכת קבצים שאולי לא תצטרכו. בעזרת -s FILESYSTEM=0 אפשר לאלץ את Emscripten לא לכלול את הקוד הזה.
  • -g4 יגרום ל-Emscripten לכלול את פרטי ניפוי הבאגים ב-.wasm, וגם להפיק קובץ מפות מקור למודול ה-wasm. מידע נוסף על ניפוי באגים באמצעות Emscripten זמין בקטע על ניפוי באגים.

זהו זה! כדי לבדוק את ההגדרה הזו, ננסה ליצור my-module.cpp קטן:

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

וגם index.html:

    <!doctype html>
    <title>Emscripten + npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

(כאן יש gist שמכיל את כל הקבצים).

כדי ליצור את כל הקבצים, מריצים את הפקודה

$ npm install
$ npm run build
$ npm run serve

כשתעבירו את הדפדפן אל localhost:8080, הפלט הבא אמור להופיע במסוף DevTools:

כלי הפיתוח שבו מוצגת הודעה שנדפסה באמצעות C++ ו-Emscripten.

הוספת קוד C/C++ כיחס תלות

אם אתם רוצים ליצור ספריית C/C++ לאפליקציית האינטרנט שלכם, הקוד שלה צריך להיות חלק מהפרויקט. אפשר להוסיף את הקוד למאגר של הפרויקט באופן ידני, או להשתמש ב-npm גם כדי לנהל את סוגי התלות האלה. נניח שאתם רוצים להשתמש ב-libvpx באפליקציית האינטרנט שלכם. libvpx היא ספריית C++‏‎‎ לקודק תמונות עם VP8, הקודק שמשמש בקובצי .webm. עם זאת, libvpx לא נמצא ב-npm ואין לו package.json, כך שאין לי אפשרות להתקין אותו ישירות באמצעות npm.

כדי לצאת מהמבוך הזה, יש את napa. בעזרת napa אפשר להתקין כל כתובת URL של מאגר git כיחס תלות בתיקייה node_modules.

מתקינים את napa כיחס תלות:

$ npm install --save napa

חשוב להריץ את napa כסקריפט התקנה:

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

כשמריצים את npm install, ה-CLI של napa יוצר עותקים (clone) של מאגר libvpx ב-GitHub ב-node_modules בשם libvpx.

עכשיו אפשר להרחיב את סקריפט ה-build כדי ליצור את libvpx. כדי ליצור את libvpx נעשה שימוש ב-configure וב-make. למרבה המזל, Emscripten יכול לעזור לוודא ש-configure ו-make משתמשים במהדר של Emscripten. לשם כך יש את פקודות המעטפת emconfigure ו-emmake:

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

ספרייה של C/C++ מחולקת לשני חלקים: הכותרות (בדרך כלל קבצים מסוג .h או .hpp) שמגדירות את מבני הנתונים, הכיתות, הקבועים וכו' שספרייה חושפת, והספרייה בפועל (בדרך כלל קבצים מסוג .so או .a). כדי להשתמש בערך הקבוע VPX_CODEC_ABI_VERSION של הספרייה בקוד, צריך לכלול את קובצי הכותרת של הספרייה באמצעות משפט #include:

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

הבעיה היא שהמְהַדר לא יודע איפה לחפש את vpxenc.h. זהו המטרה של הדגל -I. הוא מורה למהדר באילו ספריות לבדוק אם יש קובצי כותרות. בנוסף, צריך גם לספק למהדר את קובץ הספרייה בפועל:

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

אם מריצים את npm run build עכשיו, רואים שהתהליך יוצר קובץ .js חדש וקובץ .wasm חדש, ושבדף הדגמה אכן תופיע הקבועה:

DevTools שמוצגת בו גרסת ה-ABI של libvpx שנדפסה באמצעות emscripten.

תבחינו גם שתהליך ה-build נמשך זמן רב. הסיבות לזמני build ארוכים עשויות להשתנות. במקרה של libvpx, התהליך נמשך זמן רב כי מתבצע קומפילציה של מקודד ומפענח גם ל-VP8 וגם ל-VP9 בכל פעם שמפעילים את פקודת ה-build, גם אם קובצי המקור לא השתנו. גם שינוי קטן ב-my-module.cpp ייקח הרבה זמן לפתח. מומלץ מאוד לשמור את קובצי הארטיפקט של ה-build של libvpx אחרי ה-build הראשון.

אחת הדרכים לעשות זאת היא באמצעות משתני סביבה.

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

(כאן יש gist שמכיל את כל הקבצים).

הפקודה eval מאפשרת לנו להעביר פרמטרים לסקריפט ה-build כדי להגדיר משתני סביבה. הפקודה test תדלג על ה-build של libvpx אם הערך של $SKIP_LIBVPX מוגדר (לכל ערך).

עכשיו אפשר לקמפל את המודול אבל לדלג על היצירה מחדש של libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

התאמה אישית של סביבת ה-build

לפעמים ספריות תלויות בכלים נוספים לצורך פיתוח. אם יחסי התלות האלה חסרים בסביבת ה-build שסופקו על ידי קובץ האימג' של Docker, צריך להוסיף אותם בעצמכם. לדוגמה, נניח שאתם רוצים ליצור גם את המסמכים של libvpx באמצעות doxygen. Doxygen לא זמין בתוך קונטיינר Docker, אבל אפשר להתקין אותו באמצעות apt.

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

במקרה כזה, מומלץ ליצור קובץ אימג' של Docker משלכם. כדי ליצור קובצי אימג' של Docker, כותבים קובץ Dockerfile שמתאר את שלבי ה-build. קובצי Docker הם חזקים מאוד וכוללים הרבה פקודות, אבל ברוב המקרים אפשר להסתפק רק ב-FROM, ב-RUN וב-ADD. במקרה זה:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

באמצעות FROM, אפשר להצהיר באיזה קובץ אימג' של Docker רוצים להשתמש כנקודת התחלה. בחרתי ב-trzeci/emscripten כבסיס – התמונה שבה השתמשתם כל הזמן. בעזרת RUN, מורים ל-Docker להריץ פקודות מעטפת בתוך הקונטיינר. כל השינויים שהפקודות האלה מבצעות בקונטיינר הם עכשיו חלק מקובץ האימג' ב-Docker. כדי לוודא שקובץ האימג' של Docker נוצר וזמין לפני שמריצים את build.sh, צריך לשנות קצת את package.json:

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

(כאן יש gist שמכיל את כל הקבצים).

הפקודה הזו תיצור את קובץ האימג' של Docker, אבל רק אם הוא עדיין לא נוצר. לאחר מכן, הכול פועל כמו קודם, אבל עכשיו הפקודה doxygen זמינה בסביבת ה-build, וכך גם התיעוד של libvpx ייוצר.

סיכום

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

נספח: שימוש בשכבות של קובצי אימג' ב-Docker

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

בעבר, היה צריך להשקיע קצת מאמץ כדי לא לבנות מחדש את libvpx בכל פעם שמפתחים את האפליקציה. במקום זאת, אפשר להעביר את הוראות ה-build של libvpx מ-build.sh אל Dockerfile כדי להשתמש במנגנון האחסון במטמון של Docker:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

(כאן יש gist שמכיל את כל הקבצים).

שימו לב שצריך להתקין את git באופן ידני ולשכפל את libvpx, כי אין לכם אפשרות להתקין את הספריות ב-bind כשמריצים את docker build. כתוצאה מכך, אין יותר צורך ב-napa.