Pull to refresh

Трудные уроки: пять лет с Node.js

Reading time 11 min
Views 35K
Original author: Scott Nonnenberg
После пяти лет работы с Node.js я многое понял. Я уже делился некоторыми историями, но в этот раз хочу рассказать о том, какие знания дались труднее всего. Баги, проблемы, сюрпризы и уроки, которые вы можете использовать в собственных проектах!

Базовые концепции


В каждой новой платформе есть свои хитрости, но в данный момент эти концепции для меня вторичны. Разобраться в своём баге — хороший способ гарантированного обучения. Даже если это немного болезненно!

Классы


Когда я только начал работать с Node.js, то написал скрапер. Очень быстро я понял, что если ничего не предпринять, то он будет осуществлять много запросов параллельно. Одно это стало важным открытием. Но поскольку я ещё не полностью усвоил мощь экосистемы, то сел и написал собственный ограничитель параллелизма. Он работал и проверял, что в каждый момент времени активны не более N запросов одновременно.

Позже мне понадобился второй уровень ограничений параллелизма, чтобы убедиться, что мы обслуживаем только N пользователей в каждый момент времени. Но когда я внедрил второй экземпляр класса, начали проявляться очень странные проблемы. Логи потеряли смысл. В конце концов я понял, что синтаксис свойств класса в CoffeeScript не предоставляет новый массив для каждого экземпляра, а один общий для всех!

Долго используя объектно-ориентированные языки программирования, я привык к классам. Но я не полностью понял результат скрытой работы конструкций языка CoffeeScript. Хорошо изучайте свои инструменты. Проверяйте все предположения.

NaN


Однажды работая по контракту, я внедрил сортировку на основе пользовательских параметров, которая должна была применяться в многоступенчатом производственном потоке. Но мы увидели действительно странное поведение — порядок не оставался одним и тем же. Каждый раз мы отправляли одинаковый набор пользовательских параметров, но порядок элементов изменялся!

Я был в замешательстве. Предполагалось, что это детерминистическая процедура. Те же данные на входе — те же данные на выходе. Поверхностное расследование не дало результата, так что я в конце концов реализовал подробное протоколирование. И вот здесь всплыли значения NaN. Они появились в результате предыдущих вычислений и посеяли хаос в алгоритме сортировки. Эти значения не равны самим себе или чему-нибудь другому, поэтому сломали транзитивность, которая необходима сортировке.


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

Логика после callback'а


Работая над одним из моих приложений с подключением к Postgres, я заметил странное поведение после падения тестов. Если первоначальный тест падал, то все остальные тесты заканчивались по таймауту! Это происходило не очень часто, потому что мои тесты не падают часто, но такое случалось. И это начинало надоедать. Я решил разобраться.

В моём коде использовался модуль Node pg, так что я начал рыться в каталоге node_modules и добавлять протоколирование. Я обнаружил, что модулю pg нужно было сделать некую внутреннюю очистку после завершения запроса, которую он делал после вызова предоставленного пользователем callback'а. Так что если выбрасывалось исключение, этот код пропускался. По этой причине pg был в плохом состоянии и не готов к следующему запросу. Я отправил пулл-реквест, который был полностью переделан и добавлен в версии 1.0.2.


Возьмите в привычку вызывать callback'и в последнюю очередь. Также хорошая идея предварять их выражением return. Иногда их нельзя поставить последней строчкой, но они всегда должны быть последним выражением.

Архитектура


Баги могут быть в отдельных строчках кода, но гораздо болезненнее, если баг в самой архитектуре приложения…

Блокировка цикла событий


По контракту меня попросили взять одностраничное приложение, написанное на React.js, и перенести его рендеринг на сервер. После исправления нескольких частей, из-за которых предполагался его рендеринг в браузере, всё заработало. Но я очень боялся, что синхронная работа поставит сервер Node.js на колени, так что добавил новую позицию сбора данных к нашей статистике, собираемой от сервера: как долго происходит рендеринг каждой страницы?

Когда данные начали поступать, стало ясно, что ситуация нехорошая. Всё нормально установилось, но некоторые из страниц рендерились более 400 мс. Слишком, слишком долго для рабочего сервера. Опыт работы с Gartsby хорошо подготовил меня к следующему шагу: рендерить статические файлы.


