Привет, Хабр!
Сегодня рассмотрим то, как создаются нативные аддоны для 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 и убеждаемся, что всё работает как надо.
Асинхронные операции: не блокируем Event Loop
Часто приходится иметь дело с тяжёлыми задачами, от которых может пострадать производительность 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);
Здесь еще не забаываем следить за управлением памятью, чтобы избежать утечек.
Интеграция со сторонними C/C++ библиотеками
Не всегда приходится писать всю логику с нуля. Часто нужно обернуть уже существующую библиотеку. Допустим, есть сторонняя библиотека для вычисления хеша. Обёртка может выглядеть так:
// 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‑файлы. Не про какие‑то там мелкие конфиги на пару килобайт. А прям гигабайты 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++ и N-API
Создадим нативный аддон на 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"
