Всем привет! Меня зовут Евгений Обрезков, и сегодня я хочу поговорить об одной из самых «страшных» платформ - NodeJS. Я собираюсь ответить на один из самых сложных вопросов о NodeJS - «Как работает NodeJS?».

Я собираюсь представить эту статью так, как будто NodeJS вообще не существует. Так вам будет легче понять, что происходит под капотом.

Код, представленный в этом посте, взят из существующих источников NodeJS, поэтому после прочтения этой статьи вам будет удобнее работать с NodeJS.

Зачем нам это нужно?

Первый вопрос, который может прийти вам в голову - «Зачем нам это нужно?».

Здесь я хотел бы процитировать Вячеслава Егорова: «Чем больше людей перестанут воспринимать виртуальную машину JS как загадочный черный ящик, преобразующий исходный код JavaScript в нули и единицы, тем лучше». Та же идея применима к NodeJS: «Чем больше людей перестанут воспринимать NodeJS как загадочный черный ящик, который запускает JavaScript с низкоуровневым API, тем лучше».

Просто сделай это!

Вернемся в 2009 год, когда NodeJS только начинал свой путь.

Мы хотим запустить JavaScript на сервере и получить доступ к низкоуровневому API. Мы также хотим запускать наш JavaScript из CLI и REPL. По сути, мы хотим, чтобы JavaScript делал все!

Как бы мы это сделали? Первое, что приходит мне в голову…

Браузер

Браузер может выполнять JavaScript. Итак, мы можем взять браузер, интегрировать его в наше приложение и все.

Не совсем! Вот вопросы, на которые нужно ответить.

Предоставляет ли браузер низкоуровневый API для JavaScript? - Нет!

Можно ли запускать JavaScript откуда-нибудь еще? - И да, и нет, это сложно!

Нужны ли нам все возможности DOM, которые дает нам браузер? - Нет! Это накладные расходы.

Нужен ли вообще браузер? - Нет!

Нам это не нужно. JavaScript выполняется без браузера.

Если для выполнения JavaScript не требуется браузер, что тогда выполняет JavaScript?

Виртуальная машина (ВМ)

Виртуальная машина выполняет JavaScript!

VM обеспечивает абстракцию высокого уровня - язык программирования высокого уровня (по сравнению с абстракцией ISA низкого уровня системы).

ВМ предназначена для выполнения одной компьютерной программы, предоставляя абстрактную и независимую от платформы среду выполнения программ.

Существует множество виртуальных машин, которые могут выполнять JavaScript, включая V8 от Google, Chakra от Microsoft, SpiderMonkey от Mozilla, JavaScriptCore от Apple и не только. Выбирайте с умом, потому что это может быть решение, о котором вы можете сожалеть до конца своей жизни :)

Я предлагаю выбрать Google V8, почему? Потому что это быстрее, чем другие виртуальные машины. Думаю, вы согласитесь, что скорость выполнения важна для бэкэнда.

Давайте посмотрим на V8 и на то, как он может помочь в создании NodeJS.

V8 VM

V8 можно интегрировать в любой проект C ++. Просто возьмите исходники V8 и включите их как простую библиотеку. Теперь вы можете использовать API V8, который позволяет компилировать и запускать код JavaScript.

V8 может открывать C ++ для JavaScript. Это очень важно, поскольку мы хотим сделать низкоуровневый API доступным в JavaScript.

Этих двух пунктов достаточно, чтобы представить примерную реализацию нашей идеи - «Как мы можем запустить JavaScript с доступом к низкоуровневому API».

Давайте подведем черту обо всем этом выше, потому что в следующей главе мы начнем с кода C ++. Вы можете взять виртуальную машину, в нашем случае V8 - ›интегрировать ее в наш проект C ++ -› предоставить C ++ для JavaScript с помощью V8.

Но как мы можем написать код на C ++ и сделать его доступным в JavaScript?

Шаблоны V8

С помощью шаблонов V8!

Шаблон - это план для функций и объектов JavaScript. Вы можете использовать шаблон для обертывания функций и структур данных C ++ в объектах JavaScript.

Например, Google Chrome использует шаблоны для обертывания узлов C ++ DOM как объектов JavaScript и для установки функций в глобальной области.

Вы можете создать набор шаблонов, а затем использовать их. Соответственно, у вас есть столько шаблонов, сколько вы хотите.