Хорошенько подумайте о том, чего вы хотите от своего сервера Node.js. Синхронная работа — это действительно плохие новости. Для рендеринга HTML нужно много синхронной работы, и это может затормозить процесс — не только с React.js, но и с легковесными инструментами вроде Jade/Pug! Даже фаза проверки типов на большой загрузке GraphQL может отнять много синхронного времени!

Конкретно для React.js многообещающий подход демонстрирует рендерер Rapscallion от Дейла Бустада. Он расщепляет всю синхронную работу для рендеринга дерева компонентов React в строку. react-server от Redfin — ещё одна, более тяжеловесная, попытка решить эту проблему.

Неявные зависимости


Я уже активно работал над контрактом и внедрял функции на хорошей скорости. Но мне сказали, что для следующей функции я могу свериться с ещё одним их приложением Node.js для справки и помощи в реализации.

Я взглянул на связующую функцию Express на выходной точке, о которой шла речь. И обнаружил целую кучу ссылок на req.randomThing и даже некоторые вызовы req.randomFunction(). Затем я пошёл смотреть на все связующие функции, по которым уже прошёл ранее, чтобы понять, что происходит.


Делайте зависимости явными, если только не возникнет абсолютная необходимость поступить иначе. Например, вместо добавления строк локального места действия в req.messages, передайте req.locale в var getMessagesForLocale = require('./get_messages') с прямым доступом. Теперь вы ясно увидите, от чего зависит ваш код. Это работает и в другую сторону — если вы разработчик random_thing.js, то определённо захотите знать, какие части проекта используют ваш код!

Данные, APIs и версии


Клиент хотел, чтобы я добавил функции в Node.js API, который работал как бэкенд для большого количества установленных нативных приложений на планшетах и смартфонах. Я быстро обнаружил, что не могу просто добавить поле, потому что разработчики приложений использовали защитное программирование — первым действием приложения при получении данных была проверка по строгой схеме.

Учитывая такую проверку и сами приложения, стало ясно, что понадобятся два новых типа версионирования. Один для клиентов API, чтобы они могли обновиться и получить доступ к новым функциям. Второй для самих данных, чтобы мы были уверены в надёжной реализации всех этих новых функций поверх MongoDB. Занимаясь добавлением этого в приложение, я рефлексировал на тему того, как следовало разрабатывать ту первую версию.


Есть нечто в изменяемых объектах JavaScript, что восхищает людей в связи с документоориентированными СУБД. «Я могу создать любой объект в своём коде, просто дайте сохранить его куда-нибудь!» К сожалению, эти люди как будто уходят после написания первой версии. Они не думают о второй, третьей или четвёртой версиях. Я научен, поэтому использую Postgres и с первой версии думаю о версионировании.

Оснащение


Как к главному эксперту по Node.js в большом проекте по контракту, ко мне подошел эксперт по DevOps поговорить о рабочих серверах. Требовалось длительное время, чтобы оснастить новые машины в дата-центре, и он хотел убедиться, что у него правильный план. Я ценил это.

Я кивал, когда он говорил, что на каждом сервере будет работать по одному процессу Node.js. Но прекратил кивать при упоминании, что у каждого сервера четыре физических ядра. Я объяснил, что на сервере можно будет использовать только одно ядро, и он пожал плечами — удалось достать только такие серверы. Они раньше работали как магазин под .NET, и у них стандартные решения. Вскоре после этого мы представили cluster.


Трудно понять происходящее, если переходишь с других платформ. Node.js всегда работает в одном потоке, в то время как почти все остальные веб-серверные платформы масштабируются с сервером: только добавляй ядра. Используйте cluster, но смотрите внимательно — это не волшебное решение.

Тестирование


Я написал уже много кода JavaScript и научен, что тестирование абсолютно и полностью необходимо. Это действительно так.

«Лёгкая работа»


Клиент объяснил, что их эксперты по Node.js ушли и я их заменяю. У компании были большие планы на этот проект, который являлся основной темой обсуждения до того, как я начал работу. Теперь клиент стал более решителен: он осознал, что дела обстоят неважно. Но в этот раз хотел сделать всё правильно.

Меня разочаровал уровень тестового покрытия, но в наличии хоть какие-то тесты. И порадовало, что JSHint уже на месте. Просто для уверенности я проверил текущий набор правил. И с удивлением обнаружил, что опция unused не активирована. Я включил её и был шокирован сплошным потоком новых ошибок. Несколько часов я сидел и просто удалял код.


