Как переписать библиотеку с JS на Rust
Подружили Rust с библиотекой JS и рассказываем, о том, как играть в пинг-понг между языками и выйти из этой игры победителем.
1К открытий5К показов
Подружили Rust с JS и рассказываем, к чему это привело. Илья Бобровский, ведущий разработчик IT Test, о том, как играть в пинг-понг между языками и выйти из этой игры победителем.
Зачем нам понадобился Rust
В один из проектов была заложена библиотека с тысячами строк кода инженерных вычислений, написанная на JS. Мы реализовали приложение полностью на JS (frontend – Angular, backend – NestJs, standalone app – Electron), поскольку это позволило запустить проект в короткие сроки и дало возможность переиспользовать общие функции, интерфейсы да и в целом ресурс команды на всех «концах» проекта.
По началу к производительности инженерных расчетов не было вопросов – JS делал свою работу. Однако за несколько лет развития проекта библиотека расчетов серьезно выросла: количество производимых вычислений увеличивалось с каждым новым обновлением. Фактически за каждым вводом данных в форму клиентской части следовали сотни, а то и тысячи строк вычислений.
Мы заметили, что вместо стартовых 200 мс на выполнение вычислений стало уходить до 3 сек в пике, со средним значением выпрыгивающим за секунду. Библиотека не справлялась, делая приложение не самым удобным в эксплуатации. Можно сказать она стала “bottle neck” всего проекта, так как добиться плавной и быстрой работы без ускорения библиотеки было невозможно. Назревало решение о переводе вычислений на другой инструмент.
Как показал “proof of concept”, Rust работал быстрее в три раза, и это еще без параллелизации вычислений. Мы получали мощную типизацию, низкий расход памяти, неплохую гарантию утечек памяти. Ну и компилируемый файл на выходе, что тоже было для нас немаловажно, так как все вычисления крутились в standalone app, а нам нужно было подготовить продукт к выходу на рынок и защитить интеллектуальную собственность.
Конечно, есть Go, Java, всемогущий C++ и множество других языков, которые могли помочь нам ускориться, но мы остановились именно на Rust, потому что на выходе мы получали производительность сравнимую с С++, а типизацию лучше, чем в Java.
Как подружить Rust с JS
Интеграцию Rust c Node.js решили делать через FFI (foreign function interface) – механизм, с помощью которого языки программирования могут общаться между собой. У Node.js есть свое Node-API для реализации нативных аддонов: из-за этого интеграция Rust в проект казалась идеальной. Плюс не нужно было заморачиваться с установщиком под Electron и легко комбинировать JS и Rust.
В качестве библиотеки, для работы с Node-API был выбран Neon. Для того, чтобы создать проект требуется выполнить команду npm init neon <project_name>
. По пути src/lib.rs
вы найдете главный файл плагина, в нем уже будет пример с экспортированной функцией hello
, которая возвращает строку “hello node”
. Теперь мы можем собрать плагин и установить дополнительные пакеты для работы – npm install
.
На выходе получаем файл index.node
, его можно импортировать в ваш JS и запускать, как обычную функцию.
Пример работы с Neon
Теперь посмотрим на Neon и его взаимодействие с JS. Для начала изменим файл src/lib.rs
в уже созданном проекте.
src/lib.rs
В этом примере прокомментирована каждая строка. Далее комментарии будут только касательно интересных моментов.
А теперь создадим файл main.js в корне проекта для вызова созданной нами функции.
main.js
Так в консоли появится “User has 65 likes total”
.
Как не надо делать в плагинах
Очень скоро мы столкнулись с проблемой производительности. Делая замеры после закрытия одного из первых этапов, мы с ужасом обнаружили, что Rust умудряется в некоторых кейсах выполнять свою работу медленнее JS. Дело оказалось в том, что через FFI мы получаем доступ к данным JS напрямую и это достаточно “дорогая” операция. Получается, необходимо экономить количество обращений к JS объектам из Rust, иначе можно легко получить результаты, когда Rust тратит больше времени на выполнение задачи в сравнении с JS.
Выход из этой ситуации простой: нужно уходить от больших массивов/объектов, передаваемых в Rust, в пользу чего-то более примитивного, например, строки (JSON) или бинарного массива.
Мы пересобрали самый тяжелый массив объектов с большой вложенностью в бинарный и передали его в таком виде в Rust. Рассказываем, как удалось это реализовать.
main.js
В main.js
мы изменили формат отправляемых данных, передавая только лайки пользователей, завернутые в TypedArray
.
src/lib.rs
Здесь мы получили первый аргумент как JsTypedArray
и изменили подсчет лайков таким образом, чтобы он работал с новым форматом данных.
В итоге за счет того, что мы загнали данные в TypedArray
на больших объемах данных (≈300+ элементов в массиве), мы не увидели просадки по скорости.
Neon serde или упрощаем работу с моделями данных
Поскольку выяснилось, что следует минимизировать количество обращений к N-API, в каждой функции мы переводили все данные, пришедшие по N-API, во внутренние типы данных Rust, выполняли необходимые операции и переводили обратно. Вот как это выглядело.
src/lib.rs
Как видите, здесь достаточно много пустого кода для выполнения рутинных действий. А что, если у вас десятки, сотни разных структур данных? Тут на помощь может прийти Serde – отличный фреймворк для сериализации и десериализации структур данных в Rust. Для интеграции понадобится написать свой сериалайзер и десериалайзер данных из N-API объектов в структуры Rust.
К сожалению, crate neon-serde, который мог бы помочь с решением этой проблемы, не поддерживается, но есть его клоны, например neon-serde3, которые позволяют работать с последней версией Neon. В нашем случае пришлось уходить в сторону написания своего пакета, так как требовался расширенный функционал. Но для примера будет вполне достаточно пакета по ссылке упомянутого выше форка. Вот что получается.
src/lib.rs
Теперь за всю тяжелую работу отвечает neon-serde
, а нам лишь остается развешивать serialize/deserialize макрос.
Пинг-понг между языками
Как же быть, если нужно вернуть управление JS, но очень не хочется терять промежуточное состояние в Rust? Этот вопрос не давал нам покоя с самого начала работ по миграции библиотеки. Оказалось, Neon имеет решение – JsBox. Эта структура позволяет хранить данные Rust в переменных JS. Давайте изменим наш пет-проект, чтобы посмотреть на JsBox в деле.
src/lib.rs
Мы добавили структуру PostDetails
, которая теперь у нас содержит детальное описание поста, и получили две выходные функции: preparePosts
, proceed
. preparePosts
сериализует наши JS объекты в вектор структур UserPosts
, именно этот список мы хотим сохранить и вернуть JS. Также мы находим все посты, где лайков больше 30, чтобы попросить JS найти дополнительную информацию по таким постам. В результате работы функция возвращает кортеж из ссылки на вектор постов в Rust и массива uid
‘ов, которые нам интересны.
После того, как JS получил данные из preparePosts
и нашел детальную информацию по нужным постам, можно передавать управление обратно в Rust.
src/main.js
Если мы выведем в консоль содержимое rustUsers, увидим:
Это и есть наша ссылка на данные Rust.
Как только proceed возьмет управление, мы получим обратно уже сериализованный ранее список и дополнительную информацию по нашему запросу.
Подведем итоги
Как выяснилось, написание плагинов для Node.js – процесс весьма увлекательный и не такой уж сложный, хоть и не без подводных камней, вроде проблемы работы с большим количеством данных js.
Однако наша работа по переводу библиотеки вычислений на Rust еще не окончена: мы переписали основную логику вычислений, впереди еще много улучшений. Как минимум, планируем распараллелить вычисления с помощью rayon и декомпозировать библиотеку из большого монолитного черного ящика в более маленькие функции.
А с какими сложностями сталкивались вы?
1К открытий5К показов