Come si integra WebAssembly in questa configurazione? In questo articolo lo scopriremo con C/C++ ed Emscripten come esempio.
WebAssembly (wasm) viene spesso inquadrato come una primitiva di rendimento o un modo per eseguire la tua codebase C++ esistente sul web. Con squoosh.app, volevamo dimostrare che esiste almeno una terza prospettiva per Wasm: sfruttare gli enormi ecosistemi di altri linguaggi di programmazione. Con Emscripten, puoi utilizzare il codice C/C++, Rust ha il supporto WASM integrato e anche il team di Go ci sta lavorando. Sono sicuro che seguiranno molte altre lingue.
In questi scenari, Wasm non è il fulcro della tua app, ma piuttosto un pezzo di puzzle: un altro modulo. La tua app ha già asset JavaScript, CSS e immagini, un sistema di build incentrato sul web e magari anche un framework come React. Come si integra WebAssembly in questa configurazione? In questo articolo, risolveremo questo problema con C/C++ ed Emscripten come esempio.
Docker
Ho trovato Docker di inestimabile valore quando lavoro con Emscripten. Le librerie C/C++ sono spesso scritte per funzionare con il sistema operativo su cui sono basate. È molto utile avere un ambiente coerente. Con Docker ottieni un sistema Linux virtualizzato già configurato per funzionare con Emscripten e con tutti gli strumenti e le dipendenze installati. Se manca qualcosa, puoi semplicemente installarlo senza doverti preoccupare di come influisce sulla tua macchina o sugli altri progetti. In caso di problemi, getta via il contenitore e ricomincia da capo. Se funziona una volta, puoi essere certo che continuerà a funzionare e produrrà risultati identici.
Il Docker Registry ha un'immagine Emscripten di trzeci che ho utilizzato molto.
Integrazione con npm
Nella maggior parte dei casi, il punto di ingresso 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 artefatti di build 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,
mentre il file wasm deve essere trattato come qualsiasi altro asset binario più grande, come
le immagini.
Pertanto, gli artefatti di build di Emscripten devono essere creati prima dell'avvio del processo di build "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 richiamare Emscripten direttamente, ma come
accennato in precedenza, ti consiglio di utilizzare Docker per assicurarti che l'ambiente di build 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 in seguito. --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 "duplichi" la directory attuale ($(pwd)
) in /src
all'interno
del container. Le modifiche apportate ai file nella directory /src
all'interno del
container verranno replicate nel progetto effettivo. Queste directory speculari
sono chiamate "mount bind".
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
imposta la shell in modalità "fail fast". Se uno dei comandi nello script
restituisce un errore, l'intero script viene interrotto immediatamente. Questo può essere
incredibilmente utile, in quanto l'ultimo output dello script sarà sempre un messaggio di operazione riuscita
o l'errore che ha causato l'esito negativo della build.
Con le istruzioni export
, definisci i valori di un paio di variabili di ambiente. 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 allo stesso modo. Esistono due valori possibili
per la variabile OPTIMIZE
:
-O0
: non eseguire alcuna ottimizzazione. Nessun codice inutilizzato viene eliminato ed Emscripten non comprime il codice JavaScript che emette. Ideale per il debug.-O3
: Ottimizza in modo aggressivo per il rendimento.-Os
: Ottimizza in modo aggressivo per prestazioni e dimensioni come criterio secondario.-Oz
: esegui l'ottimizzazione in modo aggressivo per le 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 è
pensato per essere un "sostituto diretto di compilatori come GCC o clang". Pertanto, tutti i
flag che potresti conoscere di GCC verranno molto probabilmente implementati anche da emcc. Il flag -s
è speciale in quanto ci consente di configurare Emscripten
in modo specifico. Tutte le opzioni disponibili sono disponibili in
settings.js
di Emscripten,
ma questo file può essere piuttosto complesso. Ecco un elenco dei flag di Emscripten
che ritengo più importanti per gli sviluppatori web:
--bind
consente embind.-s STRICT=1
non supporta più tutte le opzioni di build ritirate. In questo modo, la compilazione del codice avviene in modo compatibile con le versioni future.-s ALLOW_MEMORY_GROWTH=1
consente di aumentare automaticamente la memoria se necessario. Al momento della stesura, Emscripten alloca inizialmente 16 MB di memoria. Man mano che 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 può espandere la memoria totale per accogliere l'allocazione.-s MALLOC=...
sceglie quale implementazione dimalloc()
utilizzare.emmalloc
è un'implementazionemalloc()
piccola e veloce specificamente per Emscripten. L'alternativa èdlmalloc
, un'implementazione completa dimalloc()
. Devi passare adlmalloc
solo se allochi spesso molti oggetti piccoli 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 anche l'impostazione di-s MODULARIZE=1
.
I seguenti flag non sono sempre necessari o sono utili solo per il debug:
-s FILESYSTEM=0
è un flag relativo a Emscripten e alla sua capacità di emulare un file system per te quando il tuo codice C/C++ utilizza operazioni del file system. Esegue un'analisi del codice che compila per decidere se includere o meno l'emulazione del file system nel codice di collegamento. A volte, tuttavia, questa analisi può sbagliare e paghi un bel po' di 70 kB di codice glue aggiuntivo per un'emulazione del file system che potresti non aver 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
ed emetta anche un file di mappe di origine per il modulo Wasm. Per saperne di più sul debug con Emscripten, consulta la sezione dedicata al 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 un 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 vai alla pagina localhost:8080, dovresti vedere il seguente output nella console DevTools:

