WebAssembly (сокр. WASM) — технология запуска предварительно скомпилированного бинарного кода в браузере на стороне клиента. Впервые была представлена в 2015 году и на текущий момент поддерживается большинством современных браузеров.
Один из распространенный сценариев использования — предварительная обработка данных на стороне клиента перед отправкой файлов на сервер. В этой статье разберемся как это делается.
Перед началом
Про архитектуру WebAssembly и общие шаги допольно подробно написано здесь и тут. Мы же пройдемся только по основным фактам.
Работа с WebAssembly начинается с предварительной сборки артефактов, необходимых для запуска скомпилированного кода на стороне клиента. Их два: собственно сам бинарный WASM файл и JavaScript прослойка, через которую можно вызывать экспортированные в него методы.
Пример простейшего кода на C++ для компиляции
#include <algorithm>
extern "C" {
int calculate_gcd(int a, int b) {
while (a != 0 && b != 0) {
a %= b;
std::swap(a, b);
}
return a + b;
}
}
Для сборки используется Emscripten, который кроме основного интерфейса подобных компиляторов, содержит дополнительные флаги, через которые задается конфигурация виртуальной машины и экспортируемые методы. Простейший запуск выглядит примерно так:
em++ main.cpp --std=c++17 -o gcd.html \
-s EXPORTED_FUNCTIONS='["_calculate_gcd"]' \
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]'
Указанием в качестве объекта *.html файла подсказывает компилятору, что нужно создать также простую html-разметку с js консолью. Теперь если запустить сервер на полученных файлах, увидим эту консоль с возможностью запуска _calculate_gcd:
Обработка данных
Разберем ее на простом примере lz4-компрессии с помощью библиотеки, написанной на C++. Замечу, что на этом множество поддерживаемых языков не заканчивается.
Несмотря на простоту и некоторую синтетичность примера, это довольно полезная иллюстрация того, как работать с данными. Аналогичным образом над ними можно выполнять любые действия, для которых достаточно мощностей клиента: предобработка изображений перед отправкой на сервер, компрессия аудио, подсчет различных статистик и многое другое.
Весь код целиком можно найти тут.
С++ часть
Используем уже готовую реализацию lz4. Тогда main файл будет выглядеть весьма лаконично:
#include "lz4.h"
extern "C" {
uint32_t compress_data(uint32_t* data, uint32_t data_size, uint32_t* result) {
uint32_t result_size = LZ4_compress(
(const char *)(data), (char*)(result), data_size);
return result_size;
}
uint32_t decompress_data(uint32_t* data, uint32_t data_size, uint32_t* result, uint32_t max_output_size) {
uint32_t result_size = LZ4_uncompress_unknownOutputSize(
(const char *)(data), (char*)(result), data_size, max_output_size);
return result_size;
}
}
Как можно видеть, в нем просто объявлены внешние (используя ключевое слово extern) функции, внутри вызывающие соответствующие методы из библиотеки с lz4.
Вообще говоря, в нашем случае этот файл бесполезен: можно сразу использовать нативный интерфейс из lz4.h. Однако в более сложных проектах (например, объединяющих функционал разных библиотек), удобно иметь такую общую точку входа с перечислением всех используемых функций.
Далее компилируем код используя уже упомянутый компилятор Emscripten:
em++ main.cpp lz4.c -o wasm_compressor.js \
-s EXPORTED_FUNCTIONS='["_compress_data","_decompress_data"]' \
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-s WASM=1 -s ALLOW_MEMORY_GROWTH=1
Размер полученных артефактов настораживает:
$ du -hs wasm_compressor.*
112K wasm_compressor.js
108K wasm_compressor.wasm
Если открыть JS файл-прослойку, можно увидеть примерно следующее:
В ней много лишнего: от комментариев до сервисных функций, большая часть которых не используется. Ситуацию можно исправить добавлением флага -O2, в Emscripten компиляторе он включает также оптимизацию js кода.
После этого js код выглядит более приятно:
Клиентский код
Нужно как-то вызвать обработчик на стороне клиента. Первым делом загрузим файл, предоставленный пользователем, через
FileReader
, хранить сырые данные будем в примитиве Uint8Array
:var rawData = new Uint8Array(fileReader.result);
Далее нужно передать загруженные данные в виртуальную машину. Для этого сначала аллоцируем нужное количество байт методом _malloc, затем скопируем туда JS массив методом set. Для удобства выделим эту логику в функцию arrayToWasmPtr(array):
function arrayToWasmPtr(array) {
var ptr = Module._malloc(array.length);
Module.HEAP8.set(array, ptr);
return ptr;
}
После загрузки данных в память виртуальной машины, нужно каким-то образом вызвать функцию из обработки. Но как эту функцию найти? Нам поможет метод cwrap — первым аргументом в нем указывается название искомой функции, вторым — возвращаемый тип, третьим — список с входными аргументами.
compressDataFunction = Module.cwrap('compress_data', 'number', ['number', 'number', 'number']);
И наконец нужно вернуть готовые байты из виртуальной машины. Для этого пишем еще одну функцию, копирующую их в JS массив посредством метода
subarray
function wasmPtrToArray(ptr, length) {
var array = new Int8Array(length);
array.set(Module.HEAP8.subarray(ptr, ptr + length));
return array;
}
Полный скрипт обработки входящих файлов лежит тут. HTML-разметка, содержащая форму загрузки файла и подгрузку wasm артефактов здесь.
Итог
Поиграться с прототипом можно здесь.
В результате получилась рабочая архивация, использующая WASM. Из минусов — текущая реализация технологии не позволяет освобождать аллоцированную в виртуальной машине память. Это создает неявную утечку, когда загружается большое количество файлов за одну сессию, но может быть исправлено переиспользованием уже существующей памяти вместо выделения новой.