В V8 есть два типа шаблонов: Шаблоны функций и Шаблоны объектов.

Шаблон функции - это план для отдельной функции. Вы создаете экземпляр шаблона JavaScript, вызывая метод шаблона GetFunction из контекста, в котором вы хотите создать экземпляр функции JavaScript. Вы также можете связать обратный вызов C ++ с шаблоном функции, который вызывается при вызове экземпляра функции JavaScript.

Шаблон объекта используется для настройки объектов, созданных с использованием шаблона функции в качестве их конструктора. С шаблонами объектов можно связать два типа обратных вызовов C ++: обратный вызов средства доступа и обратный вызов перехватчика. Обратный вызов средства доступа вызывается, когда скрипт обращается к определенному свойству объекта. Обратный вызов перехватчика вызывается, когда скрипт обращается к какому-либо свойству объекта. Вкратце, вы можете заключать объекты \ структуры C ++ в объекты JavaScript.

Взгляните на этот простой пример. Все, что это делает, - это раскрывает метод C ++ LogCallback в глобальном контексте JavaScript.

В строке №2 мы создаем новый ObjectTemplate. Затем в строке №3 мы создаем новый FunctionTemplate и связываем с ним метод C ++ LogCallback. Затем мы устанавливаем для этого экземпляра FunctionTemplate значение ObjectTemplate. В строке № 9 мы просто передаем наш экземпляр ObjectTemplate в новый контекст JavaScript, чтобы при запуске JavaScript в этом контексте вы могли вызвать метод log из глобальный охват. В результате будет запущен метод C ++, связанный с нашим экземпляром FunctionTemplate, LogCallback,.

Как видите, это похоже на определение объектов в JavaScript, только в C ++.

К настоящему времени мы узнали, как предоставлять методы \ структуры C ++ для JavaScript. Теперь мы узнаем, как запускать код JavaScript в этих измененных контекстах. Это просто. Просто скомпилируй и запусти принцип.

V8 Компиляция и запуск JavaScript

Если вы хотите запустить свой JavaScript в созданном контексте, вы можете сделать всего два простых вызова API для V8 - Compile и Run.

Давайте посмотрим на этот пример, где мы создаем новый Context и запускаем внутри него JavaScript.

В строке №2 мы создаем контекст JavaScript (мы можем изменить его с помощью описанных выше шаблонов). В строке №5 мы делаем этот контекст активным для компиляции и выполнения кода JavaScript. В строке №8 мы создаем новую строку из исходного кода JavaScript. Его можно жестко запрограммировать, прочитать из файла или любым другим способом. В строке №11 мы компилируем наш исходный код JavaScript. В строке №14 мы его запускаем и ожидаем результатов. Это все.

Наконец, мы можем создать простой NodeJS, объединив все методы, описанные выше :)

C ++ - ›Шаблоны V8 -› Выполнить JavaScript - ›?

Вы можете создать экземпляр виртуальной машины (также известный как Isolate в V8) - ›создать столько экземпляров FunctionTemplate с назначенными обратными вызовами C ++, сколько хотите -› создать ObjectTemplate и назначьте ему все созданные экземпляры FunctionTemplate - ›создайте контекст JavaScript с глобальным объектом в качестве нашего экземпляра ObjectTemplate -› запустите JavaScript в этом контексте и вуаля - › NodeJS. Милая!

Но какой знак вопроса стоит после «Выполнить JavaScript» в названии главы? При реализации выше возникла небольшая проблема. Мы упустили одну очень важную вещь.

Представьте, что вы написали множество методов C ++ (около 10 тыс. SLOC), которые могут работать с fs, http, crypto и т. д. Мы назначили их [обратные вызовы C ++] экземплярам FunctionTemplate и импортируем их [FunctionTemplate] в ObjectTemplate . После получения экземпляра JavaScript этого ObjectTemplate у нас есть доступ ко всем экземплярам FunctionTemplate из JavaScript через глобальную область видимости. Вроде все отлично работает, но…

Что, если нам сейчас не нужна fs? Что, если нам вообще не нужны функции криптовалюты? Как насчет того, чтобы не получать модули из глобальной области видимости, а требовать их по запросу? Как насчет того, чтобы не писать код C ++ в одном большом файле со всеми функциями обратного вызова C ++? Итак, вопросительный знак означает…

Модульность!