Aggiungere codice C/C++ come dipendenza
Se vuoi creare una libreria C/C++ per la tua app web, devi includere il codice nel tuo progetto. Puoi aggiungere il codice al repository del tuo progetto manualmente
oppure puoi utilizzare npm per gestire anche questo tipo di dipendenze. Supponiamo che io
voglia utilizzare libvpx nella mia app 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 utilizzando npm.
Per uscire da questo dilemma, esiste
napa. napa ti 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 in node_modules
con il nome libvpx
.
Ora puoi estendere lo script di build per creare libvpx. libvpx utilizza configure
e make
per essere creato. Fortunatamente, Emscripten può contribuire a garantire che configure
e
make
utilizzino il compilatore di Emscripten. A questo scopo, esistono 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++ è suddivisa in due parti: le intestazioni (tradizionalmente file .h
o
.hpp
) che definiscono le strutture di dati, le classi, le costanti e così via che una
libreria espone e la libreria vera e propria (tradizionalmente file .so
o .a
). Per
utilizzare la costante VPX_CODEC_ABI_VERSION
della libreria nel tuo 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
.
A questo serve il flag -I
. Indica al compilatore le directory da
controllare per i file di intestazione. Inoltre, devi anche fornire al compilatore
il file della libreria effettivo:
# ... 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 file .js
e un nuovo file .wasm
e che la pagina demo restituirà effettivamente la costante:

Noterai anche che il processo di creazione richiede molto tempo. Il motivo dei
lunghi tempi di compilazione può variare. Nel caso di libvpx, ci vuole molto tempo perché
compila un codificatore e un decodificatore per VP8 e VP9 ogni volta che esegui
il comando di compilazione, anche se i file di origine non sono cambiati. Anche una piccola
modifica al tuo my-module.cpp
richiederà molto tempo per essere implementata. Sarebbe molto
vantaggioso conservare gli artefatti di build di libvpx dopo la prima build.
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 passando i parametri
allo script di build. Il comando test
salterà la creazione di libvpx se
$SKIP_LIBVPX
è impostato (su qualsiasi valore).
Ora puoi compilare il modulo, ma saltare la ricompilazione di libvpx:
$ npm run build:emscripten -- SKIP_LIBVPX=1
Personalizzazione dell'ambiente di build
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 nel tuo build.sh
, dovresti scaricare e reinstallare
doxygen ogni volta che vuoi creare la tua libreria. Non solo sarebbe
uno spreco, ma ti impedirebbe anche di lavorare al tuo progetto offline.
In questo caso, è consigliabile creare la tua immagine Docker. Le immagini Docker vengono create scrivendo un Dockerfile
che descrive i passaggi di build. I Dockerfile sono piuttosto
potenti e hanno molti
comandi, ma la maggior parte delle
volte puoi cavartela usando solo 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 vuoi utilizzare come punto di partenza. Ho scelto trzeci/emscripten
come base, ovvero l'immagine che hai utilizzato
finora. Con RUN
, indichi a Docker di eseguire i comandi della shell all'interno del
container. Qualsiasi modifica apportata al container da questi comandi ora fa parte
dell'immagine Docker. Per assicurarti che l'immagine Docker sia stata creata e sia
disponibile prima di eseguire build.sh
, devi modificare leggermente il file 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",
// ...
},
// ...
}
Ecco un gist contenente tutti i file.
In questo modo verrà creata l'immagine Docker, ma solo se non è ancora stata creata. A questo punto
tutto funziona come prima, ma ora l'ambiente di build ha a disposizione il comando doxygen
, che farà in modo che venga creata anche la documentazione di libvpx.
Conclusione
Non sorprende che il codice C/C++ e npm non siano una combinazione naturale, ma puoi farli funzionare in modo abbastanza comodo con alcuni strumenti aggiuntivi e l'isolamento fornito da Docker. Questa configurazione non funziona per tutti i progetti, ma è un buon punto di partenza che puoi modificare in base alle tue esigenze. Se hai miglioramenti, condividili.
Appendice: utilizzo dei livelli delle immagini Docker
Una soluzione alternativa consiste nell'incapsulare un maggior numero di questi problemi con Docker e l'approccio intelligente di Docker alla memorizzazione nella cache. Docker esegue i Dockerfile passo dopo passo e assegna a ogni passaggio un'immagine propria. Queste immagini intermedie sono spesso chiamate "livelli". Se un comando in un Dockerfile non è cambiato, Docker non eseguirà nuovamente questo passaggio quando ricompili il Dockerfile. Riutilizza invece il livello dell'ultima volta che è stata creata l'immagine.
In precedenza, dovevi fare un po' di fatica per non ricompilare libvpx ogni volta
che creavi l'app. Invece, puoi spostare le istruzioni di compilazione per libvpx
da build.sh
a Dockerfile
per utilizzare il 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 perché non hai
mount bind quando esegui docker build
. Come effetto collaterale, non è più necessario
napa.