Аватарка пользователя Илья Бобровский
Илья Бобровский

Как переписать библиотеку с JS на Rust

Подружили Rust с библиотекой JS и рассказываем, о том, как играть в пинг-понг между языками и выйти из этой игры победителем.

852
Обложка поста Как переписать библиотеку с JS на Rust

Подружили 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 и запускать, как обычную функцию.

			const RustPlugin = require(".");
RustPlugin.hello();   
		

Пример работы с Neon

Теперь посмотрим на Neon и его взаимодействие с JS. Для начала изменим файл src/lib.rs в уже созданном проекте.

src/lib.rs

			use neon::prelude::*;
fn count_posts(mut cx: FunctionContext) -&gt; JsResult&lt;JsString&gt; {
	// Получаем первый аргумент нашей ф-ции и кастуем его в JsArray
	let user_posts_list: Handle&lt;JsArray&gt; = cx.argument(0)?;
	let total_user_likes = user_posts_list
    	// получаем вектор из JsArray
    	.to_vec(&amp;mut cx)?
    	// преобразуем в итератор
    	.into_iter()
    	// сворачиваем в итоговую сумму
    	.fold(Ok(0), |count, post| {
        	let result = count? + post
            	// кастуем пост из JsValue в JsObject
            	.downcast_or_throw::&lt;JsObject, _&gt;(&amp;mut cx)?
            	// получаем значение по ключу "likes" как JsNumber
            	.get::&lt;JsNumber, _, _&gt;(&amp;mut cx, "likes")?
            	// получаем f64 из JsNumber и кастуем его в i32
            	// по умолчанию мы всегда получаем f64,
            	// так как в JS только один тип числа
            	// и по спецификации это эквивалент f64
            	.value(&amp;mut cx) as i32;
        	// Возвращаем результат, если получилось посчитать
        	Ok(result)
    	})?;
	// Выводим итоговую сумму лайков пользователя
	Ok(cx.string(format!("User has {total_user_likes} likes total")))
}
#[neon::main]
fn main(mut cx: ModuleContext) -&gt; NeonResult&lt;()&gt; {
	// объявляем нашу функцию для плагина, именуя ее "countPosts"
	cx.export_function("countPosts", count_posts)?;
	Ok(())
}

		

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

А теперь создадим файл main.js в корне проекта для вызова созданной нами функции.

main.js

			const RustPlugin = require(".");
const usersPosts = [
	{
    	title: "My first post",
    	body: "Hello everyone",
    	likes: 10

	}, {
    	title: "Another post",
    	body: "The story of my life..",
    	likes: 55.5
	}
];
const response = RustPlugin.countPosts(usersPosts);
console.log(response);

		

Так в консоли появится “User has 65 likes total”.

Как не надо делать в плагинах

Очень скоро мы столкнулись с проблемой производительности. Делая замеры после закрытия одного из первых этапов, мы с ужасом обнаружили, что Rust умудряется в некоторых кейсах выполнять свою работу медленнее JS. Дело оказалось в том, что через FFI мы получаем доступ к данным JS напрямую и это достаточно “дорогая” операция. Получается, необходимо экономить количество обращений к JS объектам из Rust, иначе можно легко получить результаты, когда Rust тратит больше времени на выполнение задачи в сравнении с JS.

Выход из этой ситуации простой: нужно уходить от больших массивов/объектов, передаваемых в Rust, в пользу чего-то более примитивного, например, строки (JSON) или бинарного массива. 

Мы пересобрали самый тяжелый массив объектов с большой вложенностью в бинарный и передали его в таком виде в Rust. Рассказываем, как удалось это реализовать. 

main.js

			const typedLikesList = new Float64Array(usersPosts.map(x =&gt; x.likes));
const response = RustPlugin.countPosts(typedLikesList);

		

В main.js мы изменили формат отправляемых данных, передавая только лайки пользователей, завернутые в TypedArray.

src/lib.rs

			use neon::prelude::*;
use neon::types::buffer::TypedArray;
 
fn count_posts(mut cx: FunctionContext) -&gt; JsResult&lt;JsString&gt; {
	let user_posts_list: Handle&lt;JsTypedArray&lt;f64&gt;&gt; = cx.argument(0)?;
	let total_user_likes = user_posts_list
    	.as_slice(&amp;mut cx)
    	.into_iter()
    	.fold(0, |count, likes_amount| count + *likes_amount as i32);

	Ok(cx.string(format!("User has {total_user_likes} likes total")))

}

		

