Emscripten et npm

Comment intégrer WebAssembly dans cette configuration ? Dans cet article, nous allons résoudre ce problème avec C/C++ et Emscripten à titre d'exemple.

WebAssembly (wasm) est souvent soit conçue comme une primitive de performance, soit comme un moyen d'exécuter votre C++ existant sur le Web. Avec squoosh.app, nous voulions vous montrer qu'il existe au moins une troisième perspective pour le Wasm: exploiter l'énorme des écosystèmes d’autres langages de programmation. Avec Emscripten, vous pouvez utiliser du code C/C++, Compatible avec Wasm, le Go l'équipe travaille également dessus. Je suis que beaucoup d'autres langues suivront.

Dans ces scénarios, Wasm n'est pas la pièce maîtresse de votre application, mais plutôt un puzzle. pièce: encore un autre module. Votre application contient déjà du code JavaScript, CSS, image, un système de compilation centré sur le Web et peut-être même un framework comme React. Comment intégrer WebAssembly dans cette configuration ? Dans cet article, nous allons travailler avec C/C++ et Emscripten comme exemple.

Docker

Pour moi, Docker est très utile lorsque je travaille avec Emscripten. C/C++ les bibliothèques sont souvent écrites pour fonctionner avec le système d’exploitation sur lequel elles sont basées. C'est incroyablement utile d'avoir un environnement cohérent. Avec Docker, vous obtenez virtualisé Linux, déjà configuré pour fonctionner avec Emscripten, tous les outils et dépendances installés. S'il manque quelque chose, vous pouvez simplement sans avoir à vous soucier de l'impact sur votre propre machine d'autres projets. En cas de problème, jetez le conteneur terminé. S'il fonctionne une fois, vous pouvez être sûr qu'il continuera à fonctionner et produit des résultats identiques.

Le registre Docker dispose d'un fichier Emscripten image de trzeci que j'utilise beaucoup.

Intégration à npm

Dans la plupart des cas, le point d'entrée d'un projet Web est package.json Par convention, la plupart des projets peuvent être créés avec npm install && npm run build.

En général, les artefacts de compilation produits par Emscripten (un .js et un .wasm ) doivent être traités comme un simple module JavaScript et un autre élément. Le fichier JavaScript peut être géré par un bundler comme webpack ou Rollup, et le fichier Wasm doit être traité comme tout autre élément binaire plus volumineux, comme images.

Par conséquent, les artefacts de compilation Emscripten doivent être compilés avant votre configuration "normal" le processus de compilation entre en action:

{
    "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 nouvelle tâche build:emscripten pourrait appeler directement Emscripten, mais comme nous vous recommandons d'utiliser Docker pour vérifier que l'environnement de compilation cohérentes.

docker run ... trzeci/emscripten ./build.sh indique à Docker de lancer une nouvelle à l'aide de l'image trzeci/emscripten et exécutez la commande ./build.sh. build.sh est un script shell que vous allez écrire ensuite. --rm raconte Docker pour supprimer le conteneur une fois son exécution terminée. De cette façon, vous ne construisez pas une collection d'images système obsolètes au fil du temps. -v $(pwd):/src signifie que que Docker doit "mettre en miroir" le répertoire actuel ($(pwd)) vers /src à l'intérieur le conteneur. Toutes les modifications que vous apportez aux fichiers du répertoire /src à l'intérieur de conteneur seront mis en miroir dans votre projet réel. Ces répertoires dupliqués sont appelés "montages de liaison".

Examinons 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 "============================================="

Il y a beaucoup de choses à découvrir ici !

set -e place le shell en mode "fail fast" (échec rapide). . Si des commandes du script renvoie une erreur, l'ensemble du script est immédiatement abandonné. Il peut s'agir incroyablement utile car la dernière sortie du script aura toujours un succès ou l'erreur qui a causé l'échec de la compilation.

Avec les instructions export, vous définissez les valeurs de quelques environnements variables. Ils vous permettent de transmettre des paramètres de ligne de commande supplémentaires au le compilateur (CFLAGS), le compilateur C++ (CXXFLAGS) et l'éditeur de liens (LDFLAGS). Ils reçoivent tous les paramètres de l'optimiseur via OPTIMIZE pour s'assurer que tout est optimisé de la même manière. Plusieurs valeurs sont possibles Pour la variable OPTIMIZE:

  • -O0: ne procédez à aucune optimisation. Aucun code mort n'est éliminé, et Emscripten ne réduit pas non plus la taille du code JavaScript qu'il émet. Convient pour le débogage.
  • -O3: optimiser les performances de manière agressive
  • -Os: optimisation agressive des performances et de la taille en tant qu'objectif secondaire critère.
  • -Oz: procéder à une optimisation agressive en fonction de la taille, en sacrifiant les performances si nécessaire.

Pour le Web, je recommande plutôt -Os.

La commande emcc possède une multitude d'options. Notez que le fichier emcc est est censé être un "remplacement intégré pour les compilateurs tels que GCC ou clang". Donc tous que vous connaissez peut-être auprès de GCC seront très probablement implémentés par emcc bien. L'option -s est spéciale, car elle nous permet de configurer Emscripten en particulier. Vous trouverez toutes les options disponibles dans settings.js, mais ce fichier peut être assez écrasant. Voici la liste des indicateurs Emscripten qui, selon moi, sont les plus importants pour les développeurs Web:

  • --bind activations embind.
  • -s STRICT=1 ne prend plus en charge toutes les options de compilation obsolètes. Cela garantit que votre code construit de manière compatible avec les versions ultérieures.
  • -s ALLOW_MEMORY_GROWTH=1 permet d'augmenter automatiquement la mémoire si nécessaires. Au moment de la rédaction de ce document, Emscripten alloue 16 Mo de mémoire au départ. Lorsque votre code alloue des fragments de mémoire, cette option détermine si ces opérations feront échouer l'ensemble du module Wasm lorsque la mémoire est épuisé, ou si le code Glue est autorisé à développer la mémoire totale pour pour l'allocation.
  • -s MALLOC=... choisit l'implémentation de malloc() à utiliser. emmalloc correspond à une implémentation malloc() petite et rapide spécialement pour Emscripten. La l'alternative est dlmalloc, une implémentation malloc() complète. Vous uniquement besoin de passer à dlmalloc si vous allouez de nombreux petits objets fréquemment ou si vous voulez utiliser des threads.
  • -s EXPORT_ES6=1 transforme le code JavaScript en module ES6 avec une Exportation par défaut compatible avec n'importe quel bundler. Nécessite également -s MODULARIZE=1 pour définis.

Les options suivantes ne sont pas toujours nécessaires ou ne sont utiles que pour le débogage finalités:

  • -s FILESYSTEM=0 est un indicateur lié à Emscripten et permettant de émuler un système de fichiers pour vous lorsque votre code C/C++ utilise des opérations de système de fichiers. Il analyse le code qu'il compile pour décider d'inclure ou non le l'émulation du système de fichiers dans le code glue ou non. Parfois, cependant, peut se tromper et vous payez 70 Ko en colle supplémentaire, pour une émulation de système de fichiers dont vous n'avez peut-être pas besoin. Avec -s FILESYSTEM=0, vous pouvez forcer Emscripten à ne pas inclure ce code.
  • -g4 obligera Emscripten à inclure des informations de débogage dans .wasm et émet également un fichier de mappage source pour le module Wasm. Pour en savoir plus sur avec Emscripten dans leur méthode de débogage .

Et voilà ! Pour tester cette configuration, créons un petit 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);
    }

Et 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>

(Voici un essentiel contenant tous les fichiers).

Pour tout compiler, exécutez

$ npm install
$ npm run build
$ npm run serve

En accédant à localhost:8080, vous devriez obtenir le résultat suivant dans Console des outils de développement:

Outils de développement affichant un message imprimé via C++ et Emscripten.

Ajouter du code C/C++ en tant que dépendance

Si vous souhaitez créer une bibliothèque C/C++ pour votre application Web, son code doit être de votre projet. Vous pouvez ajouter manuellement le code au dépôt de votre projet ou vous pouvez également utiliser npm pour gérer ce type de dépendances. Disons que je utiliser libvpx dans l'application Web libvpx est une bibliothèque C++ permettant d'encoder des images avec le codec VP8, utilisé dans les fichiers .webm. Cependant, libvpx n'est pas sur npm et n'a pas de package.json. Je ne peux donc pas installez-le directement en utilisant npm.

Pour résoudre ce casse-tête, Napa. Napa vous permet d'installer n'importe quel l'URL du dépôt en tant que dépendance dans votre dossier node_modules.

Installez napa en tant que dépendance:

$ npm install --save napa

Veillez à exécuter napa en tant que script d'installation:

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

Lorsque vous exécutez npm install, napa se charge de cloner le fichier GitHub libvpx dans votre node_modules, sous le nom libvpx.

Vous pouvez maintenant étendre votre script de compilation pour compiler libvpx. libvpx utilise configure et make à créer. Heureusement, Emscripten peut s'assurer que configure et make utilise le compilateur d'Emscripten. À cette fin, le wrapper commandes emconfigure et 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 ...

Une bibliothèque C/C++ est divisée en deux parties: les en-têtes (généralement .h ou .hpp) qui définissent les structures de données, les classes, les constantes, etc. qu'un de la bibliothèque et de la bibliothèque proprement dite (généralement des fichiers .so ou .a). À utilisez la constante VPX_CODEC_ABI_VERSION de la bibliothèque dans votre code, vous obtenez pour inclure les fichiers d'en-tête de la bibliothèque à l'aide d'une instruction #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;
}

