Emscripten و npm

چگونه WebAssembly را در این تنظیمات ادغام می کنید؟ در این مقاله قصد داریم این موضوع را با C/C++ و Emscripten به عنوان مثال بررسی کنیم.

WebAssembly (wasm) اغلب به عنوان یک عملکرد اولیه یا راهی برای اجرای پایگاه کد C++ موجود شما در وب در نظر گرفته می شود. با squoosh.app ، می‌خواستیم نشان دهیم که حداقل دیدگاه سومی برای wam وجود دارد: استفاده از اکوسیستم‌های عظیم سایر زبان‌های برنامه‌نویسی. با Emscripten ، می‌توانید از کد C/C++ استفاده کنید، Rust دارای پشتیبانی wasm است و تیم Go نیز روی آن کار می‌کند . من مطمئن هستم که بسیاری از زبان های دیگر دنبال خواهند شد.

در این سناریوها، wasm مرکز برنامه شما نیست، بلکه یک قطعه پازل است: یک ماژول دیگر. برنامه شما قبلاً دارای جاوا اسکریپت، CSS، دارایی های تصویر، یک سیستم ساخت وب محور و شاید حتی چارچوبی مانند React است. چگونه WebAssembly را در این تنظیمات ادغام می کنید؟ در این مقاله قصد داریم این موضوع را با C/C++ و Emscripten به عنوان مثال بررسی کنیم.

داکر

من متوجه شدم که Docker هنگام کار با Emscripten بسیار ارزشمند است. کتابخانه های C/C++ اغلب برای کار با سیستم عاملی که روی آن ساخته شده اند نوشته می شوند. داشتن یک محیط ثابت فوق العاده مفید است. با Docker شما یک سیستم لینوکس مجازی دریافت می کنید که از قبل برای کار با Emscripten تنظیم شده است و تمام ابزارها و وابستگی ها را نصب کرده است. اگر چیزی کم است، می‌توانید آن را نصب کنید بدون اینکه نگران تأثیر آن بر دستگاه یا سایر پروژه‌هایتان باشید. اگر مشکلی پیش آمد، ظرف را دور بیندازید و دوباره شروع کنید. اگر یک بار کار کند، می توانید مطمئن باشید که به کار خود ادامه می دهد و نتایج یکسانی را ایجاد می کند.

Docker Registry دارای یک تصویر Emscripten توسط trzeci است که من به طور گسترده از آن استفاده کرده ام.

ادغام با npm

در اکثر موارد، نقطه ورود به پروژه وب package.json است. طبق قرارداد، اکثر پروژه ها را می توان با npm install && npm run build ساخت.

