Мы часто говорим о связи, но что такое связь?

Как правило, существует три типа соединения компонентов.

  1. Афферентная связь: задача компонента А должна зависеть от реализации компонентов В, С и D.

2. Эфферентная связь: после того, как задача компонента А завершена, должны быть выполнены компоненты В, С, D.

3. Временная связь: после того, как задача компонента А завершена, должны быть выполнены компоненты В и С. Кроме того, B предшествует C.

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

В этой статье мы углубимся, в частности, во временную связь, потому что это самая распространенная и самая упускаемая из виду ловушка. Сначала мы опишем в Node.js следующим образом:

На данный момент мы обнаружили, что это действительно общий. Почти весь наш код выглядит так. Нормально делать три вещи последовательно в методе, не так ли?

Возьмем более конкретный пример. Предположим, у нас есть электронная коммерция с функцией purchase. Поэтому начинаем кодить по-простому.

Сначала просуммируйте цену всех товаров в корзине. А затем позвоните в платежную службу, чтобы разобраться с кредитной картой. Просто, верно?

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

Эта функция также довольно распространена, и затем отдел продаж обнаружил, что купон является хорошим методом продвижения, поэтому они предложили, чтобы люди, достигшие 5000 долларов, могли получить шанс на лотерею. Этот purchase продолжает расти.

Это временная связь. Либо giveCoupon, либо lottery на самом деле зависят от purchase, что должно быть выполнено в течение жизненного цикла purchase. Как только потребность в функциях становится все больше и больше, производительность всего purchase будет постоянно снижаться. В частности, lottery обычно требует огромных вычислений, а purchase вынужден ждать, пока успех lottery будет считаться успехом.

Разделение времени по событиям предметной области

Из предыдущего раздела мы узнали, что purchase нужно только обрабатывать платежи, остальное поведение является дополнительным и не должно находиться в том же жизненном цикле, что и purchase. Другими словами, даже если giveCoupon выйдет из строя, это не должно повлиять на purchase или lottery.

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

Поэтому давайте немного изменим purchase на манер Node.

С помощью событий мы можем полностью отделить giveCoupon и lottery от purchase. Даже если какой-либо из обработчиков выйдет из строя, это не повлияет на первоначальный поток платежей.

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

Если в будущем возникнут дополнительные потребности, нет необходимости менять исходный purchase, просто добавьте новый обработчик. И это концепция развязки. Здесь мы удаляем связь на уровне кода и связь на уровне времени.

Как справиться с потерей события

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

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

Другими словами, как мы можем гарантировать, что сгенерированное событие будет выполнено. Именно поэтому в систему были введены очереди сообщений.

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

  • Не более одного раза
  • Хотя бы один раз
  • Ровно раз

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

Таким образом, чтобы избежать потери событий, мы изменим emitter.emit на отправку очереди с помощью RabbitMQ или Kafka. На данном этапе мы внедрили развязку на системном уровне, т.е. сделали так, чтобы производители и потребители событий принадлежали разным исполнительным блокам.

Как справиться с потерей излучения

История еще не окончена. Мы уже можем гарантировать выполнение генерируемых событий. Что делать, если событие вообще не отправляется? Продолжайте брать purchase в качестве примера, когда payByCreditCard прошло успешно, но не отправляет событие из-за сбоя системы по непредвиденным причинам. Тогда даже с очередью сообщений мы все равно получим неверный результат.

Чтобы избежать этой проблемы, мы можем использовать источник событий. В разделах Распределенная транзакция и CQRS я описал основную концепцию источника событий.

Прежде чем событие будет отправлено, сначала сохраните событие в хранилище. После того, как обработчик закончит обработку события, пометьте событие в хранилище как «обработанное».

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

Заключение

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

Как я уже говорил, система развивается для решения проблемы, но она также создает новые проблемы. Нам остается только выбрать наиболее приемлемое решение и искать компромиссы по сложности, производительности, продуктивности и другим факторам.

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

  • Независимо от того, будет ли событие потеряно или нет, просто используйте простейшую архитектуру, EventEmitter. Этот подход самый простой, и в 80% случаев проблемы может и не быть, но что делать, если проблема есть?
  • Стараемся быть максимально надежными, поэтому внедряем очереди сообщений, которые должны быть на 99% уверены, что проблем не возникнет. Но есть еще 1%, допустим ли такой риск?
  • Внедрение источника событий происходит за счет увеличения сложности и может повлиять на производительность. Это приемлемо?

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