Здесь мы получили первый аргумент как JsTypedArray и изменили подсчет лайков таким образом, чтобы он работал с новым форматом данных.

В итоге за счет того, что мы загнали данные в TypedArray на больших объемах данных (≈300+ элементов в массиве), мы не увидели просадки по скорости.

Neon serde или упрощаем работу с моделями данных

Поскольку выяснилось, что следует минимизировать количество обращений к N-API, в каждой функции мы переводили все данные, пришедшие по N-API, во внутренние типы данных Rust, выполняли необходимые операции и переводили обратно. Вот как это выглядело.

src/lib.rs

			use neon::prelude::*;
use neon::result::Throw;

struct UserPost {
	title: String,
	body: String,
	likes: f64
}

impl UserPost {
	fn vec_from_array(
                         cx: &amp;mut FunctionContext,
                         user_posts_list: Handle&lt;JsArray&gt;
       ) -&gt; Result&lt;Vec&lt;Self&gt;, Throw&gt; {
    	  user_posts_list
        	.to_vec(cx)?
        	.into_iter()
        	.map(|post| {
            	  let post_obj = post.downcast_or_throw::&lt;JsObject, _&gt;(cx)?;
            	  let title = post_obj
                	.get::&lt;JsString, _, _&gt;(cx, "title")
                	.unwrap()
                	.value(cx);
            	  let body = post_obj
                	.get::&lt;JsString, _, _&gt;(cx, "body")
                	.unwrap()
                	.value(cx);
            	  let likes = post_obj
                	.get::&lt;JsNumber, _, _&gt;(cx, "likes")
                      .unwrap()
                      .value(cx);
            	  Ok(Self { title, body, likes })
        	})
        	.collect()
	}

	fn vec_to_array&lt;'a, C: Context&lt;'a&gt;&gt;(cx: &amp;mut C, user_posts: Vec&lt;Self&gt;)
    	-&gt; JsResult&lt;'a, JsArray&gt; {
    	  let arr = cx.empty_array();
    	  for (i, post) in user_posts.into_iter().enumerate() {
        	  let post_obj = cx.empty_object();
        	  let title = cx.string(post.title);
        	  post_obj.set(cx, "title", title)?;
        	  let body = cx.string(post.body);
        	  post_obj.set(cx, "body", body)?;
        	  let likes = cx.number(post.likes);
        	  post_obj.set(cx, "likes", likes)?;
        	  arr.set(cx, i as u32, post_obj)?;
    	  }
    	  Ok(arr)
	}
}

fn handle_user_likes(mut cx: FunctionContext) -&gt; JsResult&lt;JsArray&gt; {
	let user_posts_list: Handle&lt;JsArray&gt; = cx.argument(0)?;
	let user_posts = UserPost::vec_from_array(&amp;mut cx, user_posts_list)?;
	// TODO: do some weird mutations here
	UserPost::vec_to_array(&amp;mut cx, user_posts)
}

		

Как видите, здесь достаточно много пустого кода для выполнения рутинных действий. А что, если у вас десятки, сотни разных структур данных? Тут на помощь может прийти Serde – отличный фреймворк для сериализации и десериализации структур данных в Rust. Для интеграции понадобится написать свой сериалайзер и десериалайзер данных из N-API объектов в структуры Rust.

К сожалению, crate neon-serde, который мог бы помочь с решением этой проблемы, не поддерживается, но есть его клоны, например neon-serde3, которые позволяют работать с последней версией Neon. В нашем случае пришлось уходить в сторону написания своего пакета, так как требовался расширенный функционал. Но для примера будет вполне достаточно пакета по ссылке упомянутого выше форка. Вот что получается.

src/lib.rs

			use neon::prelude::*;
use serde::{Serialize, Deserialize};
use neon_serde3 as neon_serde;

#[derive(Serialize, Deserialize)]
struct UserPost {
	title: String,
	body: String,
	likes: f64
}

fn handle_user_likes(mut cx: FunctionContext) -&gt; JsResult&lt;JsValue&gt; {
	let user_posts_list = cx.argument(0)?;
	let user_posts: Vec&lt;UserPost&gt; = neon_serde::from_value(&amp;mut cx,   user_posts_list)
    	.unwrap();
	// TODO: do some weird mutations here
	Ok(neon_serde::to_value(&amp;mut cx, &amp;user_posts).unwrap())
}

		