به طور کلی، مصنوعات ساخت تولید شده توسط Emscripten (یک .js و یک فایل .wasm ) باید فقط به عنوان یک ماژول جاوا اسکریپت دیگر و فقط یک دارایی دیگر در نظر گرفته شوند. فایل جاوا اسکریپت را می‌توان توسط یک باندلر مانند 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 به داکر می گوید که با استفاده از تصویر trzeci/emscripten یک ظرف جدید را بچرخاند و دستور ./build.sh را اجرا کند. build.sh یک اسکریپت پوسته است که در ادامه می خواهید بنویسید! --rm به داکر می گوید که پس از اجرای کانتینر آن را حذف کند. به این ترتیب، در طول زمان مجموعه ای از تصاویر ماشین قدیمی ایجاد نمی کنید. -v $(pwd):/src به این معنی است که می‌خواهید داکر دایرکتوری فعلی ( $(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 پوسته را در حالت "fail fast" قرار می دهد. اگر هر دستوری در اسکریپت خطایی را برگرداند، کل اسکریپت بلافاصله لغو می شود. این می تواند فوق العاده مفید باشد زیرا آخرین خروجی اسکریپت همیشه یک پیام موفقیت آمیز یا خطایی است که باعث شکست ساخت شده است.

با دستورات export ، مقادیر چند متغیر محیطی را تعریف می کنید. آنها به شما امکان می دهند پارامترهای اضافی خط فرمان را به کامپایلر C ( CFLAGS )، کامپایلر C++ ( CXXFLAGS ) و پیوند دهنده ( LDFLAGS ) منتقل کنید. همه آنها تنظیمات بهینه ساز را از طریق OPTIMIZE دریافت می کنند تا مطمئن شوند که همه چیز به همان صورت بهینه می شود. چند مقدار ممکن برای متغیر OPTIMIZE وجود دارد:

  • -O0 : هیچ بهینه سازی انجام ندهید. هیچ کد مرده ای حذف نمی شود و Emscripten کد جاوا اسکریپتی که منتشر می کند را نیز کوچک نمی کند. برای اشکال زدایی خوبه
  • -O3 : برای عملکرد به طور تهاجمی بهینه سازی کنید.
  • -Os : به طور تهاجمی برای عملکرد و اندازه به عنوان یک معیار ثانویه بهینه سازی کنید.
  • -Oz : به شدت برای اندازه بهینه سازی کنید، در صورت لزوم عملکرد را قربانی کنید.

برای وب، من بیشتر -Os توصیه می کنم.

دستور emcc گزینه های بی شماری دارد. توجه داشته باشید که emcc قرار است "جایگزینی برای کامپایلرهایی مانند GCC یا clang" باشد. بنابراین همه پرچم هایی که ممکن است از GCC بشناسید به احتمال زیاد توسط emcc نیز پیاده سازی خواهند شد. پرچم -s از این نظر خاص است که به ما اجازه می دهد Emscripten را به طور خاص پیکربندی کنیم. همه گزینه های موجود را می توان در settings.js Emscripten.js یافت، اما این فایل می تواند بسیار زیاد باشد. در اینجا لیستی از پرچم‌های Emscripten که فکر می‌کنم برای توسعه‌دهندگان وب بسیار مهم هستند، آمده است:

  • --bind امکان ebind را می دهد.
  • -s STRICT=1 پشتیبانی از تمام گزینه های ساخت منسوخ را کاهش می دهد. این تضمین می کند که کد شما به شیوه ای سازگار با جلو ساخته می شود.
  • -s ALLOW_MEMORY_GROWTH=1 به حافظه اجازه می دهد تا در صورت لزوم به طور خودکار رشد کند. در زمان نگارش، Emscripten در ابتدا 16 مگابایت حافظه را اختصاص خواهد داد. از آنجایی که کد شما تکه‌هایی از حافظه را تخصیص می‌دهد، این گزینه تصمیم می‌گیرد که آیا این عملیات باعث می‌شود که کل ماژول wasm با اتمام حافظه از کار بیفتد یا اینکه کد چسب اجازه دارد کل حافظه را برای تطبیق با تخصیص گسترش دهد.
  • -s MALLOC=... پیاده سازی malloc() را برای استفاده انتخاب می کند. emmalloc یک پیاده سازی malloc() کوچک و سریع است که به طور خاص برای Emscripten است. جایگزین dlmalloc است که یک پیاده سازی کامل malloc() است. فقط زمانی باید به dlmalloc سوئیچ کنید که مرتباً اشیاء کوچک زیادی را تخصیص می دهید یا می خواهید از threading استفاده کنید.
  • -s EXPORT_ES6=1 کد جاوا اسکریپت را به یک ماژول ES6 با یک صادرات پیش‌فرض تبدیل می‌کند که با هر باندلری کار می‌کند. همچنین نیاز به تنظیم -s MODULARIZE=1 دارد.

پرچم‌های زیر همیشه ضروری نیستند یا فقط برای اهداف اشکال‌زدایی مفید هستند:

  • -s FILESYSTEM=0 پرچمی است که به Emscripten مربوط می شود و زمانی که کد C/C++ شما از عملیات سیستم فایل استفاده می کند، می تواند یک فایل سیستم را برای شما شبیه سازی کند. برخی از تحلیل‌ها را روی کدی که کامپایل می‌کند انجام می‌دهد تا تصمیم بگیرد که آیا شبیه‌سازی سیستم فایل را در کد چسب قرار دهد یا خیر. با این حال، گاهی اوقات، این تجزیه و تحلیل ممکن است اشتباه کند و شما 70 کیلوبایت کد چسب اضافی را برای شبیه سازی سیستم فایلی که ممکن است به آن نیاز نداشته باشید، پرداخت کنید. با -s FILESYSTEM=0 می توانید Emscripten را مجبور کنید که این کد را وارد نکند.
  • -g4 باعث می‌شود که Emscripten اطلاعات اشکال‌زدایی را در .wasm . و همچنین یک فایل نقشه منبع برای ماژول wam منتشر کند. می توانید اطلاعات بیشتری در مورد اشکال زدایی با 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 باید خروجی زیر را در کنسول DevTools به شما نشان دهد:

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 کمک کند. برای این منظور دستورات 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 ...

کتابخانه AC/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 جدید می سازد و صفحه نمایشی در واقع خروجی ثابت را خواهد داشت:

DevTools یک نسخه 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 به ما این امکان را می دهد که متغیرهای محیط را با ارسال پارامترها به اسکریپت ساخت، تنظیم کنیم. اگر $SKIP_LIBVPX (به هر مقداری) تنظیم شود، دستور test از ساخت 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",
    // ...
    },
    // ...
}

(در اینجا خلاصه ای از تمام فایل ها وجود دارد.)

این تصویر Docker شما را می سازد، اما فقط در صورتی که هنوز ساخته نشده باشد. سپس همه چیز مانند قبل اجرا می شود، اما اکنون محیط ساخت دستور doxygen را در دسترس دارد که باعث می شود مستندات libvpx نیز ساخته شود.

نتیجه

تعجب آور نیست که کد C/C++ و npm یک تناسب طبیعی نیستند، اما می‌توانید با برخی ابزارهای اضافی و ایزوله‌ای که Docker ارائه می‌کند، آن را کاملاً راحت کار کنید. این تنظیم برای هر پروژه ای کار نمی کند، اما نقطه شروع مناسبی است که می توانید آن را برای نیازهای خود تنظیم کنید. اگر پیشرفت هایی دارید لطفا به اشتراک بگذارید.

ضمیمه: استفاده از لایه های تصویر داکر

راه حل جایگزین این است که تعداد بیشتری از این مشکلات را با رویکرد هوشمند Docker و Docker در کش کردن محصور کنیم. Docker Dockerfiles را گام به گام اجرا می کند و به نتیجه هر مرحله یک تصویر از خود اختصاص می دهد. این تصاویر میانی اغلب "لایه" نامیده می شوند. اگر دستوری در Dockerfile تغییر نکرده باشد، زمانی که شما در حال ساختن مجدد Dockerfile هستید، Docker عملاً آن مرحله را دوباره اجرا نخواهد کرد. در عوض لایه را از آخرین باری که تصویر ساخته شده استفاده مجدد می کند.

پیش از این، هر بار که برنامه خود را می‌سازید، مجبور بودید تلاش‌هایی را انجام دهید تا 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 پایه‌های bind ندارید. به عنوان یک عارضه جانبی، دیگر نیازی به ناپا نیست.