Нативные аддоны на Node-API: стабильный ABI и граница
Node-API — стабильный C ABI: аддон, собранный один раз, продолжает грузиться через мажоры Node, в отличие от raw-V8/NAN аддонов, которые надо пересобирать. Значения ходят через непрозрачную границу хэндлами, блокирующая работа — вне потока, и каждый переход реально стоит.
Команда отгружает нативный аддон для ресайза картинок, собранный против заголовков V8. Год работает нормально на Node 16. Потом они бампают базовый образ до Node 18, и сервис падает с segfault на первом же запросе — чистый крэш без JavaScript-стека, только core dump глубоко в v8::Object. В их коде не изменилось ничего. Изменилось то, что внутренний C++ ABI V8 сдвинулся между мажорами Node, а предсобранный бинарник .node всё ещё звал старую раскладку. Они пересобирают против заголовков Node 18 — снова работает, до следующего мажора. Они подписались пересобирать свой аддон, вечно, против каждой версии Node, которую гоняют их пользователи. Фикс был доступен всё это время: собирать против Node-API, и тот единственный бинарник продолжил бы грузиться через все эти апгрейды нетронутым.
Зачем Node-API: гарантия стабильности ABI
Нативный аддон — это разделяемая библиотека: файл .node, по сути .so/.dll, который Node грузит через process.dlopen и зовёт как любой JavaScript-модуль. Вопрос в том, от какого API зависит скомпилированный бинарник, потому что это и определяет, когда он сломается. Разберись в этом — и сможешь выбрать подход, который переживёт мажорный апгрейд, не заставив тебя встать на беговую дорожку из пересборок, как в хуке.
Исходный C++ API аддонов открывал V8 напрямую. Твой аддон #include-ил v8.h и манипулировал v8::Local<v8::Object>, v8::Isolate, v8::FunctionTemplate — собственными типами V8. V8 не даёт никаких гарантий стабильности ABI между версиями: раскладка этих классов в памяти, vtable’ы, inline-функции — всё меняется. Поэтому бинарник, скомпилированный против V8 из Node 16, зовёт V8 из Node 18 с неверными допущениями, и ты получаешь segfault из хука. NAN («Native Abstractions for Node.js») смягчил это на уровне исходника — макросы, замазывавшие изменения API, чтобы один исходник компилировался на многих версиях Node, — но для бинарника не сделал ничего. Пересобирать под каждый мажор Node всё равно надо было.
Node-API (C API; функции napi_, раньше «N-API») рвёт эту зависимость. Это стабильный, версионированный C ABI, который Node гарантирует совместимым вперёд: аддон, скомпилированный против Node-API версии N, продолжает работать на каждом будущем релизе Node, поддерживающем версию N или выше, без пересборки. Функции — чистый C: нет C++ name-mangling, нет раскладок классов, которые сдвигаются, — а реализация Node транслирует вызовы napi_ в то, что хочет текущий V8. Ты зависишь от контракта Node-API, а не от внутренностей V8.
| Подход | Зависит от | Переживает мажорный бамп Node? |
|---|---|---|
Raw V8 (v8.h) | C++ ABI V8 (нестабильный) | Нет — пересборка под каждый мажор, риск segfault, если её нет |
| NAN | ABI V8, сглаженный на исходнике | Исходник компилируется широко, но бинарник всё равно пересобирается под мажор |
Node-API (napi_*) | Версия Node-API (стабильный ABI) | Да — один бинарник грузится на всех версиях Node с этим уровнем API |
На практике почти никто не пишет сырой napi_-C руками. node-addon-api — официальная C++-обёртка над C ABI: она даёт Napi::Object, Napi::Number, RAII-управление хэндлами и исключения, при этом по-прежнему компилируясь в те самые стабильные C-вызовы под капотом. Ты получаешь эргономику C++ и гарантию ABI — это современный дефолт.
Граница: непрозрачные хэндлы, а не типы V8
Всё, к чему ты прикасаешься из нативного кода, пересекает границу, и граница намеренно непрозрачна. JavaScript-значение, приходящее в твою функцию, — это napi_value: хэндл, а не указатель на объект V8, который можно разыменовать. Ты просишь Node-API прочитать или построить значения через функции (napi_get_value_double, napi_create_string_utf8), и Node-API делает работу с V8. Эта косвенность и есть то, что покупает стабильность ABI: твой бинарник никогда не видит раскладку V8.
Вот минимальный аддон на node-addon-api — функция, складывающая два числа, — плюс его JavaScript-загрузчик.
// addon.cc
#include <napi.h>
Napi::Value Add(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env(); // napi_env: твой хэндл к рантайму
double a = info[0].As<Napi::Number>(); // маршалинг JS number -> C double
double b = info[1].As<Napi::Number>();
return Napi::Number::New(env, a + b); // маршалинг C double -> JS number
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("add", Napi::Function::New(env, Add));
return exports;
}
NODE_API_MODULE(addon, Init) // регистрация точки входа модуля// index.js
const addon = require("./build/Release/addon.node");
console.log(addon.add(2, 3)); // 5Замечай две вещи. Во-первых, info.Env() даёт тебе napi_env — контекстный токен, который ты передаёшь почти в каждый вызов Node-API; он действителен только в рамках того вызова, которому передан — используй env, переданный в каждый callback, а не закэшированный на другом потоке или удержанный через границу async-work. Во-вторых, каждое значение, пересекающее линию, маршалится: JS Number становится C double на входе, и свежий JS Number конструируется на выходе. Общего представления нет — значения копируются или оборачиваются, но не алиасятся.
▸Почему это работает
Хэндлы, созданные во время вызова, живут в handle scope, который Node-API закрывает при возврате из функции, так что они не текут. Но если ты создаёшь много значений внутри цикла, который бежит до возврата — скажем, строишь большой массив поэлементно, — они все скапливаются в этом одном scope. Для длинных циклов открывай Napi::HandleScope (или napi_open_handle_scope) явно, чтобы промежуточные хэндлы освобождались сразу, а не накапливались до конца вызова.
Асинхронная работа: никогда не блокируй поток событийного цикла
Твой Add бежит синхронно на главном JavaScript-потоке — том же, что использует событийный цикл. Для микросекунд арифметики это нормально. Для чего-то медленного — катастрофа: нативный вызов, тратящий 200мс на сжатие картинки, блокирует весь событийный цикл, замораживая каждый другой запрос и таймер на всё это время. Нативный код не запускается в фоне волшебным образом; по умолчанию он бежит там, где ты его зовёшь.
Фикс — napi_async_work (обёрнут как Napi::AsyncWorker). Ты делишь задачу на фазу Execute, бегущую на потоке пула libuv — вне главного потока, так что событийный цикл продолжает обслуживать, — и фазу OnOK/OnError, бегущую обратно на главном потоке, чтобы доставить результат. Жёсткое правило: внутри Execute нельзя трогать ни один napi_value и звать ни одну Node-API-функцию, взаимодействующую с JS, потому что ты не на JS-потоке, а движок там не твой, чтобы его касаться. Ты считываешь входы в обычные C/C++-данные до Execute, делаешь тяжёлую работу на сырых данных и конвертируешь обратно в JS-значения только в OnOK.
class ResizeWorker : public Napi::AsyncWorker {
std::vector<uint8_t> input_; // обычные данные, скопированы до Execute
std::vector<uint8_t> output_;
public:
ResizeWorker(Napi::Function cb, std::vector<uint8_t> in)
: Napi::AsyncWorker(cb), input_(std::move(in)) {}
void Execute() override { // поток libuv — НИКАКИХ napi_value здесь
output_ = heavy_resize(input_);
}
void OnOK() override { // главный поток — безопасно строить JS-значения
Callback().Call({ Env().Null(), Napi::Buffer<uint8_t>::Copy(Env(), output_.data(), output_.size()) });
}
};Для обратного случая — нативный код на каком-то другом потоке (аппаратный колбэк, рабочий пул C-библиотеки), которому надо позвать обратно в JavaScript, — нельзя просто вызвать napi_value-функцию из того потока. Ты используешь thread-safe function (napi_threadsafe_function), которая безопасно маршалит вызов на главный JS-поток. Зов JS из не-JS-потока любым другим способом портит движок.
Цена перехода и когда он окупается
Переход в нативный код не бесплатен. Каждый вызов поднимает handle scope, маршалит аргументы и сворачивается — накладные расходы, пренебрежимые против реальной работы, но доминирующие против тривиальной. Если ты профилируешь нативную интеграцию и видишь, что она отстаёт от чистого JS, причина почти всегда в форме границы, а не в самом C-коде. «Быстрая» нативная функция, вызванная в тесном цикле миллион раз, каждый раз пересекая границу ради одной дешёвой операции, легко окажется медленнее эквивалентного чистого JavaScript, потому что JIT V8 оптимизирует JS-путь, а каждый нативный вызов платит фиксированный налог границы и непрозрачен для оптимизатора.
Так что форма границы важнее языка. Нативный код окупается, когда ты пересекаешь границу редко и делаешь много за переход: отдаёшь целый буфер C-кодеку, зовёшь существующую обкатанную C-библиотеку (libvips, OpenSSL, драйвер БД) или гоняешь по-настоящему CPU-bound ядро, которое JS не вытянет. Он проигрывает, когда граница болтливая — много переходов, мало работы за каждый, — где накладные расходы маршалинга и потерянная JIT-оптимизация съедают выигрыш. Проектируй API грубым: один вызов, обрабатывающий всю картинку, а не вызов на пиксель.
Ты добавляешь CPU-тяжёлый нативный кодек в Node-сервис. Какой должна быть форма границы JS↔native?
Предсобранный .node аддон работал на Node 16 и падает с segfault после апгрейда на Node 18, без правок исходника. Самая вероятная причина?
Внутри метода Execute() у AsyncWorker (бежит на потоке пула libuv) — чего надо избегать?
- 01Почему raw-V8 аддон ломается на мажорном апгрейде Node, а Node-API аддон — нет?
- 02Каково правило про JS-значения внутри Execute у AsyncWorker и почему оно существует?
Нативный аддон — это разделяемая библиотека, которую Node грузит как модуль, и API, против которого он компилируется, решает, когда он сломается. Raw-V8 аддоны привязаны к нестабильному C++ ABI V8 и должны пересобираться под каждый мажор Node, иначе падают с segfault; NAN сглаживает исходник, но не бинарник. Node-API — стабильный, версионированный C ABI, который Node гарантирует совместимым вперёд, так что один бинарник грузится на всех версиях Node, поддерживающих его уровень API, — а node-addon-api — это C++-обёртка, сохраняющая эргономику и компилирующаяся в те стабильные C-вызовы; это современный дефолт. Через границу каждое значение — непрозрачный хэндл, маршалится (копируется или оборачивается) на вход и выход, и никогда не алиасит указатель V8 — что и покупает стабильность ABI. Медленная работа не должна бежать синхронно на главном потоке: дели её на AsyncWorker, делая тяжёлую работу в Execute на обычных данных (никаких napi_value там) и конвертируя обратно в JS только в OnOK на главном потоке, и используй thread-safe function, когда не-JS-поток должен позвать обратно в JavaScript. Наконец, каждый переход несёт фиксированную цену — handle scope, маршалинг, непрозрачность для JIT, — поэтому болтливая граница на элемент может бежать медленнее чистого JS. Делай границу грубой: пересекай редко, делай много за переход, и резервируй нативный код для CPU-bound ядер или существующих C-библиотек, где он реально выигрывает. Теперь, когда встретишь задачу «добавить .node-аддон» или профиль, где нативные вызовы отстают от ожидаемого, у тебя есть словарь для диагностики: это ABI, async или форма границы — и с чего начинать чинить.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.