Emscripten dan npm

Bagaimana cara mengintegrasikan WebAssembly ke dalam penyiapan ini? Dalam artikel ini, kita akan menyelesaikannya dengan C/C++ dan Emscripten sebagai contoh.

WebAssembly (wasm) sering kali dijelaskan sebagai primitif performa atau cara untuk menjalankan codebase C++ yang ada di web. Dengan squoosh.app, kami ingin menunjukkan bahwa setidaknya ada perspektif ketiga untuk wasm: memanfaatkan ekosistem besar bahasa pemrograman lain. Dengan Emscripten, Anda dapat menggunakan kode C/C++, Rust memiliki dukungan wasm bawaan, dan tim Go juga sedang mengerjakannya. Saya yakin banyak bahasa lain akan menyusul.

Dalam skenario ini, wasm bukanlah pusat aplikasi Anda, melainkan bagian dari teka-teki: modul lain. Aplikasi Anda sudah memiliki JavaScript, CSS, aset gambar, sistem build yang berfokus pada web, dan mungkin juga framework seperti React. Bagaimana cara mengintegrasikan WebAssembly ke dalam penyiapan ini? Dalam artikel ini, kita akan memecahkan masalah ini dengan C/C++ dan Emscripten sebagai contoh.

Docker

Saya merasa Docker sangat berharga saat bekerja dengan Emscripten. Library C/C++ sering ditulis untuk bekerja dengan sistem operasi tempat library tersebut dibangun. Memiliki lingkungan yang konsisten sangat membantu. Dengan Docker, Anda mendapatkan sistem Linux virtual yang sudah disiapkan untuk bekerja dengan Emscripten dan telah menginstal semua alat dan dependensi. Jika ada yang kurang, Anda cukup menginstalnya tanpa perlu mengkhawatirkan dampaknya terhadap komputer Anda sendiri atau proyek Anda yang lain. Jika terjadi kesalahan, buang wadah dan mulai lagi. Jika berfungsi sekali, Anda dapat yakin bahwa fungsi tersebut akan terus berfungsi dan menghasilkan hasil yang identik.

Docker Registry memiliki image Emscripten oleh trzeci yang telah saya gunakan secara ekstensif.

Integrasi dengan npm

Dalam sebagian besar kasus, titik entri ke project web adalah package.json npm. Berdasarkan konvensi, sebagian besar project dapat dibangun dengan npm install && npm run build.

Secara umum, artefak build yang dihasilkan oleh Emscripten (file .js dan .wasm ) harus diperlakukan sebagai modul JavaScript lain dan aset lain. File JavaScript dapat ditangani oleh bundler seperti webpack atau rollup, dan file wasm harus diperlakukan seperti aset biner yang lebih besar lainnya, seperti gambar.

Oleh karena itu, artefak build Emscripten harus dibuat sebelum proses build "normal" Anda dimulai:

{
    "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",
    // ...
    },
    // ...
}

Tugas build:emscripten baru dapat memanggil Emscripten secara langsung, tetapi seperti yang disebutkan sebelumnya, sebaiknya gunakan Docker untuk memastikan lingkungan build konsisten.

docker run ... trzeci/emscripten ./build.sh memberi tahu Docker untuk meluncurkan container baru menggunakan image trzeci/emscripten dan menjalankan perintah ./build.sh. build.sh adalah skrip shell yang akan Anda tulis selanjutnya. --rm memberi tahu Docker untuk menghapus container setelah selesai berjalan. Dengan begitu, Anda tidak akan membuat koleksi image mesin yang sudah usang seiring waktu. -v $(pwd):/src berarti Anda ingin Docker "mencerminkan" direktori saat ini ($(pwd)) ke /src di dalam container. Setiap perubahan yang Anda buat pada file di direktori /src di dalam penampung akan dicerminkan ke project sebenarnya. Direktori yang dicerminkan ini disebut "bind mount".

Mari kita lihat 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 "============================================="

Ada banyak hal yang perlu dianalisis di sini.

