Emscripten ו-npm

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

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

בתרחישים האלה, Wasm הוא לא החלק המרכזי של האפליקציה, אלא חלק בפאזל: עוד מודול. באפליקציה שלכם כבר יש JavaScript, CSS, נכסי תמונות, מערכת פיתוח ממוקדת-אינטרנט ואולי גם 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 יכול לטפל בקובץ ה-Bundler כמו webpack או-roll, וצריך להתייחס לקובץ ה-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 בתוך הקונטיינר ישתקפו בפרויקט שלכם בפועל. הספריות המשוכפלות האלה נקראות 'תושבות חיבור'.

בוא נסתכל על 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 מעביר את המעטפת למצב 'fail Fast'. אם פקודות כלשהן בסקריפט מחזירות שגיאה, הסקריפט כולו מתבטל מיד. זה יכול להיות שימושי מאוד, כי הפלט האחרון של הסקריפט תמיד יהיה הודעת הצלחה או השגיאה שגרמה לכשל ב-build.

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

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

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

לפקודה emcc יש מגוון אפשרויות משלה. שימו לב ש-emcc אמור להיות "תחליף" (Drop-in) מהדרים כמו 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 עם ייצוא ברירת מחדל שפועל עם כל Bundler. צריך להגדיר גם את -s MODULARIZE=1.

הסימונים הבאים לא תמיד נחוצים, או שהם שימושיים רק למטרות ניפוי באגים:

  • -s FILESYSTEM=0 הוא דגל שקשור ל-Emscripten, ויש לו אפשרות לאמול עבורכם מערכת קבצים כשקוד C/C++ משתמש בפעולות של מערכת קבצים. הוא מבצע ניתוח מסוים על הקוד שהוא יוצר כדי להחליט אם לכלול את האמולציה של מערכת הקבצים בקוד השיוך. עם זאת, לפעמים הניתוח הזה עלול לטעות ותצטרכו לשלם סכום גבוה של 70kB בקוד דבק נוסף על אמולציה של מערכת קבצים שאולי לא תצטרכו. באמצעות -s FILESYSTEM=0 אפשר לאלץ את Emscripten לא לכלול את הקוד הזה.
  • הקוד -g4 יכלול מידע על תוצאות ניפוי באגים ב-.wasm וגם יפלט קובץ של מפות מקור למודול Wam. מידע נוסף על ניפוי באגים באמצעות 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 אמור להציג לכם את הפלט הבא במסוף כלי הפיתוח:

כלי פיתוח שמציגים הודעה שהודפסה באמצעות 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. בשביל לעשות את זה יש את פקודות ה-wrapper 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 חדש, ושהפלט של דף ההדגמה הוא הקבוע:

כלי פיתוח שמציגים גרסת ABI של libvpx שמודפסת באמצעות emscripten.

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

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

# ... 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 לא השתנתה, Docker לא יריץ בפועל את השלב הזה כאשר בונים מחדש את קובץ ה-Docker. במקום זאת, נעשה שימוש חוזר בשכבה מהפעם האחרונה שבה התמונה נבנתה.

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