Привет, Хабр!
Сегодня я расскажу, как подружить Node.js с Rust и создать нативный модуль с помощью библиотеки NAPI-RS. Если вы вообще писали расширения для Node.js на C++ через N-API или node-gyp, то знаете, какое это удовольствие, точнее, не удовольствие вовсе.
Постоянная суета с указателями, утечками памяти и тонкостями ABI. Rust приходит на помощь как свежий бриз: безопасная работа с памятью, высокое быстродействие и довольно простой синтаксис по сравнению с C++
Node.js и Rust
Node.js прекрасен для многих задач, но тяжелые вычисления на чистом JS его слабое место. Однопоточный цикл событий и сборщик мусора V8 не рады, когда вы пытаетесь перемолоть сотни мегабайт данных в памяти. Почему бы не вынести тяжёлую работу в нативный код?
Собственно, так и поступают через механизм Node.js Addons, раньше писали на C/C++ с использованием N-API. Но Rust предлагает более новый подход. Вы получаете производительность близкую к C++, но без ошибок работы с памятью, без гонок данных, компилятор Rust просто не даст вам написать что то не то. К тому же, Rust дружит с многопоточностью по дефолту.
Знакомство с NAPI-RS
Если говорить простым языком, napi-rs берет на себя всю грязь по связке двух миров. Пишем код на Rust, помечая необходимые функции специальными макросами, а дальше эта библиотека делает остальное. В итоге на выходе получается готовый .node-модуль, который можно подключить в Node.js, как будто это обычный пакет на JS.
NAPI-RS поддерживает кучу платформ и версий Node.js и умеет собирать код под Windows, Linux, macOS и Андроид.
Установка и подготовка проекта
Для начала, конечно, нужен установленный Rust. Кроме того, понадобится Node.js и npm или Yarn. Рекомендую сразу установить утилиту @napi-rs/cli. Его можно поставить глобально: npm i -g @napi-rs/cli. Эта штука позволит собирать проект просто командой napi build.
Создадим шаблон проекта. Можно воспользоваться готовым темплейтом от сообщества NAPI-RS.
Cargo.toml манифест проекта. Туда добавим зависимости
napi = "X.Y.Z"иnapi-derive = "X.Y.Z". Эти крейты содержат основной API и макросы.package.json, здесь важно прописать скрипт сборки и зависимость на
@napi-rs/cli. Например:
{
"name": "my-addon",
"version": "1.0.0",
"scripts": {
"build": "napi build --release"
},
"dependencies": {
"@napi-rs/cli": "^2.0.0"
}
}
Все делает napi build. В Cargo.toml для библиотеки стоит указать crate-type = ["cdylib"], чтобы собирался динамический модуль для Node.
Теперь создадим файл src/lib.rs (если используем шаблон, мог быть lib.rs в папке lib). Подключим нужные вещи:
#![deny(clippy::all)]
use napi::bindgen_prelude::*;
use napi_derive::napi;
Эти строки подключают прелюдию N-API и макросы. Макрос #[napi] как раз помечает функции и структуры, которые должны экспортироваться в JavaScript.
Простая функция Rust для Node.js
Начнём с чего-нибудь простого. Например, напишем функцию, которая прибавляет 100 к входному числу. В Rust это тривиально, но нам важно увидеть механику экспорта.
#[napi]
pub fn plus_100(input: u32) -> u32 {
input + 100
}
Вот так выглядит функция на Rust, помеченная атрибутом #[napi]. Благодаря этой одной строчке (#[napi]) она автоматически станет доступна в Node.js. Мы объявили её публичной, и при сборке NAPI-RS сгенерирует обёртку для Node.
Теперь запустим сборку: npm run build. Утилита @napi-rs/cli вызовет cargo и соберет релизную версию библиотеки. На выходе получится файл, например, index.node (имя зависит от настроек платформы). Более того, она сама сгенерирует файл index.js и index.d.ts с обвязкой. В index.js будет код, который загружает наш скомпилированный модуль, а в index.d.ts TypeScript-описание функции:
/* auto-generated by NAPI-RS */
export function plus100(input: number): number;
Как видите, даже типы подтянулись автоматически. Править эти файлы не нужно, они будут пересоздаваться при каждой сборке.
Теперь протестируем наше детище с стороны Node.js. Создадим файл test.js и попробуем вызвать функцию:
const { plus100 } = require("./index"); // импортируем из сгенерированного index.js
console.assert(plus100(0) === 100, "Тест не пройден: plus100(0) != 100");
console.log("plus100(0) = " + plus100(0));
Запустим: node test.js. Если увидим в консоли plus100(0) = 100 и без ошибок, то все ок будет.
На самом деле произошло много интересного. NAPI-RS сгенерировал обертку, которая воспользовалась C API Node.js для регистрации функции plus100. Когда вы вызываете plus100 из Node, управление переходит в скомпилированную библиотеку, исполняется наш код, и результат возвращается обратно в JS. Все преобразования типов тоже берет на себя NAPI-RS. Кстати, если передать не число, а что-то другое, N-API выбросит исключение, тут тоже всё безопасно.
Передача данных между JavaScript и Rust
Ладно, с числами ясно, а как быть с более сложными типами? К счастью, NAPI-RS поддерживает множество типов. Строки JavaScript (String) принимаются как String (или &str) в Rust. Буфер Buffer из Node.js приходит как тип Uint8Array (см. модуль napi::bindgen_prelude, он содержит alias). Например, Uint8Array мы видели в демо для обработки изображений. То есть, можно написать функцию, которая принимает Uint8Array и, скажем, возвращает новый Uint8Array, и работать с ним в Rust, как с обычным слайсом байтов.
Кроме примитивов, NAPI-RS умеет передавать объекты и структуры. С помощью макроса #[napi] можно объявлять классы. К примеру, можно определить в Rust структуру и пометить её как #[napi], и даже прописать у неё методы (через #[napi] над impl). Тогда с стороны Node.js этот Rust-объект станет полноценным классом. Можно создавать его через new и вызывать методы как обычно в JS.
Асинхронные операции и потоки
А как быть, если моя функция выполняется долго? Ведь Node.js не должен блокировать event loop. Тут тоже хорошие новости, NAPI-RS поддерживает асинхронные функции. Можно написать функцию Rust, возвращающую Result<Promise<T>> или использовать AsyncTask. Есть макросы, позволяющие пометить функцию как #[napi(async)], тогда она вернётся в JS как возвращающая Promise. Внутри можно запустить тяжелую работу в отдельном потоке.
Также, если нужно вызывать колбэки JS из фонового потока (скажем, прогресс-бар или периодические уведомления), есть тип ThreadsafeFunction. Он позволяет безопасно дергать JavaScript-функцию, даже находясь не в основном потоке Node. N-API поставит вызов в очередь исполнения Node.js. В Rust это выглядит как канал, куда вы шлёте сообщения/данные, а Node их принимает и вызывает указанный колбэк.
Т е можно писать действительно сложные вещи, запустили в Rust потоки, они читают файлы, считают что-то, периодически отправляют прогресс в JS, а по завершении резолвят Promise с результатом.
Пример посложнее: обработка изображения
Рассмотрим небольшой пример ближе к реальному. Возьмём задачу уменьшить насыщенность изображения (сделать его бледнее), вполне ресурсозатратно для JS, зато легко делается на Rust с библиотекой image. Напишем функцию darker(filename: String, saturation: u8), которая прямо на месте правит файл с картинкой. В Rust это несколько строк с использованием crate image:
use image::{GenericImageView, ImageBuffer, Pixel};
use napi_derive::napi;
#[napi]
pub fn darker(filename: String, saturation: u8) -> Result<()> {
let img = image::open(&filename).map_err(|e| Error::from_reason(e.to_string()))?;
let (w, h) = img.dimensions();
let mut output = ImageBuffer::new(w, h);
for (x, y, pixel) in img.pixels() {
output.put_pixel(x, y, pixel.map(|p| p.saturating_sub(saturation)));
}
output.save(&filename).map_err(|e| Error::from_reason(e.to_string()))?;
Ok(())
}
Открываем изображение по пути filename, проходим по всем его пикселям, уменьшаем значение каждого канала на заданную величину и сохраняем результат обратно в тот же файл. Функция возвращает Result<()>, то есть в случае успеха undefined в JavaScript, а в случае ошибки пробросит исключение. Макрос #[napi] позаботится, чтобы Error превратился в JS-ошибку.
Скомпилировав этот код получим обновлённые биндинги. Теперь Node.js-скриптом можно вызывать:
const { darker } = require('./index');
darker('cube.png', 50);
console.log('Картинка затемнена!');
Возьмём цветной кубик и применим функцию. После выполнения пиксели картинки станут тусклее.
Разработать модуль это полдела. Как его установить? Есть механизм предкомпиляции. Можно настроить GitHub Actions или другой CI так, чтобы при выпуске релиза ваш модуль собирался сразу под все основные платформы. Есть @napi-rs/package-cli, который помогает упаковать двоичные файлы и опубликовать их в npm. Получим сразу готовый скомпиленный бинарник под свою платформу, без необходимости иметь компилятор Rust на машине.
Итоги
Итого, если у вас узкие места в Node.js, попробуйте написать для них расширение на Rust. Применимо для парсинга данных, обработки изображений, шифрования, компрессии, всё, что грузит CPU.
Если работа с нативными аддонами на Rust заставила посмотреть на Node.js шире, то следующий логичный шаг — углубиться в саму платформу. Курс «Node.js Developer» от OTUS помогает системно прокачать серверный JS: от Express и TypeScript до GraphQL, Nest.js и продакшен-архитектуры. Он закрывает те самые пробелы, которые обычно вскрываются, когда начинаешь расширять Node экосистему нативным кодом.
Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные демо-уроки:
25 ноября: Создание высокопроизводительного сервера с помощью NestJS. Записаться
8 декабря: Использование декораторов в Backend (Nest.js, TS.ed) приложениях. Записаться
15 декабря: Интеграция Node.js с локальной LLM: создаём умный сервер своими руками. Записаться