Emscripten und npm

Wie integrieren Sie WebAssembly in dieses Setup? In diesem Artikel sehen wir uns ein Beispiel mit C/C++ und Emscripten an.

WebAssembly (wasm) wird oft entweder als eine einfache Performance oder als Möglichkeit verwendet, Ihre vorhandene C++-Codebasis im Web auszuführen. 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 verfügt über Wasm-Unterstützung und das Go-Team arbeitet ebenfalls daran. Ich bin mir sicher, dass viele weitere Sprachen folgen werden.

In diesen Szenarien ist Wasm nicht das Herzstück Ihrer App, sondern ein Puzzleteil, sondern ein weiteres Modul. Ihre App verfügt bereits über JavaScript, CSS, Bild-Assets, ein weborientiertes Build-System und vielleicht sogar ein Framework wie React. Wie integrieren Sie WebAssembly in dieses Setup? In diesem Artikel sehen wir uns das mit C/C++ und Emscripten an.

Docker

Ich habe Docker als unschätzbarem Wert bei der Arbeit mit Emscripten empfunden. C/C++-Bibliotheken sind häufig so geschrieben, dass sie mit dem Betriebssystem funktionieren, auf dem sie basieren. Eine einheitliche Umgebung ist unglaublich hilfreich. Mit Docker erhalten Sie ein virtualisiertes Linux-System, das bereits für die Arbeit 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 zu machen, wie es sich auf Ihren eigenen Computer oder Ihre anderen Projekte auswirkt. Wenn ein Fehler auftritt, werfen Sie den Container weg und beginnen Sie noch einmal von vorn. Wenn er einmal funktioniert, können Sie sicher sein, dass er weiterhin funktioniert und zu identischen Ergebnissen führt.

Die Docker Registry enthält ein Emscripten-Image von trzeci, das ich bereits intensiv verwende.

Einbindung in npm

In den meisten Fällen ist der Einstiegspunkt für ein Webprojekt der package.json von npm. Konventionsgemäß können die meisten Projekte mit npm install && npm run build erstellt werden.

Im Allgemeinen sollten die von Emscripten erzeugten Build-Artefakte (.js- und .wasm-Dateien) wie ein weiteres JavaScript-Modul und nur ein weiteres 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 wie Bilder behandelt werden.

Daher müssen die Emscripten-Build-Artefakte erstellt werden, bevor der „normale“ Build-Prozess startet:

{
    "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 build:emscripten-Aufgabe könnte Emscripten direkt aufrufen, aber wie bereits erwähnt, empfehlen wir die Verwendung von Docker, damit die Build-Umgebung konsistent ist.

docker run ... trzeci/emscripten ./build.sh weist Docker an, mit dem Image trzeci/emscripten einen neuen Container zu starten und den Befehl ./build.sh auszuführen. build.sh ist ein Shell-Skript, das Sie als Nächstes schreiben werden. --rm weist Docker an, den Container nach der Ausführung zu löschen. So vermeiden Sie, dass sich im Laufe der Zeit eine Sammlung veralteter Maschinen-Images ansammelt. -v $(pwd):/src bedeutet, dass Sie möchten, dass Docker das aktuelle Verzeichnis ($(pwd)) innerhalb des Containers nach /src "spiegelt". Alle Änderungen, die Sie an Dateien im Verzeichnis /src innerhalb des Containers vornehmen, werden für Ihr tatsächliches Projekt übernommen. Diese gespiegelten Verzeichnisse werden als „bind mounts“ bezeichnet.

Sehen wir uns build.sh an:

#!/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 "============================================="

Hier gibt es viel zu lernen!

set -e versetzt die Shell in den Modus „Schnell schlagen“. Wenn Befehle im Skript einen Fehler zurückgeben, wird das gesamte Skript sofort abgebrochen. Dies kann äußerst hilfreich sein, da die letzte Ausgabe des Skripts 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. Sie ermöglichen es Ihnen, zusätzliche Befehlszeilenparameter an den C-Compiler (CFLAGS), den C++-Compiler (CXXFLAGS) und den Linker (LDFLAGS) zu übergeben. Alle erhalten die Optimierungseinstellungen über OPTIMIZE, damit alles auf die gleiche Weise optimiert wird. Die Variable OPTIMIZE kann verschiedene Werte haben:

  • -O0: Es wird keine Optimierung vorgenommen. Kein toter Code wird entfernt und Emscripten komprimiert auch den von ihm ausgegebenen JavaScript-Code nicht. Gut für die Fehlerbehebung.
  • -O3: Aggressiv im Hinblick auf die Leistung optimieren
  • -Os: Aggressiv im Hinblick auf Leistung und Größe als sekundäres Kriterium optimieren
  • -Oz: Optimieren Sie aggressiv im Hinblick auf die Größe, wobei Sie bei Bedarf Abstriche bei der Leistung machen müssen.

Für das Web empfehle ich hauptsächlich -Os.

Der Befehl emcc bietet eine Vielzahl von eigenen Optionen. Beachten Sie, dass emcc ein „Drop-in-Ersatz für Compiler wie GCC oder Clang“ sein soll. Alle Flags, die Sie von GCC kennen, werden also höchstwahrscheinlich auch von „emcc“ implementiert. Das Flag -s ist insofern etwas Besonderes, als es uns ermöglicht, Emscripten spezifisch zu konfigurieren. Alle verfügbaren Optionen finden Sie in der settings.js von Emscripten, aber die Datei kann ziemlich überwältigend sein. Hier ist eine Liste der Emscripten-Flags, die meiner Meinung nach für Webentwickler am wichtigsten sind:

  • --bind aktiviert embind.
  • -s STRICT=1 entfernt die Unterstützung für alle verworfenen Build-Optionen. Dadurch wird sichergestellt, dass Ihr Code aufwärtskompatibel erstellt wird.
  • Mit -s ALLOW_MEMORY_GROWTH=1 kann der Arbeitsspeicher bei Bedarf automatisch vergrößert werden. Beim Schreiben weist Emscripten anfangs 16 MB Arbeitsspeicher zu. Wenn Ihr Code Speicherblöcke zuweist, entscheidet diese Option, ob bei diesen Vorgängen das gesamte Wasm-Modul fehlschlägt, wenn der Arbeitsspeicher erschöpft ist, oder ob der Glue-Code den gesamten Arbeitsspeicher erweitern darf, um die Zuweisung zu ermöglichen.
  • Mit -s MALLOC=... wird die zu verwendende malloc()-Implementierung ausgewählt. emmalloc ist eine kleine und schnelle malloc()-Implementierung speziell für Emscripten. Die Alternative ist dlmalloc, eine vollwertige malloc()-Implementierung. Sie müssen nur zu dlmalloc 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 nur für die Fehlerbehebung hilfreich:

  • -s FILESYSTEM=0 ist ein Flag für Emscripten und kann ein Dateisystem für Sie emulieren, wenn Ihr C-/C++-Code Dateisystemvorgänge verwendet. Der kompilierte Code wird analysiert, um zu entscheiden, ob die Dateisystememulation in den Glue-Code aufgenommen wird oder nicht. Manchmal kann diese Analyse jedoch falsch sein und Sie bezahlen ziemlich hohe 70 KB an zusätzlichem Leimcode für eine Dateisystememulation, die Sie möglicherweise nicht benötigen. Mit -s FILESYSTEM=0 können Sie erzwingen, dass Emscripten diesen Code nicht einfügt.
  • -g4 sorgt dafür, dass Emscripten Debugging-Informationen in .wasm enthält und außerdem eine Quellzuordnungsdatei für das Wasm-Modul ausgibt. Weitere Informationen zur Fehlerbehebung mit Emscripten finden Sie im Abschnitt zur Fehlerbehebung.

Das war schon alles. Um diese Einrichtung zu testen, erstellen wir ein kleines my-module.cpp-Objekt:

    #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 ein gist, der alle Dateien enthält.

Führen Sie folgenden Befehl aus, um alles zu erstellen:

$ npm install
$ npm run build
$ npm run serve

Wenn Sie zu localhost:8080 wechseln, sollte in der Entwicklertools-Konsole die folgende Ausgabe angezeigt werden:

Entwicklertools mit einer über C++ und Emscripten gedruckten Meldung.

C-/C++-Code als Abhängigkeit hinzufügen

Wenn Sie eine C/C++-Bibliothek für Ihre Webanwendung erstellen möchten, muss deren Code Teil Ihres Projekts sein. Sie können den Code manuell in das Repository Ihres Projekts einfügen oder auch diese Art von Abhängigkeiten mit npm verwalten. Angenommen, ich möchte libvpx in meiner Webanwendung verwenden. libvpx ist eine C++-Bibliothek zum Codieren von Bildern mit dem in .webm-Dateien verwendeten Codec VP8. libvpx befindet sich jedoch nicht auf npm und hat kein package.json, also kann ich es nicht direkt mit npm installieren.

Um diesem Rätsel zu begegnen, gibt es napa. Mit napa können Sie jede beliebige 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 Ihre node_modules unter dem Namen libvpx.

Sie können Ihr Build-Skript jetzt um libvpx erweitern. libvpx verwendet für den Build configure und make. Glücklicherweise kann Emscripten dabei helfen, dass configure und make den Compiler von Emscripten verwenden. Zu diesem Zweck 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 (früher .h- oder .hpp-Dateien), die die von einer Bibliothek verfügbaren Datenstrukturen, Klassen, Konstanten usw. definieren, und die eigentliche Bibliothek (früher .so- oder .a-Dateien). Wenn Sie die VPX_CODEC_ABI_VERSION-Konstante der Bibliothek in Ihrem Code verwenden möchten, müssen Sie die Headerdateien der Bibliothek mithilfe einer #include-Anweisung hinzufügen:

#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 ist das Flag -I vorgesehen. Sie teilt dem Compiler mit, welche Verzeichnisse auf Headerdateien geprüft werden sollen. Darüber hinaus müssen Sie dem Compiler auch die eigentliche Bibliotheksdatei zur Verfügung stellen:

# ... 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 npm run build jetzt ausführen, sehen Sie, dass der Prozess eine neue .js- und eine neue .wasm-Datei erstellt und dass die Demoseite tatsächlich die Konstante ausgibt:

Entwicklertools mit der ABI-Version von libvpx, die über Emscripten gedruckt wird.

Sie werden auch feststellen, dass der Build-Prozess sehr lange dauert. Der Grund für lange Build-Zeiten kann variieren. Bei libvpx dauert es sehr lange, da bei jeder Ausführung des Build-Befehls ein Encoder und ein Decoder für VP8 und VP9 kompiliert werden, obwohl die Quelldateien nicht geändert wurden. Selbst eine kleine Änderung an my-module.cpp nimmt viel Zeit in Anspruch. Es wäre sehr vorteilhaft, die Build-Artefakte von libvpx nach der ersten Erstellung beizubehalten.

Eine Möglichkeit, dies zu erreichen, 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 sehen Sie einen gist, der alle Dateien enthält.

Mit dem Befehl eval können Sie Umgebungsvariablen festlegen, indem Sie Parameter an das Build-Skript übergeben. Der Befehl test überspringt das Erstellen von libvpx, wenn $SKIP_LIBVPX auf einen beliebigen Wert festgelegt ist.

Jetzt können Sie Ihr 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 die Dokumentation von libvpx mit doxygen erstellen. Doxygen ist in Ihrem Docker-Container nicht verfügbar. Sie können es aber mit apt installieren.

Würden Sie dies in Ihrer build.sh machen, laden Sie doxygen jedes Mal neu herunter und installieren es neu, wenn Sie Ihre Bibliothek erstellen möchten. Das wäre nicht nur Verschwendung, sondern würde Sie auch davon abhalten, offline an Ihrem Projekt zu arbeiten.

In diesem Fall ist es sinnvoll, ein eigenes Docker-Image zu erstellen. Docker-Images werden durch Schreiben eines Dockerfile erstellt, das die Build-Schritte beschreibt. Dockerfiles sind ziemlich leistungsfähig und enthalten viele Befehle, aber in den meisten Fällen reicht die Verwendung von FROM, RUN und ADD aus. In diesem Fall gilt:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

Mit FROM können Sie festlegen, welches Docker-Image Sie als Ausgangspunkt verwenden möchten. Als Grundlage habe ich trzeci/emscripten ausgewählt – das Bild, das du schon die ganze Zeit verwendet hast. 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 das package.json-Bit 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 sehen Sie einen gist, der alle Dateien enthält.

Dadurch wird Ihr Docker-Image erstellt, jedoch nur, wenn es noch nicht erstellt wurde. Dann wird alles wie zuvor ausgeführt, aber jetzt ist in der Build-Umgebung der Befehl doxygen verfügbar, wodurch auch die Dokumentation von libvpx erstellt wird.

Fazit

Es ist nicht verwunderlich, dass sich C/C++-Code und npm nicht eignen, aber Sie können dafür sorgen, dass es mit einigen zusätzlichen Tools und der von Docker bereitgestellten Isolation problemlos funktioniert. Diese Einrichtung funktioniert nicht für jedes Projekt, ist aber ein guter Ausgangspunkt, den Sie an Ihre Anforderungen anpassen können. Wenn Sie Verbesserungen haben, teilen Sie uns diese bitte mit.

Anhang: Docker-Image-Ebenen verwenden

Eine alternative Lösung besteht darin, mehr dieser Probleme mit dem intelligenten Caching-Ansatz von Docker und 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 „Layers“ bezeichnet. Wenn ein Befehl in einem Dockerfile nicht geändert wurde, führt Docker diesen Schritt bei der Neuerstellung des Dockerfiles nicht noch einmal aus. Stattdessen wird die Ebene vom letzten Zeitpunkt der Image-Erstellung wiederverwendet.

Bisher mussten Sie libvpx nicht jedes Mal neu erstellen, wenn Sie Ihre Anwendung erstellen. Stattdessen können Sie die Erstellungsanleitung für libvpx von der build.sh in das Dockerfile verschieben, um den Docker-Caching-Mechanismus 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 sehen Sie einen gist, der alle Dateien enthält.

Beachten Sie, dass Sie git manuell installieren und libvpx klonen müssen, da Sie beim Ausführen von docker build keine Bind-Bereitstellungen haben. Als Nebeneffekt wird Napa nicht mehr benötigt.