set -e menempatkan shell ke mode "gagal cepat". Jika ada perintah dalam skrip yang menampilkan error, seluruh skrip akan langsung dibatalkan. Hal ini dapat sangat membantu karena output terakhir skrip akan selalu berupa pesan keberhasilan atau error yang menyebabkan build gagal.

Dengan pernyataan export, Anda menentukan nilai beberapa variabel lingkungan. Anda dapat meneruskan parameter command line tambahan ke compiler C (CFLAGS), compiler C++ (CXXFLAGS), dan linker (LDFLAGS). Semuanya menerima setelan pengoptimal melalui OPTIMIZE untuk memastikan semuanya dioptimalkan dengan cara yang sama. Ada beberapa kemungkinan nilai untuk variabel OPTIMIZE:

  • -O0: Jangan lakukan pengoptimalan apa pun. Tidak ada kode mati yang dihilangkan, dan Emscripten juga tidak meminifikasi kode JavaScript yang dipancarkannya. Bagus untuk proses debug.
  • -O3: Mengoptimalkan performa secara agresif.
  • -Os: Mengoptimalkan secara agresif untuk performa dan ukuran sebagai kriteria sekunder.
  • -Oz: Mengoptimalkan ukuran secara agresif, mengorbankan performa jika perlu.

Untuk web, saya sebagian besar merekomendasikan -Os.

Perintah emcc memiliki banyak opsi tersendiri. Perhatikan bahwa emcc seharusnya menjadi "pengganti langsung untuk compiler seperti GCC atau clang". Jadi, semua flag yang mungkin Anda ketahui dari GCC kemungkinan besar akan diterapkan oleh emcc juga. Flag -s bersifat khusus karena memungkinkan kita mengonfigurasi Emscripten secara khusus. Semua opsi yang tersedia dapat ditemukan di settings.js Emscripten, tetapi file tersebut bisa sangat banyak. Berikut daftar flag Emscripten yang menurut saya paling penting bagi developer web:

  • --bind memungkinkan embind.
  • -s STRICT=1 menghentikan dukungan untuk semua opsi build yang tidak digunakan lagi. Hal ini memastikan bahwa kode Anda dibangun dengan cara yang kompatibel ke depan.
  • -s ALLOW_MEMORY_GROWTH=1 memungkinkan memori dikembangkan secara otomatis jika diperlukan. Pada saat penulisan, Emscripten akan mengalokasikan 16 MB memori pada awalnya. Saat kode Anda mengalokasikan potongan memori, opsi ini akan memutuskan apakah operasi ini akan membuat seluruh modul wasm gagal saat memori habis, atau apakah kode lem akan diizinkan untuk memperluas total memori guna mengakomodasi alokasi.
  • -s MALLOC=... memilih penerapan malloc() yang akan digunakan. emmalloc adalah implementasi malloc() kecil dan cepat khusus untuk Emscripten. Alternatifnya adalah dlmalloc, implementasi malloc() yang lengkap. Anda hanya perlu beralih ke dlmalloc jika Anda sering mengalokasikan banyak objek kecil atau jika Anda ingin menggunakan threading.
  • -s EXPORT_ES6=1 akan mengubah kode JavaScript menjadi modul ES6 dengan ekspor default yang berfungsi dengan bundler apa pun. Juga memerlukan -s MODULARIZE=1 untuk ditetapkan.

Flag berikut tidak selalu diperlukan atau hanya berguna untuk tujuan proses debug:

  • -s FILESYSTEM=0 adalah tanda yang terkait dengan Emscripten dan kemampuannya untuk mengemulasi sistem file untuk Anda saat kode C/C++ Anda menggunakan operasi sistem file. Compiler melakukan beberapa analisis pada kode yang dikompilasi untuk memutuskan apakah akan menyertakan emulasi sistem file dalam kode pengikat atau tidak. Namun, terkadang analisis ini bisa salah dan Anda membayar 70 kB yang cukup besar dalam kode lem tambahan untuk emulasi sistem file yang mungkin tidak Anda perlukan. Dengan -s FILESYSTEM=0, Anda dapat memaksa Emscripten untuk tidak menyertakan kode ini.
  • -g4 akan membuat Emscripten menyertakan informasi pen-debug-an dalam .wasm dan juga memancarkan file peta sumber untuk modul wasm. Anda dapat membaca selengkapnya tentang pen-debugan dengan Emscripten di bagian pen-debugan.

