Эмскриптен и нпм

Как интегрировать WebAssembly в эту систему? В этой статье мы рассмотрим это на примере C/C++ и Emscripten.

WebAssembly (wasm) часто позиционируется либо как примитив производительности, либо как способ запустить существующую кодовую базу C++ в интернете. С помощью squoosh.app мы хотели показать, что у wasm есть как минимум третья перспектива: использование обширных экосистем других языков программирования. С Emscripten вы можете использовать код C/C++, в Rust есть встроенная поддержка wasm , и команда Go тоже работает над этим . Уверен, что многие другие языки последуют этому примеру.

В таких случаях wasm — не центральный элемент вашего приложения, а скорее элемент пазла: ещё один модуль. В вашем приложении уже есть JavaScript, CSS, изображения, веб-ориентированная система сборки и, возможно, даже фреймворк, например React. Как интегрировать WebAssembly в эту систему? В этой статье мы рассмотрим это на примере C/C++ и Emscripten.

Докер

Я обнаружил, что Docker бесценен при работе с Emscripten. Библиотеки C/C++ часто пишутся для работы с операционной системой, на которой они основаны. Наличие согласованной среды невероятно полезно. С Docker вы получаете виртуализированную систему Linux, уже настроенную для работы с Emscripten и имеющую все необходимые инструменты и зависимости. Если чего-то не хватает, вы можете просто установить это, не беспокоясь о том, как это повлияет на вашу машину или другие ваши проекты. Если что-то пойдёт не так, удалите контейнер и начните заново. Если что-то заработало один раз, можете быть уверены, что оно будет работать и дальше, давая те же результаты.

В реестре Docker есть образ Emscripten от trzeci , который я активно использую.

Интеграция с npm

В большинстве случаев точкой входа в веб-проект является package.json пакета npm. По умолчанию большинство проектов можно собрать с помощью npm install && npm run build .

В целом, артефакты сборки, создаваемые Emscripten (файлы .js и .wasm ), следует рассматривать как ещё один модуль JavaScript и ещё один ресурс. Файл JavaScript можно обработать с помощью сборщика, например, Webpack или Rollup, а файл wasm следует рассматривать как любой другой большой двоичный ресурс, например, изображения.

Таким образом, артефакты сборки Emscripten необходимо собрать до того, как начнется «обычный» процесс сборки:

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

Новая задача build:emscripten может напрямую вызывать Emscripten, но, как упоминалось ранее, я рекомендую использовать Docker, чтобы убедиться в согласованности среды сборки.

docker run ... trzeci/emscripten ./build.sh указывает Docker запустить новый контейнер, используя образ trzeci/emscripten , и выполнить команду ./build.sh . build.sh — это скрипт оболочки, который вы напишете следующим! --rm указывает Docker удалить контейнер после завершения его работы. Таким образом, вы не будете накапливать коллекцию устаревших образов машин с течением времени. -v $(pwd):/src означает, что Docker должен «зеркалировать» текущий каталог ( $(pwd) ) в каталог /src внутри контейнера. Любые изменения, вносимые в файлы в каталоге /src внутри контейнера, будут зеркалироваться в вашем проекте. Эти зеркалированные каталоги называются «mounts bind».

Давайте посмотрим на 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 "============================================="

Здесь есть что проанализировать!

set -e переводит оболочку в режим «fail fast» (быстрый сбой). Если какая-либо команда в скрипте возвращает ошибку, весь скрипт немедленно прерывается. Это может быть невероятно полезно, поскольку последним выводом скрипта всегда будет сообщение об успешном завершении или ошибка, приведшая к сбою сборки.

С помощью операторов export вы определяете значения нескольких переменных окружения. Они позволяют передавать дополнительные параметры командной строки компилятору C ( CFLAGS ), компилятору C++ ( CXXFLAGS ) и компоновщику ( LDFLAGS ). Все они получают настройки оптимизатора через OPTIMIZE , чтобы гарантировать единую оптимизацию. Переменная OPTIMIZE может принимать несколько значений:

  • -O0 : Не выполнять никакой оптимизации. Неиспользуемый код не удаляется, и Emscripten также не минифицирует генерируемый им JavaScript-код. Удобно для отладки.
  • -O3 : Агрессивная оптимизация для повышения производительности.
  • -Os : Агрессивная оптимизация по производительности и размеру как вторичному критерию.
  • -Oz : Агрессивная оптимизация размера, при необходимости жертвуя производительностью.

Для веба я в основном рекомендую -Os .