Программировать на JavaScript трудно. Но у нас есть инструменты, чтобы сделать это более осмысленным. Научитесь эффективно использовать ESLint. С небольшими аннотациями Flow может помочь отлавливать неправильные вызовы функций. Много пользы с минимумом усилий.

Уборка после теста


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

После более глубокого изучения выяснилось, что предыдущий тест объявлял о успешном завершении, в то же время инициируя ряд асинхронных операций. Исключения, которые приходили от того кода, обработчик уровня процесса mocha воспринимал как приходящие от текущего теста. Дальнейшее изучение показало, что mock-объекты, которые тоже не вычистили, просачивались в другие тесты.


Если вы используете callback'и, то все тесты должны завершаться методом done(). Это легко проверить во время просмотра кода: если в тесте присутствует какая-то вложенная функция, то вероятно должен быть done(). Хотя, здесь есть небольшая сложность, потому что вы не можете вызвать done, который изначально не передали функции. Одна из тех классических ошибок просмотра кода. Также используйте функцию sandbox в Sinon — она поможет убедиться, что всё вернулось на место по окончании вашего теста.

Изменяемость


На данном проекте по данному контракту стандартным способом проведения тестов было проведение юнит-тестов или интеграционных тестов отдельно. По крайней мере, при локальной разработке. Но Jenkins проводит полное тестирование, прогоняя оба набора тестов вместе. В одном из пулл-реквестов я добавил пару новых тестов, и они вызвали сбой в Jenkins. Меня это очень удивило. Тесты нормально завершались локально!

После некоторых бесплодных размышлений я включил режим подробного изучения. Запустил в точности ту команду, которую я знал, что Jenkins использовал для прогона тестов. Потребовалось некоторое время, но проблему удалось воспроизвести. Голова закружилась, пока я пытаясь выяснить, в чём же разница между прогонами. Не было никаких идей. Подробное протоколирование приходит на помощь! Спустя два прогона я сумел обнаружить некоторые различия. После нескольких фальстартов правильное протоколирование было налажено и стало ясно: юнит-тесты изменили некоторые ключевые данные приложения, которые использовались в интеграционных тестах!


Баги такого типа крайне сложно отследить. Хотя я горжусь, что нашёл этот баг, но всё больше и больше думаю о неизменяемости. Библиотека Immutable.js неплоха, но придётся отказаться от lodash. А seamless-immutable тихо падает, когда вы пытаетесь что-то изменить (что затем нормально работает в продакшне).

Вы можете понять теперь, почему меня интересует Elixir: все данные в Elixir всегда неизменяемые.

Экосистема


В немалой степени польза Node.js заключается в эффективном использовании большой экосистемы. Выборе хороших зависимостей и правильном управлении ими.

Зависимости и версии


Я использую инструмент webpack-static-site-generator, чтобы генерировать свой блог. В рамках подготовки своего репозитория Git для публичного релиза я удалил каталог node_modules и установил всё с нуля. Обычно такой способ работает, поскольку я использую точные номера версий в package.json. Но не в этот раз. И всё перестало работать самым странным образом: без какого-либо осмысленного сообщения об ошибке.

Поскольку я отправил определённое число пулл-реквестов в Gatsby, то довольно хорошо знаю кодовую базу. Первым делом добавил несколько ключевых выражений протоколирования. И появилось сообщение об ошибке! Правда, его трудно было интерпретировать. Тогда я погрузился в webpack-static-site-generator и нашёл, что он использует Webpack для создания большого bundle.js с кодом всего приложения, который затем передаётся для запуска под Node.js. Дурдом! И вот именно оттуда вылезла ошибка — из глубины этого файла, во время запуска под Node.js.

Теперь я быстро шёл по следу. Через несколько минут у меня был конкретный фрагмент кода, который выдался вместе с тем же сообщением об ошибке. Проблема оказалась в зависимостях новых функций языка ES6 в случае запуска под Node.js версии 4! Выяснилось, что у этой зависимости есть неограниченная подзависимость, которая вытаскивает слишком новую версию punycode.


Фиксируйте всё своё дерево зависимостей на конкретные версии с помощью Yarn. Если не можете, то фиксируйте прямые зависимости на конкретные версии. Но знайте, что оставшиеся незакреплёнными версии зависимостей могут привести к такой ситуации.

