Emscripten dan npm

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

WebAssembly (wasm) sering kali dibingkai sebagai primitif performa atau cara untuk menjalankan codebase C++ yang sudah ada di web. Dengan squoosh.app, kami ingin menunjukkan bahwa setidaknya ada perspektif ketiga untuk wasm: memanfaatkan ekosistem besar dari 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 mengikuti.

Dalam skenario ini, wasm bukanlah pusat aplikasi Anda, melainkan bagian teka-teki: satu lagi modul lainnya. Aplikasi Anda sudah memiliki JavaScript, CSS, aset gambar, sistem build yang berfokus pada web, dan bahkan mungkin framework seperti React. Bagaimana Anda mengintegrasikan WebAssembly ke dalam pengaturan ini? Dalam artikel ini, kita akan menangani hal ini dengan C/C++ dan Emscripten sebagai contoh.

Docker

Saat bekerja dengan Emscripten, saya merasa Docker sangat berharga. Library C/C++ sering kali ditulis agar berfungsi dengan sistem operasi yang mendukungnya. Memiliki lingkungan yang konsisten sangat membantu. Dengan Docker, Anda mendapatkan sistem Linux virtual yang telah disiapkan agar berfungsi dengan Emscripten serta memiliki semua alat dan dependensi yang terinstal. Jika ada yang hilang, Anda dapat langsung menginstalnya tanpa perlu khawatir akan pengaruhnya terhadap komputer Anda sendiri atau project Anda yang lain. Jika terjadi masalah, buang container dan mulai dari awal. Jika berfungsi sekali, Anda dapat dipastikan akan terus berfungsi dan menghasilkan hasil yang identik.

Docker Registry memiliki Emscripten image 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 dibuat dengan npm install && npm run build.

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

Dengan demikian, artefak build Emscripten harus di-build sebelum proses build "normal" 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 menjalankan container baru menggunakan image trzeci/emscripten dan menjalankan perintah ./build.sh. build.sh adalah skrip shell yang akan Anda tulis berikutnya. --rm memberi tahu Docker untuk menghapus penampung setelah selesai dijalankan. Dengan cara ini, Anda tidak mengumpulkan kumpulan image mesin yang sudah tidak berlaku dari waktu ke waktu. -v $(pwd):/src berarti Anda ingin Docker "mencerminkan" direktori saat ini ($(pwd)) ke /src di dalam container. Setiap perubahan yang dibuat pada file dalam direktori /src di dalam penampung akan dicerminkan ke project Anda yang 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 harus diulas di sini!

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

Dengan pernyataan export, Anda dapat menentukan nilai dari beberapa variabel lingkungan. Keduanya memungkinkan Anda meneruskan parameter command line tambahan ke compiler C (CFLAGS), compiler C++ (CXXFLAGS), dan penaut (LDFLAGS). Semuanya menerima setelan pengoptimal melalui OPTIMIZE untuk memastikan bahwa 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 dimunculkannya. Cocok untuk proses debug.
  • -O3: Mengoptimalkan performa secara agresif.
  • -Os: Mengoptimalkan performa dan ukuran secara agresif sebagai kriteria sekunder.
  • -Oz: Mengoptimalkan ukuran secara agresif, sehingga mengorbankan performa jika perlu.

Untuk web, sebaiknya gunakan -Os.

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

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

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

  • -s FILESYSTEM=0 adalah flag yang berkaitan dengan Emscripten dan kemampuannya untuk mengemulasi sistem file saat kode C/C++ Anda menggunakan operasi sistem file. Alat ini melakukan analisis pada kode yang dikompilasi untuk memutuskan apakah akan menyertakan emulasi sistem file dalam kode glue atau tidak. Namun, terkadang analisis ini dapat keliru, dan Anda membayar 70 kB yang cukup besar untuk kode glue 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 proses debug di .wasm dan juga memunculkan file peta sumber untuk modul wasm. Anda dapat membaca lebih lanjut proses proses debug dengan Emscripten di bagian proses debug.

Dan, berhasil! Untuk menguji penyiapan ini, mari kita siapkan 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 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 mem-build library C/C++ untuk aplikasi web, Anda harus menjadikan kodenya sebagai bagian dari project. Anda dapat menambahkan kode ke repositori project secara manual atau menggunakan npm untuk mengelola jenis dependensi ini juga. Katakanlah saya ingin menggunakan libvpx di webapp. 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, sehingga saya tidak dapat menginstalnya menggunakan npm secara langsung.

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

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 menangani cloning repositori GitHub libvpx ke node_modules dengan nama libvpx.