Команда emcc имеет множество собственных опций. Обратите внимание, что emcc призван быть «готовой заменой для компиляторов типа GCC или clang». Поэтому все флаги, которые вы, возможно, знаете по GCC, скорее всего, будут реализованы и в emcc. Флаг -s уникален тем, что позволяет нам настраивать Emscripten индивидуально. Все доступные опции можно найти в файле settings.js Emscripten, но этот файл может быть довольно сложным. Вот список флагов Emscripten, которые, на мой взгляд, наиболее важны для веб-разработчиков:

  • --bind включает встраивание .
  • -s STRICT=1 прекращает поддержку всех устаревших параметров сборки. Это гарантирует прямую совместимость вашего кода.
  • -s ALLOW_MEMORY_GROWTH=1 позволяет автоматически увеличивать объём памяти при необходимости. На момент написания Emscripten изначально выделяет 16 МБ памяти. Поскольку ваш код выделяет фрагменты памяти, этот параметр определяет, приведут ли эти операции к сбою всего модуля wasm при исчерпании памяти, или же связующему коду будет разрешено расширить общий объём памяти для размещения выделенного объёма.
  • -s MALLOC=... выбирает, какую реализацию malloc() использовать. emmalloc — это небольшая и быстрая реализация malloc() специально разработанная для Emscripten. Альтернативой является dlmalloc — полноценная реализация malloc() . Переключаться на dlmalloc нужно только при частом выделении памяти для множества небольших объектов или при необходимости использования многопоточности.
  • -s EXPORT_ES6=1 преобразует код JavaScript в модуль ES6 с экспортом по умолчанию, совместимым с любым сборщиком. Также требуется установить -s MODULARIZE=1 .

Следующие флаги не всегда необходимы или полезны только для отладки:

  • -s FILESYSTEM=0 — это флаг, связанный с Emscripten и его способностью эмулировать файловую систему, когда ваш код C/C++ использует файловые операции. Emscripten анализирует компилируемый код, чтобы решить, включать ли эмуляцию файловой системы в связующий код или нет. Однако иногда этот анализ может дать ошибочный результат, и вам придётся потратить довольно много дополнительных 70 КБ связующего кода на эмуляцию файловой системы, которая может вам не понадобиться. С помощью -s FILESYSTEM=0 вы можете заставить Emscripten не включать этот код.
  • -g4 заставит Emscripten включить отладочную информацию в файл .wasm , а также создать файл исходных карт для модуля wasm. Подробнее об отладке с помощью Emscripten можно узнать в разделе отладки .

Вот и всё! Чтобы протестировать эту настройку, давайте создадим небольшой файл 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);
    }

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

(Вот краткое содержание всех файлов.)

Чтобы все построить, запустите

$ npm install
$ npm run build
$ npm run serve

Перейдя по адресу localhost:8080, вы увидите следующий вывод в консоли DevTools:

DevTools показывает сообщение, выведенное с помощью C++ и Emscripten.

Добавление кода C/C++ в качестве зависимости

Если вы хотите собрать библиотеку C/C++ для своего веб-приложения, её код должен быть частью вашего проекта. Вы можете добавить код в репозиторий проекта вручную или использовать npm для управления подобными зависимостями. Допустим, я хочу использовать libvpx в своём веб-приложении. libvpx — это библиотека C++ для кодирования изображений с помощью кодека VP8, используемого в файлах .webm . Однако libvpx отсутствует в npm и не имеет файла package.json , поэтому я не могу установить её напрямую с помощью npm.

Чтобы выйти из этой головоломки, есть napa . Napa позволяет вам установить любой URL-адрес репозитория git в качестве зависимости в вашу папку node_modules .

Установите napa как зависимость:

$ npm install --save napa

и обязательно запустите napa как установочный скрипт:

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

При запуске npm install , napa берет на себя клонирование репозитория libvpx GitHub в ваш node_modules под именем libvpx .

Теперь вы можете расширить свой скрипт сборки для сборки libvpx. libvpx использует configure и make для сборки. К счастью, Emscripten может помочь вам использовать компилятор Emscripten для configure и make . Для этого существуют команды-обёртки emconfigure и 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 ...

Библиотека AC/C++ разделена на две части: заголовочные файлы (традиционно файлы .h или .hpp ), определяющие структуры данных, классы, константы и т.д., предоставляемые библиотекой, и собственно сама библиотека (традиционно файлы .so или .a ). Чтобы использовать константу VPX_CODEC_ABI_VERSION библиотеки в коде, необходимо включить заголовочные файлы библиотеки с помощью оператора #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;
}

