Emscripten ו-npm

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

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

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

Docker

גיליתי ש-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 צריך להתייחס אליו כמו לכל נכס בינארי גדול אחר, כמו תמונות.

לכן, צריך לבנות את פריטי המידע שנוצרו בתהליך הפיתוח (Artifact) של 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 בתוך הקונטיינר ישתקף בפרויקט בפועל. הספריות המשוכפלות האלה נקראות 'תושבות בין חשבונות'.

בואו נבחן את 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), מהדר (compiler) C++ (CXXFLAGS) ומקשר (LDFLAGS). כולם מקבלים את הגדרות האופטימיזציה דרך OPTIMIZE כדי לוודא שהכול עובר אופטימיזציה באותה דרך. יש כמה ערכים אפשריים למשתנה OPTIMIZE:

  • -O0: ללא אופטימיזציה. לא מסירים קוד מת, וגם Emscripten לא מקטין את קוד ה-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 של זיכרון. הקוד מקצה מקטעי זיכרון, כך שהפעולה הזו מחליטה אם הפעולות האלה יגרמו לכשל במודול Wam כולו כשהזיכרון נגמר, או אם לקוד החיבור יש הרשאה להרחיב את הזיכרון הכולל כדי להשלים את ההקצאה.
  • -s MALLOC=... בוחרת באיזו הטמעה של malloc() להשתמש. emmalloc היא הטמעה קטנה ומהירה של malloc() שמיועדת במיוחד ל-Emscripten. החלופה היא dlmalloc, הטמעה מלאה של malloc(). צריך לעבור ל-dlmalloc רק אם מקצים הרבה אובייקטים קטנים לעיתים קרובות או אם רוצים להשתמש בשרשורים.
  • הקוד -s EXPORT_ES6=1 יהפוך למודול ES6 עם ייצוא ברירת מחדל שפועל עם כל Bundler. צריך גם להגדיר את -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, napa דואג לשכפול המאגר של 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 תדלג על בניית 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, אבל רק אם היא עדיין לא נוצרה. כך הכול יפעל כמו קודם, אבל עכשיו בסביבת ה-build יש את הפקודה doxygen, שתגרום ליצירת התיעוד של libvpx.

סיכום

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

נספח: שימוש בשכבות תמונה של Docker

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

בעבר, הייתם צריכים להשקיע מאמצים כדי לא לבנות מחדש את libvpx בכל פעם שאתם מפתחים את האפליקציה. במקום זאת, אפשר להעביר את הוראות הבנייה של 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 ו-clone libvpx, כי אין לכם טעינות של bind כשאתם מריצים את docker build. כתוצאה מכך, אין יותר צורך בנאפה.