Emscripten وnpm

كيف يتم دمج WebAssembly في هذا الإعداد؟ في هذه المقالة، سنوضّح ذلك باستخدام C/C++ وEmscripten كمثال.

غالبًا ما يتم استخدام WebAssembly (wasm) كطريقة لتحسين الأداء أو لتشغيل قاعدة بيانات رمز C++ الحالية على الويب. من خلال squoosh.app، أردنا توضيح أنّ هناك منظورًا ثالثًا على الأقل لواسم: الاستفادة من الأنظمة المتكاملة الضخمة الخاصة بلغات البرمجة الأخرى. باستخدام Emscripten، يمكنك استخدام رمز C/C++، ويحتوي Rust على دعم wasm مضمّن، ويعمل فريق Go على ذلك أيضًا. ونؤكّد أنّه ستتوفّر قريبًا بلغات أخرى.

في هذه السيناريوهات، لا يشكّل ملف wasm العنصر الرئيسي في تطبيقك، بل هو قطعة من أحجية: وحدة أخرى. يتضمّن تطبيقك حاليًا JavaScript وCSS ومواد عرض الصور و نظام إنشاء يركّز على الويب، وقد يتضمّن أيضًا إطار عمل مثل 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.

بوجه عام، يجب التعامل مع عناصر التصميم التي ينشئها 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 داخل الحاوية مع مشروعك الفعلي. تُعرف هذه الأدلة المطابقة باسم "عمليات ربط التثبيت".

لنلقِ نظرة على 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 إلى وضع القشرة في وضع "توقّف سريع". إذا أدّت أي أوامر في النص البرمجي إلى ظهور خطأ، يتم إيقاف النص البرمجي بالكامل على الفور. يمكن أن يكون ذلك مفعّلاً بشكلٍ هائل، لأنّ الإخراج الأخير للنص البرمجي سيكون دائمًا رسالة إشارة إلى نجاح الإنشاء أو الخطأ الذي أدّى إلى تعذّر الإنشاء.

باستخدام عبارات export، يمكنك تحديد قيم متغيرَين من متغيّرات البيئة. تتيح لك هذه الأدوات تمرير مَعلمات سطر أوامر إضافية إلى مجمع لغة C (CFLAGS) ومجمع لغة C++ (CXXFLAGS) ورابط LDFLAGS. وتتلقّى جميعها إعدادات المحسِّن من خلال OPTIMIZE للتأكّد من أنّه تتم تحسين كل العناصر بالطريقة نفسها. هناك قيمتان محتملتان للمتغيّر OPTIMIZE:

  • -O0: لا تجرِ أي تحسينات. ولا يتمّ إزالة أيّ رموز برمجية غير مفيدة، ولا تُصغّر Emscripten رمز JavaScript الذي تنشئه أيضًا. مناسبة لتصحيح الأخطاء
  • -O3: تحسين الأداء بشكلٍ فعّال
  • -Os: تحسين الأداء والحجم بشكلٍ مكثّف كأحد المعايير الثانوية
  • -Oz: تحسين الحجم بشكل كبير، مع التضحية بالأداء إذا لزم الأمر

بالنسبة إلى الويب، أنصح باستخدام -Os في أغلب الأحيان.

يتضمّن الأمر emcc العديد من الخيارات الخاصة به. يُرجى العِلم أنّ emcc هو من المفترض أن يكون "بديلاً فوريًا للمنشِئين مثل GCC أو clang". وبالتالي، من المرجّح أن تُنفِّذ emcc أيضًا كل العلامات التي قد تعرفها من GCC. إنّ العلامة -s خاصة لأنها تسمح لنا بضبط Emscripten على وجه التحديد. يمكن العثور على جميع الخيارات المتاحة في ملف Emscripten settings.js، ولكن قد يكون هذا الملف مربكًا للغاية. في ما يلي قائمة بعلامات Emscripten التي أعتقد أنّها الأكثر أهمية لمطوّري الويب:

  • --bind تفعِّل embind.
  • -s STRICT=1 لن تتوفّر جميع خيارات الإصدار المتوقّفة نهائيًا. يضمن ذلك إنشاء الرمز بطريقة متوافقة مع الإصدارات المستقبلية.
  • يسمح -s ALLOW_MEMORY_GROWTH=1 بزيادة الذاكرة تلقائيًا إذا لزم الأمر. في وقت كتابة هذه المقالة، سيخصّص Emscripten 16 ميغابايت من الذاكرة في البداية. عندما يخصّص الرمز البرمجي أجزاء من الذاكرة، يحدّد هذا الخيار ما إذا كانت هذه العمليات ستؤدي إلى تعذُّر تشغيل وحدة 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++ عمليات نظام الملفات. ويعمل على إجراء بعض التحليلات على الرمز البرمجي الذي يُجمِّعه لتحديد ما إذا كان سيتم تضمين emulatior لنظام الملفات في رمز glue أم لا. في بعض الأحيان، يمكن أن يخطئ هذا التحليل، ما يؤدي إلى تحميل 70 كيلوبايت من رمز إضافي لربط لمحاكاة نظام ملفات قد لا تحتاج إليها. باستخدام -s FILESYSTEM=0، يمكنك إجبار Emscripten على عدم تضمين هذا الرمز.
  • سيؤدي -g4 إلى تضمين Emscripten لمعلومات تصحيح الأخطاء في .wasm و أيضًا إلى إنشاء ملف خرائط مصادر لمكوّن wasm. يمكنك الاطّلاع على مزيد من المعلومات حول debugging باستخدام Emscripten في قسم debugging.

