Comment intégrer WebAssembly à cette configuration ? Dans cet article, nous allons résoudre ce problème avec C/C++ et Emscripten, par exemple.
WebAssembly (wasm) est souvent considéré comme une primitive de performance ou comme un moyen d'exécuter votre codebase C++ existant sur le Web. Avec squoosh.app, nous voulions montrer qu'il existe au moins une troisième perspective pour le wasm : exploiter les immenses écosystèmes d'autres langages de programmation. Avec Emscripten, vous pouvez utiliser le code C/C++, la compatibilité de Rust avec Wasm intégrée et l'équipe Go travaille également dessus. Je suis sûr que de nombreuses autres langues suivront.
Dans ces scénarios, Wasm n'est pas la pièce maîtresse de votre application, mais plutôt une pièce de puzzle: un autre module. Votre application dispose déjà de JavaScript, de CSS, de composants Image, d'un système de compilation centré sur le Web et peut-être même d'un framework comme React. Comment intégrer WebAssembly dans cette configuration ? Dans cet article, nous allons utiliser C/C++ et Emscripten à titre d'exemple.
Docker
Docker s'est avéré inestimable lorsque j'ai travaillé avec Emscripten. Les bibliothèques C/C++ sont souvent écrites pour fonctionner avec le système d'exploitation sur lequel elles sont compilées. C'est incroyablement utile d'avoir un environnement cohérent. Avec Docker, vous bénéficiez d'un système Linux virtualisé déjà configuré pour fonctionner avec Emscripten et sur lequel tous les outils et dépendances sont installés. Si quelque chose est manquant, vous pouvez simplement l'installer sans vous soucier de son impact sur votre propre machine ou vos autres projets. En cas de problème, jetez le conteneur et recommencez. Si elle fonctionne une fois, vous pouvez être sûr qu'elle continuera à fonctionner et à produire des résultats identiques.
Le registre Docker contient une image Emscripten de trzeci que j'ai beaucoup utilisée.
Intégration avec npm
Dans la plupart des cas, le point d'entrée d'un projet Web est package.json
de npm. 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 fichier .js
et un fichier .wasm
) doivent être traités comme un simple module JavaScript et un simple élément. Le fichier JavaScript peut être géré par un bundler comme webpack ou un cumul, et le fichier Wasm doit être traité comme tout autre élément binaire plus volumineux, comme des images.
Par conséquent, les artefacts de compilation Emscripten doivent être compilés avant que votre processus de compilation "normal" ne démarre:
{
"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 indiqué précédemment, je vous recommande d'utiliser Docker pour vous assurer que l'environnement de compilation est cohérent.
docker run ... trzeci/emscripten ./build.sh
indique à Docker de créer un nouveau conteneur à l'aide de l'image trzeci/emscripten
et d'exécuter la commande ./build.sh
.
build.sh
est un script shell que vous allez écrire ensuite. --rm
indique à Docker de supprimer le conteneur une fois son exécution terminée. Vous n'accumulez ainsi pas de collection d'images système obsolètes au fil du temps. -v $(pwd):/src
signifie que vous souhaitez que Docker "mette en miroir" le répertoire actuel ($(pwd)
) sur /src
dans le conteneur. Toutes les modifications apportées aux fichiers du répertoire /src
dans le conteneur seront reflétées dans votre projet réel. Ces répertoires dupliqués
sont appelés "montages de liaison".
Voyons 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
passe l'interface système en mode d'échec rapide. Si l'une des commandes du script renvoie une erreur, l'intégralité du script est immédiatement interrompue. Cela peut s'avérer incroyablement utile, car la dernière sortie du script sera toujours un message de réussite ou l'erreur ayant entraîné l'échec de la compilation.
Avec les instructions export
, vous définissez les valeurs de quelques variables d'environnement. Ils vous permettent de transmettre des paramètres de ligne de commande supplémentaires au compilateur C (CFLAGS
), au compilateur C++ (CXXFLAGS
) et à l'éditeur de liens (LDFLAGS
). Ils reçoivent tous les paramètres de l'optimiseur via OPTIMIZE
pour vous assurer que tout est optimisé de la même manière. La variable OPTIMIZE
peut avoir plusieurs valeurs :
-O0
: aucune optimisation n'est effectuée. Aucun code mort n'est éliminé, et Emscripten ne réduit pas non plus la taille du code JavaScript qu'il émet. Utile pour le débogage.-O3
: optimisez de manière agressive pour améliorer les performances.-Os
: optimisation agressive des performances et de la taille en tant que critère secondaire.-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
propose de nombreuses options. Notez qu'emcc est considéré comme une solution de remplacement prête à l'emploi pour les compilateurs tels que GCC ou clang. Par conséquent, tous les indicateurs que vous connaissez peut-être de GCC seront probablement implémentés par emcc. L'option -s
est spéciale, car elle nous permet de configurer Emscripten spécifiquement. Toutes les options disponibles se trouvent dans settings.js
d'Emscripten, mais ce fichier peut être assez intimidant. Voici une liste des indicateurs Emscripten qui, selon moi, sont les plus importants pour les développeurs Web:
--bind
active l'embind.-s STRICT=1
ne prend plus en charge toutes les options de compilation obsolètes. Cela garantit que votre code est compilé avec une compatibilité ascendante.-s ALLOW_MEMORY_GROWTH=1
permet d'augmenter automatiquement la mémoire si nécessaire. Au moment de la rédaction de cet article, Emscripten alloue initialement 16 Mo de mémoire. Lorsque votre code alloue des fragments de mémoire, cette option détermine si ces opérations entraîneront l'échec de l'ensemble du module Wasm lorsque la mémoire est épuisée ou si le code Glue est autorisé à développer la mémoire totale pour s'adapter à l'allocation.-s MALLOC=...
choisit l'implémentation demalloc()
à utiliser.emmalloc
est une implémentationmalloc()
petite et rapide spécifiquement pour Emscripten. L'alternative estdlmalloc
, une implémentationmalloc()
complète. Vous ne devez passer àdlmalloc
que si vous allouez fréquemment de nombreux petits objets ou si vous souhaitez utiliser des threads.-s EXPORT_ES6=1
transforme le code JavaScript en module ES6 avec une exportation par défaut qui fonctionne avec n'importe quel bundler. Nécessite également que-s MODULARIZE=1
soit défini.
Les options suivantes ne sont pas toujours nécessaires ou ne sont utiles qu'à des fins de débogage:
-s FILESYSTEM=0
est un indicateur lié à Emscripten et à sa capacité à émuler un système de fichiers pour vous lorsque votre code C/C++ utilise des opérations de système de fichiers. Il effectue une analyse du code qu'il compile pour décider d'inclure ou non l'émulation du système de fichiers dans le code de liaison. Cependant, cette analyse peut parfois se tromper et vous payez 70 ko de code de liaison 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 à émettre également un fichier de mappage source pour le module Wasm. Pour en savoir plus sur le débogage avec Emscripten, consultez la section sur le débogage.
Et voilà ! Pour tester cette configuration, préparons 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 gist contenant tous les fichiers.)
Pour tout compiler, exécutez
$ npm install
$ npm run build
$ npm run serve
Si vous accédez à localhost:8080, le résultat suivant devrait s'afficher dans la console DevTools :
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 faire partie de votre projet. Vous pouvez ajouter le code manuellement au dépôt de votre projet ou utiliser npm pour gérer ce type de dépendances. Supposons que je souhaite utiliser libvpx dans ma webapp. libvpx est une bibliothèque C++ permettant d'encoder des images avec VP8, le codec utilisé dans les fichiers .webm
.
Cependant, libvpx n'est pas sur npm et ne possède pas de package.json
. Je ne peux donc pas l'installer directement à l'aide de npm.
Pour résoudre ce problème, il existe napa. Napa vous permet d'installer n'importe quelle URL de dépôt Git en tant que dépendance dans votre dossier node_modules
.
Installez napa en tant que dépendance:
$ npm install --save napa
et assurez-vous d'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 dépôt 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
pour être compilé. Heureusement, Emscripten peut vous aider à vous assurer que configure
et make
utilisent le compilateur d'Emscripten. À cette fin, les commandes de wrapper emconfigure
et emmake
sont utilisées:
# ... 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 (fichiers .h
ou .hpp
, traditionnellement) qui définissent les structures de données, les classes, les constantes, etc. qu'une bibliothèque expose, et la bibliothèque réelle (fichiers .so
ou .a
, traditionnellement). Pour utiliser la constante VPX_CODEC_ABI_VERSION
de la bibliothèque dans votre code, vous devez 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 où chercher 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 fournir 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 constaterez que le processus compile une nouvelle .js
et un nouveau fichier .wasm
, et que la page de démonstration générera effectivement la constante:
Vous remarquerez également que le processus de compilation prend beaucoup de temps. Les raisons des longs délais de compilation peuvent varier. Dans le cas de libvpx, cela 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 de compilation, même si les fichiers sources n'ont pas changé. Même un petit changement apporté à votre my-module.cpp
prendra beaucoup de temps à compiler. Il serait très bénéfique de conserver les artefacts de compilation de libvpx une fois qu'ils ont été compilés pour 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 ...
(Voici un gist 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 sont manquantes dans l'environnement de compilation fourni par l'image Docker, vous devez les ajouter vous-même. Par exemple, supposons que vous souhaitiez également créer la documentation de libvpx à l'aide de doxygen. Doxygen 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 télécharger et réinstaller doxygène chaque fois que vous souhaitez créer votre bibliothèque. Non seulement ce serait du gaspillage, mais cela vous empêcherait également de travailler sur votre projet hors connexion.
Dans ce cas, il est judicieux de créer votre propre image Docker. Les images Docker sont créées en écrivant un fichier Dockerfile
décrivant les étapes de compilation. Ils sont assez puissants et comportent de nombreuses commandes, mais la plupart du 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. J'ai choisi trzeci/emscripten
comme base, l'image que vous utilisez 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 de l'image Docker. Pour vous assurer que votre image Docker a été créée et est disponible avant d'exécuter build.sh
, vous devez ajuster un peu votre 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",
// ...
},
// ...
}
(Voici un gist contenant tous les fichiers.)
Cela crée votre image Docker, mais uniquement si elle n'a pas encore été créée. Ensuite, tout s'exécute comme précédemment, mais la commande doxygen
est désormais disponible dans l'environnement de compilation, ce qui entraîne également la création de la documentation de libvpx.
Conclusion
Il n'est pas surprenant que le code C/C++ et npm ne soient pas naturellement intégrés, mais vous pouvez faire en sorte qu'ils fonctionnent de manière assez confortable avec des outils supplémentaires et l'isolation fournie par Docker. Cette configuration ne fonctionnera pas pour tous les projets, mais elle constitue un bon point de départ que vous pouvez ajuster en fonction de vos besoins. Si vous avez des 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 Docker de la mise en cache. Docker exécute les fichiers Dockerfile é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 "couches". Si une commande d'un Dockerfile n'a pas changé, Docker ne réexécutera pas cette étape lorsque vous recompilerez le Dockerfile. Au lieu de cela, il réutilise la couche de la dernière fois que l'image a été créée.
Auparavant, vous deviez faire des efforts pour ne pas recompiler libvpx chaque fois que vous compilez votre application. Au lieu de cela, vous pouvez déplacer les instructions de compilation de libvpx de votre build.sh
vers Dockerfile
pour utiliser le mécanisme de mise en cache de 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
(Voici un gist contenant tous les fichiers.)
Notez que vous devez installer manuellement git et cloner libvpx, car vous n'avez pas de montages de liaisons lorsque vous exécutez docker build
. Comme effet secondaire, la Napa n’est plus nécessaire.