Написать пост

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

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

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

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

main.js

			const typedLikesList = new Float64Array(usersPosts.map(x => 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) -> JsResult<JsString> {
	let user_posts_list: Handle<JsTypedArray<f64>> = cx.argument(0)?;
	let total_user_likes = user_posts_list
    	.as_slice(&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: &mut FunctionContext,
                         user_posts_list: Handle<JsArray>
       ) -> Result<Vec<Self>, Throw> {
    	  user_posts_list
        	.to_vec(cx)?
        	.into_iter()
        	.map(|post| {
            	  let post_obj = post.downcast_or_throw::<JsObject, _>(cx)?;
            	  let title = post_obj
                	.get::<JsString, _, _>(cx, "title")
                	.unwrap()
                	.value(cx);
            	  let body = post_obj
                	.get::<JsString, _, _>(cx, "body")
                	.unwrap()
                	.value(cx);
            	  let likes = post_obj
                	.get::<JsNumber, _, _>(cx, "likes")
                      .unwrap()
                      .value(cx);
            	  Ok(Self { title, body, likes })
        	})
        	.collect()
	}

	fn vec_to_array<'a, C: Context<'a>>(cx: &mut C, user_posts: Vec<Self>)
    	-> JsResult<'a, JsArray> {
    	  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) -> JsResult<JsArray> {
	let user_posts_list: Handle<JsArray> = cx.argument(0)?;
	let user_posts = UserPost::vec_from_array(&mut cx, user_posts_list)?;
	// TODO: do some weird mutations here
	UserPost::vec_to_array(&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) -> JsResult<JsValue> {
	let user_posts_list = cx.argument(0)?;
	let user_posts: Vec<UserPost> = neon_serde::from_value(&mut cx,   user_posts_list)
    	.unwrap();
	// TODO: do some weird mutations here
	Ok(neon_serde::to_value(&mut cx, &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<RefCell<Vec<UserPost>>>;

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

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

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

   Ok(result)
}

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

   let mut list = list.borrow_mut();
   let detail_info: Vec<PostDetail> =
       neon_serde::from_value(&mut cx, detail_info)
           .unwrap();

   patch_posts(&mut list, detail_info);

   Ok(cx.undefined())
}


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

Мы добавили структуру PostDetails, которая теперь у нас содержит детальное описание поста, и получили две выходные функции: preparePosts, proceed. preparePosts сериализует наши 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 и декомпозировать библиотеку из большого монолитного черного ящика в более маленькие функции.

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

Следите за новыми постами
Следите за новыми постами по любимым темам
1К открытий3К показов