Все эти методы C ++ должны быть разделены на модули и размещены в разных файлах (упрощает разработку), чтобы каждый модуль C ++ соответствовал каждой fs, http или любую другую функцию. Та же логика и в контексте JavaScript. Все модули JavaScript не должны быть доступны из глобальной области видимости, но должны быть доступны по запросу.

На основе этих лучших практик нам необходимо реализовать собственный загрузчик модулей. Этот загрузчик модулей должен обрабатывать загрузку модулей C ++ и модулей JavaScript, чтобы мы могли захватить модуль C ++ по запросу из кода C ++ и то же самое для контекста JavaScript - захватить модуль JavaScript по запросу из кода JavaScript.

Сначала начнем с загрузчика модулей C ++.

Загрузчик модулей C ++

Здесь будет много кода C ++, так что постарайтесь не терять рассудок :)

Начнем с основ всех загрузчиков модулей. Каждый загрузчик модулей должен иметь переменную, содержащую все модули (или информацию о том, как их получить). Давайте объявим структуру C ++ для хранения информации о модулях C ++ и назовем ее node_module.

В этой структуре мы можем хранить информацию о существующих модулях. В результате у нас есть простой словарь всех доступных модулей C ++.

Я не буду объяснять все поля из приведенной выше структуры, но хочу, чтобы вы обратили внимание на одно из них. В nm_filename мы можем сохранить имя файла нашего модуля, чтобы мы знали, откуда его загрузить. В nm_register_func и nm_context_register_func мы можем хранить функции, которые нам нужно вызывать, когда требуется модуль. Эти функции будут отвечать за создание экземпляра Template. И nm_modname может хранить имя модуля (не имя файла).

Затем нам нужно реализовать вспомогательные методы, которые работают с этой структурой. Мы можем написать простой метод, который может сохранять информацию в нашей структуре node_module, а затем использовать этот метод в определениях наших модулей. Назовем его node_module_register.

Как видите, все, что мы здесь делаем, это просто сохраняем новую информацию о модуле в нашу структуру node_module.

Теперь мы можем упростить процесс регистрации с помощью макроса. Давайте объявим макрос, который вы можете использовать в своем модуле C ++. Этот макрос - просто оболочка для метода node_module_register.

Первый макрос - это оболочка для метода node_module_register. Другой - просто оболочка для предыдущего макроса с некоторыми предопределенными аргументами. В результате у нас есть макрос, который принимает два аргумента: modname и regfunc. Когда он вызывается, мы сохраняем информацию о новом модуле в нашей структуре node_module. Что означают modname и regfunc? Ну… modname - это просто имя нашего модуля, например, fs. regfunc - это метод модуля, о котором мы говорили ранее. Этот метод должен отвечать за инициализацию шаблона V8 и присвоение его ObjectTemplate.

Как видите, каждый модуль C ++ может быть объявлен в макросе, который принимает имя модуля (modname) и функцию инициализации (regfunc), которая будет вызываться, когда требуется модуль. Все, что нам нужно сделать, это просто создать методы C ++, которые могут считывать эту информацию из структуры node_module, и вызвать метод regfunc.

Напишем простой метод, который будет искать модуль в структуре node_module по его имени. Мы назовем его get_builtin_module.

Это вернет ранее объявленный модуль, если имя соответствует nm_modname из структуры node_module.

Основываясь на информации из структуры node_module, мы можем написать простой метод , который загрузит модуль C ++ и назначит экземпляр V8 Template нашему ObjectTemplate. В результате этот ObjectTemplate будет отправлен как экземпляр JavaScript в контекст JavaScript.

Несколько замечаний относительно приведенного выше кода. Привязка принимает имя модуля в качестве аргумента. Этот аргумент - имя модуля, которое вы указали с помощью макроса. Мы ищем информацию об этом модуле с помощью метода get_builtin_module. Если мы его находим, мы вызываем функцию инициализации из этого модуля, отправляя некоторые полезные аргументы, такие как exports. exports - это экземпляр ObjectTemplate, поэтому мы можем использовать для него API V8 Template. После всех этих операций мы получаем объект exports, который мы получаем в результате использования метода Binding. Как вы помните, экземпляр ObjectTemplate может возвращать экземпляр JavaScript, и это то, что делает Binding.

Последнее, что нам нужно сделать, - это сделать этот метод доступным из контекста JavaScript. Это делается в последней строке путем обертывания метода Binding в FunctionTemplate и присвоения его глобальной переменной process.

