Come si integra WebAssembly in questa configurazione? In questo articolo illustreremo questo argomento con C/C++ ed Emscripten come esempio.
WebAssembly (wasm) è spesso definito come una primitiva delle prestazioni o un modo per eseguire il codebase C++ esistente sul web. Con squoosh.app, vogliamo 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. Sicuramente verranno usate molte altre lingue.
In questi scenari, wasm non è il fulcro dell'app, ma un pezzo di puzzle: un altro modulo. La tua app dispone già di JavaScript, CSS, asset immagine, un sistema di creazione incentrato sul web e forse anche un framework come React. Come si integra WebAssembly in questa configurazione? In questo articolo spiegheremo come usare C/C++ ed Emscripten.
Docker
Docker è stato prezioso quando lavoro con Emscripten. Le librerie C/C++ sono spesso scritte per funzionare con il sistema operativo su cui sono basate. Avere un ambiente coerente è incredibilmente utile. Con Docker si ottiene un sistema Linux virtualizzato già configurato per il funzionamento con Emscripten e in cui sono installati tutti gli strumenti e le dipendenze. Se manca qualcosa, puoi semplicemente installarlo senza preoccuparti dell'impatto sulla tua macchina o sugli altri tuoi progetti. Se si verifica un problema, getta via il contenitore e ricomincia. Se funziona una volta, puoi avere la certezza che continuerà a funzionare e produca risultati identici.
Il Docker Registry ha un'immagine Emscripten di trzeci che sto usando ampiamente.
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 come un altro
asset. Il file JavaScript può essere gestito da un bundler come webpack o di aggregazione e il file wasm deve essere trattato come qualsiasi altro asset binario più grande, come le immagini.
Di conseguenza, gli artefatti di build di Emscripten devono essere creati prima che inizi il tuo "normale" processo di build:
{
"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",
// ...
},
// ...
}
La nuova attività build:emscripten
potrebbe richiamare Emscripten direttamente, ma, come detto 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 successivamente. --rm
indica a Docker di eliminare il container al termine dell'esecuzione. In questo modo, non creerai
una raccolta di immagini macchina inattive nel tempo. -v $(pwd):/src
significa che
vuoi che Docker esegua il "Mirroring" della directory attuale ($(pwd)
) su /src
all'interno
del container. Eventuali modifiche apportate ai file nella directory /src
all'interno del container verranno sincronizzate nel progetto effettivo. Queste directory con mirroring
sono chiamate "montaggi di associazione".
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'è tanto da analizzare qui!
set -e
imposta la shell in modalità "fail fast". Se uno o più comandi dello script
restituiscono un errore, l'intero script viene interrotto immediatamente. Questo può
essere incredibilmente utile perché 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 ulteriori parametri della riga di comando al compilatore C (CFLAGS
), al compilatore C++ (CXXFLAGS
) e al linker (LDFLAGS
). Tutti ricevono le impostazioni di ottimizzazione tramite OPTIMIZE
per garantire che tutto venga ottimizzato nello stesso modo. Esistono un paio di possibili valori per la variabile OPTIMIZE
:
-O0
: non eseguire alcuna ottimizzazione. Non viene eliminato nessun codice morto e 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
: ottimizza in modo aggressivo per le dimensioni, sacrificando il rendimento se necessario.
Per il web, consiglio principalmente -Os
.
Il comando emcc
ha una miriade di opzioni. Tieni presente che emcc è da considerarsi una "sostituzione drop-in per compilatori come GCC o clang". Quindi, molto probabilmente tutti i flag che potresti conoscere in GCC verranno implementati anche dall'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 quel file può essere piuttosto complesso. Ecco un elenco delle segnalazioni Emscripten
che ritengo siano più importanti per gli sviluppatori web:
--bind
consente di embinare.-s STRICT=1
non supporta più tutte le opzioni di build deprecate. Ciò garantisce che il codice venga creato in modo compatibile con l'inoltro.-s ALLOW_MEMORY_GROWTH=1
consente di aumentare automaticamente la memoria, se necessario. Al momento della scrittura, Emscripten alloca inizialmente 16 MB di memoria. Man mano che il codice alloca blocchi di memoria, questa opzione determina se queste operazioni rendono l'intero modulo Wasm non riuscito quando la memoria è esaurita o se il codice colla può espandere la memoria totale per soddisfare l'allocazione.-s MALLOC=...
sceglie l'implementazionemalloc()
da utilizzare.emmalloc
è un'implementazionemalloc()
di dimensioni ridotte e veloce specifica per Emscripten. L'alternativa èdlmalloc
, un'implementazionemalloc()
completa. Devi passare adlmalloc
solo se assegni frequentemente molti oggetti di piccole dimensioni o se vuoi utilizzare l'organizzazione in thread.-s EXPORT_ES6=1
trasformerà il codice JavaScript in un modulo ES6 con un'esportazione predefinita compatibile con qualsiasi bundler. È necessario anche impostare-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 ti consente di emulare un file system quando il tuo codice C/C++ utilizza operazioni di file system. Esegue alcune analisi sul codice compilato per decidere se includere o meno l'emulazione del file system nel glue code. Tuttavia, a volte questa analisi può sbagliare e costano 70 kB in più di colla codice per un'emulazione del file system che potrebbe non essere necessaria. Con-s FILESYSTEM=0
puoi forzare Emscripten a non includere questo codice.- Con
-g4
, Emscripten includerà informazioni di debug in.wasm
ed emetterà anche un file delle mappe di origine per il modulo wasm. Per ulteriori informazioni sul debug con Emscripten, consulta la relativa sezione sul debug.
Ecco fatto! Per testare questa configurazione, prepariamo un 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 accedi a localhost:8080, dovresti visualizzare il seguente output nella console di DevTools:
Aggiunta del codice C/C++ come dipendenza
Se vuoi creare una libreria C/C++ per la tua applicazione web, il suo codice deve far parte del progetto. Puoi aggiungere il codice al repository del progetto manualmente
oppure utilizzare npm per gestire anche questo tipo di dipendenze. Supponiamo che io voglia usare libvpx nella mia applicazione web. libvpx è una libreria C++ per codificare le immagini con VP8, il codec utilizzato nei file .webm
.
Tuttavia, libvpx non è in npm e non ha un package.json
, quindi non posso
installarlo utilizzando direttamente npm.
Per risolvere questo rompicapo, c'è napa, che 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. libvpx utilizza configure
e make
per la creazione. Fortunatamente, Emscripten può contribuire ad assicurare 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 dei dati, le classi, le costanti e così via esposte dalla 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
.
Ecco a cosa serve il flag -I
. Indica al compilatore le directory da
controllare i file di intestazione. Inoltre, devi anche fornire al compilatore l'effettivo file della libreria:
# ... 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 .wasm
e che la pagina demo restituirà infatti la costante:
Noterai anche che il processo di compilazione richiede molto tempo. I motivi alla base di
tempi di compilazione lunghi possono variare. Nel caso di libvpx, ci vuole molto tempo perché compila un codificatore e un decoder sia per VP8 che per VP9 ogni volta che esegui il comando di build, anche se i file di origine non sono cambiati. Anche una piccola
modifica al tuo my-module.cpp
richiederà molto tempo per la creazione. Sarebbe molto utile mantenere gli artefatti di build di libvpx una volta costruiti per la prima volta.
Un modo per raggiungere questo obiettivo è 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 parametri
allo script di compilazione. Il comando test
ignorerà la creazione di libvpx se è impostato $SKIP_LIBVPX
(su qualsiasi valore).
Ora puoi compilare il modulo ma puoi evitare di ricreare libvpx:
$ npm run build:emscripten -- SKIP_LIBVPX=1
Personalizzazione dell'ambiente di compilazione
A volte le librerie dipendono da strumenti aggiuntivi per la creazione. Se queste dipendenze non sono presenti nell'ambiente di build fornito dall'immagine Docker, dovrai
aggiungerle manualmente. Ad esempio, supponiamo di voler creare la documentazione di libvpx utilizzando doxygen. Doxygen non è disponibile all'interno del tuo container Docker, ma puoi installarlo utilizzando apt
.
Se lo facessi su build.sh
, riscarica e reinstalli doxygen ogni volta che vuoi creare la tua libreria. Non solo sarebbe uno spreco, ma ti impedirà anche di lavorare al 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 abbastanza potenti e hanno molti comandi, ma nella maggior parte dei casi puoi semplicemente utilizzare 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, ovvero l'immagine che avete sempre utilizzato. Con RUN
, indichi a Docker di eseguire i comandi shell all'interno del container. Qualunque sia la modifica apportata da questi comandi al container,
ora fa parte dell'immagine Docker. Per assicurarti che l'immagine Docker sia stata creata e che sia disponibile prima di eseguire build.sh
, devi regolare un bit 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.)
L'immagine Docker verrà creata, ma solo se non è stata ancora creata. Poi
tutto funziona come prima, ma ora nell'ambiente di build è disponibile il comando doxygen
, il che causerà la creazione della documentazione di libvpx.
Conclusione
Non sorprende che il codice C/C++ e npm non siano adatti a te, ma puoi farli funzionare senza problemi 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 adattare alle tue esigenze. Se hai dei miglioramenti, condividili.
Appendice: utilizzo dei livelli di immagini Docker
Una soluzione alternativa è incapsulare un maggior numero di questi problemi con l'approccio intelligente di Docker e Docker alla memorizzazione nella cache. Docker esegue passo passo i Dockerfile e assegna al risultato di ogni passaggio un'immagine personalizzata. Queste immagini intermedie sono spesso chiamate "livelli". Se un comando in un Dockerfile non è cambiato, Docker non eseguirà nuovamente questo passaggio quando viene ricreato il Dockerfile. Riutilizza invece il livello dell'ultima creazione dell'immagine.
In precedenza, dovevi evitare di ricreare libvpx ogni volta
che creavi l'app. Puoi invece spostare le istruzioni di creazione di libvpx
da build.sh
in Dockerfile
per utilizzare il meccanismo di memorizzazione
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 montaggi associati durante l'esecuzione di docker build
. Come effetto collaterale, non c'è più bisogno
di una sosta.