Используем написанную на Rust динамическую библиотеку в Node.js

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

Разные подходы могут быть задействованы для решения этой задачи (динамические библиотеки, C++ аддоны Node.js и др). 

В данной статье я хочу рассмотреть использование FFI [1]  (Foreign Function Interface) и C-совместимой библиотеки написанной на Rust [2]. Этот способ не является самым эффективным по производительности, но тем не менее, может дать неплохой прирост с точки зрения оптимизации.  

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

Часть первая: Обмен строками

Задача: произвести обмен строками между Rust-библиотекой и Node.js, чтобы проиллюстрировать взаимодействие.

Часть вторая: Структуры

Задача: использовать структуры, для обмена данными и результатом между библиотекой и Node.js

В финальном примере я решил объеденить эти две части в одну. 

Node.js

В настоящий момент работает только для версии 16.18.1

Напишем сценарий для Node.js, который обращается к динамической библиотеке и вызывает ее функции с передачей параметров и выводом возвращаемых значений. 

В качестве зависимостей мы будем использовать:

  • ffi-napi - Foreign function interface, расширение для загрузки и вызова динамических библиотек
  • ref-napi - дополнительная зависимость, который поможет нам работать с некоторыми C-подобными типами в JavaScript
  • ref-struct-napi предлагает реализацию типа Struct для Node.js

Рассмотрим полный листинг 
index.js

const ffi = require('ffi-napi')
const ref = require('ref-napi')
const StructType = require('ref-struct-napi')
const ArrayType = require('ref-array-napi')
// Инициализиция C-подобного массива
const OutputArrayType = ArrayType(ref.types.int64, 2)
// Инициализиция C-подобной структуры
const OutputType = StructType({
result: ref.types.int64,
operands: OutputArrayType,
description: ref.types.CString
})
// Инициализиция C-подобной структуры
const OperationType = StructType({
operand_a: ref.types.int64,
operand_b: ref.types.int64,
result: ref.types.int64,
})
// Инициализиция динамической библиотеки
const lib = ffi.Library('target/release/lib_node_rust', {
hello: ['string', ['string']],
multiply: [OutputType, [OperationType]],
})
// Вывод в консоль
process.stdout.write('\nCall the \'hello\' function:\n')
const stringToRust = 'Node.js'
// Вызов функции "hello"
let stringFromRust = lib.hello(stringToRust)
console.log(stringFromRust)
process.stdout.write('\nCall the \'sum\' function:\n')
// Создать С-подобную структуку с данными
const multiplication = new OperationType({ operand_a: 50, operand_b: -5 })
// Вызов функции "multiply"
const result = lib.multiply(multiplication)
console.log(result.result)
console.log(result.operands.toArray())
console.log(result.description)
// Print as an object
console.dir(result.toObject())
view raw index.js hosted with ❤ by GitHub

Несмотря на то, что код достаточно легко читаем, некоторые строки все же требуют пояснений.

*7 Для того, чтобы производить обмен данными с динамической библиотекой, мы должны вооружиться специальными типами, которые эта библиотека будет понимать. Мы не можем использовать JS массив и вместо этого мы будем работать с указателями (Pointer [3]). Для этого нам потребутся модуль (C++ addon) ref (который среди прочего позволяет создавать свои собственные типы), а так же модули реализующие типы структура и массив. В этой строке мы создаем C-подобный тип массива размером в 2 элемента, который будет содержать значения типа int64

*10 Поскольку функция multiply библиотеки будет возвращать структуру (struct), представление которой в JavaScript отсутствует, нам потребуется модуль ref-struct, позволяющий работать с этим типом данных и, в частности, преобразовать его в объект JavaScript. В этом месте сценария мы создаем структуру OutputType, (подобно Rust или C) с тремя полями типов: int64,OutputArrayType,CString. Последний тип представляет собой так называемую Нуль-терминированную строку (NULL-terminated C-style strings). Такой строкой мы сможем пользоваться в библиотеке.

*24 Для инициализации доступа к функциям библиотеки мы используем вызов ffi.Library, который имеет следующую сигнатуру: 

