Emscripten وnpm

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

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

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

بشكل عام، يجب التعامل مع عناصر البناء التي ينتجها 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". لذلك، من المرجّح أن يتم تنفيذ جميع العلامات التي قد تعرفها من GCC بواسطة emcc أيضًا. العلامة -s مميزة لأنّها تتيح لنا ضبط إعدادات Emscripten بشكل خاص. يمكن العثور على جميع الخيارات المتاحة في settings.js من Emscripten، ولكن قد يكون هذا الملف كبيرًا جدًا. في ما يلي قائمة بعلامات 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++ عمليات نظام الملفات. ويجري بعض التحليلات على الرمز البرمجي الذي يجمّعه لتحديد ما إذا كان سيتم تضمين محاكاة نظام الملفات في الرمز البرمجي الوسيط أم لا. ومع ذلك، قد يكون هذا التحليل غير دقيق في بعض الأحيان، وقد تدفع تكلفة إضافية كبيرة تبلغ 70 كيلوبايت مقابل رمز ربط إضافي لمحاكاة نظام ملفات قد لا تحتاج إليه. باستخدام -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 إلى عرض الناتج التالي في وحدة تحكّم أدوات المطوّرين:

تعرض &quot;أدوات مطوّري البرامج&quot; رسالة تمت طباعتها باستخدام 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، التي تستخدم 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 جديدًا، وأنّ الصفحة التجريبية ستعرض الثابت بالفعل:

تعرض &quot;أدوات مطوّلي البرامج&quot; إصدار واجهة التطبيق الثنائية (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 يصف خطوات الإنشاء. ملفات Dockerfile فعّالة جدًا وتتضمّن الكثير من الأوامر، ولكن في معظم الأحيان، يمكنك الاكتفاء باستخدام 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.