Недавно команда Realm JavaScript заново реализовала Realm JS Node.js SDK с нуля, чтобы использовать N-API. В этом посте мы описываем необходимость перехода на N-API из-за критических изменений в виртуальной машине JavaScript и то, как мы подходили к этому итеративно.

История

Node.js и Электрон являются поддерживаемыми платформами для Realm JS SDK. Наша встроенная библиотека состоит из библиотеки JavaScript и надстройки Node.js с собственным кодом, которая взаимодействует с собственным кодом базы данных Realm. Это обеспечивает функциональность базы данных для мира JS. Он взаимодействует с движком V8, который представляет собой виртуальную машину JavaScript, используемую в Node.js, которая выполняет пользовательский код JavaScript.

Существуют разные способы написать надстройку Node.js. Один из способов — напрямую использовать API-интерфейсы V8. Другой способ — использовать уровень абстракции, который скрывает особенности V8 и обеспечивает стабильный API для разных версий Node.js.

Виртуальная машина JavaScript V8 — это движущаяся цель. Его API постоянно меняются между версиями. Некоторые из них устарели, и постоянно появляются новые API. Предыдущие версии Realm JS использовали NAN для взаимодействия с виртуальной машиной V8, потому что мы хотели иметь более стабильный уровень API для интеграции.

Хотя это было полезно, у этого были свои недостатки, поскольку NAN также необходимо было обрабатывать устаревшие API V8 в разных версиях. А поскольку NAN тесно интегрируется с API-интерфейсами V8, это не защитило нас от изменений виртуальных машин под ним. Чтобы работать с разными версиями Node.js, нам нужно было создать собственный двоичный файл для каждой основной версии Node.js. Иногда это требовало значительных усилий от команды, что приводило к задержке выпуска Realm JS для новой версии Node.js.

Изменение функциональности VM API означало самостоятельную обработку устаревших функций V8, что приводило к различным проверкам версий в кодовой базе и ошибкам, если они не обрабатывались во всех местах.

Было много других нативных дополнений, которые столкнулись с той же проблемой. Таким образом, команда Node.js решила создать стабильную сборку уровня API внутри самого Node.js, которая гарантирует стабильность API в основных версиях Node.js независимо от изменений API виртуальной машины. Этот уровень API называется N-API. Он не только обеспечивает стабильность API, но и гарантирует стабильность ABI. Это означает, что двоичные файлы, скомпилированные для одной основной версии, могут работать в более поздних основных версиях Node.js.

N-API — это C API. Для поддержки C++ для написания надстроек Node.js существует модуль под названием node-addon-api. Этот модуль является более эффективным способом написания кода, вызывающего N-API. Он обеспечивает уровень поверх N-API. Разработчики используют это для создания значений JavaScript и управления ими со встроенной обработкой исключений, которая позволяет обрабатывать исключения JavaScript как собственные исключения C++ и наоборот.

Проблемы N-API

Когда мы начали наш переход на N-API, команда Realm JavaScript заранее решила, что мы будем создавать собственный модуль N-API, используя библиотеку node-addon-api. Это связано с тем, что Realm JS написан на C++, и нет причин не выбирать уровень C++ вместо чистого уровня N-API C.

Мотивация необходимости защиты от критических изменений в JS VM стала одной из целей при полной перезаписи библиотеки. Нам нужно было обеспечить точно такое же поведение, которое существует в настоящее время. К счастью, в библиотеке Realm JS есть обширный набор тестов, которые охватывают все поддерживаемые функции. Тесты написаны в виде интеграционных тестов, которые охватывают конкретный пользовательский API, его вызов и ожидаемый результат.

Таким образом, нам не нужно было обрабатывать и переписывать мелкие модульные тесты, которые проверяют конкретные детали того, как выполняется реализация. Мы выбрали этот подход, потому что мы могли итеративно преобразовать нашу кодовую базу в N-API, медленно преобразовывая участки кода, выполняя регрессионные тесты, которые подтверждали правильное поведение, при этом одновременно запуская NAN и N-API. Это позволило нам не браться за полную переработку сразу.

Одна из первых проблем, с которыми мы столкнулись, заключалась в том, как мы собирались подойти к такому большому переписыванию библиотеки. Переписать библиотеку с новым API и в то же время иметь возможность тестировать как можно раньше — это идеально, чтобы убедиться, что код работает правильно. Мы хотели иметь возможность выполнять миграцию N-API частично, шаг за шагом переопределяя разные части, в то время как другие оставались на старом NAN API. Это позволило бы нам построить и протестировать весь проект с некоторыми частями в NAN и другими в N-API. Некоторые тесты будут вызывать новую перереализованную функциональность, а некоторые тесты будут использовать старую.

К сожалению, NAN и N-API слишком сильно разошлись, начиная с первоначальной настройки нативного аддона. Большая часть кода NAN использовала v8::Isolate, а код N-API имел непрозрачную структуру Napi::Env в качестве замены. Наш код инициализации с NAN использовал v8::Isolate для инициализации конструктора Realm в функции инициализации.

