N-API: аддоны для Node.js
- суббота, 8 марта 2025 г. в 00:00:05
Привет, Хабр!
Сегодня рассмотрим то, как создаются нативные аддоны для Node.js на C++ с использованием N‑API.
До появления N‑API написание аддонов шло напрямую через V8 API, что влекло за собой жёсткую привязку к конкретной версии движка. Каждый апдейт Node.js требовал пересборки и правки кучи низкоуровневого кода. N‑API решает эту проблему, предоставляя стабильный ABI. Это позволяет писать универсальные, долговечные и, главное, поддерживаемые модули, не боясь, что обновление Node.js подбросит вам сюрприз в виде «segmentation fault».
Преимущества N‑API:
Стабильность ABI. Один раз написав код, вы можете быть уверены, что он будет работать в будущем, несмотря на изменения в V8.
Упрощённое API. Работа с объектами, строками, буферами становится интуитивной, как будто вы уже год пользуетесь C++ для Node.js.
Поддержка асинхронности. Возможность легко интегрировать асинхронные операции с использованием Napi::AsyncWorker.
Для начала работы необходимо это:
Node.js и npm. Рекомендуется LTS‑версия, чтобы избежать проблем с несовместимостью.
Компилятор C++. На Windows — Visual Studio, на Linux/Mac — gcc или clang.
Пакет node‑addon‑api. Установите его через npm:
npm install node-addon-api --save
Структура проекта. Минимальная структура, которая подойдёт для аддона:
my-addon/
├── binding.gyp
├── package.json
└── src/
├── main.cpp
├── async.cpp
├── bufferAddon.cpp
└── hashAddon.cpp
Файл binding.gyp содержит инструкции для сборки, а src/ — это кладезь C++ кода.
Начнём с простейшего примера. Предположим, нужно реализовать функцию сложения, которую можно вызвать из JavaScript. Базовая реализация:
// src/main.cpp
#include <napi.h>
// Функция, которая принимает два числа и возвращает их сумму
Napi::Number Add(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 2) {
Napi::TypeError::New(env, "Ожидается два аргумента").ThrowAsJavaScriptException();
return Napi::Number::New(env, 0);
}
if (!info[0].IsNumber() || !info[1].IsNumber()) {
Napi::TypeError::New(env, "Оба аргумента должны быть числами").ThrowAsJavaScriptException();
return Napi::Number::New(env, 0);
}
double arg0 = info[0].As<Napi::Number>().DoubleValue();
double arg1 = info[1].As<Napi::Number>().DoubleValue();
return Napi::Number::New(env, arg0 + arg1);
}
// Инициализация модуля и регистрация функции
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "add"), Napi::Function::New(env, Add));
return exports;
}
NODE_API_MODULE(addon, Init)
Файл binding.gyp для сборки этого аддона выглядит следующим образом:
{
"targets": [
{
"target_name": "addon",
"sources": [ "src/main.cpp" ],
"include_dirs": [
"<!(node -p \"require('node-addon-api').include\")"
],
"dependencies": [
"<!(node -p \"require('node-addon-api').gyp\")"
],
"cflags!": [ "-fno-exceptions" ],
"cflags_cc!": [ "-fno-exceptions" ],
"defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
}
]
}
После настройки проекта выполняем следующие команды:
npm install
node-gyp configure
node-gyp build
Чтобы проверить работу, создаем файл test.js:
// test.js
const addon = require('./build/Release/addon');
console.log("2 + 3 =", addon.add(2, 3));
Запускаем его через node test.js и убеждаемся, что всё работает как надо.
Часто приходится иметь дело с тяжёлыми задачами, от которых может пострадать производительность Node.js. Здесь на помощь приходит Napi::AsyncWorker. Рассмотрим пример асинхронного аддона.
// src/async.cpp
#include <napi.h>
#include <thread>
#include <chrono>
class MyAsyncWorker : public Napi::AsyncWorker {
public:
MyAsyncWorker(const Napi::Function &callback)
: Napi::AsyncWorker(callback), result(0) {}
// Метод, выполняемый в отдельном потоке
void Execute() override {
std::this_thread::sleep_for(std::chrono::seconds(2));
result = 42; // Пример вычисления
}
// Метод, вызываемый в главном потоке после выполнения Execute
void OnOK() override {
Napi::HandleScope scope(Env());
Callback().Call({ Env().Null(), Napi::Number::New(Env(), result) });
}
private:
int result;
};
Napi::Value StartAsyncTask(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsFunction()) {
Napi::TypeError::New(env, "Ожидается callback функция").ThrowAsJavaScriptException();
return env.Null();
}
Napi::Function cb = info[0].As<Napi::Function>();
MyAsyncWorker* worker = new MyAsyncWorker(cb);
worker->Queue();
return env.Undefined();
}
Napi::Object InitAsync(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "startAsyncTask"), Napi::Function::New(env, StartAsyncTask));
return exports;
}
NODE_API_MODULE(addon_async, InitAsync)
Проверяем работу асинхронного аддона в файле testAsync.js:
// testAsync.js
const addon = require('./build/Release/addon_async');
console.log("Запускаем асинхронную задачу...");
addon.startAsyncTask((err, result) => {
if (err) {
console.error("Ошибка:", err);
} else {
console.log("Результат асинхронной задачи:", result);
}
});
Это позволяет выполнять долгие операции без блокировки главного потока — жизненно важно для масштабируемых приложений.
Научиться разработке серверных приложений на Node.js с использованием Express, TypeScript, GraphQl, Apollo и Nest.js можно на онлайн-курсе "Node.js Developer".
Одной из распространённых задач является обработка бинарных данных. Пусть у нас есть аддон, который принимает Node.js Buffer, модифицирует его и возвращает новый Buffer. Как это можно реализовать:
// src/bufferAddon.cpp
#include <napi.h>
#include <cstring>
Napi::Value ProcessBuffer(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsBuffer()) {
Napi::TypeError::New(env, "Ожидается буфер").ThrowAsJavaScriptException();
return env.Null();
}
Napi::Buffer<char> inputBuffer = info[0].As<Napi::Buffer<char>>();
size_t length = inputBuffer.Length();
const char* inputData = inputBuffer.Data();
// Создаём новый буфер и выполняем простую операцию над каждым байтом: побитовое отрицание
Napi::Buffer<char> outputBuffer = Napi::Buffer<char>::New(env, length);
char* outputData = outputBuffer.Data();
for (size_t i = 0; i < length; ++i) {
outputData[i] = ~inputData[i];
}
return outputBuffer;
}
Napi::Object InitBufferAddon(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "processBuffer"), Napi::Function::New(env, ProcessBuffer));
return exports;
}
NODE_API_MODULE(addon_buffer, InitBufferAddon)
Пример использования в JS:
// testBuffer.js
const addon = require('./build/Release/addon_buffer');
const buf = Buffer.from("Hello, Node.js!");
const processed = addon.processBuffer(buf);
console.log("Исходный буфер:", buf);
console.log("Обработанный буфер:", processed);
Здесь еще не забаываем следить за управлением памятью, чтобы избежать утечек.
Не всегда приходится писать всю логику с нуля. Часто нужно обернуть уже существующую библиотеку. Допустим, есть сторонняя библиотека для вычисления хеша. Обёртка может выглядеть так:
// src/hashAddon.cpp
#include <napi.h>
#include "external_hash_lib.h" // заголовочный файл внешней библиотеки
Napi::Value ComputeHash(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsString()) {
Napi::TypeError::New(env, "Ожидается строка").ThrowAsJavaScriptException();
return env.Null();
}
std::string input = info[0].As<Napi::String>().Utf8Value();
std::string hash = external_compute_hash(input); // функция из внешней библиотеки
return Napi::String::New(env, hash);
}
Napi::Object InitHashAddon(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "computeHash"), Napi::Function::New(env, ComputeHash));
return exports;
}
NODE_API_MODULE(addon_hash, InitHashAddon)
При интеграции важно: правильно линковать стороннюю библиотеку через binding.gyp.
Представим, что есть веб‑приложение, которое обрабатывает огромные JSON‑файлы. Не про какие‑то там мелкие конфиги на пару килобайт. А прям гигабайты JSON, которые система должна парсить, анализировать и, конечно же, не уронить сервер в процессе.
JavaScript — интерпретируемый язык, а значит, любой серьёзный парсинг JSON в чистом Node.js превращается CPU‑загрузку на 100% и долгие секунды ожидания.
Вы пишете вот такой код, надеясь, что всё будет хорошо:
const fs = require('fs');
const rawData = fs.readFileSync('bigdata.json', 'utf8');
const jsonData = JSON.parse(rawData);
console.log("JSON загружен, ура!");
На JSON в 2 гигабайта Node.js посмотрит, задумается и… сожрёт всю оперативку, перед тем как просто умереть.
Создадим нативный аддон на C++, который будет парсить JSON стримом, без загрузки всего файла в память. В отличие от JSON.parse(), который жрёт всё сразу, мы будем разбирать файл кусочками, отдавая результаты по мере обработки.
Код на C++ (ускоренный JSON‑парсер)
#include <napi.h>
#include <fstream>
#include <iostream>
#include <string>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
Napi::Value StreamJsonParse(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsString()) {
Napi::TypeError::New(env, "Ожидается путь к файлу").ThrowAsJavaScriptException();
return env.Null();
}
std::string filePath = info[0].As<Napi::String>().Utf8Value();
std::ifstream file(filePath);
if (!file.is_open()) {
Napi::Error::New(env, "Не удалось открыть файл").ThrowAsJavaScriptException();
return env.Null();
}
json parsedJson;
file >> parsedJson; // Парсим напрямую из файла, без загрузки всего JSON в память
return Napi::String::New(env, parsedJson.dump().substr(0, 100)); // Отдаём первые 100 символов JSON
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "parseLargeJSON"), Napi::Function::New(env, StreamJsonParse));
return exports;
}
NODE_API_MODULE(json_parser, Init)
Как теперь работаем в Node.js:
const jsonParser = require('./build/Release/json_parser');
const result = jsonParser.parseLargeJSON('bigdata.json');
console.log("Часть JSON:", result);
Что получили в итоге:
JSON‑файл в 2+ гигабайта парсится без убийства памяти.
Парсинг идёт стримом, а не загружает всё сразу.
Скорость выросла в 5–10 раз, а нагрузка на сервер упала.
Если проект упирается в лимиты JavaScript, аддоны — логичный следующий шаг: они ускоряют вычисления, дают доступ к системным ресурсам и открывают двери к мощным C++ библиотекам. А какой у вас опыт при работе с этими аддонами? Делитесь в комментариях.
19 марта пройдет открытый урок на тему «Создание масштабируемых backend-решений с использованием Node.js и Firebase Cloud Functions». Если тема интересна, записывайтесь бесплатно на странице курса "Node.js"