Emscripten dan npm

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

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

Dalam skenario ini, wasm bukanlah pusat aplikasi Anda, melainkan teka-teki bagian: modul lain. 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 mengerjakan dengan C/C++ dan Emscripten sebagai contoh.

Docker

Menurut saya, Docker sangat bermanfaat saat bekerja dengan Emscripten. C/C++ perpustakaan sering ditulis untuk bekerja dengan sistem operasi yang mereka bangun. Memiliki lingkungan yang konsisten akan sangat membantu. Dengan Docker, Anda mendapatkan sistem Linux yang tervirtualisasi yang sudah diatur untuk bekerja dengan Emscripten dan memiliki semua alat dan dependensi yang terinstal. Jika ada yang kurang, Anda bisa menginstalnya tanpa harus khawatir tentang bagaimana hal itu mempengaruhi komputer Anda atau project lainnya. Jika ada yang salah, buang wadah dan mulailah berakhir. Jika pernah berhasil, Anda bisa yakin bahwa aplikasi itu akan terus berfungsi dan memberikan hasil yang identik.

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

Integrasi dengan npm

Dalam kebanyakan kasus, titik masuk ke proyek web adalah npm dari package.json. Berdasarkan konvensi, sebagian besar project dapat dibuat dengan npm install && npm run build.

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

Dengan demikian, artefak build Emscripten harus dibangun sebelum proses build 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 sebagai yang disebutkan sebelumnya, sebaiknya gunakan Docker untuk memastikan lingkungan build konsisten.

docker run ... trzeci/emscripten ./build.sh memberi tahu Docker untuk menjalankan container 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 container setelah selesai berjalan. Dengan cara ini, Anda tidak membangun sekumpulan gambar mesin yang usang dari waktu ke waktu. -v $(pwd):/src berarti bahwa Anda ingin Docker "mencerminkan" direktori saat ini ($(pwd)) ke /src di dalam container-nya. Setiap perubahan yang Anda buat pada file dalam direktori /src di dalam akan dicerminkan ke project Anda yang sebenarnya. Direktori yang dicerminkan ini disebut “{i>bind mounts<i}”.

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 bisa dibedah di sini!

set -e menempatkan shell ke dalam mode "gagal cepat" mode. Jika ada perintah dalam skrip mengembalikan pesan {i>error<i}, maka seluruh skrip akan segera dibatalkan. Dapat berupa sangat membantu karena {i>output<i} terakhir dari skrip akan selalu berhasil atau error yang menyebabkan build gagal.

Dengan pernyataan export, Anda menentukan nilai beberapa lingkungan variabel. Keduanya memungkinkan Anda meneruskan parameter command line tambahan ke C compiler (CFLAGS), compiler C++ (CXXFLAGS), dan penaut (LDFLAGS). Mereka semua 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 tidak memperkecil kode JavaScript yang dihasilkannya. Bagus untuk proses debug.
  • -O3: Mengoptimalkan performa secara agresif.
  • -Os: Mengoptimalkan performa dan ukuran secara agresif sebagai elemen sekunder kriteria.
  • -Oz: Mengoptimalkan ukuran secara agresif, dengan mengorbankan performa jika perlu.

Untuk web, saya biasanya merekomendasikan -Os.

Perintah emcc memiliki berbagai opsinya sendiri. Perhatikan bahwa ECC adalah seharusnya menjadi "pengganti langsung untuk kompiler seperti GCC atau clang". Jadi, semua yang mungkin Anda ketahui dari GCC kemungkinan besar akan diimplementasikan oleh emcc sebagai ya. Flag -s bersifat khusus karena memungkinkan kita mengonfigurasi Emscripten secara spesifik. Semua opsi yang tersedia dapat ditemukan di settings.js, tetapi file tersebut bisa sangat melelahkan. Berikut adalah daftar tanda Emscripten yang menurut saya paling penting bagi pengembang web:

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

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

  • -s FILESYSTEM=0 adalah flag yang berhubungan dengan Emscripten dan kemampuannya untuk mengemulasi sistem file ketika kode C/C++ menggunakan operasi sistem file. Ia melakukan beberapa analisis pada kode yang dikompilasi untuk memutuskan apakah akan menyertakan emulasi sistem file dalam kode {i>glue<i} atau tidak. Namun terkadang, analisis bisa salah dan Anda membayar 70 kB untuk lem tambahan kode 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 di proses debug dengan Emscripten dalam proses debug bagian.

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 adalah gist yang berisi semua file.)

Untuk membangun semuanya, jalankan

$ npm install
$ npm run build
$ npm run serve

Menavigasi ke {i>localhost:8080 <i}akan menampilkan {i>output<i} berikut ini Konsol DevTools:

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

Menambahkan kode C/C++ sebagai dependensi