Dan, berhasil! Untuk menguji penyiapan ini, mari kita buat my-module.cpp kecil:

    #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);
    }

Dan 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>

(Berikut adalah gist yang berisi semua file.)

Untuk membangun semuanya, jalankan

$ npm install
$ npm run build
$ npm run serve

Membuka localhost:8080 akan menampilkan output berikut di konsol DevTools:

DevTools menampilkan pesan yang dicetak melalui C++ dan Emscripten.

Menambahkan kode C/C++ sebagai dependensi

Jika ingin membuat library C/C++ untuk aplikasi web, Anda harus menyertakan kodenya sebagai bagian dari project. Anda dapat menambahkan kode ke repositori project secara manual atau menggunakan npm untuk mengelola jenis dependensi ini. Misalnya, saya ingin menggunakan libvpx di aplikasi web saya. libvpx adalah library C++ untuk mengenkode gambar dengan VP8, codec yang digunakan dalam file .webm. Namun, libvpx tidak ada di npm dan tidak memiliki package.json, jadi saya tidak dapat menginstalnya menggunakan npm secara langsung.

Untuk keluar dari masalah ini, ada napa. napa memungkinkan Anda menginstal URL repositori git apa pun sebagai dependensi ke folder node_modules Anda.

Instal napa sebagai dependensi:

$ npm install --save napa

dan pastikan untuk menjalankan napa sebagai skrip penginstalan:

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

Saat Anda menjalankan npm install, napa akan meng-clone repositori GitHub libvpx ke node_modules Anda dengan nama libvpx.

Sekarang Anda dapat memperluas skrip build untuk membangun libvpx. libvpx menggunakan configure dan make untuk dibangun. Untungnya, Emscripten dapat membantu memastikan bahwa configure dan make menggunakan compiler Emscripten. Untuk tujuan ini, ada perintah wrapper emconfigure dan 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 ...

Library C/C++ dibagi menjadi dua bagian: header (biasanya file .h atau .hpp) yang menentukan struktur data, class, konstanta, dll. yang diekspos library dan library sebenarnya (biasanya file .so atau .a). Untuk menggunakan konstanta VPX_CODEC_ABI_VERSION library dalam kode Anda, Anda harus menyertakan file header library menggunakan pernyataan #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;
}

Masalahnya adalah compiler tidak tahu di mana harus mencari vpxenc.h. Inilah fungsi tanda -I. Ini memberi tahu compiler direktori mana yang harus diperiksa untuk file header. Selain itu, Anda juga perlu memberikan file library yang sebenarnya kepada compiler:

# ... 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 ...

Jika Anda menjalankan npm run build sekarang, Anda akan melihat bahwa proses ini membuat .js baru dan file .wasm baru serta halaman demo akan menampilkan konstanta:

DevTools
menampilkan versi ABI libvpx yang dicetak melalui emscripten.

Anda juga akan melihat bahwa proses build memerlukan waktu yang lama. Alasan waktu build yang lama dapat bervariasi. Dalam kasus libvpx, prosesnya membutuhkan waktu yang lama karena mengompilasi encoder dan decoder untuk VP8 dan VP9 setiap kali Anda menjalankan perintah build, meskipun file sumber tidak berubah. Bahkan perubahan kecil pada my-module.cpp Anda akan memerlukan waktu lama untuk dibuat. Akan sangat bermanfaat untuk menyimpan artefak build libvpx setelah dibangun untuk pertama kalinya.

Salah satu cara untuk melakukannya adalah dengan menggunakan variabel lingkungan.

# ... 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 ...

(Berikut adalah gist yang berisi semua file.)

Perintah eval memungkinkan kita menetapkan variabel lingkungan dengan meneruskan parameter ke skrip build. Perintah test akan melewati pembuatan libvpx jika $SKIP_LIBVPX ditetapkan (ke nilai apa pun).