Anda kini dapat memperluas skrip build untuk mem-build libvpx. libvpx menggunakan configure dan make untuk di-build. 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 oleh library dan library aktual (biasanya file .so atau .a). Untuk menggunakan konstanta VPX_CODEC_ABI_VERSION library dalam kode, 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 flag -I. Kode ini memberi tahu compiler direktori mana yang akan diperiksa file header-nya. Selain itu, Anda juga harus 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 menjalankan npm run build sekarang, Anda akan melihat bahwa proses membangun .js baru dan file .wasm baru, dan halaman demo memang akan menghasilkan 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. Pada kasus libvpx, diperlukan waktu yang lama karena mengompilasi encoder dan decoder untuk VP8 dan VP9 setiap kali Anda menjalankan perintah build, meskipun file sumbernya belum berubah. Bahkan perubahan kecil pada my-module.cpp akan memerlukan waktu lama untuk di-build. Akan sangat bermanfaat untuk menyimpan artefak build libvpx setelah dibangun pertama kali.

Salah satu cara untuk melakukan ini 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 ini 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 pembuatan 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 perlu menambahkannya sendiri. Sebagai contoh, misalnya Anda juga ingin membuat dokumentasi libvpx menggunakan doxygen. Doxygen tidak tersedia di dalam container Docker, tetapi Anda dapat menginstalnya menggunakan apt.

Jika melakukannya di build.sh, Anda harus mendownload ulang dan menginstal ulang doxygen setiap kali ingin mem-build library. Tidak hanya akan sia-sia, tetapi juga akan membuat Anda berhenti mengerjakan project saat offline.

Di sini, Anda dapat membangun image Docker sendiri. Image Docker dibangun dengan menulis Dockerfile yang menjelaskan langkah-langkah build. Dockerfile cukup andal dan memiliki banyak perintah, tetapi sering kali Anda dapat menyelesaikan hal ini hanya dengan menggunakan FROM, RUN, dan ADD. Dalam kasus ini:

FROM trzeci/emscripten

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

Dengan FROM, Anda dapat mendeklarasikan image Docker mana yang ingin Anda gunakan sebagai titik awal. Saya memilih trzeci/emscripten sebagai dasar, yakni gambar yang telah Anda gunakan selama ini. Dengan RUN, Anda menginstruksikan Docker untuk menjalankan perintah shell di dalam container. Apa pun perubahan yang dibuat oleh perintah ini pada container kini menjadi bagian dari image Docker. Untuk memastikan image Docker telah dibangun dan tersedia sebelum 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 ini gist yang berisi semua file.)

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

Kesimpulan

Tidaklah mengherankan jika kode C/C++ dan npm tidak secara natural, tetapi Anda dapat membuatnya berfungsi dengan cukup nyaman dengan beberapa alat tambahan dan isolasi yang disediakan Docker. Penyiapan ini mungkin tidak sesuai untuk setiap project, tetapi ini adalah titik awal yang baik yang dapat Anda sesuaikan dengan kebutuhan. Jika Anda memiliki peningkatan, silakan bagikan.

Lampiran: Pemanfaatan lapisan image Docker

Solusi alternatifnya adalah mengenkapsulasi lebih banyak masalah ini dengan pendekatan cerdas Docker dan Docker terhadap cache. Docker menjalankan Dockerfile langkah demi langkah dan menetapkan hasil dari setiap langkah sebagai image-nya sendiri. Gambar perantara ini sering disebut "lapisan". Jika perintah di Dockerfile tidak berubah, Docker tidak akan benar-benar menjalankan kembali langkah tersebut saat Anda membangun kembali Dockerfile. Sebagai gantinya, API ini akan menggunakan kembali lapisan dari saat terakhir kali image dibuat.

Sebelumnya, Anda harus melakukan upaya untuk tidak membangun ulang libvpx setiap kali mem-build aplikasi. Sebagai gantinya, Anda dapat memindahkan petunjuk build untuk libvpx dari build.sh ke Dockerfile untuk memanfaatkan mekanisme 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 ini gist yang berisi semua file.)

Perhatikan bahwa Anda perlu menginstal git dan meng-clone libvpx secara manual karena tidak memiliki binding mount saat menjalankan docker build. Sebagai efek samping, napa tidak perlu lagi digunakan.