Возможно Вам доводилось слышать о том что Node.js идеален для создания веб-серверов. В этой статье я объясню, почему оно так и какие архитектурные принципы заложенные в основу серверного JavaScript, делают его таким подходящим для приложений с высокой интенсивностью ввода/вывода.
Параллелизм, однопоточность, многопоточность
Среда Node.js асинхронна по своей природе и эта особенность, позволяет реализовывать приложения с высокой степенью параллелизма, способных обрабатывать множество запросов одновременно. Весь этот параллелизм, однопоточен, а значит не будет возникать проблем с отладкой и синхронизацией, множества исполняемых потоков. Таким образом мы получаем параллелизм, присущий другим языкам (Java, C#), но никак не можем угодить в состояние гонки (race condition), так как вся работа происходит в одном потоке. И при этом, среда Node.js крайне экономна в вопросе оперативной памяти!
Но если вам этого мало и хочется полноценную многопоточку с блэкджеком и параллельными вычислениями, то в Node, как и в клиентском JavaScript, для этого есть веб-воркеры. Они тоже спроектированы, так что Вы не сможете прострелить себе колено, угадив в состояние гонки, круто-же! Веб-воркеры могут пригодиться для вычислений с интенсивным использованием процессора. Такая необходимость на серверах написанных на Node, возникает не часто, так что оставим их обсуждение на потом и сосредоточимся на классической асинхронке в Node.
Асинхронность
Вся среда Node изначально асинхронна. Тем не менее многие функции Node, на всякий случай, имеют синхронные аналоги блокирующие поток. Обычно они имеют постфикс Sync в названиях.
Асинхронность в Node.js появилась до промисов, поэтому она основана на обратных вызовах. Функции обратного вызова в Node, как правило, принимает 2 аргумента:
Ошибка (принцип error-frist callback). Если в ходе ошибок нет, то аргумент равен null, а если ошибки есть в нём будет объект Error либо числовой код ошибки. Ошибка ставится первым аргументом, для того чтобы её нельзя было пропустить. Коллбэк функция всегда должна проверять наличие ошибок.
Данные.
Для примера напишем простенький асинхронный скрипт читающий файл:
const fileSystemHandler = require('node:fs') // Подключаем модуль для работы с файловой системой
fileSystemHandler.readFile('text.txt', 'utf8', (error, data) => {
if (error) { // Обязательно в начале функции проверяем наличие ошибок
console.error(error)
return
}
console.log(data)
})
А вот то же самое, только с синхронной версией функции:
const fileSystemHandler = require('node:fs') // Подключаем модуль для работы с файловой системой
const fileData = fileSystemHandler.readFileSync('text.txt', 'utf8')
console.log(fileData)
Всё простенько и со вкусом синхронно, как в каком-нибудь PHP.
Жизненный цикл программ на Node.js
Работа любого приложения в среде Node делится на 2 этапа:
Исполнение кода. Если в коде нет асинхронных функций, то работа программы на этом завершается;
Если же асинхронные функции были, то после выполнения кода стартует второй этап: обработка событий. На данном этапе среда Node, "засыпает" и стартует только для запуска обработчиков событий. Завершается эта стадия, только после обработки всех зарегистрированных событий, но в принципе может длиться бесконечно.
Цикл обработки событий
Второй этап жизненного цикла программы на Node.js, работает по следующему алгоритму:
Регистрация обработчика события в операционной системе;
Сохранение в памяти функции обработчика, который должен быть вызван на данное событие;
Операционная система уведомляет Node, о возникновении ранее зарегистрированного события;
Node вызывает функцию-обработчик, повешенную на данное событие. В свою очередь данная функция, может регистрировать новые обработчики событий.

Такой жизненный цикл идеально подходит для серверов, которые большую часть времени проводят в ожидании ввода/вывода.
Промисы в Node.js
ES6 принёс в JavaScript, клёвую фичу промисы. Незаменимая штука для реализации асинхронного кода. Но как было написано выше, Node.js изначально был асинхронным и не мог ждать, когда в стандарте опишут инструментарий, который требуется уже здесь и сейчас. Но когда в стандарте появилась долгожданная асинхронность, разработчики среды Node, не могли проигнорировать столь важные изменения и поэтому в Node 10, был добавлен интерфейс util.promisify, позволяющий переделать любой метод, работающий через функции обратного вызова, на промисы. Переделаем примеры с чтением файла на промисы:
const fileSystemHandler = require('node:fs') // Подключаем модуль для работы с файловой системой
const util = require('util') // Подключаем модуль util, который поможет нам преобразовать методы на обратных вызовах, в методы на промисах
const promiseFileReader = util.promisify(fileSystemHandler.readFile) // Просто передаём методу promisify, метод который нужно переделать на промисы
promiseFileReader('text.txt', 'utf8').then((data) => { // Передаём те-же аргументы, что и раньше, передавались функции обратного вызова, за исключением, первого аргумента с ошибкой. В ответ получаем промис
console.log(data)
})
Вместо использования интерфейса promisify, пример выше можно упростить, импортировав модуль для чтения файлов, который изначально работает на промисах:
const promiseFileSystemHandler = require('node:fs/promises')
promiseFileSystemHandler.readFile('text.txt', { encoding: 'utf8' }).then((data) => {
console.log(data)
})
Вывод
Node.js благодаря своей дефолтной асинхронности и особенностям её реализации, является во многом уникальным языком, что делает его незаменимым для решения определённого пула задач, а именно создание крайне нетребовательных к ресурсам веб-серверов. В частности популярная в наших суровых краях, не менее суровая CMS Битрикс имеет в своём окружении Push and Pull сервер, сделанный на Node.js. Но как уже говорилось в предыдущих статьях по JS, интерфейсы данного языка часто оказываются избыточными, и серверная версия не стал исключением из данного правила, имея аж по 3 версии практически каждого метода. Это даёт определённую гибкость в разработке, но в то же время может привести к рассогласованности в стилистике кода. Поэтому перед стартом разработки на Node, команде стоит договориться между собой о формате методов которые будут доминировать в проекте. Лично я бы отдал предпочтение версиям на промисах, так как они:
Соответствуют стандарту ECMAScript;
Лучше передают суть асинхронности;
Имеют более глубокий инструментарий, встроенный в современные версии JavaScript (async/await, for/await и т.д.), что даёт большую гибкость.
Будут знакомы разработчикам клиентского JavaScript.
Отдавать приоритет синхронным функциям вообще не имеет никого смысла, так как они нивелируют главную фичу Node.js: асинхронность из коробки и уже не проще ли тогда реализовать задачу на PHP. Их стоит использовать, только в конкретных ситуациях, где требуется именно блокирующее поведение кода.