Текст
                    
Efficient Node.js A Beyond the Basics Guide Samer Buna
Эффективный Node.js от базовых навыков к профессиональной разработке Самер Буна 2026
ББК 32.988-02-018 УДК 004.738.5 Б91 Буна Самер Б91 Эффективный Node.js. — Астана: «Спринт Бук», 2026. — 224 с.: ил. ISBN 978-601-12-5945-3 Node.js — это технология, которая изменила современный веб. Более 10 миллионов разработчиков по всему миру используют ее для создания быстрых и масштабируемых приложений. Эта книга поможет вам присоединиться к их числу и превратить знания JavaScript в уверенные навыки бэкенд-разработки. Вы научитесь создавать надежные, быстрые и масштабируемые приложения, востребованные в таких компаниях, как Amazon, Netflix, LinkedIn. Самер Буна, эксперт с более чем 20-летним опытом в проектировании API и создании масштабируемых систем, шаг за шагом проведет вас от базовых принципов до продвинутых тем. Вы научитесь работать с асинхронностью, потоками, событиями и встроенными модулями Node.js, а затем осо­знанно использовать популярные библиотеки и инструменты экосистемы. 16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.) ББК 32.988-02-018 УДК 004.738.5 Права на издание получены по соглашению с O’Reilly. Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав. Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими. В книге возможны упоминания организаций, деятельность которых запрещена на территории Российской Федерации, таких как Meta Platforms Inc., Facebook, Instagram и др. ISBN 978-1098145194 англ. ISBN 978-601-12-5945-3 Authorized Russian translation of the English edition of Efficient Node.js ISBN 9781098145194 © 2025 Samer Buna. This translation is published and sold by permission of O’Reilly Media, Inc., which owns or controls all rights to publish and sell the same. © Перевод на русский язык ТОО «Спринт Бук», 2025 © Издание на русском языке, оформление ТОО «Спринт Бук», 2025
Краткое содержание От издательства...................................................................................................................... 11 Предисловие............................................................................................................................. 12 Глава 1. Что такое Node?....................................................................................................... 16 Глава 2. Скрипты и модули................................................................................................... 48 Глава 3. Асинхронность и события.................................................................................. 70 Глава 4. Ошибки и отладка................................................................................................... 92 Глава 5. Управление пакетами.........................................................................................107 Глава 6. Потоки данных.......................................................................................................125 Глава 7. Дочерние процессы.............................................................................................147 Глава 8. Тестирование в Node...........................................................................................158 Глава 9. Масштабирование в Node................................................................................176 Глава 10. Node на практике...............................................................................................195 Об авторе...................................................................................................................................218 Иллюстрация на обложке................................................................................................219
Оглавление От издательства...........................................................................................................11 О научных редакторах русского издания............................................................................... 11 Предисловие................................................................................................................12 Для кого написана эта книга?....................................................................................................... 12 Зачем я написал эту книгу?............................................................................................................ 12 Обозначения, принятые в этой книге....................................................................................... 13 Использование исходного кода примеров ........................................................................... 14 Благодарности..................................................................................................................................... 14 Глава 1. Что такое Node?.............................................................................................16 Введение в Node................................................................................................................................. 16 Язык JavaScript.............................................................................................................................. 18 Запуск кода на Node................................................................................................................... 19 Использование встроенных модулей................................................................................ 22 Использование пакетов........................................................................................................... 24 ES-модули........................................................................................................................................ 29 Асинхронные операции........................................................................................................... 33 Неблокирующая модель................................................................................................................. 35 Встроенные модули Node.............................................................................................................. 42 Пакеты Node......................................................................................................................................... 44 Аргументы против использования Node................................................................................ 45 Выводы.................................................................................................................................................... 46 Глава 2. Скрипты и модули.........................................................................................48 Интерфейс командной строки Node......................................................................................... 48 Опции и аргументы..................................................................................................................... 49
Оглавление  7 Переменные окружения.......................................................................................................... 52 Режим REPL..................................................................................................................................... 55 Модули Node........................................................................................................................................ 60 Определение пути к модулю.................................................................................................. 61 Загрузка модулей........................................................................................................................ 61 Область видимости модулей................................................................................................. 62 Выполнение модулей................................................................................................................ 64 Кэширование модулей.............................................................................................................. 67 Выводы.................................................................................................................................................... 69 Глава 3. Асинхронность и события...........................................................................70 Синхронная и асинхронная обработка.................................................................................... 70 Функции-обработчики..................................................................................................................... 73 Промисы.......................................................................................................................................... 75 async/await...................................................................................................................................... 77 Аналогия для объяснения промисов................................................................................. 79 Событийный цикл.............................................................................................................................. 82 Эмиттеры событий............................................................................................................................. 84 Асинхронность............................................................................................................................. 86 Ошибки............................................................................................................................................. 88 Примеры.......................................................................................................................................... 89 Выводы.................................................................................................................................................... 91 Глава 4. Ошибки и отладка.........................................................................................92 Создание и перехват ошибок....................................................................................................... 92 Типы ошибок......................................................................................................................................... 94 Стандартные ошибки................................................................................................................. 94 Системные ошибки..................................................................................................................... 96 Кастомные ошибки..................................................................................................................... 97 Многоуровневое управление ошибками..............................................................................100 Отладка в Node..................................................................................................................................102 Профилактические меры..............................................................................................................104 Инструменты контроля качества кода............................................................................105 Неизменяемые объекты.........................................................................................................105
8  Оглавление Тестирование...............................................................................................................................105 Проверка кода............................................................................................................................105 Выводы..................................................................................................................................................106 Глава 5. Управление пакетами.................................................................................107 Знакомство с управлением пакетами.....................................................................................107 Команда npm......................................................................................................................................109 Семантическое версионирование...........................................................................................114 Обновление и удаление пакетов..............................................................................................115 Создание и публикация пакетов...............................................................................................118 Скрипты npm Run.............................................................................................................................120 Команда npx........................................................................................................................................122 Выводы..................................................................................................................................................124 Глава 6. Потоки данных............................................................................................125 Что такое потоки данных?............................................................................................................125 Как использовать потоки данных?...........................................................................................127 Базовые знания о потоках данных...........................................................................................130 Метод pipe...........................................................................................................................................131 События потоков данных..............................................................................................................132 Приостановленный и потоковый режимы...........................................................................134 Имплементация потоков...............................................................................................................135 Записываемые потоки.............................................................................................................135 Читаемые потоки.......................................................................................................................136 Дуплексные/трансформирующие потоки......................................................................138 Асинхронные генераторы и итераторы.................................................................................140 Объектный режим потоков.........................................................................................................142 Встроенные трансформирующие потоки.............................................................................144 Выводы..................................................................................................................................................146 Глава 7. Дочерние процессы....................................................................................147 Что такое дочерние процессы?.................................................................................................147 Функция spawn..................................................................................................................................148 Синтаксис оболочки и функция exec.......................................................................................150
Оглавление  9 Функция execFile...............................................................................................................................153 Функция fork.......................................................................................................................................154 Выводы..................................................................................................................................................157 Глава 8. Тестирование в Node..................................................................................158 Проверочные утверждения и раннеры.................................................................................158 Типы тестов..........................................................................................................................................162 Модульное тестирование......................................................................................................163 Функциональные тесты..........................................................................................................164 Интеграционные тесты...........................................................................................................164 Сквозные тесты...........................................................................................................................165 Тестовые дублеры............................................................................................................................166 Организация и фильтрация тестов...........................................................................................170 Разработка через тестирование (TDD)...................................................................................172 Непрерывная интеграция............................................................................................................174 Выводы..................................................................................................................................................174 Глава 9. Масштабирование в Node.........................................................................176 Стратегии масштабирования......................................................................................................176 Модуль Cluster...................................................................................................................................177 Основной процесс и воркеры....................................................................................................179 Передача сообщений.....................................................................................................................183 Повышение доступности..............................................................................................................186 Перезапуски без простоев...........................................................................................................188 Работа с состоянием.......................................................................................................................191 Менеджеры процессов..................................................................................................................193 Выводы..................................................................................................................................................194 Глава 10. Node на практике......................................................................................195 Инструменты контроля качества кода...................................................................................195 Prettier.............................................................................................................................................197 ESLint...............................................................................................................................................198 Другие инструменты................................................................................................................199 Бандлеры модулей...........................................................................................................................200
10  Оглавление Таск-раннеры......................................................................................................................................203 Фреймворки.......................................................................................................................................206 Транспайлеры JavaScript...............................................................................................................210 TypeScript.............................................................................................................................................211 Выводы..................................................................................................................................................216 Об авторе....................................................................................................................218 Иллюстрация на обложке.........................................................................................219
От издательства Ваши замечания, предложения, вопросы отправляйте по адресу comp@sprintbook.kz (издательство «SprintBook», компьютерная редакция). Мы будем рады узнать ваше мнение! О научных редакторах русского издания Иван Щукин — тимлид команды обеспечения надежности фронтенда в Купере. Путь в ИТ начал в 1999 году системным администратором, имеет опыт разработки на JavaScript, Python, PHP и скриптовых языках. Екатерина Рязанова — ведущий разработчик интерфейсов команды клиентской архитектуры в Купере. Начинала в 2016 году как fullstack-разработчик на Node.js, затем занималась задачами в платформенных командах. Работала над BFF-сервисами, серверной логикой и реализацией SSR-решений. Благодарим их за помощь в работе над русскоязычным изданием книги и вклад в повышение качества переводной литературы.
Предисловие Я пользуюсь Node.js с самых ранних ее версий, и она никогда меня не подводила. Чем больше я писал на Node, тем бˆольшим уважением проникался к этой среде. Чем глубже я в нее погружался, тем продуктивнее работал. Node.js — по-настоящему революционная технология. Это мощная и гибкая среда. Как только вы освоите ее азы и поймете принципы работы с асинхронностью, вы будете быстро прогрессировать и сможете с легкостью создавать и масштабировать бэкенд-сервисы. Для кого написана эта книга? Я написал эту книгу в надежде помочь вам эффективно освоить Node. В ней мы частично затронем некоторые концепции JavaScript, но в целом, чтобы извлечь из книги максимальную пользу, вам нужно знать язык JavaScript хотя бы на базовом уровне. Если вы пока не чувствуете уверенности при работе с объектами, функциями, операторами и итераторами, рекомендую сначала прочитать вводную книгу по JavaScript. Когда я только начинал разбираться с Node, мне очень хотелось, чтобы кто-то выпустил такую книгу. Она особенно хорошо подойдет фронтенд-разработчикам, которые хотят расширить свой опыт в сторону бэкенда. Зачем я написал эту книгу? Во многих уроках, книгах и курсах по Node упор делается на библиотеки и инструменты из экосистемы Node, а не на саму среду выполнения. В них объясняют, как пользоваться популярными библиотеками и фреймворками, когда начать следовало бы со встроенных возможностей Node. Такой подход понятен: Node — низкоуровневая среда выполнения. Она не предлагает готовых решений, но дает набор небольших, но важных модулей, которые помогают быстрее и проще генерировать собственные. Например, полноценный веб-сервер умеет раздавать статические файлы (изображения, CSS и т. д.). С помощью встроенного модуля http в Node можно написать веб-сервер, который отдает бинарные данные, а со встроенным модулем fs — читать содержимое файловой системы. Объединив эти возможности, можно реализовать раздачу статических
Обозначения, принятые в этой книге  13 ресурсов собственным кодом на JavaScript. Но в Node нет встроенных возможностей, которые позволили бы сделать это сразу. Популярные библиотеки Node, не входящие в саму платформу (например, Express. js, Next.js и многие другие с суффиксом .js), предлагают почти готовые решения в своих областях. Так, Express.js специализируется на создании и запуске вебсервера с поддержкой раздачи статических ресурсов и множеством других возможностей. На практике большинство разработчиков не используют Node в чистом виде, поэтому учебные материалы часто концентрируются на библиотеках, которые предоставляют готовые решения, чтобы можно было сразу перейти к интересной части. Зачастую считается, что разбираться в основах Node надо только тем, кто пишет такие библиотеки. Однако я считаю, что необходимо изучить встроенные возможности Node до того, как начинать использовать внешние библиотеки и инструменты. Разработчики, которые хорошо разбираются в Node.js, осознанно выбирают и эффективно применяют их в своей работе. В этой книге я попытаюсь сделать упор на изучение собственных возможностей Node и только потом показать, как на основе этих знаний успешно использовать внешние библиотеки и инструменты из ее экосистемы. Обозначения, принятые в этой книге В этой книге используются следующие условные обозначения. Курсив Курсивом выделены новые термины и важные понятия. Моноширинный шрифт Используется для листингов программ, а также внутри абзацев для обозначения таких элементов, как переменные и функции, базы данных, типы данных, переменные среды, операторы и ключевые слова, папки и каталоги, пути, имена файлов и их расширений. Жирный моноширинный шрифт Показывает команды или другой текст, который пользователь должен ввести самостоятельно. Курсивный моноширинный шрифт Используется для оформления текста, который должен быть заменен значениями, предоставляемыми пользователем или определяемыми контекстом. Шрифт без засечек Используется для обозначения элементов интерфейса, названий клавиш, URL и адресов электронной почты.
14  Предисловие Так оформляется примечание. Так оформляется совет или предложение. Так оформляется предупреждение или предостережение. Использование исходного кода примеров Вспомогательные материалы (примеры кода, упражнения и т. д.) доступны для загрузки по адресу: https://github.com/samerbuna/efficient-node. Если у вас возникнут вопросы технического характера по использованию примеров кода, направляйте их по электронной почте на адрес support@oreilly.com. В общем случае все примеры кода из книги вы можете использовать в своих программах и документации. Вам не нужно обращаться в издательство за разрешением, если вы не собираетесь воспроизводить существенные части программного кода и если вы разрабатываете программу и используете в ней несколько фрагментов кода из книги. Но для продажи или распространения примеров из книги вам потребуется разрешение от издательства O’Reilly. Вы можете отвечать на вопросы, цитируя данную книгу или примеры из нее, но для включения существенных объемов программного кода из книги в документацию вашего продукта потребуется разрешение. Мы рекомендуем, но не требуем добавлять ссылку на первоисточник при цитировании. Под ссылкой на первоисточник мы подразумеваем указание авторов, издательства и ISBN. За получением разрешения на использование значительных объемов программного кода из книги обращайтесь по адресу permissions@oreilly.com. Благодарности Я безумно благодарен всем, кто приложил руку к выпуску этой книги. Прежде всего я хотел бы сказать огромное спасибо команде O’Reilly Media за терпение, поддержку и наставления на протяжении всей работы.
Благодарности  15 Спасибо ведущему редактору Джеффу Блейелю за ценные идеи, которые значительно улучшили ясность и подачу материала. А также редактору Аманде Куинн, литературному редактору Миа Сандвик и выпускающему редактору Грегори Хайману за огромный вклад в эту книгу. Также благодарю талантливых разработчиков, которые рецензировали черновики и давали бесценные комментарии. Вы помогли сделать эту книгу лучше. Особую благодарность я хотел бы выразить Хазему Тваиру за его внимательность к деталям (на которые без него я не обратил бы внимания), а также Тамашу Пирошу и Аникету Ваттамвару за скрупулезную техническую редактуру: без вас эта книга не была бы такой выверенной. Спасибо моим наставникам и коллегам в сообществе Node.js за мудрость, вдохновение и поддержку. Я благодарю всех, кто внес свой вклад в создание этой книги: ваша преданность делу и профессионализм позволили мне сделать больше, чем я бы смог, работая в одиночку. Ваша помощь очень много для меня значит!
ГЛАВА 1 Что такое Node? Node — это открытая кросс-платформенная среда выполнения, в которой разработчики могут создавать бэкенд-сервисы с помощью языка программирования JavaScript. Node построена на базе JavaScript-движка браузера Chrome, V8, и задействует множество встроенных модулей для асинхронности благодаря событийно-ориентированному подходу, известному также как неблокирующая модель. Разработчики Node могут использовать события и обработчики запросов, чтобы несколько операций выполнялись параллельно. Этот процесс эффективен и позволяет избегать сложностей, которые возникают при работе со множеством процессов и потоков. Нам предстоит многое узнать. В главе 1 мы сперва познакомимся с Node, выделим принципы ее работы и поймем, почему она так популярна. Затем изучим основы интерфейса командной строки Node, работу с модулями и пакетами, а также синхронные и асинхронные операции. Далее мы обсудим основы событийно-ориентированной неблокирующей модели Node и узнаем, как использовать коллбэки (callbacks), промисы (promises) и события (events) для обработки результатов асинхронных операций. Официальное название этой среды — Node.js, но для краткости я буду называть ее просто Node, как это делают другие специалисты. Введение в Node Работу над Node Райан Даль (Ryan Dahl) начал в 2009 году под впечатлением от производительности V8, JavaScript-движка Google Chrome. V8 основан на событийно-ориентированной модели и поэтому эффективно обрабатывает параллельные подключения и запросы. Райан захотел перенести эту событийно-ориентированную архитектуру на серверные приложения и добиться от них такой же производительности. Чтобы понимать, как работает Node (а заодно и V8), вам нужно запомнить, что оба они основаны на событийно-ориентированной модели. В этой главе я кратко введу вас в курс дела, а более подробно объясню все в главе 3.
Введение в Node  17 Я начал работать с Node после того, как посмотрел презентацию Райана Даля. Мне кажется, вам тоже стоит начать изучение Node с нее. Найдите на YouTube ролик «Ryan Dahl introduction to Node» (https://oreil.ly/TACAt), но помните, что с тех пор Node сильно изменилась, так что советую обращать внимание не на примеры, а на идеи и объяснения. По сути, Node позволяет использовать JavaScript на любой машине, не запуская браузер. Ее часто называют «JavaScript на бэкенде». До появления Node это было нетривиальной задачей, поэтому JavaScript в основном применяли во фронтенде. Однако «JavaScript на бэкенде» — не совсем точное определение, ведь Node нужна не только для того, чтобы запускать JavaScript на сервере. На самом деле JavaScript выполняется на движке V8, а Node лишь предоставляет ему интерфейс для запуска кода. V8 — это JavaScript-движок от Google с открытым исходным кодом, который компилирует и выполняет код, написанный на JavaScript. Он используется в Node, а также в Chrome и многих других браузерах. Кроме того, он применяется в Deno, новой среде выполнения для JavaScript, которую Райан Даль создал в 2018 году. V8 — не единственный движок для JavaScript. Также существуют, например, SpiderMonkey, используемый в Firefox, и JavaScriptCore, который применяется в браузере Safari и в Bun, универсальной среде выполнения, менеджере пакетов и сборщике JavaScript. Более точное определение Node звучит так: это серверная среда выполнения, построенная на базе V8 и предоставляющая модули, которые помогают разработчикам создавать и запускать эффективные приложения на JavaScript. Ключевое слово в этом определении — эффективные. Node переняла и развила событийно-ориентированную модель, на которой работает V8. На этой модели построены большинство встроенных модулей Node. Они могут использоваться асинхронно, не блокируя основной поток, в котором выполняется код. По сути, поток — это небольшой процесс, который происходит внутри более крупного. Каждый процесс может порождать несколько потоков. Каждый из этих потоков привязан к ядру процессора. Потоки могут одновременно использовать память и ресурсы в рамках общего процесса. В многопоточном программировании тяжелые операции обычно запускаются в отдельных потоках. В Node же код выполняется в одном, главном, а тяжелые операции — асинхронно, вне этого потока. Вам нужно прочитать содержимое внешнего файла? Это можно сделать асинхронно, не блокируя главный поток. Хотите запустить веб-сервер? Работать с сетевыми сокетами? Парсить, сжимать или шифровать данные? В Node у всех низкоуровневых операций асинхронный API, так что их можно совершать, не мешая остальным.
18  Глава 1. Что такое Node? В Node для параллельного выполнения операций не нужно работать со множеством потоков вручную и тратить ресурсы на те, которые простаивают. Вы можете спокойно писать код в одном потоке, потом использовать асинхронные API, а Node возьмет на себя эффективное выполнение асинхронных операций вне главного потока. Запуск любого кода после тяжелой операции можно организовать с помощью событий (event) и обработчиков (handler). Событие — это сигнал о том, что что-то произошло и нужно выполнить какое-то действие. Это действие задается функцией-обработчиком, связанной с этим событием. Каждый раз, когда поступает сигнал о событии, запускается нужный обработчик. Это и называется событийноориентированным подходом. Подробнее мы разберем эти важные идеи, когда научимся запускать код Node и работать с ее модулями и пакетами. Язык JavaScript Изучив такие языки программирования, как Python, Lua и Haskell, Райан Даль остановился на JavaScript. Он решил, что этот язык программирования лучше всего подходит для Node, потому что он простой, гибкий и популярный. Но самое главное: в JavaScript можно обращаться с функциями высшего порядка так же, как с любыми другими объектами. Их можно сохранять в переменные, передавать в другие функции в виде аргументов и даже возвращать как результат, при этом сохраняя их состояние. Это свойство JavaScript активно используется в Node для обработки асинхронных операций. Несмотря на ряд проблем, с которыми JavaScript сталкивался с самого начала, это вполне достойный язык программирования, и его можно сделать более эффективным с помощью TypeScript (это мы обсудим в главе 10). JavaScript как браузерный язык программирования не только упрощает реализацию асинхронных операций, но и позволяет Node использовать один язык для всего стека. Также это дает массу других неочевидных преимуществ. Работа с одним языком позволяет реализовывать и держать в голове меньше синтаксиса, меньше API и меньше инструментов, с которыми нужно разбираться, а значит, это помогает делать меньше ошибок. Один язык обеспечивает более эффективную интеграцию между фронтендом и бэкендом и позволяет применять один код и там и там. Например, можно создать фронтенд-приложение с использованием React, а затем отрендерить те же компоненты на сервере с помощью Node и сгенерировать HTML-страницу для фронтенд-приложения. Это называется серверным рендерингом страницы (SSR). Многие фронтенд-фреймворки для Node поддерживают его по умолчанию.
Введение в Node  19 Единый язык программирования дает командам возможность распределять проект между разработчиками, не создавая отдельные подразделения для фронт- и бэкенда. Над всем стеком: над API, веб- и сетевыми серверами, интерактивными сайтами, мобильными и даже десктопными приложениями — может работать одна команда. Работодателю выгодно нанимать JavaScriptразработчиков, способных работать как с фронтом, так и с бэком. Запуск кода на Node Если вы уже установили Node, команды node и npm должны быть доступны в терминале. Убедитесь, что у вас версия Node 20.x или выше. Чтобы это проверить, откройте терминал и выполните команду node -v. Если команда node не распознается, вам нужно скачать и установить Node с официального сайта https://nodejs.org. Это просто и занимает всего несколько минут. Пользователи macOS также могут установить Node с помощью системы управления пакетами Homebrew, выполнив команду: $ brew install node В этой книге символ $ обозначает команды, которые вводятся в терминале. $ не является частью команды. Как правило, он используется как символ приглашения для ввода команды в терминале. Также установить Node можно с помощью Node Version Manager (NVM). Он позволяет запускать несколько версий Node и легко переключаться между ними. Это удобно, если для одного проекта требуется старая версия Node, а для другого — самая новая. NVM работает на Mac и Linux, а для Windows есть аналог — nvmwindows. Node для Windows Все примеры, которые я привожу в этой книге, написаны для macOS, но должны работать и на системах на базе Linux. Для Windows необходимы их эквиваленты. Я не рекомендую использовать Node на Windows в «родной» среде при наличии альтернатив. Если у вас современный компьютер с Windows, советую установить подсистему Windows для Linux (WSL). Это отличный компромисс: вы сможете запускать Linux, не перезагружая компьютер, и даже редактировать код в Windows и выполнять его в Linux!
20  Глава 1. Что такое Node? Если вы используете NVM, установите последнюю версию Node. Для этого введите команду: $ nvm install node У Node довольно часто выходят мажорные обновления. Новая версия на полгода получает статус Current, чтобы разработчики библиотек успели адаптировать свои проекты. Через полгода разработчики перестают поддерживать нечетные версии (19, 21 и т. д.), а четные (18, 20 и т. д.) переходят в статус Active LTS» (долгосрочная поддержка), обычно на 30 месяцев. В продуктовом окружении стоит использовать только версии с LTS-статусом. После того как команда node заработала, откройте терминал и введите node без аргументов. Запустится интерактивная сессия Node REPL (Read-Eval-Print-Loop1). Это удобный способ быстро протестировать простой JavaScript- или Node-код. Вы можете ввести любую строку на JavaScript. Например, попробуйте Math.random(), как показано на рис. 1.1. Рис. 1.1. Режим Node REPL Node прочитает строку, проведет вычисления, выведет результат и будет повторять этот цикл со всем кодом, пока вы не завершите сессию сочетанием клавиш Ctrl+D. Обратите внимание, что вывод результата происходит автоматически, без ввода дополнительных команд. Node сам покажет результат каждой строки. А вот при работе со скриптами Node вводить дополнительные команды все же придется. Давайте рассмотрим и этот вариант. Подробнее особенности работы режима REPL в Node мы разберем в главе 2. Создайте новую папку для отработки упражнений из этой книги и перейдите в нее с помощью команд: 1 Цикл «чтение — вычисление — вывод». — Примеч. пер.
Введение в Node  21 $ mkdir efficient-node $ cd efficient-node Откройте редактор кода и создайте новый файл. Назовите его test.js и введите строку Math.random(): Math.random(); Чтобы выполнить этот скрипт, введите следующую команду: $ node test.js Обратите внимание: после ввода команды ничего не произошло. Так получилось потому, что мы не вывели результат скрипта. Чтобы это сделать, нужно использовать глобальный объект console, аналогичный тому, что доступен в веб-браузерах. Например: console.log( Math.random() ); Теперь при запуске test.js в терминале выведется случайное число (как показано на рис. 1.2). В этом простом примере используется как стандартный объект JavaScript, Math, так и объект console, который есть в Node API. Метод console. log записывает значения своих аргументов в стандартный поток вывода текущего процесса (stdout). Рис. 1.2. Выполнение скрипта Node
22  Глава 1. Что такое Node? console — один из множества глобальных объектов высшего уровня, доступных в Node без необходимости подключать зависимости. По аналогии с тем, как в браузерах доступ к глобальному объекту window можно получить через свойство globalThis, в Node globalThis является глобальным объектом, а console — его частью. Node дает прямой доступ ко всем свойствам globalThis: например, console.log вместо globalThis.console.log (хотя второй вариант тоже работает). Использование встроенных модулей Чтобы настроить простой веб-сервер в Node, можно использовать встроенный модуль node:http. Создайте файл server.js и вставьте в него следующий код: // Пример простейшего веб-сервера const { createServer } = require('node:http'); const server = createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World'); }); server.listen(3000, '127.0.0.1', () => { console.log('Server is running...'); }); Так в Node выглядит хрестоматийное «Hello World». И этот скрипт запустится на обычной Node, без установки чего-либо еще! Отличный пример того, насколько Node функциональна сама по себе. При запуске этого скрипта Node создаст веб-сервер и запустит его по адресу http://127.0.0.1:3000 (рис. 1.3). Учитывайте, что у Node есть одна особенность: процесс не завершится, пока не произойдет непредвиденная ошибка. Это связано с тем, что Node работает в фоновом режиме в ожидании запросов пользователей и выполняет их по мере поступления. Несмотря на то что это простейший веб-сервер, в нем есть несколько важных концептов. Давайте разберем их подробнее. Функция require в Node — часть оригинальной системы управления зависимостями. Она позволяет одному модулю (например, server.js) использовать функциональность другого (например, node:http). Когда мы подключаем модуль node:http с помощью функции require, модуль server.js становится зависимым от модуля node:http и отдельно работать не может. Также функциональность другого модуля позволяет задействовать инструкция import, которая относится к системе ES-модулей. Сейчас при работе на Node лучше
Введение в Node  23 применять именно эту систему, так как она является частью современного стандарта ECMAScript для модулей на JavaScript. Большинство примеров, которые мы будем рассматривать в этой книге, основаны именно на ней. Однако стоит изучить и оригинальную систему модулей, CommonJS, поскольку множество проектов и библиотек построено на ней и, скорее всего, рано или поздно вам придется с ней столкнуться, даже если вы будете создавать проект с нуля. Рис. 1.3. Простейший веб-сервер Существует множество библиотек для создания веб-сервера, но node:http встроена в Node по умолчанию, и чтобы ее использовать, не нужно устанавливать дополнительные компоненты. Однако ее нужно явно импортировать в проект с помощью функции require или инструкции import. В Node REPL встроенные модули (например, node:http) доступны глобально — их не нужно подключать отдельно. Но чтобы использовать в исполняемых скриптах даже встроенные модули, следует сначала объявить зависимость от них. При подключении модуля не обязательно загружать все его содержимое. Можно выбрать только те компоненты, что необходимы. В этом примере подключается только функция createServer, хотя в node:http доступно множество других функций и объектов.
24  Глава 1. Что такое Node? Чтобы создать объект сервера, мы вызываем createServer. В качестве аргумента указываем функцию RequestListener. В синтаксис пока можете не вникать: сейчас важно понять суть. В Node функции-слушатели связаны с конкретными событиями и запускаются при их срабатывании. В нашем примере Node будет выполнять функцию RequestListener при каждом входящем подключении к веб-серверу — это и есть событие этой функции. Функция-слушатель принимает два аргумента. Объект запроса (в примере назван req) Предоставляет информацию о входящих запросах, например URL или IP-адрес клиента. Объект ответа (в примере назван res) Отправляет ответ клиенту. Именно это делает наш простой веб-сервер. Он устанавливает успешный код ответа на 200, а заголовок Content-Type меняет на text/plain. Затем он отправляет текст Hello World методом end объекта res. Функция createServer лишь создает объект сервера, но не запускает его. Чтобы запустить веб-сервер, нужно вызвать на созданном сервере метод listen. Метод listen принимает множество аргументов — например, хост и порт, на которых будет работать сервер. Последним аргументом передается функция, которая вызовется один раз при успешном запуске сервера. В нашем примере эта функция выводит в консоль сообщение о том, что сервер работает. Обе функции, которые используются в методах createServer и listen, это примеры функций-обработчиков: они привязаны к событиям и связаны с асинхронными операциями. Как управляются такие события и функции-обработчики, мы подробнее изучим в главе 3. Обратите внимание: при работе со встроенными модулями в Node я пишу префикс node:. Он помогает сразу отличить встроенный модуль от внешнего. Для некоторых модулей (например, node:test) префикс обязателен, так что лучше использовать его везде — для единообразия. Сочетание клавиш Ctrl+C в терминале останавливает работу веб-сервера. Использование пакетов Менеджер пакетов для Node npm — простой инструмент командной строки, который позволяет устанавливать в проект внешние пакеты и управлять ими. Пакет может включать как один, так и несколько модулей, которые сгруппированы
Введение в Node  25 вместе и предоставляют единый API. Подробнее об npm, его командах и работе с пакетами мы поговорим в главе 5. Сейчас же давайте рассмотрим простой пример установки и использования npm-пакета. В качестве примера возьмем популярный пакет lodash — это вспомогательная библиотека JavaScript, содержащая множество полезных методов, которые можно применять к числам, строкам, массивам, объектам и другим элементам. Сначала нужно скачать пакет. Для этого используется команда npm install: $ npm install lodash Она скачает пакет lodash из репозитория1 npm и поместит его в папку node_modules (а также создаст эту папку, если ее еще нет). Проверить это можно, запустив команду ls: $ ls node_modules После выполнения команды в папке node_modules должна появиться папка lodash. Теперь вы можете подключить и использовать в своем коде модуль lodash с помощью require. Например, у lodash есть метод random, который генерирует случайное число в заданном диапазоне значений. Вот пример его использования: const _ = require('lodash'); console.log( _.random(1, 99) ); После выполнения этого скрипта на экране появится случайное число от 1 до 99 (рис. 1.4). Подчеркивание (_) — распространенное имя переменной для lodash, но вы можете использовать любое другое. Так как мы импортировали внешний (не встроенный) модуль lodash с использованием метода require, Node будет искать его в папке node_modules — и найдет благодаря npm. 1 В оригинале используется словосочетание npm registry, которое в современном техническом языке полностью калькируется с английского. Под русским словом «реестр» чаще понимается windows registry — централизованное хранилище параметров операционной системы. При работе с пакетами, как правило, употребляется слово «репозиторий». — Примеч. науч. ред.
26  Глава 1. Что такое Node? Рис. 1.4. Использование npm-пакета При работе в команде важно уведомить других разработчиков о добавлении внешней зависимости. В Node это делается через файл package.json, который располагается в корне проекта. При установке модуля с помощью npm install команда npm автоматически добавляет его и его текущую версию в файл package.json — в раздел dependencies1. Посмотрите, как это выглядит в файле package.json, который был автоматически создан после установки пакета lodash (рис. 1.5). Файл package.json может содержать такую информацию о проекте, как имя, версия, описание и так далее. Также в него можно включить сведения о сценариях командной строки, которые должны быть запущены для выполнения разнообразных задач, например сборки и тестирования проекта. Вот так обычно выглядит файл package.json: { } 1 "name": "efficient-node", "version": "1.0.0", "description": "A guide to learning Node.js", "license": "MIT", "scripts": { "start": "node index.js" }, "dependencies": { "lodash": "^4.17.21" } Зависимости. — Примеч. пер.
Введение в Node  27 Рис. 1.5. Файл package.json Файл package.json для нового проекта также можно создать в интерактивном режиме. Для этого следует ввести команду: $ npm init После этого нужно будет ответить на несколько вопросов (или каждый раз нажимать Enter, чтобы оставить значения по умолчанию, — чаще всего они подойдут, так как команда старается автоматически определить информацию о проекте). Затем введите команду npm install и установите новый пакет (например, chalk). Он появится в файле package.json в разделе dependencies. Удалите пакет с помощью npm uninstall и убедитесь, что он исчез из package.json. Со временем в файле package.json появится множество зависимостей. Другим разработчикам, если им придется работать с вашим кодом, будет достаточно ввести команду npm install, и она установит все зависимости, перечисленные в package. json, в папку node_modules. Некоторые пакеты, например ESLint, нужны только в разработке, а в продуктовом окружении в них необходимости нет. При запуске команды npm install можно указать, что эта зависимость используется только для разработки. Это можно сделать с помощью аргумента --save-dev (или просто -D): $ npm install -D eslint Эта команда установит пакет eslint в папку node_modules и добавит его в раздел devDependencies файла package.json. В этот раздел помещаются тестовые фреймворки, средства форматирования кода и другие утилиты, которые используются только при разработке.
28  Глава 1. Что такое Node? Помимо dependencies и devDependencies в package.json можно указывать optionalDependencies — необязательные зависимости — и peerDepen­ dencies — зависимости, которые должны работать вместе с другими пакетами, но не зависят от них напрямую. PeerDependencies чаще всего используются авторами библиотек. Давайте заглянем в папку node_modules после установки eslint. Обратите внимание: там появилось множество новых пакетов (рис. 1.6). Рис. 1.6. Пакеты npm и их транзитивные зависимости Пакет eslint зависит от всех этих пакетов. Эти транзитивные связи нужно учитывать. При подключении одного пакета проект автоматически становится зависимым от всех его зависимостей, зависимостей подзависимостей и так далее. С установкой каждого пакета вы добавляете в код дерево зависимостей.
Введение в Node  29 Некоторые пакеты можно установить и настроить с помощью команды init . ESLint — пример пакета, который требует конфигурационного файла для начала использования. Установить ESLint и создать этот файл можно с помощью следующей команды (после ее ввода также надо будет ответить на несколько вопросов о проекте): $ npm init @eslint/config@latest В продуктовом окружении1, как правило, игнорируются зависимости, используемые только для разработки. Для команды npm install есть флаг --production, который позволяет пропустить установку этих зависимостей. Также перед выполнением команды npm install можно установить переменную окружения NODE_ENV в значение production. Подробнее о средах Node и переменных мы поговорим в главе 2. ES-модули В Node есть два типа загрузчиков модулей. По умолчанию это CommonJS, который был в примере с функцией require. Еще существует нативный загрузчик ES-модулей, который также поддерживается современными веб-браузерами. В ESмодулях для подключения зависимостей используется конструкция import, а для предоставления функциональности другим модулям — export. Одно из ключевых отличий между этими системами модулей заключается в том, что CommonJS-модули загружаются динамически во время выполнения, тогда как зависимости ES-модулей определяются на этапе компиляции. Это позволяет статически анализировать и оптимизировать код. Например, с ES-модулями легко определить неиспользуемый код и исключить его из скомпилированной версии приложения. Эти типы модулей можно использовать одновременно, но с осторожностью. Не забывайте, что модули CommonJS работают синхронно, а ES-модули — асинхронно. Чтобы посмотреть, как работают ES-модули, мы усложним уже знакомый нам пример с веб-сервером и разделим его на два файла: один будет создавать сервер, а второй — запускать его. 1 В оригинале используется слово «машина», однако современные среды сборки и выполнения кода — это сложные многоуровневые абстракции, в которых «машина» — один из нижних уровней. Код сейчас зачастую собирается в контейнеры, которые выполняются в кластерах из нескольких машин. Поэтому чаще говорится об «окружении» в целом, нежели о конкретных отдельно взятых «машинах». — Примеч. науч. ред.
30  Глава 1. Что такое Node? Самый простой способ внедрить в Node ES-модули — сохранять файлы в расширении .mjs, а не .js. По умолчанию Node считает, что файлы с расширением .js используют систему CommonJS, но это можно изменить. Чтобы Node считал все .js-файлы ES-модулями, добавьте в package.json ключ type со значением module (по умолчанию там стоит commonjs). Это можно сделать вручную или с помощью команды: $ npm pkg set type=module Теперь вы можете использовать ES-модули с расширением .js. Независимо от того, какой тип модулей используется по умолчанию, Node всегда будет считать файлы с расширением .mjs модулями ES, а файлы с расширением .cjs — модулями CommonJS. .cjs-файл можно импортировать в ES-модуль, и наоборот — .mjs -файл можно импортировать в модуль CommonJS. Давайте переделаем наш пример простейшего веб-сервера с использованием ESмодулей. В файл server.js добавьте следующий код: import { createServer } from 'node:http'; export const server = createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World'); }); Обратите внимание на конструкции import и export. Это синтаксис ES-модулей: import обозначает зависимость от модуля, а export указывает, какие части вашего модуля могут использовать другие. В этом примере модуль server.js экспортирует объект server, чтобы другие модули могли его импортировать и применять. Чтобы использовать экспортированный объект из server.js в других модулях, нужна еще одна конструкция import. В файл index.js добавьте: import { server } from './server.js'; server.listen(3000, () => { console.log('Server is running...'); }); Префикс ./ в имени модуля сообщает Node, что поиск файла с модулем необходимо выполнять в той же папке, в которой находится index.js. Для того чтобы подняться на уровень выше, введите ../, на два уровня — ../../ и так далее.
Введение в Node  31 Без ./ или ../ Node решит, что вы импортируете встроенный модуль или модуль из папки node_modules. В этом коде модуль index.js зависит от модуля server.js и использует его экспортированный объект server, чтобы запустить сервер на порту 3000. Откройте веб-сервер и проверьте его работу с помощью файла index.js, как показано на рис. 1.7. Рис. 1.7. Веб-сервер на базе ES-модуля Синтаксис export object называется именованным экспортом. Он отлично подходит, когда нужно экспортировать из модуля несколько сущностей. Ключевое слово export можно использовать с любыми объектами: функциями, классами и неструктурированными переменными: export function functionName() { ... } export class ClassName { ... } export const [name1, name2] Также все именованные объекты можно экспортировать с помощью одного ключевого слова export (обычно это делается в конце модуля): export { functionName, ClassName, name1, name2, // ... };
32  Глава 1. Что такое Node? Именованные экспорты можно импортировать по отдельности или все сразу, используя синтаксис * as: // Для импорта именованных объектов по отдельности: import { functionName, name1 } from './module' // Для импорта всех именованных объектов: import * as serviceName from './module' // После этого именованные объекты доступны под: // serviceName.functionName // serviceName.name1 Помимо именованного экспорта, ES-модули также поддерживают экспорт по умолчанию: // Для экспорта объекта server // как экспортируемого объекта по умолчанию // в модуле server.js: export default server; // Для импорта объекта по умолчанию // необходимо указать его имя в целевом модуле: import myServer from './server.js'; // Или: import { default as myServer} from './server.js'; Обратите внимание: при импорте объекта по умолчанию нужно задать имя, тогда как при использовании именованных объектов этого не требуется (хотя и возможно). Именованные объекты выигрывают с точки зрения единообразия, удобства поиска и сопровождения кода. Так как в модуле может не быть экспортируемого объекта по умолчанию, все­ гда можно использовать синтаксис * as, он работает как с экспортируемыми объектами по умолчанию, так и без них. Ключевые слова export/import также позволяют переименовать объект или использовать объекты других модулей. Однако я рекомендую по возможности избегать экспорта по умолчанию. Всегда именуйте экспортируемые объекты и оформляйте их просто и единообразно. Например, я всегда указываю именованные экспорты в последней строке модуля через единый export { ... }.
Введение в Node  33 Аналогии для понимания Node и npm Иногда разобраться в нюансах программирования помогают аналогии из реальной жизни. Лично мне нравится сравнивать программирование с составлением кулинарных рецептов. В таком случае рецепт — это программа, а повар — компьютер. Иногда можно брать готовые ингредиенты — например, смесь для выпечки или соус. И конечно, при готовке никак не обойтись без инструментов: кастрюли, сита и т. д. В программировании аналоги таких заготовок и инструментов — сторонние пакеты кода, которые можно скачать и использовать во время работы. Продолжим аналогию: npm-репозиторий — это магазин, где вы берете заготовки и инструменты, чтобы «приготовить» код по рецепту. Но с чем тут сравнить Node? Мне кажется, что Node — это кухня, где можно «готовить» строки кода по вашему рецепту с помощью встроенных инструментов вроде плиты или раковины. Представьте, что перед тем, как начать готовить, вам нужно сначала смастерить плиту. Согласитесь, без встроенных инструментов приготовить вкусное блюдо было бы намного сложнее! Асинхронные операции Хотя для загрузки начальных зависимостей предпочтительнее использовать статические инструкции импорта, часто необходим динамический импорт модулей. Например: когда модуль замедляет загрузку кода, и сейчас в нем нет необходимости; когда модуль отсутствует в момент загрузки; когда имя импортируемого модуля формируется динамически; когда модуль должен загружаться по определенным условиям. В таких случаях можно использовать функцию import(). Она похожа на require(), но работает асинхронно. Представьте, что нам нужно прочитать содержимое внешнего файла перед запуском базового веб-сервера. Мы можем смоделировать задержку чтения файла, используя простую функцию setTimeout.
34  Глава 1. Что такое Node? Чтобы отложить выполнение функции или сделать так, чтобы она регулярно повторялась, существуют функции таймера. На случай, если вам не доводилось с ними работать, ниже я приведу несколько примеров. Поскольку модуль server.js нужен нам не сразу, его можно импортировать с помощью import(). Тогда он запустится в заданное время: setTimeout(async () => { const { server } = await import('./server.js'); server.listen(3000, () => { console.log('Server is running...'); }); }, 5_000); После выполнения этого кода Node подождет 5 секунд, затем динамически импортирует модуль server.js и с его помощью запустит сервер. Этот пример вводит важное понятие объекта Promise и показывает, как использовать его через синтаксис async/await. Это современный способ работы с асинхронными операциями в JavaScript. Подробнее Promise и async/await мы разберем в следующем разделе. Выражения динамического импорта можно использовать в модулях CommonJS для загрузки модулей ES. Функции таймера Такие функции таймера в Node, как setTimeout и setInterval, работают аналогично тем, что используются в веб-браузерах. Эти функции принимают в качестве аргумента другую функцию: const printGreeting = () => console.log('Hello'); setTimeout(printGreeting, 4_000); Функция printGreeting будет вызвана один раз через четыре секунды. Для многократного повторения можно использовать функцию setInterval. Если заменить setTimeout на setInterval, Node будет бесконечно выводить сообщение «Hello» каждые четыре секунды. Функции таймера можно отменить после их определения. При вызове таймера возвращается его уникальный идентификатор, который нужен для отмены запланированной операции. Для остановки таймера, запущенного через setTimeout, используют clearTimeout(timerId), а для остановки setInterval — clearInterval(timerId):
Неблокирующая модель  35 const timerId = setTimeout( () => console.log('Hello'), 0, ); clearTimeout(timerId); В этом примере, несмотря на то что мы установили таймер на ноль миллисекунд, сообщение выведено не будет, потому что асинхронная операция была отменена сразу после создания таймера. Неблокирующая модель Когда в вашем коде требуется выполнить тяжелую операцию (например, прочитать файл из файловой системы), нужно обработать ее результат. Смоделируем тяжелую операцию с помощью долгого цикла for: function slowOperation() { for (let i = 0; i <= 1e9; i++) { // ... } return { success: true }; } Функция slowOperation может сработать успешно, а может сгенерировать ошибку (например, по таймауту). Вот простой пример функции, которая будет обрабатывать результат: function handlerFunction(output) { if (!output.success) { // Что-то пошло не так } // Обработай результат } Чтобы передать результат функции slowOperation в handlerFunction, используйте ее так: const output = slowOperation(); handlerFunction(output) console.log('Hello'); // Другие операции Проблема заключается в том, что одна такая тяжелая операция блокирует все остальные. Команда console.log выполнится только после завершения slowOperation и handlerFunction.
36  Глава 1. Что такое Node? Поскольку функции в JavaScript можно передавать как аргументы, мы можем переписать slowOperation так, чтобы она сама вызывала функцию-обработчик после завершения: function slowOperation(callbackFunction) { for (let i = 0; i <= 1e9; i++) { // ... } callbackFunction({ success: true }); } slowOperation( (output) => handlerFunction(output) ); // Другие операции Теперь мы запустим slowOperation в другом потоке, и остальные операции в главном потоке не будут блокироваться. Это называется паттерн с использованием функции обратного вызова, или паттерн коллбэков (callback pattern), — именно с его помощью в Node изначально обрабатывались асинхронные операции. Коллбэк вызывается позже, после завершения тяжелой операции. Функция setTimeout — простейший пример асинхронной функции, использующей коллбэк-паттерн: setTimeout( function callback() { console.log('World'); }, 2_000, // задержка в миллисекундах ); console.log('Hello'); Сама по себе функция setTimeout не является частью JavaScript. Она реализована в среде выполнения, например в Node или браузере. JavaScript-движок исполняет именно функцию коллбэка. Коллбэк-паттерн можно рассматривать как простой способ выполнения асинхронной операции с функцией-обработчиком и встроенным событием. В случае с setTimeout в роли встроенного события выступает задержка по времени. Обработчик здесь — функция, которую мы передаем в setTimeout. Сама операция таймера выполняется в отдельном потоке, чтобы не блокировать основной. Поэтому вывод будет следующим: Hello World
Неблокирующая модель  37 Первой выполняется операция, которая указана после операции таймера. Когда две секунды спустя сработало встроенное событие, движок V8 запускает коллбэк, связанный с этим событием. Такой же результат будет и при задержке таймера в ноль миллисекунд. Все асинхронные операции, независимо от скорости их выполнения, немедленно убираются из главного потока, обрабатываются внутри Node и после завершения всех синхронных операций возвращаются в поток с помощью коллбэка. Для того чтобы запланировать выполнение всех дальнейших синхронных операций, можно использовать код с нулевой задержкой. Это хорошая иллюстрация неблокирующей модели, на которой работает Node. Если поместить длинный цикл for в коллбэк setTimeout с задержкой в ноль миллисекунд, по сути, мы отложим выполнение этого цикла до тех пор, пока не завершатся все синхронные операции, идущие после него. setTimeout( () => { for (let i = 0; i <= 1e9; i++) { // ... } }, 0, ); console.log('Hello'); Сначала на экран выведется сообщение «Hello», а затем выполнится длинный цикл. Важно помнить, что задержка в функциях таймера указывает не на абсолютное, а на минимальное время ожидания. Например, задержка в 10 миллисекунд означает, что функция будет выполнена не раньше, чем через 10 миллисекунд, но может и позже, в зависимости от других операций в очереди. Через несколько лет после успеха Node и широкого применения коллбэк-паттерна в JavaScript появились объекты Promise. Promise — это значение, которое может стать доступным в будущем, что позволяет оборачивать асинхронные операции в Promise-объекты и прикреплять к ним обработчики, которые будут выполнены после того, как получено значение промиса. Ниже я привел пример использования Promise вместе с slowOperation и hand­ lerFunction: const outputPromise = slowOperation(); outputPromise.then( (output) => handlerFunction(output) ); // Другие операции
38  Глава 1. Что такое Node? В модуле node:timers есть версия setTimeout, основанная на Promise, которую удобно использовать с этим паттерном: import { setTimeout } from 'node:timers/promises'; setTimeout(2_000).then( function callback() { console.log('World'); } ); console.log('Hello'); Этот код работает по тому же принципу, что и пример с коллбэком. Сначала сразу появится сообщение «Hello», а затем с задержкой в две секунды — «World». Для работы с асинхронными API встроенных модулей в Node до сих пор можно использовать как коллбэк-паттерны, так и паттерны с промисами. Рассмотрим пример из модуля node:fs, с помощью которого можно читать содержимое файлов файловой системы. Проще всего это сделать так1: // Чтение файла синхронно import { readFileSync } from 'node:fs'; const data = readFileSync('/Users/samer/.bash_history'); console.log(Length: ${data.length}); console.log(Process: ${process.pid}); Результат выполнения этого кода показан на рис. 1.8. Чтение файла — это операция ввода-вывода. В этом примере она выполняется синхронно, то есть блокирует главный поток, так что весь код, написанный после нее, будет ждать завершения операции. Обратите внимание, что команда console.log для отображения PID процесса была выполнена только после окончания чтения файла. Это плохо, особенно если вам нужно прочитать большой файл. Если бы этот код был частью веб-сервера, все входящие запросы должны были бы ждать завершения этой блокирующей операции в главном потоке. Пример такой блокировки мы рассмотрим в главе 3. 1 Во введении автор делает оговорку, что примеры указывает для MacOS. Файл .bash_history, в случае установки на компьютер оболочки Bash, есть и в MacOS, и в Linux, можно заменить /Users/samer/.bash_history на ~/.bash_history для унификации. Но вообще использование абсолютных путей к файлам считается в современной разработке плохой практикой. — Примеч. науч. ред.
Неблокирующая модель  39 Рис. 1.8. Чтение файла синхронно Операцией ввода-вывода называют любое взаимодействие между процессом компьютерной программы и внешним миром. Обычно это передача данных с или на устройства хранения (жесткие диски, память), периферийные устройства (клавиатуры, мыши, принтеры) или через сеть. Операции ввода-вывода могут быть тяжелыми, поэтому, как правило, они выполняются в отдельных потоках, чтобы не блокировать основной поток. В некоторых случаях синхронное выполнение операций ввода-вывода допустимо. Например, если нужно один раз прочитать файл перед запуском веб-сервера или его завершением, это можно сделать синхронно. Но в большинстве случаев лучше избегать синхронных операций и использовать только неблокирующие. Прочитать содержимое файла асинхронно, не блокируя основной поток, можно так: // Чтение файла асинхронно import { readFile } from 'node:fs'; readFile('/Users/samer/.bash_history', function cb(error, data) { console.log(`Length: ${data.length}`); }); console.log(`Process: ${process.pid}`); Результат вывода этого кода представлен на рис. 1.9. Обратите внимание, что сообщение о процессе было выведено до вывода длины данных файла. Операция чтения файла не заблокировала главный поток.
40  Глава 1. Что такое Node? Рис. 1.9. Чтение файла асинхронно Это происходит благодаря асинхронности метода readFile. Node вообще не выполняет его в главном потоке. Он передается во внутреннюю очередь, а ассоциированный с ним коллбэк будет вызван только после завершения чтения. В этом простом примере коллбэк напрямую связан с методом readFile, но на самом деле он ассоциируется с неявным событием, которое срабатывает, когда данные файла готовы. Скоро мы разберем примеры функций, связанных с явными событиями — как встроенными, так и пользовательскими. Коллбэк в этом примере можно заменить на Promise-объект: // Асинхронное чтение файла с использованием промисов import { readFile } from 'node:fs/promises'; async function logFileLength() { const data = await readFile('/Users/samer/.bash_history'); console.log(`Length: ${data.length}`); } logFileLength(); console.log(`Process: ${process.pid}`);
Неблокирующая модель  41 Обратите внимание, что здесь метод readFile импортируется из модуля node:fs/ promises. Это встроенная промисифицированная версия модуля fs в Node. Вызов этого метода вернет объект Promise. Чтобы получить результат, мы используем в асинхронной функции ключевое слово await. Оно приостанавливает выполнение функции logFileLength до тех пор, пока промис не будет завершен (успешно или с ошибкой). Любая функция, использующая await, становится асинхронной и неявно возвращает Promise-объект. Результат выполнения этого кода проиллюстрирован на рис. 1.10. Рис. 1.10. Чтение файла с промисами Промисы сильно упрощают понимание кода и работу с ним. Обратите внимание на структуры кода при чтении файла асинхронно и синхронно с помощью Promise: они очень похожи. При работе с Promise-объектами неблокирующую модель Node можно использовать без коллбэков. Promise-объекты вместе с синтаксисом async/ await делают код более читаемым, особенно когда нужно выполнять несколько асинхронных операций, зависящих друг от друга. Коллбэки сильно усложняют работу с кодом, тогда как с промисами достаточно добавить дополнительные строки с await. Подробнее события, коллбэки, промисы и синтаксис async/await мы изучим в главе 3.
42  Глава 1. Что такое Node? Встроенные модули Node Вооружившись простой неблокирующей моделью, Райан Даль и другие первопроходцы проекта Node реализовали множество низкоуровневых модулей, которые предоставляют асинхронные API для таких задач, как чтение и запись файлов, передача и получение данных по сети, сжатие и шифрование данных, а также множество других функций. Мы уже рассмотрели простые примеры использования модулей node:http и node:fs. Чтобы увидеть список всех встроенных модулей Node, выполните следующую команду (в сеансе REPL, как показано на рис. 1.11): require('repl').builtinModules Рис. 1.11. Встроенные модули в Node По сути, все это вам нужно знать, чтобы освоить Node. Правда, точный список зависит от версии Node и поэтому может включать устаревшие или устаревающие модули. В зависимости от сферы вашей работы и других факторов большая часть этих модулей может вам не понадобиться. Например, если вы работаете с HTTPS, можно не использовать встроенные возможности Node, а разместить Node HTTPсервер за обратным прокси, таким как nginx, или сервисом вроде Cloudflare. А опыт работы с модулями типа wasi понадобится, только если вы будете работать с WebAssembly.
Встроенные модули Node  43 Обратите внимание, что некоторые из этих модулей указаны дважды — с суффиксом /promises. Это означает, что модуль поддерживает как коллбэк-паттерн, так и паттерны с промисами. В этом списке указаны не все модули Node. В него не включены модули, доступные только по префиксу, и экспериментальные модули, например node:test, node:sea и node:sqlite. Полный список модулей и их статус разработки можно найти в таблице стабильности в официальной документации Node (https:// nodejs.org/docs/latest/api/documentation.html). Рекомендую ознакомиться с этим списком, чтобы получить общее представление о возможностях Node. В таблице 1.1 перечислены ключевые модули и их основные функции. Таблица 1.1. Основные модули Node Модуль Задача node:assert Проверка условий перед тестированием node:buffer Представление и обработка бинарных данных node:child_process Запуск команд оболочки и ветвление процессов node:cluster Масштабирование процесса путем распределения нагрузки между воркерами node:console Вывод отладочной информации node:crypto Выполнение криптографических операций node:dns Разрешение доменных имен, например поиск IP-адресов node:events Определение пользовательских событий и обработчиков node:fs Работа с файловой системой node:http Создание HTTP-серверов и клиентов node:net Создание сетевых серверов и клиентов node:os Взаимодействие с операционной системой node:path Работа с путями к файлам и папкам node:perf_hooks Измерение и анализ производительности приложений node:stream Эффективная обработка больших потоков данных node:test Написание и выполнение JavaScript-тестов node:timers Планирование выполнения кода на будущее время node:url Парсинг и работа с объектами URL В этой книге приведено множество примеров использования этих модулей. Некоторым из них посвящены целые главы.
44  Глава 1. Что такое Node? Пакеты Node Node поставляется с мощной системой управления пакетами — npm. До появления Node в мире JavaScript не было пакетного менеджера, поэтому npm стал настоящей революцией. Он кардинально изменил подход к работе с JavaScript. В Node можно реализовать множество функций с помощью кода, который свободно доступен через npm. В реестре npm находится более миллиона пакетов, которые можно установить и применять на ваших Node-серверах. Пакетный менеджер надежен и имеет простой интерфейс командной строки. Основная команда, npm, предлагает простые способы установки и сопровождения сторонних пакетов, а также публикации собственного кода и его повторного использования. Пакеты Node можно устанавливать и из других реестров, например напрямую из GitHub. Вместе npm и модульная система Node серьезно упрощают работу с любыми JavaScript-системами, а не только с теми, что запускаются на бэкенде или в брау­ зере. Например, если у вас есть умный холодильник, работающий на JavaScript, вы можете использовать Node и npm для упаковки, организации и управления зависимостями, а затем собрать код и отправить его в этот холодильник! В Node можно запускать разные пакеты, как небольшие, предназначенные для выполнения конкретных задач, так и те, что позволяют управлять жизненным циклом приложений. Есть также пакеты, которые разработчики используют ежедневно, чтобы создавать и поддерживать большие сложные проекты. Вот мои любимые инструменты, доступные в npm: ESLint Этот инструмент можно подключить к любому Node-приложению и использовать для поиска проблем в коде, написанном на JavaScript (а в некоторых случаях — и для их автоматического исправления). ESLint помогает следовать более эффективным практикам и поддерживать единый стиль кода. Также он способен указывать на возможные ошибки выполнения. В продуктивной среде его не применяют — это инструмент, предназначенный для улучшения качества кода на этапе разработки. Prettier Инструмент для автоматического форматирования кода с жестко заданными правилами. С Prettier вам не придется вручную выравнивать отступы, делить длинные строки кода или следить за единообразием стиля (например, всегда использовать одинарные или двойные кавычки, ставить точку с запятой или наоборот). Он берет всю эту работу на себя.
Аргументы против использования Node  45 Webpack Инструмент для сборки и объединения ресурсов. Позволяет легко собрать многофайловое фронтенд-приложение в один файл для продуктового окружения и скомпилировать расширения JavaScript (например, JSX для React). Этот инструмент Node можно использовать автономно — для работы с Webpack веб-сервер на Node не нужен. TypeScript Инструмент, добавляющий в JavaScript статическую типизацию и другие функции. Полезен тем, что позволяет находить ошибки еще до выполнения кода, тем самым упрощая поддержку и масштабирование больших проектов. Статическая типизация TypeScript также улучшает автодополнение и документацию в редакторах кода и тем самым повышает производительность разработчиков. Все эти и другие инструменты значительно расширяют возможности создания и поддержки JavaScript-приложений как на фронтенде, так и на бэкенде. Даже если вы не размещаете фронтенд-приложения на Node, ее все равно можно использовать для сборки. Например, фронтенд может быть размещен на фреймворке вроде Ruby on Rails, а Node будет использоваться для сборки ассетов. Подробнее об этих и других инструментах мы поговорим в главе 10. Аргументы против использования Node Node основан на асинхронной неблокирующей модели. Этот уникальный подход требует переосмысления традиционной логики программирования и с непривычки может показаться странным. Чтобы с ним освоиться, может потребоваться какое-то время. Изначально модульная система Node строилась на стандарте CommonJS. Со временем вместо него начали использовать ES-модули. И хотя Node поддерживает оба подхода, их совмещение может смутить пользователей, особенно новичков. Различия в импорте и экспорте между CommonJS- и ES-модулями могут привести к неустойчивости кода и проблемам несовместимости. Разработчики на Node часто используют множество сторонних библиотек и зависимостей, которые npm размещает в одной общей папке node_modules. Эта папка может быстро разрастись до огромных размеров, из-за чего в ней будет трудно ориентироваться. Нередко в проекте на Node используются сотни сторонних пакетов, за которыми нужно следить. Поскольку пакеты регулярно обновляются или забрасываются, требуется постоянный контроль: обновление зависимостей, устранение конфликтов версий, замена устаревших решений и защита проекта от потенциальных уязвимостей.
46  Глава 1. Что такое Node? Безопасность в широком смысле — это один из самых весомых аргументов против использования Node. Скрипты Node получают неограниченный доступ к файловой системе, сети и другим системным ресурсам. Это может быть опасно при запуске стороннего кода, поскольку правами могут воспользоваться вредоносные скрипты. В Node внедряется новая модель управления правами, которая позволяет ограничивать доступ к определенным ресурсам во время выполнения кода. Можно запретить процессу Node доступ к файловой системе, созданию новых процессов, использованию воркеров, нативных модулей и WebAssembly. Однако по умолчанию эти ограничения не активированы. Еще одно ограничение Node — отсутствие встроенных инструментов для проверки типов, линтинга и форматирования кода. Чтобы добавить эти функции, разработчикам приходится использовать сторонние пакеты. И хотя есть отличные решения, их настройка занимает время и требует дополнительных шагов перед написанием кода. Также Node оптимизирован для операций ввода-вывода и высокоуровневых задач, но может быть не лучшим выбором для задач, требующих высокой нагрузки на процессор, например для обработки изображений и видео. Поскольку Node однопоточный и использует только одно ядро процессора зараз, ресурсоемкие задачи могут снизить производительность. JavaScript — не лучший язык для высокопроизводительных вычислений, так как он уступает по скорости таким языкам, как C++ или Rust. И наконец, у JavaScript — языка программирования, который используется в Node, — есть еще один важный недостаток. Это язык с динамической типизацией: типы объектов не задаются на этапе компиляции явно и могут изменяться во время выполнения. Такое допустимо в небольших проектах, но в крупных отсутствие строгой типизации может привести к ошибкам, которые трудно обнаружить и отладить, а также это усложняет сопровождение и понимание кода. Выводы Node — это мощный фреймворк для создания бэкенд-сервисов. Являясь оберткой JavaScript движка V8, он позволяет разработчикам без труда запускать код, написанный на JavaScript. Архитектура Node основана на простой событийноориентированной неблокирующей модели, чтобы создавать эффективные и масштабируемые приложения. В Node асинхронные операции обрабатываются с помощью коллбэков или промисов. Это простые реализации обработки одноразовых событий одной функцией. Промисы предпочтительнее коллбэков, так как предлагают более читаемый синтаксис и позволяют лучше управлять структурой кода.
Выводы  47 Встроенные модули Node предоставляют низкоуровневую базу, на которой разработчики могут создавать приложения, не начиная с нуля. Модульная система Node позволяет структурировать код в переиспользуемые модули, которые можно подключать и использовать в других частях приложения. У Node большое и активное сообщество, и его члены создали множество популярных пакетов, которые можно легко интегрировать в проекты на Node с помощью npm-репозитория. В следующей главе мы рассмотрим режимы CLI и REPL и узнаем, как Node загружает и выполняет модули.
ГЛАВА 2 Скрипты и модули В главе 1 мы использовали команду node, чтобы ознакомиться с режимом REPL и простыми скриптами. В этой главе мы узнаем, как Node загружает и выполняет скрипты и модули. Сперва мы рассмотрим дополнительные опции, аргументы и переменные окружения, которые можно использовать с командой node, а также узнаем больше о возможностях режима REPL. Затем мы разберем этапы загрузки и выполнения модуля в Node. Интерфейс командной строки Node Есть множество опций, которые позволяют настроить поведение команды node. Кроме того, она поддерживает аргументы и переменные окружения, которые можно использовать для передачи данных из операционной системы в среду выполнения Node. Рассмотрим теорию на практике. Откройте терминал и напечатайте: $ node -h | less На экране появится справка по команде (постранично, так как мы передали вывод в команду less). Я рекомендую всегда перечитывать справку по командам, которые вы часто используете: Использование: node [options] [ script.js ] [arguments] node inspect [options] [ script.js | host:port ] [arguments] Опции: - Скрипт читается из stdin (по умолчанию, если имя файла не указано; в случае tty включается интерактивный режим). -- Означает конец списка опций Node. --abort-on-uncaughtexception При аварийном завершении работы создает core-дамп, который можно использовать для анализа. --build-snapshot Генерирует BLOB-снапшот при завершении процесса. В настоящее время поддерживается только в двоичном файле node_mksnapshot. -c, --check Проверяет синтаксис скрипта без его выполнения. --completion-bash Выводит скрипт автодополнения для bash, который можно подключить через source.
Интерфейс командной строки Node  49 -C, --conditions=... Задает дополнительные условия пользователя для условных экспортов и импортов. --cpu-prof Запускает профилировщик ЦПУ движка V8 при старте процесса. : В первых двух строках описано, как использовать команду node. То, что указано в квадратных скобках, опционально, а это значит, что, согласно первой строчке, команду node можно вызывать без опций, скриптов и аргументов. Именно так мы запускали REPL-сессию. Чтобы выполнить скрипт, нужен синтаксис node script. js (вместо script можно подставить любое имя). Вторая строка описывает запуск отладочной сессии Node в терминале. Иногда это и правда бывает полезно, но в главе 4 я покажу гораздо более удобный способ отладки кода в Node. На странице справки сразу после строк с примерами использования приводится список всех опций, которые можно применять с командой node. Большинство из них относятся к расширенным возможностям, но полезно хотя бы знать об их существовании. Рекомендую просмотреть этот список, чтобы составить общее представление о том, какие задачи можно решать с помощью команды node. На некоторые функции следует обратить особое внимание. Опции и аргументы Опция --check (или -c) позволяет проверить синтаксис скрипта без его выполнения. Опцию можно использовать, например, чтобы автоматизировать проверку синтаксиса перед отправкой кода коллегам. Опции --eval и --print (или -e и -p) позволяют запускать код, написанный на JavaScript, прямо из командной строки. Лично мне больше нравится опция -p: она печатает результат сразу после выполнения кода аналогично режиму REPL. Чтобы использовать эти опции, передайте им строку с JavaScript-кодом, например: $ node -p "Math.random()" Это удобно, поскольку позволяет создавать собственные мощные команды и при желании задавать им псевдонимы. Например, если вам нужна команда для формирования уникальной случайной строки (в случае если вы хотите, скажем, сгенерировать пароль), вы можете использовать встроенный модуль Node crypto в однострочной команде -p: $ node -p "crypto.randomBytes(16).toString('hex')" Здорово, да?
50  Глава 2. Скрипты и модули Обратите внимание, что модуль crypto доступен в опции -p без необходимости явно его подключать — как и в режиме REPL1. А если нужна команда для подсчета слов в любом файле? $ node -p "fs.readFileSync(process.argv[1]) .toString().split(/\s+/).length" ~/.bashrc Главное — не паникуйте. После ввода этой команды произойдет много всего. Она использует возможности и Node, и JavaScript. Запустите ее и посмотрите, что будет. ~/.bashrc можно заменить на путь к любому файлу в вашей системе. А однострочник, приведенный выше, давайте разберем подробнее. Функция readFileSync входит в состав встроенного модуля node:fs. Она принимает путь к файлу в качестве аргумента и синхронно возвращает двоичное представление содержимого файла. Вот почему я добавил .toString — чтобы получить строковое содержимое файла в кодировке UTF-8. Вместо того чтобы жестко прописывать путь, я указал его первым аргументом к команде node и использовал process. argv[1], чтобы передать его как параметр в функцию redFileSync (подробнее будет показано далее). Это позволяет применять однострочную команду подсчета слов с любым файлом. Мы можем задать ей псевдоним и вызывать с указанием пути к файлу, как показано на рис. 2.1: $ alias count-words="node -p 'fs.readFileSync(process.argv[1]) .toString().split(/\s+/).length'" Рис. 2.1. Однострочная печать с псевдонимом 1 node -p использует тот же механизм глобальной доступности встроенных модулей, что и REPL, но это не полноценный REPL, потому что в REPL мы можем интерактивно взаимодействовать с результатом выполнения команды, а в -p получаем однократный запуск выражения и вывод его значения. — Примеч. науч. ред.
Интерфейс командной строки Node  51 Получив содержимое файла, я использую метод split и регулярное выражение /\s+/, которое разбивает текст по пробелам (одному или нескольким). Это создает массив слов, и с помощью .length мы получаем количество элементов в массиве, то есть количество слов. Массив process.argv Из синтаксиса использования мы знаем, что команда node принимает аргументы. Это может быть любой набор строк, который становится доступным для выполнения процесса Node. Однострочнику для подсчета слов нужен process.argv[1]. Объект process — это глобальный объект, представляющий собой интерфейс Node к процессу операционной системы, который выполняет команду node. Свойство argv — это массив, и он содержит все аргументы, переданные в команду node, независимо от способа ее использования. Для наглядности запустите следующую команду: $ node -p "process.argv" hello world Она выведет весь массив аргументов. Первый аргумент этого массива Node использует для указания пути к самой команде node , а затем перечисляет остальные. Поэтому в однострочной команде для подсчета слов нужен второй элемент argv. Обратите внимание: если вы запускаете скрипт, то путь к нему будет вторым элементом process.argv, а все последующие аргументы — третьим и т. д.1 Опция --require (или -r) позволяет подключить модуль до выполнения основного скрипта. Это удобно, если перед запуском скрипта вы хотите загрузить нужный модуль, настроить конфигурацию или установить значения необходимых переменных. Эта опция работает только с модулями CommonJS. Для ES-модулей используйте опцию --import. Например, у вас есть проект на Node, в котором за загрузку переменных окружения из файла отвечает модуль dotenv. В обычной ситуации, чтобы использовать этот модуль, в начало основного файла нужно добавить require('dotenv').config(). Однако с помощью опции -r вы можете автоматически загрузить модуль без необходимости прописывать его в файле: $ node -r dotenv/config index.js 1 Необходимо помнить, что элементы массива нумеруются, начиная с нуля. — Примеч. науч. ред.
52  Глава 2. Скрипты и модули Node также поддерживает загрузку переменных окружения напрямую из файла через опцию --env-file. Чуть ниже мы рассмотрим этот случай подробнее. Опция --watch позволяет отслеживать изменения в файле и его зависимостях. Она перезапустит Node, если обнаружит их. Особенно полезна эта функция в разработке. Ее можно протестировать с любым файлом, примеры которых мы разбирали в этой книге. Например, чтобы запустить простейший веб-сервер из главы 1 в режиме отслеживания, введите следующую команду: $ node --watch index.js Сервер начнет работать в режиме отслеживания. А если вы внесете любые изменения в файл server.js (например, в строке «Hello World»), Node автоматически перезапустится. Опция --test заставляет Node искать и выполнять код, написанный для тестирования. Для этого Node использует простые правила именования. Например, она будет искать файлы, имя которых оканчивается на .test.js или начинается с test-. Я перечислил здесь далеко не все опции, большинство из них предназначены для продвинутых пользователей. Но знать об их существовании все же стоит — возможно, это сильно облегчит вам жизнь в будущем. Так как Node — обертка над движком V8, а V8 поддерживает собственные опции интерфейса командной строки, команда node принимает многие из них. Список всех доступных опций V8 можно вывести следующей командой: $ node --v8-options | less Вы можете включать и выключать экспериментальные функции JavaScript с помощью harmony flags1, устанавливать флаги трассировки, настраивать управление памятью движка и активировать или деактивировать множество других возможностей. Переменные окружения Ближе к концу вывода команды node -h можно увидеть список переменных окружения, таких как NODE_DEBUG, NODE_PATH и многих других. Переменные окружения — это еще один способ настроить поведение Node или сделать пользовательские данные доступными процессу Node (аналогично аргументам командной строки). 1 harmony flags — это специальные флаги запуска движка, которые позволяют включать еще не стабилизированные или экспериментальные возможности JavaScript. Исторически они назывались harmony (в честь проекта ECMAScript Harmony, предшественника ES6), но сейчас туда попадают любые экспериментальные функции. — Примеч. науч. ред.
Интерфейс командной строки Node  53 После каждого запуска команды node создается процесс операционной системы. В Linux просмотреть все запущенные процессы можно с помощью команды ps. Если вы выполните команду ps, пока работает процесс Node (например, наш простейший веб-сервер), в списке будет отображаться Node и вы сможете увидеть его идентификатор процесса и при необходимости завершить его через терминал. Вот пример команды, которая выведет все данные о процессах и отфильтрует те, что содержат слово node: $ ps -ef | grep "node" Среду Node соединяет с операционной системой объект process. С его помощью можно обмениваться информацией между Node и ОС. Например, когда вы используете console.log, на самом деле под капотом — объект process, так строка будет записана в поток стандартного вывода ОС (stdout). Переменные окружения — это способ передать информацию из окружения ОС (в котором выполняется команда node) в среду Node. Мы можем прочитать значения таких переменных через свойство env объекта process, например вот так: $ NAME="Reader" node -p "'Hello ' + process.env.NAME" Этот код выведет сообщение Hello Reader, как показано на рис. 2.2. Здесь задается переменная окружения NAME, а затем ее значение читается с помощью process. env.NAME. Вы можете задавать сразу несколько переменных окружения — либо напрямую из командной строки, как в этом примере, либо с помощью команды Linux export перед выполнением команды node: $ export GREETING="Hello"; export NAME="Reader"; \ node -p "process.env.GREETING + ' ' + process.env.NAME" В Linux и macOS для выполнения нескольких команд в одной строке можно ввести точку с запятой, а для переноса длинной команды на следующую строку используется \. Рис. 2.2. Переменные окружения в Node
54  Глава 2. Скрипты и модули С помощью переменных окружения вы можете настраивать свой код под разные машины и среды. Например, номер порта в нашем простейшем веб-сервере из главы 1 был задан прямо в коде и был равен 3000. Однако на другой машине порт 3000 может быть недоступен, или сервер потребуется запускать на другом порту в продуктовом окружении. Чтобы это сделать, в код можно вписать process.env. PORT ?? 3000 вместо 3000 (это надо делать в методе listen). После этого нужно запустить команду node с кастомным кодом: $ PORT=4000 node index.js Обратите внимание, что, если не указать значение порта, оно будет по умолчанию составлять 3000, потому что я использовал оператор ?? (оператор нулевого слияния), который задает значение по умолчанию, если process.env.PORT не определен. Это распространенная практика. В Node нельзя использовать объект process.env для изменения переменных окружения операционной системы. Это просто копия всех переменных окружения, доступных текущему процессу. Список переменных окружения, приведенный в конце вывода команды node -h, включает встроенные переменные окружения Node. Если они заданы, Node будет их использовать. Вот некоторые примеры таких переменных. NODE_PATH Нужен в том числе для упрощения импортов, позволяя использовать абсолютные пути вместо относительных. NODE_OPTIONS Альтернативный способ указывать параметры запуска Node, вместо того чтобы каждый раз передавать их через командную строку. NODE_DEBUG Включает вывод отладочной информации для указанных в переменной библио­ тек, заданных в виде списка, разделенного запятыми. Например, после ввода команды NODE_DEBUG=fs,http Node покажет отладочные сообщения при работе с модулями node:fs или node:http. Эта переменная поддерживается многими сторонними пакетами. Пример ее использования с модулем node:http вы можете посмотреть на рис. 2.3. Все необходимые переменные окружения можно также поместить в файл (например, файл .env), а затем попросить Node брать значения из этого файла в объекте process.env, используя опцию --env-file команды node. Допустим, у вас есть следующий .env-файл: PORT=3000 NODE_DEBUG=fs,http
Интерфейс командной строки Node  55 Скрипт Node с этими переменными окружения можно запустить, выполнив команду: $ node --env-file=.env script.js Рис. 2.3. Использование NODE_DEBUG с модулем http При необходимости можно использовать несколько файлов окружения. Режим REPL В главе 1 мы узнали, что в режиме REPL можно вводить любой код на JavaScript, а Node выполнит его и сразу покажет результат. Это очень удобно, когда нужно быстро протестировать короткие выражения, но метод подходит и для больших фрагментов кода.
56  Глава 2. Скрипты и модули Обычно в REPL вы вводите выражение (например, 0.1 + 0.2), затем нажимаете Enter, и на экране появляется результат. Написать можно также конструкции, которые не являются выражениями, например let v = 21;. При этом будет определена переменная v, а на экране появится undefined, так как команда присвоения не возвращает никакого значения. Чтобы очистить экран, нажмите Ctrl+L. Если вы начнете писать функцию и нажмете Enter в конце первой строки, REPL распознает, что ввод не завершен, и переключится в многострочный режим, как показано на рис. 2.4. Попробуйте набрать простую функцию, чтобы убедиться в этом. Рис. 2.4. Многострочный режим REPL в Node У многострочного режима в REPL ограниченный функционал, но в нем также есть встроенный редактор. Чтобы открыть его, введите во время REPL-сессии команду .editor. Теперь вы сможете писать неограниченное количество строк, определять, сколько функций использовано в коде, и вставлять код из буфера обмена. Закончив работу над кодом, нажмите Ctrl+D. Команда .editor — одна из многих встроенных REPL-команд. Чтобы увидеть полный список, наберите .help: > .help .break .clear .editor .exit .help .load .save Эта команда выручит вас, если REPL зависнет Псевдоним для .break Войти в режим редактора Выйти из REPL Показать справку Загрузить JS из файла в REPL-сессию Сохранить все команды текущей сессии в файл Чтобы прервать выполнение текущего выражения, нажмите Ctrl+C. Чтобы выйти из REPL, нажмите Ctrl+D.
Интерфейс командной строки Node  57 Команда .break помогает, если REPL завис. Например, если вы вставили в многострочный режим фрагмент кода, но не знаете, сколько нужно закрывающих фигурных скобок, команда .break (или сочетание клавиш Ctrl+C) отменяет текущий ввод. Это позволяет не заканчивать всю REPL-сессию из-за одной ошибки. Завершить REPL-сессию можно с помощью команды .exit или сочетания клавиш Ctrl+D. Команда .save сохраняет весь код, который вы набрали в текущей REPL-сессии, в файл. Команда .load позволяет загрузить JavaScript-код из файла и сделать его доступным в текущей REPL-сессии. Обе команды принимают в качестве аргумента имя файла. В REPL мне нравится возможность просматривать почти все, что доступно в Node, без необходимости использовать require. Все встроенные модули (fs, http и т. д.) уже загружены в REPL. Изучить их API можно, нажав клавишу Tab. В REPL Tab работает по такому же принципу, что в терминале или редакторе: программа попытается применить автодополнение и продолжить то, что вы начали писать. Введите cr, нажмите Tab — и в строке ввода появится crypto. Двойное нажатие Tab показывает список всех возможных вариантов автозаполнения. Например, если ввести a и дважды нажать Tab, на экране появятся все глобальные объекты, начинающиеся с a (рис. 2.5). Рис. 2.5. Автодополнение в REPL Такое поведение не только облегчает работу, но и позволяет меньше печатать и не делать опечатки. Но это еще не все. С помощью Tab можно изучать методы и свойства любого объекта. Введите Array, дважды нажмите на Tab — и на экране появятся все методы и свойства класса Array в JavaScript. Более того, эта функция работает и для модулей Node (можете попробовать с fs или http).
58  Глава 2. Скрипты и модули Автодополнение классов и методов работает даже для объектов, которые вы создали сами. Подготовьте пустой массив с помощью let myArr = [];, затем введите myArr и дважды нажмите Tab — и вы увидите все методы, доступные экземпляру массива ( рис. 2.6). Рис. 2.6. Изучение методов с помощью функции автодополнения Функция автодополнения через двойное нажатие Tab работает и глобально. Дважды нажмите Tab на пустой строке, и на экране появится список всех глобально доступных элементов, как показано на рис. 2.7. Список длинный, но очень полезный. В нем есть все глобальные сущности самого языка JavaScript (Array, Number, Math и т. д.), все глобальные объекты Node (process, setTimeout и т. п.), а также все встроенные модули Node (fs, http и другие). Обратите внимание: в этом списке есть символ подчеркивания (_). В REPL это специальная переменная, которая помнит результат последнего выполненного выражения. Например, после Math.random() вы можете ввести _ и получить то же самое случайное значение. Этот символ можно использовать в любом выражении JavaScript. Попробуйте: let random =_;.
Интерфейс командной строки Node  59 Рис. 2.7. Двойное нажатие Tab на пустой строке Модуль node:repl можно использовать, чтобы создать собственный REPL-сервер. Такие параметры нового сервера, как приглашение командной строки (prompt), потоки ввода/вывода, цвета и другие опции, можно настроить под себя. Также в контекст REPL можно добавить необходимые глобальные объекты. Вот пример кастомного REPL-сервера, который запускается со своим приглашением prompt в строгом режиме (in strict mode) и не выводит undefined, если результат выражения отсутствует. Также в глобальном контексте доступна библиотека lodash (рис. 2.8): import { start, REPL_MODE_STRICT } from 'repl'; import lodash from 'lodash'; const replServer = start({ prompt: '... ', ignoreUndefined: true, replMode: REPL_MODE_STRICT, }); replServer.context.lodash = lodash;
60  Глава 2. Скрипты и модули Рис. 2.8. Использование кастомного REPL-сервера Модули Node Модуль — это переиспользуемый блок кода, который можно подключать к любому приложению и применять столько раз, сколько нужно. Скрипт — это файл, который запускается один раз командой node. Все остальные файлы и папки, которые подключаются через require или import, называются модулями. Когда модуль указывается как зависимость, его загрузка в Node проходит в несколько этапов. 1. Определение пути к модулю. 2. Чтение его содержимого. 3. Изоляция пути видимости. 4. Выполнение кода модуля. 5. Кэширование результата.
Модули Node  61 Определение пути к модулю При поиске импортируемого модуля Node прибегает к следующему алгоритму: если имя модуля не начинается с . (относительный путь) или / (абсолютный путь), Node проверяет, встроенный он или нет. Если модуль встроенный, Node загружает и исполняет его сразу. Если модуль не встроенный, он ищет его в папках node_modules, начиная с директории, в которой находится файл, выполняющий импорт, и поднимается вверх по иерархии директорий. Например, если модуль находится в /User/samer/efficientnode/src, сначала Node будет искать его в src/node_modules, затем в efficientnode/node_modules и так далее, пока не дойдет до корня файловой системы. С помощью этого механизма можно локализовать зависимости в отдельных node_ modules, но это усложняет структуру проекта. В папку node_modules также можно сохранять файлы нескольких проектов. Для этого надо разместить ее в общей родительской папке. Однако обычно для каждого проекта создается своя папка node_modules, и я рекомендую придерживаться этой практики во время работы. Если модуль начинается с . или /, Node будет искать его по указанному относительному или абсолютному пути. Перед запуском скрипта для CommonJS-модулей можно установить переменную окружения NODE_PATH. В таком случае Node будет искать модули сначала в путях, указанных в NODE_PATH. Можно указать один такой путь или несколько — для этого разделите их двоеточием или запятой. Это удобно, если вы хотите использовать короткие абсолютные пути вместо запутанных относительных. Если вы хотите проверить существование модуля, но не исполнять его, используйте require.resolve() (для CommonJS) или import.meta.resolve() (для ESмодулей). Эти функции не загружают модуль, а проверяют его наличие и выводят ошибку, если его нет. Загрузка модулей Определив путь к модулю, Node читает его содержимое и распознает тип модуля. Модули бывают двух типов: CommonJS и ES. Поддерживаются расширения файлов .js, .cjs и .mjs. Это может быть как отдельный файл, так и папка с файлом package.json, который указывает, какие файлы в каталоге можно импортировать. Также модуль может быть JSON-файлом (с расширением .json). При импорте JSON-файла вы получаете JavaScript-объект, представляющий данные из этого файла: // В модулях CommonJS: const data = require('./file.json');
62  Глава 2. Скрипты и модули // В ES-модулях со статическим импортом: import data from './file.json' with { type : 'json' }; // В ES-модулях с динамическим импортом: const { default: data } = await import('./file.json', { with: { type: 'json' }, }); Ключевое слово with в примере используется для указания атрибута типа импорта. Свойство import attributes дает рантайму инструкции о том, как загружать модуль. Это стандарт безопасности, который помогает предотвратить выполнение вредоносного кода. Он может использоваться и с другими типами модулей, например с CSS-модулями в браузере. Также модуль может быть скомпилированным аддоном. Аддоны (add-on) — это динамически подключаемые объекты, написанные на низкоуровневых языках (например, C или C++1) и скомпилированные для загрузки как обычные модули Node. В Node есть API, известный как Node-API, который предназначен для нативных расширений. Он работает независимо от JavaScript-движка. Если вам нужен высокопроизводительный модуль или доступ к системным ресурсам (или интеграция с C/C++ библиотеками), вы можете использовать Node-API, чтобы создать расширение и загрузить его как встроенный модуль. При импорте через ES-модули такие расширения не поддерживаются, но их можно загрузить с помощью функции module.createRequire(). Область видимости модулей В JavaScript функции можно вызывать с любым количеством аргументов. Для доступа к списку всех переданных аргументов введите ключевое слово arguments (рис. 2.9). Если вам нужна функция с переменным числом аргументов, лучше использовать явные остаточные параметры (rest parameters), а не неявный объект arguments. Node оборачивает все CommonJS-модули в функцию, предоставляя им изолированную (приватную) область видимости. Эта «оберточная» функция вызывается с пятью неявными аргументами. Чтобы в этом убедиться, выведите в верхнем уровне CommonJS-модуля значение arguments (рис. 2.10). 1 Согласно официальному делению, указанные языки также относятся к языкам высокого уровня. Языки низкого уровня — языки, специфичные для аппаратного обеспечения, например ассемблеры. — Примеч. науч. ред.
Модули Node  63 Рис. 2.9. Неявный объект arguments Рис. 2.10. «Оборачивание» CommonJS-модуля
64  Глава 2. Скрипты и модули В число этих неявных аргументов входят exports, require, module, __filename и __dirname. Когда вы используете их внутри CommonJS-модуля, вы не обращаетесь к глобальным переменным — это параметры оберточной функции. Аргументы exports, require и module — это механизм Node для управления API модуля и его зависимостями. __filename содержит полный путь к файлу модуля. __dirname — путь к директории, где расположен этот файл. Аналогично обертке CommonJS, ES-модули исполняются в неявной области видимости, но без функции-обертки, и пять неявных аргументов не определены. Вместо этого API ES-модуля и его зависимости управляются с помощью операторов import и export. Чтобы получить имя файла или имя директории ES-модуля, используйте import. meta.filename и import.meta.dirname. Благодаря области видимости модулей все переменные, которые вы определяете внутри модуля, оказываются локальными. Если вы хотите создать глобальную переменную, используйте globalThis. Любое свойство, которое вы добавите к этому объекту, станет глобальной переменной. Но несмотря на то, что когда-нибудь это знание может оказаться вам полезным, я не рекомендую использовать глобальные переменные, так как они могут стать причиной большого количества проблем. Выполнение модулей На этом этапе Node исполняет код модуля и завершает связывание зависимостей и экспортов. Один из распространенных приемов в разработке — выносить конфигурационные переменные (например, PORT и HOST для запуска веб-сервера) в отдельный модуль. Создадим для этих переменных файл config.cjs. Расширение .cjs указывает, что это CommonJS-модуль и он будет обернут в функцию для создания своей области видимости. Такой модуль получает пять неявных аргументов. Под синтаксисом CommonJS-модулей я буду также приводить синтаксис ESмодулей. Если вы захотите проработать примеры с ES-модулями, используйте файлы с расширением .mjs. На рис. 2.11 видно, что изначально аргумент exports является пустым объектом. Чтобы определить API модуля config.cjs , достаточно задать свойства объекта exports. Ими могут быть статические значения или объекты любого типа в JavaScript (например, функция, класс или промис). console.log('Loading config.cjs'); exports.PORT = process.env.PORT ?? 3000;
Модули Node  65 exports.HOST = process.env.HOST ?? 'localhost'; exports.SERVER_URL = ( protocol = process.env.PROTOCOL ?? 'http', ) => `${protocol}://${exports.host}:${exports.PORT}`; // ES-модуль export const PORT = process.env.PORT ?? 3000; export const HOST = process.env.HOST ?? 'localhost'; export const SERVER_URL = ( protocol = process.env.PROTOCOL ?? 'http', ) => `${protocol}://${exports.host}:${exports.PORT}`; Рис. 2.11. Аргумент exports Обратите внимание: чтобы сделать конфигурацию гибкой для разных окружений, я использовал переменные process.env, а SERVER_URL реализовал как функцию с аргументом protocol, что позволяет задавать значение во время выполнения. Когда мы подключаем config.cjs в другом модуле, вызов require возвращает объект exports. Пример использования в файле index.cjs: const config = require('./config.cjs'); console.log(config); // Или через деструктуризацию: // const { PORT, HOST } = require('./config.cjs'); // ES-модуль import * as config from './config.mjs'; console.log(config); // Или импортировать конкретные значения // import { PORT, HOST } from './config.cjs';
66  Глава 2. Скрипты и модули Вывод показан на рис. 2.12. Рис. 2.12. Использование CommonJS-модуля через require Таким образом, модуль index.cjs зависит от config.cjs. В этом и заключается суть управления зависимостями — мы подключаем API одного модуля и используем его в другом. Аргумент exports в модулях CommonJS на самом деле является псевдонимом для module.exports. Именно module.exports возвращается, когда мы вызываем функцию require. В некоторых случаях вам может понадобиться, чтобы объект верхнего уровня API был не просто объектом, а, например, функцией или классом. Тогда необходимо переопределить значение module.exports, чтобы задать специальный API. Например, мы хотим, чтобы все конфигурационные значения возвращались из функции. Это очень удобно при тестировании, так как можно подменять реализацию конфигурационной функции для разных тестов. Поэтому в подавляющем большинстве проектов по умолчанию используют module.exports. Для config. cjs это будет выглядеть так: module.exports = () => { return { PORT: process.env.PORT ?? 3000, HOST: process.env.HOST ?? 'localhost', SERVER_URL: (protocol = process.env.PROTOCOL ?? 'http') => `${protocol}://${exports.host}:${exports.PORT}`, }; }; // ES-модуль export default () => { return {
Модули Node  67 }; }; PORT: process.env.PORT ?? 3000, HOST: process.env.HOST ?? 'localhost', SERVER_URL: (protocol = process.env.PROTOCOL ?? 'http') => `${protocol}://${exports.host}:${exports.PORT}`, Чтобы использовать такую конфигурацию в index.cjs, нам нужно вызвать то, что возвращает функция require: const config = require('./config.cjs'); console.log( config(), // Обратите внимание: мы вызываем функцию ); // ES-модуль import config from './config.mjs'; console.log( config(), // Обратите внимание: мы вызываем функцию ); Такой подход часто используется в шаблоне проектирования «внедрение зависимостей», когда одни модули «внедряются» в другие, чтобы код был более гибким и подходил для разных задач. Если вы хотите, чтобы модуль Node можно было запускать как скрипт из командной строки, используйте свойство require.main, чтобы проверить, запускается ли модуль напрямую. Если require.main равен module, значит, файл выполняется сам по себе. На рис. 2.13 показан пример простого модуля, который с помощью этой последовательности определяет порядок работы. В ES-модулях аналогичной проверки нет. Вместо нее можно использовать import.meta.url совместно с process.argv. В npm это поведение хорошо реализовано в пакете es-main. Кэширование модулей Чтобы понять еще один важный нюанс работы модулей Node, предлагаю несколько раз повторить строку require в файле index.cjs. require('./config.cjs'); require('./config.cjs'); require('./config.cjs'); А теперь ответьте, сколько раз при выполнении файла index.cjs будет выведена строка Loading config.cjs из config.cjs?
68  Глава 2. Скрипты и модули Рис. 2.13. Проверка через require.main Сообщение показано не три раза, а только один, как видно на рис. 2.14. Рис. 2.14. Кэширование модуля в Node ES- и CommonJS-модули кэшируются после первого импорта. То есть модуль выполняется один раз, а когда вы повторяете это в дальнейшем, require или import подгружаются из кэша.
Выводы  69 Например, во фронтенд-приложении React почти каждый компонент импортирует модуль React — так и должно быть, потому что выполняется только первый импорт, а остальные используют кэш. Но если мы все же хотим, чтобы console.log срабатывал при каждом импорте config.cjs? Можно сделать экспортируемым элементом не объект, а функцию, поместить весь код внутрь нее и каждый раз выполнять ее вручную. В таком случае кэш будет хранить определение функции, а не результат ее выполнения. Выводы В CLI-интерфейсе в Node есть множество мощных опций, которые мы можем контролировать. Аргументы и переменные окружения можно передавать перед запуском. Эти данные переходят из операционной системы в Node через объект process. С помощью режима REPL очень удобно тестировать выражения, изучать API и встроенные модули, сторонние пакеты и даже собственные объекты. Модули CommonJS в Node неявно оборачиваются в функцию и получают пять аргументов. Модули ES также имеют изолированную область видимости, но без обертки. Чтобы описать API модуля, в CommonJS есть объект exports, а в ES-модулях — export-инструкции. Если модуль зависит от других модулей, для доступа к их API используется require или import. Node управляет кэшем модулей. Чтобы определить, где искать модуль, Node следует набору правил: если это относительный или абсолютный путь — ищется по нему; если это просто имя — Node ищет в папках node_modules. В следующей главе мы изучим, как Node работает с асинхронными операциями, и изучим событийно-ориентированную архитектуру.
ГЛАВА 3 Асинхронность и события Мы уже знаем, что самая важная концепция в Node — событийно-ориентированная модель, также известная как неблокирующая модель. Node связывает асинхронные операции с событиями и внутренне запускает их независимо от других операций. Когда асинхронная операция завершается, Node планирует выполнение зависимого от нее кода. В этой главе мы подробно разберем события и их функции-обработчики: коллбэки, промисы и слушатели. Мы узнаем, как Node управляет асинхронными операциями с помощью цикла и очередей событий, а также рассмотрим примеры создания и применения пользовательских эмиттеров событий и функций-слушателей. Синхронная и асинхронная обработка Веб-браузер — это однопоточная среда, где весь код, связанный с пользователем, обрабатывается в одном основном потоке. Если на веб-странице есть тяжелый элемент, во время его выполнения пользователи не смогут даже прокручивать страницу, потому что для этого также используется единственный занятый поток. В Node такой же принцип выполнения кода. При этом Node может работать с другими потоками «за кулисами» и при необходимости позволяет создавать потоки вручную. Однако крайне важно не блокировать основной поток. Это мы усвоили на простых примерах из главы 1. Если заблокировать главный поток, это может остановить работу всего сервера. Давайте изучим это на практике. Если в Node выполняется тяжелая синхронная операция, при входящем HTTPзапросе она заблокирует единственный поток и сервер не сможет обрабатывать другие запросы, пока эта операция не завершится. Для наглядности давайте сымитируем тяжелую операцию внутри простого HTTPсервера из главы 1 с помощью длинного цикла for. Запустите тяжелую операцию только при первом запросе. Все остальные обработаем как обычно. Это можно сделать с помощью простого счетчика: import http from 'node:http'; const slowOperation = () => { for (let i = 0; i < 1e9; i++) {
Синхронная и асинхронная обработка  71 } // Имитация синхронной задержки }; let counter = 0; const server = http.createServer((req, res) => { counter = counter + 1; if (counter === 1) { slowOperation(); res.end('Slow Response'); } else { res.end('Normal Response'); } }); server.listen(3000, () => { console.log('Server is running...'); }); Обратите внимание: при первом запросе значение счетчика равно 1, и поэтому ответ будет выведен с задержкой. Все остальные запросы, которые не используют эту тяжелую функцию, по идее, должны обрабатываться быстро. Однако из-за того, что медленная операция синхронная, тормозить будет не только первый запрос, но и все последующие, потому что они будут ждать его завершения. Чтобы проверить это, запустите пример и несколько раз откройте http://localhost:3000. Вы увидите, как все запросы задерживаются из-за первого (рис. 3.1). Пока обрабатывается запрос из первого браузера, второй находится в состоянии ожидания. Очевидно, что подобных практик в работе следует избегать. Смоделировать выполнение медленной операции без блокировки основного потока можно с помощью функции setTimeout: import http from 'node:http'; const slowOperation = (cb) => { // Симуляция асинхронной задержки setTimeout( () => cb(), 15_000, // delay is in ms ); }; let counter = 0; const server = http.createServer((req, res) => { counter = counter + 1; if (counter === 1) { slowOperation(() => { res.end('Slow Response'); });
72  Глава 3. Асинхронность и события } else { res.end('Normal Response'); } }); server.listen(3000, () => { console.log('Server is running...'); }); Рис. 3.1. Синхронная тяжелая операция Суть этих примеров одна и та же: первый запрос обрабатывается медленно, потому что должен дождаться завершения тяжелой операции. Разница в том, что в этом случае задержка реализована асинхронно с помощью setTimeout, а не синхронным длинным циклом for. Если во время тестирования этого кода вы отправите несколько запросов, с задержкой будет выполнен только первый, но другие обработаются быстро, потому что не будут заблокированы первым, как показано на рис. 3.2. Обратите внимание, что во втором браузере ответ пришел сразу, в то время как первый ждал завершения тяжелой операции. Теперь сервер способен обрабатывать несколько задач параллельно, и для этого нам не пришлось использовать потоки.
Функции-обработчики  73 Рис. 3.2. Асинхронная тяжелая операция Иногда тяжелую операцию нельзя сделать асинхронной с помощью стандартных API. Что делать в таких ситуациях, при этом не блокируя главный поток, мы разберем в главе 7. Функции-обработчики Рассмотрим типичный пример асинхронной функции, которая работает так же, как коллбэки. // Коллбэк-стиль (стиль обратных вызовов) const readFileAsArray = function(file, cb) { fs.readFile(file, function(err, data) { if (err) { return cb(err); } const lines = data.toString().trim().split('\n'); cb(null, lines); }); };
74  Глава 3. Асинхронность и события Функция readFileAsArray принимает как аргументы путь к файлу и коллбэк. Она считывает содержимое файла, разбивает его на массив строк и вызывает функцию-обработчик, передавая этот массив строк в коллбэк в качестве аргумента. Если при чтении файла возникает ошибка, коллбэк вызывается с этой ошибкой в первом аргументе. Функция readFileAsArray является асинхронной, потому что использует асинхронный метод fs.readFile. Функция становится асинхронной, если использует асинхронные API или возвращает промис. Также вы можете сделать функцию асинхронной с помощью ключевого слова async. Предположим, у нас есть файл numbers.txt вот с таким содержимым: 10 11 12 13 14 15 Если нам нужно посчитать количество нечетных чисел в файле, это можно сделать с помощью readFileAsArray: readFileAsArray('./numbers.txt', (err, lines) => { if (err) throw err; const numbers = lines.map(Number); const oddNumbers = numbers.filter(n => n%2 === 1); console.log('Odd numbers count:', oddNumbers.length); }); Функция readFileAsArray создает из содержимого файла массив строк. В случае с файлом numbers.txt это будет последовательность чисел. Коллбэк в этом примере преобразует строки в числа и посчитает, сколько в файле нечетных чисел. Здесь применяется коллбэк-стиль, принятый в Node. По стандарту Node коллбэки передаются как последний аргумент асинхронной функции. В качестве первого аргумента принято использовать переменную ошибки (в этом примере err). Такой подход называют коллбэк-стилем error-first (с первым аргументом-ошибкой). Таким образом обеспечиваются читаемость и надежная обработка ошибок. Помимо единообразия, коллбэк-стиль с первым аргументом ошибки существует ради читабельности и более эффективной обработки ошибок. Если вы работаете с коллбэками в стиле Node, всегда оформляйте функции в соответствии с этими соглашениями. Если в асинхронной функции произойдет ошибка, она вызовет коллбэк, передав ошибку в аргументе err. Если ошибки нет, значение err будет null. Любой коллбэк в этом стиле должен проверить, есть ли ошибки в err, прежде чем обращаться
Функции-обработчики  75 к другим данным (например, lines, как в нашем примере). Если не проверять err, ошибки будут просто проигнорированы, а это очень плохо. С коллбэками связано несколько проблем, и самая популярная из них — так называемая пирамида ужаса (ее еще называют «коллбэк-хелл»). Она возникает, когда нужно выполнить несколько асинхронных операций, зависящих друг от друга. В таком случае приходится вкладывать одни коллбэки в другие, и в итоге получается пирамидообразный код. Это сильно усложняет понимание логики программы. Есть еще один важный недостаток коллбэков: они берут на себя контроль над тем, что должно происходить после завершения асинхронной операции. Это значит, что нам приходится «доверяться» их коду. Этот случай мы разберем в разделе «Аналогия для объяснения промисов» на с. 79. Синхронные коллбэки Важно понимать, что операции не становятся асинхронными только потому, что вы используете коллбэки. Функция может вызвать коллбэк и синхронно. Ниже приведен пример функции, которая может вызвать коллбэк по-разному, в зависимости от условий: // Пример функции с синхронным // и асинхронным коллбэком function fileSize (fileName, cb) { if (typeof fileName !== 'string') { return cb(new TypeError('argument should be string')); // Синхронный } fs.stat(fileName, (err, stats) => { if (err) { return cb(err); } // Асинхронный cb(null, stats.size); // Асинхронный }); } Здесь функция cb вызывается синхронно, если fileName не является строкой, и асинхронно в остальных случаях. Подобной практики стоит избегать, так как она может привести к непредсказуемым ошибкам. Функции лучше проектировать так, чтобы они вызывали коллбэки либо всегда синхронно, либо всегда асинхронно. Например, в блоке if можно использовать setTimeout, чтобы сделать поведение fileSize всегда асинхронным. Промисы Вместо того чтобы передавать коллбэк в асинхронную функцию и обрабатывать в одном месте успешный и ошибочный сценарии, промис разделяет их обработку, а также объединяет цепочки асинхронных вызовов без вложенности.
76  Глава 3. Асинхронность и события Если функция readFileAsArray поддерживает промисы, ее можно использовать так: readFileAsArray('./numbers.txt') .then( (lines) => { const numbers = lines.map(Number); const oddNumbers = numbers.filter(n => n%2 === 1); console.log('Odd numbers count:', oddNumbers.length); } ) .catch( (err) => console.error(err) ); Мы вызываем метод .then на значении, возвращенном асинхронной функцией. Чтобы использовать такой синтаксис, функция должна возвращать объект Promise. В JavaScript можно создать объект Promise с помощью конструктора промисов. Скоро мы это разберем. В этом примере метод .then дает нам доступ к тому же массиву lines, как и в версии с коллбэком, и мы можем обработать его аналогичным образом. При работе с объектами Promise с помощью этого синтаксиса всегда нужно добавлять .catch (как нужно проверять аргумент err в коллбэках). При использовании синтаксиса async/await для обработки ошибок удобно вводить конструкцию try/catch. А вот как можно изменить функцию readFileAsArray, чтобы она поддерживала как промисы, так и коллбэки1: // Как сделать так, чтобы коллбэки поддерживали промисы const readFileAsArray = function (file, cb = () => {}) { return new Promise((resolve, reject) => { fs.readFile(file, function (err, data) { if (err) { reject(err); return cb(err); } const lines = data.toString().trim().split('\n'); resolve(lines); cb(null, lines); }); }); }; Так функция с помощью new Promise возвращает Promise-объект, который оборачивает асинхронный вызов fs.readFile. Если в коллбэк передается ошибка, то 1 Код пытается быть универсальным, но делать это за счет снижения надежности и читаемости не приветствуется. — Примеч. науч. ред.
Функции-обработчики  77 также срабатывает функция неудачного разрешения промиса reject. Аналогично, когда в коллбэк попадают данные, вызывается функция успешного разрешения промиса resolve. Чтобы это сделать, пришлось внести одно небольшое изменение: значение по умолчанию для аргумента cb. Это необходимо на случай, если используется только Promise-интерфейс, — чтобы при работе не возникало ошибок. Для этого достаточно передать в качестве аргумента пустую функцию () => {}. Таким образом, функцию readFileAsArray можно использовать как с коллбэками, так и как Promise-функцию. И хотя при использовании промисов легко создать «пирамиду ужаса», этого можно избежать, потому что Promise-объекты поддерживают объединение в цепочку. Если внутри первого .then нужно сделать еще одну асинхронную операцию, ее .then можно добавить к исходному вызову, не вкладывая один в другой. Допустим, у вас есть ресурс users и ресурс posts, а код вам нужно начать с переменной userEmail. Запустите асинхронную операцию, чтобы увидеть значение переменной userId, затем используйте его в другой асинхронной операции для получения постов пользователя. Код может выглядеть следующим образом: getUserIdFromEmail(userEmail) .then((userId) => getUserPosts(userId)) .then((userPosts) => { // Обработка полученных постов }) .catch((err) => { // Обработка ошибки }); .finally(() => { // Действие, которое выполняется, // даже если промис завершился с ошибкой }); В этом случае не пришлось вкладывать асинхронные операции, и нам не придется это делать, сколько бы асинхронных операций ни выполнялось. Такой код читается намного лучше по сравнению с коллбэк-стилем. async/await Шаблон async/await не только делает код более читабельным, но и позволяет лучше обрабатывать ошибки. Также он упрощает управление областями видимости переменных при работе с асинхронным кодом. Вот как можно использовать промисифицированную версию функции readFileAsArray с синтаксисом async/await: async function countOdd () { try { const lines = await readFileAsArray('./numbers.txt');
78  Глава 3. Асинхронность и события } const numbers = lines.map(Number); const oddCount = numbers.filter(n => n%2 === 1).length; console.log('Odd numbers count:', oddCount); } catch(err) { // Операции в случае ошибки } countOdd(); Сначала мы создаем асинхронную функцию — это обычная функция, перед которой указано слово async. Внутри этой функции мы вызываем readFileAsArray, как будто она возвращает данные напрямую (хотя на самом деле она возвращает промис). Чтобы код заработал, перед вызовом ставим ключевое слово await. Пос­ ле этого продолжаем работу над кодом, как будто readFileAsArray — синхронная функция. Для обработки возможных ошибок вызов await оборачивается в блок try/catch. Чтобы код выполнялся, нужно вызвать асинхронную функцию (в данном случае countOdd()). Синтаксис async/await упрощает код, особенно когда нужно вызывать несколько зависящих друг от друга асинхронных функций. Вот пример, как async/await помогает упростить цепочку асинхронных вызовов: async function getUserInfo(userEmail) { try { const userId = await getUserIdFromEmail(userEmail); const userPosts = await getUserPosts(userId); } // Работа с userId и userPosts } catch (err) { // Обработка ошибки } Так мы избавляемся от .then- и .catch-вызовов и необходимости возвращать промисы из каждого .then. Но этот способ дает еще одно неочевидное, но важное преимущество: результаты всех последовательных асинхронных вызовов можно использовать в одном месте. Чтобы добиться такого же эффекта с .then/.catch, потребуется гораздо больше кода. Зачастую во время работы несколько промисов придется выполнять параллельно, а не по очереди. Это можно сделать, например, так: Promise. all([promise1, promise2, promise3]). Также можно использовать await [promise1, promise2, promise3]. В обоих случаях вы получите массив с результатами всех промисов в том же порядке, в каком они были вызваны. Возможности async/await можно применять к любым функциям, поддерживающим интерфейс Promise. await нельзя использовать с коллбэк-функциями, но, как
Функции-обработчики  79 мы уже видели ранее, любую функцию с коллбэк-стилем можно преобразовать в Promise-вариант. Для этого в Node даже есть встроенная утилита. Например, если функция readFileAsArray изначально написана в коллбэк-стиле, ее можно «промисифицировать» с помощью встроенного модуля node:util: import { promisify } from 'node:util'; // const readFileAsArray = function(file, cb) { ... } const readFileAsArrayPromise = promisify(readFileAsArray); // Теперь функцию можно использовать так: await readFileAsArrayPromise(...) Кроме того, некоторые встроенные модули Node поддерживают в своем API стиль как коллбэков, так и промисов. Например, модуль node:fs имеет версию своего API, основанную на промисах. Вот пример функции readFileAsArray с использованием API node:fs, основанной на промисах: import { readFile } from 'node:fs/promises'; const readFileAsArray = async (file) => { const data = await readFile(file, 'utf8'); const lines = data.trim().split('\n'); return lines; }; Ключевое слово await можно вводить на верхнем уровне модуля Node ES (то есть вне функции async). Это полезно, например, при работе с динамическими импортами или с инициализацией ресурсов. Но учтите, что await останавливает выполнение текущего блока кода. Поэтому, если использовать его на верхнем уровне, он заблокирует последующий код. Также обработчики можно реализовывать с помощью объектов — эмиттеров событий. Но прежде чем мы приступим к их изучению, давайте разберем аналогию, объясняющую разницу между коллбэками и промисами, а также рассмотрим проблему контроля и доверия. Аналогия для объяснения промисов Представьте, что вы пришли в кафе и заказали какое-то блюдо. Его нужно приготовить, так что пока вы оплачиваете заказ, называете свое имя и ждете, когда ваш заказ будет готов. Тогда вас позовут по имени. Ваше имя в данном случае работает как коллбэк, который вы передаете асинхронной операции. Его вызывают, когда запрошенный объект готов к работе. У коллбэк-паттерна есть недостатки. Лучше работать с промис-паттернами. Их преимущество в контроле и доверии. Давайте рассмотрим это на еще одном примере. Представьте, что вы готовите мансаф — левантийское блюдо из риса, баранины и йогурта. Йогурт нужно постоянно помешивать, иначе он пригорит. Пока вы
80  Глава 3. Асинхронность и события готовите йогурт, вы не можете заниматься другими делами, если только не выключить плиту и закончить готовить совсем. По рецепту, когда йогурт закипел, нужно убавить огонь, добавить мясной бульон и продолжать помешивать. Если готовите только вы, то вам придется и убавлять огонь, и добавлять бульон, и — синхронно со всем этим — помешивать йогурт. Ваше тело, которое в данной аналогии можно сравнить с одиночным потоком JavaScript, заблокировано на все время выполнения этой синхронной задачи. Вы должны закончить готовить йогурт и только потом сможете приступить к приготовлению риса. По тому же принципу работают синхронные циклы в коде: putYogurtInPot(); putPotOnStove(); while (yogurtIsNotBoiling) { stirYogurt(); // Теперь ты заблокирован до окончания цикла } lowerHeat(); addMeatBroth(yogurt) while (yogurtIsNotBoiling) { stirYogurt(); // Ты снова заблокирован до окончания цикла } // Начинай готовить рис Но если йогурт и рис нужно готовить одновременно, без помощи не обойтись. Нужно делегировать! И в этом поможет су-шеф. Делегируя помешивание йогурта кому-то другому, вы как будто используете модуль (например, node:fs), который выполняет за вас асинхронную задачу (например, читает содержимое файла). А если в Node вы применяете коллбэк при работе с асинхронным вызовом, это все равно что дать конкретные инструкции по готовке и ингредиентам блюда су-шефу: sousChef.handleStirring(rawYogurt, (problem?, boilingYogurt) => { lowerHeat(); const yogurtMeatMix = addMeatBroth(boilingYogurt); handleStirring(yogurtMeatMix) // Другая асинхронная операция }); // Начинай готовить рис Так вы освобождаете свое однопоточное тело и можете заняться чем-то другим. Например, готовить рис. Вы используете асинхронный API.
Функции-обработчики  81 Какие же у этого способа минусы? Вы больше не контролируете, что происходит с йогуртом. Су-шеф теперь не только помешивает йогурт, он выполняет всю работу, которую нужно делать, когда йогурт закипит. И нет никакой гарантии, что он будет в точности следовать вашим инструкциям. Увидит ли он, что йогурт закипел, вовремя? Добавит ли мясной бульон? Не переборщит ли с бульоном? Не забудет ли убавить огонь? Для удобства можно попросить су-шефа сообщить, когда, по его мнению, йогурт закипит, тогда вы подтвердите это и проконтролируете, как он добавляет мясной бульон. Так процесс станет промисифицированным, то есть вы попросите су-шефа пообещать (promise) сообщить вам, что йогурт закипел: try { const boilingYogurt = await sousChef.handleStirringP(rawYogurt); sousChef.lowerHeat(); const yogurtMeatMix = sousChef.addMeatBroth(boilingYogurt); const cookedYogurt = await sousChef.handleStirringP(yogurtMeatMix); } catch(problem) { sousChef.reportIt(); } // Начинай готовить рис handleStirring от handleStirringP здесь отличает только то, что во втором случае вам обещают результат, с которым вы можете работать. У вас появляется немного больше доверия к процессу и контроля над ним. Вы можете сами добавить бульон (так как работу блокировало именно помешивание): try { const boilingYogurt = await sousChef.handleStirringP(rawYogurt); you.lowerHeat(); const yogurtMeatMix = you.addMeatBroth(boilingYogurt); const cookedYogurt = await sousChef.handleStirringP(yogurtMeatMix); } catch(problem) { you: inspect(problem) && maybe(retry) || orderSomeTakeout(); } // Начинай готовить рис Доверять — не значит меньше контролировать, и наоборот. Все зависит от характера асинхронного модуля и его API. Чтобы понять разницу в синтаксисе между вложенностью, цепочками и использованием async/await, давайте приготовим рис! Например, его можно на какое-то время замочить в воде, набухший рис поставить на плиту, залить водой и довести до кипения. Затем нужно убавить огонь, накрыть кастрюлю крышкой и оставить рис томиться на пару.
82  Глава 3. Асинхронность и события Все это асинхронные операции. Например, пока рис замачивается, вы можете заняться чем-то другим. Кроме того, все эти операции зависят друг от друга. Но параллельно их выполнять нельзя, только последовательно. Поскольку вы начинаете с объекта «сухой рис», который затем становится набухшим, потом кипящим, а затем пропаренным, процесс готовки можно сравнить с использованием промисов: // Nesting dryRice.soak() .then(wetRice => wetRice.boil() .then(boiledRice => boiledRice.steam() .then(steamedRice => serve(steamedRice)); ); ); // Chaining dryRice.soak() .then(wetRice => wetRice.boil()) .then(boiledRice => boiledRice.steam()) .then(steamedRice => serve(steamedRice)); // async/await const wetRice = await dryRice.soak(); const boiledRice = await wetRice.boil(); const steamedRice = await boiledRice.steam(); serve(steamedRice); Событийный цикл Как обработчик запускается в основном потоке после завершения асинхронной операции вне этого потока? Но что еще важнее: когда работа ведется с несколькими асинхронными операциями и обработчиками, как они управляются и в каком порядке возвращаются в основной поток? Чтобы ответить на эти вопросы, нужно разобраться в трех основных структурах: колл-стеке, очередях событий и событийном цикле. V8 управляет выполнением функций JavaScript, используя простую структуру стека, известную как колл-стек. Стек — это структура данных типа LIFO (last-in, first-out, последним пришел — первым вышел), в которой последняя добавленная сущность обрабатывается первой. Каждый раз, когда вызывается функция, ссылка на нее помещается на вершину колл-стека. Если эта функция вызывает другие, в том числе рекурсивно саму себя, ссылки на вложенные функции также помещаются на вершину колл-стека.
Событийный цикл  83 Когда выполнение функции завершается, ее ссылка удаляется из колл-стека. Вложенные вызовы удаляются по одному, начиная с вершины колл-стека, последней завершается функция, которая была добавлена в стек первой. При работе в Node любой код, написанный на JavaScript, должен попасть в колл-стек, чтобы V8 мог его выполнить. Колл-стек однопоточен, то есть, пока в нем есть функции, все остальные операции ждут, когда стек освободится. Именно поэтому нельзя писать код, который долго блокирует стек вызовов (например, длинный цикл for). Пока колл-стек занят, все обработчики ваших асинхронных операций будут ждать. Любой код, который должен выполняться долго, следует запускать вне основного потока и его единственного колл-стека. Когда Node добавляет функции в колл-стек и доходит до асинхронной задачи, эта задача обрабатывается вне колл-стека, внутри себя. В зависимости от типа задачи Node может использовать другой поток или асинхронные возможности операционной системы. Для выполнения неблокирующих операций ввода-вывода Node использует асинхронные возможности операционной системы. Для задач, нагружающих процессор, и других задач, которые ОС не может обрабатывать асинхронно, Node предоставляет пул потоков через библиотеку libuv. Эта библиотека лежит в основе всей асинхронности в Node. В ней реализован событийный цикл, и она гарантирует абстракции от платформозависимых деталей, чтобы Node могла одинаково работать на разных ОС. После завершения асинхронной задачи Node помещает связанный с ней обработчик в структуру данных, которая называется очередью событий (или очередью задач). Очередь — это структура типа FIFO (first-in, first-out, первым пришел — первым вышел), где первым обрабатывается то, что было добавлено первым. Чтобы обработчик выполнялся, его нужно поместить в колл-стек. За эту задачу отвечает событийный цикл (рис. 3.3). Событийный цикл — это простой бесконечный цикл с очень понятной задачей: когда в очереди событий есть функции-обработчики, а колл-стек пуст, он берет первую функцию из очереди и помещает ее в коллстек, чтобы движок V8 мог ее выполнить. Затем он ждет, пока стек освободится для обработки следующей функции, и все повторяется, пока в очереди событий не останется обработчиков. Этот процесс происходит при любой асинхронной операции, будь то обработка входящего запроса к веб-серверу, чтение файла из файловой системы, запуск таймера или шифрование данных. Каждая асинхронная задача имеет обработчик, который помещается в очередь событий, выбирается событийным циклом, добавляется в колл-стек и выполняется движком V8. Также организовать и использовать асинхронный код в Node можно с помощью объектов — эмиттеров событий (event emitters) и функций-слушателей (event listeners). Об этом поговорим дальше.
84  Глава 3. Асинхронность и события Колл-стек Работа Node с асинхронными операциями Очередь событий Событийный цикл Рис. 3.3. Работа с асинхронными операциями с помощью событийного цикла В событийном цикле есть несколько фаз с разными приоритетами. Он работает с несколькими очередями событий, связанными с этими фазами. Есть фаза для таймеров, фаза для системных коллбэков, фаза для операций ввода-вывода и другие. Промисы и задачи с высоким приоритетом обрабатываются отдельно, в очереди микрозадач. Эмиттеры событий Использование событий для обработки асинхронных задач поднимает разработку на совершенно новый уровень. Прежде всего события — это успешная коммуникация. Независимо от сложности ситуации, успешная коммуникация все упрощает. Это справедливо и для жизни, и для событий в Node. Концепция событий в Node простая: объекты EventEmitter генерируют (emit) именованные события, в ответ на которые вызываются заранее зарегистрированные функции-обработчики. У объекта-эмиттера есть две основные функции: генерация (испускание) именованных событий; регистрация (и удаление) функций-слушателей. Чтобы создать объект — эмиттер события, подготовьте экземпляр класса EventEmitter или любого класса, который его расширяет. Второй вариант позволяет реализовать собственную логику, которую можно повторно использовать между несколькими объектами-событиями: import { EventEmitter } from 'node:events'; class MyEmitter extends EventEmitter { // Кастомная логика для всех объектов — эмиттеров событий } const myEmitter = new MyEmitter();
Эмиттеры событий  85 На любом этапе жизненного цикла объекта-эмиттера можно вызвать функцию emit. Она генерирует любое именованное событие: myEmitter.emit('something-happened', data); // Или внутри класса MyEmitter: this.emit('something-happened', data); После имени события можно передать дополнительные данные. Допустимо делать это с любым количеством аргументов — все они будут доступны зарегистрированным обработчикам события. Добавить обработчики можно с помощью метода on — они будут вызываться каждый раз, когда объект-событие транслирует соответствующее событие: myEmitter.on('something-happened', (data) => { // Обработка }) Для наглядности предлагаю вернуться к примеру реализации readFileAsArray, но вместо коллбэков и промисов использовать события: import fs from 'node:fs'; import { EventEmitter } from 'node:events'; class ReaderEmitter extends EventEmitter { readFileAsArray(file) { fs.readFile(file, (err, data) => { if (err) { this.emit('error', err); return; } const lines = data.toString().trim().split('\n'); this.emit('data', lines); }); } } const reader = new ReaderEmitter(); reader.on('data', (lines) => { const numbers = lines.map(Number); const oddNumbers = numbers.filter((n) => n % 2 === 1); console.log('Odd numbers count: ', oddNumbers.length); }); reader.on('error', (err) => { throw err; }); reader.readFileAsArray('./numbers.txt');
86  Глава 3. Асинхронность и события Хотя это простой пример, видно, что обработка данных и обработка ошибок вынесены в отдельные функции. Так код становится более модульным и простым в управлении. По мере усложнения логики событийный подход упрощает повторное использование и масштабируемость кода. Мы можем создавать несколько эмиттеров, добавлять новые события и обрабатывать каждое из них сколько угодно раз. Кроме того, подход на основе событий упрощает написание целенаправленных модульных тестов и делает отладку кода проще. Асинхронность Как и со стилем коллбэков, наличие объекта-эмиттера не значит, что события будут вызываться асинхронно. Давайте рассмотрим пример: import { EventEmitter } from 'node:events'; class WithLog extends EventEmitter { execute(taskFunc) { this.emit('begin'); taskFunc(); this.emit('end'); } } const withLog = new WithLog(); withLog.on('begin', () => console.log('About to execute')); withLog.on('end', () => console.log('Done with execute')); withLog.execute(() => console.log('*** Executing task ***')); Класс WithLog определяет один метод — execute. Этот метод принимает один аргумент taskFunc, то есть функцию задачи, и генерирует события до и после ее выполнения. Чтобы увидеть порядок действий, нужно определить слушатели событий и вызвать тестовую задачу. Результат будет таким: About to execute *** Executing task *** Done with execute Важно отметить, что все это выполняется синхронно. В этом коде нет асинхронных операций. А так как код устроен как последовательность синхронных строк, если передать в taskFunc асинхронную функцию, события начнут вызываться в неправильном порядке. Такую ситуацию можно смоделировать с помощью setImmediate: // ... withLog.execute(() => { setImmediate(() => {
Эмиттеры событий  87 console.log('*** Executing task ***') }); }); В результате мы получим в консоли сообщения, последовательность которых не будет соответствовать действительной последовательности операций, которые выполняет код: About to execute Done with execute *** Executing task *** Чтобы сгенерировать событие после завершения асинхронной функции, нужно объединить коллбэки (или промисы) с событийным подходом. Вот пример: import fs from 'node:fs'; import { EventEmitter } from 'node:events'; class WithTime extends EventEmitter { execute(asyncFunc, ...args) { this.emit('begin'); console.time('execute'); asyncFunc(...args, (err, data) => { if (err) { return this.emit('error', err); } } } this.emit('data', data); console.timeEnd('execute'); this.emit('end'); }); const withTime = new WithTime(); withTime.on('begin', () => console.log('About to execute')); withTime.on('end', () => console.log('Done with execute')); withTime.execute(fs.readFile, import.meta.filename); Класс WithTime выполняет асинхронную функцию asyncFunc и сообщает, сколько времени это заняло, с помощью console.time и console.timeEnd. Он генерирует правильную последовательность событий до и после завершения. Протестировать withTime можно с помощью функции fs.readFile. Она асинхронная. Теперь вместо того, чтобы обрабатывать результат через коллбэк, мы можем слушать событие data. После выполнения мы получим корректный порядок событий и отчет о времени: About to execute execute: 4.507ms Done with execute
88  Глава 3. Асинхронность и события Обратите внимание: чтобы добиться правильного результата, нам пришлось объединить коллбэк и EventEmitter. Если бы asyncFunc поддерживал промисы, можно было бы использовать async/await: class WithTime extends EventEmitter { async execute(asyncFunc, ...args) { this.emit('begin'); try { console.time('execute'); const data = await asyncFunc(...args); this.emit('data', data); console.timeEnd('execute'); this.emit('end'); } catch(err) { this.emit('error', err); } } } Ошибки Error — особенное событие, которое всегда нужно обрабатывать. Если мы не определим обработчик события error и вызовем метод execute с некорректным аргументом, произойдет ошибка. Добавим ошибочный вызов перед корректным: class WithTime extends EventEmitter { // ... } // ... withTime.execute(fs.readFile, ''); // неверный вызов withTime.execute(fs.readFile, import.meta.filename); Первый вызов спровоцирует ошибку. events.js:163 throw er; // Необработанное событие error ^ Error: ENOENT: no such file or directory, open '' Второй вызов из-за этого может даже не выполниться. Если же мы зарегистрируем слушатель события error, поведение изменится: // ... withTime.on('error', (err) => { // Производим действие с err, например логируем его console.log(err) });
Эмиттеры событий  89 withTime.execute(fs.readFile, ''); // НЕВЕРНЫЙ ВЫЗОВ withTime.execute(fs.readFile, import.meta.filename); Теперь ошибка будет поймана и не приведет к завершению процесса. Второй вызов выполнится успешно: { Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT' ... } execute: 4.276ms Однако не стоит забывать: при появлении необработанной ошибки процесс завершится по умолчанию. С обработчиками ошибок нужно быть очень аккуратными. Ожидаемые ошибки можно перехватить, но все остальные должны завершать процесс. Необработанные ошибки могут привести к непредсказуемому состоянию приложения. Примеры Многие встроенные модули Node имеют в своих API объекты, которые являются экземплярами класса EventEmitter. Эти объекты эмитируют определенные события, которые мы можем обрабатывать в своем коде. Например, простой HTTP-сервер «Hello World» из главы 1 можно переписать с использованием событий: import { createServer } from 'node:http'; const server = createServer(); server.on('request', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World'); }); server.on('listening', () => { console.log('Server is running...'); }); server.listen(3000, '127.0.0.1'); Это возможно потому, что объект server — экземпляр класса http.Server, который «под капотом» расширяет класс EventEmitter. События request (при запросе) и listening (при запуске прослушивания) — это встроенные события http.Server. Объекты req и res (передаваемые как данные события request) тоже являются эмиттерами событий! В главе 6 мы познакомимся с потоками Node. Это тоже объекты — эмиттеры событий. Вот простой пример, как с помощью потока можно прочитать содержимое файла:
90  Глава 3. Асинхронность и события import { createReadStream } from 'node:fs'; const readStream = createReadStream('input.txt', 'utf8'); readStream.on('data', (chunk) => { console.log('Read chunk:', chunk); }); readStream.on('end', () => { console.log('Reading finished'); }); Обратите внимание: в этом примере файл читается по частям, а не целиком, как это происходит при использовании readFile. Эта мощная особенность Node позволяет эффективно работать с большими объемами данных. Поговорим об этом в главе 6. К другим модулям Node, содержащим объекты — эмиттеры событий, относятся node:child_process, node:net, node:dgram, node:os и node:cluster. Примеры их использования мы рассмотрим по ходу книги. Другой способ обработать эмитированные события error — определить обработчик события uncaughtException на объекте process. Это событие срабатывает при необработанных ошибках. Обычно его использовать не рекомендуется, но если нужно что-то сделать перед завершением процесса, это допустимо: process.on('uncaughtException', (err) => { // Произошла необработанная ошибка. Нужно завершить процесс. // Сделайте, что нужно // По окончании завершите процесс process.exit(1); }); Однако иногда может возникнуть одновременно несколько ошибок. Тогда слушатель uncaughtException может сработать несколько раз, и код в нем выполнится многократно. Чтобы этого избежать, у объектов-эмиттеров есть метод once — он регистрирует обработчик, который сработает только один раз, даже если событие произойдет многократно. Метод once лучше использовать с событием uncaughtException, тогда обработчик точно выполнится ровно один раз, поскольку процесс все равно завершится. Если для одного и того же события зарегистрировано несколько обработчиков, они вызываются в том порядке, в котором были добавлены. Первый зарегистрированный слушатель будет вызван первым: withTime.on('data', (data) => { console.log(`Length: ${data.length}`); });
Выводы  91 withTime.on('data', (data) => { console.log(`Characters: ${data.toString().length}`); }); withTime.execute(fs.readFile, import.meta.filename); В этом примере строка Length: появится перед строкой Characters:, потому что слушатели были определены в таком порядке. Если же нужно добавить новый слушатель, но при этом вызвать его первым, можно использовать метод prependListener: withTime.on('data', (data) => { console.log(`Length: ${data.length}`); }); withTime.prependListener('data', (data) => { console.log(`Characters: ${data.toString().length}`); }); withTime.execute(fs.readFile, import.meta.filename); В этой версии строка Characters: будет выведена первой. Чтобы удалить слушатель, используется метод removeListener. Выводы Событийно-ориентированный подход Node — это простой, но мощный механизм, который гарантирует гибкость и контроль на всех этапах асинхронных операций: до, во время и после их выполнения. Для работы с асинхронностью в Node можно использовать коллбэки, промисы или эмиттеры событий. Цикл событий Node управляет асинхронными задачами с помощью очередей событий. В Node есть модуль events, с помощью которого можно создавать объекты, поддерживающие множество событий и их обработчиков. Функции-обработчики всегда «слушают» события, к которым они привязаны, и срабатывают при их наступлении. События генерируются для того, чтобы обозначить определенные условия. В следующей главе мы обсудим, как обрабатывать ошибки в Node и как отлаживать Node-приложения.
ГЛАВА 4 Ошибки и отладка Объекты ошибок играют в Node важную роль. Они используются для того, чтобы сообщать о проблеме, и предоставляют контекст и подробности. Грамотная обработка ошибок делает программы надежнее и безопаснее. Эта глава полностью посвящена ошибкам и тому, как с ними работать. Сначала мы расскажем, как создаются и перехватываются ошибки, затем рассмотрим их типы и поговорим о многоуровневом управлении ошибками. Также мы изучим отладку кода в Node и обсудим профилактику потенциальных проблем. Создание и перехват ошибок В Node ошибка — это объект класса Error. Иногда нужно создать собственный объект ошибки. Например, это можно сделать так: const error = new Error('Some message'); Это универсальный объект ошибки, но на вашем месте я бы не стал его использовать. Существуют более подходящие варианты, о них мы поговорим позднее. Также в Node есть встроенные ошибки — это уже готовые объекты ошибок. Их мы обсудим в следующем разделе. Что же делать с объектом ошибки? Неважно, какого она типа, создана она вручную или является встроенной: если во время работы с кодом произошла ситуация, которой быть не должно, нужно сообщить об этом тому, кто использует ваш код. Это делается через выбрасывание ошибки1. Допустим, мы хотим создать функцию для вычисления квадратного корня. Из отрицательных чисел квадратный корень извлечь невозможно, поэтому функция должна запрещать вызовы с такими значениями. Это ожидаемая ошибка ввода, и в таком случае функция должна сообщить, что ее используют неправильно. Один из способов это сделать — выбросить ошибку: 1 Очень часто в работе мы используем кальку с английского и говорим, например, «метод выбросил ошибку». Это позволяет избежать путаницы при использовании фразы «создать ошибку», что может означать как создание объекта типа Error, так и генерацию события при возникновении ошибки в коде. — Примеч. науч. ред.
Создание и перехват ошибок  93 function calculateSquareRoot(number) { if (number < 0) { throw new Error( 'Cannot calculate square root of negative number' ); } } return Math.sqrt(number); calculateSquareRoot(-1); // Ошибка: невозможно вычислить квадратный корень отрицательного числа Выбрасывание ошибки останавливает выполнение текущей операции. В Node после выброса ошибки происходит аварийное завершение процесса. Вот что произойдет при запуске этого кода: $ node index.js file:///Path/To/File:3 throw new Error( ^ Error: Cannot calculate square root of negative number at calculateSquareRoot (file:///Path/To/File:3:11) at file:///Path/To/File:11:1 На первый взгляд, это неправильный подход. Зачем из-за одной ошибки завершать работу всей программы? Ответ прост: подобные ошибки нельзя игнорировать — их нужно обрабатывать. Игнорирование может привести к неожиданным последствиям и нарушению целостности приложения. Если функция получила неверные данные, они могут распространиться по всей программе и стать причиной ее некорректной работы. А это уже серьезно. В некоторых случаях невалидный ввод может даже привести к проблемам безопасности, став вектором эксплойт-атаки на приложение. Если не обрабатывать ошибки, связанные с окружением приложения, могут случиться утечка данных и исчерпание системных ресурсов. Игнорировать это очень рискованно. В базовых модулях всегда следует выбрасывать ошибку, если код указывает на проблему. Это сигнал: либо ошибка будет подтверждена и обработана, либо произойдет аварийное завершение работы приложения. Теперь модули, использующие ваш базовый код, могут по-своему реагировать на определенные ошибки. Например, модуль, вызывающий функцию calculateSquareRoot, может захотеть сделать исключение для случая, когда в нее передается отрицательное число. Это можно реализовать, перехватив ошибку. Всякий раз, когда вы работаете с чужим кодом (например, вызываете функцию), следует помнить: он может выбросить ошибку. Как пользователь этого кода вы решаете, что с ними делать. Вам помогут перехват и обработка ошибки.
94  Глава 4. Ошибки и отладка Допустим, вы решили: когда функция calculateSquareRoot вызывается с отрицательным числом, она должна вывести предупреждение, а не завершать работу всего приложения. Вот как это делается: try { let result = calculateSquareRoot(-1); } catch (error) { console.error(error.message); } Поскольку вызов обернут в конструкцию try...catch, а блок catch просто выводит сообщение об ошибке в консоль, при вызове calculateSquareRoot с отрицательным числом аварийного завершения работы приложения не будет. Выброшенная ошибка стала обработанным исключением. Однако в данном случае перехватывается не только ошибка, связанная с отрицательным числом. На этом уровне будет перехвачена и обработана любая другая ошибка внутри calculateSquareRoot. Мы должны обрабатывать только те ошибки, о которых знаем. Неизвестные ошибки также должны выбрасываться и останавливать работу приложения. Чтобы это реализовать, нам нужно обрабатывать ошибку и проверять ее тип. Дальше мы узнаем, какие могут быть типы ошибок, и обсудим их основные особенности. Типы ошибок Разные типы ошибок требуют разных стратегий обработки. Если вы научитесь определять типы ошибок и причины их возникновения, то будете быстрее разбираться в сути проблемы. Вот основные типы ошибок в Node: стандартные ошибки, системные и пользовательские (кастомные). Рассмотрим каждый из них по порядку. Стандартные ошибки Стандартные ошибки — это встроенные ошибки, создаваемые самим JavaScript, когда возникает неожиданное условие, нарушающее нормальное выполнение программы. Это может быть ошибка синтаксиса, обращение к переменной до ее объявления или неправильное использование функции. Основные классы стандартных ошибок — это SyntaxError , ReferenceError , RangeError и TypeError. Все они являются подклассами класса Error. SyntaxError Ошибка синтаксиса. Возникает, когда код не соответствует правилам синтаксиса JavaScript. Примеры: ввод зарезервированного ключевого слова, недопустимое имя переменной, лишняя запятая, скобка и т. п.:
Типы ошибок  95 > console.log('Hello world'; console.log('Hello world'; ^^^^^^^^^^^^^ // Необработанная SyntaxError: пропущена ) после списка аргументов > ReferenceError Ошибка при создании ссылки. Возникает при обращении к переменной, которая не была объявлена или находится вне области видимости: console.log(x); // Необработанная ReferenceError: x не определен let x = 5; RangeError Ошибка диапазона. Возникает при использовании значения вне допустимого диапазона — например, при передаче некорректного аргумента функции или переполнении стека вызовов: (123.456).toFixed(101); // Необработанная RangeError: toFixed() числовой аргумент // должен быть в диапазоне от 0 до 100 Эта ошибка может появиться также после рекурсивного вызова функции без условия завершения: function f() { f(); } f(); // Необработанная RangeError: Превышен максимальный размер стека вызовов TypeError Ошибка типа. Возникает, если значение используется некорректно, например, когда метод thing.toUpperCase() вызывается у числа, а не у строки: let thing = 42; console.log(thing.toUpperCase()); // Необработанная TypeError: thing.toUpperCase не является функцией Другие примеры TypeError: попытка изменить const-переменную, доступ к свойствам у null или undefined либо использование ключевого слова new с функцией, которая не является конструктором. Это наиболее распространенные стандартные ошибки. Есть и другие, например URIError (ошибка при работе с URI) и EvalError (используется только для совместимости, больше не выбрасывается современным JavaScript).
96  Глава 4. Ошибки и отладка Системные ошибки В Node системные ошибки выбрасываются, когда что-то неожиданное происходит на уровне операционной системы. Обычно это проблемы, вызванные средой и ОС, в которой запущено Node-приложение. Примеры: попытка чтения или записи в несуществующий файл, использование занятого сетевого порта, отправка данных через закрытый сокет и т. д. У таких ошибок есть специальные коды, по которым можно определить причину их появления. Вот несколько распространенных системных кодов ошибок: ENOENT Возникает при попытке доступа к несуществующему файлу или каталогу. EACCES Возникает при отсутствии необходимых прав. Например, если попытаться записать в файл только для чтения. EADDRINUSE Возникает, когда сервер (HTTP или TCP) не может запуститься, потому что нужный адрес уже используется. ECONNREFUSED Возникает при попытке подключения к неработающему серверу. К другим распространенным системным ошибкам относятся: ETIMEDOUT Возникает, когда операция превысила лимит времени. ECONNRESET Возникает, когда соединение сброшено другой стороной. ENOTFOUND Возникает, когда объект не найден. EPERM Возникает, когда операция не разрешена. Если код ошибки выглядит как ESOMETHING, скорее всего, это системная ошибка. Полный список системных ошибок можно посмотреть через os.constants.errno (рис. 4.1). Системные ошибки входят в более широкую категорию операционных. Это нефатальные ошибки, которые можно предсказать, обнаружить и в большинстве
Типы ошибок  97 случаев корректно обработать в коде. Помимо системных к ним относятся также другие ошибки выполнения, не связанные с ОС: ошибки при работе с внешними сервисами (например, базой данных), ошибки ввода (например, некорректные данные от пользователя), ошибки таймаута и т. д. Рис. 4.1. Системные ошибки Эти коды ошибок соответствуют стандарту POSIX. POSIX (Portable Operating System Interface) — набор стандартов (основанный на Unix), который обеспечивает совместимость между операционными системами. Следующая категория ошибок — пользовательские, или кастомные, — также может считаться подмножеством операционных ошибок. Кастомные ошибки Кастомные ошибки в Node создаются разработчиками для обработки специфических условий или ситуаций в приложении. В качестве примера здесь можно назвать ошибку, которую мы создавали при попытке вычислить корень из отрицательного числа. Не забывайте, что кастомные ошибки, известные также как пользовательские ошибки, — это ошибки, заданные разработчиком, а не конечным пользователем приложения.
98  Глава 4. Ошибки и отладка Чаще всего вам придется иметь дело именно с кастомными ошибками. Мы создаем их для специфической обработки, а также для добавления контекста или дополнительных свойств, связанных с логикой приложения. Пример: ошибка UserNotFoundError может использоваться, если совершается попытка получить пользователя, которого нет в системе. TransactionFailedError — при неудачной транзакции. В больших приложениях на Node могут быть десятки, если не сотни пользовательских ошибок. Хотя простое создание экземпляра класса Error уже считается пользовательской ошибкой, лучше расширять его, создавая собственные классы ошибок, например, так: class ValidationError extends Error { constructor(message, fieldName) { super(message); this.name = 'ValidationError'; this.fieldName = fieldName; } } // Использовать: // throw new ValidationError('Some message', 'some_field'); Используйте этот класс, чтобы создать конкретную ошибку при валидации данных. Например, это можно сделать, если нужно убедиться, что у объекта пользователя есть поле username: function validateUser(user) { if (!user.username) { throw new ValidationError('Username is required', 'username'); } } Обратите внимание: ошибка конкретная и полезная. Когда она возникает, мы сразу понимаем, в чем проблема, и можем получить дополнительные данные из нее самой. Вот еще один пример: try { validateUser({}); // Пустой объект не является корректным объектом "user" } catch (error) { if (error instanceof ValidationError) { console.error( `Error in field '${error.fieldName}': ${error.message}`, ); } else { console.error(`Unexpected error: ${error.message}`); throw error; } }
Типы ошибок  99 Не стоит создавать сложную иерархию ошибок — ограничьтесь одним уровнем. Если у вас модульная архитектура (domain, api, app и т. д.), можно разносить ошибки по отдельным модулям в рамках этих структур. Как бы вы ни организовывали пользовательские ошибки, будьте максимально конкретны в их именовании. Если не удается подключиться к базе данных — выбрасывайте DatabaseConnectionError. Если не удалось найти пользователя по ID — CouldNotFindUserFromIdError. Обратите внимание: в одном случае используется существительное, в другом — глагольная форма. Выбирайте подходящий стиль. Как видно из примера выше, если после обработки исключений я получаю известные ошибки типа ValidationError, то пробрасываю все прочие ошибки дальше. Это очень важно. Мы обсудим это в следующем разделе. Ошибки утверждений В Node есть еще одна категория ошибок — ошибки утверждений (assertion errors). Это специальные ошибки, которые в основном используются при разработке и тестировании. Обычно они применяются, когда программа ожидает, что некое условие истинно, но оно оказывается ложным. Ошибки утверждений часто нужны при написании тестов, отладке и проверке предположений во время разработки. Если в каком-то месте кода срабатывает ошибка утверждения, это, скорее всего, указывает на баг или недопонимание, которое нужно устранить до того, как код попадет в релиз. В Node ошибки утверждений создаются с помощью модуля assert, который предоставляет простой набор проверок: assert(value[, message]) Истинно ли значение? assert.equal(actual, expected[, message]) Равны ли фактическое и ожидаемое значения? assert.doesNotMatch(string, regexp[, message]) Совпадает ли строка с шаблоном regexp? Метод, предоставляемый модулем node:assert, при вызове проверяет определенное условие. Если условие не выполняется, метод выбрасывает ошибку AssertionError с описанием сбоя. Если выполняется, программа продолжает работу. Для различных проверок существует множество других методов модуля assert. Подробнее см. документацию Node по модулю assert: https://oreil.ly/I_t_F. Примеры того, как его использовать, мы рассмотрим в главе 8.
100  Глава 4. Ошибки и отладка Многоуровневое управление ошибками Допустим, у вас есть модули A, B и C: модуль B использует A, а C использует B. При проектировании обработки ошибок важно учитывать эту многоуровневую структуру. Теперь, когда мы умеем отличать типы ошибок и умеем создавать кастомные ошибки, можно выбрасывать разные ошибки для разных ситуаций и дать возможность верхним уровням структуры, использующим наш код, реагировать на них. Но после обработки исключений блок перехвата ошибок всегда должен завершаться повторным созданием вызова throw error, чтобы пробросить дальше все неизвестные ошибки: try { // Часть кода может выбрасывать различные типы ошибок } catch (error) { if (error instanceof KnownErrorType) { // Обработка известного типа ошибки } else if (error instanceof AnotherKnownErrorType) { // Обработка другого известного типа ошибки } else { // Логирование и проброс дальше всех неизвестных ошибок console.log('Encountered an unexpected error: ', error); throw error; } } Если неизвестную ошибку не выбросить повторно, фактически она будет проигнорирована. Это может привести к нестабильной или некорректной работе приложения. При этом верхние уровни не узнают, что произошел сбой. Кроме того, это сильно усложняет отладку, так как в таком случае контекст ошибки теряется. При повторном выбрасывании ошибки мы передаем ее на более высокий уровень. Если этого не делать, получится, что мы «прячем» ее от высоких уровней. Есть еще один способ обработки ошибок. Вместо того чтобы выбросить ошибку, можно передать ее дальше, в те части приложения, которые используют наш модуль, и дать им решить: обрабатывать ее или передавать дальше. Перенаправление ошибок часто используется в асинхронном и событийно-ориентированном программировании. Там ошибка, возникшая в одной функции, не обрабатывается сразу, а передается следующей функции вместе с данными. Один из способов такого перенаправления — коллбэк-стиль, который мы рассматривали в главе 3. Когда в функции возникает ошибка, она передается в следующий коллбэк первым аргументом. Это позволяет следующей функции проверить, есть ли ошибка, и решить, как с ней поступить.
Многоуровневое управление ошибками  101 function fetchData(callback) { getSomeDataFromAnAPI((err, data) => { if (err) { callback(err); // Пересылка ошибки в коллбэк return; } callback(null, data); // Ошибки нет, продолжение нормальных вычислений }); } fetchData((err, data) => { if (err) { console.log('Error fetching data: ', err); return; } console.log('Data received: ', data); }); Еще один способ перенаправить ошибки — функции, основанные на промисах. Ошибка внутри реализации промиса передается через метод reject: function fetchData() { return new Promise((resolve, reject) => { getSomeDataFromAnAPI((err, data) => { if (err) { reject(err); // Пересылка ошибки через метод reject } else { resolve(data); // Разрешение промиса } }); }); } fetchData() .then((data) => { console.log('Data received: ', data); }) .catch((err) => { console.log('Error fetching data: ', err); }); Однако для перенаправления ошибок необязательно использовать промисы или коллбэки. Достаточно сделать так, чтобы функция возвращала либо данные (успех), либо ошибку, либо и то и другое (частичный успех). Реализовать это можно, передавая объект с двумя свойствами — error и data. Но есть и еще более простой способ: можно возвращать объект с данными или объект с ошибкой. Второй способ немного сложнее, так как функция должна передавать объекты разных типов. Я бы советовал использовать его только в проектах Node с TypeScript, который добавляет статическую типизацию в JavaScript. Основы TypeScript в контексте Node мы рассмотрим в главе 10.
102  Глава 4. Ошибки и отладка Вот пример функции, возвращающей единый объект: function fetchData() { try { let data = getSomeData(); return { error: null, data: data }; } catch (error) { return { error: error, data: null }; } } const result = fetchData(); if (result.error) { console.log('Error fetching data:', result.error); } else { console.log(result.data ); } Перенаправление ошибок помогает поддерживать чистый и предсказуемый поток данных и ошибок во всех операциях приложения. Однако каждая часть приложения должна либо обрабатывать ошибки, либо перенаправлять их дальше — точно так же, как без перенаправления каждая часть приложения должна либо обрабатывать, либо выбрасывать ошибки. Управлять ошибками следует в едином стиле, который строго соблюдается во всем приложении. Отладка в Node В Node существует несколько способов отладки проблем в коде. Самый легкий — выводить в лог информацию рядом с проблемным участком кода. Для начала можно использовать простые выражения console.log. Если стек ошибки недостаточно информативен, с их помощью можно выяснить, где именно происходит сбой: console.log('Starting application...'); app.init(); console.log('Application initialized successfully.'); Если второе сообщение не появилось, значит, проблема в app.init(). Дополнительные логи помогают выводить данные, проверять предположения и отслеживать изменения. Это простой, но эффективный способ быстро выявлять ошибки в процессе разработки. Для более функциональной отладки Node предоставляет встроенный отладчик, который включается из командной строки с аргументом inspect: $ node inspect script.js Эта команда запускает ваше Node-приложение с подключенным инспектором, позволяющим приостанавливать работу, пошагово проходить код и просматривать
Отладка в Node  103 переменные во время исполнения. Точки останова можно указать прямо в коде, добавив инструкцию debugger;. Таким образом, когда интерпретатор достигнет этой строки, выполнение кода остановится. Встроенный отладчик полезен, но его функции ограниченны. Существует более мощная альтернатива. Для отладки Node-приложений можно использовать инструменты разработчика Chrome DevTools. Это так же просто, как и отладка вебприложений. Запустите Node с флагом –inspect: $ node --inspect script.js Чтобы остановить выполнение сразу в начале скрипта, можно использовать флаг --inspect-brk. Затем подключитесь к запущенному процессу Node через страницу chrome:// inspect в Chrome (рис. 4.2). Там отобразится запущенный процесс Node. Кликнув по нему, вы получите доступ ко всем возможностям DevTools: отладчику, профилировщику производительности, инспектору памяти и консоли реального времени. Рис. 4.2. Использование Chrome DevTools с Node Также в Node есть встроенный профилировщик производительности, который запускается с флагом --prof. Он периодически делает снимки стека вызовов во время выполнения программы и сохраняет эти данные вместе с важными событиями оптимизации.
104  Глава 4. Ошибки и отладка Кроме того, в Node есть ряд флагов трассировки, которые позволяют выводить расширенные сведения в стек ошибок. Их описание можно увидеть в выводе node --help: $ node --help | grep trace --enable-source-maps Поддерживает Source Map V3 для трассировки стека. --trace-deprecation Показывает полный stack trace для deprecated функций. --trace-event-categories=... Предназначен для включения трассировки конкретных категорий событий, которые передаются как аргументы для данного флага через запятую. --trace-event-file-pattern=... Предназначен для назначения шаблона имени файла трассировки. --trace-exit Выводит трассировку стека всякий раз, когда выполняется активный выход из среды, т. е. вызов process.exit(). --trace-promises Трассировка promise в отклоненном состоянии, вне зависимости от того, был ли обработан промис после отклонения или нет. --trace-sigint1 Включает печать стека JS при SIGNIT. --trace-sync-io Показывает стек при использовании синхронного ввода/вывода. --trace-tls Выводит информацию о TLS-пакетах. --trace-uncaught Показывает стек для необработанных исключений throw. --trace-warnings Показывает стек для всех предупреждений (включая deprecation). Еще одно преимущество понятных и говорящих имен — особенно для функций — заключается в удобстве отладки. Правильно подобранные имена объектов в трассировках и логах ошибок помогают быстрее определить, где и почему возникла проблема. Профилактические меры Хотя ошибок в работе избежать невозможно, есть инструменты и практики, которые снижают шансы столкнуться с неприятными неожиданностями. 1 Полезно для отладки graceful shutdown (вежливого, мягкого отключения) приложений. — Примеч. науч. ред.
Профилактические меры  105 Инструменты контроля качества кода Когда вы пишете функцию с аргументами, первым делом проверяйте их тип, формат и диапазон. Если они неправильные, выбрасывайте ошибку. На первый взгляд использование TypeScript усложняет Node-проекты, но преимуществ он дает гораздо больше. Он выявляет ошибки в вашем коде еще до запуска. Вы передали функции неверный тип аргумента? Допустили опечатку при доступе к свойству объекта? Вызвали метод, которого не существует? TypeScript сразу подсветит эти проблемы. Также он поможет написать более эффективный код управления ошибками. Также полезен линтер ESLint — он выявляет паттерны, которые приводят к возникновению ошибок, еще до выполнения кода, а также помогает соблюдать стиль и делает работу более продуктивной. У ESLint много мощных плагинов для разных библиотек. Подробнее о TypeScript и ESLint мы поговорим в главе 10. Неизменяемые объекты Истинно неизменяемый объект нельзя изменить после создания, так как любые обновления создают новый объект. Неизменяемые структуры данных (например, с использованием библиотеки Immutable.js) помогают избежать проблем от нежелательных преобразований. Даже простые концепции неизменяемости в самом JavaScript, например ввод const вместо let, замораживание объектов и использование свойств, доступных только для чтения, очень полезны и предотвращают многие проблемы. Тестирование Тестировать код нужно всегда. Обычно это делается вручную, но протестировать так поведение всего приложения после каждого изменения невозможно. Мелкие правки способны стать причиной неполадок в самых неожиданных местах. Поэтому важно писать код так, чтобы он мог тестироваться автоматически. Сейчас создание автотестов на Node стало простым как никогда. С помощью модуля node:test можно структурировать и описывать тесты, а модуль node:assert позволяет писать утверждения, чтобы проверять логику работы приложения, и выбрасывать ошибки утверждения, если выявит несоответствия. Проверка кода Несмотря на очевидность, стоит еще раз упомянуть: важно тщательно проверять весь код в проекте, чтобы выявить проблему на раннем этапе и найти наиболее эффективный способ ее решения. Я бы сказал, что каждую строку кода должны проверить как минимум два разработчика.
106  Глава 4. Ошибки и отладка Выводы Во время выполнения программы в Node возникают разные проблемы: одни не зависят от вас, другие можно и нужно обрабатывать. Ошибки представлены в виде объектов — встроенных или настраиваемых. Базовые модули создают их, модулипотребители могут перехватить их и обработать. При возникновении ошибки Node по умолчанию аварийно завершит выполнение кода. Это хорошая практика, поскольку продолжать работу в состоянии неизвестности опасно. Однако если ошибка ожидаемая, работа приложения может быть продолжена. В исключительных случаях ошибку можно перехватить и обработать, не завершая работу приложения. В Node есть разные типы ошибок: стандартные JavaScript, системные, кастомные и ошибки утверждений. В многоуровневых системах ошибки должны передаваться между уровнями. Это можно сделать как через пробрасывание необработанных ошибок, так и через более структурированное перенаправление объектов ошибок. Логирование — базовый инструмент отладки приложений в Node. Для более продвинутой отладки следует обращаться к встроенным в Node инструментам командной строки или Chrome DevTools. Не забывайте использовать инструменты и практики, снижающие риск ошибок: TypeScript, ESLint, валидацию входных данных, тестирование, неизменяемые структуры и код-ревью. В следующей главе речь пойдет об управлении пакетами в Node и возможностях пакетного менеджера.
ГЛАВА 5 Управление пакетами В главе 1 мы вскользь упомянули npm, стандартный менеджер пакетов Node. А теперь давайте разберемся в его работе подробнее и научимся находить, использовать и создавать пакеты для Node. Пакет (package) в мире программного обеспечения — это папка, содержащая код. В Node такая папка имеет файл package.json, в нем указаны метаданные и зависимости пакета. Модуль (module) — это один файл или набор связанных файлов, которые реализуют определенный функционал. Модули позволяют разработчикам структурировать код в отдельные, многократно используемые части. Обычно пакет Node содержит один модуль, но иногда в пакете бывает несколько модулей. Как правило, в пакете содержится внешний код, от которого зависит проект, но, по сути, этот код — универсальный, общий (generic). Вы сами можете определять, какие части написанного вами кода являются универсальными, и выделять их в пакеты для того, чтобы использовать в других проектах. Знакомство с управлением пакетами Если пакеты — это просто папки, зачем нужен менеджер пакетов? Когда таких папок становится слишком много и они зависят друг от друга, следить за ними вручную становится сложно. Особенно это актуально для командной разработки в рамках одного проекта. Инструменты управления пакетами упрощают и систематизируют работу. Они позволяют следить за установкой, обновлением и удалением пакетов с помощью простых команд и гарантируют, что проект будет одинаково корректно работать на всех машинах. Но куда важнее способность таких инструментов разрешать конфликты в дереве зависимостей (dependency tree). «Деревом» его называют потому, что в проекте есть основные зависимости, у них есть свои зависимости и т. д. Такие зависимости (то есть второго и последующих уровней) называют промежуточными или транзитивными (transitive dependency). Чаще всего npm означает инструмент командной строки npm, который поставляется вместе с Node и позволяет управлять ее пакетами. Также у npm есть сайт (https://npmjs.com), где размещен публичный репозиторий с тысячами npm-пакетов
108  Глава 5. Управление пакетами с открытым исходным кодом. Репозиторий npmjs.com — это огромный склад JavaScript-пакетов, в котором можно найти готовые решения для типовых задач и встроить их в проект. Например, если нужно обрабатывать веб-запросы, создавать веб-сокеты или подключаться к базам данных, не нужно реализовывать все с нуля или лезть в низкоуровневый код. Достаточно взять готовое, проверенное временем универсальное решение из репозитория и построить поверх свою уникальную логику. npm долгое время был менеджером пакетов Node по умолчанию, но затем у него появился ряд достойных альтернатив, например Yarn, pnpm, JSR. У каждой из этих утилит есть свои особенности и преимущества. Часто они выигрывают по скорости, занимаемому месту и возможностям в управлении версиями. Эта здоровая конкуренция подтолкнула npm к развитию. В книге мы рассматриваем только npm, но вы можете выбрать для работы любой другой пакетный менеджер. Принципы работы у них примерно одинаковые, отличаются только командный интерфейс и внутренняя реализация. Но можно ли доверять таким готовым решениям? Выбор за вами, но многие из них давно заслужили доверие JS- и Node-сообществ. Все npm-пакеты в репозитории с открытым исходным кодом, и вы можете проверить их сами. Но встречаются также и вредоносные пакеты, так что тщательно подбирайте зависимости для своих проектов и следите за их обновлениями1. Даже надежный пакет можно взломать, поэтому полезно следить за изменениями в коде и активностью вокруг него (сообщения об ошибках, пулл-реквесты и т. д.). По умолчанию npm использует репозиторий npmjs.com, однако npm можно достаточно широко конфигурировать. Например, вы можете указать любой другой репозиторий. Системный подход к управлению зависимостями пакетов крайне важен при командной разработке. С npm все зависимости пакетов указываются в файле package. json, который доступен всем участникам. Это позволяет с легкостью настроить новое окружение или обновить старое, а поскольку все разработчики используют одинаковые версии пакетов проекта, любые конфликты можно обнаружить сразу по мере их появления. Пакеты часто обновляются: исправляются ошибки, добавляются функции и улучшается производительность, а менеджер пакетов позволяет контролировать эти изменения. Чтобы обеспечить совместимость и избежать конфликтов, вы можете указать точные версии пакетов, которые нужно использовать в проекте. Также можно настроить автоматическую установку важных обновлений безопасности. 1 Особенно актуально это в последнее время, когда в распространенные и проверенные пакеты встраивался вредоносный функционал, который запускался, например, при нахождении сервера на территории определенного государства. — Примеч. науч. ред.
Команда npm  109 Проект npm начинался как внутренний инструмент разработки Node и представлял собой небольшой набор скриптов для автоматизации типовых задач разработчиков. Со временем он превратился в полнофункциональный менеджер пакетов, полезный не только для Node, но и для любого кода, написанного на JavaScript. В репозитории npm можно увидеть как пакеты для Node, так и библио­ теки и фреймворки, предназначенные для браузера или мобильных приложений. Особые энтузиасты смогут найти в репозитории примеры пакетов для других сред, где может выполняться JavaScript. В репозитории npmjs.com есть немало бесполезных пакетов. Публиковать пакеты в нем может кто угодно, и контроля качества нет. Не стоит воспринимать наличие пакета в реестре как гарантию его надежности. Всегда проверяйте пакет сами: изучите, где он используется, посмотрите его исходный код. Популярные и поддерживаемые в экосистеме Node пакеты есть на сайте Node.js Toolbox (https://nodejstoolbox.com). Лучшие из них сгруппированы по задачам, так что можно посмотреть все варианты пакетов, например, для отправки электронной почты или загрузки файлов. Команда npm Утилита командной строки npm — мощный инструмент со множеством команд. Чтобы увидеть справку и список всех доступных команд, выполните npm --help $ npm --help npm <command> Usage: npm npm npm npm npm npm npm npm install install <foo> test run <foo> <command> -h -l help <term> help npm install all the dependencies in your project add the <foo> dependency to your project run this project's tests run the script named <foo> quick help on <command> display usage info for all commands search for help on <term> more involved overview All commands: access, adduser, audit, bin, bugs, cache, ci, completion, config, dedupe, deprecate, diff, dist-tag, docs, doctor, edit, exec, explain, explore, find-dupes, fund, get, help, hook, init, install, install-ci-test, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, ping, pkg, prefix, profile, prune, publish, rebuild, repo, restart, root, run-script, search, set, set-script, shrinkwrap, star, stars, start, stop, team, test, token, uninstall, unpublish, unstar, update, version, view, whoami
110  Глава 5. Управление пакетами Не пугайтесь. На самом деле вам не понадобится столько команд. Чаще всего вы будете использовать команды install и update. Также вы будете запускать команды start, test и еще несколько других, в зависимости от проекта. Остальные команды вам будут нужны редко. Ниже я коротко опишу некоторые из них. npm create (рис. 5.1) Настраивает новый или существующий npm-пакет. Это псевдоним команды init, которую мы использовали в главе 1. С ее помощью можно создать файл package.json или запустить инициализатор (например, ESLint или Vite). Инициализатор определяет, как будет сконфигурировано и собрано новое приложение, а также может выполнять другие задачи. Рис. 5.1. npm create npm show <имя_пакета> (рис. 5.2) Показывает информацию о пакете: какая у него лицензия, сколько у него зависимостей, когда он был опубликован и т. д. Без аргументов сообщает все о текущем пакете. npm search <ключевые слова> (рис. 5.3) Осуществляет поиск по репозиторию npm на основе указанного поискового запроса. Можете попробовать, например, npm search lodash.
Команда npm  111 Рис. 5.2. npm show Рис. 5.3. npm search
112  Глава 5. Управление пакетами npm list <имя_пакета> (рис. 5.4) Отображает древовидную структуру установленных пакетов, их зависимостей и версий. Эту команду можно использовать для конкретного пакета или для всего приложения. Распространенный псевдоним — npm ls. Попробуйте добавить параметр --depth=1, чтобы увидеть только первый уровень транзитивных зависимостей. Рис. 5.4. npm list npm link Создает символическую ссылку между локальным пакетом и пакетом в папке node_modules (или установленным глобально). Это позволяет разрабатывать и тестировать пакеты локально без публикации или повторной установки. npm cache clean Очищает кэш npm, что в определенных случаях помогает решить проблемы с некорректной установкой или устаревшими версиями пакетов. npm publish Публикует ваш пакет в репозитории npm, делая его доступным для других пользователей. Дополнительную информацию по любой команде npm можно получить, выполнив npm <имя_команды> -h. Вот краткая справка по команде npm install:
Команда npm  113 $ npm install -h Install a package Usage: npm install npm install npm install npm install npm install npm install npm install npm install npm install npm install [<@scope>/]<pkg> [<@scope>/]<pkg>@<tag> [<@scope>/]<pkg>@<version> [<@scope>/]<pkg>@<version range> <alias>@npm:<name> <folder> <tarball file> <tarball url> <git:// url> <github username>/<github project> Options: [-S|--save|--no-save|--save-prod|--save-dev|--save-optional|.. ... aliases: add, i, in, ins, inst, ... Как видно, команду install можно использовать множеством способов и с различными параметрами. У нее также есть псевдонимы. Вам необязательно запоминать все способы выполнения этих команд, достаточно бегло просмотреть справку, чтобы при необходимости знать, что делать. Это сокращенная версия справки по установке. Полную справку вы получите, выполнив команду npm help install. С помощью справки по npm install можно решить, например, следующие задачи. Установить пакет, опубликованный под скоупом. Скоуп в npm — это способ группировки связанных пакетов одним пространством имен или именем организации. Пример скоупа — @babel, пример пакета под скоупом — core. Установить пакет напрямую с GitHub. Попробуйте установить lodash с GitHub. Проверьте его, перейдя в раздел зависимостей в package.json — у lodash должна быть пометка github. Установить пакет глобально, тем самым сделав его доступным для любого проекта на вашем компьютере. Этот способ часто используется для инструментов командной строки. Например, установив пакет ESLint глобально, вы сможете применять команду eslint в любом проекте. Избегайте глобальной установки пакетов, если без нее можно обойтись. Это действие снижает модульность проектов и может привести к конфликтам версий и, как следствие, к непредсказуемому поведению проектов в разных окружениях.
114  Глава 5. Управление пакетами Семантическое версионирование Команда npm update нужна для обновления пакетов, указанных в файле package. json, до последних версий в пределах заданных ограничений. Чтобы понять, как это работает, нужно разобраться с семантическим версионированием, или SemVer. Семантическое версионирование используется в npm во время обновления пакетов. У каждого пакета есть версия1. Это обязательный параметр. Обычно он указывается в формате SemVer. Вспомним пример из главы 1. Когда мы устанавливали пакет lodash, в секции зависимостей файла package.json появилась такая строка: "lodash": "^4.17.21" «4.17.21» — это строка формата SemVer, своего рода соглашение между автором пакета и его пользователями, описывающее, насколько значительны изменения в коде при выпуске новой версии. Первое число — это номер мажорной версии, который указывает на то, что в коде есть изменения, нарушающие обратную совместимость. Чтобы обеспечить корректную работу своего кода с новым релизом пакета, пользователям придется внести в него изменения. Следующий серьезный релиз lodash приведет к смене первой цифры SemVer с «4» на «5». Второе число — это номер минорной версии, приносящей новые возможности без нарушения обратной совместимости. Оно указывает, что в релиз были внесены новые функции, но старые продолжают работать без изменений. Минорная версия может содержать предупреждения о будущих изменениях API и возможном исключении некоторых компонентов. Обновления минорных версий должны сохранять обратную совместимость, быть безопасными для пользователей и не требовать внесения изменений в проекты, которые их используют. Последнее число — это номер версии патча. Оно указывает, что в релизе были только исправлены выявленные ошибки или улучшена безопасность. Перед строками версий можно увидеть специальные символы. Они обозначают диапазон допустимых версий и используются для уточнения поведения npm при обновлении дерева зависимостей. Например, тильда (~) означает, что можно обновлять только последние патч-версии (третье число в формате SemVer), а карет (^) указывает на более мягкое ограничение: можно обновить до последней минорной версии. Так, если мы установим пакет lodash в то время, как строка SemVer равна «^4.17.21», будет совершена попытка найти последнюю версию SemVer с мажорной строкой «4». Например, установится версия 4.19.1, но ни в коем случае не 5.1.2. 1 Автор путает причину и следствие: пакеты сначала именуются по правилам SemVer, а уже затем это правило позволяет правильно обновлять зависимости. — Примеч. науч. ред.
Обновление и удаление пакетов  115 Есть и другие специальные символы: =, >, >=, <, <=. Если специальный символ не пишется, версия должна строго соответствовать указанной. Использование флага --save-exact (или -E) при установке пакета позволит зафиксировать точную версию зависимости. Для установки самой последней доступной версии пакета можно использовать звездочку (*) в качестве строки формата SemVer. Для обозначения диапазона версий добавляется символ x. Например, 4.x — это любая версия, которая начинается с «4». 4.17.x — это любая версия, начинающая­ ся с «4.17». Также диапазон версий можно задать вручную — с помощью дефиса (-). Например, 4.15.0 - 4.17.0. Подробнее о формате SemVer можно узнать на сайте https://semver.npmjs.com/. Там вы сможете ввести строку версии для конкретного пакета npm и посмотреть все доступные версии, ограниченные этой строкой. Я считаю SemVer великим изобретением. Каждый ответственный разработчик должен следовать заложенным в формате соглашениям при релизе новых версий своих продуктов. Но не стоит воспринимать SemVer как гарантию стабильности приложений. Даже выпуск патча может привести к значительному изменению функционала, потому что оно было реализовано в новых версиях пакетов, от которых зависит приложение, а за минорным обновлением может следовать конфликт нового функционала с уже существующим кодом. Единственный способ убедиться, что код работает, — протестировать его. Обновление и удаление пакетов Когда для пакетов, использующихся в вашем проекте, доступны обновления, можно установить как один пакет, запустив команду npm update <имя_пакета>, так и все дерево зависимостей с помощью команды npm update без параметров. Смоделируем ситуацию, при которой появится обновление пакета. Для этого установим более старую версию lodash, указав нужную нам после символа @: $ npm install lodash@3.9.1 Проверить версию можно с помощью команды npm ls. После запуска команды должна быть установлена версия 3.9.1. Откройте файл package.json. Обратите внимание, что строка версии начинается со специального символа ^. Это позволяет npm обновить пакет до последней доступной минорной версии.
116  Глава 5. Управление пакетами Чтобы узнать, какая версия будет установлена при использовании команды npm update, перед ее выполнением запустите команду npm outdated. Она выведет список всех пакетов, и если для каких-то из них доступно допустимое обновление (в рамках ограничений версии), в колонке Wanted будет указана новая версия. Как показано на рис. 5.5, в колонке Latest также будет указана последняя доступная версия. Рис. 5.5. npm outdated Так как используется ограничение ^, версия в колонке Wanted в этом случае будет 3.10.1. Это последняя версия lodash в рамках мажорной ветки 3. Если заменить ^ на более строгое ~ и выполнить npm outdated, то версия Wanted будет 3.9.3. Это последняя версия в рамках минорной ветки 3.9. Если заменить ~ на >, то при выполнении npm outdated версия Wanted будет соответствовать самой последней доступной. Команда outdated — это пробный прогон, который позволяет проверить, какие пакеты будут обновлены. Она не устанавливает их сама по себе. Для обновления используйте команду npm update. Поработайте с командами outdated, update и ls. Я бы рекомендовал использовать в качестве подопытного кролика ESLint и подобные ему пакеты, у которых есть собственные зависимости. $ npm i eslint@8 Обратите внимание на версию. Число 8 — это мажорная версия. Судя по ее синтаксису, она требует установить последнюю версию ESLint, начинающуюся с 8. Проверьте установленную версию с помощью команды npm ls. А что будет, если изменить строку версии в package.json на более старую? Поменяйте строку версии ESLint на ~7.32.0. Поскольку это ограничение указывает на более раннюю версию, чем установленная, команда npm update понизит версию ESLint. Установленную версию можно проверить с помощью команды npm ls. Команда update обновит все зависимости, включая транзитивные, в соответствии с ограничениями версий, указанными в файлах package.json зависимых пакетов.
Обновление и удаление пакетов  117 Чтобы команда outdated показала все зависимости, которые можно обновить, выполните ее с флагом -a: $ npm outdated -a Допустим, мы решили больше не использовать пакет ESLint. Из package.json его можно удалить вручную, но это не удалит его из папки node_modules. Чтобы убрать пакет отовсюду, запустите команду npm uninstall <имя_пакета>. В данном случае это предпочтительный способ. Однако если кто-то из команды использовал команду uninstall, а после этого вы скачали изменения, вы увидите только, что строка была удалена из package. json. Обычно папка node_modules не добавляется в систему контроля версий. Для синхронизации node_modules с обновленным package.json нужно будет выполнить ряд команд npm. Чтобы смоделировать такую ситуацию, удалите строку eslint из package.json. Теперь у вас есть установленные пакеты, которые, согласно package.json, больше не нужны. Если теперь выполнить npm ls, эти пакеты будут отмечены как лишние (extraneous), как показано на рис. 5.6. Рис. 5.6. Лишние пакеты Для удаления всех неиспользуемых пакетов из проекта вызовите команду npm prune: $ npm prune removed 84 packages, and audited 4 packages in 2s 1 package is looking for funding run `npm fund` for details found 0 vulnerabilities Теперь, если снова выполнить npm ls, лишних пакетов остаться не должно.
118  Глава 5. Управление пакетами Чтобы убедиться, что зависимости проекта синхронизированы с изменениями в package.json, при каждом получении новых изменений запускайте команды prune и install. Однако команда npm install всегда устанавливает самую свежую версию пакета, разрешенную строкой ограничения версии. Это означает, что между тем, как один разработчик добавил зависимость, и тем, как другой ее установил, могла выйти новая версия, и если ограничение, заданное в package.json, позволит это сделать, npm install выберет именно эту, новую, версию, отличную от той, что была у изначального разработчика. Именно поэтому npm автоматически создает и поддерживает в корне проекта файл package-lock.json. Этот файл нужен, чтобы зафиксировать версии пакетов и чтобы все разработчики проекта использовали одни и те же. Это касается как прямых зависимостей, так и транзитивных. Каждый раз при добавлении, обновлении или удалении зависимости npm обновляет package-lock.json, описывая все дерево зависимостей (прямых и транзитивных) с указанием точных версий для установки. Так как package-lock.json должен входить в репозиторий проекта, история его изменений позволяет вернуться к точному состоянию папки node_modules в прошлом. Файл package-lock.json также используется npm для оптимизации работы. Создание и публикация пакетов Создадим и опубликуем простой npm-пакет с функцией printInFrame. Эта функция принимает строку и выводит ее внутри рамки из символов *. Назовем наш пакет print-in-frame. import printInFrame from 'print-in-frame'; printInFrame('Hello World'); При запуске этот код выведет: *************** * Hello World * *************** Прежде всего, для кода этого пакета нужно создать новую папку. Обычно имя папки совпадает с именем пакета, хотя это и необязательно. $ mkdir print-in-frame & cd print-in-frame Затем эту папку нужно превратить в npm-пакет. Для этого создайте файл package. json, например, с помощью команды npm init: $ npm init
Создание и публикация пакетов  119 Ответьте на вопросы или оставьте значения по умолчанию. После создания файла вручную добавьте 'type': 'module', чтобы Node понял, что проект использует только ES-модули. Откройте редактор кода и создайте файл index.js в корне проекта. Чтобы реализовать функцию printInFrame, нам нужно высчитать длину текста и использовать ее для вывода набора символов * до и после текста. Это можно сделать разными способами. Вот как это сделал я: // В файле index.js import times from 'lodash.times'; const printInFrame = (text) => { const frameWidth = text.length + 4; // 2 звездочки + 2 пробела let textToPrint = ''; times(frameWidth, () => (textToPrint = textToPrint + '*')); textToPrint = textToPrint + '\n' + '* ' + text + ' *' + '\n'; times(frameWidth, () => (textToPrint = textToPrint + '*')); console.log(textToPrint); return textToPrint; }; export default printInFrame; Я сделал эту функцию зависимой от lodash.times, которая предоставляет функцию для многократного выполнения переданной операции. Нужно установить зависимость: npm install lodash.times. Чтобы использовать пакет print-in-frame в Node-проекте, сначала его нужно установить — напрямую из файловой системы. Допустим, наш Node-проект расположен в том же каталоге, что и папка print-in-frame. Тогда в этом другом проекте мы можем выполнить следующее: $ npm install ../print-in-frame Хотя это рабочий способ, если вы поделитесь этим кодом, придется отправлять вместе с проектом и папку print-in-frame. Чтобы хранить их отдельно, нужно опубликовать пакет в репозитории npm. Если вы хотите опубликовать пакет в репозитории npmjs.com, нужно в нем зарегистрироваться. Используйте npm login для авторизации вашего локального npm-клиента. Будут запрошены ваше имя пользователя и пароль. Имя пакета в репозитории должно быть уникальным, поэтому добавьте к его имени префикс, чтобы не было конфликта. Я изменил name в package.json на
120  Глава 5. Управление пакетами samer-print-in-frame. Добавьте также описание пакета. Это необязательно, но так другим пользователям будет проще его найти. Когда будете готовы, выполните команду npm publish. Если все сработало, пакет появится на сайте репозитория и вы сможете найти его с помощью UI-поиска или команды npm search. После публикации пакета установите его в свой основной Node-проект командой npm install PREFIX-print-in-frame, где вместо «PREFIX» надо вставить тот префикс, который вы использовали, когда меняли имя файла. А теперь проверьте вывод, выполнив команду npm ls. На экране должны появиться две новые зависимости: PREFIX-print-in-frame и lodash.times. Чтобы обновлять пакет и тестировать изменения в проекте до публикации новой версии, можно воспользоваться командой npm link. С ее помощью можно временно подключить локальную версию пакета вместо версии из реестра. В папке printin-frame выполните npm link; затем в основном проекте — npm link PREFIX-printin-frame. Теперь можно переделывать локальный пакет и тестировать сразу в основном проекте. После завершения внесите изменение версии в package.json и снова выполните npm publish. В пакете print-in-frame я использовал ES-модули, то есть пакет будет работать только в проектах с ES-модулями. Если же вы хотите покрыть любой проект на Node, потребуется также версия на CommonJS. Для автоматизации таких задач можно использовать, например, Babel или TypeScript. Скрипты npm Run Скрипты — это фича npm, которая позволяет разработчикам с легкостью выполнять или автоматизировать типовые задачи: сборку, тестирование, деплой и т. п. Скрипт можно определить в разделе scripts файла package.json. При выполнении npm init автоматически добавляется пример скрипта: "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, Этот скрипт можно выполнить, запустив команду npm run test. Некоторым распространенным именам скриптов (test, start, stop) соответствует короткая команда-псевдоним. В данном случае достаточно написать npm test. Если запустить npm run без аргументов, команда выведет все скрипты, определенные в проекте.
Скрипты npm Run  121 Пример запускаемого тестового скрипта просто выводит сообщение об ошибке, но обратите внимание, что он применяет команды оболочки echo и exit. Можно использовать любые доступные shell-команды. Например, сделать скрипт ls -al или npm ls | grep 'extraneous'. Второй вариант — хороший пример того, как с помощью скрипта можно упростить задачу проекта и документировать ее для других участников, которые о ней не знают. Но какое ей дать имя, чтобы она была понятна всем? Например, подойдет list-unused-packages: "scripts": { ... "list-unused-packages": "npm ls | grep 'extraneous'" }, Теперь разработчик, который не знает об extraneous-метке, может изучить скрипт и понять, как вывести неиспользуемые пакеты списком. Для этого ему нужно будет всего лишь выполнить команду npm run list-unused-packages. Это особенно важно при публикации для других разработчиков. Лучший способ объяснить им, что делать с пакетом, — это написать npm-скрипты. Скрипты помогают автоматизировать выполнение задач. Например, если вам нужно постоянно запускать все интеграционные тесты, вы сможете легко сделать это с помощью скриптов. Но куда важнее то, что npm-скрипты обеспечивают согласованность между всеми разработчиками проекта. Более того, если способ запуска интеграционных тестов потребуется изменить, вам не придется сообщать об этом в чате. Достаточно будет исправить package.json, и запись об этом навсегда сохранится в истории изменений проекта. Автоматизацию даже можно сделать официальной — и настроить запуск одних задач до или после выполнения других. Например, я частенько забываю ввести npm prune && npm install после обновления кода, перед тем как делать тестирование. Но можно добавить npm-скрипт, который будет автоматически выполнять очистку и установку перед тестами. Для этого в имени скриптов используют префиксы pre или post. В примере ниже мы можем определить сценарий предварительного тестирования для удаления/ установки: "scripts": { ... "pretest": "npm prune && npm install" }, Это особенный скрипт. После его добавления при каждом запуске npm test перед началом выполнения тестов будут автоматически срабатывать команды prune/ install. Для этого подойдет любое имя скрипта. Если у вас есть имя скрипта dosomething, вы можете определить сценарии predosomething и postdosomething для выполнения задач до или после запуска dosomething.
122  Глава 5. Управление пакетами Этот способ отлично решает множество задач. Например, можно запускать тесты, проводить форматирование/линтинг или генерацию документации автоматически при каждом пуше кода. У npm-скриптов есть еще одна классная фишка: они могут запускать инструменты, установленные в node_modules, без явного указания пути. npm сам найдет путь к командам. Для примера, запустите npm i eslint в папке проекта. Теперь, если вы попытаетесь выполнить команду eslint в папке проекта, она будет недоступна. Эта команда находится внутри node_modules, но npm не делает ее общедоступной. Однако npmскрипты видят эти команды. Для проверки добавьте скрипт: "scripts": { ... "lint": "eslint" }, Теперь можно выполнить npm run lint, и npm найдет и запустит eslint. Если вы укажете аргументы, npm передаст их вызываемой программе, но поскольку его команды тоже принимают аргументы, нужно использовать разделитель --. Например, чтобы посмотреть справку eslint, выполните команду: $ npm run lint -- --help Та же команда без разделителя -- выведет справку по скрипту npm run. Аргументы до -- обрабатываются самой командой npm run. Я специально назвал скрипт lint, а не eslint. Для npm-скриптов лучше подходят обобщенные названия. Возможно, в будущем мы будем использовать иной инструмент для линтинга. Смена имени скрипта может сломать обратную совместимость, особенно если скрипты используются для автоматизации. Команда npx Запускать инструменты командной строки, установленные в проекте, можно также с помощью команды npx (что означает node package execute, «выполнение Nodeпакета»). Например, команда npx eslint --help сработает всегда, даже если пакет ESLint не установлен в проекте. Если пакет установлен, команда npx будет использовать локальную копию из папки node_modules. Если нет, npx автоматически загрузит его временную копию и использует ее для выполнения запрошенной команды. Посмотрите, как работает команда npx, на примере eslint. Сначала удалите его командой npm uninstall eslint, затем запустите npx eslint –help, и увидите, как npx загрузит и выполнит команду.
Команда npx  123 Как и в случае с npm, npx можно использовать с указанием конкретной версии. Допустим, вам нужно выяснить, какие из параметров eslint (которые вы можете увидеть на странице справки) существовали на ранней стадии, начиная с первой доступной версии eslint. С помощью команды npm show можно узнать самую раннюю доступную версию eslint: $ npm show eslint versions Чтобы посмотреть справку по версии eslint 0.4.0 , выполните команду npx eslint@0.4.0 –help (рис. 5.7). Рис. 5.7. Использование npx с указанием версии пакета Команда npx часто используется для начальной генерации проекта на основе шаб­ лона. С помощью npx можно сгенерировать рабочее React-приложение, выбрав один из поддерживаемых шаблонов: $ npx create-react-app your-app-name-here Программа не только загрузит временную копию пакета create-react-app, но и распознает, что это пакет-генератор с командой по умолчанию для создания проекта. Она выполнит эту команду по умолчанию1. 1 React-команда официально прекратила поддержку create-react-app 14 февраля 2025 года (https://github.com/facebook/create-react-app). Она предлагает создавать свое первое приложение на основе команды npx create-next-app@latest my-app –yes. — Примеч. науч. ред.
124  Глава 5. Управление пакетами Генераторы могут поддерживать несколько различных команд. Чтобы посмотреть пример такой работы, ознакомьтесь со страницей справки по генератору @vue/cli1: $ npx @vue/cli --help Выводы Менеджер пакетов npm — важная часть работы с проектами на Node. Он предоставляет простой и стандартный способ управления внешними зависимостями проекта, поддерживая их в актуальном, согласованном и бесконфликтном состоянии. Файлы package.json и package-lock.json автоматически модифицируются npm при каждом изменении дерева зависимостей проекта. Помимо команды npm также есть команда npx, которая позволяет запускать локальные или удаленные CLI-инструменты. В следующей главе мы узнаем, как работать с потоками в Node, чтобы эффективно обрабатывать большие объемы данных, не перегружая процесс. 1 Vue CLI находится в режиме поддержки. Новые фичи не будут имплементироваться. Для новых проектов теперь рекомендуется использовать create-vue для создания проектов на основе Vite. — Примеч. науч. ред.
ГЛАВА 6 Потоки данных Потоки данных (data flows) в Node долгое время считались сложными для понимания и работы. Возможно, в первые годы существования Node так и было, но ситуация изменилась, и создавать и использовать потоки в Node стало относительно легко. Для работы с потоками данных в Node можно даже применять встроенные в JavaScript асинхронные итераторы и генераторы. В этой главе я объясню, что такое потоки данных, зачем они нужны, а также как их создавать, использовать и комбинировать, чтобы эффективно обрабатывать большие объемы данных, не перегружая память, доступную процессу Node. Что такое потоки данных? Когда вы скачиваете что-то из Интернета, смотрите фильм или слушаете музыку, вы используете потоки данных. Контент поступает к вам отдельными блоками, их также называют чанками (chank). По сути, потоки — это наборы данных, очень похожие на массивы или строки. Но вместо того, чтобы хранить все сведения в памяти, потоки обрабатывают их постепенно. С помощью потоков можно обрабатывать очень большие объемы данных, используя ограниченный объем памяти. Аналогии с потоками данных можно найти в повседневной жизни. Например, если у вас в раковине полно грязной посуды, ее можно загрузить в посудомойку — так вы обработаете всю посуду сразу, прямо как данные, которые хранятся в массиве. Если же у вас нет посудомойки, придется обработать посуду так же, как работают потоки: брать одну тарелку, мыть ее, ополаскивать и переходить к следующей. И этот способ отлично работает, если грязной посуды мало. Но если ее много, разделите задачу на два потока: один человек будет мыть посуду, а другой ополаскивать. Так каждый будет выполнять свою задачу, но ввод одного процесса (ополаскивание) будет зависеть от вывода другого (мытье). Также в процесс можно включить дополнительные потоки, например вытирание посуды и ее уборку в шкаф. Комбинирование потоков повысит эффективность работы. Когда вывод одного потока используется как ввод другого, это называется пайпинг (piping). Термин заимствован из семейства операционных систем Unix, где пайпы применяются аналогичным образом для передачи данных от одного процесса другому.
126  Глава 6. Потоки данных Давайте рассмотрим пример использования пайпов в Linux. Допустим, вам поручили подсчитать, сколько раз слово require встречается во всех файлах большого проекта. В Linux это делается с помощью команды grep, которая ищет шаблоны в файлах, и команды wc, которая подсчитывает строки, слова и символы. Мы можем брать выходные данные команды grep и передавать их в качестве входных данных команды wc: $ grep -wR require * | wc -l Linux с помощью пайпов собирает из простых команд более мощные, а возможность перенаправлять потоки делает их компонуемыми. Если бы в Node были аналоги потоков данных, принадлежащих командам grep и wc, их можно было бы объединить аналогичным образом: const grep = ... // Поток для вывода команды grep const wc = ... // Поток для ввода команды wc grep.pipe(wc) А если выразить аналогию с посудой в псевдокоде, то получится так: const scrub = ... // Поток вывода мытой посуды const rinse = ... // Поток ввода посуды на протирку scrub.pipe(rinse) О методе pipe мы подробнее расскажем далее в этой главе. Прежде чем закончить аналогию с посудой, разберем еще несколько концептов, связанных с потоками данных. В Node есть два основных типа потоков: читаемые (readable) и записываемые (writable). Некоторые потоки могут совмещать обе эти задачи, их называют дуплексными (duplex). Читаемый поток генерирует вывод, записываемый поток принимает ввод. В нашей аналогии процесс мытья — читаемый (он выдает чистую посуду), процесс полоскания — записываемый (ему нужен ввод чистой посуды). В Node метод pipe вызывается на читаемом потоке и принимает записываемый: readableStream.pipe(writableStream) Еще одна важная концепция, в которой обязательно нужно разобраться, — непрерывность и равномерность потока данных. Если два человека, которые моют посуду, работают в одном темпе, то поток от грязной до вымытой, а потом и до полностью чистой посуды идет непрерывно и равномерно. Скажем, если тот, кто моет посуду, наткнется на очень грязную тарелку, тому, кто ополаскивает, придется приостановить работу. А если тот, кто ополаскивает посуду, будет работать
Как использовать потоки данных?  127 медленнее, стопка вымытой посуды рано или поздно станет такой большой, что останавливаться придется тому, кто моет. Это пример проблемы согласования скоростей при работе с потоками, и их мы также разберем в этой главе. Как использовать потоки данных? Чтобы лучше понять, насколько эффективны потоки данных, обратимся к конкретным цифрам. Выполним одну и ту же задачу с потоками и без них и сравним объемы памяти, которая для этого используется. Нам нужен большой файл, потому что потоки созданы для обработки больших объемов данных. Вот простой способ создать большой файл в Node: import fs from 'node:fs'; import { randomBytes } from 'node:crypto'; const file = fs.createWriteStream('./big.file'); for (let i = 0; i <= 1e6; i++) { file.write( randomBytes(200).toString('hex') ); } file.end(); Обратите внимание: для создания большого файла я использовал записываемый поток! Модуль node:fs позволяет читать и записывать файлы через потоковый интерфейс. Мы создавали файл по частям — в цикле, в каждой итерации которого стримили одну строку в файл, и повторили это миллион раз. При выполнении этого кода получился файл размером 381 Мб. А теперь, допустим, нужно, чтобы этот файл можно было скачать с веб-сервера Node. Вот пример сервера, который отдает big.file полностью: import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; const server = createServer(); server.on('request', async (req, res) => { const data = await readFile('./big.file'); res.end(data); }); server.listen(3000, () => { console.log('Server is running...'); });
128  Глава 6. Потоки данных Получив запрос, сервер отдает содержимое файла методом fs.readFile. Для этого не потребовалось использовать потоки данных, а раз код асинхронный, стек вызовов не блокируется. Так в чем проблема? Давайте посмотрим, что будет, если запустить сервер: подключимся к нему и понаблюдаем за потреблением памяти. Сразу после запуска сервер потреблял нормальный для процесса объем памяти — примерно 17,8 Мб (рис. 6.1). Рис. 6.1. Начальная память серверного процесса Но после подключения к серверу потребление памяти резко выросло — до 402,4 Мб (рис. 6.2). Рис. 6.2. Память серверного процесса после запроса Фактически мы загрузили все содержимое big.file в память перед тем, как отправить его в ответ на запрос. Но это очень неэффективно. Будет намного лучше, если мы выполним эту работу с помощью потоков.
Как использовать потоки данных?  129 К счастью, под капотом большинства встроенных модулей Node используются потоки данных, и модуль node:http — один из них. Объект res в обработчике событий request — это записываемый поток! И мы можем использовать его для потоковой передачи данных клиенту вместо того, чтобы отправлять их все разом. Все, что для этого нужно, — читаемый поток, который будет представлять содержимое big. file. Тогда с помощью метода pipe мы сможем отправлять файл по одному чанку за раз, не занимая при этом 400 Мб памяти. Модуль node:fs может дать нам читаемый поток для любого файла с помощью метода createReadStream. Вот как мы можем использовать его вместе с объектом потока res: import { createServer } from 'node:http'; import { createReadStream } from 'node:fs'; const server = createServer(); server.on('request', (req, res) => { const src = createReadStream('./big.file'); src.pipe(res); }); server.listen(3000, () => { console.log('Server is running...'); }); Чтобы проверить результат, запустите сервер, подключитесь к нему, начните скачивать файл и следите за использованием памяти процесса во время передачи (рис. 6.3). Рис. 6.3. Память процесса сервера при использовании потоков В этой версии веб-сервера при запросе файла мы стримим его по чанкам, то есть не буферизуем в памяти весь файл целиком.
130  Глава 6. Потоки данных Можно задействовать все возможности способа, использованного в примере выше. Для этого создайте big.file заново, но увеличьте количество строк до 5 миллио­ нов. Размер файла превысит 2 Гб, что больше размера буфера памяти Node по умолчанию. Обработать такой большой файл через fs.readFile с настройками по умолчанию не получится (хотя лимиты можно изменить). Но с fs.createReadStream проблем не будет: можно отправить клиенту и 2 Гб, и больше, при этом потребление памяти процесса будет приблизительно таким же. Базовые знания о потоках данных Выше я писал, что потоки данных бывают читаемыми, записываемыми или дуплексными (двунаправленными). Читаемый поток — абстракция источника, из которого данные потребляются (пример — fs.createReadStream). Записываемый поток — абстракция приемника, в который данные записываются (пример — fs.createWriteStream). Поток данных, способный как отправлять, так и принимать данные, называется дуплексным. Такие потоки полезны, когда данные могут течь в обе стороны, например в сетевых соединениях с использованием TCP-сокетов. Трансформирующий поток — это дуплексный поток данных, который может модифицировать их при передаче. В качестве примера приведу zlib.createGzip, который сжимает данные в потоковом режиме с помощью gzip. Представьте, что трансформирующий поток — это функция, ее аргумент — записываемая часть дуплексного потока, а результат выполнения — читаемая часть. Многие встроенные модули Node реализуют интерфейс потоков данных. Я собрал несколько примеров такой реализации в таблице 6.1. Таблица 6.1. Примеры объектов-потоков Читаемые потоки Записываемые потоки HTTP-запросы HTTP-ответы fs — читающие потоки fs — записывающие потоки zlib-потоки zlib-потоки crypto-потоки crypto-потоки TCP-сокеты TCP-сокеты stdout & stderr дочерних процессов stdin дочернего процесса process.stdin process.stdout, process.stderr
Метод pipe  131 Обратите внимание: объекты потоков в таблице 6.1 связаны между собой. Также стоит отметить, что stdio-потоки (stdin, stdout, stderr) для дочерних процессов «перевернуты» по типам — это и позволяет с легкостью перенаправлять как в, так и из stdio дочернего процесса, используя потоки stdio основного процесса. Подробнее о дочерних процессах мы поговорим в главе 7. Все потоки — экземпляры класса EventEmitter. Они генерируют события, по которым можно читать или записывать данные. Однако гораздо проще читать данные из потока с помощью метода pipe. Метод pipe Метод pipe в Node соединяет два потока данных: читаемый источник и записываемый приемник. Ниже — ключевая строка, которую стоит запомнить: readableSrc.pipe(writableDest); В этой строке мы перенаправляем вывод читаемого потока на вход записываемого. Источник должен быть читаемым, а приемник — записываемым. Конечно, оба могут быть и дуплексными, и трансформирующими. На практике, имея дело с дуплексными потоками данных, мы можем объединять их в цепочки подобно тому, как это делается в Linux: readableSrc .pipe(transformStream1) .pipe(transformStream2) .pipe(finalWritableDest); Метод pipe возвращает поток-приемник, что позволяет продолжать цепочку, если приемник дуплексный. Допустим, у нас есть несколько потоков: a (читаемый), b и c (дуплексные) и d (записываемый). Их можно объединить следующим образом: a.pipe(b) .pipe(c) .pipe(d); // Что эквивалентно выражениям: a.pipe(b); b.pipe(c); c.pipe(d); // Эквивалент в Linux: // $ a | b | c | d Метод pipe — самый простой способ использования потоков: он берет на себя контроль над скоростью и равномерностью получения данных, чтобы приемник
132  Глава 6. Потоки данных не был перегружен данными из читаемого источника. При использовании прочих методов чтения и записи потоков эти аспекты взаимодействия необходимо контролировать вручную. Обычно при использовании метода pipe достаточно лишь перехватывать возникающие ошибки. Но прочие методы и события позволяют нам тонко контролировать обмен данными в потоках. Удобнее другой способ управления несколькими потоками данных — функция pipeline. Ее можно использовать как с коллбэками, так и с промисами: import { pipeline } from 'node:streams/promises'; await pipeline( readableSrc, transformStream1, transformStream2, finalWritableDest ); Рipeline — это существенное улучшение по сравнению с .pipe(), которое обеспе- чивает надежную обработку ошибок из всех потоков, автоматическую очистку ресурсов, поддержку современных Promise API, лучшую читаемость кода, безопасность типов. Использование pipeline делает код более надежным, читаемым и поддерживаемым, особенно в production-средах, где обработка ошибок критически важна. pipeline следует оборачивать в блок try/catch, чтобы отловить ошибки, вы- брошенные любым потоком в цепочке. События потоков данных Методы потоков, такие как read и write, можно использовать вместе со слушателями событий для получения данных из потоков. Вот упрощенный эквивалент того, что метод pipe делает при чтении или записи: // readable.pipe(writable) readable.on('data', chunk => { writable.write(chunk); }); readable.on('end', () => { writable.end(); });
События потоков данных  133 В таблице 6.2 приведены важные события и методы для читаемых и записываемых потоков. Таблица 6.2. Важные события и методы для потоков Читаемые потоки Записываемые потоки События data, end, error, close, readable drain, finish, error, close, pipe, unpipe Методы pipe(), unpipe(), wrap(), destroy() write(), destroy(), end() read(), unshift(), resume(), pause(), setEncoding() cork(), uncork(), setDefaultEncoding() События и методы в таблице 6.2 связаны между собой и обычно применяются в сочетании. События и методы позволяют гибко выстраивать работу с потоками. Данные из читаемого потока можно получать через pipe/unpipe или read/unshift/resume. Записываемый поток — сделать целью pipe/unpipe либо писать в него через write и вызвать end в конце. Если не использовать pipe/unpipe, придется работать через события потоков. Важнейшие события читаемого потока: data Возникает, когда поток отдает очередной чанк данных потребителю. end Возникает, когда данных больше нет. Важнейшие события записываемого потока: finish Возникает, когда все данные сброшены в нижележащую систему. drain Сигнал, что записываемый поток готов принять следующую порцию данных. Событие drain важно: оно позволяет получить сбалансированный поток данных. Скорость, с которой данные подаются или потребляются потоком, называется давлением потока. Если записываемый поток обрабатывает данные медленнее, чем читаемый поток их «подает», в записываемом потоке начинает накапливаться буфер — возникает «обратное давление».
134  Глава 6. Потоки данных Пока потребление памяти записываемого потока находится в допустимых пределах, метод write возвращает true. После достижения лимита метод write вернет false — это сигнал, требующий остановить запись данных до возникновения события drain. Событие error поток данных может выбросить в любой момент — его всегда надо обрабатывать, даже при использовании pipe: readable.pipe(writable); readable.on('error', (err) => { // Обработка возможных ошибок чтения }); writable.on('error', (err) => { // Обработка возможных ошибок записи }); Коллбэки — обработчики ошибок принимают в качестве аргумента только объект Error. По этому объекту можно различать и по-разному обрабатывать разные ошибки. Приостановленный и потоковый режимы В читаемых потоках есть два основных режима, которые влияют на способ их потребления: приостановленный (paused) или потоковый (flowing). Их иногда называют режим pull и режим push. Все читаемые потоки по умолчанию стартуют в приостановленном режиме, но при необходимости их можно легко переключить на потоковый и обратно. Иногда переключение происходит автоматически. Когда читаемый поток находится в приостановленном режиме, мы можем вызвать метод read(), чтобы получать данные по требованию. В потоковом режиме данные поступают непрерывно: их нужно потреблять через обработчики событий. В потоковом режиме данные могут быть потеряны, если у них нет потребителей. Поэтому в нем читаемому потоку нужен обработчик события data. Вообще добавление обработчика события data автоматически переводит поток из приостановленного в потоковый режим, а удаление этого обработчика возвращает обратно в приостановленный. Отчасти это поведение было сохранено ради обратной совместимости со старым интерфейсом потоков в Node. Для ручного переключения между режимами используют методы resume() и pause(). Не нужно переживать по поводу режимов при потреблении данных из читаемых потоков с помощью методов pipe и pipeline. В этом случае управление режимами происходит автоматически.
Имплементация потоков  135 Имплементация потоков Когда речь заходит о потоках в Node, выделяют две основные задачи: имплементация потоков; потребление потоков. До сих пор мы говорили только о потреблении потоков. А теперь давайте займемся их реализацией! Модули, имплементирующие потоки, обычно импортируют модуль stream. Записываемые потоки Для реализации записываемых потоков используют конструктор Writable из модуля stream: import { Writable } from 'node:stream'; Сделать это можно по-разному. Например, расширить класс Writable: class myWritableStream extends Writable { // ... } Однако проще создать объект через конструктор Writable и передать ему несколько опций. Единственная обязательная опция — функция write, которой передается чанк данных для записи. import { Writable } from 'node:stream'; const outStream = new Writable({ write(chunk, encoding, callback) { console.log(chunk.toString()); callback(); } }); process.stdin.pipe(outStream); Метод write принимает три аргумента: Аргумент chunk Передаваемая часть данных. Обычно это объект буфера Node, но может быть и строка. Аргумент encoding Нужен, если chunk — строка с кодировкой, отличной от utf8. В остальных случаях его можно опустить.
136  Глава 6. Потоки данных Аргумент callback Функция, вызываемая после обработки чанка: как успешной, так и с ошибками. Вызов этой функции с объектом Error в качестве аргумента — сигнал об ошибке обработки. В этом примере мы просто выводим преобразованный в строку чанк в консоль, а затем вызываем коллбэк, не передавая ему объект Error в качестве аргумента (тем самым сигнализируя об успехе). Это простейший эхо-поток. Он лишь отдает то, что получает. Созданный поток мы можем использовать, например, в связке со входным потоком данных процесса process.stdin (это читаемый поток), просто запайпив данные из него в наш outStream. При запуске кода все, что вводится в process.stdin , будет выведено обратно в консоль через outStream. Объект outStream, по сути, похож на встроенный объект process.stdout: мы можем перенаправить stdin в stdout и получить аналогичный результат: process.stdin.pipe(process.stdout); Читаемые потоки Чтобы реализовать читаемый поток, используют конструктор Readable из модуля stream: import { Readable } from 'node:stream'; const inStream = new Readable(); Существует простой способ, как этого достичь. Мы можем пушить (передавать) данные, которые хотим сделать доступными потребителям потока, напрямую: // ... inStream.push('ABCDEFGHIJKLM'); inStream.push('NOPQRSTUVWXYZ'); inStream.push(null); // Данные закончились inStream.pipe(process.stdout); Когда мы пушим объект null, это значит, что мы подаем сигнал о том, что данных в потоке больше нет. Чтобы вычитать данные из потока, мы запайпили его в записываемый поток process.stdout. При запуске все данные из inStream будут выведены в стандартный вывод. Это очень простой, но неэффективный способ, поскольку фактически все данные будут
Имплементация потоков  137 передаваться в поток одновременно, а затем направляться в process.stdout. Данные лучше отдавать по требованию, когда от пользователя поступает запрос. Для этого при создании читаемого потока реализуют метод read(): const inStream = new Readable({ read(size) { // Ожидаем данные... // Кто-то хочет их прочитать. } }); Когда у читаемого потока вызывается метод read, реализация может запушить часть данных в очередь. Например, их можно добавлять по одной букве, начиная с кода 65 (A) и увеличивая значение кода при каждом пуше: const inStream = new Readable({ read(size) { this.push(String.fromCharCode(this.currentCharCode++)); if (this.currentCharCode > 90) { this.push(null); } }, }); inStream.currentCharCode = 65; inStream.pipe(process.stdout); До тех пор пока потребитель читаемого потока будет читать данные, метод read будет вызываться и пушить в поток другие буквы. Нужно, чтобы этот цикл в какойто момент остановился, поэтому в примере я использовал конструкцию if, чтобы запушить null, когда значение currentCharCode будет больше 90 (то есть после буквы Z). Этот код эквивалентен простому примеру выше, но теперь данные отправляются по требованию (рис. 6.4). Это предпочтительный подход. Если источник данных — строка, массив или любой итерируемый объект, можно создать из него читаемый поток через Readable.from: // Преобразовать строку в читаемый поток Readable.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); Чтобы больше данных было доступно для чтения в созданном потоке, можно использовать функции-генераторы JavaScript совместно с Readable.from и ключевым словом yield. Например: function* generate() { for (let index = 65; index <= 90; index++) { yield String.fromCharCode(index); } }
138  Глава 6. Потоки данных // Чтобы создать поток из полученных значений: Readable.from(generate()); Рис. 6.4. Читаемый поток Дуплексные/трансформирующие потоки С помощью дуплексных потоков мы можем реализовать одновременно и читаемый, и записываемый поток в одном объекте. Это похоже на наследование от двух интерфейсов сразу. Вот пример дуплексного потока, который объединяет предыдущие примеры записываемого и читаемого потоков: import { Duplex } from 'node:stream'; const inoutStream = new Duplex({ write(chunk, encoding, callback) { console.log(chunk.toString()); callback(); }, read(size) { this.push(String.fromCharCode(this.currentCharCode++)); if (this.currentCharCode > 90) { this.push(null);
Имплементация потоков  139 } }); } inoutStream.currentCharCode = 65; process.stdin.pipe(inoutStream).pipe(process.stdout); Объединив эти методы, мы можем применять дуплексный поток для чтения букв от A до Z, а также для эхо-функции. Чтобы использовать эхо-функцию, мы пайпим читаемый поток stdin в этот дуплексный поток, а чтобы увидеть буквы от A до Z, сам дуплексный поток направим в записываемый поток stdout. Важно понимать, что читаемая и записываемая части дуплексного потока работают полностью независимо друг от друга. Две функции просто объединяются в одном объекте. Трансформирующий поток — это более интересная разновидность дуплексного потока, выходной поток данных которого является обработанным входным. Для трансформирующего потока нам не нужно реализовывать методы read или write; достаточно задействовать метод transform, который их объединяет. У него та же сигнатура1, что у метода write, и мы также можем использовать его для пушинга данных. Вот простой трансформирующий поток, который возвращает введенный текст, преобразуя его в верхний регистр: import { Transform } from 'node:stream'; const upperCaseTr = new Transform({ transform(chunk, encoding, callback) { this.push(chunk.toString().toUpperCase()); callback(); } }); process.stdin.pipe(upperCaseTr).pipe(process.stdout); В этом трансформирующем потоке, который мы используем так же, как и в предыдущем примере с дуплексным потоком, реализован только метод transform(). В нем мы преобразуем полученный фрагмент данных в верхний регистр и отправляем его как читаемую часть потока (рис. 6.5). 1 Сигнатура — это объявление функции или метода, а также его параметры. Сигнатура содержит информацию о том, какие аргументы передаются в функцию или метод, их типы и названия, а также сведения о возвращаемых значениях. У методов transform и write одинаковый набор аргументов и одинаковый тип результата исполнения. — Примеч. науч. ред.
140  Глава 6. Потоки данных Рис. 6.5. Трансформирующий поток Асинхронные генераторы и итераторы Теперь мы знаем, как функцию-генератор можно использовать для создания читае­ мого потока. То же самое можно сделать с асинхронными итераторами: for await работает внутри асинхронной функции-генератора и применяется для создания читаемого потока. Вот пример читаемого потока на основе запросов к API GitHub: import { Readable } from 'node:stream'; async function* ghRepos() { const response = await fetch('https://api.github.com/users'); const users = await response.json(); } for (let index = 0; index < 10; index++) { const reposResponse = await fetch(users[index].repos_url); yield await reposResponse.json(); } async function* ghFirsts() { for await (const repos of ghRepos()) {
Асинхронные генераторы и итераторы  141 } } if (repos[0]) { yield repos[0].full_name + `\n`; } Readable.from(ghFirsts()).pipe(process.stdout); Чтобы лучше понять этот пример, откройте в браузере https://api.github.com/ users, найдите поле repos_url у любого пользователя и перейдите по этой ссылке. Функция ghRepos получает список пользователей, а затем поочередно запрашивает и возвращает список репозиториев для первых десяти. Функция ghFirsts используется для того, чтобы возвращать имя первого репозитория из списка, полученного от функции ghRepos. Так как функция ghFirsts — асинхронный генератор, мы можем использовать ее для создания читаемого потока, где каждый фрагмент данных — это имя первого репозитория каждого пользователя. Метод pipeline даже поддерживает асинхронные функции-генераторы в цепочке обработки данных. Вместо последней строки (где мы использовали .pipe) можно включить в вызов pipeline вызов ghFirsts: // Вместо: // Readable.from(ghFirsts()).pipe(process.stdout); await pipeline(ghFirsts(), process.stdout); Результат вы можете увидеть на рис. 6.6. Метод pipeline также передает объект потока в асинхронный генератор, который идет следующим в цепочке, позволяя ему работать как трансформирующий поток. Вот пример преобразования текста в верхний регистр с помощью функции асинхронного генератора: import { pipeline } from 'node:stream/promises'; async function* upperCaseTr(source) { for await (const chunk of source) { yield String(chunk).toUpperCase(); } } await pipeline( process.stdin, upperCaseTr, process.stdout );
142  Глава 6. Потоки данных Рис. 6.6. Поток, читаемый асинхронным генератором Читаемые потоки можно обрабатывать и с помощью асинхронного итератора: for await (const chunk of readableStream) { console.log(chunk); } Объектный режим потоков По умолчанию потоки ожидают значения в виде буферов или строк. Существует флаг objectMode, который можно установить, чтобы поток принимал любые объекты JavaScript. Продемонстрирую это на простом примере. Следующая комбинация трансформирующих потоков позволяет преобразовать строку со значениями, разделенными запятыми, в объект JavaScript. Например, a,b,c,d превратится в {a: b, c: d}: import { Transform } from 'node:stream'; const commaSplitter = new Transform({ readableObjectMode: true,
Объектный режим потоков  143 transform(chunk, encoding, callback) { this.push(chunk.toString().trim().split(',')); callback(); }, }); const arrayToObject = new Transform({ readableObjectMode: true, writableObjectMode: true, transform(chunk, encoding, callback) { const obj = {}; for (let i = 0; i < chunk.length; i += 2) { obj[chunk[i]] = chunk[i + 1]; } this.push(obj); callback(); }, }); const objectToString = new Transform({ writableObjectMode: true, transform(chunk, encoding, callback) { this.push(JSON.stringify(chunk) + '\n'); callback(); }, }); Эти трансформирующие потоки можно использовать с методом pipeline: await pipeline( process.stdin, commaSplitter, arrayToObject, objectToString, process.stdout, ); Мы передаем входную строку (например, a,b,c,d ) через commaSplitter , который отправляет массив как читаемые данные (["a", "b", "c", "d"] ). Флаг readableObjectMode в этом потоке обязателен, потому что мы пушим объект, а не строку. Затем мы берем этот массив и направляем его в поток arrayToObject. Чтобы поток мог принимать объект, нужен флаг writableObjectMode. Он также будет отправлять объект (входной массив, преобразованный в объект), поэтому здесь нам потребуется флаг readableObjectMode . Последний поток objectToString принимает объект, но возвращает строку, поэтому здесь нужен только флаг writableObjectMode. Читаемая часть — это обычная строка (объект stringified; рис. 6.7).
144  Глава 6. Потоки данных Рис. 6.7. Режим потокового объекта Встроенные трансформирующие потоки В Node есть несколько полезных встроенных трансформирующих потоков, например потоки модулей zlib и crypto. Вот пример, где поток zlib.createGzip() используется вместе с читаемыми/ записываемыми потоками модуля node:fs для создания скрипта сжатия файла: import fs from 'node:fs'; import zlib from 'node:zlib'; import { pipeline } from 'node:stream/promises'; const file = process.argv[2]; await pipeline( fs.createReadStream(file), zlib.createGzip(), fs.createWriteStream(file + '.gz') );
Встроенные трансформирующие потоки  145 С помощью этого скрипта вы можете сжать любой файл, путь к которому передается в качестве аргумента. Мы направляем читаемый поток файла во встроенный трансформирующий поток zlib, а затем в записываемый поток для нового сжатого файла в формате .gz. Вся прелесть использования конвейера потоков заключается в том, что он позволяет собирать программу по частям и в удобочитаемом виде. Например, если вы хотите, чтобы пользователь видел индикатор прогресса во время сжатия файла, можно создать поток, который будет сообщать о прогрессе, и добавить его в конвейер. Асинхронная функция-генератор упрощает такие задачи. await pipeline( fs.createReadStream(file), zlib.createGzip(), async function* (source) { for await (const chunk of source) { process.stdout.write('.'); yield chunk; } }, fs.createWriteStream(file + '.gz') ); Возможности комбинирования потоков безграничны. Например, чтобы зашифровать файл до (или после) сжатия, достаточно добавить в конвейер еще один трансформирующий поток. У модуля node:crypto есть метод createCipheriv, который можно использовать для этой цели: import crypto from 'node:crypto'; // .. const algorithm = 'aes-256-ctr'; const key = crypto.randomBytes(32); const iv = crypto.randomBytes(16); await pipeline( fs.createReadStream(file), zlib.createGzip(), crypto.createCipheriv(algorithm, key, iv), fs.createWriteStream(file + '.gz'), ); Этот код сначала сжимает, а затем шифрует файл, переданный в качестве аргумента, что делает невозможным его распаковку с помощью обычных утилит распаковки. Такой файл сможет использовать только тот, у кого есть значения ключа и вектора инициализации (key/iv). Чтобы распаковать файл, созданный этим кодом, нужны обратные потоки для crypto и zlib в реверсивном порядке: await pipeline( fs.createReadStream(file),
146  Глава 6. Потоки данных crypto.createDecipheriv(algorithm, key, iv), zlib.createGunzip(), fs.createWriteStream(file.slice(0, -3)), ); Этот код создает читаемый поток из зашифрованного и сжатого файла, направляет его в поток crypto.createDecipheriv() с теми же значениями key и iv, затем передает результат в поток zlib.createGunzip() и записывает содержимое в файл без расширения .gz. Выводы Потоки обеспечивают эффективную обработку больших объемов данных в Node, позволяя рационально использовать память устройства. Они дают возможность работать с данными частями (чанками) по мере их поступления, вместо того чтобы загружать все целиком. Есть два основных типа потоков: читаемые потоки, которые производят данные, и записываемые потоки, которые данные потребляют. Дуплексные потоки совмещают обе эти функции, а трансформирующие потоки — это вид дуплексных, они изменяют данные в процессе передачи. У потоков множество методов и событий, которые можно совмещать для корректной работы. Читаемые потоки находятся в режиме с автоматическим потоком данных или в приостановленном режиме. Записываемый поток может быть быстрее или медленнее, чем читаемый. Все эти состояния требуют различных методов и событий. Проще всего с потоками работать с помощью метода pipeline. Он автоматически справляется со многими сложностями из-за их использования. В следующей главе мы научим Node выполнять команды операционной системы и делать так, чтобы один процесс порождал другой.
ГЛАВА 7 Дочерние процессы Когда речь идет о простых приложениях, Node прекрасно работает в рамках одного процесса. Но по мере роста приложения и числа пользователей один процесс перестает справляться с нагрузкой. Вне зависимости от мощности сервера, один поток выдерживает лишь ограниченную нагрузку. То, что код Node выполняется в одном потоке, не мешает задействовать несколько процессов и, при необходимости, несколько машин. Использование нескольких процессов — один из лучших способов масштабирования Node-приложения. Node проектировалась для распределенных приложений со множеством узлов (nodes) — отсюда и название. Масштабируемость заложена в платформу, а не добавляется только на поздних этапах разработки. Что такое дочерние процессы? В основном процессе Node можно с легкостью создать дочерний процесс с помощью встроенного модуля node:child_process. Дочерние процессы могут обмениваться сообщениями в событийно-ориентированной системе, потому что под капотом объекты процессов построены на основе структуры EventEmitter. Модуль node:child_process позволяет эффективно запускать команды ОС (например, find, grep и т. п.) из процесса Node, безопасно контролировать передаваемые командам аргументы и обрабатывать вывод команд. Это значительно расширяет возможности процесса Node. Кроме того, node:child_process поддерживает и буферы, и потоки для обработки ввода/вывода дочерних процессов. Используя потоки, можно комбинировать несколько команд и запускать их вместе даже при работе с большими объемами данных — достаточно передать вывод одной команды на вход другой с помощью конвейера. Модуль node:child_process предлагает четыре метода, чтобы создать дочерний процесс: spawn(), fork(), exec() и execFile(). Далее мы рассмотрим различия и случаи применения каждого из них.
148  Глава 7. Дочерние процессы Функция spawn Функция spawn запускает команду в новом процессе, и при необходимости мы можем использовать ее для передачи этой команде любых аргументов запуска. Например, с помощью такого кода можно запустить новый процесс, который будет выполнять команду pwd в Linux: import { spawn } from 'node:child_process'; const child = spawn('pwd'); Мы импортируем метод spawn и вызываем его, передавая команду операционной системы в качестве первого аргумента. Результат вызова spawn (в примере — объект child) — экземпляр ChildProcess, который реализует API EventEmitter. Это позволяет регистрировать обработчики событий на этом объекте напрямую. Например, можно реагировать на завершение дочернего процесса, подписавшись на событие exit: child.on('exit', function(code, signal) { console.log( `Child process exited. Code: ${code} - Signal: ${signal}` ); }); Функция — обработчик события exit получает код завершения и сигнал, который использовался для остановки дочернего процесса. Если процесс завершился нормально, значение signal будет null. Еще одно важное событие — error. Оно генерируется в случаях, если процесс не удалось запустить, если он был прерван или если его работа завершилась с ошибкой. При работе очень полезно всегда регистрировать обработчик для события error. child.on('error', (err) => { console.error(`Child process encountered an error: ${err.message}`); }); У экземпляра ChildProcess есть и другие события, на которые можно зарегистрировать обработчики: message, spawn, disconnect и close: Событие message Генерируется, когда дочерний процесс вызывает функцию process.send() для отправки сообщений. Так происходит обмен сообщениями между родительским и дочерним процессами. Пример этого события мы рассмотрим чуть ниже. Событие spawn Генерируется в момент успешного создания и запуска дочернего процесса.
Функция spawn  149 Событие disconnect Генерируется, когда родительский процесс вручную вызывает метод child. disconnect. Событие close Генерируется, когда потоки ввода/вывода дочернего процесса завершены. У каждого дочернего процесса есть три стандартных потока ввода-вывода (stdio-потока): child.stdin, child.stdout и child.stderr. Когда эти потоки закрываются, процесс генерирует close. Основное отличие событий close и exit заключается в том, что несколько дочерних процессов могут разделять одни и те же stdio-потоки, поэтому выход одного процесса не означает автоматического закрытия потоков. Поскольку все потоки — эмиттеры событий, мы можем прослушивать различные события на stdio-потоках, которые присоединены к каждому дочернему процессу. Однако, в отличие от обычного процесса, в дочернем потоки stdout и stderr читаемые, а поток stdin — записываемый. По сути, типы противоположны типам этих потоков в главном процессе. События, которые мы можем использовать для stdio-потоков, стандартные. Важно то, что на читаемых потоках мы можем слушать событие data — оно будет содержать вывод команды или любые ошибки, возникшие во время ее выполнения. child.stdout.on('data', data => { console.log(`child stdout:\n${data}`); }); child.stderr.on('data', data => { console.error(`child stderr:\n${data}`); }); Эти два обработчика выведут данные дочернего процесса в stdout и stderr главного процесса. Если мы запустим этот код, будет напечатан вывод команды pwd, а дочерний процесс завершится с кодом 0 — то есть без ошибок. Аргументы можно передать команде, которая запускается функцией spawn с помощью второго аргумента этой функции. Этот аргумент представляет собой массив всех аргументов, передаваемых команде. Например, выполнить команду find в текущем каталоге с аргументом -type f (рекурсивный поиск и отображение списком имен файлов без учета папок и символических ссылок) можно так: const child = spawn('find', ['.', '-type', 'f']); Если во время выполнения команды произойдет ошибка, например, если мы указали неверный путь для команды find, на child.stderr сработает обработчик события data, а обработчик события exit вернет код выхода 1. Конкретные коды и сообщения зависят от ОС и типа ошибки.
150  Глава 7. Дочерние процессы stdin дочернего процесса — это записываемый поток. С его помощью мы можем передать команде некоторые входные данные. Как и с любым записываемым потоком, проще всего подать на него данные через функцию pipe: направить читаемый поток в записываемый. Поскольку stdin главного процесса — читаемый поток, его можно перенаправить в child.stdin: import { spawn } from 'node:child_process'; const child = spawn(`wc`); process.stdin.pipe(child.stdin); child.stdout.on(`data`, data => { console.log(`child stdout:\n${data}`); }); В примере дочерний процесс запускает команду wc — утилиту для подсчета строк, слов и символов. Затем мы перенаправляем stdin главного процесса (читаемый поток) в stdin дочернего (записываемый поток). В результате мы получили стандартный режим ввода, где можем напечатать что-нибудь, а при нажатии Ctrl+D набранный текст будет отправлен на вход команде wc. Также можно связать стандартные потоки ввода/вывода нескольких процессов аналогично конвейерам в Linux. Например, перенаправить stdout команды find на stdin команды wc, чтобы посчитать все файлы в текущей директории: import { spawn } from 'node:child_process'; const find = spawn('find', ['.', '-type', 'f']); const wc = spawn('wc', ['-l']); find.stdout.pipe(wc.stdin); wc.stdout.on('data', data => { console.log(`Number of files ${data}`); }); Я добавил к команде wc аргумент -l как указание подсчитывать только количество введенных строк. При выполнении код выведет количество всех файлов во всех поддиректориях текущего каталога. Синтаксис оболочки и функция exec По умолчанию функция spawn не создает оболочку для выполнения переданной команды. Благодаря этой особенности она чуть более эффективна, чем функция exec, которая оболочку создает. У функции exec есть еще одно важное отличие. Она сохраняет весь вывод команды в буфере и передает его целиком в коллбэкфункцию, а не использует потоки, как это делает spawn.
Синтаксис оболочки и функция exec  151 Так как функция exec применяет для выполнения команды оболочку, мы можем напрямую использовать любой синтаксис оболочки. Синтаксис оболочки (shell syntax) — это набор правил, определяющих, как команды записываются и интерпретируются в среде оболочки. Например, у команд могут быть параметры и аргументы. С командами можно использовать специальные символы. Например, вертикальная черта (|) нужна для соединения выходных данных одной команды с входными данными другой, звездочка (*) — для поиска файлов по шаблону, а символ > — для перенаправления вывода команды в файл. В командах оболочки можно также использовать циклы, условия и другие продвинутые возможности. Вот пример из предыдущего раздела с find и wc, реализованный через функцию exec: import { exec } from 'node:child_process'; exec('find . -type f | wc -l', (err, stdout, stderr) => { if (err) { console.error(`exec error: ${err}`); return; } console.log(`Number of files ${stdout}`); }); Обратите внимание, что в этом примере я перенаправил вывод команды find на ввод команды wc, применяя пайпинг оболочки, который обозначен с помощью вертикальной черты (|). Используйте синтаксис оболочки в Node с большой осторожностью. Это связано с угрозами безопасности, особенно если вы выполняете команды, полученные из внешних источников. Например, злоумышленники могут легко инициировать атаку инъекции команд, используя синтаксические символы оболочки, такие как ; (например, команда + '; rm -rf ~'). Методы защиты от таких атак включают фильтрацию и проверку вводимых данных, составление списка допустимых команд и ограничение доступа. Функция exec буферизует вывод и передает его в переданную во втором аргументе коллбэк-функцию как аргумент stdout. Этот аргумент содержит вывод команды, который мы и видим на экране. Вы можете остановиться на функции exec, если нужен синтаксис оболочки, а предполагаемый объем данных небольшой, так как эта функция полностью загружает получаемые данные в память. Но если на выходе ожидается большой объем данных, лучше сделать выбор в пользу функции spawn с ее потоками данных. Потоки совместимы со стандартными потоками ввода/вывода, которые часто применяют с дочерними процессами. Мы даже можем сделать так, чтобы дочерний процесс, запущенный через spawn,
152  Глава 7. Дочерние процессы наследовал стандартные потоки ввода/вывода родителя. Более того, spawn тоже можно заставить использовать синтаксис оболочки. Например, вот та же команда find | wc, реализованная с помощью функции spawn: const child = spawn('find . -type f | wc -l', { stdio: 'inherit', shell: true }); При использовании опции stdio: 'inherit' дочерний процесс наследует стандартные потоки ввода, вывода и ошибок (stdin, stdout, stderr) родительского процесса. Это приводит к тому, что обработчики событий данных дочернего процесса срабатывают непосредственно на потоке process.stdout основного процесса, заставляя скрипт выводить результат немедленно. Опция shell: true позволяет запускаемой команде использовать синтаксис оболочки, как и в случае с exec. Но при этом у нас сохраняются преимущества потоковой передачи данных, которые дает spawn. Пожалуй, это лучший вариант из двух предложенных. Помимо shell и stdio есть еще несколько полезных опций, которые можно передать последним аргументом в функцию node:child_process. Например, можно применять опцию cwd, чтобы изменить рабочую директорию скрипта. Вот тот же пример подсчета всех файлов через функцию spawn с использованием оболочки и рабочей директорией, установленной в папку Downloads: const child = spawn('find . -type f | wc -l', { stdio: 'inherit', shell: true, cwd: '/Users/samer/Downloads' }); Еще одна полезная опция — env. С ее помощью можно задавать переменные окружения, доступные запускаемому дочернему процессу. По умолчанию используется process.env, которая дает команде доступ к окружению текущего процесса. Если это поведение нужно изменить, достаточно просто передать пустой объект в опцию env или указать там новые значения, которые будут единственными переменными окружения для дочернего процесса: const child = spawn('echo $ANSWER', { stdio: 'inherit', shell: true, env: { ANSWER: 42 } }); Так как мы указали объект env, команда echo не имеет доступа к переменным окружения родительского процесса. Например, она не может получить $HOME, но может использовать $ANSWER, так как он был определен в свойстве env. Последняя важная опция дочерних процессов, о которой хотелось бы рассказать, — detached. Она позволяет процессу выполняться независимо от родительского.
Функция execFile  153 Допустим, у нас есть файл timer.js, который держит цикл событий занятым: setTimeout(() => { // Удерживает цикл событий занятым }, 20_000); Мы можем запустить timer.js в фоновом режиме, используя опцию detached: import { spawn } from 'node:child_process'; const child = spawn('node', ['timer.js'], { detached: true, stdio: 'ignore' }); child.unref(); Поведение отделенных (detached) дочерних процессов зависит от операционной системы. В Windows у них будет свое консольное окно, а в Linux они станут лидерами новых групп процессов и сессий. Если у отделенного процесса вызвать метод unref, родительский процесс сможет завершиться независимо от дочернего. Это полезно, если дочерний процесс выполняет длительную задачу, но чтобы он продолжал работу в фоне, его настройки stdio также должны быть независимыми от родительского процесса1. Этот пример запускает скрипт timer.js в фоне, отделяя от родительского как сам дочерний процесс, так и потоки ввода-вывода, что позволяет родительскому процессу завершиться, пока дочерний продолжает работу. Функция execFile Функция execFile работает так же, как и exec, но не использует оболочку. Отсутствие синтаксиса оболочки делает ее быстрее и безопаснее. Ее можно применять для запуска внешней программы или скрипта. Вот пример: import { execFile } from 'node:child_process'; execFile('ruby', ['-e', 'puts rand(1..100)'], (error, stdout, stderr) => { if (error) { console.error(`Error: ${error}`); return; } console.log(`Output: ${stdout}`); }); 1 Подробнее о подобном поведении дочерних процессов можно прочитать на странице официальной документации Node: https://nodejs.org/api/child_process.html#child_ processspawncommand-args-options. — Примеч. науч. ред.
154  Глава 7. Дочерние процессы В этом примере execFile используется для вызова интерпретатора языка ruby, которому в качестве аргумента передается однострочный скрипт, генерирующий случайное число в диапазоне от 1 до 100. В Windows некоторые файлы, например .bat или .cmd, нельзя запустить напрямую с помощью execFile. Для их выполнения нужно использовать exec или spawn с параметром shell: true. Синхронные функции для дочерних процессов У функций spawn, exec и execFile из модуля node:child_process есть синхронные блокирующие версии, которые ждут завершения дочернего процесса: import { spawnSync, execSync, execFileSync } from 'node:child_process'; Синхронные версии могут быть полезны для упрощения скриптов или задач инициализации при запуске приложений Node, но в остальных случаях их лучше избегать. Функция fork Функция fork — это разновидность рассмотренной выше функции spawn(). Основная особенность функции fork состоит в установлении канала межпроцессного взаимодействия с дочерним процессом. Вместе с объектом process мы можем использовать в процессе с функцией fork метод send для обмена сообщениями между родительским и дочерним процессами. Это делается через интерфейс модуля EventEmitter. Разберем пример. В родительском файле parent.js у нас есть следующий код: import { fork } from 'node:child_process'; const forked = fork('child.js'); forked.on('message', msg => { console.log('Message from child', msg); }); forked.send({ hello: 'world' });
Функция fork  155 А в дочернем файле child.js: process.on('message', msg => { console.log('Message from parent:', msg); }); let counter = 0; setInterval(() => { process.send({ counter: counter++ }); }, 1_000); В родительском файле мы форкаем child.js (он будет запущен командой node) и слушаем событие message. Событие message будет генерироваться каждый раз, ко­гда дочерний процесс вызывает process.send (в нашем примере — каждую секунду). Чтобы отправить сообщение от родительского процесса дочернему, мы можем выполнить функцию send на объекте, полученном через вызов функции fork. Затем в дочернем скрипте мы можем слушать событие message у объекта process. При запуске файла parent.js код в этом файле отправит объект { hello: 'world' }, который будет выведен форкнутым дочерним процессом. Тот каждую секунду будет отправлять увеличенное значение счетчика для вывода в родительском процессе. Рассмотрим более практический пример с функцией fork. Предположим, у нас есть веб-сервер, который обрабатывает два эндпоинта. Один из этих эндпоинтов, /compute, очень ресурсоемкий, на его выполнение нужно несколько секунд. Симулировать это можно с помощью длинного цикла for: import { createServer } from 'node:http'; const longComputation = () => { let sum = 0; for (let i = 0; i < 1e9; i++) { sum += i; } return sum; }; const server = createServer(); server.on('request', (req, res) => { if (req.url === '/compute') { const sum = longComputation(); return res.end(`Sum is ${sum}`); } else { res.end('Ok'); } });
156  Глава 7. Дочерние процессы server.listen(3000, () => { console.log('Server is running...'); }); У этой программы есть серьезная проблема: когда запрашивается эндпоинт /compute, сервер не сможет обрабатывать другие запросы, потому что главный поток занят длинным циклом for. Можно по-разному решить эту проблему в зависимости от характера вычислений, но универсальный способ — вынести эту операцию в отдельный процесс с помощью функции fork. Для этого нужно вынести всю функцию longComputation в отдельный файл и заставить ее выполняться при получении события message от основного процесса. В новом файле compute.js: const longComputation = () => { let sum = 0; for (let i = 0; i < 1e9; i++) { sum += i; } return sum; }; process.on('message', msg => { const sum = longComputation(); process.send(sum); }); Теперь вместо выполнения тяжелой операции в главном потоке мы можем форк­ нуть файл compute.js и использовать интерфейс сообщений для обмена данными между сервером и этим процессом: import { createServer } from 'node:http'; import { fork } from 'node:child_process'; const server = createServer(); server.on('request', (req, res) => { if (req.url === '/compute') { const compute = fork('compute.js'); compute.send('start'); compute.on('message', sum => { res.end(`Sum is ${sum}`); }); } else { res.end('Ok'); } }); server.listen(3000, () => { console.log('Server is running...'); });
Выводы  157 Теперь при запросе к /compute достаточно отправить сообщение к форкнутому процессу, чтобы он начал выполнять долгую операцию. Главный поток при этом блокироваться не будет. Как только форкнутый процесс в этой долгой операции завершится, он может отправить результат выполнения в родительский процесс с помощью process.send. В родительском процессе мы слушаем событие message у форкнутого процесса. Вместе с этим событием мы получим значение sum, которое отправим пользователю по HTTP. Этот метод ограничен количеством процессов, которые можно форкнуть, но при запуске и запросе тяжелого эндпоинта по HTTP главный сервер вообще не блокируется и может обрабатывать дальнейшие запросы. Кластерный модуль в Node, о котором пойдет речь в главе 9, основан на идее создания дочерних процессов и распределения нагрузки между ними, что может быть реализовано в любой операционной системе. Выводы В Node можно создавать дочерние процессы. Это позволяет масштабировать приложения за пределы одного потока и получать доступ к функциям ОС и другим программам. С помощью дочерних процессов Node выполняются внешние команды оболочки, программы и скрипты. При необходимости вы можете установить обмен сообщениями между родительскими и дочерними процессами. В модуле child_process в Node есть несколько гибких асинхронных функций (spawn, exec, execFile) и соответствующие синхронные версии, которые ждут завершения дочернего процесса. Функция spawn нужна для потоковой передачи вывода выполнения, что удобно для работы с большими объемами данных. В spawn и exec можно использовать синтаксис оболочки. Функция fork позволяет передать выполнение скрипта Node другому процессу, освобождая главный для других задач. Объекты дочерних процессов — это эмиттеры событий. Они генерируют такие события, как exit, error и message. Событие message используется для обмена данными между родительским и дочерним процессами. В следующей главе мы узнаем, как с помощью встроенных модулей Node можно писать тесты и проверочные утверждения.
ГЛАВА 8 Тестирование в Node Тестирование в реальном мире — это, как правило, поиск того, что работает неправильно. Но в программировании тестируют, наоборот, чтобы найти, что работает правильно. Это гарантия того, что код или его отдельные части выполняются корректно. Непротестированный код опасен. Эффективный и масштабируемый код в 100 % случаев прошел тщательное тестирование. При этом проверяют, не только верно ли работает код, но и не пострадала ли корректность работы после любых изменений непосредственно кода, его окружения и способов его использования. От тестирования напрямую зависит качество кода, так как оно выявляет возможные проблемы на ранних этапах. Регулярное тестирование кода обеспечивает его работоспособность и упрощает обслуживание. Также оно повышает уверенность разработчиков в корректности изменений. Внести изменения в непроверенный код — верный путь к катастрофе. Новая функция в модуле X может нарушить работу других функций в модуле Y. Держать в голове все цепочки зависимостей физически невозможно, но проверять код вручную при каждом изменении тоже нельзя. Выход из этой ситуации только один: писать код, который будет тестировать ваш код. И да, в тестирующем коде тоже могут быть ошибки. Тесты могут давать ложноотрицательные и ложноположительные результаты. Поэтому крайне важно писать тестирующий код правильно. Об этом мы и поговорим в этой главе. Проверочные утверждения и раннеры Тест в Node — это набор проверочных утверждений (assertions). Чтобы выполнить тест, нужен тестовый раннер (runner). В Node есть встроенные модули и для утверж­ дений, и для запуска тестов. Вот простой тест для метода map массивов: import test from 'node:test'; import assert from 'node:assert/strict'; test('doubles items for a 2*e transformer', () => { // Arrange const inputArray = [1, 2, 3, 10]; const expectedResult = [2, 4, 6, 20];
Проверочные утверждения и раннеры  159 // Act const actualResult = inputArray.map((e) => 2 * e); // Assert assert.deepEqual(actualResult, expectedResult); }); Полезно представлять тесты в виде шаблона Arrange–Act–Assert. Arrange Подготовка: на этом этапе мы настраиваем окружение и подготавливаем все необходимое для теста. Act Действие: на этом этапе мы выполняем метод, который хотим протестировать. Assert Проверка: на этом этапе мы убеждаемся, что результат соответствует ожиданиям. Чтобы выполнить этот тест, достаточно поместить его в Node-скрипт и вызвать команду node. Результат теста можно увидеть на рис. 8.1. У модуля node:test есть несколько объектов, которые подходят для организации тестов и их именования. В этом примере я использовал метод test, чтобы дать тесту имя «doubles items for a 2*e transformer». Когда команда node находит этот метод, встроенный раннер вызывает все тестовые утверждения и выводит результат, в котором перечислены все тесты, их статус, время выполнения каждого и диагностическое резюме. Как видно из примера, наш единственный тест прошел успешно. Модуль node:assert содержит набор функций для реализации части Assert в любом тесте. В этом примере я использовал метод deepEqual, чтобы убедиться, что полученный массив совпадает с ожидаемым по содержимому. Обычный метод assert. equal здесь не подойдет, так как массивы — это разные объекты. Здесь нам нужно сравнить значения элементов массива, и это можно сделать с помощью метода deepEqual. Обратите внимание, что я использовал node:assert/strict. Этот метод всегда должен иметь такой вид. Устаревший режим (без /strict) основан на операторе ==, который выполняет преобразование типов перед сравнением. Этого следует избегать.
160  Глава 8. Тестирование в Node Вместо простого метода test мы можем использовать методы describe и it. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; describe('The map method on arrays', () => { it('doubles items for a 2*e transformer', () => { const inputArray = [1, 2, 3, 10]; const expectedResult = [2, 4, 6, 20]; const actualResult = inputArray.map((e) => 2 * e); assert.deepEqual(actualResult, expectedResult); }); }); Рис. 8.1. Пройденный тест По сути метод it — псевдоним test, но я предпочитаю использовать его, так как он помогает формулировать названия тестов, задает вопрос: «Что проверяет этот тест?» и отвечает: «Он… (It…)». Метод describe позволяет группировать тесты и описывать их назначение. Обычно для таких методов, как map, создается несколько тестов. Их можно объединять
Проверочные утверждения и раннеры  161 с помощью метода describe. Это также влияет на формат вывода при запуске тестов. А теперь посмотрим, как выглядит неудачный запуск теста. Замените в тексте метода map deepEqual на equal и запустите его снова. На рис. 8.2 показан полученный вывод. Рис. 8.2. Проваленный тест Так как ожидаемый массив не равен фактическому, использование assert.equal здесь приводит к сбою. В этом примере я применил метод map, но при работе тестировать стандартные методы библиотеки не стоит: они и так хорошо протестированы. Тестировать нужно свой код: функции, модули, зависимости и системы. Для начала давайте поговорим о разных типах тестов.
162  Глава 8. Тестирование в Node Глубокое и поверхностное равенство Когда мы применяем выражение a === b к объектным типам, мы делаем поверхностную проверку, которая определяет, относятся ли a и b к одной и той же области памяти. Глубокое равенство означает, что два объекта имеют одинаковую структуру и значения вне зависимости от их расположения в памяти. Рассмотрим два массива: const arrA = [1, 4, 16]; const arrB = [2-1, 2*2, 4**2]; assert.equal(arrA, arrB); assert.deepEqual(arrB, arrB); // AssertionError // Ok Так как эти два массива — разные объекты, проверка assert.equal завершится ошибкой. Однако, так как оба массива содержат по три элемента и каждый элемент arrA равен элементу arrB в той же позиции, они являются глубоко равными, и проверка assert.deepEqual будет выполнена успешно. Типы тестов Есть четыре основных типа тестов: модульные (unit), функциональные (functional), интеграционные (integration) и сквозные (end-to-end, e2e). У каждого из них свой охват и назначение. На практике же бывает, что один тест порой можно отнести сразу к нескольким типам. Однако, разобравшись в их различиях, вы будете писать более эффективные тесты. Для наглядности возьмем пример кода и рассмотрим различные примеры тестов для него. Предположим, у нас есть два модуля для управления товарами и заказами в базе данных. Для простоты будем хранить записи в обычных массивах. В файле products.js мы видим следующее: let { { { ]; products = [ id: 1, name: 'Phone', price: 600 }, id: 2, name: 'Laptop', price: 2000 }, id: 3, name: 'Headphone', price: 100 }, const addProduct = (product) => { products.push(product); return product; }; const getProductById = (id) => {
Типы тестов  163 return products.find((product) => product.id === id); }; export { addProduct, getProductById }; А в файле orders.js код такой: const orders = []; const createOrder = (productId, quantity) => { const order = { productId, quantity, status: 'pending' }; orders.push(order); return order; }; const updateOrderStatus = (orderId, status) => { const order = orders.find((order) => order.id === orderId); if (order) { order.status = status; } return order; }; export { createOrder, updateOrderStatus }; Я проинициализировал массив товаров тестовыми данными. Как правило, подготовка тестовых данных (seeding) осуществляется на отдельном этапе, например во время подготовки перед прогоном тестов. Для простоты я поместил их прямо в модуль. Модульное тестирование Модульный тест (или юнит-тест) пишется для небольшой части кода, которая называется юнит (unit). Юнитом может быть отдельная функция, группа связанных функций, модуль, компонент — все, что можно проверить изолированно. Обычно все внешние зависимости заменяются на дублеров1 (double objects), чтобы сохранить возможность изолированного тестирования модуля. Подробнее об этом мы поговорим чуть ниже. В качестве примера модульного теста мы рассмотрим проверку одной функции модуля. Например, протестируем getProductById на известном тестовом товаре и на несуществующем id: import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { getProductById } from './products.js'; describe('getProductById', () => { 1 В профессиональной среде их еще часто называют заглушками. — Примеч. пер.
164  Глава 8. Тестирование в Node it('finds a product that exists', () => { const product = getProductById(2); assert.deepEqual(product, { id: 2, name: 'Laptop', price: 2000, }); }); it('returns undefined for a product that does not exist', () => { const product = getProductById(-1); assert.equal(product, undefined); }); }); В последующих примерах я буду опускать импорты node:test и node:assert для краткости. Функциональные тесты Функциональный тест (functional) пишется для проверки какого-либо функцио­ нала системы (фичи, feature). Неважно, опирается фича на код одного юнита или нескольких, такие тесты не могут быть запущены изолированно, поскольку они часто взаимодействуют с внешними зависимостями — базами данных, сетью и т. п. Для модуля заказов в качестве примера функционального теста можно привести проверку оформления заказа и обновления его статуса: import { createOrder, updateOrderStatus } from './orders.js'; describe('Order Management', () => { it('places an order and updates its status', () => { const newOrder = createOrder(1, 1); const updatedOrder = updateOrderStatus( newOrder.id, 'completed', ); assert.equal(updatedOrder.status, 'completed'); }); }); Интеграционные тесты Цель создания интеграционных тестов (integration) — удостовериться, что разные модули и сервисы корректно работают вместе. В то время как функциональные тесты фокусируются на фичах отдельного модуля или сервиса, интеграционные — на их совместной работе. В качестве примера интеграционного теста для модулей
Типы тестов  165 товаров и заказов можно привести проверку интеграции создания заказа и получения товара: import { getProductById } from './products.js'; import { createOrder } from './orders.js'; describe('Order Creation', () => { it('integrates with product retrieval', () => { const product = getProductById(1); const order = createOrder(product.id, 2); assert.equal(order.productId, product.id); assert.equal(order.quantity, 2); }); }); Сквозные тесты Сквозные, или e2e (end-to-end), тесты моделируют реальное использование системы от начала до конца, проходя через все компоненты, модули и сервисы. Пример e2e-теста для модулей товаров и заказов — проверка полного пути от добавления товара до завершения заказа: import { addProduct, getProductById } from './products.js'; import { createOrder, updateOrderStatus } from './orders.js'; describe('From product addition to order completion', () => { it('works', () => { addProduct({ id: 4, name: 'Tablet', price: 500 }); const product = getProductById(4); const order = createOrder(product.id, 1); const finalOrder = updateOrderStatus(order.id, 'completed'); assert.equal(product.name, 'Tablet'); assert.equal(finalOrder.status, 'completed'); }); }); В целом чем лучше вы будете понимать отличия между типами тестов, тем грамотнее будете выстраивать стратегию тестирования, которая соответствует алгоритму работы приложения. Также по типу проваленного теста проще понять, где искать причину ошибки. Однако границы между типами тестов часто очень расплывчатые. Например, чтобы лучше протестировать getProductById на уровне юнита, тест может сначала добавить товар, а затем попытаться его найти — а такой подход ближе к интеграционным тестам. Важен не тип теста, а стратегия, подходящая приложению, и организация его тестирования в духе этой стратегии. В приложении с полностью изолированными модулями интеграционных тестов может быть мало — основной упор в таком приложении делается на модульные тесты. В других проектах команда может вообще отказаться от юнит-тестов и делать интеграционные и e2e-тесты.
166  Глава 8. Тестирование в Node Тестовые дублеры Зачастую для тестирования нужно имитировать определенное поведение. Например, худшее, что можно придумать при проверке кода, который применяет внешний API, — это тестировать код с использованием API, развернутым в продуктовом окружении. Для этих целей лучше задействовать тестовых дублеров, которые имитируют ответы API, и поддерживать их в актуальном состоянии на основе реальных ответов API. По сути, тестовые дублеры (test doubles) — это объекты, которые нужны для имитирования поведения реальных объектов. Как каскадер в кино выполняет трюки вместо актера, так и тестовый дублер имитирует действия настоящего объекта. Чаще всего тестовые дублеры используются в юнит-тестах, так как они сосредоточены на изолированных юнитах. Например, если нужно протестировать функцию, которая для своей работы получает запись из базы данных, можно подменить объект доступа к базе тестовым дублером и сосредоточить тест на оставшейся логике функции. А фактическую выборку из базы данных проверют интеграционные или e2e-тесты. Тестовые дублеры упрощают и ускоряют тестирование. С правильно подобранными дублерами для простых изолированных тестов не нужно поднимать базу данных, API или сетевую инфраструктуру. Есть несколько видов тестовых дублеров. Чтобы изучить их, начнем с примера кода: function notifyCompletion(taskId, { database, emails, mailer }) { const status = database.getStatus(taskId); } if (status === 'complete') { const email = emails.notifications; mailer.sendEmail(email, `Task ${taskId} is complete.`); } Это простая функция с тремя зависимостями: объект database, объект emails и сервис mailer. Все три зависимости передаются в функцию аргументами, согласно паттерну внедрения зависимостей (dependency injection). Для имитации этих сервисов можно применять разные типы тестовых дублеров, учитывая задачи тестирования. Фиктивные объекты (дамми, dummy) передаются в аргументах, но фактически не используются. Это просто заглушки, которые ничего не делают. Например, если нас не интересует сервис emails, в тесте можно передать пустой фиктивный объект: // Проверка notifyCompletion(123, {
Тестовые дублеры  167 database, emails: {}, mailer, }); Stub-объекты (стабы, stub) заменяют часть кода реализацией с фиксированным поведением. Так, стаб для функции — это новая функция, которая всегда возвращает один и тот же результат. Например, заменить сервис database в notifyCompletion стабом можно так: const stubDatabase = { getStatus: function (taskId) { return 'complete'; }, }; // Проверка notifyCompletion(123, { database: stubDatabase, emails, mailer, }); Mock-объекты (моки, mock) нужны для проверки взаимодействий и сценариев между компонентами. Например, чтобы убедиться, что функция вызвана с нужными аргументами, что происходит попытка отправки письма или что методы вызываются в заданном порядке. В случае notifyCompletion можно использовать мок для mailer, чтобы проверить, что sendEmail вызывается с правильными аргументами: const mockMailer = { sendEmail(email, message) { assert.equal(email, 'task@example.com'); assert.equal(message, 'Task 123 is complete.'); }, }; // Проверка notifyCompletion(123, { database, emails, mailer: mockMailer, }); Spy-объекты (шпионы) собирают информацию о вызовах функций — например, сколько раз она была вызвана. Если нас интересует, сколько раз вызывали метод sendEmail, можно использовать такой шпион:
168  Глава 8. Тестирование в Node const spyMailer = { sendCount: 0, sendEmail (email, message) { this.sendCount++; }, }; Fake-объекты (фейки) — это упрощенные реализации сложного интерфейса/класса. Работают как «настоящие», но их реализация значительно проще. Например, если в рамках тестирования метода getStatus нужно получать как значение complete, так и incomplete, можно имплементировать fake-объект database вот так: class FakeDatabase { constructor() { this.tasks = { 123: 'incomplete', 456: 'complete' }; } } getStatus(taskId) { return this.tasks[taskId]; } Как видите, выбор типа дублера полностью зависит от того, что именно и насколько глубоко вы хотите тестировать. Дублер не обязан относиться строго к одному виду, их можно комбинировать. Мы можем заставить FakeDatabase отслеживать, сколько раз был вызван метод getStatus , и добавить некоторые утверждения, превратив его таким образом в мок. Названия и границы типов полезно знать, но не более. Запомните главное: тестовые дублеры имитируют поведение реальных объектов и повышают эффективность тестов: упрощают их, позволяют отслеживать вызовы и проверять корректность работы. В Node в модуле node:test есть встроенный объект mock, который применяется для создания тестовых двойников с мощными возможностями. Рассмотрим несколько примеров. С помощью объекта mock можно подменить (замокать) метод любого объекта, в том числе встроенных модулей. Например, если вы тестируете функцию, использующую readFile из node:fs, можно замокать метод и не читать в тестах файлы по-настоящему: import fs from 'node:fs/promises'; import { mock } from 'node:test' mock.method(fs, 'readFile', async () => 'Hello World');
Тестовые дублеры  169 После мокания у метода появляется свойство .mock, с помощью которого можно отслеживать вызовы. Далее мы проверяем, что readFile вызвали ровно один раз: assert.equal( fs.readFile.mock.calls.length, 1, ); Объект mock можно использовать и для таймеров setTimeout/setInterval: // Создаем мок-функцию const fn = mock.fn(); // Мокаем API setTimeout mock.timers.enable({ apis: ['setTimeout'] }); // Теперь вызовы setTimeout не будут // создавать реальный таймер // Проверка setTimeout(fn, 500); assert.equal(fn.mock.callCount(), 0); // Можно вручную "тикнуть" мок-таймер mock.timers.tick(500); assert.equal(fn.mock.callCount(), 1); Также mock-объект позволяет подменять дату в тестах, зависящих от времени: test('mocks the Date object', (context) => { // Мокаем объект Date context.mock.timers.enable({ apis: ['Date'] }); // Начальная дата будет соответствовать отметке 0 эпохи UNIX assert.equal(Date.now(), 0); // Прокрутка времени сдвигает и дату context.mock.timers.tick(100); assert.equal(Date.now(), 100); }); Обратите внимание: здесь аргумент теста context. Он передается в каждую тестфункцию и позволяет взаимодействовать с раннером. В нем для удобства доступны многие возможности node:test. В частности, объектом context.mock можно пользоваться напрямую без импорта mock. У контекстных методов есть плюсы: например, при вводе context.mock раннер автоматически восстановит замоканное поведение после окончания теста. При прямом использовании mock это не так — тогда придется вручную вызывать mock.reset(). С контекстным объектом связано множество других методов. Ниже я перечислю некоторые из них.
170  Глава 8. Тестирование в Node Метод context.test Позволяет иерархически структурировать тесты, вкладывая сабтесты в верхнеуровневый тест. Метод context.diagnostic Пишет диагностическое сообщение в вывод раннера; оно попадает в конец результатов теста. Методы .before, .after, .beforeEach, .afterEach Выполняют код до/после всех тестов и до/после каждого теста в области. Практические примеры: test('top level test', async (t) => { t.afterEach((t) => t.diagnostic(`Finished running ${t.name}`)); t.after((t) => t.diagnostic(`Finished running ${t.name}`)); await t.test('subtest 1', (t) => { assert.equal(1, 1); }); await t.test('subtest 2', (t) => { assert.equal(2, 2); }); }); Вывод показан на рис. 8.3. Обратите внимание: ${t.name} в afterEach относится к сабтестам, а в after — к верхнеуровневому тесту. Функции after, before, afterEach и beforeEach также доступны как импорты из node:test. Организация и фильтрация тестов Где хранить тесты в приложении? Есть два варианта: поместить тестовый файл рядом с кодом, который он обрабатывает, или держать тесты отдельно, в структуре каталогов, отражающей структуру приложения. В первом варианте тест лежит рядом с модулем. Если модуль — orders.js, создайте orders.test.js (или ordersTest.js, или orders.spec.js) в той же папке. Выберите формат именования и последовательно применяйте его по всему проекту. Преимущество этого подхода заключается в том, что можно быстро находить тесты для любого кода, и к тому же так сразу видно, что еще не было протестировано. Этот вариант отлично подходит для модульных и функциональных тестов.
Организация и фильтрация тестов  171 Рис. 8.3. Пример теста с контекстом Во втором варианте вы зеркалите структуру приложения в каталоге tests. Например, для папок app/domain и app/models надо создать директории tests/domain и tests/models. Здесь преимущество в том, что код и тесты разделены, но их все еще легко находить. Плохо то, что все завязано на структуру приложения: если вы ее меняе­ те, придется синхронно перестраивать и тестовую, иначе искать тесты станет труднее. Решить эту проблему можно, например, так: держать папку tests внутри каждого раздела приложения: app/domain/tests, app/models/tests и т. п. Так вы будете следовать структуре приложения, сохраните изоляцию кода и тестов, а также сильно упростите себе жизнь в случае изменения файловой структуры вашего проекта.
172  Глава 8. Тестирование в Node Какой бы подход вы ни выбрали, главное — единообразие. Определите соглашения по именованию папок и файлов и соблюдайте их по всему приложению. Можно подключить инструменты качества кода (например, ESLint с плагинами), чтобы подсвечивать отступления от принятых соглашений. Единообразие в организации помогает, когда нужно запускать подмножество тестов. В работе над частью приложения вам чаще нужны только те тесты, которые относятся к этой части, а полный прогон необходим реже. Node поддерживает фильтрацию запускаемых тестов. Второй необязательный аргумент к основным методам node:test можно использовать, чтобы полностью пропустить тест, пометить его как тест запланированного (TODO) функционала (который выполняется, но не включается в число неудачных тестов) или пометить тест как единственный для выполнения: // Этот тест будет пропущен ('something', { skip: true }, () => { // ... }); // Этот тест помечен как TODO test('something', { todo: true }, () => { // ... }); // Этот тест будет единственным к исполнению // если запустить `node` // с опцией `--test-only` test('something', { only: true }, () => { // ... }); Тесты можно фильтровать по имени с помощью опций --test-name-pattern="..." и --test-skip-pattern="...". Они позволяют запускать или пропускать тесты по шаблону. В комбинации эти возможности дают большую гибкость при запуске и сопровождении тестов. Разработка через тестирование (TDD) Зачем писать тесты для кода, если можно писать код для тестов? Это и есть разработка через тестирование (test-driven-development, TDD). Вы пишете тест (он, конечно, падает, ведь кода-то еще нет!), потом — короткий код, чтобы тест прошел успешно, затем — еще один тест, а потом снова пишете код, чтобы и этот тест прошел успешно, — и так далее. Получается цикл «красный → зеленый» (чередование провалившихся и пройденных тестов). В зеленой фазе вы
Разработка через тестирование (TDD)  173 перерабатываете код и делаете его более эффективным, поэтому такой цикл часто также называют Red-Green-Refactor. Поначалу работа в парадигме TDD может казаться непривычной, но этот подход невероятно эффективен. Если вы будете строго следовать подходу TDD, у вас просто не будет ни одной строчки кода, не покрытой тестами: писать код можно только ради того, чтобы сделать красный тест зеленым. Более того, TDD дисциплинирует и улучшает качество кода. Вы не тратите время на написание ненужного кода: задумывая какую-то функцию, вы сразу составляете для нее тест-кейс. Код становится более понятным, а у вас появляется возможность принимать более взвешенные решения о его развитии. Но хотя TDD прост в использовании, следует помнить о некоторых нюансах. Допустим, мы хотим создать функцию, которая проверяет адреса электронной почты. Чтобы применять TDD, начнем писать тест наподобие этого: describe('validateEmail', () => { it('works for a normal email', () => { assert(validateEmail('test@example.com')); }); }); Далее пишем простейший код, который делает тест зеленым. Важно: включаем только самое необходимое. Когда тест успешно завершился, допишем еще один тест-кейс: it('works for less common emails', () => { assert(validateEmail('test.one@example.com.ab')); assert(validateEmail('test+one@1.com.ab')); assert(validateEmail('123@a-z.com.ab')); }); Пишем код, чтобы тест успешно завершился, и добавляем еще один тест: it('works for non-english characters', () => { assert(validateEmail('test@mañana.org')); }); А теперь повторяем все заново: it('fails for invalid addresses', () => { assert(validateEmail('test@') === false); assert(validateEmail('@test.com') === false); }); Обратите внимание, что код в данном случае продуман до мелочей и каждая ваша задумка документируется с помощью теста. В случае с самым последним примером у вас, вероятно, возникла мысль: должна ли функция вернуть false или тест должен завершиться с ошибкой? Эти решения принимаются на стадии проектирования тестов.
174  Глава 8. Тестирование в Node TDD отлично подходит для модульных и части функциональных тестов, но его сложнее применять для более масштабных сценариев. Непрерывная интеграция Работая над Node-проектом, в котором есть тесты, запускайте их при каждом изменении в коде. В этом вам помогут описанные выше приемы организации и фильтрации. Также для автозапуска тестов при каждом их изменении можно использовать опцию --watch. Этот процесс называется непрерывным тестированием (continuous testing). Не стоит пушить код, не убедившись, что все тесты были успешно завершены на локальной машине. Но даже в этом случае в другой среде они могут завершиться ошибкой. Поэтому и нужен процесс непрерывной интеграции (continuous integration, CI). В платформах типа GitHub Actions или GitLab CI/CD с помощью CI можно автоматически прогонять все тесты при каждом предложенном изменении (например, в pull requests). Пока все тесты не пройдены, слияние делать запрещено. CI запускает тесты в облаке и интегрируется с системой контроля версий: например, можно автоматически блокировать пулл-реквест, если внесенные изменения приводят к ошибке в тестах. CI минимизирует типичные ошибки: кто-то мог не запустить все тесты, или они прошли локально, но не проходят в окружении, близком к продуктовому. CI обес­ печивает отказоустойчивость процесса. Еще один плюс CI-пайплайна — записи истории. С его помощью можно отслеживать прогресс прохождения набора тестов, время прогона, покрытие и динамику этих показателей. Покрытие (coverage) — показатель того, какая доля кода покрыта тестами. Пусть количество и не гарантирует качество, но большой уровень покрытия — это хороший первый шаг к качественному коду. Настройте интеграционный пайплайн на генерацию отчета о покрытии (тестовый раннер Node это поддерживает) и установите порог: процент покрытого кода не должен опускаться ниже заданного значения. Пуш непротестированного кода снизит показатель покрытия, что приведет к отклонению вашего мерж-реквеста. Выводы Небольшое простое приложение может существовать и без тестов, но по мере эволюции кода для его уверенного развития необходимо тестирование. В Node есть встроенные инструменты тестирования: модуль node:test для организации и запуска тестов и модуль node:assert для написания утверждений.
Выводы  175 Есть четыре основных типа тестов: юнит-тесты проверяют один компонент изолированно; функциональные — отдельный функционал; интеграционные — взаимодействие нескольких частей приложения; сквозные (или e2e) — полный пользовательский сценарий от начала до конца. Тестовые дублеры (стабы, моки, шпионы и т. д.) могут подменять части кода для изоляции зоны тестирования, ускорения и улучшения тестов. Тестовые файлы можно хранить рядом с кодом или в структуре, зеркалирующей приложение. Для запуска используйте фильтры, чтобы проводить только нужное подмножество тестов. В качественно проверяемом приложении на Node тесты — часть рабочего процесса. Запускайте все тесты локально перед пушем, а лучше пишите и запускайте их до и после изменений (то есть практикуйте TDD). Также запускайте тесты в стей­ джинге через CI-пайплайн, который автоматически реагирует на каждое новое изменение и блокирует его, если тесты завершаются с ошибкой. В следующей главе мы рассмотрим модуль cluster и увидим, как с его помощью управлять запуском множества процессов Node.
ГЛАВА 9 Масштабирование в Node Суть масштабирования приложения заключается в том, чтобы оно могло выполнять больше работы без замедления и сбоев. Масштабировать любое приложение можно, либо предоставив его серверам больше памяти или мощности процессора, либо добавив больше серверов. В случае Node с ее неблокирующей событийно-ориентированной моделью масштабирование заложено в саму платформу. Приложение на Node стоит строить из множества небольших распределенных узлов. Один и тот же процесс Node можно запустить на нескольких ядрах ЦПУ или на нескольких серверах, а затем распределять между ними входящие запросы. Для этого в Node есть встроенный модуль. В этой главе мы разберем модуль node:cluster: он помогает увеличивать производительность процесса, используя все доступные процессорные ядра. Также он повышает доступность и время безотказной работы серверов. Стратегии масштабирования Несмотря на то что наиболее распространенная причина масштабирования приложений — увеличение объема работы, которую они способны выполнять, для этого есть и другие предпосылки, например желание повысить доступность или отказоустойчивость. Есть два способа масштабировать существующее приложение: вертикальный (увеличение количества памяти и ЦПУ) и горизонтальный (увеличение числа доступных серверов). Тем не менее, если вы задумываетесь о масштабировании уже на этапе создания приложений (а это правильная стратегия), вам стоит ознакомиться с тремя следующими подходами. Выбор решения по масштабированию должен основываться на этом знании и на том, как, по вашим прогнозам, приложения будут использоваться в будущем. Клонирование (Cloning) Самый простой способ масштабировать большое приложение — клонировать его несколько раз и поручить каждому клону свою долю нагрузки (например, через балансировщик нагрузки). Это малозатратно по времени разработки и весьма эффективно. Это минимум, который стоит сделать, и в Node есть
Модуль Cluster  177 встроенный модуль node:cluster, упрощающий реализацию стратегии клонирования на одном сервере. Декомпозиция (Decomposing) Приложение можно масштабировать, разложив его на более мелкие части по функциям и сервисам. Вы получите несколько приложений с разными кодовыми базами и иногда с собственными базами данных и интерфейсами. Эту стратегию часто называют микросервисной: «микро» намекает на минимальный размер сервисов (на деле важнее не размер, а слабая связанность между сервисами и высокая связность внутри сервиса). Реализовать это непросто, и возможны долгосрочные последствия, но при правильной реализации плюсы этого способа существенные. Деление (Splitting) Также приложение можно разделить на несколько инстансов, где каждый отвечает лишь за свой участок данных. Эта стратегия называется горизонтальным партиционированием (horizontal partitioning) или, если речь идет о базах данных, — шардированием (sharding). При партиционировании перед каждой операцией нужен шаг маршрутизации или поиска, чтобы понимать, к какому инстансу обратиться. Например, чтобы разделить пользователей по стране или языку, сначала нужно определить это значение. Конкретное приложение может выбрать одну или комбинировать несколько стратегий — в зависимости от текущих и прогнозируемых потребностей. Модуль Cluster Модуль node:cluster позволяет запускать несколько инстансов одного процесса Node на разных ядрах процессора и распределять нагрузку между ними. По умолчанию он использует метод child_process.fork. С помощью этого метода можно форкать процесс столько раз, сколько нужно. На одном сервере можно создать по форку на каждое ядро1. Затем модуль node:cluster может взять на себя управление 1 На самом деле мы не ограничены здесь количеством ядер: при создании дочерних процессов в количестве, превышающем количество ядер ЦПУ, воркеры будут выполняться благодаря заложенному в ядро ОС переключению контекста вычислений, разделяя мощность одного или нескольких ядер ЦПУ. В этом случае, в зависимости от особенностей кода, тоже можно получить прирост производительности всего приложения, пусть и не настолько впечатляющий, как при одном воркере на ядро. Каждый форкнутый процесс имеет свою память с собственными экземплярами V8. Однако при высокой интенсивности вычислительной нагрузки эффект может быть и обратным. Есть ряд ограничивающих факторов, которые на практике определяют число форкнутых процессов, которое можно создать. — Примеч. науч. ред.
178  Глава 9. Масштабирование в Node этими форками, сбалансировать нагрузку на все запросы, поступающие в приложение во всех форках, и при необходимости перезагрузить все форки. node:cluster — это помощник для вертикальной реализации стратегии клонирова- ния на одном сервере. Если у вас мощный сервер с большим запасом ресурсов или если намного проще и дешевле нарастить ресурсы одному серверу, чем добавлять новые, node:cluster — отличный способ быстро имплементировать в ваше приложение стратегию клонирования. Обычно даже у небольших серверов есть несколько процессорных ядер, и даже если вы не ожидаете большой нагрузки, я все равно советую использовать модуль node:cluster, чтобы повысить доступность и отказоустойчивость вашего сервера. Этот маленький шаг даст вам множество преимуществ. Пользоваться возможностями модуля node:cluster просто. Сначала нужно создать основной процесс, который форкает несколько рабочих процессов (воркеров, worker) и управляет ими. Каждый воркер — это инстанс приложения. Все входящие запросы принимает основной процесс, и именно он решает, какому воркеру их передать. Задача основного процесса проста: для выбора рабочего процесса он использует циклический алгоритм. Этот алгоритм используется по умолчанию на большинстве платформ, но такое поведение можно изменить, поручив балансировку ОС или реализовав собственную логику. Циклический алгоритм равномерно распределяет нагрузку по воркерам на основе ротации: первый запрос — первому воркеру, второй — следующему и т. д. Дойдя до конца списка, алгоритм начинает распределение сначала. Циклический алгоритм — один из простейших алгоритмов балансировки, но есть и другие. Наименьшее число соединений (Least Connections) Основной процесс отдает запрос воркеру с наименьшим количеством активных соединений. Это помогает равномерно распределить нагрузку. Взвешенный циклический алгоритм (Weighted round-robin) Воркерам назначаются веса; воркеры с большим весом получают больше запросов. Случайный (Random) Основной процесс случайным образом выбирает воркер для обработки запроса. Хеш по IP (IP Hash) Основной процесс использует IP клиента, чтобы определять, какой воркер будет обрабатывать его запросы.
Основной процесс и воркеры  179 Хеш по URL (URL Hash) Основной процесс использует запрошенный URL, чтобы определить, какой воркер должен обработать запрос. У каждого алгоритма маршрутизации есть свои сценарии, плюсы и минусы. Выбор алгоритма зависит от требований приложения и целевых сред, где оно работает. Основной процесс и воркеры Чтобы увидеть работу модуля node:cluster в действии, давайте возьмем простой HTTP-сервер. Создайте файл slow-server.js со следующим кодом: import { createServer } from 'node:http'; createServer((req, res) => { for (let i = 0; i < 1e8; i++) { // Симуляция работы ЦПУ: }; res.end(); }).listen(3000, () => { console.log(`Process ${process.pid}`); }); Обратите внимание: я добавил длинный цикл for, чтобы симулировать нагрузку на ЦПУ перед отправкой ответа. Используя модуль node:cluster, мы будем работать с несколькими процессами, поэтому я вывожу в лог идентификатор процесса (process.pid) при старте HTTPсервера, чтобы видеть, какие процессы создаются. Прежде чем создавать кластер для этого сервера и форкать много рабочих процессов, давайте проведем простое нагрузочное тестирование. По сути, нагрузочное тестирование — это отправка большого количества запросов на сервер, чтобы определить, сколько их максимально возможно обработать за единицу времени. Существует множество инструментов для нагрузочного тестирования — от простых вроде ApacheBench до полнофункциональных, таких как Artillery. Чтобы упростить пример, мы будем использовать простой npm-пакет loadtest. После установки пакета запустите HTTP-сервер, а затем выполните команду loadtest (в другом терминале), чтобы провести нагрузочное тестирование: $ node slow-server.js $ npx loadtest http://localhost:3000/
180  Глава 9. Масштабирование в Node На рис. 9.1 показано сводное представление производительности, которое я получил при тестировании на своем компьютере. На моем компьютере один сервер на Node смог обработать примерно 15 запросов в секунду. На других машинах результат может отличаться. Рис. 9.1. Сводка производительности однопроцессного сервера Теперь, когда у нас есть эталонный результат для одного процесса, давайте посмотрим, как изменится производительность с модулем node:cluster. Вот код (в файле cluster.js), который создает основной процесс с помощью модуля node:cluster и заставляет его форкать множество рабочих процессов для запуска того же HTTP-сервера: import cluster from 'node:cluster'; import os from 'node:os';
Основной процесс и воркеры  181 if (cluster.isPrimary) { const cpus = os.availableParallelism(); console.log(`Forking for ${cpus} CPUs`); for (let i = 0; i < cpus; i++) { cluster.fork(); } } else { import('./slow-server.js'); } Сначала мы импортировали модули node:cluster и node:os. С помощью node:os мы определили, сколько параллельных процессов можно запустить, вызвав функцию availableParallelism. В зависимости от сервера эта функция вернет число от 1 до максимального количества доступных ядер ЦПУ (os.cpus().length). Нам нужно форкнуть один рабочий процесс на каждое доступное ядро процессора. Модуль node:cluster дает удобный булев флаг isPrimary, чтобы определить, загружается ли файл cluster.js как основной процесс. При первом запуске файла будет выполняться основной процесс, и флаг isPrimary будет равен true. В этом случае можно заставить основной процесс форкнуть сервер столько раз, сколько доступно ядер процессора методом cluster.fork в цикле. Таким образом, кластер использует всю доступную вычислительную мощность. Когда строка с cluster.fork выполняется из основного процесса, файл cluster.js запускается снова, но уже в режиме воркера с флагом isPrimary = false. В этом случае также устанавливается еще один флаг — isWorker, который вы можете проверять при необходимости. Когда приложение работает как воркер, оно может выполнять реальную работу. Здесь мы определяем логику сервера, что в нашем примере делается импортом файла slow-server.js. Вот и все. Этого достаточно, чтобы использовать всю вычислительную мощность сервера. Чтобы протестировать кластер, запустите файл cluster.js: $ node cluster.js Forking for 10 CPUs Process 15601 Process 15602 Process 15606 Process 15607 Process 15604 Process 15605 Process 15603 Process 15609 Process 15610 Process 15608
182  Глава 9. Масштабирование в Node На моем компьютере было 10 ядер, поэтому цикл форкнул 10 рабочих процессов. Важно понимать, что эти форки — полностью самостоятельные процессы Node. Каждый форкнутый рабочий процесс имеет собственное пространство памяти и свой цикл событий. Когда сервер получает множество запросов благодаря встроенному балансировщику нагрузки в node:cluster, запросы распределяются между разными рабочими процессами с разными ID. Рабочие процессы не будут вызываться строго по очереди, так как node:cluster оптимизирует нагрузку при выборе следующего воркера, но нагрузка все равно распределяется между ними. Теперь давайте используем ту же команду loadtest, чтобы измерить производительность кластера (рис. 9.2). Рис. 9.2. Сводка производительности серверного кластера Теперь тот же компьютер, который справлялся только с 15 запросами в секунду в одном процессе, обрабатывает 123 запроса в секунду! Производительность
Передача сообщений  183 увеличилась больше чем в восемь раз, и чтобы этого добиться, достаточно было добавить несколько строк кода1. Нам не пришлось запускать рабочие процессы на разных портах. Модуль node:cluster сам обеспечивает совместное использование портов рабочими процессами. Передача сообщений Общение между основным процессом и воркерами простое, потому что внутри модуль node:cluster использует API child_process.fork. Это означает, что между основным процессом и каждым воркером доступны каналы межпроцессного взаимо­действия. Мы можем получить список объектов воркеров через cluster.workers — это объект, который содержит ссылки на все воркеры и позволяет читать информацию о них. Так как каналы связи между основным процессом и воркерами уже настроены, мы можем рассылать сообщения всем воркерам с помощью простого цикла и метода send для каждого воркера: Object.values(cluster.workers).forEach(worker => { worker.send(`Hello Worker ${worker.id}`); }); Метод Object.values используется, чтобы получить массив всех воркеров из объекта cluster.workers. Затем для каждого воркера нужно вызвать функцию send, чтобы передать любое нужное нам значение. В файле воркера (например, slow-server.js) для чтения сообщений, поступающих от основного процесса, можно зарегистрировать обработчик события message на объекте process. Вот пример: process.on('message', msg => { console.log(`Message from primary: ${msg}`); }); На рис. 9.3 показано, что я увидел, когда протестировал эти два изменения в примере cluster.js/slow-server.js. Перед отправкой я добавил setTimeout, чтобы дать время дочерним процессам запуститься2. 1 2 Несмотря на использование десяти ядер ЦПУ, производительность приложения увеличилась в 7 раз, поскольку существуют накладные расходы на создание дочерних процессов. — Примеч. науч. ред. Использование фиксированных таймаутов для подобных целей является антипаттерном. Более правильным подходом было бы дождаться сигнала от самого воркера о том, что он готов.: См. https://nodejs.org/docs/latest/api/cluster.html#event-online_1. — Примеч. науч. ред.
184  Глава 9. Масштабирование в Node Рис. 9.3. Сообщения между основным и рабочими процессами Каждый воркер получил сообщение от основного процесса. Сделаем пример более приближенным к практике. Допустим, нам нужно, чтобы HTTP-сервис отвечал количеством пользователей в базе данных. Чтобы упростить задачу, используем фейковую функцию вместо реального запроса к базе: // Фейки const dbUsersCount = (() => { let count = 1; return () => { count = 2 * count;
Передача сообщений  185 return count; }; })(); Эта функция возвращает 2 при первом вызове, а затем каждый раз удваивает значение. Это нужно только для теста. В реальном случае функция делала бы запрос в базу, и нам важно избежать множества запросов от разных воркеров. Можно было бы реализовать кэширование в воркерах и обновлять значение один раз в определенный промежуток времени (например, каждые 60 секунд). Это более эффективный способ, но следует помнить, что при 10 воркерах каждые 60 секунд делалось бы 10 запросов в базу данных. А представьте, как было бы здорово, если бы отдельный процесс выполнял запрос и рассылал результат всем воркерам раз в 60 секунд. Для этого можно совместить стратегию клонирования со стратегией декомпозиции! Для простоты сделаем так, чтобы основной процесс выполнял подсчет пользователей в базе (в идеале этим должен заниматься отдельный сервис). Мы вызовем dbUsersCount в cluster.js и будем один раз в 60 секунд рассылать результат всем воркерам через цикл. Для этого я изменил файл cluster.js следующим образом: // В ветви cluster.isPrimary: const dbUsersCount = (() => { let count = 1; return () => { count = 2 * count; return count; }; })(); const updateWorkers = () => { const usersCount = dbUsersCount(); Object.values(cluster.workers).forEach((worker) => { worker.send({ usersCount }); }); }; updateWorkers(); setInterval(updateWorkers, 60_000); Таким образом, каждые 60 секунд все воркеры будут получать обновленное число пользователей через канал IPC, а в базу при этом будет уходить только один запрос. В коде сервера мы можем сохранять полученное значение usersCount в переменную верхнего уровня и использовать его где угодно в модуле.
186  Глава 9. Масштабирование в Node Для этого я изменил файл slow-server.js следующим образом: let usersCount; createServer((req, res) => { for (let i = 0; i < 1e8; i++) { // Симуляция работы ЦПУ: }; res.write(`Process ${process.pid}\n`); res.end(`Users: ${usersCount}`); }).listen(3000, () => { console.log(`Process ${process.pid}`); }); process.on('message', (msg) => { usersCount = msg.usersCount; }); Если протестировать кластерный код сейчас, то первые 60 секунд все воркеры будут возвращать число пользователей (2) и в базу пойдет только один запрос. Через 60 секунд все воркеры начнут возвращать число 4, и в базу снова пойдет всего один запрос. Все это возможно благодаря каналам связи между основным процессом и воркерами. Повышение доступности Одна из проблем работы приложения Node в одном инстансе заключается в том, что при падении его нужно перезапускать. Это вызывает простой сервера между остановкой и запуском, даже если процесс мониторится и перезапуск автоматизирован. То же самое происходит, когда сервер нужно перезапустить для обновления приложения. При одном инстансе всегда будет простой, который снижает доступность системы. Но если инстансов несколько, доступность можно легко повысить буквально несколькими строками кода. Давайте посмотрим как. Для начала, чтобы пример был более наглядным, давайте смоделируем случайный сбой в серверном процессе. Мы можем выполнить вызов process.exit внутри таймера, который сработает через случайный промежуток времени: // В slow-server.js setTimeout(() => { process.exit(1); }, Math.random() * 10_000);
Повышение доступности  187 Когда воркер завершается таким образом, основной процесс получает уведомление через событие exit на объекте node:cluster. Мы можем подписаться на это событие и просто форкнуть новый воркер взамен упавшего: // В ветви cluster.isPrimary: cluster.on('exit', (worker) => { if (worker.exitedAfterDisconnect === true) return; console.log( `Worker ${worker.id} crashed.` + 'Starting a new worker...', ); cluster.fork(); }); Обратите внимание: я добавил условие if, чтобы убедиться, что воркер действительно упал, а не был отключен вручную или завершен самим основным процессом. Например, основной процесс после наблюдения за паттернами нагрузки может решить, что потребление ресурсов слишком велико и ему нужно «убить» несколько воркеров. Для этого можно вызвать метод disconnect у любого объекта воркера — тогда флаг exitedAfterDisconnect будет равен true, и наше условие if не позволит форкать новый воркер в таком сценарии. Если запустить cluster.js с этим обработчиком и случайным падением в slowserver.js , то через некоторые промежутки времени воркеры начнут падать, а основной процесс будет сразу форкать новые, тем самым повышая доступность системы. Эту доступность можно измерить той же командой loadtest и посмотреть, сколько запросов сервер в целом не сможет обработать (потому что часть «неудачных» запросов столкнется с моментом падения). На рис. 9.4 показан вывод loadtest на моем компьютере в момент случайных падений. С ошибкой завершились лишь 15 запросов из 1228 — это более чем 98 % доступности1. И этого удалось достичь благодаря нескольким простым строкам кода. Теперь можно больше не переживать о падениях рабочих процессов: основной процесс будет следить за воркерами, которые завершились с ошибкой, и при необходимости подменять их новыми. 1 Как правило, в продуктовых окружениях показателем высокой доступности является доступность сервисов 99,9 % и выше. — Примеч. науч. ред.
188  Глава 9. Масштабирование в Node Рис. 9.4. Сводка производительности во время случайных падений сервера Перезапуски без простоев Во время релиза новой версии приложения все процессы Node нужно перезапустить. Во время перезапуска запросы к этим процессам будут завершаться с ошибкой. В кластере вместо того, чтобы перезапускать все рабочие процессы одновременно, мы можем делать это по одному. Таким образом, пока один воркер перезапускается, остальные продолжают обслуживать запросы и простоя сервера вообще не будет. И это очень просто реализовать с помощью модуля node:cluster. Давайте посмотрим как. Так как мы не хотим перезапускать основной процесс после его запуска, поскольку это вызовет полный простой всех воркеров, нужно отправить основному процессу команду, чтобы он начал перезапуск воркеров по одному.
Перезапуски без простоев  189 Это можно сделать разными способами, в зависимости от ОС, на которой работает сервер. В Linux мы можем слушать сигналы, посылаемые процессу, например SIGUSR1 или SIGUSR2. Эти сигналы можно отправить процессу с помощью команды kill, например так: $ kill -SIGUSR2 PID В Node мы можем слушать сигнал SIGUSR2 как событие объекта process и внутри его обработчика выполнять любые действия: process.on('SIGUSR2', () => { // Какие-либо действия }); Не используйте SIGUSR1 — Node применяет его для отладки. Кроме того, в Windows такие сигналы процессов не поддерживаются, а следовательно, для этой ОС придется искать другой способ отдавать команды основному процессу. Есть альтернативы: можно использовать стандартный ввод или сокеты, а также мониторить наличие файла process.pid и отслеживать его удаление. Теперь, когда мы знаем, как отдавать команды основному процессу, можно реализовать перезапуск, отключив воркер и форкнув новый воркер на его замену. Но так как мы хотим отключать воркеры по одному, процесс должен проходить так: мы отключаем один воркер, ждем, форкаем новый воркер, ждем, пока новый воркер начнет принимать запросы, отключаем следующий воркер и т. д. У рабочего процесса есть событие exit, похожее на событие node:cluster, но относящееся к конкретному воркеру. Оно означает, что этот воркер завершился. Мы можем использовать это событие, чтобы запустить форк следующего воркера. У воркера также есть событие listening, которое срабатывает после вызова listen в этом воркере. Его можно использовать, чтобы начать отключение следующего воркера. Вот все изменения, которые я внес в cluster.js, чтобы реализовать поэтапную перезагрузку всех воркеров: // В ветке cluster.isPrimary: console.log( `To restart workers, use: kill -SIGUSR2 ${process.pid}`, ); process.on('SIGUSR2', () => { const workers = Object.values(cluster.workers); const restartWorker = (workerIndex) => { const worker = workers[workerIndex]; if (!worker) return;
190  Глава 9. Масштабирование в Node worker.on('exit', () => { if (worker.exitedAfterDisconnect === false) return; console.log(`Exited process ${worker.process.pid}`); cluster.fork().on('listening', () => { restartWorker(workerIndex + 1); }); }); worker.disconnect(); }; restartWorker(0); }); Массив workers содержит список всех воркеров кластера. Функция restartWorker получает индекс воркера для перезапуска, начиная с 0. Таким образом, перезапуск выполняется последовательно: функция вызывает саму себя с индексом следующего воркера, когда будет готова для следующего рабочего процесса. В функции restartWorker мы получаем ссылку на воркер, который нужно перезапустить. Затем мы отключаем этот воркер (с помощью worker.disconnect1), но до перезапуска следующего воркера нужно форкнуть новый на замену текущему. Для этого используем событие exit на текущем воркере: при его завершении форкаем новый. Так как функция вызывается рекурсивно, чтобы сформировать последовательность, нам нужно условие выхода. Когда воркеров для перезапуска больше нет, функция просто возвращает управление. Флаг exitedAfterDisconnect гарантирует, что процесс завершился именно из-за нашего .disconnect(), а не из-за фатальной ошибки (crash). Если флаг равен false, значит, выход вызван чем-то другим, и тогда ничего делать не нужно. Но если он равен true, можно форкнуть новый воркер для замены. Когда новый форкнутый воркер готов (вы поймете это по событию listening), можно перезапустить следующий, вызвав функцию restartWorker снова, но уже с индексом следующего воркера. Это все, что нужно для перезапуска без простоя. Чтобы протестировать результат, я добавил в начало вызов console.log для вывода ID процесса кластера. Выполнение команды kill -SIGUSR2 PID, выведенной в консоль, запускает перезагрузку воркеров. 1 worker.disconnect не убивает процесс сразу. Он посылает воркеру вежливую просьбу завершить работу. Воркер перестает принимать новые соединения (в приведенном примере — новые HTTP-запросы), но продолжает обрабатывать уже существующие. Этот метод является ключом к zero-downtime. На практике может возникнуть ситуация, когда длительные серверные подключения будут мешать воркерам отключиться. — Примеч. науч. ред.
Работа с состоянием  191 Чтобы посмотреть, как перезапуск без простоя выглядит в действии, перед выполнением команды kill запустите ту же самую команду loadtest. Результаты теста смотрите на рис. 9.5. Рис. 9.5. Сводка производительности во время перезапуска воркеров Во время перезагрузки этого кластера не упал ни один запрос! Работа с состоянием Одна из ключевых задач в масштабируемой среде — это управление состоянием в памяти и вообще любыми состояниями в коммуникациях.
192  Глава 9. Масштабирование в Node Так как у воркеров собственная область памяти, мы не можем закэшировать данные только в одном воркере, поскольку остальные не будут иметь доступа к этому значению. Если вам нужно закэшировать данные в масштабируемой среде, необходимо применять отдельную сущность и осуществлять чтение/запись в ее API из всех рабочих процессов. Такой сущностью может быть, например, сервер базы данных, сервис вроде Redis или выделенный процесс Node с API для чтения/записи, к которому обращаются все воркеры. Это называется распределенным кэшированием (distributed caching) и является примером декомпозирования. Советую делать это, даже если приложение работает всего на одном ядре. Другой пример управления состоянием — аутентификация пользователей и управление пользовательскими сессиями. В масштабируемой среде запрос на аутентификацию поступает в основной процесс и передается одному из воркеров. Этот процесс «помнит» состояние пользователя. Однако при следующем запросе балансировщик нагрузки может отправить его к другому воркеру, который не знает, что этот уже аутентифицирован. Хранить данные о сессии аутентифицированного пользователя в одном процессе больше не получится. Вместо этого нужно делать воркеры без сохранения состояния, а всю информацию о состоянии хранить во внешних сущностях. Еще один способ решить проблему сессий пользователей — так называемые липкие сессии (sticky sessions). Этот способ гораздо менее эффективен, чем внешний сервис, но значительно проще в реализации. Можно применить несложный алгоритм маршрутизации, например IP-хэш. «Липкие сессии» — простой пример стратегии разделения при масштабировании. Когда пользователь аутентифицируется в одном воркере, запись об этом сохраняется в основном процессе. При последующих запросах этого пользователя система снова направляет его в тот же рабочий процесс. Таким образом, код на сервере можно не менять. Хотя алгоритм маршрутизации IP-hash не поддерживается напрямую модулем node:cluster, реализовать его довольно просто. Мы можем разместить все воркеры в массиве, преобразовать IP-адрес входящего запроса в индекс этого массива и передать запрос соответствующему воркеру. Вот базовая реализация: import os from 'node:os'; import { fork } from 'node:child_process'; import { createServer } from 'node:http'; const cpus = os.availableParallelism(); const workers = []; for (let i = 0; i < cpus; i++) { workers.push(fork('./slow-server.js')); }
Менеджеры процессов  193 function getWorkerIndex(ip) { const hash = ip.split('.').reduce( // Получение хэшей октетов IP-адреса и их комбинация (hash, part) => Number(part) + 256 * hash, ); return hash % cpus; } createServer((req, res) => { const ip = req.socket.remoteAddress; const workerIndex = getWorkerIndex(ip); workers[workerIndex].send({ req, res }); // Логика межпроцессного взаимодействия }).listen(3000); Алгоритм балансировки IP-hash легко реализовать, и его применение может быть уместно и эффективно, например, при работе с маленьким приложением с несколькими воркерами. Однако этот подход далеко не идеален: он плохо масштабируется и может привести к неравномерному распределению нагрузки. С точки зрения распределения нагрузки и масштабируемости лучше держать воркеры без сохранения состояния, а состоянием в данном случае управлять из внешнего сервиса. Алгоритмы балансировки нагрузки на основе «липких сессий», например IPхэш, можно легко настроить и применить во внешних балансировщиках нагрузки, таких как nginx и многих других. Менеджеры процессов Если вы не хотите управлять кластером самостоятельно, можно воспользоваться одним из продвинутых менеджеров процессов для Node, распространяемых в виде пакетов. Эти пакеты оборачивают модуль node:cluster и предоставляют инструмент командной строки для упрощенного управления форкнутыми процессами. Один из популярных менеджеров процессов для Node — это PM2. Многие его функции платные, однако базовые возможности запуска и управления кластерами доступны бесплатно. Установите пакет pm2 через npm и посмотрите его справку (с опцией -h). У него есть множество команд и опций для запуска, мониторинга и управления кластером. Например, чтобы запустить кластер воркеров, достаточно вызвать Node-процесс командой start и указать опцию -i с числом форков или -i max для максимально возможного значения. $ npx pm2 start script.js -i max
194  Глава 9. Масштабирование в Node Чтобы выполнить перезапуск с нулевым временем простоя, вы можете использовать команду reload: $ npx pm2 reload all Просмотреть список всех рабочих процессов с указанием их статуса и загрузки можно с помощью команды list: $ npx pm2 list Выводы Масштабирование в Node — это базовая концепция. Именно поэтому Node и получила свое название. Существует множество стратегий масштабирования: клонирование, декомпозиция и разделение. В этой главе основное внимание уделим стратегии клонирования. Встроенный модуль node:cluster позволяет создать основной процесс, который порождает множество воркеров и управляет ими. Он может запускать и останавливать их, передавать и принимать данные. Воркеры способны разделять порты, а главный процесс распределяет нагрузку между ними, повышая производительность сервера. Нагрузка может распределяться между рабочими процессами разными способами. Модуль node:cluster применяет простой циклический алгоритм маршрутизации для балансировки входящих запросов, но можно использовать и другие алгоритмы. Кэширование и управление состоянием реализуется как через внешние сервисы, так и с помощью других алгоритмов маршрутизации. Основной процесс также может мониторить состояние воркеров, мгновенно заменять упавших и перезапускать их по одному. Это повышает доступность и отказоустойчивость сервера. В следующей главе мы рассмотрим инструменты, которые помогают сделать процессы разработки более эффективными и упростить создание, тестирование, деплой и поддержку кода Node.
ГЛАВА 10 Node на практике Хотя основные модули Node созданы для разработки бэкенд-сервисов, их гибкость, асинхронные API и простая интеграция с внешними средами делают Node отличной платформой для запуска полезных рабочих инструментов как бэкенда, так и фронтенда. В этой главе мы рассмотрим некоторые из этих инструментов и поймем, какую роль они играют в создании, тестировании, развертывании и поддержке Node-проектов. Речь идет о целом наборе разнообразных средств, начиная с небольших библиотек для узких задач и заканчивая мощными фреймворками, которые можно использовать для фулстек-разработки. Для каждой цели можно подобрать множество инструментов. В этой главе мы разберем самые популярные и изучим основы работы с ними. Мы поговорим об инструментах контроля качества кода, бандлерах модулей, таск-раннерах, веб-фреймворках, а также о расширениях и транспайлерах JavaScript. Инструменты контроля качества кода В главе 1 мы рассмотрели несколько инструментов Node, которые помогают во время жизненного цикла приложения. Давайте подробнее остановимся на двух важнейших для качества кода — Prettier и ESLint. Они оба очень полезны, и я считаю, что без них не должен обходиться ни один проект на Node. Эти инструменты просты, легко интегрируются в проекты, разработка с их помощью идет быстрее, а код становится чище и единообразнее. Зачем отказываться от того, что позволяет делать работу лучше? Prettier нужен для автоматизации форматирования кода. Как бы хорошо вы ни редактировали код вручную, без Prettier форматирование будет непоследовательным. Кроме того, зачем вообще тратить время на ручную редактуру? И зачем спорить о стиле форматирования в код-ревью? Prettier умеет форматировать код не только на JavaScript. В проектах фронтенда его можно использовать для CSS, HTML, JSON, GraphQL и даже Markdown.
196  Глава 10. Node на практике ESLint нужен, чтобы обеспечить правила качества кода и найти любые потенциальные ошибки еще на этапе его ввода. Его можно сравнить с детективом, который всегда рядом, внимательно наблюдает за всем, что вы делаете, и подсказывает вам верное решение каждый раз, когда вы делаете что-то неправильно. ESLint очень гибок. Его можно сделать сверхстрогим или, наоборот, максимально свободным. Вы можете задать свои правила или использовать рекомендуемые, настроить автоматическое исправление ошибок или ограничиться выводом преду­преждений. Можно указать, какие нарушения игнорировать — в целом или в отдельных случаях. Существует множество наборов рекомендуемых правил, созданных разными сообществами разработчиков. Лично я предпочитаю правила из комплекта поставки ESLint. Ознакомиться с полным списком правил ESLint и узнать, какие из них советуют применять, вы можете на сайте ESLint ( https://eslint. org/docs/rules). Есть шанс, что вы не поймете часть рекомендуемых правил. Взгляните на это как на возможность узнать что-то новое! Эти правила существуют не просто так. Изучите их и разберитесь, зачем они нужны. Лучший способ интегрировать Prettier и ESLint в работу — запускать их при каждом сохранении кода. Кто-то предпочитает делать это прямо во время набора текста. Кто-то — перед отправкой изменений в репозиторий. Я выбираю первый вариант. Во многих редакторах кода есть плагины и настройки, позволяющие запускать эти инструменты при сохранении или наборе кода. Интеграция Prettier и ESLint в редактор кода — это здорово, но проект также должен автоматически контролировать их использование. Плагин редактора может работать некорректно — например, потому что кто-то забыл его включить или случайно сохранил файл, когда он был выключен. Принудительный контроль может выполняться локально с помощью хуков до отправки кода в репозиторий или удаленно — уже после отправки, но до принятия изменений. Позже в этой главе мы рассмотрим инструменты, которые можно использовать для такого контроля. Пакет prettier в npm устанавливает инструмент командной строки, который можно запускать, чтобы форматировать код и проверить правильность его форматирования. То же самое относится и к пакету eslint. Эти пакеты частично перекрывают друг друга по функционалу. Например, требование использовать или не использовать точки с запятой в JavaScript можно задать в обоих. Но это лишь малая часть функционала, так что беспокоиться не о чем. ESLint и Prettier отлично работают вместе как для бэкенд-, так и для фронтендкода, и их стоит устанавливать в рабочую среду одними из первых.
Инструменты контроля качества кода  197 Существуют аргументы против использования Prettier и ESLint. Да, иногда они могут раздражать. Они не всегда работают идеально. Иногда заставляют переделывать код, который и так работает нормально. Иногда на них уходит лишнее время. Но на мой взгляд польза, которую приносят эти инструменты, в большинстве случаев перевешивает небольшие недостатки. К тому же со временем они становятся лучше, и их можно настроить так, чтобы при необходимости они были более гибкими. Prettier Чтобы использовать Prettier в проекте Node, его можно установить через npm: $ npm i -D -E prettier Важно: это зависимость для разработки. В продуктовом окружении она никакой роли не играет. Также обратите внимание на флаг -E, который сохраняет зависимость в виде точной версии. Он гарантирует, что все участники проекта будут использовать одну и ту же версию Prettier, поскольку даже патч-релизы Prettier могут слегка менять форматирование. Prettier все же необходимо периодически обновлять. Делать это нужно отдельным коммитом, посвященным только этой задаче. У Prettier есть набор настроек по умолчанию, и его можно использовать прямо «из коробки». Но если нужно настроить работу под проект, можно сделать это одним из следующих способов: ключ prettier в package.json; файл модуля JavaScript с именем prettier.config.js; конфигурационный JSON-файл с именем .prettierrc. Конечно, есть и другие способы настроить Prettier, но эти три — самые распространенные. Вот пример JSON-конфигурации для Prettier: { } "arrowParens": "always", "quoteProps": "consistent", "singleQuote": true, "trailingComma": "es5" Например, параметр singleQuote форматирует код таким образом, чтобы в строках использовались одинарные кавычки вместо двойных. Значение always для arrowParens форматирует код так, чтобы в нем всегда стояли круглые скобки в стрелочных функциях, даже если они необязательны. С полным списком всех опций и их значением вы можете ознакомиться в документации Prettier (https:// prettier.io/docs/en).
198  Глава 10. Node на практике Чтобы использовать Prettier, запустите команду prettier с параметром --write для записи файлов с новым форматированием или с параметром --check для проверки корректности форматирования. Второй вариант удобно выбирать, чтобы постоянно контролировать стиль кода в проекте. Команду prettier можно запускать на отдельный файл, папку или на все папки проекта, указав в качестве цели точку .: $ npx prettier -w . ESLint Чтобы использовать ESLint в проекте Node, нужно установить его и добавить файл конфигурации. Это можно сделать одной командой: $ npm init @eslint/config@latest После ввода вам нужно будет ответить на несколько вопросов. На основе ваших ответов команда создаст файл eslint.config.js. Также она установит ESLint и связанные пакеты как зависимости для разработки. В конфигурационном файле можно настраивать глобальные переменные, плагины и правила ESLint. Вот пример такого файла: import globals from 'globals'; import pluginJs from '@eslint/js'; export default [ { languageOptions: { globals: { ...globals.browser, ...globals.node }, }, }, pluginJs.configs.recommended, { rules: { 'curly': 'error', 'no-else-return': 'error', 'no-unneeded-ternary': 'error', 'no-useless-return': 'error', 'no-var': 'error', 'prefer-const': 'error', 'yoda': ['error', 'never', { exceptRange: true }], }, }, ]; По умолчанию ESLint предупреждает, если в коде используется необъявленная переменная. В этой конфигурации сделано исключение для глобальных переменных в среде Node и браузера, так что объекты вроде process, console и window будут разрешены.
Инструменты контроля качества кода  199 Рекомендуемые правила добавлены через плагин @eslint/js и дополнены набором кастомных правил, которые не входят в стандартные. Также можно переопределять рекомендации с помощью собственных правил. Вот примеры кастомных правил ESLint: curly — требует, чтобы все блоки были заключены в фигурные скобки, даже если это синтаксически не обязательно; no-var — запрещает использовать ключевое слово var для объявления пере- менных; yoda — запрещает использовать «условия Йоды»1 в if. Обратите внимание: у этого правила есть дополнительная настройка — исключение для диапазонных условий. Если вас заинтересовало какое-то правило, его можно найти в справочнике ESLint (https://eslint.org/docs/rules). Как только ESLint будет установлен и настроен для проекта, редактор с поддержкой ESLint сразу начнет подсвечивать вам любые проблемы. Кроме того, в пакете есть команда, которую можно запускать локально (или в CI-пайплайне), чтобы проверить, есть ли ошибки: $ npx eslint . Другие инструменты Prettier и ESLint — отличные инструменты, которые повышают качество кода, но в проектах Node есть и другие полезные средства для работы с качеством. Фреймворки для тестирования Несмотря на то что в Node есть хорошие встроенные основы для тестирования, существует множество библиотек для тестирования, которые предлагают гораздо больше функций и понятных в использовании API. Самые известные из них — Mocha и Chai, но лично мне больше нравится Jest. Это простая 1 Обычное условие (естественное для человека): if (age >= 18) { // Какие-либо действия } Условие Йоды (перевернутое): if (18 <= age) { // Какие-либо действия } — Примеч. науч. ред.
200  Глава 10. Node на практике и функцио­нальная библиотека, которая подходит как для бэкенда, так и для фронтенда. Инструменты статической типизации Динамическая типизация в JavaScript создает ощущение гибкости, но на практике отсутствие статической типизации (ее можно проверить на этапе компиляции) открывает путь ко множеству скрытых ошибок в коде. Поэтому появились инструменты, добавляющие в JavaScript статическую типизацию. Самый популярный и продвинутый из них — TypeScript. Редакторы и AI-ассистенты Современный редактор сильно влияет на качество кода. Такие редакторы, как WebStorm, Atom1 и Visual Studio Code (VS Code), предоставляют много функций: умное автодополнение и навигацию по коду, обнаружение ошибок, встроенную отладку, работу с системой контроля версий и многое другое. AIассистенты, такие как GitHub Copilot, делают эти возможности еще мощнее. Они используют машинное обучение на основе открытого исходного кода, чтобы давать рекомендации по улучшению кода с учетом контекста, лучших практик и стандартов. Бандлеры модулей В фронтенд-среде, например в веб-браузере, запрос ресурса (JavaScript- или CSS-файла) с сервера — это асинхронная задача, которая выполняется через сеть. Каждый сетевой запрос влияет на производительность браузера и качество пользовательского опыта. Особенно это заметно на мобильных устройствах и устройствах с ограниченными ресурсами и соединением. При разработке фронтенд-приложений стоит тестировать их в условиях ограниченных ресурсов и соединения. Например, браузеры вроде Chrome предоставляют режим троттлинга (искусственного ограничения скорости), позволяющий замедлять сеть и процессор. Эти ограничения помогают понять, как приложение будет работать на слабых устройствах, и принимать более взвешенные решения, а также обрабатывать ситуации, о существовании которых вы можете не подозревать. Хотя модули можно публиковать по отдельности — современные браузеры поддерживают импорт модулей, — все же стоит стремиться минимизировать количество и размер ресурсов, необходимых для запуска приложения на клиентских устройствах. 1 На момент перевода книги объявлен устаревшим и более не поддерживается разработчиками. Развитие продолжается в проекте Pulsar: https://pulsar-edit.dev/. — Примеч. науч. ред.
Бандлеры модулей  201 Вот несколько распространенных практик, которые помогают уменьшить количество и размер ресурсов приложения. Объедините все ресурсы, необходимые для отображения первого экрана приложения, и отдайте их одним запросом. Это называется бандлингом (bundling), а получившийся файл — это бандл (bundle). Максимально сократите код в бандле: удалите пробелы и сократите имена переменных. Ваш исходный код должен быть читаемым, но код, который отправляется в браузер, таким быть не обязан. Удалите неиспользуемый код. Это может быть как ваш собственный код (например, функция, которую вы определили, но никогда не вызывали), так и код сторонних библиотек. Например, если вы установили библиотеку lodash , а использовали лишь несколько ее функций. Остальные функции в бандле не нужны. Весь неиспользуемый код можно удалить, чтобы уменьшить размер бандла. Этот процесс называется tree shaking. После загрузки первого экрана приложение может подгружать дополнительные бандлы в зависимости от действий пользователя. Вместо того чтобы отправлять весь код приложения одним файлом, можно разбить его по функционалу на разные бандлы и загружать их по мере необходимости. Это называется разделением кода. В Node есть множество инструментов, которые могут помочь реализовать все эти и другие практики (например, Webpack, Parcel и Rollup). Давайте рассмотрим базовое использование Webpack для бандлинга модулей. Webpack разделен на два основных пакета: библиотека ядра (webpack) и интерфейс командной строки (webpack-cli). Установите их как dev-зависимости: $ npm i -D webpack webpack-cli Чтобы запустить Webpack, нужно указать точку входа (entry point) — первый файл, с которого Webpack начнет сборку. Это начало графа зависимостей. Команду webpack можно запустить даже без конфигурации, если точка входа вашего приложения — src/index.js. Если у приложения несколько точек входа, Webpack сможет собрать несколько бандлов. С помощью загрузчиков Webpack способен обрабатывать разные типы файлов перед бандлингом, например CSS-файлы и их расширения, Babel, TypeScript, SVG, JSX, GraphQL и многое другое. У Webpack удобная архитектура для плагинов. Он поддерживает использование внешних модулей, которые могут «встраиваться» в его жизненный цикл. С их
202  Глава 10. Node на практике помощью можно определять глобальные константы, копировать файлы, игнорировать модули и даже интегрировать Webpack с ESLint и Prettier. Вот упрощенный пример конфигурации Webpack для проекта, который использует TypeScript и расширение CSS — Sass: const webpack = require('webpack'); const CopyPlugin = require('copy-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { entry: { app: './src/app.ts', search: './src/search.ts', }, output: { filename: '[name].ts', path: __dirname + '/dist', }, module: { rules: { test: /\.ts$/, exclude: /node_modules/, use: { loader: 'ts-loader' }, }, { test: /\.scss$/, exclude: /node_modules/, use: [ MiniCssExtractPlugin.loader, { loader: 'css-loader', options: { url: false } }, 'sass-loader', ], }, ], }, plugins: [ new CopyPlugin({ patterns: [{ from: 'src', to: 'dist' }], }), new webpack.DefinePlugin({ 'PRODUCTION': JSON.stringify(process.env.NODE_ENV === 'production'), 'process.env.API_KEY': JSON.stringify(process.env.API_KEY), }), ], }; Эта конфигурация указывает Webpack использовать две точки входа и собрать два бандла с соответствующими именами. Для всех файлов с расширением .ts
Таск-раннеры  203 применяется загрузчик TypeScript, а для файлов .scss — загрузчик Sass. CSSфайлы выносятся в отдельные бандлы при помощи загрузчика из пакета mini-cssextract-plugin. Для копирования статических файлов из src в dist используется copy-webpack-plugin. Флаг PRODUCTION задается с помощью встроенного плагина DefinePlugin, а переменная окружения process.env.API_KEY передается внутрь бандла. Как видите, Webpack — очень гибкий инструмент, который предоставляет множество опций и возможностей для настройки. Другие бандлеры модулей предлагают схожий или более широкий набор функций и отличаются по производительности и размеру итогового бандла. Благодаря мультиплексированию, сжатию и возможности выдавать пушуведомления сервера протокол HTTP/2 снижает необходимость в бандлинге, так как браузеры умеют обрабатывать множество одновременных запросов, и лучше кэшировать ресурсы по отдельности. У бандлинга есть свои преимущества, но по мере развития браузеров придется искать баланс между тем, что объединять, и тем, что отправлять по отдельности. Таск-раннеры Такие инструменты таск-раннеров, как gulp и Grunt, используются для запуска и автоматизации повторяющихся задач в разных средах. Одни задачи нужны в процессе разработки, другие — в продуктовом окружении, а третьи — для деплоя изменений кода в разные окружения и на серверы. Вот примеры задач, которые нужно выполнять регулярно: форматирование кода и его проверка на ошибки; запуск тестов и анализ покрытия кода; минификация кода и создание бандлов; деплой кода в другие окружения; генерация отчетов и отправка писем. Выполнять эти задачи вручную долго. К тому же во время работы с ними можно допустить ошибку. Если описать задачи в коде и запускать их стандартным способом, это сэкономит вам время и защитит от ошибок. Также таск-раннеры могут повысить производительность задач — например, gulp по умолчанию использует потоки Node, благодаря чему работает быстрее, особенно с большими задачами и файловой системой. У задачи, определенной через таск-раннер, есть еще одно преимущество: при работе легче понять, что именно она делает, и в случае необходимости отладить ее.
204  Глава 10. Node на практике У gulp и Grunt есть плагины для определения типовых задач — например, если приложение использует расширение CSS — Sass, а вам нужно определить задачу для преобразования всех Sass-файлов в CSS. Это можно сделать через модульный бандлер (например, Webpack), но допустим, что для продуктового окружения вы хотите применить другой подход. В случае с gulp можно использовать такую задачу: const gulp = require('gulp'); const sass = require('gulp-sass')(require('sass')); gulp.task('sass', () => { return gulp.src('src/*.scss') // Исходные файлы .pipe(sass()) // Компиляция Sass в CSS .pipe(gulp.dest('dist')); // Папка для итоговых файлов }); А с Grunt — вот такую: module.exports = function(grunt) { grunt.initConfig({ sass: { dist: { files: { 'dist/app.css': 'src/app.scss' } } } }); grunt.loadNpmTasks('grunt-sass'); grunt.registerTask('default', ['sass']); }; Для простых задач можно использовать секцию scripts в package.json: "scripts": { "sass": "sass src/app.scss dist/app.css" } Также можно определять и запускать задачи в облаке с помощью сервисов CI/CD — таких как GitHub Actions, Travis CI, CircleCI и других. Эти сервисы предлагают более продвинутые и интегрированные пайплайны. Они легко настраиваются, и их можно выполнять в несколько потоков. Выбор между локальными средствами запуска задач и облачными сервисами зависит от ваших предпочтений, но в любом случае важно, чтобы задачи приложения были четко определены, легко выполнялись с помощью простых команд и в большинстве случаев были автоматизированы.
Таск-раннеры  205 Автоматизация в Node Чтобы получить максимум пользы от инструментов качества и продуктивности в Node, нужно автоматизировать рабочие процессы. Автоматизация — гарантия того, что код будет проверен целиком и вы ничего не забудете (хотя я в принципе не рекомендую полагаться на свою память). Также автоматизация экономит много времени. Не запускайте команды вручную — сделайте так, чтобы они выполнялись автоматически в нужный момент. Интегрируйте инструменты с редактором кода, чтобы они действовали прямо при вводе или сохранении. Когда вы работаете с бандлерами и фреймворками тестирования, используйте режим наблюдения. Настраивайте удаленные пайплайны CI/CD. Но не злоупотребляйте инструментами автоматизации: между продуктивностью и переусложнением лежит очень тонкая грань. Помимо автоматизации процессов, которые вам точно приходится повторять часто, обратите внимание на задачи, которые вы постоянно делаете вручную, и автоматизируйте и их тоже. Вот лишь некоторые инструменты, которые можно применять для улучшения и автоматизации локальных процессов в Node. npm pre- и post-скрипты Позволяют запускать скрипт до или после завершения другого скрипта. Например, скрипт pretest можно использовать для запуска ESLint перед запуском тестов. npm-run-all Позволяет запускать несколько сценариев npm параллельно или последовательно. Его можно использовать, например, для совместного запуска Prettier и ESLint. Husky Позволяет автоматически запускать команды на разных этапах жизненного цикла Git. Нужен, например, для запуска задачи gulp после каждого внесения изменений в Git или каждый раз, когда вы отправляете коммиты на слияние. Live Server Отслеживает изменения в файлах проекта веб-сервера и автоматически перезагружает браузер.
206  Глава 10. Node на практике Фреймворки В веб-разработке, хотя Node предоставляет встроенную поддержку HTTP через модули node:http и http2/https, большинство разработчиков предпочитает использовать для создания веб-серверов более высокоуровневые фреймворки. Встроенные возможности Node дают только базовые средства для строительства и запуска веб-серверов. Хотя их можно использовать в чистом виде, это не очень удобно: слишком много придется делать вручную, а код получится неструктурированным, повторяющимся и сложным в поддержке. Такие веб-фреймворки, как Express, Koa, hapi, AdonisJS 1 и другие, предлагают удобные API. Они дают более высокоуровневые абстракции, позволяя сосредоточиться на бизнес-логике. Эти фреймворки хорошо структурированы, проверены на практике и эффективны. Более того, сообщество активно развивает их, добавляя плагины и расширения. Одна из самых распространенных задач, с которой приходится сталкиваться при работе с веб-серверами, это маршрутизация (routing). Маршрутизация — это процесс определения того, как приложение реагирует на входящие запросы к определенным URL-адресам (или маршрутам) и HTTP-методам. Без веб-фреймворка можно проверить значение req.url и определить оператор switch для любого маршрута, который нужно поддерживать: import { createServer } from 'node:http'; const server = createServer(); server.on('request', (req) => { switch (req.url) { case '/': // Логика для вывода главной страницы break; case '/about': // Логика для вывода страницы /about break; // Ветвления для других страниц приложения } }); default: // Логика для ошибки 404 server.listen(3000, () => { console.log("Server running at http://localhost:3000/"); }); 1 Стоит упомянуть еще один популярный веб-фреймворк — fastify. — Примеч. науч. ред.
Фреймворки  207 Для нескольких простых страничек этого вполне достаточно, и к ним даже можно добавить еще немного, но это не лучший способ реализации роутинга. Представьте, что будет, если вдобавок нужно учитывать различные HTTP-методы: server.on('request', (req) => { switch (req.url) { // ... case '/user': switch (req.method) { case 'GET': // Получение данных пользователя break; case 'POST': // Создание пользователя break; case 'DELETE': // Удаление пользователя break; } break; } }); // ... Чтобы упростить задачу, здесь нужна более высокоуровневая абстракция. Это колесо очень давно изобрели создатели фреймворков типа Express и других подобных. Вот как маршрутизация выглядит в Express: import express from 'express'; const server = express(); server.get('/', (req, res) => { // Главная страница res.send('Home Page'); }); server.get('/user', (req, res) => { // Получение данных пользователя res.send('User Page'); }); server.post('/user', (req, res) => { // Создание пользователя res.send({ status: 'ok' }); }); server.listen(3000, () => { console.log('Server running at http://localhost:3000/'); });
208  Глава 10. Node на практике Запустите этот файл после установки Express через npm и протестируйте его работу. Обратите внимание на несколько нюансов этого кода. Основная структура веб-сервера похожа на встроенную реализацию: мы создаем объект сервера и запускаем его с помощью метода listen. Это связано с тем, что Express в Node оборачивает модуль node:http. Для определения обработчиков для разных URL и HTTP-методов вместо условных операторов используется более декларативный синтаксис. Для подготовки и отправки ответа с правильными заголовками вместо низкоуровневых методов (write, end и других) используется метод send, который делает все за нас. С его помощью можно отправлять текст, HTML, JSON-объекты и массивы. Он даже работает с буферами в Node. Чтобы отправить статические ресурсы, такие как файлы или изображения, многострочные функции, необходимые во встроенном модуле node:http, становятся одной строкой при использовании Express: // Без Express fs.readFile(imagePath, (err, data) => { if (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error'); return; } res.writeHead(200, { 'Content-Type': 'image/png' }); res.end(data); }); // С Express res.sendFile(imagePath); Помимо упрощения типичных задач такие фреймворки, как Express, добавляют множество расширенных возможностей, делая код более гибким и простым в обслуживании. Например, в Express есть функция промежуточного программного обеспечения (middleware), которая позволяет встраивать любую логику во все запросы и при необходимости изменять все ответы. Функции middleware полезны для таких задач, как логирование, аутентификация, парсинг, обработка ошибок и многое другое. Вот пример веб-сервера, использующего middleware: import express from 'express'; import morgan from 'morgan'; const server = express(); // Встроенная middleware для парсинга JSON server.use(express.json());
Фреймворки  209 // Сторонняя middleware для логирования server.use(morgan('tiny')); // Middleware уровня приложения для аутентификации const authenticate = (req, res, next) => { const token = req.header('Authorization'); if (token === process.env.SECRET_TOKEN) { next(); } else { res.status(401).send('Unauthorized'); } }; server.use(authenticate); server.get('/', (req, res) => { // ... }); У Express есть множество других полезных возможностей и бесчисленное количество пакетов, расширяющих его функционал. Если вы создаете веб-сервер с Node, вам однозначно стоит изучить Express и его альтернативы. Можно пойти еще дальше и использовать фреймворк, который находится на более высоком уровне, чем Express. Многие веб-фреймворки для Node интегрируются с фронтенд-частью веб-приложений. Например, приложение на React может использовать React-фреймворк для Node — Next.js, который поддерживает такие возможности, как серверный рендеринг (SSR), файловая маршрутизация, автоматическое разделение кода и т. д. Есть и другие фреймворки со специализированным применением, например: Apollo Server Специализируется на создании API-серверов на основе GraphQL. Socket.IO Специализируется на приложениях реального времени. Strapi Специализируется на системах управления контентом. Mailchimp Open Commerce (ранее Reaction Commerce) Специализируется на приложениях электронной торговли. Еще один важный фреймворк для Node — NestJS, который ориентирован на бэк­ енд-сервисы. У него отличная модульная архитектура, которая делает код проще в сопровождении и масштабировании. Он использует такие возможности, как внедрение зависимостей и декораторы, и успешно интегрируется с базами данных
210  Глава 10. Node на практике вроде MongoDB и PostgreSQL. Также NestJS отлично работает с микросервисами и поддерживает множество транспортных уровней (например, HTTP, WebSockets и gRPC). Транспайлеры JavaScript Язык JavaScript постоянно совершенствуется. Особенно активно его развитие идет после крупного релиза шестой версии ECMAScript в 2015 году. И Node, и браузерные окружения регулярно получают обновления для поддержки новых возможностей JavaScript. Как разработчик Node-приложений вы легко можете обновить Node и использовать все новые возможности JavaScript, однако при публикации приложений для браузеров нужно учитывать, что у части пользователей могут остаться старые браузеры, которые не поддерживают современные функции. Здесь на помощь приходят транспайлеры (transpilers) типа Babel и TypeScript. Они преобразуют код, написанный с использованием современного или расширенного синтаксиса, в более совместимый код, который будет работать в большем количестве браузеров и их версий. Уровень совместимости при конверсии вы выбираете сами. Например, можно настроить поддержку последних трех версий Chrome и последних пяти версий Microsoft Edge. В идеале это решение принимается исходя из статистики использования вашего приложения. Помимо поддержки старых браузеров транспиляция дает преимущества в последовательности и производительности. В каких-то браузерах современные функции могут быть реализованы медленно или не полностью. Транспиляция кода гарантирует одинаковую работу во всех браузерах. Более того, иногда транспайлеры делают код даже быстрее, так как включают лучшие практики и могут сгенерировать более оптимизированный вариант. Babel фокусируется на транспиляции современного синтаксиса JavaScript. Он также включает полифилы (polyfills) — куски кода, добавляющие недостающий функционал в старых окружениях. Полифилы удобны тем, что подключаются только при необходимости, а современные браузеры выполняют код в том виде, в каком он написан. Еще один крупный транспайлер — TypeScript. Его функционал значительно шире, чем у Babel. Помимо транспиляции современного синтаксиса TypeScript расширяет сам язык JavaScript, добавляя такие полезные возможности, как статические типы, определение типов, интерфейсы, декораторы и многое другое.
TypeScript  211 TypeScript Один из самых весомых доводов против использования JavaScript — отсутствие статической типизации. В JavaScript типы динамические: вы можете объявить переменную, присвоить ей число, а потом заменить значение строкой. Кто-то считает, что это гибкость, но на деле она часто приводит к неожиданным ошибкам, которые проявляются только при непосредственном выполнении кода. Наличие фиксированных типов для переменных и функций (то есть аргументов и возвращаемых значений) значительно повышает качество, читаемость и надежность кода. Отладка и рефакторинг становятся проще и быстрее, а разработчики получают больше уверенности в изменениях. Но самое главное — ошибки выявляются еще до запуска кода. TypeScript помогает вам работать эффективнее. Поначалу может казаться, что TypeScript замедляет работу. На деле же он экономит массу времени по мере роста проекта. Мне кажется, что сознательно отказываться от TypeScript во время работы с Node — большая ошибка, так как он приносит слишком много пользы, чтобы так просто лишиться его функционала. У TypeScript есть альтернативы, однако ни одна не сравнится с ним по функциональности, интеграциям и темпам развития. Какую же пользу TypeScript приносит вашему коду на Node? Рассмотрим несколько примеров. Сначала установим TypeScript как dev-зависимость: $ npm i -D typescript @types/node Это дает вам возможность использовать команду tsc. TypeScript интегрирован во многие редакторы, например VS Code, которые после установки начнут показывать все проблемы, выявленные TypeScript. Кроме того, с помощью команды tsc можно искать ошибки в коде. Обратите внимание: также мы установили @types/node — это пакет определений типов для Node. Он описывает типы для глобальных объектов Node и API его встроенных модулей. Пример мы разберем чуть позже. Далее вам понадобится конфигурационный файл tsconfig.json, чтобы кастомизировать работу TypeScript. Этот файл можно создать командой tsc –init. Она сгенерирует файл с рекомендуемыми и закомментированными настройками и с соответствующими пояснениями: { "compilerOptions": { /* Подробнее о файле читайте по ссылке: https://aka.ms/tsconfig */
212  Глава 10. Node на практике */ "target": "es2016", /* Установка целевой версии JavaScript и подключение совместимых библиотек. "module": "commonjs", /* Обозначение типа генерируемого модуля.*/ "esModuleInterop": true, /* Генерация дополнительного кода для обглечения подключения импортируеиых модулей CommonJS. Эта настройка также включает флаг 'allowSyntheticDefaultImports' для обеспечения совместимости типов. */ "forceConsistentCasingInFileNames": true, /* Проверка совпадения регистра в именах импортируемых модулей. */ "strict": true, /* Включение всех строгих проверок соответствия типов переменных. */ "skipLibCheck": true /* Пропуск проверки типизации во всех подключаемых файлах с расширением .d.ts. */ } } Теперь вместо расширения .js вам необходимо использовать расширение .ts (это задается в настройках). Есть и другие способы работы с TypeScript, но проще всего применять расширение .ts. Далее вы пишете код JavaScript в файле .ts и запускаете его с помощью команды tsc, чтобы транспилировать его в .js. Для сборки и деплоя используйте файл с расширением .js. Этот процесс можно оптимизировать с помощью таких инструментов, как Webpack. Если TypeScript обнаружит в коде файла .ts ошибки, он выдаст предупреждение и не сгенерирует файл .js. TypeScript может логически определять типы переменных в коде и фиксировать нарушения даже без явного указания типов. Пример этого можно посмотреть на рис. 10.1. Код объявляет переменную count и инициализирует ее значением 1. Затем он пытается вызвать count как функцию. TypeScript блокирует такую операцию. Так как JavaScript — интерпретируемый язык, без TypeScript подобные ошибки могут остаться незамеченными и проявиться только во время выполнения кода, уже в продуктовом окружении. Это пример реальной ошибки TypeError в JavaScript — ошибки времени выполнения, которую легко отследить (с помощью множества инструментов). Логические ошибки же, напротив, обнаружить сложнее. Рассмотрим это на примере кода на рис. 10.2.
TypeScript  213 В этом коде пытаются увеличить переменную count. Однако в JavaScript выражение 1 + "1" дает результат "11"! Рис. 10.1. Ошибка синтаксического типа Рис. 10.2. Ошибка логического типа
214  Глава 10. Node на практике TypeScript обнаружил эту логическую ошибку, и для этого ему не надо было явно задавать типы1. Это называется выводом типов (type inference). Так как count был инициализирован числом, TypeScript сделал вывод, что его тип — число. Затем он определил, что count + "1" — это строка, и заключил, что в переменную-число пытаются записать строку. Именно об этой ошибке он и сообщал. Определение типов — это здорово, но у него есть ограничения, и он работает не во всех случаях. Полагаться только на него не стоит. Когда TypeScript не может определить тип переменной, он присваивает переменной тип any, что означает, что переменной может быть дано значение любого возможного типа. Я рекомендую полностью отказаться от типа переменной any. Конечно, его придумали не просто так — с его помощью можно, например, задействовать стороннюю библиотеку без определения типов, но в своем коде его использовать не стоит. TypeScript и другие инструменты проверки качества кода (например, ESLint) можно настроить так, чтобы тип any не использовался ни явно, ни неявно. Определять явные типы в TypeScript просто: достаточно добавить : TYPE к переменной: let isActive: boolean; let hobbies: string[] = ['Reading', 'Hiking']; let person: { name: string; age: number }; function add(x: number, y: number): number { return x + y } Обратите внимание на синтаксис определения типов для массивов, объектов и функций (аргументы + возвращаемое значение). Типы могут становиться сложными, но со временем ваши усилия по их правильному описанию принесут свои плоды. Есть несколько способов упростить работу с типами. Например, их можно определить один раз, а затем использовать многократно. Рассмотрим пример кода и рис. 10.3: 1 TypeScript обладает мощной системой вывода типов, которая автоматически определяет и назначает типы переменным, параметрам функций и возвращаемым значениям на основе контекста их использования. Рекомендуем книгу «Эффективный TypeScript: 62 способа улучшить код» Д. Вандеркама (вывод типов описан в гл. 3). — Примеч. науч. ред.
TypeScript  215 type ErrorObject = { message: string; }; function log(error: ErrorObject) { console.log(error.message); } Рис. 10.3. Определение типов Также типы можно экспортировать, импортировать и использовать в других проектах. Мы установили пакет @types/node, который делает типы доступными для всех модулей Node в нашем проекте (рис. 10.4). Обратите внимание, как TypeScript обнаружил проблему в server(). Он понимает, что тип server является экземпляром Server<typeof IncomingMessage, typeof ServerResponse>, который нельзя вызывать как функцию. Это часть пакета @types/ node. Определять типы для встроенных объектов Node не нужно. Огромное сообщество разработчиков уже сделало это за вас как для Node, так и для множества других библиотек, и продолжает добавлять и исправлять эти типы по мере обновлений
216  Глава 10. Node на практике самих библиотек. Например, если в вашем проекте TypeScript нужно использовать пакет lodash, установите @types/lodash и получите определения типов для всех методов lodash. Узнать, для каких библиотек есть готовые типы, можно в репозитории проекта Definitely Typed https://oreil.ly/pA6C3. Рис. 10.4. Использование типов из пакета Некоторые пакеты даже содержат встроенные определения типов. Например, для работы с пакетом graphql не нужен @types/graphql — достаточно установить graphql, и вам сразу будут доступны все типы его объектов. Надеюсь, теперь вы обладаете всеми необходимыми знаниями, чтобы начать применять TypeScript. Я настоятельно рекомендую вам продолжить изучение TypeScript и использовать его во всех проектах Node и в любом коде JavaScript, над которым вы работаете. Выводы Для оптимизации и улучшения разработки в Node существует множество инструментов. Они делают работу более эффективной и продуктивной, а также повышают качество и надежность кода.
Выводы  217 Даже простой инструмент, такой как Prettier, экономит массу времени и сил, автоматически и единообразно форматируя код. ESLint повышает качество кода и помогает обнаруживать ошибки прямо в процессе написания. Инструменты для автоматизации задач, такие как gulp и Grunt, упрощают выполнение повторяющихся задач в разных окружениях. Сборщики модулей и транспайлеры JavaScript помогают упаковывать код и оптимизировать его для работы в браузерах. Хотя встроенные модули Node предлагают мощный базовый функционал для множества сервисов, со временем появились фреймворки, которые объединяют эти функции, расширяют их и делают использование более удобным и эффективным. TypeScript увеличивает возможности языка JavaScript, добавляя статические типы, которые можно проверять на этапе компиляции, еще до запуска кода. Это помогает находить ошибки на ранних стадиях разработки, что предотвращает написание проблемного кода, и создавать более качественные и надежные приложения.
Об авторе Самер Буна имеет более 20 лет опыта в разработке программного обеспечения, проектировании API, управлении базами данных и обеспечении масштабируе­ мости. Он является автором нескольких книг и онлайн-курсов, посвященных JavaScript, Node.js, React.js и другим технологиям. Самер увлечен всем, что связано с JavaScript, и любит исследовать новые библиотеки. Его любимый технологический стек — это PostgreSQL, GraphQL, Node и React.
Иллюстрация на обложке На обложке книги «Эффективный Node.js» — красноклювый голубь (Patagioenas flavirostris), вид крупных голубей, ареал обитания которых простирается от КостаРики до Мексики. Их также можно встретить на юге Техаса, вдоль реки Рио-Гранде. О красноклювом голубе, как и о многих других птицах в этом регионе, известно относительно мало. Они питаются желудями, семенами и почками растений, которые находят в сухих лесах и прибрежных болотистых угодьях, там же гнездятся и выводят птенцов. Их характерный крик описывают как длинный высокий «Куууу!», за которым следуют три ноты «Кук-к-к-ку». Международным союзом охраны природы красноклювый голубь отнесен к категории видов, вызывающих наименьшие опасения (Least Concern) с точки зрения сохранности. Многие животные на обложках O’Reilly признаны исчезающими видами, и все они важны для нашего мира. Иллюстрация на обложке создана Карен Монтгомери на основе старинной гравюры из «Зоологии» Шоу. Дизайн серии разработали Эди Фридман, Элли Фолькхаузен и Карен Монтгомери.
Самер Буна Эффективный Node.js Перевел с английского М. Лидман Научный редактор И. Щукин Изготовлено в России. Изготовитель: ТОО «Спринт Бук». Место нахождения и фактический адрес: 010000, Казахстан, город Астана, район Алматы, проспект Ракымжан Кошкарбаев, д. 10/1, н. п. 18. Дата изготовления: 01.2026. Наименование: книжная продукция. Срок годности: не ограничен. Подписано в печать 12.12.25. Формат 70×100/16. Бумага офсетная. Усл. п. л. 18,060. Тираж 700. Заказ 0000. Отпечатано в ТОО «ФАРОС Графикс». 100004, РК, г. Караганда, ул. Молокова, 106/2.
Робин Никсон СОЗДАЕМ ДИНАМИЧЕСКИЕ ВЕБ-САЙТЫ С ПОМОЩЬЮ PHP, MYSQL, JAVASCRIPT, CSS И HTML5 7-е издание Владея лишь основами HTML, вы сможете научиться создавать интерактивные веб-сайты, управляемые данными, используя мощное сочетание вебстандартов и технологий с открытым исходным кодом. В последнем издании этого популярного практического руководства вы освоите динамическое вебпрограммирование с использованием новейших версий современных технологий — PHP, MySQL, JavaScript, CSS, HTML, Node.js и мощной библиотеки React. Вы научитесь использовать все эти технологии в комплексе и попутно освоите ценные практики веб-программирования, в том числе оптимизацию сайтов для мобильных устройств. А в самом конце вы объедините все вместе и создадите полноценный сайт социальной сети, который можно просматривать как на настольных компьютерах, так и на мобильных устройствах.
Кори Джей Болл ХАКИНГ API: ВЗЛОМ ПРОГРАММНЫХ ИНТЕРФЕЙСОВ ВЕБ-ПРИЛОЖЕНИЙ «Хакинг API» — это экспресс-курс по тестированию безопасности веб-API. Взламывайте API, ищите уязвимости, которые часто упускают даже опытные специалисты, и обеспечивайте безопасность ваших собственных API. Сначала вы разберетесь, как на самом деле работают REST API и какие уязвимости для них наиболее характерны. Затем создадите эффективную среду тестирования API с помощью Burp Suite, Postman, Kiterunner, OWASP Amass и других инструментов, предназначенных для разведки, анализа и тестирования защищенности конечных точек. Затем, вооруженные этими знаниями, научитесь проводить самые популярные виды атак для взлома механизмов аутентификации, искать ошибки в бизнес-логике и выявлять специфические слабые места API. Узнаете, что такое межсайтовый скриптинг и массовое переназначение параметров, а также какие уязвимости, связанные с инъекцией кода, часто встречаются в веб-приложениях.
Билл Любанович FASTAPI: ВЕБ-РАЗРАБОТКА НА PYTHON FastAPI — относительно новый, но надежный фреймворк с чистым дизайном, использующий преимущества актуальных возможностей Python. Как следует из названия, FastAPI отличается высоким быстродействием и способен конкурировать в этом с аналогичными фреймворками на таких языках, как Golang. Эта практическая книга расскажет разработчикам, знакомым с Python, как FastAPI позволяет достичь большего за меньшее время и с меньшим количеством кода. Билл Любанович рассказывает о тонкостях разработки с применением FastAPI и предлагает множество рекомендаций по таким темам, как формы, доступ к базам данных, графика, карты и многое другое, что поможет освоить основы и даже пойти дальше. Кроме того, вы познакомитесь с RESTful API, приемами валидации данных, авторизации и повышения производительности. Благодаря сходству с такими фреймворками, как Flask и Django, вы легко начнете работу с FastAPI.
Эндрю Хоффман БЕЗОПАСНОСТЬ ВЕБПРИЛОЖЕНИЙ. РАЗВЕДКА, ЗАЩИТА, НАПАДЕНИЕ 2-е издание Три столпа безопасности приложений — разведка, нападение и защита. Во втором издании Эндрю Хоффман рассматривает десятки смежных тем, от новейших типов атак и средств защиты до моделирования угроз, жизненного цикла безопасной разработки ПО (SSDL/SDLC) и архитектуры нулевого доверия. Вы получите подробную информацию об эксплойтах и средствах защиты от атак с использованием GraphQL, облачных технологий и доставки контента (CDN). В главы, посвященные атакам и их предотвращению, добавлены сведения для более продвинутых читателей.