Emscripten und npm

Wie integrieren Sie WebAssembly in diese Konfiguration? In diesem Artikel werden wir dies anhand von C/C++ und Emscripten als Beispiel besprechen.

WebAssembly (wasm) wird oft als Leistungsprimitive oder als Möglichkeit zur Ausführung Ihrer vorhandenen C++-Codebasis im Web dargestellt. 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 integrieren Sie WebAssembly in diese Konfiguration? 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 sehr hilfreich. Mit Docker erhalten Sie ein virtualisiertes Linux-System, das bereits für die Arbeit mit Emscripten eingerichtet ist und alle erforderlichen Tools und Abhängigkeiten installiert hat. Wenn etwas fehlt, können Sie es einfach installieren, ohne sich Gedanken darüber machen zu müssen, wie sich das auf Ihren eigenen Computer oder Ihre anderen Projekte auswirkt. Wenn etwas schief geht, werfen Sie den Behälter weg und beginnen Sie von vorn. Wenn es einmal funktioniert, können Sie sicher sein, dass es auch weiterhin funktioniert und identische Ergebnisse liefert.

In der Docker Registry gibt es ein Emscripten-Image von trzeci, das ich intensiv verwendet habe.

Einbindung in npm

In den meisten Fällen ist der Einstiegspunkt eines Webprojekts package.json von npm. Die meisten Projekte können standardmäßig 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. Die WASM-Datei sollte wie jedes andere größere Binär-Asset wie Bilder behandelt werden.

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 build:emscripten-Aufgabe könnte Emscripten direkt aufrufen. Wie bereits erwähnt, empfehle ich jedoch, Docker zu verwenden, um für eine einheitliche Buildumgebung zu sorgen.

Mit docker run ... trzeci/emscripten ./build.sh wird Docker angewiesen, einen neuen Container mit dem trzeci/emscripten-Image zu starten und den Befehl ./build.sh auszuführen. build.sh ist ein Shell-Script, das Sie als Nächstes schreiben werden. Mit --rm wird Docker angewiesen, den Container nach Abschluss 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 im Container vornehmen, werden in Ihrem tatsächlichen Projekt gespiegelt. Diese gespiegelten Verzeichnisse werden als „Bindungs-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 "============================================="

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: Keine Optimierung vornehmen. Es wird kein inaktiver Code entfernt und Emscripten minimiert auch nicht den von ihm generierten JavaScript-Code. Gut für die Fehlerbehebung.
  • -O3: Leistung aggressiv optimieren.
  • -Os: Leistung wird aggressiv optimiert und die Größe ist ein sekundäres Kriterium.
  • -Oz: Die Größe wird stark optimiert, wobei bei Bedarf die Leistung geopfert wird.

Für das Web empfehle ich meistens -Os.

Der Befehl emcc hat eine Vielzahl eigener Optionen. emcc soll ein „direkter Ersatz für Compiler wie GCC oder clang“ sein. Daher werden alle Flags, die Sie von GCC kennen, höchstwahrscheinlich auch von emcc implementiert. Das Flag -s ist insofern besonders, als es uns ermöglicht, Emscripten speziell zu konfigurieren. Alle verfügbaren Optionen finden Sie in der Datei settings.js von Emscripten. 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 erweitert werden. Zum Zeitpunkt der Erstellung dieses Artikels weist Emscripten anfangs 16 MB Arbeitsspeicher zu. Wenn Ihr Code Speicherbereiche zuweist, wird mit dieser Option festgelegt, ob diese Vorgänge dazu führen, dass das gesamte WASM-Modul fehlschlägt, wenn der Speicher aufgebraucht ist, oder ob der Glue-Code den Gesamtspeicher erweitern darf, um die Zuweisung zu ermöglichen.
  • -s MALLOC=... wählt aus, welche malloc()-Implementierung verwendet werden soll. 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, das sich auf Emscripten bezieht und die Möglichkeit bietet, ein Dateisystem 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 70 kB zusätzlichen Klebecode für eine Dateisystem-Emulation, 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 Informationen zur Fehlerbehebung in die .wasm einfügt und auch eine Quellkartendatei 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 mit einer 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 ein Gist mit allen Dateien.)

Führen Sie zum Erstellen aller Dateien Folgendes aus:

$ npm install
$ npm run build
$ npm run serve

Wenn Sie localhost:8080 aufrufen, sollte in der DevTools-Konsole die folgende Ausgabe angezeigt werden:

In den DevTools wird eine Nachricht angezeigt, die über C++ und Emscripten ausgegeben wurde.

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 VP8, dem Codec, der in .webm-Dateien verwendet wird. Libvpx ist jedoch nicht bei npm verfügbar und hat keine package.json. Daher kann ich sie nicht direkt über npm installieren.

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 unter dem Namen libvpx in Ihr node_modules.

Sie können Ihr Build-Script jetzt um den Build von libvpx erweitern. Für den Build von libvpx werden configure und make verwendet. Glücklicherweise kann Emscripten dafür sorgen, dass configure und make den Emscripten-Compiler 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 angeben:

# ... 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:

DevTools mit der ABI-Version von libvpx, die über emscripten gedruckt wurde

Außerdem wird der Build-Prozess sehr lange dauern. 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 Ihrem my-module.cpp dauert lange. Es ist sehr nützlich, die Build-Artefakte von libvpx aufzubewahren, nachdem sie zum ersten Mal erstellt wurden.

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. Wenn $SKIP_LIBVPX festgelegt ist (auf einen beliebigen Wert), wird das Erstellen von libvpx übersprungen.test

Jetzt können Sie Ihr Modul kompilieren, aber das Neukompilieren 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.

Wenn Sie das in Ihrer build.sh tun würden, müssten Sie Doxygen jedes Mal neu herunterladen und installieren, wenn Sie Ihre Bibliothek erstellen möchten. 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. Docker-Images werden erstellt, indem eine Dockerfile geschrieben wird, die die Buildschritte beschreibt. Dockerfiles sind recht leistungsfähig und enthalten viele Befehle. In den meisten Fällen reichen jedoch 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 angeben, welches Docker-Image Sie als Ausgangspunkt verwenden möchten. Ich habe trzeci/emscripten als Grundlage verwendet, das Bild, das Sie bisher 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. Damit Ihr Docker-Image erstellt wurde und verfügbar ist, bevor Sie build.sh ausführen, müssen Sie package.json ein wenig 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, aber nur, wenn es noch nicht erstellt wurde. Danach läuft alles wie zuvor, aber jetzt ist in der Build-Umgebung 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. Wenn Sie Verbesserungsvorschläge haben, teilen Sie uns diese bitte mit.

Anhang: Docker-Imageebenen 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 Bindungs-Mounts haben. Als Nebeneffekt ist Napa nicht mehr erforderlich.