Come puoi integrare WebAssembly in questa configurazione? In questo articolo lo illustreremo utilizzando C/C++ ed Emscripten come esempio.
WebAssembly (wasm) viene spesso interpretato come una primitiva per le prestazioni o come un modo per eseguire il codice base C++ esistente sul web. Con squoosh.app, volevamo mostrare che c'è almeno un terzo punto di vista per wasm: usare gli enormi ecosistemi di altri linguaggi di programmazione. Con Emscripten, puoi utilizzare il codice C/C++, il supporto wasm integrato in Ruust e anche il team di Go ci sta lavorando. Sono sicura che seguiranno molte altre lingue.
In questi scenari, wasm non è il fulcro della tua app, ma un pezzo di puzzle: un altro modulo. La tua app dispone già di JavaScript, CSS, asset immagine, un sistema di build incentrato sul web e forse anche un framework come React. Come si integra WebAssembly in questa configurazione? In questo articolo esamineremo questo metodo con C/C++ ed Emscripten come esempio.
Docker
Ho trovato Docker preziosa quando lavoravo con Emscripten. Le librerie C/C++ sono spesso scritte per funzionare con il sistema operativo su cui sono basate. È incredibilmente utile avere un ambiente coerente. Con Docker ottieni un sistema Linux virtualizzato che è già configurato per funzionare con Emscripten e in cui sono installati tutti gli strumenti e le dipendenze. Se manca qualcosa, puoi semplicemente installarlo senza preoccuparti di come influisce sulla tua macchina o su altri progetti. In caso di problemi, scarta il container e ricomincia. Se funziona una volta, puoi essere certo che continuerà a funzionare e a produrre risultati identici.
Il Docker Registry ha un'immagine Emscripten di trzeci che uso molto spesso.
Integrazione con npm
Nella maggior parte dei casi, il punto di contatto di un progetto web è package.json
di npm. Per convenzione, la maggior parte dei progetti può essere creata con npm install &&
npm run build
.
In generale, gli elementi di compilazione prodotti da Emscripten (un file .js
e un file .wasm
) devono essere trattati come un altro modulo JavaScript e un altro asset. Il file JavaScript può essere gestito da un bundler come webpack o rollup
e il file wasm deve essere trattato come qualsiasi altro asset binario più grande, come
le immagini.
Di conseguenza, gli artefatti di build Emscripten devono essere creati prima che venga attivato il processo di compilazione "normale":
{
"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",
// ...
},
// ...
}
Il nuovo task build:emscripten
potrebbe chiamare direttamente Emscripten, ma come accennato in precedenza, ti consiglio di utilizzare Docker per assicurarti che l'ambiente di compilazione sia coerente.
docker run ... trzeci/emscripten ./build.sh
indica a Docker di avviare un nuovo container
utilizzando l'immagine trzeci/emscripten
ed eseguire il comando ./build.sh
.
build.sh
è uno script shell che scriverai a breve. --rm
indica a Docker di eliminare il contenitore al termine dell'esecuzione. In questo modo, non crei
una raccolta di immagini macchina obsolete nel tempo. -v $(pwd):/src
significa che vuoi che Docker esegua il "specchietto" della directory corrente ($(pwd)
) su /src
all'interno del container. Qualsiasi modifica apportata ai file nella directory /src
all'interno del
contenitore verrà rispecchiata nel progetto effettivo. Queste directory con mirroring
sono chiamate "bind mounts".
Diamo un'occhiata a 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 "============================================="
C'è molto da analizzare qui!
set -e
attiva la modalità "fail fast" per la shell. Se uno dei comandi dello script
restituisce un errore, l'intero script viene interrotto immediatamente. Questo può essere molto utile perché l'ultimo output dello script sarà sempre un messaggio di successo o l'errore che ha causato il fallimento della compilazione.
Con le istruzioni export
, definisci i valori di un paio di variabili di ambiente. Ti consentono di passare parametri aggiuntivi della riga di comando al compilatore C (CFLAGS
), al compilatore C++ (CXXFLAGS
) e al linker (LDFLAGS
). Tutti ricevono le impostazioni dell'ottimizzatore tramite OPTIMIZE
per assicurarsi che tutto venga ottimizzato nello stesso modo. Esistono alcuni valori possibili per la variabile OPTIMIZE
:
-O0
: non effettuare alcuna ottimizzazione. Non viene eliminato alcun codice inutilizzato ed Emscripten non minimizza nemmeno il codice JavaScript che emette. Ideale per il debug.-O3
: ottimizza in modo aggressivo per il rendimento.-Os
: ottimizza in modo aggressivo per rendimento e dimensioni come criterio secondario.-Oz
: esegui un'ottimizzazione aggressiva in base alle dimensioni, sacrificando le prestazioni se necessario.
Per il web, consiglio principalmente -Os
.
Il comando emcc
ha una miriade di opzioni proprie. Tieni presente che emcc dovrebbe essere un "sostituzione diretta per compilatori come GCC o clang". Pertanto, è molto probabile che tutti i flag che potresti conoscere da GCC vengano implementati anche da emcc. Il flag -s
è speciale in quanto consente di configurare Emscripten
in modo specifico. Tutte le opzioni disponibili si trovano in settings.js
di Emscripten, ma quel file può essere piuttosto complesso. Ecco un elenco dei flag Emscripten
che ritengo più importanti per gli sviluppatori web:
--bind
attiva embind.-s STRICT=1
non supporta più tutte le opzioni di compilazione ritirate. Ciò garantisce che il codice venga creato in modo compatibile con il forwarding.-s ALLOW_MEMORY_GROWTH=1
consente di aumentare automaticamente la memoria, se necessario. Al momento della scrittura, Emscripten allocherà inizialmente 16 MB di memoria. Quando il codice alloca blocchi di memoria, questa opzione decide se queste operazioni causeranno l'errore dell'intero modulo wasm quando la memoria è esaurita o se il codice di collegamento è autorizzato a espandere la memoria totale per soddisfare l'allocazione.-s MALLOC=...
sceglie quale implementazione dimalloc()
utilizzare.emmalloc
è un'implementazionemalloc()
piccola e veloce specifica per Emscripten. L'alternativa èdlmalloc
, un'implementazionemalloc()
completa. Devi passare adlmalloc
solo se allocati spesso molti oggetti di piccole dimensioni o se vuoi utilizzare i thread.-s EXPORT_ES6=1
trasformerà il codice JavaScript in un modulo ES6 con un'esportazione predefinita che funziona con qualsiasi bundler. Richiede inoltre l'impostazione di-s MODULARIZE=1
.
I seguenti flag non sono sempre necessari o sono utili solo per scopi di debugging:
-s FILESYSTEM=0
è un flag relativo a Emscripten e alla sua capacità di emulare un file system quando il codice C/C++ utilizza operazioni di file system. Esegue alcune analisi sul codice che compila per decidere se includere o meno l'emulazione del filesystem nel codice di collegamento. A volte, però, questa analisi può commettere errori e ti costerà 70 kB di codice aggiuntivo per un'emulazione del file system di cui potresti non avere bisogno. Con-s FILESYSTEM=0
puoi forzare Emscripten a non includere questo codice.-g4
farà in modo che Emscripten includa le informazioni di debug in.wasm
e inoltre emetta un file di mappe di origine per il modulo wasm. Per ulteriori informazioni sul debug con Emscripten, consulta la sezione Debug.
Ecco fatto! Per testare questa configurazione, creiamo un piccolo 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);
}
E 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>
(Ecco un gist contenente tutti i file.)
Per creare tutto, esegui
$ npm install
$ npm run build
$ npm run serve
Se accedi all'indirizzo localhost:8080, nella console DevTools dovresti visualizzare il seguente output:
Aggiunta di codice C/C++ come dipendenza
Se vuoi creare una libreria C/C++ per la tua app web, il codice deve far parte del progetto. Puoi aggiungere il codice al repository del progetto manualmente
oppure puoi usare npm per gestire anche questo tipo di dipendenze. Supponiamo di voler utilizzare libvpx nella mia applicazione web. libvpx è una libreria C++ per codificare le immagini con VP8, il codec utilizzato nei file .webm
.
Tuttavia, libvpx non è su npm e non ha un package.json
, quindi non posso
installarlo direttamente usando npm.
Per uscire da questo enigma, esiste napa. napa consente di installare qualsiasi URL del repository git come dipendenza nella cartella node_modules
.
Installa napa come dipendenza:
$ npm install --save napa
e assicurati di eseguire napa
come script di installazione:
{
// ...
"scripts": {
"install": "napa",
// ...
},
"napa": {
"libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}
Quando esegui npm install
, napa si occupa di clonare il repository GitHub libvpx
nel tuo node_modules
con il nome libvpx
.
Ora puoi estendere lo script di build per creare libvpx. Per creare libvpx utilizza configure
e make
. Fortunatamente, Emscripten può contribuire a garantire che configure
e
make
utilizzino il compilatore di Emscripten. A questo scopo sono disponibili i comandi wrapper emconfigure
e 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 ...
Una libreria C/C++ è divisa in due parti: le intestazioni (tradizionalmente file .h
o
.hpp
) che definiscono le strutture di dati, le classi, le costanti e così via esposte da una
libreria e la libreria effettiva (tradizionalmente i file .so
o .a
). Per
utilizzare la costante VPX_CODEC_ABI_VERSION
della libreria nel codice, devi
includere i file di intestazione della libreria utilizzando un'istruzione #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;
}
Il problema è che il compilatore non sa dove cercare vpxenc.h
.
Questo è lo scopo del flag -I
. Indica al compilatore quali directory controllare per i file di intestazione. Inoltre, devi fornire al compilatore anche il
file della libreria effettiva:
# ... 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 ...
Se esegui npm run build
ora, vedrai che il processo crea un nuovo .js
e un nuovo file .wasm
e che la pagina demo restituirà effettivamente la costante:
Noterai anche che il processo di compilazione richiede molto tempo. Il motivo dei lunghi tempi di compilazione può variare. Nel caso di libvpx, l'operazione richiede molto tempo perché compila un codificatore e un decodificatore sia per VP8 che per VP9 ogni volta che esegui il comando di compilazione, anche se i file di origine non sono stati modificati. Anche una piccola
variazione al tuo my-module.cpp
richiederà molto tempo per essere creata. È molto utile conservare gli artefatti di build di libvpx dopo la prima compilazione.
Un modo per farlo è utilizzare le variabili di ambiente.
# ... 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 ...
(Ecco un gist contenente tutti i file.)
Il comando eval
ci consente di impostare le variabili di ambiente trasmettendo parametri
allo script di build. Il comando test
salta la compilazione di libvpx se$SKIP_LIBVPX
è impostato (su qualsiasi valore).
Ora puoi compilare il modulo, ma saltare la ricostruzione di libvpx:
$ npm run build:emscripten -- SKIP_LIBVPX=1
Personalizzazione dell'ambiente di compilazione
A volte le librerie dipendono da strumenti aggiuntivi per la compilazione. Se queste dipendenze
non sono presenti nell'ambiente di build fornito dall'immagine Docker, devi
aggiungerle manualmente. Ad esempio, supponiamo che tu voglia anche creare la documentazione di libvpx utilizzando doxygen. Doxygen non è disponibile all'interno del container Docker, ma puoi installarlo utilizzando apt
.
Se lo facessi in build.sh
, dovrai scaricare di nuovo e reinstallare
doxygen ogni volta che vuoi creare la tua raccolta. Non solo sarebbe uno spreco, ma ti impedirà anche di lavorare al tuo progetto offline.
In questo caso, ha senso creare la tua immagine Docker. Le immagini Docker vengono create
scrivendo un Dockerfile
che descrive i passaggi di build. I Dockerfile sono molto
potenti e hanno molti
comandi, ma il più delle volte
puoi riuscirci semplicemente usando FROM
, RUN
e ADD
. In questo caso:
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen
Con FROM
, puoi dichiarare quale immagine Docker utilizzare come punto di partenza. Ho scelto trzeci/emscripten
come base, l'immagine che hai utilizzato
da sempre. Con RUN
, indichi a Docker di eseguire comandi shell all'interno del
container. Qualsiasi modifica apportata al container da questi comandi
fa parte dell'immagine Docker. Per assicurarti che l'immagine Docker sia stata creata e sia disponibile prima di eseguire build.sh
, devi regolare package.json
a 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",
// ...
},
// ...
}
Ecco un riepilogo che contiene tutti i file.
Verrà creata l'immagine Docker, ma solo se non è ancora stata creata. Tutto viene eseguito come prima, ma ora nell'ambiente di build è disponibile il comando doxygen
, che causerà la creazione anche della documentazione di libvpx.
Conclusione
Non c'è da stupirsi che il codice C/C++ e npm non siano una soluzione naturale, ma è possibile farla funzionare abbastanza comodamente con alcuni strumenti aggiuntivi e l'isolamento fornito da Docker. Questa configurazione non è adatta a tutti i progetti, ma è un buon punto di partenza che puoi adattare alle tue esigenze. Se ci sono miglioramenti, condividili.
Appendice: utilizzo dei livelli di immagini Docker
Una soluzione alternativa è incapsulare più di questi problemi con Docker e con l'approccio intelligente di Docker alla memorizzazione nella cache. Docker esegue i Dockerfile passo dopo passo e assegna al risultato di ogni passaggio un'immagine personale. Queste immagini intermedie sono spesso denominate "livelli". Se un comando in un Dockerfile non è cambiato, Docker non eseguirà di nuovo quel passaggio durante la ricostruzione del Dockerfile. Riutilizza invece il livello dall'ultima creazione dell'immagine.
In precedenza, dovevi fare un po' di lavoro per non ricreare libvpx ogni
volta che creavi la tua app. Invece, puoi spostare le istruzioni per la creazione di libvpx
da build.sh
in Dockerfile
per utilizzare il meccanismo di
meccanismo di memorizzazione nella cache di 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
(Ecco un gist contenente tutti i file.)
Tieni presente che devi installare manualmente git e clonare libvpx poiché non hai
bind mount quando esegui docker build
. Come effetto collaterale, non è più necessario
napa.