Emscripten وnpm

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

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

  • تتيح السمة --bind الربط.
  • -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>

(في ما يلي 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.

يمكنك الآن توسيع نص الإصدار البرمجي لإنشاء 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 ...

(إليك gist عن جميع الملفات).

يسمح لنا الأمر 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 تشغيل أوامر الغلاف داخل الحاوية. ومهما كانت التغييرات التي تجريها هذه الأوامر على الحاوية، أصبح الآن جزءًا من صورة 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 متاحًا في بيئة الإنشاء، ما يؤدي إلى إنشاء مستندات 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 وclone libvpx يدويًا لأنّه ليس لديك حاملات ربط عند تشغيل docker build. من الآثار الجانبية، ليست هناك حاجة إلى نابا بعد الآن.