ها أنت ذا. لاختبار هذا الإعداد، لننشئ 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 إلى عرض الإخراج التالي فيconsole DevTools:

أدوات المطوّرين تعرض رسالة تم طباعتها من خلال C++ وEmscripten

إضافة رمز C/C++ كعنصر تابع

إذا كنت تريد إنشاء مكتبة C/C++ لتطبيقك على الويب، يجب أن يكون رمزها البرمجي جزءًا من مشروعك. يمكنك إضافة الرمز إلى مستودع مشروعك يدويًا أو يمكنك استخدام npm لإدارة هذا النوع من التبعيات أيضًا. لنفترض أنّني أريد استخدام libvpx في تطبيق الويب الخاص بي. libvpx هي مكتبة C++ لتشفير الصور باستخدام VP8، وهو برنامج الترميز المستخدَم في ملفات .webm. ومع ذلك، لا يتوفّر libvpx في npm ولا يتضمّن package.json، لذا لا يمكنني تثبيته باستخدام npm مباشرةً.

لحلّ هذه المشكلة، يمكنك استخدام مكتبة 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. لهذا الغرض، تتوفّر الأمران 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.

ستلاحظ أيضًا أنّ عملية الإنشاء تستغرق وقتًا طويلاً. يمكن أن يختلف سبب مدّة الإنشاء الطويلة. في ما يتعلّق بمكتبة libvpx، يستغرق الأمر وقتًا طويلاً لأنّه يتم تجميع برنامج ترميز وفك ترميز لكل من VP8 وVP9 في كل مرة يتم فيها تنفيذ أمر الإنشاء، حتى لو لم تتغيّر الملفات المصدر. سيستغرق إنشاء أي تغيير صغير في 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 ...

(إليك ملخّص يحتوي على جميع الملفات).

يسمح لنا الأمر eval بضبط متغيّرات البيئة من خلال تمرير المَعلمات إلى نصّ البناء. سيتخطّى الأمر test إنشاء libvpx إذا تم ضبط $SKIP_LIBVPX (على أي قيمة).

يمكنك الآن تجميع المكوّن الإضافي ولكن تخطّي إعادة إنشاء libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

تخصيص بيئة الإصدار

تعتمد المكتبات أحيانًا على أدوات إضافية لعملية الإنشاء. إذا كانت هذه التبعيات غير متوفّرة في بيئة الإنشاء التي تقدّمها صورة Docker، عليك إضافتها بنفسك. على سبيل المثال، لنفترض أنّك تريد أيضًا إنشاء مستندات libvpx باستخدام doxygen. لا يتوفر Doxygen داخل حاوية Docker، ولكن يمكنك تثبيته باستخدام apt.

إذا أردت إجراء ذلك في build.sh، عليك إعادة تنزيل وإعادة تثبيت doxygen في كل مرة تريد فيها إنشاء مكتبتك. ولن يؤدي ذلك فقط إلى إهدار المساحة، بل سيمنعك أيضًا من العمل على مشروعك أثناء عدم الاتصال بالإنترنت.

في هذه الحالة، من المنطقي إنشاء صورة Docker الخاصة بك. يتم إنشاء صور Docker من خلال كتابة ملف Dockerfile يصف خطوات الإنشاء. إنّ ملفات Dockerfiles مفيدة جدًا وتحتوي على العديد من الطلبات، ولكن في معظم الأحيان، يمكنك استخدام FROM وRUN وADD فقط. في هذه الحالة:

FROM trzeci/emscripten

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

باستخدام FROM، يمكنك تحديد صورة Docker التي تريد استخدامها كنقطة بداية. اخترتُ trzeci/emscripten كأساس، وهي الصورة التي كنت تستخدمها طوال الوقت. باستخدام RUN، يمكنك توجيه Docker لتشغيل أوامر shell داخل الحاوية. إنّ أي تغييرات تجريها هذه الأوامر على الحاوية أصبحت الآن جزءًا من صورة 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",
    // ...
    },
    // ...
}

(إليك ملخّص يحتوي على جميع الملفات).

سيؤدي ذلك إلى إنشاء صورة Docker، ولكن فقط إذا لم يتم إنشاؤها بعد. بعد ذلك، يتم تنفيذ كل شيء كما كان من قبل، ولكن تتوفّر الآن في بيئة الإنشاء doxygen الأمر الذي سيؤدي إلى إنشاء مستندات 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

(إليك ملخّص يحتوي على جميع الملفات).

يُرجى العِلم أنّك بحاجة إلى تثبيت git يدويًا واستنساخ libvpx لأنّه لا يتوفّر لديك عمليات ربط عمليات التثبيت عند تشغيل docker build. نتيجةً لذلك، لن يكون عليك استخدام napa بعد الآن.