Le problème est que le compilateur ne sait pas rechercher vpxenc.h. C'est à cela que sert l'indicateur -I. Il indique au compilateur quels répertoires vérifier les fichiers d'en-tête. De plus, vous devez également donner au compilateur le fichier de bibliothèque réel:

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

Si vous exécutez npm run build maintenant, vous verrez que le processus crée une nouvelle .js. et un nouveau fichier .wasm, et que la page de démonstration renverra bien la constante:

DevTools
affichant la version ABI de libvpx imprimée via emscripten.

Vous remarquerez également que le processus de compilation prend beaucoup de temps. La raison pour laquelle les longues durées de compilation peuvent varier. Dans le cas de libvpx, l'opération prend beaucoup de temps, car il compile un encodeur et un décodeur pour VP8 et VP9 chaque fois que vous exécutez votre commande build, même si les fichiers sources n'ont pas changé. Même un petit La compilation d'une modification de votre my-module.cpp prendra beaucoup de temps. Il serait très utile de conserver les artefacts de compilation de libvpx une fois qu'ils ont été construit la première fois.

Pour ce faire, vous pouvez utiliser des variables d'environnement.

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

(en bref contenant tous les fichiers).

La commande eval permet de définir des variables d'environnement en transmettant des paramètres au script de compilation. La commande test ignore la compilation de libvpx si $SKIP_LIBVPX est défini sur n'importe quelle valeur.

Vous pouvez maintenant compiler votre module, mais sans avoir à recréer libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personnaliser l'environnement de compilation

Les bibliothèques ont parfois besoin d'outils supplémentaires pour se compiler. Si ces dépendances manquants dans l'environnement de compilation fourni par l'image Docker, vous devez ajoutez-les vous-même. Par exemple, supposons que vous souhaitiez également créer de libvpx utilisant doxygen. Doxygène n’est pas disponible dans votre conteneur Docker, mais vous pouvez l'installer à l'aide de apt.

Si vous le faisiez dans votre build.sh, vous devez le télécharger à nouveau, puis le réinstaller doxygène chaque fois que vous voulez développer votre bibliothèque. Non seulement ce serait gaspillage, mais cela vous empêcherait également de travailler sur votre projet hors ligne.

Dans ce cas, il est judicieux de créer votre propre image Docker. Les images Docker sont créées par écrire une Dockerfile qui décrit les étapes de compilation. Les Dockerfiles sont assez puissant et ont beaucoup de commandes, mais la plupart des temps, vous pouvez simplement utiliser FROM, RUN et ADD. Dans ce cas :

FROM trzeci/emscripten

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

Avec FROM, vous pouvez déclarer l'image Docker que vous souhaitez utiliser comme point de départ. point d'accès. J'ai choisi trzeci/emscripten comme base (l'image que vous avez utilisée) depuis le début. Avec RUN, vous demandez à Docker d'exécuter des commandes shell dans le conteneur. Les modifications apportées par ces commandes au conteneur font désormais partie l'image Docker. Pour vous assurer que votre image Docker a été créée et qu'elle est disponibles avant d'exécuter build.sh, vous devez ajuster votre package.json bit:

{
    // ...
    "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",
    // ...
    },
    // ...
}

(en bref contenant tous les fichiers).

Votre image Docker sera créée, mais seulement si elle n'a pas encore été créée. Ensuite, Tout s'exécute comme précédemment, mais l'environnement de compilation dispose désormais du doxygen disponible. La documentation de libvpx sera donc compilée bien.

Conclusion

Il n'est pas surprenant que le code C/C++ et npm ne soient pas un ajustement naturel, mais vous pouvez pour qu'il fonctionne confortablement, avec d'autres outils et l'isolement fournies par Docker. Cette configuration ne fonctionne pas pour tous les projets, mais il s'agit point de départ correct que vous pouvez adapter à vos besoins. Si vous avez d'améliorations, veuillez les partager.

Annexe: Utiliser les couches d'image Docker

Une autre solution consiste à encapsuler davantage de ces problèmes avec Docker et L'approche intelligente de la mise en cache de Docker Docker exécute les Dockerfiles étape par étape et attribue au résultat de chaque étape une image qui lui est propre. Ces images intermédiaires sont souvent appelées "calques". Si une commande dans un Dockerfile n'a pas changé, n'exécutera pas à nouveau cette étape lorsque vous recompilez le Dockerfile. À la place il réutilise la couche de la dernière création de l'image.

Auparavant, vous deviez faire des efforts pour ne pas recompiler libvpx à chaque fois lors de la création de votre application. À la place, vous pouvez déplacer les instructions de compilation de libvpx depuis votre build.sh vers le Dockerfile pour exploiter la mise en cache de Docker. mécanisme:

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

(en bref contenant tous les fichiers).

Notez que vous devez installer manuellement git et cloner libvpx, car vous n'avez pas des montages liés lors de l'exécution de docker build. Comme effet secondaire, il n'est pas nécessaire maintenant.