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