איך משלבים WebAssembly בהגדרה הזו? במאמר הזה נסביר איך לעשות את זה באמצעות C/C++ ו-Emscripten כדוגמה.
WebAssembly (wasm) מוצג לעיתים קרובות כפרימיטיב של ביצועים או כדרך להריץ באינטרנט את בסיס הקוד הקיים של 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 באמצעות כלי לאריזת מודולים כמו webpack או rollup, וצריך להתייחס לקובץ ה-wasm כמו לכל נכס בינארי גדול אחר, כמו תמונות.
לכן, צריך ליצור את ארטיפקטים של Emscripten לפני שתהליך הבנייה 'הרגיל' מתחיל:
{
"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 כדי לוודא שסביבת הבנייה עקבית.
הפקודה docker run ... trzeci/emscripten ./build.sh
אומרת ל-Docker להפעיל קונטיינר חדש באמצעות קובץ האימג' trzeci/emscripten
ולהריץ את הפקודה ./build.sh
.
build.sh
הוא סקריפט מעטפת שתכתבו בהמשך. --rm
אומר ל-Docker למחוק את הקונטיינר אחרי שהוא סיים לפעול. כך לא תצטבר לכם עם הזמן קולקציה של תמונות מכונה לא עדכניות. -v $(pwd):/src
אומר שאתם רוצים ש-Docker ישקף את הספרייה הנוכחית ($(pwd)
) אל /src
בתוך הקונטיינר. כל שינוי שתבצעו בקבצים בספרייה /src
בתוך הקונטיינר ישתקף בפרויקט בפועל. הספריות המשוכפלות האלה נקראות 'חיבורי bind'.
בואו נסתכל על 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'. אם פקודה כלשהי בסקריפט מחזירה שגיאה, הסקריפט כולו מבוטל באופן מיידי. האפשרות הזו יכולה להיות מאוד שימושית כי הפלט האחרון של הסקריפט תמיד יהיה הודעת הצלחה או השגיאה שגרמה לכשל בבנייה.
בעזרת ההצהרות export
, מגדירים את הערכים של כמה משתני סביבה. הם מאפשרים להעביר פרמטרים נוספים של שורת פקודה לקומפיילר C (CFLAGS
), לקומפיילר C++ (CXXFLAGS
) וללינקר (LDFLAGS
). כולם מקבלים את הגדרות האופטימיזציה דרך OPTIMIZE
כדי לוודא שהאופטימיזציה מתבצעת באופן זהה. יש כמה ערכים אפשריים למשתנה OPTIMIZE
:
-O0
: לא מתבצעת אופטימיזציה. קוד מת לא מוסר, וגם קוד ה-JavaScript שנוצר על ידי Emscripten לא עובר מיניפיקציה. מתאים לניפוי באגים.-
-O3
: אופטימיזציה אגרסיבית לביצועים. -
-Os
: אופטימיזציה אגרסיבית של הביצועים והגודל כקריטריון משני. -
-Oz
: אופטימיזציה אגרסיבית של הגודל, גם אם זה בא על חשבון הביצועים.
באינטרנט, בדרך כלל מומלץ להשתמש ב--Os
.
לפקודה emcc
יש מגוון רחב של אפשרויות משלה. שימו לב ש-emcc אמור להיות "תחליף מוכן לשימוש עבור קומפיילרים כמו GCC או clang". לכן, סביר להניח שכל הדגלים שאתם מכירים מ-GCC יוטמעו גם ב-emcc. הדגל -s
הוא מיוחד כי הוא מאפשר לנו להגדיר את Emscripten באופן ספציפי. כל האפשרויות הזמינות מופיעות בקובץ settings.js
של Emscripten, אבל יכול להיות שהקובץ הזה יהיה מורכב מדי. זו רשימה של דגלי Emscripten
שנראים לי הכי חשובים למפתחי אתרים:
--bind
enables embind.-
-s STRICT=1
מפסיקה את התמיכה בכל אפשרויות הבנייה שהוצאו משימוש. כך אפשר לוודא שהקוד נבנה בצורה שתואמת לגרסאות עתידיות. -
-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>
(כאן אפשר לראות את כל הקבצים).
כדי לבנות את הכול, מריצים
$ npm install
$ npm run build
$ npm run serve
אם תעברו אל localhost:8080, הפלט הבא יוצג במסוף של כלי הפיתוח:

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

בנוסף, תשימו לב שתהליך הבנייה אורך זמן רב. יכולות להיות סיבות שונות לכך שזמני הבנייה ארוכים. במקרה של libvpx, התהליך ארוך כי הוא יוצר קומפילציה של מקודד ומפענח גם ל-VP8 וגם ל-VP9 בכל פעם שמריצים את פקודת ה-build, גם אם קובצי המקור לא השתנו. גם שינוי קטן ב-my-module.cpp
ייקח הרבה זמן. יהיה מאוד מועיל לשמור את ארטיפקטים של 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
לפעמים, כדי לבנות ספריות צריך להשתמש בכלים נוספים. אם יחסי התלות האלה לא קיימים בסביבת הבנייה שסופקה על ידי קובץ האימג' של Docker, צריך להוסיף אותם בעצמכם. לדוגמה, נניח שאתם רוצים ליצור גם את התיעוד של libvpx באמצעות doxygen. Doxygen לא זמין בתוך מאגר Docker, אבל אפשר להתקין אותו באמצעות apt
.
אם תעשו את זה ב-build.sh
, תצטרכו להוריד ולהתקין מחדש את doxygen בכל פעם שתרצו ליצור את הספרייה. בנוסף לבזבוז, לא תוכלו לעבוד על הפרויקט במצב אופליין.
במקרה כזה, כדאי ליצור קובץ אימג' משלכם ב-Docker. קובצי אימג' של Docker נוצרים על ידי כתיבת Dockerfile
שמתאר את שלבי הבנייה. קובצי Dockerfile הם די חזקים ויש להם הרבה פקודות, אבל ברוב המקרים אפשר להסתפק בשימוש בפקודות 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 בכל פעם שאתם בונים את האפליקציה. עכשיו אתם יכולים להעביר את הוראות הבנייה של 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 mounts כשמריצים את docker build
. כתוצאה מכך, אין יותר צורך ב-napa.