ffi.Library(путь_к_библиотеке, { имя_функции: [ возвращаемый_тип, [ тип_аргумента_1, тип_аргумента_2, ... ], ... ]);

Возвращаемый тип для функции multiply - созданная нами ранее структура OutputType. Аргументом функции является другая структура. 

Вы могли заметить, что возвращаемый тип и тип аргумента функции hello есть простая строка string. Это то же самое что и ref.types.CString, никакой проблемы здесь нет, я сделал это нарочно, для демонстрации. Модуль ref позволяет вам использовать "string" и "number". Для подробностей, смотрите справку к модулю. 

*35 Вызываем функцию hello библиотеки, как обычный метод объекта JavaScript.

*41 Создаем структуру с данными. Обратите внимание что свойство result я намеренно опускаю. 

*46 Обращаемся к свойству структуры, так же как мы делаем с обычными объектами JS

*47 Преобразовываем к обычному массиву.

*51 Поскольку мы не можем работать со структурами в Node.js, используя его нативные возможности, мы преобразуем ее в объект. (Заметьте, мы всегда можем обратиться напрямую к полям структуры, используя нотацию доступа к свойствам объектов JavaScript).

Rust

Теперь разберем полный листинг кода библиотеки

extern crate libc;
use libc::c_char;
use std::ffi::CString;
use std::ffi::CStr;
#[no_mangle]
#[allow(clippy::not_unsafe_ptr_arg_deref)]
// Возвращает входную строку объединенную со строковым литералом
pub extern "C" fn hello(input: *const c_char) -> *const c_char {
let input_cstring: &CStr = unsafe {
// Оборачивает необработанную C-строку в безопасную оболочку
// Функция является небезопасной
CStr::from_ptr(input)
};
// Преобразует валидную UTF-8 CStr в строковый срез
let input_str: &str = input_cstring.to_str().unwrap();
// Получает новую строку
let output_string: String = format!("Hello {} from Rust", input_str);
// Возвращает строку как c_char
CString::new(output_string).unwrap().into_raw()
}
#[repr(C)]
pub struct Output {
result: i64,
operands: [i64; 2],
description: *const c_char,
}
impl Output {
fn multiplication(operation: Operation) -> Output {
let description: String = format!("{} multiplied by {} is {}", operation.operand_a, operation.operand_b, operation.result);
Output {
result: operation.result,
operands: [operation.operand_a, operation.operand_b],
description: CString::new(description).unwrap().into_raw(),
}
}
}
#[repr(C)]
pub struct Operation {
operand_a: i64,
operand_b: i64,
result: i64,
}
impl Operation {
fn multiply(&mut self) {
self.result = self.operand_a * self.operand_b;
}
}
#[no_mangle]
// Выполняет умножение двух чисел и возвращает [`Output`] структуру
pub extern fn multiply(mut operation: Operation) -> Output {
operation.multiply();
Output::multiplication(operation)
}
view raw lib.rs hosted with ❤ by GitHub

Начнем с функции hello.

*1, *3-5 Для работы с С-типами нам потребуются некоторые зависимости. 

*7, *56 Аннотация, указывающая компилятору Rust не искажать имя этой функции. 

В процессе создания библиотеки Rust, в скомпилированном файле происходит изменение имени функции (техника называемая mangling [4]). Для того, чтобы функцию можно было вызвать из других языков, мы должны отключить такое поведение. 

*8 Эта аннотация добавлена мной чтобы линтер clippy не вызывал ошибку о небезопасной функции. 

*10 Функция объявляется как публичная - pub (может быть вызвана за пределами этого модуля), а ключевое слово extern обозначет что мы собираемся работать с FFI, то есть вызывать ее из другого языка. "C" говорит о том, какой двоичный интерфейс приложения (ABI) будет использоваться. ABI определяет, как вызывать функцию на уровне сборки. В данном случает это C. 

Функция принимает и возвращает значения типа *const c_char

*const Т - тип "указатель"  (raw pointer [5]). c_char - эквивалент типа char в языке С  [6]

*14 Так как на вход функции мы получаем указатель, а работать мы хотим со строкой, то нам нужно выполнить небезопасный (по ряду причин, например из-за отсутствия гарантии валидности) метод from_ptr, который принимает указатель и возвращает представление заимствованной строки C ( &'a CStr). Эта структура позволяет нам использовать полезные методы. Поскольку функция небезопасная мы оборачиваем ее в блок unsafe *11 

*17 Используем метод to_str() чтобы получить срез строки

 *22 Возвращаем новую строку как указатель с типом c_char

Теперь рассмотрим функцию multiply и связанные с ней структуры

 *25, *43 Аннотация #[repr(C)] перед описанием структуры Output, сообщает компилятору, что данный тип должен быть С-совместимым (с-representation) [7]

*29, *38 Текстовые данные мы возвращаем как тип указатель

Cargo.toml для библиотеки

Чтобы скомпилировать библиотеку нам нужно будет добавить следующие строки к  Cargo.toml файлу:

 
  [dependencies]
  libc = "*"

  [lib]
  name = "_node_rust"
  path = "src/lib.rs"
  crate-type = ["dylib"]

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

Теперь можно скомпилировать библиотеку cargo build --release и запустить node index.js



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

Но лучший способ понять принцип это его испробовать. 
По этой ссылке вы найдте репозиторий описанного примера и инструкцию по установке. Удачи! 

Ссылки: 

[1]. Foreign Function Interface
[2]. Статья: Rust Inside Other Languages
[3]. Pointers in C programming language
[4]. Name mangling (Wikipedia)
[5]. Type: std/pointer
[6]. Type: std/c_char
[7]. Type layout: c-representation

Комментарии