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

Как интегрировать 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 , который я широко использую.

Интеграция с нпм

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

В общем, артефакты сборки, созданные Emscripten (файлы .js и .wasm ), следует рассматривать как еще один модуль JavaScript и просто еще один ресурс. Файл JavaScript может обрабатываться сборщиком, например веб-пакетом или накопителем, а файл 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 внутри контейнера, будут отражены в вашем реальном проекте. Эти зеркальные каталоги называются «привязками».

Давайте посмотрим на 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 переводит оболочку в режим «быстрого отказа». Если какая-либо команда в сценарии возвращает ошибку, весь сценарий немедленно прерывается. Это может быть невероятно полезно, поскольку последним выводом сценария всегда будет сообщение об успехе или ошибка, которая привела к сбою сборки.

С помощью операторов 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++ использует операции с файловой системой. Он проводит некоторый анализ компилируемого кода, чтобы решить, включать ли эмуляцию файловой системы в связующий код или нет. Однако иногда этот анализ может оказаться неправильным, и вы платите довольно большие 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 может помочь configure и make компилятор Emscripten. Для этой цели существуют команды-оболочки 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 показывает версию libvpx ABI, напечатанную с помощью 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 к кэшированию. Docker выполняет Dockerfiles шаг за шагом и присваивает результату каждого шага собственный образ. Эти промежуточные изображения часто называют «слоями». Если команда в файле 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 у вас нет монтирования привязки. В качестве побочного эффекта напа больше не нужна.