Теперь за всю тяжелую работу отвечает neon-serde, а нам лишь остается развешивать serialize/deserialize макрос.

Пинг-понг между языками

Как же быть, если нужно вернуть управление JS, но очень не хочется терять промежуточное состояние в Rust? Этот вопрос не давал нам покоя с самого начала работ по миграции библиотеки. Оказалось, Neon имеет решение – JsBox. Эта структура позволяет хранить данные Rust в переменных JS. Давайте изменим наш пет-проект, чтобы посмотреть на JsBox в деле.

src/lib.rs

			use std::cell::{RefCell, RefMut};
use neon::prelude::*;
use serde::{Serialize, Deserialize};
use neon_serde3 as neon_serde;

impl Finalize for UserPost {}

#[derive(Serialize, Deserialize)]
struct UserPost {
   uid: String,
   title: String,
   likes: f64
}

#[derive(Serialize, Deserialize)]
struct PostDetail {
   uid: String,
   detail: String
}

type BoxedPostsList = JsBox&lt;RefCell&lt;Vec&lt;UserPost&gt;&gt;&gt;;

fn prepare_posts(mut cx: FunctionContext) -&gt; JsResult&lt;JsArray&gt; {
   let user_posts_list = cx.argument(0)?;
   let user_posts: Vec&lt;UserPost&gt; =
       neon_serde::from_value(&amp;mut cx, user_posts_list)
           .unwrap();

   let result = cx.empty_array();
   let filtered_posts: Vec&lt;String&gt; = user_posts.iter()
       .filter_map(|post|
           if post.likes &gt; 30.0 { Some(post.uid.clone()) }
           else { None })
       .collect();

   let js_box: Handle&lt;BoxedPostsList&gt; = JsBox::new(
       &amp;mut cx,
       RefCell::new(user_posts)
   );
   result.set(&amp;mut cx, 0, js_box)?;
   let filtered_posts = neon_serde::to_value(&amp;mut cx, &amp;filtered_posts)
       .unwrap();
   result.set(&amp;mut cx, 1, filtered_posts)?;

   Ok(result)
}

fn proceed(mut cx: FunctionContext) -&gt; JsResult&lt;JsUndefined&gt; {
   let list: Handle&lt;BoxedPostsList&gt; = cx.argument(0)?;
   let detail_info = cx.argument(1)?;

   let mut list = list.borrow_mut();
   let detail_info: Vec&lt;PostDetail&gt; =
       neon_serde::from_value(&amp;mut cx, detail_info)
           .unwrap();

   patch_posts(&amp;mut list, detail_info);

   Ok(cx.undefined())
}


#[neon::main]
fn main(mut cx: ModuleContext) -&gt; NeonResult&lt;()&gt; {
   cx.export_function("preparePosts", prepare_posts)?;
   cx.export_function("proceed", proceed)?;
   Ok(())
}

		

Мы добавили структуру PostDetails, которая теперь у нас содержит детальное описание поста, и получили две выходные функции: preparePosts, proceedpreparePosts сериализует наши JS объекты в вектор структур UserPosts, именно этот список мы хотим сохранить и вернуть JS. Также мы находим все посты, где лайков больше 30, чтобы попросить JS найти дополнительную информацию по таким постам. В результате работы функция возвращает кортеж из ссылки на вектор постов в Rust и массива uid'ов, которые нам интересны.

После того, как JS получил данные из preparePosts и нашел детальную информацию по нужным постам, можно передавать управление обратно в Rust.

src/main.js

			const [rustUsers, postsQuery] = RustPlugin.preparePosts(userPosts);
RustPlugin.proceed(rustUsers, findPostsDetail(postsQuery));

		

Если мы выведем в консоль содержимое rustUsers, увидим:

			 [External: 600000012980]
		

Это и есть наша ссылка на данные Rust. 

Как только proceed возьмет управление, мы получим обратно уже сериализованный ранее список и дополнительную информацию по нашему запросу.

Подведем итоги

Как выяснилось, написание плагинов для Node.js – процесс весьма увлекательный и не такой уж сложный, хоть и не без подводных камней, вроде проблемы работы с большим количеством данных js.

Однако наша работа по переводу библиотеки вычислений на Rust еще не окончена: мы переписали основную логику вычислений, впереди еще много улучшений. Как минимум, планируем распараллелить вычисления с помощью rayon и декомпозировать библиотеку из большого монолитного черного ящика в более маленькие функции.

***

А с какими сложностями сталкивались вы?

852