Jika ingin membangun library C/C++ untuk aplikasi web, Anda memerlukan kodenya dari proyek Anda. Anda dapat menambahkan kode ke repositori project secara manual atau Anda juga dapat menggunakan npm untuk mengelola dependensi semacam ini. Katakanlah 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 bisa menginstalnya menggunakan npm secara langsung.

Untuk keluar dari teka-teki ini, ada napa. napa memungkinkan Anda menginstal semua git URL repositori 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 menangani cloning 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 wrapper perintah 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 (secara tradisional .h atau file .hpp) yang menentukan struktur data, class, konstanta, dll. yang mengekspos library dan library sebenarnya (biasanya file .so atau .a). Kepada menggunakan konstanta VPX_CODEC_ABI_VERSION library dalam kode Anda, Anda harus untuk 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, compiler tidak tahu di mana harus mencari vpxenc.h. Inilah fungsi flag -I. Ini memberi tahu compiler direktori mana yang harus periksa file {i>header<i}. Selain itu, Anda juga perlu memberi compiler file library yang sebenarnya:

# ... 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 tersebut membangun .js baru dan file .wasm baru dan bahwa halaman demo benar-benar akan menghasilkan konstanta:

DevTools
menunjukkan versi ABI dari libvpx yang dicetak melalui emscripten.

Perhatikan juga bahwa proses build memerlukan waktu yang lama. Alasan waktu build yang lama dapat bervariasi. Dalam kasus libvpx, butuh waktu lama karena mengkompilasi encoder dan decoder untuk VP8 dan VP9 setiap kali Anda menjalankan perintah {i>build<i} Anda, meskipun file sumbernya tidak berubah. Bahkan tugas kecil proses build perubahan ke my-module.cpp akan memerlukan waktu yang lama. Akan sangat mempertahankan artefak build dari libvpx setelah pertama kali dibuat.

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 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 membangun ulang libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Menyesuaikan lingkungan build

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

Jika Anda melakukannya di build.sh, Anda perlu mendownload ulang dan menginstal ulang {i>doxygen<i} setiap kali Anda ingin membangun perpustakaan Anda. Tidak hanya akan menjadi pemborosan, tetapi juga akan menghentikan Anda dari mengerjakan proyek Anda saat {i>offline<i}.

Jadi, masuk akal untuk membangun image Docker Anda sendiri. Image Docker dibangun oleh menulis Dockerfile yang menjelaskan langkah-langkah build. Dockerfile cukup canggih dan memiliki banyak perintah, tetapi sebagian besar Anda bisa menghemat waktu hanya dengan menggunakan FROM, RUN, dan ADD. 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 awalan poin. 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. Apa pun perubahan yang dilakukan perintah ini pada kontainer sekarang menjadi bagian dari image Docker. Untuk memastikan bahwa image Docker Anda telah dibangun dan tersedia sebelum menjalankan build.sh, Anda harus menyesuaikan package.json bit:

{
    // ...
    "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 gist yang berisi semua file.)

Ini akan membangun image Docker Anda, tetapi hanya jika image tersebut belum dibangun. Selanjutnya semuanya berjalan seperti sebelumnya, tetapi sekarang lingkungan build memiliki doxygen yang akan menyebabkan dokumentasi {i>libvpx <i}dibuat sebagai ya.

Kesimpulan

Tidak mengherankan jika kode C/C++ dan npm tidak cocok, tetapi Anda bisa membuatnya bekerja dengan cukup nyaman dengan beberapa alat tambahan dan isolasi yang disediakan Docker. Pengaturan ini tidak akan berfungsi untuk setiap proyek, namun titik awal yang layak yang dapat disesuaikan dengan kebutuhan Anda. Jika Anda memiliki peningkatan, silakan bagikan.

Lampiran: Penggunaan lapisan image Docker

Solusi alternatifnya adalah dengan mengenkapsulasi lebih banyak masalah ini dengan Docker dan Pendekatan cerdas Docker untuk caching. Docker mengeksekusi Dockerfile langkah demi langkah dan menetapkan hasil dari setiap langkah sebuah gambarnya sendiri. Gambar perantara ini sering disebut "lapisan". Jika perintah di Dockerfile belum berubah, Docker tidak akan menjalankan kembali langkah itu ketika Anda membangun ulang Dockerfile. Sebagai gantinya ia menggunakan kembali lapisan sejak terakhir kali gambar dibuat.

Sebelumnya, Anda harus berusaha keras untuk tidak membangun kembali libvpx setiap kali Anda membangun aplikasi. Sebagai gantinya, Anda dapat memindahkan instruksi pembuatan untuk {i>libvpx<i} dari build.sh ke Dockerfile untuk memanfaatkan caching Docker mekanisme:

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 gist yang berisi semua file.)

Perhatikan bahwa Anda perlu menginstal git dan meng-clone libvpx secara manual karena Anda tidak memiliki bind mount saat menjalankan docker build. Sebagai efek samping, tidak perlu {i>napa<i} lagi.