Sekarang Anda dapat mengompilasi modul, tetapi melewati pembangunan ulang libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Menyesuaikan lingkungan build

Terkadang library bergantung pada alat tambahan untuk di-build. Jika dependensi ini tidak ada di lingkungan build yang disediakan oleh image Docker, Anda harus menambahkannya sendiri. Sebagai contoh, misalkan Anda juga ingin membuat dokumentasi libvpx menggunakan doxygen. Doxygen tidak tersedia di dalam container Docker Anda, tetapi Anda dapat menginstalnya menggunakan apt.

Jika Anda melakukannya di build.sh, Anda akan mendownload ulang dan menginstal ulang doxygen setiap kali Anda ingin membuat library. Tindakan tersebut tidak hanya sia-sia, tetapi juga akan menghentikan Anda mengerjakan proyek saat offline.

Di sini, Anda sebaiknya membuat image Docker sendiri. Image Docker dibuat dengan menulis Dockerfile yang menjelaskan langkah-langkah build. Dockerfile cukup canggih dan memiliki banyak perintah, tetapi sebagian besar waktu Anda dapat menggunakan FROM, RUN, dan ADD saja. Dalam hal ini:

FROM trzeci/emscripten

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

Dengan FROM, Anda dapat mendeklarasikan image Docker yang ingin digunakan sebagai titik awal. Saya memilih trzeci/emscripten sebagai dasar — gambar yang telah Anda gunakan selama ini. Dengan RUN, Anda menginstruksikan Docker untuk menjalankan perintah shell di dalam container. Setiap perubahan yang dilakukan perintah ini pada container kini menjadi bagian dari image Docker. Untuk memastikan bahwa image Docker Anda telah dibuat dan tersedia sebelum Anda menjalankan build.sh, Anda harus menyesuaikan package.json sedikit:

{
    // ...
    "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",
    // ...
    },
    // ...
}

(Berikut adalah gist yang berisi semua file.)

Tindakan ini akan membangun image Docker Anda, tetapi hanya jika image belum dibangun. Kemudian semuanya berjalan seperti sebelumnya, tetapi sekarang lingkungan build memiliki perintah doxygen yang tersedia, yang akan menyebabkan dokumentasi libvpx dibangun juga.

Kesimpulan

Tidak mengherankan bahwa kode C/C++ dan npm tidak cocok secara alami, tetapi Anda dapat membuatnya berfungsi dengan cukup nyaman menggunakan beberapa alat tambahan dan isolasi yang disediakan Docker. Penyiapan ini tidak akan berfungsi untuk setiap project, tetapi ini adalah titik awal yang baik yang dapat Anda sesuaikan dengan kebutuhan Anda. Jika Anda memiliki peningkatan, harap bagikan.

Lampiran: Memanfaatkan lapisan image Docker

Solusi alternatifnya adalah mengapsulasi lebih banyak masalah ini dengan Docker dan pendekatan cerdas Docker untuk melakukan caching. Docker mengeksekusi Dockerfile langkah demi langkah dan menetapkan hasil setiap langkah ke image-nya sendiri. Gambar perantara ini sering disebut "lapisan". Jika perintah di Dockerfile tidak berubah, Docker tidak akan menjalankan kembali langkah tersebut saat Anda membangun kembali Dockerfile. Sebagai gantinya, layer digunakan kembali dari terakhir kali image dibuat.

Sebelumnya, Anda harus melakukan beberapa upaya agar tidak membangun ulang libvpx setiap kali Anda membangun aplikasi. Sebagai gantinya, Anda dapat memindahkan petunjuk pembuatan untuk libvpx dari build.sh ke Dockerfile untuk memanfaatkan mekanisme penyimpanan dalam cache 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

(Berikut adalah gist yang berisi semua file.)

Perhatikan bahwa Anda harus menginstal git secara manual dan meng-clone libvpx karena Anda tidak memiliki pemasangan bind saat menjalankan docker build. Sebagai efek samping, napa tidak diperlukan lagi.