static void init(v8::Local<v8::Object> exports,
   v8::Local<v8::Value> module, v8::Local<v8::Context> context) {
   v8::Isolate* isolate = context->GetIsolate();
   v8::Local<v8::Function> realm_constructor =
 js::RealmClass<Types>::create_constructor(isolate);

   Nan::Set(exports, realm_constructor->GetName(), realm_constructor);
   }
NODE_MODULE_CONTEXT_AWARE(Realm, realm::node::init);

и наш эквивалент N-API для этого кода должен был быть

static Napi::Object NAPI_Init(Napi::Env env, Napi::Object exports) {
      return exports;
   }
NODE_API_MODULE(realm, NAPI_Init)

Когда мы смотрим на код, мы видим, что мы не можем вызывать v8::isolate, который мы использовали в нашей старой реализации, из открытого N-API. Проблема становится ясной: у нас нет никакого доступа к v8::Isolate, который нам нужен, если мы хотим вызвать нашу старую логику инициализации.

К счастью, оказалось, что мы могли просто использовать хак в нашей первоначальной реализации. Это позволило нам преобразовать определенные части нашей реализации Realm JS, в то время как мы продолжали создавать и поставлять новые версии Realm JS с частями, использующими NAN. Поскольку Napi::Env — это всего лишь эквивалентная замена v8::Isolate, мы можем проверить, не прячется ли в нем v8::Isolate. Как оказалось, это способ сделать это, но это частный член. Мы можем взять его из памяти с помощью

napi_env e  =  env;
v8::Isolate* isolate = (v8::Isolate*)e + 3;

и наш метод NAPI_init становится

static Napi::Object NAPI_Init(Napi::Env env, Napi::Object exports) {
//NAPI: FIXME: remove when NAPI complete
    napi_env e  =  env;
    v8::Isolate* isolate = (v8::Isolate*)e + 3;
  //the following two will fail if isolate is not found at the expected location
     auto currentIsolate = isolate->GetCurrent();
     auto context = currentIsolate->GetCurrentContext();
   //

   realm::node::napi_init(env, currentIsolate, exports);
    return exports;
}

Здесь мы вызываем две функции isolate->GetCurrent() и isolate->GetCurrentContext() для ранней проверки правильности указателя на v8::Isolate и отсутствия сбоев.

Это позволило нам извлечь простую функцию, которая может возвращать v8::Isolate из структуры Napi::Env в любое время, когда нам это нужно. Мы продолжали переключать сигнатуры всех наших функций на использование новой структуры Napi::Env, но реализацию этих функций можно было бы оставить без изменений, получая v8::Isolate из Napi::Env там, где это необходимо. Не каждую функцию NAN в Realm JS можно было реализовать таким образом, но, тем не менее, этот хак позволил упростить процесс путем преобразования функции в NAPI, сборки и тестирования. Затем это дало нам возможность выпустить полностью NAPI-версию без взлома, когда у нас было время преобразовать базовый API в стабильную версию.

Что мы узнали

Возможность построить весь проект на ранней стадии, а затем даже запустить его в гибридном режиме с NAN и N-API, позволила нам провести рефакторинг и продолжить поставлять новые функции. Мы смогли запустить определенные тесты с новой функциональностью, в то время как другие части библиотеки остались нетронутыми. Возможность построить проект более ценна, чем тратить месяцы на повторную реализацию с новым API, только потом обнаруживая, что что-то не так. Как говорится: «Проверяй раньше, ошибайся быстро».

Наш опыт работы с N-API и node-addon-api был положительным. API прост в использовании и разуме. Встроенная обработка ошибок имеет большое преимущество. Он перехватывает исключения JS из обратных вызовов JS и повторно генерирует их как исключения C++ и наоборот. Были некоторые странности в том, как node-addon-api обрабатывал выделенную память при возникновении исключений, но мы легко смогли их преодолеть. Мы отправили PR для некоторых из этих исправлений в библиотеку node-addon-api.

Недавно мы переключились на одну из основных функций, которые мы получили от N-API — выпуск системы сборки собственного двоичного файла Realm JS. Теперь мы собираем и выпускаем один двоичный файл для каждой основной версии Node.js.

Когда мы закончили, Realm JS с реализацией N-API привел к гораздо более чистому коду, чем раньше, и наш набор тестов был зеленым. Миграция N-API устранила некоторые из основных проблем, которые у нас были с предыдущей реализацией, и обеспечивает нашу будущую поддержку для каждой новой основной версии Node.js.

Для нашего сообщества это означает уверенность в том, что Realm JS будет продолжать работать независимо от того, с какой версией Node.js или Electron они работают — это причина, по которой команда Realm JS решила перестроиться на N-API.

Это сообщение изначально было опубликовано в Центре разработчиков MongoDB. Чтобы узнать больше, задавайте вопросы, оставляйте отзывы, подписывайтесь на @realm или посетите наш форум.