На этом этапе вы можете вызвать, например, process.binding (‘fs’) и получить для него собственные привязки.

Вот пример встроенного модуля с опущенной логикой для простоты .

Приведенный выше код создаст привязку с именем «v8», которая экспортирует объект JavaScript, так что вызов process.binding (‘v8’) из контекста JavaScript получит этот объект.

Надеюсь, вы все еще следите за мной.

Теперь мы должны создать загрузчик модулей JavaScript, который поможет нам делать все такие полезные вещи, как require (‘fs’).

Загрузчик модулей JavaScript

Отлично, благодаря нашим последним улучшениям мы можем вызвать process.binding () и получить доступ к привязкам C ++ из контекста JavaScript. Но это все еще не решает проблему с модулями JavaScript. Как мы можем писать модули JavaScript и требовать их по запросу?

Прежде всего, нам нужно понять, что есть два разных типа модулей. Один из них - это модули JavaScript, которые мы пишем вместе с обратными вызовами C ++. Вкратце, это встроенные в NodeJS модули, такие как fs, http и т. Д. Назовем эти модули NativeModule . Другой тип - это модули в вашем рабочем каталоге. Назовем их просто Модуль.

Нам нужно иметь возможность требовать оба типа. Это означает, что нам нужно знать, как получить NativeModule из NodeJS и Module из вашего рабочего каталога.

Начнем сначала с NativeModule.

Все собственные модули JavaScript находятся в нашем проекте C ++ в другой папке. Это означает, что все источники JavaScript доступны во время компиляции. Это позволяет нам заключать источники JavaScript в заголовочный файл C ++, который мы можем использовать в будущем.

Для этого существует инструмент Python под названием js2c.py (находится в tools / js2c.py). Он создает файл заголовка node_natives.h с обернутым кодом JavaScript. node_natives.h можно включить в любой код C ++ для получения исходных текстов JavaScript в C ++.

Теперь, когда мы можем использовать источники JavaScript в контексте C ++, давайте попробуем. Мы можем реализовать простой метод DefineJavaScript, который получает источники JavaScript из node_natives.h и назначает их экземпляру ObjectTemplate.

В приведенном выше коде мы перебираем каждый собственный модуль JavaScript и устанавливаем их в экземпляр ObjectTemplate с именем модуля в качестве ключа и самим модулем в качестве значения. Последнее, что нам нужно сделать, это вызвать DefineJavaScript с экземпляром ObjectTemplate в качестве target.

Здесь пригодится метод Binding. Если вы посмотрите на нашу реализацию Binding C ++ (раздел C ++ Module Loader), вы увидите, что мы жестко запрограммировали две привязки: константы и уроженцы. Таким образом, если имя привязки - natives, тогда вызывается метод DefineJavaScript с объектами environment и exports. В результате собственные модули JavaScript будут возвращены при вызове process.binding (‘natives’).

Так что это круто. Но здесь можно сделать еще одно улучшение, определив задачу GYP в файле node.gyp и вызвав из него инструмент js2c.py. Это сделает так, что при компиляции NodeJS исходные коды JavaScript также будут заключены в файл заголовка node_natives.h.

К настоящему времени у нас есть исходные коды наших собственных модулей на JavaScript, доступные как process.binding (‘natives’). Теперь напишем простую оболочку JavaScript для NativeModule.

Теперь, чтобы загрузить модуль, вы вызываете метод NativeModule.require () с именем модуля, который вы хотите загрузить. Это сначала проверит, существует ли модуль в кеше, если да, то получит его из кеша, в противном случае модуль компилируется, кэшируется и возвращается как объект exports.

Давайте теперь подробнее рассмотрим методы cache и compile.

Все, что делает cache, - это просто устанавливает экземпляр NativeModule на статический объект _cache, расположенный в NativeModule.

Более интересен метод compile. Сначала мы получаем исходные коды необходимого модуля из _source (мы устанавливаем это статическое свойство с помощью process.binding (‘natives’)). Затем мы заключаем их в функцию с методом wrap. Как видите, полученная функция принимает exports, require, module, __filename и __dirname аргументы. После этого мы вызываем эту функцию с необходимыми аргументами. В результате наш модуль JavaScript заключен в область видимости, в которой exports как указатель на NativeModule.exports, require как указатель на NativeModule .require, module как указатель на сам экземпляр NativeModule и __filename как строку с текущим именем файла. Теперь вы знаете, откуда в вашем коде JavaScript берутся такие вещи, как module и require. Это просто указатели на экземпляр NativeModule :)