Документация и версии


На одном из проектов я использовал Async.js, особенно функцию filterLimit. У меня был список путей, и я хотел выйти в файловую систему, чтобы получить характеристики файла, который затем будет определять, должны ли пути остаться в списке. Я написал метод для фильтра нормальным асинхронным образом, со стандартной подписью async callback(err, result). Но ничего не работало.

Я обратился к документации, которая в то время была на главной странице проекта GitHub. Посмотрел на описание filterLimit, и там была ожидаемая подпись: callback(err, result). Я вернулся обратно к проекту и запустил npm outdated. У меня стояла v1.5.2, а последней числилась v2.0.0-rc.1. Я не собирался обновлять именно до этой версии, так что запустил npm info async для проверки, стоит ли у меня последняя версия 1.х. Так и было.

Всё ещё в недоумении, я вернулся к коду и добавил исключительно подробное протоколирование. Без толку. В конце концов, я пошёл к исходникам Async.js на GitHub. Что делает эта глупая функция? И вот тогда я понял, что произошло — в ветке master на GitHub был код 2.х. Чтобы посмотреть документацию для моей установленной версии 1.5.2, то нужно было искать в истории. Когда я сделал это, то нашёл правильную подпись callback(result) без возможности распространения ошибок.


Очень внимательно смотрите на номер версии документации, к которой обращаетесь. Тут легко облениться, потому что первая попавшаяся документация часто подходит: методы редко меняются или вы и так на последней версии. Но лучше проверить дважды.

Чего не понимает New Relic


Я был занят производительностью сервера Node.js для клиента, так что мне предоставили доступ к инструменту мониторинга: New Relic. Несколько лет назад New Relic приходилось использовать для мониторинга приложения клиента на Rails, и я также готовил для другого клиента анализ целесообразности связки New Relic/Node.js, так что в целом знал, как работает система, и знал, каким образом она интегрируется с Express и асинхронными вызовами.

Так что я приступил. В системе были на удивление исчерпывающие следы, как обрабатываются входящие запросы: работа промежуточного программного обеспечения Express и вызовы на другие серверы. Но я везде искал и не мог найти ключевого показателя для процессов: состояния цикла событий. Так что пришлось прибегнуть к обходному манёвру: я вручную высылал показатели от toobusy-js в New Relic и создал новый график на их основе.

С этими дополнительными данными было больше уверенности в правильности анализа. Конечно, скачки в показателях времени ожидания (latency) совпадают с тем, что New Rellic называет ‘time spent in requests’. Я посмотрел и с беспокойством обнаружил, что там сумма слагаемых не совпадает с результатом. Общее время, потраченное на запрос, и его составляющие — не совпадают. Иногда есть категория «Другое», которая пытается исправить ситуацию, в других случаях её нет.


Не используйте New Relic для мониторинга приложений Node.js. Этот инструмент не имеет понятия о цикле событий. Его нагромождённые графики ‘average time spent’ совершенно вводит в заблуждение — при условии медленного цикла событий выделенные конечные точки будут теми, которые сильнее всего откладывают цикл событий. New Relic может определить факт наличия проблемы, но не поможет вычислить её источник.

Если вам ещё нужны причины не использовать New Relic, вот пожалуйста:

  1. В окнах по умолчанию отображаются средние значения, плохой способ представления данных из реального мира. Следует пройти дальше за дефолтные окна, чтобы получить вразумительные графики, вроде 95%-ных. Но не чувствуйте себя слишком комфортно, потому что эти вразумительные графики вы не можете добавить на созданные под себя панели мониторинга!
  2. Он не понимает использования cluster на одной машине. Если вы вручную отправите данные вроде показателей времени задержки для цикла событий из toobusy-js, только один показатель в секунду победит для всего сервера. Даже если там четыре воркера.

Всё понятно!


Эмоциональные события запоминаются с наибольшей ясностью и точностью. Поэтому каждая из этих ситуаций надёжно сохранилась в моей памяти. Вы не запомните это настолько хорошо, как я. Может, если представить мою борьбу, то это поможет?

Удивительно, но изначально в этой статье я хотел описать в два раза больше ситуаций, достойных упоминания, многие из которых не относятся напрямую к Node.js. Так что ждите ещё постов вроде этого!
Tags:
Hubs:
+21
Comments 8
Comments Comments 8

Articles