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

Хотя NodeJS является одним из лучших вариантов для создания этих серверов, у него определенно есть свои проблемы. NodeJS является однопоточным (за исключением внутренних компонентов, которые могут использовать несколько потоков, таких как операции ввода-вывода). Я не буду здесь сравнивать производительность однопоточных и многопоточных серверов, поскольку у обоих есть свои плюсы и минусы. И действительно, NodeJS очень хорошо управляет запросами и ресурсами, используя концепцию однопоточного цикла событий, о чем свидетельствует тот факт, что компаниям нравится Netflix, Paypal и Uber, и это лишь некоторые из них, используют NodeJS.

Проблема с чтением объектов запросов откуда угодно

Крупные микросервисы обычно строятся на основе надежной и оптимизированной архитектуры. Во многом это делается для обеспечения масштабируемости. Надежная архитектура обычно имеет несколько уровней, по которым должен пройти запрос. Это может включать в себя несколько промежуточных программ, контроллер и/или несколько служб.

Проблема, которую мы здесь пытаемся решить, очень распространена. Это нужно для получения наших текущих данных запроса из любого из вышеупомянутых слоев. В обычном приложении на основе MVC у нас есть доступ к нашему объекту запроса на уровне контроллера. Это верно для любого сервера на основе MVC, будь то однопоточный или многопоточный. Теперь, очевидно, первое, что приходит на ум, это:

— Почему бы просто не передать объект запроса вниз по уровням?

Фактически, мы можем передать объект запроса в качестве параметра функции вниз по уровням. Но это создает некоторые другие проблемы. Например:

  • А как насчет разделения ответственности за сервисные функции? Функции сервиса не должны быть привязаны к объекту запроса.
  • Как насчет масштабируемости? Когда у вас огромное приложение с сотнями контроллеров и сервисов и над ними работает большое количество разработчиков, лучше не перекладывать такие обязанности на разработчиков.
  • Как насчет производительности и ресурсов? Чем больше времени требуется конечной точке для выполнения, тем больше памяти потребуется серверу. Это связано с аргументами, что функция будет жить до тех пор, пока выполняется сервисная функция. И наличие всех данных запроса для каждого параллельного запроса было бы худшим случаем.

Следующее, что приходит на ум, — почему бы просто не передать требуемые данные из запроса вниз по уровням в качестве параметров функции? Ну, это еще хуже для части масштабируемости и приведет к плохому коду.

Как многопоточные приложения управляют контекстом запроса

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

Следовательно, приложения, основанные, например, на платформе Laravel, могут получить доступ к объекту запроса из любого места, и они будут предоставлять данные запроса, уникальные для жизни этого запроса.

Почему это не проблема в однопоточных приложениях?

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

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

Вернуться к контексту запроса…

В этот момент вам должно быть интересно, какое отношение все это имеет к хранению контекста запроса?
Совсем недавно в NodeJS была введена концепция «асинхронных хуков». Эти ловушки в основном представляют собой события, которые отслеживают время жизни асинхронной операции. Это экспериментальная функция, которая вводит некоторые очень важные события, которые запускаются в нескольких экземплярах. Эти крючки:

  • в этом
  • до
  • после
  • разрушать
  • обещаниеResolve

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

  1. Асинхронным ресурсом может быть тайм-аут, обещание или асинхронная функция. Полный список типов асинхронных ресурсов можно найти здесь.
    Событие init запускается при создании асинхронного ресурса. API асинхронных перехватчиков присваивает этому ресурсу уникальный asyncId. Параметр type представляет собой строку и определяет тип асинхронных ресурсов, как обсуждалось ранее. triggerId — это asyncId родительского асинхронного ресурса, создавшего этот ресурс. В случае первого выполнения он равен 0. Параметр resource — это ссылка на асинхронный ресурс.
  2. Событие before запускается перед использованием/выполнением асинхронного ресурса. Это можно использовать для перехвата выполнения ресурса.
  3. Событие after запускается после использования/выполнения асинхронного ресурса. Это можно использовать для выполнения любого выполнения над ресурсом после его использования.
  4. Событие destroy запускается при освобождении асинхронного ресурса. Любая очистка должна быть выполнена здесь.

— Итак, как это можно использовать для глобального предоставления контекста запроса? Вот как:

  • Мы можем поддерживать хранилище, карту, в которой будут храниться контексты всех запросов. Ключом к этой карте будет уникальный asyncId, предоставленный событием инициализации. Но с этим есть проблема. Если асинхронная функция вызывает другую асинхронную функцию, она снова вызовет событие инициализации, которое даст новый asyncId. Но triggerId будет asyncId вызывающей функции. Мы можем использовать это, чтобы проверить, существует ли контекст, только если его нет, мы должны добавить его в хранилище.
  • Затем мы можем получить к нему доступ из любого места с помощью метода executionAsyncId(), предоставляемого API асинхронных перехватчиков. Этот метод возвращает asyncId текущего асинхронного ресурса.
  • Как только функция возвращается, событие уничтожения запускается с помощью asyncId. Наша задача — удалить контекст этого asyncId из хранилища. Это можно сделать здесь, чтобы убедиться, что нет утечки памяти.

Хотя кажется, что это слишком много для хранения контекста запроса, на самом деле это довольно просто и может быть выполнено с помощью класса Singleton. Чтобы упростить задачу, NodeJS также предоставляет AsyncLocalStorage, обертку всей реализации, которую мы обсуждали выше для асинхронных хуков.

Изучение AsyncLocalStorage…

NodeJS предоставляет два важных метода через API AsyncLocalStorage:

  • run(store, callback) используется для создания контекста для выполнения, описанного в блоке обратного вызова. Первый аргумент принимает хранилище, уникальное для контекста, это может быть карта. Второй аргумент принимает обратный вызов, в котором выполняются все остальные выполнения.
  • getStore() используется для получения объекта хранилища, предоставленного нами при вызове для запуска. Важно, чтобы это вызывалось внутри функции обратного вызова, так как это место, где это хранилище уникально. В магазине есть 2 метода:
  • get(key), который используется для получения данных, хранящихся в указанном ключе.
  • set(key, value), который используется для установки данных по указанному ключу.

Это делает его намного проще, чем асинхронные хуки. Кроме того, согласно документам NodeJS, это предпочтительнее асинхронных хуков из-за преимуществ в производительности.

Слишком много для контекста запроса?

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

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

И многое другое, что еще предстоит открыть…

Влияние на производительность

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

В одном из наших случаев мы нашли следующие показатели производительности с помощью wrk:

Примечание. Эти тесты проводились с использованием одного и того же API.

Как видно из этих тестов, асинхронное локальное хранилище работает очень близко к исходному API с влиянием всего на 2%.

Заключительные моменты…

Прежде чем я закончу эту статью, вот несколько вещей, которые следует рассмотреть:

  • Функция асинхронных перехватчиков все еще находится на стадии эксперимента, но тем не менее она уже используется в некоторых хорошо известных продуктах в производственных средах, таких как kuzzle.io.
  • Асинхронное локальное хранилище было помечено как стабильное в версии 16.4.0. Это предпочтительнее, чем асинхронные хуки.

Несмотря на это, это огромный плюс, учитывая минимальное влияние на производительность, и поэтому его обязательно нужно попробовать!