Другое дело - реализация загрузчика Module.

Реализация загрузчика Module в основном такая же, как и в NativeModule, разница в том, что источники берутся не из файла заголовка node_natives.h, а из файлов которые мы можем прочитать с помощью собственного модуля fs. Таким образом, мы делаем все то же самое, что и wrap, cache и compile, только с источниками, считываемыми из файла.

Отлично, теперь мы знаем, как требовать собственные модули или модули из вашего рабочего каталога.

Наконец, мы можем написать простой модуль JavaScript, который будет запускаться каждый раз при запуске NodeJS, и подготовить среду NodeJS, используя все вышеперечисленное.

Библиотека времени выполнения NodeJS?

Что такое библиотека времени выполнения? Это библиотека, которая подготавливает среду, устанавливает глобальные переменные process, console, Buffer и т. Д. И запускает основной сценарий, который вы отправляете в NodeJS. CLI как аргумент. Это может быть достигнуто с помощью простого файла JavaScript, который будет выполняться во время выполнения NodeJS перед всем остальным кодом JavaScript.

Мы можем начать с проксирования всех наших собственных модулей в глобальную область видимости и настройки других глобальных переменных. Это просто большой объем кода JavaScript, который выполняет что-то вроде global.Buffer = NativeModule.require (‘buffer’) или global.process = process.

Второй шаг - это запуск основного скрипта, который вы отправляете в NodeJS CLI в качестве аргумента. Логика здесь тоже проста. Он просто анализирует process.argv [1] и создает экземпляр Module с его значением в качестве значения конструктора. Итак, Module может читать источники из файла - ›кеша и компилировать его, как NativeModule с предварительно скомпилированными источниками JavaScript.

Я не так много могу добавить сюда, это действительно очень просто, но если вы хотите получить более подробную информацию, вы можете взглянуть на файл src / node.js в репозитории узлов. Этот файл выполняется во время выполнения NodeJS и использует все методы, описанные в этой статье.

Таким образом NodeJS может запускать ваш код JavaScript с доступом к низкоуровневому API. Круто, не правда ли?

Но все вышеперечисленное пока не умеет делать асинхронные вещи. На этом этапе все операции, подобные fs.readFile (), полностью синхронны.

Что нам нужно для асинхронных операций? цикл событий…

Цикл событий

Цикл событий - это диспетчер сообщений, который ожидает и отправляет события или сообщения в программе. Он работает, отправляя запрос некоторому внутреннему или внешнему поставщику событий (который обычно блокирует запрос до тех пор, пока не появится событие), а затем вызывает соответствующий обработчик событий (отправляет событие). Цикл событий может использоваться вместе с реактором, если провайдер событий следует файловому интерфейсу, который может быть выбран или опрошен. Цикл событий почти всегда работает асинхронно с отправителем сообщения.

V8 может принимать цикл событий в качестве аргумента при создании среды V8. Но прежде чем настраивать цикл обработки событий для V8, нам нужно сначала его реализовать ...

Наконец, у нас уже есть реализация, которая называется libuv. Он отвечает за все асинхронные операции, такие как чтение файла и другие. Без libuv NodeJS - это просто синхронное выполнение JavaScript \ C ++.

Итак, в принципе, мы можем включить источники libuv в NodeJS и создать среду V8 с циклом обработки событий по умолчанию libuv. Вот его реализация.

CreateEnvironment принимает цикл событий libuv в качестве аргумента loop. Мы можем вызвать Environment :: New из пространства имен V8 и отправить туда цикл событий libuv, а затем настроить его в среде V8. Так NodeJS стал асинхронным.

Я хотел бы поговорить о libuv подробнее и рассказать, как это работает, но это отдельная история в другой раз :)

Спасибо!

Спасибо всем, кто дочитал этот пост до конца. Надеюсь, вам понравилось, и вы узнали что-то новое. Если вы обнаружили какие-либо проблемы или что-то в этом роде, не стесняйтесь комментировать, и я отвечу как можно скорее.

Евгений Обрезков aka ghaiklor, технический руководитель компании «Оникс-Системс», Кировоград, Украина.