Wie binden Sie WebAssembly in dieses Setup ein? In diesem Artikel werden wir dies anhand von C/C++ und Emscripten als Beispiel besprechen.
WebAssembly (wasm) wird oft als Leistungsprivileg dargestellt oder als Möglichkeit zum Ausführen Ihrer vorhandenen C++-Codebasis im Web. Mit squoosh.app wollten wir zeigen, dass es mindestens eine dritte Perspektive für wasm gibt: die Nutzung der riesigen Ökosysteme anderer Programmiersprachen. Mit Emscripten können Sie C/C++-Code verwenden, Rust hat eine integrierte WASM-Unterstützung und auch das Go-Team arbeitet daran. Ich bin sicher, dass viele weitere Sprachen folgen werden.
In diesen Szenarien ist wasm nicht das Herzstück Ihrer App, sondern ein Puzzleteil: ein weiteres Modul. Ihre App enthält bereits JavaScript, CSS, Bild-Assets, ein webzentriertes Build-System und möglicherweise sogar ein Framework wie React. Wie binden Sie WebAssembly in dieses Setup ein? In diesem Artikel werden wir dies anhand von C/C++ und Emscripten als Beispiel besprechen.
Docker
Ich habe festgestellt, dass Docker bei der Arbeit mit Emscripten unverzichtbar ist. C/C++-Bibliotheken sind oft so geschrieben, dass sie mit dem Betriebssystem funktionieren, auf dem sie erstellt wurden. Eine einheitliche Umgebung ist unglaublich hilfreich. Mit Docker erhalten Sie ein virtualisiertes Linux-System, das bereits für die Verwendung mit Emscripten eingerichtet ist und auf dem alle Tools und Abhängigkeiten installiert sind. Wenn etwas fehlt, können Sie es einfach installieren, ohne sich Gedanken darüber machen zu müssen, wie sich dies auf Ihren Computer oder Ihre anderen Projekte auswirkt. Wenn etwas schiefgeht, verwerfen Sie den Container und beginnen Sie von vorn. Wenn es einmal funktioniert, können Sie sicher sein, dass es auch weiterhin funktioniert und identische Ergebnisse liefert.
Die Docker Registry enthält ein Emscripten-Image von trzeci, das ich intensiv verwende.
Integration mit npm
In den meisten Fällen ist der Einstiegspunkt eines Webprojekts package.json
von npm. Konventionsgemäß können die meisten Projekte mit npm install &&
npm run build
erstellt werden.
Im Allgemeinen sollten die von Emscripten erstellten Build-Artefakte (eine .js
- und eine .wasm
-Datei) wie jedes andere JavaScript-Modul und jedes andere Asset behandelt werden. Die JavaScript-Datei kann von einem Bundler wie Webpack oder Rollup verarbeitet werden und die Wasm-Datei sollte wie jedes andere größere binäre Asset behandelt werden, z. B. Bilder.
Daher müssen die Emscripten-Build-Artefakte erstellt werden, bevor der „normale“ Build-Prozess beginnt:
{
"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",
// ...
},
// ...
}
Die neue Aufgabe build:emscripten
könnte Emscripten direkt aufrufen. Ich empfehle jedoch, wie bereits erwähnt, Docker zu verwenden, um für Konsistenz der Build-Umgebung zu sorgen.
docker run ... trzeci/emscripten ./build.sh
weist Docker an, mithilfe des trzeci/emscripten
-Images einen neuen Container einzurichten und den Befehl ./build.sh
auszuführen.
build.sh
ist ein Shell-Script, das Sie als Nächstes schreiben werden. --rm
weist Docker an, den Container nach der Ausführung zu löschen. So sammeln Sie im Laufe der Zeit nicht eine Sammlung veralteter Maschinen-Images an. -v $(pwd):/src
bedeutet, dass Docker das aktuelle Verzeichnis ($(pwd)
) in /src
im Container „spiegeln“ soll. Alle Änderungen, die Sie an Dateien im Verzeichnis /src
innerhalb des Containers vornehmen, werden in Ihr eigentliches Projekt gespiegelt. Diese gespiegelten Verzeichnisse werden als „bind-Bereitstellungen“ bezeichnet.
Werfen wir einen Blick auf 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 "============================================="
Es gibt hier viel zu analysieren.
set -e
versetzt die Shell in den „Fail Fast“-Modus. Wenn Befehle im Script einen Fehler zurückgeben, wird das gesamte Script sofort abgebrochen. Das kann sehr hilfreich sein, da die letzte Ausgabe des Scripts immer eine Erfolgsmeldung oder der Fehler ist, der zum Fehlschlagen des Builds geführt hat.
Mit den export
-Anweisungen definieren Sie die Werte einiger Umgebungsvariablen. Damit können Sie dem C-Compiler (CFLAGS
), dem C++-Compiler (CXXFLAGS
) und dem Linker (LDFLAGS
) zusätzliche Befehlszeilenparameter übergeben. Sie alle erhalten die Optimierungseinstellungen über OPTIMIZE
, damit alles auf dieselbe Weise optimiert wird. Für die Variable OPTIMIZE
gibt es mehrere mögliche Werte:
-O0
: Es wird keine Optimierung durchgeführt. Es wird kein inaktiver Code entfernt und Emscripten minimiert auch nicht den von ihm generierten JavaScript-Code. Gut zum Debugging.-O3
: Leistung aggressiv optimieren-Os
: Leistung wird aggressiv optimiert und die Größe ist ein sekundäres Kriterium.-Oz
: Führen Sie eine aggressive Optimierung im Hinblick auf die Größe durch und verringern Sie bei Bedarf die Leistung.
Für das Web empfehle ich am besten -Os
.
Der Befehl emcc
hat eine Vielzahl eigener Optionen. Beachten Sie, dass emcc ein „Drop-in-Ersatz für Compiler wie GCC oder Clang“ sein soll. Daher werden alle Flags, die Sie von GCC kennen, höchstwahrscheinlich auch von emcc implementiert. Das Flag -s
ist insofern eine Besonderheit, als es die Möglichkeit bietet, Emscripten speziell zu konfigurieren. Alle verfügbaren Optionen finden Sie in Emscriptens settings.js
. Diese Datei kann jedoch ziemlich überwältigend sein. Hier ist eine Liste der Emscripten-Flags, die meiner Meinung nach für Webentwickler am wichtigsten sind:
--bind
ermöglicht embind.-s STRICT=1
unterstützt keine veralteten Buildoptionen mehr. So wird sichergestellt, dass Ihr Code zukunftsfähig ist.- Mit
-s ALLOW_MEMORY_GROWTH=1
kann der Arbeitsspeicher bei Bedarf automatisch vergrößert werden. Zum Zeitpunkt der Erstellung dieses Artikels weist Emscripten anfangs 16 MB Arbeitsspeicher zu. Während der Code Arbeitsspeicher zuweist, entscheidet diese Option, ob durch diese Vorgänge das gesamte Wasm-Modul ausfällt, wenn der Arbeitsspeicher erschöpft ist, oder ob der Glue Code den Gesamtarbeitsspeicher erweitern darf, um die Zuweisung zu ermöglichen. -s MALLOC=...
wählt aus, welchemalloc()
-Implementierung verwendet werden soll.emmalloc
ist eine kleine und schnellemalloc()
-Implementierung speziell für Emscripten. Die Alternative istdlmalloc
, eine vollwertigemalloc()
-Implementierung. Sie müssen nur dann zudlmalloc
wechseln, wenn Sie häufig viele kleine Objekte zuweisen oder Threading verwenden möchten.-s EXPORT_ES6=1
wandelt den JavaScript-Code in ein ES6-Modul mit einem Standardexport um, der mit jedem Bundler funktioniert. Außerdem muss-s MODULARIZE=1
festgelegt sein.
Die folgenden Flags sind nicht immer erforderlich oder dienen nur zur Fehlerbehebung:
-s FILESYSTEM=0
ist ein Flag, das sich auf Emscripten bezieht. Es ist die Fähigkeit, ein Dateisystem für Sie zu emulieren, wenn Ihr C/C++ Code Dateisystemvorgänge verwendet. Es führt eine Analyse des kompilierten Codes durch, um zu entscheiden, ob die Dateisystememulation in den Glue-Code aufgenommen werden soll oder nicht. Manchmal kann diese Analyse jedoch falsch sein und Sie zahlen ganze 70 KB zusätzlichen Glue Code für eine Dateisystememulation, die Sie möglicherweise nicht benötigen. Mit-s FILESYSTEM=0
können Sie Emscripten zwingen, diesen Code nicht einzubinden.-g4
sorgt dafür, dass Emscripten Debugging-Informationen in.wasm
einfügt und eine Quellzuordnungsdatei für das Wasm-Modul ausgibt. Weitere Informationen zum Debuggen mit Emscripten finden Sie im Abschnitt zum Debuggen.
Das war schon alles. Testen wir diese Konfiguration anhand eines kleinen 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);
}
Und ein 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>
(Hier ist eine Zusammenfassung mit allen Dateien.)
Führen Sie den folgenden Befehl aus, um alles zu erstellen:
$ npm install
$ npm run build
$ npm run serve
Wenn Sie localhost:8080 aufrufen, sollten Sie in der Entwicklertools-Konsole die folgende Ausgabe sehen:
C/C++-Code als Abhängigkeit hinzufügen
Wenn Sie eine C/C++-Bibliothek für Ihre Webanwendung erstellen möchten, muss der Code Teil Ihres Projekts sein. Sie können den Code dem Repository Ihres Projekts manuell hinzufügen oder diese Art von Abhängigkeiten auch mit npm verwalten. Angenommen, ich möchte libvpx in meiner Webanwendung verwenden. libvpx ist eine C++-Bibliothek zum Codieren von Bildern mit dem Codec VP8, der in .webm
-Dateien verwendet wird.
libvpx ist jedoch nicht auf npm installiert und hat kein package.json
, sodass ich es nicht direkt mit npm installieren kann.
Napa ist die Lösung für dieses Problem. Mit Napa können Sie jede Git-Repository-URL als Abhängigkeit in Ihrem node_modules
-Ordner installieren.
Installieren Sie Napa als Abhängigkeit:
$ npm install --save napa
und führen Sie napa
als Installationsskript aus:
{
// ...
"scripts": {
"install": "napa",
// ...
},
"napa": {
"libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}
Wenn Sie npm install
ausführen, klont napa das libvpx-GitHub-Repository in Ihr node_modules
unter dem Namen libvpx
.
Sie können Ihr Build-Skript jetzt so erweitern, dass libvpx erstellt wird. libvpx verwendet zum Erstellen configure
und make
. Glücklicherweise kann Emscripten dazu beitragen, dass configure
und make
den Compiler von Emscripten verwenden. Dazu gibt es die Wrapper-Befehle emconfigure
und 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 ...
Eine C/C++-Bibliothek besteht aus zwei Teilen: den Headern (traditionell .h
- oder .hpp
-Dateien), in denen die Datenstrukturen, Klassen, Konstanten usw. definiert werden, die eine Bibliothek bereitstellt, und der eigentlichen Bibliothek (traditionell .so
- oder .a
-Dateien). Wenn Sie die Konstante VPX_CODEC_ABI_VERSION
der Bibliothek in Ihrem Code verwenden möchten, müssen Sie die Headerdateien der Bibliothek mit einer #include
-Anweisung einbinden:
#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;
}
Das Problem ist, dass der Compiler nicht weiß, wo er nach vpxenc.h
suchen soll.
Dafür gibt es das -I
-Flag. Sie gibt dem Compiler an, in welchen Verzeichnissen nach Headerdateien gesucht werden soll. Außerdem müssen Sie dem Compiler die eigentliche Bibliotheksdatei übergeben:
# ... 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 ...
Wenn Sie jetzt npm run build
ausführen, sehen Sie, dass dabei eine neue .js
- und eine neue .wasm
-Datei erstellt werden und dass die Demoseite tatsächlich die Konstante ausgibt:
Sie werden auch feststellen, dass der Build-Prozess sehr lange dauert. Die Gründe für lange Buildzeiten können variieren. Bei libvpx dauert es lange, weil jedes Mal, wenn Sie den Build-Befehl ausführen, ein Encoder und ein Decoder sowohl für VP8 als auch für VP9 kompiliert werden, auch wenn sich die Quelldateien nicht geändert haben. Selbst eine kleine Änderung an Ihrer my-module.cpp
nimmt viel Zeit in Anspruch. Es ist sehr vorteilhaft, die Build-Artefakte von libvpx nach dem ersten Erstellen beizubehalten.
Eine Möglichkeit dazu ist die Verwendung von Umgebungsvariablen.
# ... 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 ...
(Hier ist ein Gist mit allen Dateien.)
Mit dem Befehl eval
können wir Umgebungsvariablen festlegen, indem wir dem Build-Script Parameter übergeben. Der Befehl test
überspringt das Erstellen von libvpx, wenn $SKIP_LIBVPX
festgelegt (auf einen beliebigen Wert) ist.
Jetzt können Sie das Modul kompilieren, aber die Neuerstellung von libvpx überspringen:
$ npm run build:emscripten -- SKIP_LIBVPX=1
Build-Umgebung anpassen
Manchmal sind für die Erstellung von Bibliotheken zusätzliche Tools erforderlich. Wenn diese Abhängigkeiten in der vom Docker-Image bereitgestellten Build-Umgebung fehlen, müssen Sie sie selbst hinzufügen. Angenommen, Sie möchten auch die Dokumentation von libvpx mit doxygen erstellen. Doxygen ist in Ihrem Docker-Container nicht verfügbar, Sie können es aber mit apt
installieren.
In diesem Fall müssen Sie Doxygen jedes Mal noch einmal herunterladen und installieren, wenn Sie Ihre Bibliothek erstellen möchten.build.sh
Das wäre nicht nur verschwenderisch, sondern würde auch verhindern, dass Sie offline an Ihrem Projekt arbeiten können.
Hier ist es sinnvoll, ein eigenes Docker-Image zu erstellen. Zum Erstellen von Docker-Images wird ein Dockerfile
geschrieben, in dem die Build-Schritte beschrieben werden. Dockerfiles sind sehr leistungsfähig und enthalten viele Befehle. In der Regel reicht es jedoch aus, nur FROM
, RUN
und ADD
zu verwenden. In diesem Fall gilt:
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen
Mit FROM
können Sie angeben, welches Docker-Image Sie als Ausgangspunkt verwenden möchten. Als Grundlage habe ich trzeci/emscripten
ausgewählt – das Bild, das Sie schon immer verwendet haben. Mit RUN
weisen Sie Docker an, Shell-Befehle im Container auszuführen. Alle Änderungen, die diese Befehle am Container vornehmen, sind jetzt Teil des Docker-Images. Wenn Sie prüfen möchten, ob Ihr Docker-Image erstellt wurde und verfügbar ist, bevor Sie build.sh
ausführen, müssen Sie package.json
anpassen:
{
// ...
"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",
// ...
},
// ...
}
(Hier ist ein Gist mit allen Dateien.)
Dadurch wird Ihr Docker-Image erstellt, allerdings nur, wenn es noch nicht erstellt wurde. Dann wird alles wie zuvor ausgeführt, aber in der Build-Umgebung ist jetzt der Befehl doxygen
verfügbar, wodurch auch die Dokumentation von libvpx erstellt wird.
Fazit
Es ist nicht überraschend, dass C/C++-Code und npm nicht gut zusammenpassen. Mit einigen zusätzlichen Tools und der Isolation, die Docker bietet, lässt sich das Problem jedoch ganz einfach lösen. Diese Einrichtung eignet sich nicht für jedes Projekt, ist aber ein guter Ausgangspunkt, den Sie an Ihre Anforderungen anpassen können. Teilen Sie Ihre Verbesserungsvorschläge mit.
Anhang: Docker-Image-Ebenen verwenden
Eine alternative Lösung besteht darin, mehr dieser Probleme mit Docker und dem intelligenten Caching-Ansatz von Docker zu kapseln. Docker führt Dockerfiles Schritt für Schritt aus und weist dem Ergebnis jedes Schritts ein eigenes Image zu. Diese Zwischenbilder werden oft als „Ebenen“ bezeichnet. Wenn sich ein Befehl in einem Dockerfile nicht geändert hat, führt Docker diesen Schritt beim Neuaufbau des Dockerfiles nicht noch einmal aus. Stattdessen wird die Ebene aus dem letzten Build des Bilds wiederverwendet.
Bisher mussten Sie sich etwas Mühe machen, um libvpx nicht jedes Mal neu zu kompilieren, wenn Sie Ihre App erstellen. Stattdessen können Sie die Buildanleitung für libvpx aus Ihrer build.sh
in die Dockerfile
verschieben, um den Cachingmechanismus von Docker zu nutzen:
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
(Hier ist ein Gist mit allen Dateien.)
Sie müssen Git manuell installieren und libvpx klonen, da Sie beim Ausführen von docker build
keine bind-Bereitstellungen haben. Als Nebeneffekt ist Napa nicht mehr erforderlich.