Проблема в том, что компилятор не знает , где искать vpxenc.h . Для этого и нужен флаг -I . Он сообщает компилятору, в каких каталогах искать заголовочные файлы. Кроме того, необходимо предоставить компилятору сам файл библиотеки:

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

Если вы запустите npm run build сейчас, вы увидите, что процесс создает новый файл .js и новый файл .wasm , а демонстрационная страница действительно выведет константу:

DevTools показывает версию ABI libvpx, напечатанную через emscripten.

Вы также заметите, что процесс сборки занимает много времени. Причины долгой сборки могут быть разными. В случае с libvpx это происходит потому, что при каждом запуске команды сборки компилируются кодер и декодер для VP8 и VP9, даже если исходные файлы не менялись. Даже небольшое изменение в файле my-module.cpp займёт много времени для сборки. Было бы очень полезно сохранить артефакты сборки libvpx после их первой сборки.

Одним из способов достижения этого является использование переменных окружения.

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

(Вот краткое содержание всех файлов.)

Команда eval позволяет задать переменные окружения, передавая параметры скрипту сборки. Команда test пропустит сборку libvpx, если задано любое значение $SKIP_LIBVPX .

Теперь вы можете скомпилировать свой модуль, но пропустить пересборку libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Настройка среды сборки

Иногда для сборки библиотек требуются дополнительные инструменты. Если эти зависимости отсутствуют в среде сборки, предоставляемой образом Docker, вам необходимо добавить их самостоятельно. Например, предположим, что вы также хотите собрать документацию libvpx с помощью doxygen . Doxygen недоступен внутри вашего контейнера Docker, но вы можете установить его с помощью apt .

Если бы вы сделали это в build.sh , вам пришлось бы заново скачивать и переустанавливать doxygen каждый раз, когда вы захотите собрать библиотеку. Это было бы не только расточительно, но и не позволило бы вам работать над проектом в автономном режиме.

В этом случае имеет смысл создать собственный образ Docker. Образы Docker создаются путём написания Dockerfile , описывающего этапы сборки. Dockerfile — довольно мощный инструмент, содержащий множество команд , но в большинстве случаев достаточно использовать FROM , RUN и ADD . В данном случае:

FROM trzeci/emscripten

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

С помощью FROM вы можете указать, какой образ Docker вы хотите использовать в качестве отправной точки. Я выбрал trzeci/emscripten в качестве основы — образ, который вы использовали всё это время. С помощью RUN вы указываете Docker выполнять команды оболочки внутри контейнера. Любые изменения, вносимые этими командами в контейнер, теперь являются частью образа Docker. Чтобы убедиться, что ваш образ Docker собран и доступен перед запуском build.sh , вам нужно немного изменить 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",
    // ...
    },
    // ...
}

(Вот краткое содержание всех файлов.)

Это соберёт ваш образ Docker, но только если он ещё не собран. После этого всё работает как и раньше, но теперь в среде сборки доступна команда doxygen , которая также соберёт документацию libvpx.

Заключение

Неудивительно, что код на C/C++ и npm несовместимы, но вы можете добиться комфортной работы с дополнительными инструментами и изоляцией, которую обеспечивает Docker. Такая конфигурация подойдёт не для каждого проекта, но это неплохая отправная точка, которую можно адаптировать под свои нужды. Если у вас есть предложения по улучшению, пожалуйста, поделитесь ими.

Приложение: Использование слоев образа Docker

Альтернативное решение — инкапсулировать большую часть этих проблем с помощью Docker и его интеллектуального подхода к кэшированию. Docker выполняет Dockerfile пошагово и присваивает результату каждого шага собственный образ. Эти промежуточные образы часто называют «слоями». Если команда в Dockerfile не изменилась, Docker не будет повторно выполнять этот шаг при повторной сборке Dockerfile. Вместо этого он повторно использует слой с момента последней сборки образа.

Раньше приходилось прикладывать некоторые усилия, чтобы не пересобирать libvpx при каждой сборке приложения. Вместо этого вы можете перенести инструкции по сборке libvpx из build.sh в Dockerfile , чтобы использовать механизм кэширования 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

(Вот краткое содержание всех файлов.)

Обратите внимание, что вам нужно вручную установить git и клонировать libvpx, поскольку при запуске docker build у вас нет монтирования bind. Как побочный эффект, отпадает необходимость в napa.