javascript

N-API: аддоны для Node.js

  • суббота, 8 марта 2025 г. в 00:00:05
https://habr.com/ru/companies/otus/articles/888220/

Привет, Хабр!

Сегодня рассмотрим то, как создаются нативные аддоны для 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.

Всё, что нужно для старта

Для начала работы необходимо это:

  1. Node.js и npm. Рекомендуется LTS‑версия, чтобы избежать проблем с несовместимостью.

  2. Компилятор C++. На Windows — Visual Studio, на Linux/Mac — gcc или clang.

  3. Пакет node‑addon‑api. Установите его через npm:

    npm install node-addon-api --save
  4. Структура проекта. Минимальная структура, которая подойдёт для аддона:

    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"