Microservices - artemovsergey/ASP GitHub Wiki

Развенчание мифов о микросервисных приложениях

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

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

В частности, в этой главе рассматриваются следующие вопросы:

  • Возникновение сервис-ориентированных архитектур (SOA) и микросервисов.
  • Определение и организация архитектуры микросервисов.
  • Когда стоит применять архитектуру микросервисов?
  • Общие шаблоны микросервисов.

Возникновение сервис-ориентированных архитектур (SOA) и микросервисов

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

Возникновение SOA

Первый шаг в направлении микросервисов был сделан так называемыми сервис-ориентированными архитектурами, или SOA, то есть архитектурами, основанными на сетях взаимодействующих процессов. Изначально SOA реализовывались в виде веб-сервисов, подобных тем, с которыми вы, возможно, уже сталкивались в ASP.NET Core. В SOA различные макромодули, реализующие разные функции или роли в программных приложениях, представлены в виде отдельных процессов, которые взаимодействуют друг с другом через стандартные протоколы. Первой реализацией SOA были веб-службы, взаимодействующие через протокол SOAP на основе XML. Затем большинство архитектур веб-служб перешло на веб-API на основе JSON, о которых вы, возможно, уже знаете, поскольку веб-службы REST доступны в качестве стандартных шаблоны проектов ASP.NET. Раздел «Дополнительная информация» содержит полезные ссылки, которые предоставляют более подробную информацию о веб-службах REST.

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

  1. Обеспечение взаимодействия между модулями, реализованными с использованием различных технологий и работающими на разных платформах (Linux + Apache, Linux + NGINX или Windows + IIS). Фактически, программное обеспечение, основанное на разных технологиях, не является бинарным совместимым, но оно может все же взаимодействовать с другими, если каждое из них реализовано в виде веб-сервиса, который обменивается данными с другими через независимый от технологии стандартный протокол. Среди них стоит упомянуть текстовый протокол HTTP REST и бинарный протокол gRPC. Стоит также упомянуть, что протокол HTTP REST является фактическим стандартом, в то время как на данный момент gRPC является лишь де-факто стандартом, предложенным Google. Раздел «Дополнительная информация» содержит полезные ссылки для получения более подробной информации об этих протоколах.
  2. Включение версионирования каждого макромодуля для его независимого развития от других. Например, вы можете решить перенести некоторые веб-службы на новую версию .NET 9, чтобы воспользоваться преимуществами новых функций .NET или новых доступных библиотек, оставив другие веб-службы, которые не требуют изменений, на предыдущей версии, например .NET 8.
  3. Продвижение общедоступных веб-сервисов, которые предлагают услуги другим приложениям. В качестве примера вспомните различные общедоступные сервисы, предлагаемые Google, такие как Google Maps, или сервисы искусственного интеллекта, предлагаемые Microsoft, такие как сервисы перевода языков.
  4. Ниже приведена диаграмма, которая обобщает классическую SOA.
image Рисунок 2.1: SOA 5. Со временем информационная система компании и другие сложные приложения SOA завоевали больше рынков и пользователей, поэтому появились новые потребности и ограничения. Мы обсудим их в следующем подразделе.

На пути к архитектуре микросервисов

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

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

По мере того как информационная система компании приобретала все более важную роль, ее непрерывная работа, то есть практически нулевое время простоя, стала обязательным условием, что привело к появлению еще одного важного ограничения:

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

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

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

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

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

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

  1. Нам также нужен новый участник, который будет решать, сколько копий каждой службы использовать и на каком оборудовании их размещать. Существуют похожие сущности, называемые оркестраторами. Стоит отметить, что у нас может быть несколько оркестраторов, каждый из которых будет отвечать за подмножество служб, или же оркестраторов может не быть вовсе!
  2. Подводя итог, мы перешли от приложений, состоящих из крупнозернистых связанных веб-сервисов, к мелкозернистым и слабосвязанным микросервисам, каждый из которых реализован разными командами разработчиков, как показано на следующем рисунке.
image Figure 2.2: Microservices architecture

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

Определение и организация микросервисных

архитектур

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

Определение архитектуры микросервисов

Сначала перечислим все требования к микросервисам. Затем мы обсудим каждое из них в отдельном подразделе. Архитектура микросервисов — это архитектура, основанная на SOA, которая удовлетворяет всем перечисленным ниже ограничениям:

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

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

Область экспертизы и микросервисы

Это ограничение имеет целью предоставить практическое правило для определения границ каждого микросервиса, чтобы микросервисы оставались слабо связанными и могли обрабатываться слабо связанными командами. Оно основано на теории доменно-ориентированного проектирования, разработанной Эриком Эвансом (см. Domain-Driven дизайн: https://www.amazon.com/exec/obidos/ASIN/0321125215/domainlanguag-20). Здесь мы рассмотрим лишь несколько основных концепций этой теории, но если вы хотите узнать больше, обратитесь к разделу «Дополнительная литература» для получения более подробной информации. По сути, каждая область знаний использует свой типичный язык. Поэтому во время анализа достаточно обнаружить изменения в языке, используемом экспертами, с которыми вы разговариваете, чтобы понять, что включено в каждый микросервис, а что исключено.

Основанием для этой техники является то, что люди, активно взаимодействующие друг с другом, всегда развивают специфический язык, понятный другим людям, которые работают в той же области, в то время как отсутствие такого общего языка является признаком слабого взаимодействия. Таким образом, область приложения или поддомен приложения разделяется на так называемые ограниченные контексты, каждый из которых характеризуется использованием общего языка. Стоит отметить, что домен, поддомен и ограниченный контекст являются основными концепциями DDD. Более подробную информацию о них и DDD вы можете найти в разделе «Дополнительная литература», но нашего простого описания должно достаточно для начала работы с микрослужбами. Таким образом, мы получаем первое разделение приложения на ограниченные контексты. Каждый из них назначается команде, и для каждого из них определяется формальный интерфейс. Этот интерфейс становится спецификацией микросервиса, а также всем, что другие команды должны знать о микросервисе.

Затем каждая команда, которой был назначен микросервис, может разделить его на более мелкие микросервисы, чтобы масштабировать каждый из них независимо от других, проверяя, что каждый полученный микросервис обменивается приемлемым количеством сообщений с другими (слабая связь). Первое разделение используется для распределения работы между командами, а второе — для оптимизации производительности различными способами, которые мы подробно опишем в подразделе «Организация микросервисов».

Воспроизводимые микрослужбы

Должна быть возможность создавать несколько экземпляров одной и той же микрослужбы и размещать их на доступном оборудовании, чтобы выделять больше аппаратных ресурсов для наиболее важных микрослужб. Для некоторых приложений или отдельных микрослужб это можно сделать вручную, но чаще всего используются специальные программные инструменты, называемые оркестраторами. В этой книге мы опишем два оркестратора: Kubernetes в главе 8 «Практическая организация микросервисов с помощью Kubernetes» и Azure Container Apps в главе 9 «Упрощение контейнеров и Kubernetes: Azure Container Apps и другие инструменты».

Разделение разработки микросервисов между разными командами

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

Микросервисы, интерфейсы и протоколы связи

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

  • Контекстные карты представляют собой графическое отображение организационных взаимосвязей между различными командами, работающими над всеми контекстами, связанными с приложениями.
  • Каталоги услуг собирают информацию обо всех требованиях к микросервисам, командах, затратах и других свойствах. Такие инструменты, как Datadog (https://docs.datadoghq.com/service_catalog/) и Backstage (https://backstage.io/docs/features/software-catalog/) выполняют различные типы мониторинга, в то время как такие инструменты, как Postman (https://www.postman.com/) и Swagger (https://swagger.io/), в основном сосредоточены на формальных требованиях, таких как тестирование и автоматическая генерация клиентов для взаимодействия с сервисами.

Только интерфейсы логических микросервисов являются общедоступными.

Код каждого микросервиса не может делать никаких предположений о том, как реализован общедоступный интерфейс всех других логических микросервисов. Нельзя делать никаких предположений о используемых технологиях (.NET, Python, Java и т. д.) и их версиях, а также о алгоритмах и архитектурах данных, используемых другими микросервисами. Проанализировав определение архитектуры микросервисов и ее непосредственные последствия, мы можем перейти к наиболее практичному на сегодняшний день способу их организации.

Организация микросервисов

Первым следствием независимости выбора архитектуры микросервисов является то, что каждый микросервис должен иметь собственное хранилище, поскольку общая база данных приведет к появлению зависимостей между микросервисами, которые ее используют. Предположим, что микросервисы A и B обращаются к одной и той же таблице базы данных T. Теперь мы модифицируем микросервис A, чтобы удовлетворить требования нового пользователя. В рамках этого обновления решение для A потребует от нас замены таблицы T двумя новыми таблицами, T1 и T2. В аналогичной ситуации мы были бы вынуждены также изменить код B, чтобы адаптировать его к замене T на T1 и T2. Очевидно, что такое же ограничение не распространяется на разные экземпляры одного и того же микросервиса, поэтому они могут совместно использовать одну и ту же базу данных. Подводя итог, можно сделать следующие выводы:

Инстансы разных микросервисов не могут использовать общую базу данных.

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

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

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

image Figure 2.3: Chain of synchronous request-responses

Сообщения 1-6 запускаются запросом к микросервису A и отправляются последовательно, поэтому время их обработки суммируется с временем ответа. Кроме того, микросервис A после отправки сообщения 1 остается заблокированным, ожидая ответа, до тех пор, пока не получит последнее сообщение (6); то есть он остается заблокированным на протяжении всего времени работы цепочки коммуникационного процесса. Микросервис B остается заблокированным дважды, ожидая ответа на отправленный им запрос. Первый раз это происходит во время связи 2-3, а второй — во время связи 4-5. Подводя итог, наивный паттерн запрос-ответ в коммуникации микросервисов подразумевает высокое время ответа и потерю вычислительного времени микросервисов. Единственные способы преодолеть вышеуказанные проблемы — либо избежать полной зависимости между микросервисами, либо кэшировать всю информацию, необходимую для удовлетворения любого запроса пользователя, в первом микросервисе A. Поскольку достичь полной независимости практически невозможно, обычным решением является кэширование в A всех данных, необходимых для ответа на запросы, без запроса дополнительной информации о других микросервисах.

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

image Figure 2.4: Data-driven communication

Обе коммуникации с меткой 1 запускаются при изменении данных микросервисов C/D и могут происходить параллельно. Более того, после отправки коммуникации каждый микросервис может вернуться к своей задаче, не дожидаясь ответа. Наконец, когда запрос поступает в микросервис A, он уже имеет все данные, необходимые для построения ответа, без необходимости взаимодействия с другими микросервисами. В целом, микросервисы, основанные на асинхронном обмене данными, предварительно обрабатывают данные и отправляют их любому другому сервису, которому они могут понадобиться, как только их данные изменяются. Таким образом, каждый микросервис уже содержит предварительно вычисленные данные, которые он может использовать для немедленного ответа на запросы пользователей без необходимости дальнейших коммуникаций, связанных с конкретными запросами. На этот раз мы не можем говорить о запросах и ответах, а просто об обмене сообщениями. Люди, работающие с классическими веб-приложениями, привыкли к коммуникации по принципу «запрос-ответ», когда клиент отправляет запрос, а сервер обрабатывает этот запрос и отправляет ответ.

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

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

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

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

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

image Figure 2.5: Updates and query microservices

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

image Figure 2.6: Frontend and worker microservices

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

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

Пример каршеринга

На следующем рисунке показана схема связи части приложения каршеринга, отвечающей за обработку маршрутов. Пунктирные линии окружают все физические микросервисы, принадлежащие одному логическому микросервису. Микросервисы запросов находятся в верхней части изображения, микросервисы обновлений — в нижней, а рабочие микросервисы — в середине (заштрихованные серым цветом).

image Figure 2.7: Route-handling subsystem of a car-sharing application

Анализ языка выявил два логических микросервиса. Первый из них использует язык пользователей каршеринга и состоит из шести физических микросервисов. Второй микросервис ориентирован на топологию, поскольку он находит оптимальные маршруты между пунктом отправления и пунктом назначения и сопоставляет промежуточные пары «пункт отправления-пункт назначения» с существующими маршрутами. Владельцы автомобилей обрабатывают свои запросы с помощью операций CRUD в микросервисе Car-Holding-Requests updates, а пользователи, ищущие автомобиль, взаимодействуют с микросервисом Car-Seeking-Requests updates аналогичным образом. Микросервис Routes-Listing перечисляет все доступные поездки с пустыми местами для новых пассажиров, чтобы помочь тем, кто ищет автомобиль, выбрать дату поездки. После выбора даты запрос отправляется через микросервис Car-Seeking-Requests.

Как владельцы автомобилей, так и лица, ищущие автомобиль, взаимодействуют с микросервисом обновлений выбора маршрута. Лица, ищущие автомобиль, выбирают один из нескольких доступных маршрутов как для отправления, так и для прибытия, а владельцы автомобилей принимают лиц, ищущих автомобиль, выбирая маршруты, которые соответствуют их отправлениям и прибытиям. Как только маршрут выбран лицом, ищущим автомобиль, и принят владельцем автомобиля, все другие несовместимые варианты удаляются из списка лучших вариантов как владельца автомобиля, так и лица, ищущего автомобиль. Все доступные маршруты как для ищущих автомобиль, так и для владельцев автомобилей перечисляются микросервисом My-Best-Matches. Рабочий микросервис Routes-Planner вычисляет лучшие маршруты, подходящие для источника и пункта назначения владельца автомобиля, который также содержит источники и пункты назначения для некоторых ищущих автомобиль. Он хранит несоответствующие запросы ищущих автомобиль до тех пор, пока не будет добавлен маршрут, проходящий на приемлемом расстоянии от них . Когда это происходит, микросервис Routes-Planner создает новый альтернативный маршрут для той же поездки, который содержит новую пару «пункт отправления-пункт назначения». Все изменения маршрутов отправляются как микросервису My-Best-Matches, так и микросервису Route-Choosing.

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

Когда стоит применять микросервисную

архитектуру?

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

Приложения с низким трафиком, для реализации которых требуется небольшая команда из менее чем пяти человек, не являются хорошей целью для архитектуры микрослужб. Решение о том, когда применять микрослужбы во всех других ситуациях, которые находятся между вышеуказанными крайними случаями, не является простым. Как правило, это требует детального анализа затрат и доходов. С учетом затрат, использование архитектуры микрослужб требует примерно в пять раз большего объема разработки, чем обычное монолитное приложение. Мы получили этот показатель как среднее значение по 7 переписанным монолитным приложениям с архитектурой микросервисов. Отчасти это связано с дополнительными усилиями, необходимыми для обеспечения надежной связи, координации и детального управления ресурсами. Однако большая часть затрат связана со сложностями тестирования, отладки и мониторинга распределенного приложения.

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

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

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

Общие шаблоны микросервисов

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

Устойчивое выполнение задач

Микросервисы можно перемещать с одной машины на другую для достижения лучшей балансировки нагрузки. Их также можно перезапускать для сброса возможных утечек памяти или решения других проблем с производительностью. Во время этих операций они могут пропустить некоторые отправленные им сообщения или прервать некоторые текущие вычисления. Кроме того, могут также произойти сбои из-за программных ошибок или аппаратных неисправностей. Поскольку архитектуры микросервисов должны быть надежными (практически без простоев), они обычно являются избыточными, и требуется особое внимание для обнаружения неисправностей и применения корректирующих действий. Поэтому все архитектуры микросервисов должны предоставлять механизмы как для обнаружения сбоев, таких как простые таймауты, так и для исправления неудачных операций. Сбои обнаруживаются путем обнаружения либо неожиданных исключений, либо таймаутов. Поскольку код всегда можно организовать таким образом, чтобы превратить таймауты в исключения, обнаружение сбоев всегда можно свести к адекватной обработке исключений. Для решения этой проблемы сообщество разработчиков микросервисов определило полезные политики повторных попыток, которые можно привязать к конкретным исключениям. Обычно они реализуются с помощью специальных библиотек вместе с другими шаблонами надежности, но иногда они предлагаются готовыми облачными провайдерами. Ниже приведены стандартные шаблоны надежности, используемые в архитектурах микросервисов:

  • Экспоненциальный повтор попытки: он был разработан для преодоления временных сбоев, таких как сбой из-за перезапуска экземпляра микрослужбы. После каждого сбоя операция повторяется с задержкой, которая увеличивается экспоненциально с количеством попыток, пока не будет достигнуто максимальное количество попыток. Например, сначала мы повторим попытку через 10 миллисекунд, и если эта повторная операция приведет к новой сбою, новая попытка будет предпринята через 20 миллисекунд, затем через 40 миллисекунд и так далее. Если достигнуто максимальное количество попыток, генерируется исключение, где можно найти другую политику повторных попыток или какую-либо другую стратегию обработки исключений.

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

  • Изоляция перегородкой: Изоляция перегородкой была разработана для предотвращения распространения сбоев и перегрузок. Основная идея заключается в организации служб и/или ресурсов в изолированные разделы, чтобы сбои или перегрузки, возникающие в одном разделе, оставались ограниченными этим разделом, а остальная часть системы продолжала работать нормально. Предположим, например, что несколько реплик микрослужб используют одну и ту же базу данных (как это часто бывает). Из-за сбоя одна из реплик может начать открывать слишком много подключений к базе данных, что приведет к перегрузке всех других реплик, которым требуется доступ к той же базе данных. В этом случае мы понимаем, что подключения к базе данных являются критически важными ресурсами, которые требуют изоляции перегородкой. Таким образом, мы вычисляем максимальное количество подключений, которое база данных может корректно обработать, и распределяем их между всеми репликами, назначая, например, максимум пять одновременных подключений для каждой реплики микрослужбы.

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

  1. Разрешается только максимальное количество одинаковых одновременных исходящих запросов к общему ресурсу; допустим, 5, как в предыдущем примере с базой данных. Это похоже на установку верхнего предела на создание потоков.
  2. Запросы, превышающие предыдущий предел, помещаются в очередь.
  3. Если достигнута максимальная длина очереди, любые дальнейшие запросы приводят к возникновению исключений, которые прерывают их. Стоит отметить, что показанный ранее паттерн разделения и ограничения запросов является распространенным способом применения изоляции перегородкой, но это не единственный способ. Любая стратегия разделения и изоляции может быть классифицирована как изоляция переборкой. Например, можно разделить реплики двух взаимодействующих микросервисов на две изолированные партиции, так что только реплики, принадлежащие одной и той же партиции, могут взаимодействовать. Таким образом, сбой в одной партиции не может повлиять на другую партицию.

Наряду с описанными выше действиями и стратегиями по устранению сбоев, архитектуры микросервисов также предлагают стратегии предотвращения сбоев. Предотвращение сбоев достигается путем мониторинга аномального потребления аппаратных ресурсов и проведения периодических проверок работоспособности аппаратного и программного обеспечения. Для этой цели оркестраторы отслеживают использование ресурсов памяти и ЦП и перезапускают экземпляр микросервиса или добавляют новый экземпляр, когда они выходят за пределы диапазона, определенного разработчиком. Кроме того, они предлагают возможность объявлять периодические проверки программного обеспечения, которые оркестратор может выполнять для проверки правильности работы микрослужбы. Наиболее распространенной из таких проверок работоспособности является вызов REST-конечной точки работоспособности, предоставляемой микрослужбой. Опять же, если микрослужба не проходит проверку работоспособности, она перезапускается. Когда аппаратный узел не проходит проверку работоспособности, все его микрослужбы перемещаются на другой аппаратный узел.

Эффективная обработка асинхронной коммуникации

Асинхронная коммуникация с соответствующим асинхронным подтверждением вызывает три важных проблемы:

  1. Поскольку после коммуникации отправляющий микросервис переходит к обслуживанию других запросов, не дожидаясь подтверждения, он должен сохранять копии всех отправленных сообщений до тех пор, пока не будет обнаружено подтверждение или сбой коммуникации, например, таймаут, чтобы он мог повторить операцию (например, с экспоненциальным повторением) или предпринять другие корректирующие действия.
  2. Поскольку в случае таймаута сообщение может быть отправлено повторно, предполагаемый получатель может получить несколько копий одного и того же сообщения.
  3. Сообщения могут доходить до получателя в порядке, отличном от того, в котором они были отправлены. Например, если два сообщения, которые дают получателю указание изменить название продукта, отправляются в порядке M1, M2, мы ожидаем, что окончательное название будет тем, которое содержится в M2. Однако, если получатель получает два сообщения в неправильном порядке, M2, M1, окончательное название продукта будет тем, которое содержится в M1, что приведет к ошибке. Первая проблема решается путем хранения всех сообщений в очереди, как показано на следующем рисунке:
image Figure 2.8: Output message queue

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

image Figure 2.9: Input message queue

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

Коммуникации на основе событий

Предположим, что мы добавляем новый микросервис в приложение для каршеринга на рисунке 2.7, например, рабочий микросервис, который вычисляет статистику по поездкам пользователей. Мы будем вынуждены изменить все микросервисы, от которых он получает входные данные, потому что все эти микросервисы также должны отправлять некоторые сообщения вновь добавленному микросервису. Основным ограничением архитектуры микросервисов является то, что изменения в одном микросервисе не должны распространяться на другие микросервисы, но, просто добавив новый микросервис, мы уже нарушили этот основной принцип. Чтобы преодолеть эту проблему, сообщения, которые могут также интересовать вновь добавленные микросервисы, обрабатываются с помощью паттерна «издатель-подписчик». То есть отправитель отправляет сообщение на конечную точку издателя, а не напрямую конечным получателям. Затем каждый микросервис, заинтересованный в этом сообщении, просто подписывается на этот конечный пункт, чтобы конечный пункт подписки автоматически отправлял ему все полученные сообщения. На следующем рисунке показано, как работает модель «издатель-подписчик».

image Figure 2.10: Publisher-subscriber pattern

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

Конечные точки публикации обрабатываются приложениями, называемыми брокерами сообщений, которые предлагают эту услугу вместе с другими услугами по доставке сообщений. Брокеры сообщений могут быть развернуты сами по себе в качестве реплицируемых микрослужб, но обычно они предлагаются в качестве стандартных услуг всеми основными поставщиками облачных услуг. Среди них стоит упомянуть RabbitMQ, который необходимо устанавливать как микросервис, и Azure Service Bus, который доступен как облачная услуга в Azure. Мы расскажем о них подробнее в дальнейшей части книги, но заинтересованные читатели могут найти ссылки с более подробной информацией в разделе «Дополнительная литература».

Взаимодействие с внешним миром

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

image Figure 2.11: API gateway

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

Подводя итог, API-шлюзы предлагают следующие услуги:

  • Перевод веб-API-интерфейса
  • Управление версиями
  • Аутентификация Однако они часто предлагают и другие услуги, такие как: • Конечные точки API-документации, то есть конечные точки, которые предлагают формальное описание услуг, предлагаемых приложением, и способов их запроса. В случае REST-коммуникации API-документация основана на стандарте OpenAPI (см. Дополнительная литература).
  • Кэширование, то есть добавление правильных HTTP-заголовков для обработки соответствующего кэширования всех ответов как в клиентском приложении пользователя, так и в промежуточных веб-узлах. Стоит отметить, что вышеуказанные услуги являются лишь общими примерами услуг, доступных в коммерческих или открытых API-шлюзах, которые обычно предлагают широкий спектр услуг. API-шлюзы могут быть реализованы в виде специальных микросервисов с использованием библиотек, таких как YARP (https:// microsoft.github.io/reverse-proxy/index.html), или они могут использовать уже существующие настраиваемые приложения, например, открытый Ocelot (https://github.com/ThreeMammals/Ocelot). Все основные поставщики предлагают мощные настраиваемые API-шлюзы, называемые системами управления API (для Azure см. https://azure.microsoft.com/en-us/products/api-management). Однако существуют также независимые облачные предложения, такие как Kong (https://docs.konghq.com/gateway/ latest/).

Резюме

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

Вопросы

  1. В чем заключается основное различие между SOA типа «холд» и современной архитектурой микросервисов? Архитектуры микросервисов являются мелкозернистыми. Более того, каждый микросервис не должен зависеть от проектных решений других микросервисов. Кроме того, микросервисы должны быть избыточными, воспроизводимыми и отказоустойчивыми.
  2. Почему слабосвязанные команды так важны? Потому что слабосвязанные команды довольно легко координировать.
  3. Почему каждый логический микросервис должен иметь выделенное хранилище? Это прямое следствие независимости проектных решений одного микросервиса от проектных решений, принятых во всех других микросервисах. Фактически, совместное использование общей базы данных привело бы к необходимости принятия общих проектных решений по структуре базы данных.
  4. Почему необходима коммуникация на основе данных? Это единственный способ избежать длинных цепочек рекурсивных запросов и ответов, которые привели бы к недопустимому общему времени отклика.
  5. Почему событийно-ориентированная коммуникация так важна? Потому что событийно-ориентированная коммуникация полностью развязывает микросервисы, так что разработчики могут добавлять новые микросервисы без изменения существующих.
  6. Обычно ли API-шлюзы предлагают услуги входа в систему? Услуги входа в систему не предлагаются конкретными микрослужбами, называемыми серверами аутентификации.
  7. Что такое экспоненциальная повторная попытка? Политика повторных попыток, которая экспоненциально увеличивает задержку между сбоями и повторными попытками после каждого сбоя.

Настройка и теория: Docker и луковичная архитектура

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

  • Контейнеры Docker: контейнеры Docker — это инструмент виртуализации, который позволяет вашим микросервисам работать на широком спектре аппаратных платформ, предотвращая проблемы с совместимостью.
  • Луковичная архитектура: луковичная архитектура ограничивает зависимости как от пользовательского интерфейса (UI), так и от платформы развертывания в драйверах, так что программные модули, которые кодируют все бизнес-знания, полностью независимы от выбранного UI, инструментов и среды выполнения. Кроме того, для оптимизации взаимодействия между экспертами в области и разработчиками все сущности области реализуются в виде классов следующим образом:
  1. Каждая сущность взаимодействует с остальной частью кода только через методы, которые представляют поведение всех фактических сущностей домена.

  2. Имена сущностей и членов сущностей взяты из словаря домена приложения. Цель состоит в том, чтобы создать общий язык между разработчиками и пользователями, называемый универсальным языком.

Хотя контейнеры Docker в целом связаны с оптимизацией производительности микросервисов, архитектура Onion не является специфичной для микросервисов. Однако описанная здесь архитектура Onion была разработана специально для использования с микросервисами, поскольку она широко использует некоторые из специфичных для микросервисов шаблонов, которые мы описали в главе 2 «Развенчание мифов о микросервисных приложениях», таких как события «издатель-подписчик», чтобы максимально увеличить независимость программных модулей и обеспечить разделение между программными модулями обновления и запроса. В этой главе мы представим шаблон решения Visual Studio, основанный на архитектуре Onion, а также фрагменты кода, которые мы будем использовать в остальной части книги для реализации любого типа микрослужбы. Мы обсудим как теорию, лежащую в ее основе, так и ее преимущества. В частности, в этой главе рассматриваются следующие темы:

  • Архитектура «луковица»
  • Шаблон решения, основанный на архитектуре «луковица»
  • Контейнеры и Docker

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

Архитектура Onion

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

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

image Figure 3.1: Basic Onion Architecture

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

Каждый слой может ссылаться только на внутренние слои. Способ реализации этого ограничения зависит от базового языка и стека. Например, слои могут быть реализованы в виде пакетов, пространств имен или библиотек. Мы будем реализовывать слои с помощью проектов библиотек .NET, которые также можно легко преобразовать в пакеты NuGet.

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

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

...
builder.Services.AddScoped<IMyFunctionalityInterface1,
MyFunctionalityImplementation1>();
builder.Services.AddScoped<IMyFunctionalityInterface2,
MyFunctionalityImplementation2>();
...

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

image Figure 3.2: Complete Onion Architecture

При необходимости уровень прикладных служб можно разделить на несколько подуровней, а между уровнями прикладных служб и домена можно разместить дополнительные уровни, но это делается редко. Уровень домена часто делится на два подуровня: уровень модели, который содержит фактические определения сущностей домена, и уровень доменных служб, который содержит дополнительные бизнес-правила.

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

Уровень домена

Уровень домена содержит представление классов каждой сущности домена с ее поведением, закодированным в публичном методе таких классов. Кроме того, сущности домена могут быть изменены только с помощью методов, которые представляют фактические операции домена. Так, например, мы не можем напрямую получить доступ и изменить все поля заказа на покупку; мы ограничены манипулированием им только с помощью методов, которые представляют фактические операции домена, такие как добавление или удаление элемента, применение скидки или изменение даты доставки. Имена всех публичных методов и свойств должны быть построены с использованием фактического языка, используемого экспертами домена, ранее упомянутого повсеместного языка. Все вышеперечисленные ограничения имеют целью оптимизацию коммуникации между разработчиками и экспертами. Таким образом, эксперты домена и разработчики могут обсуждать публичный интерфейс сущности, поскольку он использует тот же словарный запас и фактические операции домена. Ниже приведена часть гипотетической сущности PurchaseOrder:

public class PurchaseOrder
 {
 …
 #region private members
 private IList<PurchaseOrderItem> items;
 private DateTime _deliveryTime;
 #endregion
 public PurchaseOrder(DateTime creationTime, DateTime deliveryTime)
 {
 CreationTime = creationTime;
 _deliveryTime = deliveryTime;
 items=new List<PurchaseOrderItem>();
 }
 public DateTime CreationTime {get; init;}
 public DateTime DeliveryTime => _deliveryTime;
 public IEnumerable<PurchaseOrderItem> Items => items;
 public bool DelayDelyveryTime(DateTime newDeliveryTime)
 {

if(_deliveryTime< newDeliveryTime)
 {
 _deliveryTime = newDeliveryTime;
 return true;
 }
 else return false;
 }
 public void AddItem (PurchaseOrderItem x)
 { items.Add(x); }
 public void RemoveItem(PurchaseOrderItem x)
 { items.Remove(x); }}

После извлечения из конструктора CreationTime больше не может быть изменено, поэтому оно реализуется как свойство {get; init;}. Список всех элементов может быть изменен с помощью методов AddItem и RemoveItem, которые понятны всем экспертам в данной области. Наконец, мы можем отложить дату доставки, но не можем ее предсказать. Это автоматически кодирует бизнес-правило области, обязательно используя метод DelayDeliveryTime. Мы можем улучшить сущность PurchaseOrder, добавив свойство get PurchaseTotal, которое возвращает общую сумму покупки, и добавив метод ApplyDiscount. Подводя итог, мы можем сформулировать следующее правило:

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

Эти сущности сильно отличаются от обычных сущностей Entity Framework Core, к которым мы привыкли, по следующим причинам:

  • Сущности Entity Framework Core — это классы, похожие на записи, без методов. То есть они представляют собой просто набор пар «свойство-значение».
  • Каждая сущность Entity Framework Core соответствует одному объекту, каким-то образом связанному с другими сущностями, в то время как доменные сущности часто представляют собой деревья вложенных объектов. Именно поэтому доменные сущности обычно называют агрегатами.

Так, например, агрегат PurchaseOrder содержит основную сущность и коллекцию PurchaseOrderItem. Стоит отметить, что PurchaseOrderItem не может считаться отдельной сущностью домена, поскольку не существует операций домена, которые бы затрагивали отдельный PurchaseOrderItem, но PurchaseOrderItem можно манипулировать только как часть PurchaseOrder. Подобное явление не наблюдается в случае плоских сущностей Entity Framework, поскольку в них отсутствует концепция доменных операций. Мы можем сделать следующий вывод:

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

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

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

Теперь мы наблюдаем другое явление: сущности без идентичности! Два заказа на покупку с одинаковыми датами и товарами остаются двумя разными сущностями; фактически, они должны иметь разные доставки для каждого из них. Однако что происходит с двумя адресами, содержащими одинаковые поля? Если мы рассмотрим семантику адреса, можем ли мы сказать, что это две разные сущности? Каждый адрес обозначает место, и если два адреса имеют одинаковые поля, они обозначают точно одно и то же место. Таким образом, адреса похожи на числа: даже если мы можем повторить их несколько раз, каждая копия всегда обозначает одну и ту же абстрактную сущность. Следовательно, мы можем сделать вывод, что адреса с одинаковыми полями неразличимы. Реляционные базы данных используют основные ключи для проверки, когда две кортежи ссылаются на одну и ту же абстрактную сущность, поэтому мы можем сделать вывод, что основным ключом адреса должен быть набор всех его полей.

В теории доменных сущностей объекты, подобные адресам, называются объектами-значениями, и их представление в памяти не должно содержать явных основных ключей. Оператор равенства, примененный к двум экземплярам таких объектов, должен возвращать true, если и только если все их поля равны. Кроме того, они должны быть неизменяемыми, то есть после создания их свойства не могут быть изменены, поэтому единственный способ изменить объект-значение — это создать новый объект с измененным значением какого-либо свойства. В C# объекты-значения легко представляются с помощью записей:

public record Address
{
 public string Country {get; init;}
 public string Town {get; init;}
 public string Street {get; init;}
}

Ключевое слово init делает свойства типа record неизменяемыми, поскольку означает, что они могут быть только инициализированы. Модифицированную копию записи можно создать следующим образом:

var modifiedAddress = myAddress with {Street = "new street"};

Если мы передадим все свойства в конструкторе вместо использования инициализаторов, предыдущее определение можно упростить следующим образом:

public record Address(string Country, string Town, string Street) ;

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

Поскольку агрегаты и объекты-значения сильно отличаются от сущностей, используемых всеми основными ORM, такими как Entity Framework, при использовании ORM для взаимодействия с базами данных мы должны преобразовывать сущности ORM в агрегаты и объекты-значения и наоборот каждый раз, когда мы обмениваемся данными с ORM.

Согласно общим правилам архитектуры Onion, доменный уровень взаимодействует с фактической реализацией, предоставляемой ORM, через интерфейс. Обычно это делается с помощью так называемого шаблона репозитория.

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

Это означает, что уровень домена должен содержать отдельный интерфейс для каждого агрегата, который отвечает за извлечение, сохранение и удаление всего агрегата. Шаблон репозитория помогает сохранить модульность кода и упростить его поиск и обновление, поскольку мы знаем, что для каждого агрегата должен быть один и только один интерфейс репозитория, поэтому мы можем организовать весь код, связанный с агрегатом, в одной папке. Фактическая реализация каждого репозитория содержится в инфраструктурном слое Onion Architecture в виде драйвера базы данных (или драйвера постоянного хранения), вместе с различными другими драйверами, которые виртуализируют взаимодействие с инфраструктурой. Каждый интерфейс репозитория агрегата содержит методы, которые возвращают агрегаты, удаляют агрегаты и выполняют любые другие операции, связанные с постоянством, над агрегатами. В сложных приложениях лучшей практикой является разделение уровня домена на уровень модели, который содержит только агрегаты, и внешний уровень доменных служб, который содержит интерфейсы репозитория и определение доменных операций, которые не могут быть реализованы как методы агрегата.

В частности, интерфейсы Domain Services обрабатывают кортежи, используемые для кодирования результатов, возвращаемых микросервисами запросов. Эти кортежи не являются агрегатами, а представляют собой смесь данных, взятых из разных таблиц данных, поэтому они соответствуют совершенно другому шаблону проектирования. Они возвращаются в виде объектов, похожих на записи, без методов и только со свойствами, которые соответствуют полям кортежей базы данных. Дополнительные интерфейсы Domain Services также реализованы в драйвере постоянного хранения инфраструктурного уровня. Обработка запросов и изменений отдельно и с использованием разных шаблонов проектирования известна как шаблон разделения ответственности за запросы и команды (CQRS).

Поскольку микросервисы, описанные в этой книге, довольно просты, в наших примерах кода мы не будем разделять доменный уровень на уровни модели и доменных сервисов. Поэтому репозиторий и другие интерфейсы доменных сервисов будут смешаны с агрегатами в одном проекте Visual Studio. Однако при реализации более сложных приложений следует использовать разделение доменного уровня на уровни модели и доменных сервисов.

Рассмотрим несколько примеров интерфейса репозитория. Агрегат PurchaseOrder может иметь связанный интерфейс репозитория, который выглядит следующим образом:

public interface IPurchaseOrderRepository
 {
 PurchaseOrder New(DateTime creationTime, DateTime deliveryTime);
 Task<PurchaseOrder> GetAsync(long id);
 Task DeleteAsync(long id);
 Task DeleteAsync(PurchaseOrder order);
 Task<IEnumerable<OrderBasicInfoDTO>> GetMany(DateTime? startPeriod,
 DateTime? endPeriod, int? customerId
 );
 ...
 }

Метода обновления нет, поскольку обновления реализуются путем прямого вызова агрегатных методов. Последний метод в приведенном коде возвращает коллекцию DTO, похожих на записи, под названием OrderBasicInfoDTO.

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

Несколько изменений в различных агрегатах могут быть обработаны транзакционным способом благодаря шаблону Unit Of Work, который будет описан позже в подразделе «Команда». Более подробная информация о том, как Entity Framework Core поддерживает реализацию интерфейсов репозитория и о том, как доменные объекты связываются и переводятся в Entity Framework Core сущности и обратно, будет представлена в разделе «Шаблон решения на основе архитектуры Onion».

Поняв представление объектов домена в памяти, мы можем перейти к тому, как ориентированная на микросервисы архитектура Onion представляет все бизнес-транзакции/операции.

Application services

В подразделе «Организация микрослужб» главы 2 «Развенчание мифов о микрослужбах» мы видели, что в архитектурах микрослужб часто используется паттерн CQRS, при котором одни микрослужбы специализируются на запросах, а другие — на обновлениях. Это сильная версия паттерна CQRS, но есть и более слабая версия, которая просто требует, чтобы запросы и обновления были организованы в разные модули, возможно, принадлежащие одному микросервису. Хотя не всегда удобно применять CQRS в его более сильной форме, его более слабая форма является обязательной при реализации микросервисов, поскольку обновления включают агрегаты, а запросы включают только DTO, похожие на записи, поэтому они требуют совершенно разных типов обработки. Соответственно, операции, определенные в уровне сервисов приложения микросервиса, разделены на два разных типа: запросы и команды. Как мы увидим, выполнение команд может вызывать события, поэтому вместе с командами и запросами сервисы приложения должны также обрабатывать так называемые события домена. Мы обсудим все эти различные операции в специальных подразделах, которые следуют далее.

Queries

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

public interface IPurchaseOrderByStartDateQuery: IQuery
{
 Task<IEnumerable<OrderBasicInfoDTO>> Execute(DateTime startDate);
}

public class PurchaseOrderByStartDateQuery(IPurchaseOrderRepository repo):
 IPurchaseOrderByStartDateQuery
{
 public async Task<IEnumerable<OrderBasicInfoDTO>> Execute(DateTime
startDate)
 {
 return await repo.GetMany(startDate, null, null);
 }
}

Интерфейс наследуется от пустого интерфейса, единственная цель которого — пометить как интерфейс, так и его реализацию как запросы. Таким образом, все запросы и связанные с ними реализации могут быть автоматически найдены с помощью рефлексии и добавлены в механизм впрыска зависимостей. Мы предоставим код, который обнаруживает все запросы в шаблоне решения A на основе раздела «Архитектура луковицы» вместе с полным шаблоном решения. Как уже упоминалось, реализация просто вызывает метод репозитория и передает ему соответствующие параметры. Реализация репозитория передается в основной конструктор класса тем же механизмом впрыска зависимостей, который впрыскивает сам запрос в конструктор объекта уровня представления (контроллера, в случае веб-сайта ASP.NET Core).

Commands

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

public record ApplyDiscountCommand(decimal discount, long orderId):
ICommand;

Команды должны быть неизменяемыми, поэтому мы внедрили их в качестве записей. Фактически, единственная операция, разрешенная для них, — это их выполнение. Подобно запросам, команды также реализуют пустой интерфейс, который помечает их как команды (в данном случае ICommand). Обработчики команд являются реализациями следующего интерфейса:

public interface ICommandHandler {}
 public interface ICommandHandler<T>: ICommandHandler where T: ICommand
 Task HandleAsync(T command);
 }

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

public class ApplyDiscountCommandHandler(
IPackageRepository repo):ICommandHandler<ApplyDiscountCommand>
 {
 public async Task HandleAsync(ApplyDiscountCommand command)
 {
 var purchaseOrder = await repo.GetAsync(command.OrderId);
 //call adequate aggregate methods to apply the required update
 //possibly modify other aggregates by getting them with other
 //injected repositories
 ...
 }
 }

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

builder.Services.AddScoped<ICommandHandler<ApplyDiscountCommand>,ApplyDiscountCommandHandler>();

Это можно сделать автоматически, сканируя сборку служб приложения с помощью рефлексии. Мы предоставим код, который обнаруживает все обработчики команд в шаблоне решения A на основе раздела «Архитектура Onion». Каждый обработчик команд получает или создает агрегаты, изменяет их, вызывая их методы, а затем выполняет инструкцию сохранения, чтобы сохранить все изменения в базовом хранилище. Операция сохранения должна быть реализована в драйвере хранилища (например, Entity Framework Core), поэтому, как обычно для всех операций драйверов Onion Architecture, она опосредуется интерфейсом. Интерфейс, который выполняет операции сохранения и другие операции, связанные с транзакциями, обычно называется IUnitOfWork. Возможное определение этого интерфейса выглядит следующим образом:

public interface IUnitOfWork
 {
 Task<bool> SaveEntitiesAsync();
 Task StartAsync();
 Task CommitAsync();
 Task RollbackAsync();
 }

Давайте разберемся:

  • SaveEntitiesAsync сохраняет все выполненные на данный момент обновления в одной транзакции. Он возвращает true, если механизм хранения действительно изменился после операции сохранения, и false в противном случае.
  • StartAsync запускает транзакцию.
  • CommitAsync и RollbackAsync соответственно фиксируют и отменяют открытую транзакцию.

Все методы, которые явно контролируют начало и окончание транзакции, полезны для объединения как операции get, так и окончательного сохранения SaveEntitiesAsync в одной транзакции, как в следующем упрощенном фрагменте кода бронирования авиабилетов:

await unitOfWork.StartAsync();
var flight = await repo.GetFlightAsync(flightId);
flight.Seats--;
if(flight.Seats < 0)
{
 await unitOfWork.RollBackAsync();
 return;
}
...
await unitOfWork.SaveEntitiesAsync();
await unitOfWork.CommitAsync();

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

Domen events

Мы можем определить события домена следующим образом:

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

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

Зачем использовать события внутри микрослужбы? Причина всегда одна и та же: обеспечить лучшую развязку между частями. Здесь участвующие части являются агрегатами. Код каждого агрегата должен быть полностью независимым от других агрегатов, чтобы обеспечить модульность и модифицируемость, поэтому отношения между агрегатами опосредуются либо обработчиками команд, либо некоторым паттерном «издатель-подписчик». Соответственно, если взаимодействие между двумя агрегатами каким-то образом определяется кодом обработчика команд, тот же обработчик команд может заниматься обработкой данных обоих, а затем каким-то образом обновлять их. Однако, если взаимодействие связано с обработкой в рамках метода агрегата, мы вынуждены использовать события, потому что не можем сделать агрегат осведомленным обо всех других агрегатах, которые должны быть проинформированы о некоторых изменениях его данных. Подводя итог, мы можем сформулировать следующий принцип:

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

Еще один важный принцип заключается в следующем:

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

Соответственно, каждый агрегат хранит все события внутри себя в списке событий, а затем обработчик команд решает, когда выполнять эти обработчики. Обычно все события всех агрегатов, обрабатываемые обработчиком команд, выполняются непосредственно перед тем, как обработчик сохраняет все изменения, вызывая unitOfWork. SaveEntitiesAsync(). Однако это не является общим правилом. События обрабатываются аналогично командам, с той лишь разницей, что каждая команда имеет только один связанный обработчик, в то время как каждое событие может иметь несколько подписчиков. К счастью, эту сложность можно легко преодолеть с помощью некоторых расширенных функций механизма введения зависимостей .NET.

Точнее говоря, события — это классы, помеченные пустым интерфейсом IEventNotification, в то время как обработчики событий являются реализацией следующего интерфейса:

public interface IEventHandler
{
}
public interface IEventHandler<T>: IEventHandler
 where T: IEventNotification
 {
 Task HandleAsync(T ev);
 }

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

public class EventTrigger<T>
 where T: IEventNotification
 {
 private readonly IEnumerable<IEventHandler<T>> _handlers;
 public EventTrigger(IEnumerable<IEventHandler<T>> handlers)
 {
 _handlers = handlers;
 }
public async Task Trigger(T ev)
 {
 foreach (var handler in _handlers)
 await handler.HandleAsync(ev);
 }
 }

Здесь IEventNotification — это пустой интерфейс, используемый только для обозначения класса как представляющего событие. Если мы добавим предыдущий общий класс в механизм впрыска зависимостей с помощью service.AddSco ped(typeof(EventTrigge<>)), то всякий раз, когда нам потребуется конкретный экземпляр этого класса (например, для обобщенного аргумента события MyEvent), механизм впрыска зависимостей автоматически извлечет все реализации IEventHandler и передаст их в конструктор возвращаемого экземпляра EventTrigger. После этого мы можем запустить все подписанные обработчики с помощью чего-то вроде следующего:

public class MyCommandHandler(EventTrigger<MyEvent> myEventHandlers):{
 public async Task HandleAsync(MytCommand command)
 {await myEventHandlers.Trigger(myEvent)}
}

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

На следующем рисунке показано, что происходит:

image Figure 3.3: Domain event example

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

Шаблон решения на основе архитектуры Onion

В этом разделе мы описываем шаблон решения на основе архитектуры «луковицы», который мы будем использовать в остальной части книги. Его можно найти в папке ch03 репозитория книги GitHub книги (https://github.com/PacktPublishing/Practical-Serverless-andMicroservices-with-Csharp). Этот шаблон показывает, как применить на практике то, что вы узнали об архитектуре Onion. Решение содержит два проекта библиотек .NET, называемых ApplicationServices и DomainLayer, которые реализуют, соответственно, сервисы приложения и доменные уровни архитектуры Onion:

image Figure 3.4: Solution template based on the Onion Architecture

В соответствии с требованиями архитектуры Onion, проект ApplicationServices имеет ссылку на проект архитектуры DomainLayer. В ApplicationServices мы добавили следующие папки:

  • Queries для размещения всех запросов и интерфейсов запросов
  • Commands для размещения всех классов команд
  • CommandHandlers для размещения всех обработчиков команд
  • EventHandlers для размещения всех обработчиков событий
  • Tools, которая содержит все интерфейсы, связанные с архитектурой Onion Architecture, используемые службами приложения, описанными в предыдущем разделе
  • Extensions, которая содержит метод расширения HandlersDIExtensions.AddApplicationServices(), который добавляет все запросы, обработчики событий и обработчики команд, определенные в проекте, в механизм впрыска зависимостей Все вышеперечисленные папки можно организовать в подпапки, чтобы повысить читаемость кода.

В проекте DomainLayer мы добавили следующие папки:

  • Models для размещения всех агрегатов и объектов-значений
  • Events для размещения всех событий, которые могут быть вызваны агрегатами
  • Tools, которая содержит все интерфейсы, связанные с архитектурой Onion, используемые доменом, который мы описали в предыдущем разделе, а также некоторые дополнительные классы утилит Папка Extensions проекта ApplicationServices содержит только один файл:
image Figure 3.5: ApplicationServices extensions

Статический класс HandlersDIExtensions содержит две перегрузки метода расширения, который добавляет все запросы, обработчики команд, обработчики событий и класс EventMediator в механизм впрыска зависимостей:

public static IServiceCollection AddApplicationServices
 (this IServiceCollection services, Assembly assembly)
{
 AddAllQueries(services, assembly);
 AddAllCommandHandlers(services, assembly);
 AddAllEventHandlers(services, assembly);
 services.AddScoped<EventMediator>();
 return services;
}
public static IServiceCollection AddApplicationServices
 (this IServiceCollection services)
{
 return AddApplicationServices(services,
 typeof(HandlersDIExtensions).Assembly);
}

Он использует три разных частных метода, которые сканируют сборку с помощью рефлексии, ища соответственно запросы, обработчики команд и обработчики событий. Полный код доступен в папке ch03 репозитория GitHub, связанного с книгой. Здесь мы проанализируем только AddAllCommandHandlers, чтобы показать основные идеи, используемые всеми тремя методами:

private static IServiceCollection AddAllCommandHandlers
 (this IServiceCollection services, Assembly assembly)
{
 var handlers = assembly.GetTypes()
 .Where(x => !x.IsAbstract && x.IsClass
 && typeof(ICommandHandler).IsAssignableFrom(x));

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

foreach (var handler in handlers)
{
 var handlerInterface = handler.GetInterfaces()
 .Where(i => i.IsGenericType &&typeof(
 ICommandHandler).IsAssignableFrom(i))
 .SingleOrDef

Наконец, если мы находим такой интерфейс, мы добавляем пару в механизм впрыска зависимостей:

foreach (var handler in handlers)
{if (handlerInterface != null)
 {
 services.AddScoped(handlerInterface, handler);
 }
}

Папка «Tools» проекта «ApplicationServices» содержит следующие файлы:

image Figure 3.6: ApplicationServices tools

Мы уже проанализировали все интерфейсы и классы, содержащиеся в папке Tools, за исключением EventMediator в предыдущем разделе. Давайте вспомним их:

  • IQuery и ICommand — пустые интерфейсы, которые обозначают, соответственно, запросы и команды.
  • ICommandHandler и IEventHandler — интерфейсы, которые должны быть реализованы, соответственно, обработчиками команд и обработчиками событий.
  • EventTrigger — класс, который выполняет магическую задачу сбора всех обработчиков событий, связанных с одним и тем же событием T. EventMediator — это класс-утилита, который решает практическую проблему. Обработчик команд, которому необходимо запустить все обработчики событий, связанные с событием T, должен ввести EventTrigger в свой конструктор. Однако дело в том, что команда обнаруживает, что ей необходимо запустить событие T, только когда она находит событие T в списках событий агрегата, поэтому она должна ввести все возможные EventTrigger в свой конструктор.

Чтобы решить эту проблему, класс EventMediator использует IServiceProvider для запроса обработчиков событий, связанных со списком событий, который передается в его методе TriggerEvents(IEnumerable events). Соответственно, достаточно ввести EventMediator в конструктор каждого обработчика команд, чтобы при обнаружении непустого списка событий L в агрегате он мог просто вызвать следующее:

await eventMediator.TriggerEvents(L);

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

public class EventMediator
{
 readonly IServiceProvider services;
 public EventMediator(IServiceProvider services)
 {
 this.services = services;
 }
 ...

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

public async Task TriggerEvents(IEnumerable<IEventNotification> events)
 {
 if (events == null) return;
 foreach(var ev in events)
 {
 var triggerType = typeof(EventTrigger<>).MakeGenericType(
 ev.GetType());
 var trigger = servi

Наконец, он вызывает методы EventTrigger.Trigger с помощью рефлексии:

var task = (Task)triggerType.GetMethod(nameof(
 EventTrigger<IEventNotification>.Trigger))
 .Invoke(trigger, new object[] { ev });
await task.ConfigureAwait(false);

Ниже приведен полный код класса EventMediator:

public class EventMediator
{
 readonly IServiceProvider services;
 public EventMediator(IServiceProvider services)
 {
 this.services = services;
 }
 public async Task TriggerEvents(IEnumerable<IEventNotification> events)
 {
 if (events == null) return;
 foreach(var ev in events)
 {
 var triggerType = typeof(EventTrigger<>).MakeGenericType(
 ev.GetType());
 var trigger = services.GetService(triggerType);
 var task = (Task)triggerType.GetMethod(nameof(
 EventTrigger<IEventNotification>.Trigger))
 .Invoke(trigger, new object[] { ev });
 await task;
}
 }
}

Папка «Tools» проекта DomainLayer содержит следующие файлы:

image Figure 3.7: DomainLayer tools

IEventNotification и IRepository — это пустые интерфейсы, которые обозначают, соответственно, события и интерфейсы репозитория. Мы уже обсуждали их в предыдущем разделе. Мы также уже обсуждали IUnitOfWork, который является интерфейсом, необходимым обработчикам команд для сохранения изменений и обработки транзакций.

Entity — это класс, от которого должны наследоваться все агрегаты:

public abstract class Entity<K>
 where K: IEquatable<K>
{
 public virtual K Id {get; protected set; } = default!;
 public bool IsTransient()
 {
 return Object.Equals(Id, default(K));
 }
 >Domain events handling region
 >Override Equal region
}

Предыдущий класс содержит две минимизированные области кода. Общий параметр K является типом главного ключа агрегата Id. Метод IsTransient() возвращает значение true, если агрегату еще не был назначен главный ключ. Область Override Equal содержит код, который переопределяет метод Equal и определяет операторы равенства и неравенства. Переопределенный метод Equal считает два экземпляра равными, если и только если они имеют одинаковый главный ключ.

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

#region domain events handling
public List<IEventNotification> DomainEvents { get; private set; } =
null!;
public void AddDomainEvent(IEventNotification evt)
{
 DomainEvents ??= new List<IEventNotification>();
 DomainEvents.Add(evt);
}
public void RemoveDomainEvent(IEventNotification evt)
{
 DomainEvents?.Remove(evt);
}
#endregion

Нам не нужен абстрактный класс для объектов-значений, потому что, как обсуждалось в предыдущем разделе, тип записи .NET идеально представляет все особенности типов значений. Прежде чем более подробно обсудить, как связать два библиотечных проекта шаблона с фактическими драйверами хранения и фактическим пользовательским интерфейсом, нам необходимо понять, как справиться с несоответствием между агрегатами и классами ORM, похожими на записи. Мы сделаем это в специальном подразделе, который следует ниже.

Сопоставление агрегатов и сущностей ORM

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

Entity Framework Core поддерживает сопоставление с частными полями, но если мы стремимся к полной независимости от драйвера базы данных, мы не можем полагаться на эту особенность Entity Framework Core. Более того, этот подход заставляет нас определять сущности ORM в доменном слое, поскольку они также являются агрегатами. Это означает, что мы не можем украсить член класса атрибутами, специфичными для ORM, и что нам нужно беспокоиться о том, как класс будет использоваться ORM при определении каждого агрегата, что подрывает независимость от конкретного драйвера хранилища.

Лучшим подходом является подход с использованием объектов состояния:

  1. Мы связываем каждый агрегат с интерфейсом, который хранит состояние агрегата в своих свойствах. Таким образом, вместо использования частных вспомогательных полей, агрегат использует свойства этого интерфейса.
  2. Интерфейс состояния передается в конструктор агрегата, а затем хранится в частном свойстве, доступном только для чтения.
  3. Сущность ORM, связанная с агрегатом, реализует этот интерфейс. Таким образом, драйвер базы данных адаптируется к агрегатам, а не наоборот, что позволяет достичь необходимой независимости доменного уровня от драйвера базы данных.
  4. Когда доменный слой требует либо новый агрегат, либо агрегат, уже сохраненный в базе данных через метод интерфейса репозитория, реализация метода репозитория в базе данных создает или извлекает соответствующую сущность ORM, а затем создает новый агрегат, передавая эту сущность ORM в его конструктор в качестве объекта состояния.
  5. Когда агрегаты изменяются, все их изменения отражаются на их объектах состояния, которые, будучи сущностями ORM, отслеживаются ORM. Поэтому, когда мы даем ORM команду сохранить все изменения, все изменения агрегатов автоматически передаются в базу данных, поскольку эти изменения хранятся в отслеживаемых объектах. На следующем рисунке показан предыдущий поток:
image Figure 3.8: Aggregates lifecycle

Давайте попробуем изменить наш предыдущий агрегат PurchaseOrder, используя следующий интерфейс состояния:

public interface IPurchaseOrderState
{
 public DateTime CreationTime { get; set; }
 public DateTime DeliveryTime { get; set; }
 public ICollection<PurchaseOrderItem> Items { get; set; }}

Изменения просты и не усложняют код:

public class PurchaseOrder
{
 private readonly IPurchaseOrderState _state;
 public PurchaseOrder(IPurchaseOrderState state)
 {
 _state = state;
 }
 public DateTime CreationTime => _state.CreationTime;
 public DateTime DeliveryTime => _state.DeliveryTime;
 public IEnumerable<PurchaseOrderItem> Items => _state.Items;
 public bool DelayDelyveryTime(DateTime newDeliveryTime)
 {
 if(_state.DeliveryTime < newDeliveryTime)
 {
 _state.DeliveryTime = newDeliveryTime;
 return true;
 }
 else return false;
 }
 public void AddItem (PurchaseOrderItem x)
 { _state.Items.Add(x); }
 public void RemoveItem(PurchaseOrderItem x)
 { _state.Items.Remove(x); }
}

Теперь мы готовы понять, как связать два проекта нашего шаблона с реальным драйвером базы данных и реальным пользовательским интерфейсом.

Комплексное решение на основе архитектуры «луковицы»

Папка ch03 в репозитории GitHub книги (https://github.com/PacktPublishing/ Practical-Serverless-and-Microservices-with-Csharp) содержит полное решение, которое, наряду с библиотеками служб приложения и доменного уровня, также включает драйвер базы данных, основанный на Entity Framework Core, и уровень представления, основанный на проекте ASP.NET Core Web API . Цель этого проекта — показать, как использовать общий шаблон «луковой архитектуры», описанный в этом разделе, в реальном решении. На следующем рисунке показано полное решение:

image Figure 3.9: A complete solution based on the Onion Architecture

Проект DBDriver — это проект библиотеки .NET, в который мы добавили зависимость от следующих пакетов Nuget:

  • Microsoft.EntityFrameworkCore.SqlServer: этот пакет загружает как Entity Framework Core, так и его поставщика SQL Server
  • Microsoft.EntityFrameworkCore.Tools: этот пакет предоставляет все инструменты для создания каркаса и обработки миграций баз данных

Поскольку проект DBDriver должен предоставлять драйвер хранилища, он также имеет зависимость от проекта библиотеки домена.

Проект WebApi — это проект ASP.NET Core Web API. Он работает как самый внешний слой архитектуры «луковицы».

Наиболее внешний слой луковой архитектуры (в нашем примере WebApi) должен иметь зависимость от каталога служб приложения и всех проектов драйверов (в нашем примере только DBDriver).

Мы добавили в проект DBDriver несколько папок и классов, которые должны использоваться во всех драйверах, основанных на Entity Framework Core. На следующем рисунке показана структура проекта:

image Figure 3.10: DBDriver project structure

Вот описание всех папок:

  • Entities: поместите сюда все свои сущности Entity Framework Core, возможно, организовав их в подпапках.
  • Repositories: поместите сюда все реализации репозиториев, возможно, организовав их в подпапках.
  • MainDbContext: это скелет контекста базы данных Entity Framework проекта, который также содержит реализацию интерфейса IUnitOfWork.
  • Extensions: эта папка содержит два класса расширений. RepositoryExtensions предоставляет только метод расширения AddAllRepositories, который обнаруживает все реализации репозиториев и добавляет их в механизм введения зависимостей. Его код похож на один из методов расширения AddAllCommandHandlers, который мы описали в предыдущем подразделе, поэтому мы не будем его здесь описывать. DBExtension содержит только метод расширения AddDbDriver, который добавляет все реализации, предоставляемые DBDriver, в механизм введения зависимостей.

Реализация метода расширения AddDbDriver проста:

public static IServiceCollection AddDbDriver(
 this IServiceCollection services,
 string connectionString)
{
 services.AddDbContext<IUnitOfWork, MainDbContext>(options =>
 options.UseSqlServer(connectionString,
 b => b.MigrationsAssembly("DBDriver"
services.AddAllRepositories(typeof(DBExtensions).Assembly);
 return services;
}

Он принимает строку подключения к базе данных в качестве единственного входного параметра и добавляет контекст MainDbContext Entity Framework в качестве реализации интерфейса IUnitOfWork с помощью обычного метода расширения AddDbContext Entity Framework Core. Затем он вызывает метод AddAllRepositories, чтобы добавить все реализации репозиториев, предоставляемые DBDriver. Вот класс MainDbContext:

internal class MainDbContext : DbContext, IUnitOfWork
{
 public MainDbContext(DbContextOptions options)
 : base(options)
 {
 }
 protected override void OnModelCreating(ModelBuilder builder)
 {
 }
 region IUnitOfWork Implementation
}

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

#region IUnitOfWork Implementation
public async Task<bool> SaveEntitiesAsync()
{
 return await SaveChangesAsync() > 0; ;
}
public async Task StartAsync()
{
 await Database.BeginTransactionAsync();
}
public Task CommitAsync()
{

return Database.CommitTransactionAsync();
}
public Task RollbackAsync()
{
 return Database.RollbackTransactionAsync();
}
#endregion

Реализация IUnitOfWork проста, поскольку она состоит из сопряжения «один к одному» с методами DBContext.

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

Обсудив все, что нам нужно знать о DBDriver, перейдем к проекту Web API.

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

В нашем случае нам нужно добавить всего два вызова в Program.cs:

..
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddApplicationServices();
builder.Services.AddDbDriver(
 builder.Configuration?.GetConnectionString(
 "DefaultConnection") ?? string.Empty);
..

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

Контейнеры и Docker

Мы уже обсуждали преимущества микросервисов, которые не зависят от среды, в которой они работают; микросервисы можно перемещать с загруженных узлов на незагруженные без ограничений, тем самым достигая лучшего баланса нагрузки и, как следствие, лучшего использования доступного оборудования. Однако, если нам нужно смешивать устаревшее программное обеспечение с более новыми модулями или если мы хотим использовать лучший стек для каждого модуля, с возможностью смешивать несколько реализаций стека разработки, мы столкнемся с проблемой, что каждый стек имеет разные аппаратные/программные требования. В таких случаях независимость каждого микросервиса от хостинговой среды может быть восстановлена путем развертывания каждого микросервиса вместе со всеми его зависимостями на частной виртуальной машине. Однако запуск виртуальной машины с ее частной копией операционной системы занимает много времени, а микросервисы должны запускаться и останавливаться быстро, чтобы снизить затраты на балансировку нагрузки и восстановление после сбоев. К счастью, микросервисы могут полагаться на более легкую форму технологии виртуализации: контейнеры. Контейнеры обеспечивают легкую и эффективную форму виртуализации. В отличие от традиционных виртуальных машин, которые виртуализируют всю машину, включая операционную систему, контейнеры виртуализируются на уровне файловой системы операционной системы, находясь поверх ядра операционной системы хоста. Они используют операционную систему хост-машины (ядро, DLL и драйверы) и используют нативные функции операционной системы для изоляции процессов и ресурсов, создавая изолированную среду для образов, которые они запускают.

На следующем рисунке показано, как работают контейнеры:

image Figure 3.11: Container basic principles

Контейнеры запускаются средой выполнения контейнеров из образов, которые кодируют их содержимое. Один и тот же образ может создавать несколько идентичных контейнеров. Образы хранятся в реестрах образов, которые идентифицируют их по имени образа и версии образа. В свою очередь, образы создаются с помощью команд в текстовом файле, которые определяют как содержимое контейнера, так и его свойства. Более конкретно, имена представляют собой URL-адреса, доменная часть которых является доменом реестра, а часть пути состоит из пространства имен, которое включает связанные образы, и имени репозитория. Версия присоединяется к этому URL-адресу с помощью двоеточия и называется тегом, поскольку может быть любой строкой. Подводя итог, имя и версия кодируются, как показано здесь:

<registry domain>/<namespace>/<repository name>:<tag>

Так, например, полный URL-адрес образа Docker для среды выполнения ASP.NET CORE 9.0 выглядит следующим образом:

mcr.microsoft.com/dotnet/aspnet:9.0

Здесь mcr.microsoft.com — это домен реестра, dotnet — пространство имен, asp.net — имя репозитория, а 9.0 — тег.

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

image Figure 3.12: Containers/images lifecycle

В остальной части книги мы будем использовать контейнеры Docker в качестве де-факто стандарта. Каждый образ Docker создается путем указания изменений, которые необходимо применить к другому уже существующему образу с помощью языка описания контейнеров Docker. Инструкции по созданию образа Docker содержатся в файле, который должен называться Dockerfile (без какого-либо расширения). Каждый файл Dockerfile обычно начинается с инструкции FROM, которая указывает существующий образ для изменения, как показано здесь:

FROM mcr.microsoft.com/dotnet/aspnet:9.0
...

Тег с версией ASP.NET CORE, которую необходимо использовать, указывается после URL-адреса образа, перед которым ставится двоеточие, как показано в предыдущем коде. Образы, взятые из частных репозиториев, должны указываться с полным URL-адресом, который начинается с домена реестра. Образы без полного URL-адреса допускаются только в том случае, если они размещены в бесплатном общедоступном реестре Docker, hub.docker.com/r/.

На следующем рисунке показана иерархическая организация образов Docker:

image Figure 3.13: Hierarchy of images and containers

Оператор FROM указывает среду, в которой вы находитесь, называемую этапом сборки. После этого вы можете работать с образом, как с файловой системой, копируя в него файлы со своего компьютера и выполняя команды оболочки:

image Figure 3.14: Building the image

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

Вот основные команды Dockerfile:

WORKDIR <путь в файловой системе образа> Эта инструкция определяет текущий каталог в файловой системе образа. Если каталог не существует, он создается. После этого вы можете использовать относительные пути также в файловой системе образа.

  • COPY <путь на вашем компьютере> <путь в образе> Скопируйте один или несколько файлов в файловую систему образа. Если исходный путь обозначает папку, вся папка копируется рекурсивно; в противном случае копируется один файл. В любом случае, скопированная папка или файл принимают имя, указанное в пути образа.
  • Copy <путь1> <путь2> … ./ (или [<путь1>, <путь2>, …, ./] Содержимое, указанное всеми исходными путями, копируется в текущую папку образа. Имена исходных файлов не изменяются.
  • Copy –-from=<имя образа или URL>:<версия> … Работает как предыдущие команды копирования, но файлы берутся из образа, указанного именем/URL после from=. Имя может быть указано вместо URL только в том случае, если образ содержится на вашем компьютере или в общедоступном репозитории Docker. Если версия не указана, по умолчанию используется название последней версии.
  • RUN <команда> <аргумент1> <аргумент2> ... Эта команда выполняет указанную команду оболочки с указанными аргументами в текущем каталоге образа.
  • CMD [<команда>, <аргумент1>, <аргумент2>, ...] ENTRYPOINT [<команда>, <аргумент1>, <аргумент2>, ...] Это указывает, что происходит при запуске контейнера. Более конкретно, это объявляет как команду, так и аргументы, которые должны быть запущены при запуске контейнера.
  • EXPOSE Это объявляет все порты, поддерживаемые контейнером. Сетевой трафик должен быть перенаправлен в контейнер только через порты, объявленные здесь, но трафик, направленный на другие порты, не блокируется.

Dockerfile также может создавать промежуточные образы в качестве шага для определения окончательного образа. Например, образ, содержащий весь SDK .NET, может быть создан с единственной целью компиляции решения .NET. Затем окончательные двоичные файлы будут скопированы с помощью инструкции Copy –-from=… в окончательный образ, который содержит только среду выполнения .NET. Мы более подробно проанализируем эту возможность при обсуждении поддержки Visual Studio для Docker. Перейдем к очень простому примеру, чтобы ознакомиться как с инструкциями Dockerfile, так и с командами оболочки, которые манипулируют образами и контейнерами Docker.

Docker Desktop: простой пример

Для работы с Docker на клиентском компьютере необходимо установить Doker Desktop. Инструкции по установке см. в разделе «Технические требования». Как описано в разделе «Технические требования», все примеры предполагают наличие компьютера с Windows, на котором установлена WSL и настроен Docker Desktop для контейнеров Linux. После установки Docker Desktop у вас будет следующее:

  • Среда выполнения Docker, позволяющая создавать контейнеры из образов и запускать их на вашем компьютере.
  • Клиент Docker, позволяющий компилировать Dockerfiles в образы и выполнять другие команды оболочки, связанные с Docker.
  • Локальный реестр Docker. Все образы, скомпилированные на вашем компьютере, будут размещены здесь. Отсюда вы можете переместить их в другие реестры. Кроме того, перед созданием контейнеров на вашем компьютере вам необходимо загрузить их образы сюда. Чтобы продемонстрировать возможности Docker, мы начнем с простого примера на Java. Вы увидите, что для компиляции и запуска простой программы на Java не требуется ни среда выполнения Java, ни Java SDK, поскольку все необходимое загружается в создаваемый образ. Начнем с создания папки, в которую будут помещены все файлы, необходимые для создания образа. Назовем ее SimpleExample. В эту папку поместите файл Hello.java, содержащий следующий простой код:
class Hello{
 public static void main(String[] args){
 System.out.println("This program runs in a Docker container");
 }
}

Теперь в той же папке нам нужен только файл Dockerfile со следующим содержанием:

FROM eclipse-temurin:11
COPY . /var/www/java
WORKDIR /var/www/java
RUN javac Hello.java
CMD ["java", "Hello"]

eclipse-temurin — это Java SDK. Это позволит нам как компилировать, так и выполнять Java-код в нашем образе и наших контейнерах. Затем код копирует все содержимое нашей папки в новосозданный путь /var/www/java в создаваемом образе. Помните, что относительные пути в исходном коде оцениваются относительно положения Dockerfile. Наконец, мы переходим в папку var/www/java и запускаем компилятор Java, который создаст файл .jar в той же папке. Инструкция CMD указывает вызов команды Java на ранее созданном файле .jar, когда будет запущен контейнер на основе этого образа. Теперь нам нужно открыть оболочку Linux в нашей папке SimpleExample, чтобы выполнить команды Docker. Щелкните правой кнопкой мыши на изображении папки SimpleExample, одновременно нажав клавишу Shift, и выберите в появившемся меню опцию для открытия оболочки Linux. В качестве первого шага нам нужно скомпилировать инструкции Dockerfile для создания образа. Это делается с помощью команды build следующим образом:

docker build ./ -t simpleexample

Первый аргумент указывает расположение файла Dockerfile, а опция -t указывает тег (URL-адрес образа), который будет прикреплен к образу, в нашем случае simpleexample. Поскольку образ будет размещен в нашем локальном реестре Docker Desktop, достаточно указать часть URL, относящуюся к репозиторию, но если у вас есть несколько локальных образов, вы также можете добавить пространство имен, чтобы лучше классифицировать свои образы. Обычно на этом этапе тег версии не добавляется, и Docker использует последний тег по умолчанию

Помните: все имена изображений должны быть написаны строчными буквами!

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

Теперь выполните команду docker images, чтобы увидеть все образы, определенные в вашем локальном реестре. Вы должны увидеть среди них simpleexample. Образы также отображаются в пользовательском интерфейсе, который появляется при двойном щелчке по значку Docker Desktop на рабочем столе. Теперь давайте создадим контейнер на основе вновь созданных образов. Команда run создает контейнер на основе заданного образа и сразу же запускает его:

docker run --name myfirstcontainer simpleexample

Опция --name указывает имя контейнера, а другой аргумент — имя образа, который мы хотим использовать для создания контейнера. Контейнер выводит строку, которую мы поместили в наш класс Java, а затем быстро завершает работу. Давайте перечислим все выполняющиеся контейнеры с помощью docker ps. Ни один контейнер не был перечислен, поскольку наш контейнер завершил свое выполнение. Однако мы также можем увидеть все неработающие контейнеры с помощью опции --all:

docker ps --all

Давайте повторно запустим наш контейнер. Если мы повторно выполним команду run, то создадим еще один контейнер, поэтому правильный способ повторного запуска спящего контейнера следующий:

docker restart myfirstcontainer

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

docker stop myfirstcontainer

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

docker rm myfirstcontainer

Теперь вы можете удалить также изображение, использованное для создания контейнера, с помощью следующей команды:

docker rmi simpleexample

Вы изучили много полезных команд оболочки Docker. Следующий раздел посвящен описанию некоторых более продвинутых полезных команд.

Еще несколько команд и опций Docker

Во время работы микросервисов контейнеры Docker перемещаются с одного аппаратного узла на другой для балансировки нагрузки. К сожалению, когда контейнер удаляется для создания в другом месте, все файлы, сохраненные в его файловой системе, теряются. По этой причине некоторые части файловой системы контейнера сопоставляются с внешним хранилищем, обычно предоставляемым сетевыми дисковыми устройствами. Это возможно, потому что команда run имеет опцию для сопоставления каталога на хост-машине (например, S) с каталогом во внутреннем пространстве хранения контейнера (например, D), так что файлы, записанные в D, фактически сохраняются в S и остаются в безопасности даже после удаления контейнера. Эта операция называется bind mount, и опция для ее добавления к команде run выглядит следующим образом:

docker run -v <путь к хост-машине>:<путь к контейнеру> ... Другой вариант позволяет сопоставить каждый порт, открытый контейнером, с фактическим портом на хост-компьютере: docker run -p <порт хост-машины>:<порт контейнера> ... Этот вариант можно повторить несколько раз, чтобы сопоставить более одного порта. Без этого варианта было бы невозможно перенаправить сетевой трафик внутри контейнера. Опция -e передает переменные среды операционной системы в контейнер. Код, выполняющийся в контейнере, может легко запросить значения этих переменных у операционной системы, поэтому они являются предпочтительным способом настройки приложения: docker run -e mayvariable1=mayvalue1 -e mayvariable2=mayvalue2. .. Еще одна полезная опция команды run — опция -d (d означает «отсоединенный»): docker run -d ... Когда этот параметр указан, контейнер запускается отдельно от текущей командной строки оболочки, то есть в отдельном процессе. Таким образом, контейнер, в котором запущена бесконечная программа, такая как веб-сервер, не блокирует командную строку оболочки. Каждый образ может быть привязан к неограниченному количеству тегов, которые можно использовать в качестве альтернативных имен: docker tag <имя образа> <тег> Тегирование — это первый шаг для отправки локального образа в общедоступный реестр. Предположим, у нас есть образ под названием myimage, который мы хотим отправить в частный реестр, который у нас есть в Azure, например, myregistry. azurecr.io/. Предположим, мы хотим поместить этот образ в путь mypath/mymage этого реестра, то есть в myregistry.azurecr.io/mypath/mymage. В качестве первого шага мы помечаем наш образ его окончательным URL-адресом: docker tag myimage myregistry.azurecr.io/mypath/mymage Затем достаточно выполнить операцию push, которая использует новый тег, прикрепленный к образу: docker push myregistry.azurecr.io/mypath/mymage: Извлечение образов из общедоступного реестра в наш локальный реестр также не представляет сложности: docker pull myregistry.azurecr.io/mypath/myotherimage:

Перед взаимодействием с реестром, требующим входа в систему, необходимо выполнить операцию входа в систему. Каждый реестр имеет свою собственную процедуру входа в систему.

Самый простой способ войти в реестр Azure — использовать Azure CLI. Вы можете скачать его установщик здесь: https://aka.ms/installazurecliwindows. В качестве первого шага войдите в свою учетную запись Azure с помощью следующей команды: az login Эта команда должна запустить ваш браузер по умолчанию и провести вас через процедуру ручного входа в вашу учетную запись Azure. После входа в свою учетную запись Azure вы можете войти в свой частный реестр, введя следующую команду: az acr login --name Здесь — это уникальное имя вашего реестра Azure, а не его полный URL-адрес. После входа в систему вы можете свободно работать со своим реестром Azure. Visual Studio имеет встроенную поддержку Docker. Давайте проанализируем все возможности, которые предлагает эта поддержка.

Поддержка Docker в Visual Studio

Поддержку Docker в Visual Studio можно включить, просто установив флажок «Включить поддержку контейнеров» в соответствующих настройках проекта Visual Studio. Давайте попробуем с проектом ASP. NET Core MVC. После выбора проекта и присвоения ему имени, например DockerTest, вы должны попасть на следующую страницу настроек:

image Figure 3.15: Enabling Docker support

Установите флажок «Включить поддержку контейнеров». Если вы забыли включить поддержку Docker здесь, вы всегда можете щелкнуть правой кнопкой мыши значок проекта в Visual Studio Solution Explorer и выбрать «Добавить» -> «Поддержка Docker». Проект содержит файл Dockerfile:

image Figure 3.16: Visual Studio Dockerfile

Щелкните файл Dockerfile; он должен содержать определение четырех образов. Фактически, окончательный образ создается в четыре этапа.

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

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081

Имя базы после AS будет вызываться другими инструкциями FROM в том же файле. На втором этапе выполняется сборка проекта с помощью SDK dotnet:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DockerTest/DockerTest.csproj", "DockerTest/"]
RUN dotnet restore "./DockerTest/DockerTest.csproj"
COPY . .
WORKDIR "/src/DockerTest"
RUN dotnet build "./DockerTest.csproj" -c $BUILD_CONFIGURATION -o /app/
build

Инструкция ARG определяет переменную, которую можно вызвать как $BUILD_CONFIGURATION в других инструкциях. Здесь она используется для определения выбранной конфигурации для сборки. Вы можете заменить ее значение на Debug, чтобы скомпилировать в режиме отладки. Первая инструкция Copy просто копирует файл проекта в каталог /src/DockerTest образа. Затем восстанавливаются пакеты Nuget и все исходные файлы копируются из каталога, содержащего Dockerfile, в текущий каталог образа, /src. Наконец, мы переходим в /src/DockerTest и выполняем сборку. Файлы вывода сборки помещаются в каталог /app/build в образе. Третий этап построен на основе образа сборки и просто публикует файлы проекта в папке / app/publish:

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./DockerTest.csproj" -c $BUILD_CONFIGURATION -o /app/
publish /p:UseAppHost=false

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

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

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DockerTest.dll"]

Теперь установите точку останова в методе Index файла HomeController.cs и запустите решение. Visual Studio автоматически скомпилирует файл Dockerfile и запустит образ. Точка останова будет достигнута, поскольку Visual Studio может выполнять отладку внутри образов контейнеров ! Во время работы приложения Visual Studio отображает для каждого контейнера журналы, переменные среды, привязки и другую информацию:

image Figure 3.17: Visual Studio Containers console

Вы также можете получить интерактивную оболочку внутри каждого контейнера, где вы можете исследовать файловую систему контейнера, выполнять команды оболочки, а также проводить диагностику и измерять производительность, просто открыв оболочку Linux и выполнив следующую команду: docker exec -it <имя-или-идентификатор-контейнера> /bin/bash

В нашем случае давайте перечислим все запущенные контейнеры с помощью docker ps, чтобы получить ID нашего контейнера:

CONTAINER ID IMAGE COMMAND CREATED
STATUS PORTS NAMES
f6ca4537e060 dockertest "dotnet --roll-forwa…" 17 minutes ago Up
17 minutes 0.0.0.0:49154->8080/tcp, 0.0.0.0:49153->8081/tcp DockerTest

Затем выполните следующее:

docker exec -it DockerTest /bin/bash

Теперь вы находитесь в файловой системе контейнера! Давайте попробуем некоторые команды оболочки, например Is. Когда вы закончите работу с контейнером, достаточно выполнить команду exit, чтобы вернуться в консоль хост-компьютера.

Резюме

В этой главе описаны два важных компонента архитектуры микрослужб: архитектура «луковица» и контейнеры Docker. В главе описаны основные принципы архитектуры «луковица» и организация уровней прикладных служб и доменов. В частности, мы описали команды, запросы, события и их обработчики вместе с агрегатами и объектами-значениями. Кроме того, вы узнали, как использовать вышеупомянутые концепции в решении Visual Studio благодаря предоставленным шаблонам решений Visual Studio. В главе объясняется важность контейнеров, как создать файл Dockerfile и как использовать команды оболочки Docker на практике. В заключение в главе описана поддержка Docker в Visual Studio. Следующая глава посвящена функциям Azure и их основным триггерам.

Вопросы

  1. Правда ли, что проект уровня домена должен иметь ссылку на проект драйвера базы данных? Нет, это неправда. Ссылки на драйверы должны быть добавлены в уровень инфраструктуры.
  2. Какие проекты решения входят в ссылки служб приложения? Только те проекты, которые являются частью доменного уровня.
  3. Какие проекты решения входят в ссылки проекта самого внешнего уровня лукообразной архитектуры? Сервисы приложения, драйверы баз данных и все драйверы инфраструктуры.
  4. Правда ли, что агрегат всегда соответствует уникальной таблице базы данных? Нет, это неправда.
  5. Зачем нужны события домена? Они нужны для развязки кода разных агрегатов.
  6. Какова цель инструкции WORKDIR Dockerfile? Установить текущий каталог образа.
  7. Как можно передать переменные среды в контейнер? С помощью опции -e команды docker run.
  8. Каков правильный способ сохранить хранилище контейнеров Docker? Для сохранения хранилища контейнеров Docker используются привязки томов.

Микросервисы на практике

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

Все концепции будут проиллюстрированы на примере микросервиса-рабочего, взятого из прикладного исследования, представленного в книге, которое мы представили в подразделе «Пример каршеринга» главы 2 «Развенчание мифов о микросервисных приложениях».

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

Наконец, мы обсудим детали реализации микросервиса с помощью шаблонов проектов Onion Architecture, представленных в разделе «Шаблон решения на основе архитектуры Onion Architecture» главы 3 «Настройка и теория: Docker и архитектура

Наконец, мы обсудим детали реализации микросервиса с помощью шаблонов проектов Onion Architecture, представленных в разделе «Шаблон решения на основе Onion Architecture» главы 3 «Настройка и теория: Docker и Onion Architecture».

В частности, в этой главе рассматриваются следующие вопросы:

  • Микросервис планирования маршрутов приложения каршеринга
  • Базовый дизайн микросервиса
  • Обеспечение отказоустойчивой связи с Polly
  • От абстракции к деталям реализации

Микросервис планирования маршрутов приложения для каршеринга

В этом разделе мы описываем наш пример микросервиса, как обеспечить безопасность и как подготовить решение для его внедрения в трех отдельных подразделах.

Спецификации микросервисов

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

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

Когда предложение о продлении маршрута принимается, исходный маршрут продлевается.

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

Запросы и маршруты удаляются или изменяются в следующих случаях:

  1. Маршрут удаляется из возможных совпадений, когда он закрыт для новых участников или прерван.
  2. Маршрут расширяется, когда он объединяется с некоторыми запросами. В результате этой операции новые совпадения не ищутся.
  3. Запрос удаляется из возможных совпадений, когда он объединяется с маршрутом.
  4. Запрос становится доступным снова, когда маршрут, с которым он был объединен, прерван. После этой операции ищутся новые совпадения.
  5. Как запросы, так и маршруты удаляются через N дней после истечения их максимального срока действия, где N — параметр, который необходимо указать.

Сопоставление маршрутов и запросов выполняется при соблюдении следующих условий:

  1. Дата маршрута находится в пределах минимальной и максимальной дат, связанных с запросом.
  2. Города начала и окончания запроса находятся достаточно близко к маршруту.

Мы будем реализовывать большую часть коммуникации между микрослужбами с помощью паттерна Pub/Sub, чтобы максимально увеличить развязку микрослужб. Этот выбор также минимизирует общий объем кода, связанного с коммуникацией, поскольку обработчики сообщений и их клиентские библиотеки решают большинство проблем асинхронной коммуникации. Более подробную информацию о коммуникации на основе событий см. в подразделе «Коммуникация на основе событий» главы 2 «Разгадка микрослужб».

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

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

Большинство веб-серверов и библиотек связи можно настроить на автоматическое сжатие данных JSON. Веб-серверы согласовывают сжатие с клиентом.

Наконец, поскольку внутренняя и внешняя коммуникация нашего микросервиса работника основана на брокерах сообщений, а не на обычных протоколах HTTP и gRPC ASP.NET Core, мы можем рассмотреть специальный шаблон проекта службы Worker, основанный на так называемых хостируемых службах (хостируемые службы будут рассмотрены в следующем разделе). Однако лучшие практики микросервисов предписывают, что каждый микросервис должен раскрывать конечную точку HTTP для проверки своего состояния, поэтому мы будем использовать минимальный проект ASP.

NET Core Web API на основе API, поскольку он также поддерживает хостируемые службы, которые нам нужны для приема коммуникации на основе посредников сообщений. Уточнив обязанности микросервисов, мы можем перейти к соображениям безопасности.

Обработка безопасности и авторизации

Авторизация запросов, поступающих от реальных пользователей, обрабатывается с помощью обычных методов ASP.NET Web API, то есть с помощью веб-токенов (обычно это JSON-токен носителя) и атрибутов Authorize.

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

Запросы, поступающие от других служб, обычно защищаются с помощью mTLS, то есть с помощью аутентификации клиента на основе сертификата. Клиентские сертификаты обрабатываются протоколом TCP/IP более низкого уровня вместе с сертификатом сервера, используемым для шифрования HTTPS-связи. Затем информация, извлеченная из клиентского сертификата, передается в промежуточное ПО аутентификации ASP.NET Core для создания ClaimsPrincipal (обычный объект ASP.NET Core User). Когда приложение работает в оркестраторе, также можно использовать авторизацию, специфичную для оркестратора, а когда приложение работает в облаке, можно использовать авторизацию, специфичную для облака.

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

Мы проанализируем оркестратор Kubernetes в главе 8 «Практическая организация микросервисов с помощью Kubernetes» и его средства обеспечения безопасности связи в главе 10 «Безопасность и наблюдаемость для бессерверных приложений и приложений на основе микросервисов». Даже в частной сети рекомендуется шифровать внутреннюю коммуникацию с помощью mTLS или других методов шифрования, чтобы снизить риск внутренних угроз и сетевых атак, но для простоты в этой книге мы будем обеспечивать безопасность только коммуникации с внешним миром. Таким образом, если мы правильно организуем нашу частную сеть, нам нужно обеспечить безопасность только коммуникации с внешним миром, то есть коммуникации с фронтенд-микросервисами. Однако, как обсуждалось в подразделе «Взаимодействие с внешним миром» главы 2 «Развенчание мифов о микросервисных приложениях», приложения на основе микросервисов используют API-шлюзы для связи с внешним миром. В простейшем случае интерфейс с внешним миром представляет собой просто веб-сервер с балансировкой нагрузки, который выполняет терминацию HTTPS, то есть принимает HTTPS-сообщения от внешнего мира. Хотя некоторые архитектуры терминируют HTTPS на API-шлюзе и используют HTTP внутри сети, рекомендуется поддерживать шифрование в частной сети с помощью mTLS или повторного шифрования, чтобы обеспечить безопасность в экосистеме микросервисов. Таким образом, мы можем использовать только один сертификат HTTPS для всего приложения, избегая тем самым всей процедуры выдачи и обновления сертификатов для всех микросервисов, составляющих приложение.

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

Теперь мы готовы подготовить решение Visual Studio, которое будет размещать микрослужбу планирования маршрутов!

Создание решения Visual Studio

Поскольку мы решили реализовать самый внешний уровень нашего рабочего микросервиса с помощью проекта ASP.NET Core Web API, давайте создадим решение CarSharing Visual Studio, содержащее проект ASP.NET Core Web API под названием RoutesPlanning. Проект ASP.NET Core Web API можно легко найти, выбрав C#, Все платформы и Web API из раскрывающихся списков окна выбора проекта Visual Studio, как показано здесь:

image Figure 7.1: Project selection

Как обсуждалось ранее, мы можем избежать использования HTTPS-связи, а микросервисы-рабочие не требуют аутентификации. Однако нам нужна поддержка Docker, поскольку микросервисы обычно контейнеризированы.

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

image Figure 7.2: Project settings

Мы будем использовать архитектуру Onion, поэтому нам также необходимо добавить проект для Application Services и Domen. Поэтому давайте добавим еще два проекта библиотеки классов, названные RoutesPlanning ApplicationServices и RoutesPlanningDomainLayer. Мы адаптируем шаблон архитектуры Onion, представленный в разделе «Шаблон решения на основе архитектуры Onion» главы 3, «Настройка и теория: Docker и архитектура Onion». Откроем шаблон проекта OnionArchitectureComplete, который можно найти в папке ch03 репозитория GitHub книги. В проекте RoutesPlanningDomainLayer удалите файл Class1.cs, выберите три папки в проекте DomainLayer шаблона проекта ch03, скопируйте их и вставьте в проект RoutesPlanningDomainLayer. Если у вас установлена последняя версия Visual Studio 2022, вы сможете выполнить операцию копирования из Visual Studio Solution Explorer. Кроме того, добавьте ссылку на пакет Microsoft.Extensions.DependencyInjection.Abstractions.

Затем выполните аналогичные операции в проектах RoutesPlanningApplicationServices и ApplicationServices. Теперь, когда все файлы Onion Architecture находятся на месте, вам нужно добавить ссылку на RoutesPlanningDomainLayer в RoutesPlanningApplicationServices и ссылку на Rout esPlanningApplicationServices в RoutesPlanning.

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

Добавим новый проект библиотеки классов и назовем его RoutesPlanningDBDriver. Добавим ссылки на пакеты Nuget Microsoft.EntityFrameworkCore.SqlServer и Microsoft.EntityFrameworkCore.Tools, а также на проект RoutesPlanningDomainLayer. После этого удалите файл Class1.cs и замените его всеми файлами кода и папками из проекта DBDriver шаблона проекта ch03. Наконец, добавьте ссылку на RoutesPlanningDBDriver в RoutesPlanning и добавьте следующий фрагмент кода в файл RoutesPlanning Program.cs:

builder.Services.AddOpenApi();

builder.Services.AddApplicationServices();
builder.Services.AddDbDriver(
builder.Configuration?.GetConnectionString("DefaultConnection") ?? string.Empty);

RoutesPlanning требует ссылки на RoutesPlanningDBDriver, поскольку самый внешний слой Onion Architecture должен ссылаться на все драйверы, специфичные для реализации. AddApplicationServices добавляет все запросы, команды и обработчики событий в механизм впрыска зависимостей, а AddDbDtiver добавляет все реализации репозиториев и реализацию IUnitOfWork в впрыск зависимостей.

Теперь наше решение наконец готово! Мы можем приступить к проектированию нашего рабочего микросервиса!

Базовый дизайн микросервиса

В этом разделе мы определим все основные абстракции микросервиса, то есть общую стратегию коммуникации, все команды и события Onion Architecture, а также верхние уровни циклов необходимых хостируемых сервисов. Начнем с описания выбранного брокера сообщений: RabbitMQ.

Брокер сообщений: RabbitMQ

RabbitMQ изначально поддерживает асинхронный протокол обмена сообщениями AMQP, который является одним из наиболее используемых асинхронных протоколов, другим из которых является MQTT, имеющий специальный синтаксис для модели «издатель/подписчик». Поддержка MQTT может быть добавлена с помощью плагина, но RabbitMQ имеет средства для простой реализации модели «издатель/подписчик» на основе AMQP. Кроме того, RabbitMQ предлагает несколько инструментов для поддержки масштабируемости, восстановления после сбоев и избыточности, поэтому он отвечает всем требованиям, чтобы быть первоклассным игроком в облачных средах и средах микросервисов. Более конкретно, определив кластер RabbitMQ, мы можем достичь как балансировки нагрузки, так и репликации данных, что требуется в большинстве баз данных SQL и NoSQL.

В этом разделе мы просто опишем основные операции RabbitMQ, а установка и использование кластеров RabbitMQ в Kubernetes будут рассмотрены в главе 8 «Практическая организация микросервисов с помощью Kubernetes». Более подробную информацию можно найти в учебных материалах и документации на официальном сайте RabbitMQ: https://www.rabbitmq.com/.

Сообщения RabbitMQ должны быть подготовлены в двоичном формате, поскольку сообщения RabbitMQ должны быть просто массивом байтов. Однако мы будем использовать клиент EasyNetQ, который занимается сериализацией объектов, а также большей частью подключения клиент-сервер и восстановлением после ошибок.

EasyNetQ — это пакет NuGet, построенный на основе низкоуровневого клиента RabbitMQ.Client NuGet, который упрощает использование RabbitMQ, сокращая накладные расходы на коммуникационный код и повышая его модульность и модифицируемость.

После отправки в RabbitMQ сообщения помещаются в очереди. Точнее говоря, они помещаются в одну или несколько очередей, проходя через другие сущности, называемые обменниками (Exchanges). Обмены направляют сообщения в очереди, используя стратегию маршрутизации, которая зависит от типа обмена. Обмены — это концепция, специфичная для AMQP, и они являются способом RabbitMQ настраивать сложные протоколы связи, такие как протокол публикации/подписки, как показано на следующем рисунке:

image Figure 7.3: RabbitMQ exchanges

Адекватно определив стратегию маршрутизации обмена, мы можем реализовать несколько шаблонов. Более конкретно, применяются следующие:

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

Существует также обмен темами, который улучшает модель «издатель/подписчик», позволяя сопоставлять именованные подклассы событий, называемые темами. Сопоставление между получателями и темами также поддерживает символы-заполнители. Мы опишем его практическое использование с микрослужбами предприятия в подразделе «Обеспечение обработки сообщений в правильном порядке».

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

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

Самый простой способ установить RabbitMQ — использовать его образ Docker. Мы воспользуемся этим вариантом, поскольку все наши микросервисы также будут контейнеризованы, а в окончательной версии Kubernetes общего приложения мы будем использовать контейнеризованные кластеры RabbitMQ.

Мы можем просто запустить следующую команду в оболочке Linux:

docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4.0-management

Поскольку мы указали флаги -it, после загрузки образа, создания и запуска контейнера оболочка Linux остается заблокированной в файловой системе контейнера. Кроме того, поскольку мы также добавили опцию –-rm, контейнер уничтожается, как только он останавливается с помощью следующей строки:

docker stop rabbitmq

Чтобы убедиться, что RabbitMQ работает правильно, перейдите по ссылке http://localhost:15672. Должна появиться консоль управления RabbitMQ. Вы можете войти в систему с помощью учетных данных для запуска, которые для имени пользователя и пароля являются guest.

Вам не нужно оставлять контейнер запущенным; вы можете остановить его и повторно выполнить команду запуска, когда вам понадобится RabbitMQ для тестирования кода микрослужбы. Дисковое пространство, необходимое для RabbitMQ, монтируется как том Docker с помощью следующего оператора volume, вставленного непосредственно в образ Dockerfile:

VOLUME /var/lib/rabbitmq

Это означает, что содержимое диска сбрасывается при уничтожении контейнера и его повторном запуске. Поэтому, если вы хотите сохранить содержимое диска, не запускайте контейнер с опцией –-rm, чтобы он не был уничтожен при остановке. Если вам нужны настраиваемые учетные данные, добавьте следующие переменные среды в команду запуска:

-e RABBITMQ_DEFAULT_USER=my_user_name -e RABBITMQ_DEFAULT_PASS=my_password

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

Теперь мы можем перейти к разработке входных и выходных сообщений наших рабочих микросервисов.

Входная коммуникация

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

Однако в более сложных сценариях, содержащих сотни или тысячи микросервисов, их организация должна быть иерархической, поэтому у нас будут сообщения уровня 0, известные всем микросервисам; сообщения уровня 1, известные только в группах микросервисов уровня 1, и так далее.

Давайте добавим в наше решение новый проект «Библиотека классов» с названием SharedMessages и выберем для него версию standard 2.1. Затем добавим ссылку на этот новый проект в проект RoutesPlannin gApplicationServices. Здесь мы разместим все сообщения приложения.

Из спецификаций микросервиса планирования маршрутов мы получаем всего четыре сообщения:

  1. Новый запрос: он будет содержать уникальный идентификатор запроса, интервал приемлемых дат поездки и два уникальных идентификатора для городов отправления и прибытия, их отображаемые имена, а также их широту и долготу. Кроме того, он будет содержать уникальный идентификатор, представляющий пользователя, который отправил запрос, и его отображаемое имя.
  2. Новый маршрут: он будет содержать уникальный идентификатор маршрута, дату поездки и два уникальных идентификатора, представляющих города отправления и прибытия, их отображаемые названия, а также их широту и долготу. Кроме того, он будет содержать уникальный идентификатор, представляющий владельца автомобиля, который отправил предложение по маршруту, и его отображаемое имя.
  3. Маршрут закрыт/прерван: Содержит только уникальный идентификатор маршрута и флаг, указывающий, был ли маршрут успешно закрыт или прерван.
  4. Продление маршрута: Сообщает, что владелец автомобиля согласился продлить маршрут с городами начала и окончания других запросов. Содержит ту же информацию, что и сообщение о новом маршруте, а также сообщения о новых запросах. Также содержит флаг, указывающий, был ли маршрут закрыт для других участников после продления.
  5. Отмена маршрута: Сообщает, что владелец автомобиля отказался от продления маршрута.

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

Однако сообщения, отправляемые по схеме «издатель/подписчик», используются несколькими потенциально неизвестными подписчиками, поэтому они не могут предполагать наличие конкретных априорных знаний о подписчиках. Например, сообщение о расширении маршрута также будет подписано микросервисом, который обрабатывает все запросы, не содержащие информацию обо всех существующих предложениях по маршруту, поэтому вся информация, необходимая для объединенного маршрута, должна быть получена через это сообщение. Напротив, сообщение о закрытии/прекращении маршрута не должно передавать всю информацию о маршруте, поскольку любой сервис, заинтересованный в этом событии, уже должен знать об этом маршруте и уже иметь все необходимые данные о нем. Эти данные могут отсутствовать, если сервис никогда не взаимодействовал с этим маршрутом, но в этом случае событие, представленное в сообщении, не может изменить его состояние и должно просто игнорироваться. Важный вопрос, который мы всегда должны задавать о всех входных данных микросервисов: что произойдет, если сообщения поступят в неправильном порядке, то есть в порядке, отличном от того, в котором они были отправлены? Если порядок сообщений имеет значение, мы либо обеспечиваем, чтобы все сообщения поступали и обрабатывались в правильном порядке, либо переупорядочиваем сообщения с помощью метода, описанного в подразделе «Эффективная обработка асинхронной коммуникации» главы 2 «Разгадка микросервисных приложений». К сожалению, переупорядочивания входных сообщений недостаточно; мы также должны обрабатывать их в правильном порядке.

Это нетривиальная задача, если несколько реплик одного и того же микросервиса обрабатывают эти входные сообщения одновременно. К счастью, ни одно приложение не требует фиксированного порядка для всех входных сообщений. Но некоторые связанные сообщения, например, все сообщения, содержащие один и тот же маршрут, должны обрабатываться в правильном порядке. Поэтому мы можем избежать простой одновременной обработки связанных сообщений, передавая все связанные сообщения одной и той же реплике. Мы проанализируем методы достижения аналогичной стратегии балансировки нагрузки всех реплик в разделе «Обеспечение обработки сообщений в правильном порядке». В нашем случае порядок поступления новых предложений маршрутов и запросов маршрутов не является проблемой, поскольку мы можем правильно обрабатывать сообщения, поступившие не в порядке, с помощью простых уловок. Нам просто нужно добавить номер версии обновления, чтобы обнаруживать прошлые обновления. Номера версий обновлений должны быть уникальными и должны соответствовать реальному порядку, в котором обновления были применены к данному объекту. Когда объект создается, он начинает с версии 0, а затем этот номер увеличивается при каждом новом обновлении.

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

Фактически, мы можем распознать и применить входящее изменение только в том случае, если оно более позднее, чем то, которое уже было применено. Более того, мы всегда можем проверить, не было ли уже удалено упомянутое в сообщении об изменении сущность, и отклонить изменение. Наконец, если упомянутая в изменении сущность еще не была создана, мы всегда можем создать ее с помощью данных, содержащихся в сообщении об изменении, поскольку каждое изменение содержит все данные сущности. В нашем случае порядок сообщений о расширении маршрута не имеет значения, потому что запросы, объединенные в маршрут, просто суммируются, и достаточно выбрать более поздний список городов из того, который хранится в маршруте, и того, который содержится в сообщении. Инверсии сообщений о расширении маршрута и закрытии/прекращении маршрута также не вызывают проблем, поскольку достаточно игнорировать расширения прекращенных маршрутов и объединять предыдущие запросы, поступившие после закрытия. Инверсии создания и расширения маршрутов никогда не могут иметь место, поскольку только успешно созданные маршруты могут вызывать совпадения запросов и маршрутов, которые впоследствии могут вызывать расширения маршрутов. Удаленные маршруты не вызывают проблем, поскольку как сообщения об отмене маршрута, так и сообщения о закрытии маршрута являются де-факто логическими удалениями. Мы можем удалить их по истечении N дней после окончания дня путешествия, поскольку на этот момент предыдущие задержанные сообщения не могут поступить (сообщения могут быть задержаны на несколько часов или даже на день в случае серьезных сбоев). Это можно сделать с помощью cron-задач. Возможное дублирование сообщений из-за таймаутов и повторных отправлений также не вызывает проблем, поскольку их всегда можно распознать и игнорировать. В качестве упражнения вы можете подробно проанализировать все возможности.

Все необходимые сообщения можно легко определить с помощью нескольких базовых типов, которые мы поместим в папку BasicTypes проекта SharedMessages. Они следующие:

public class GeoLocalizationMessage
{
 public double Latitude { get; set; }
 public double Longitude { get; set; }
}
public class TimeIntervalMessage
{
 public DateTime Start { get; set; }
 public DateTime End { get; set; }
}
public class UserBasicInfoMessage
{
 public Guid Id { get; set; }
 public string? DisplayName { get; set; }
}
public class TownBasicInfoMessage
{
 public Guid Id { get; set; }
 public string? Name { get; set; }
 public GeoLocalizationMessage? Location { get; set; }
}

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

public class TimedMessage
{
 public long TimeStamp { get; set; }
}

Давайте поместим этот класс тоже в папку BasicTypes.

Теперь все сообщения можно определить следующим образом:

  1. New request:
public class RouteRequestMessage: TimedMessage
{
 public Guid Id { get; set; }
 public TownBasicInfoMessage? Source { get; set; }
 public TownBasicInfoMessage? Destination { get; set; }
 public TimeIntervalMessage? When { get; set; }
 public UserBasicInfoMessage? User { get; set; }
}
  1. New route:
public class RouteOfferMessage: TimedMessage
{
 public Guid Id { get; set; }
 public IList<TownBasicInfoMessage>? Path { get; set; }
 public DateTime? When { get; set; }
 public UserBasicInfoMessage? User { get; set; }
}
  1. Route closed/aborted:
public class RouteClosedAbortedMessage: TimedMessage
{
 public Guid RouteId { get; set; }
 public bool IsAborted { get; set; }
}
  1. Route extension:
public class RouteExtendedMessage: TimedMessage
{
 public RouteOfferMessage? ExtendedRoute { get; set; }
 public IList<RouteRequestMessage>? AddedRequests { get; set; }
 public bool Closed { get; set; }
}

Place them in a SharedMessages project folder called RouteNegotiation.

Мы только что закончили с проектированием ввода микросервиса! Перейдем к выводу.

Выходная коммуникация

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

public class RouteExtensionProposalsMessage: TimedMessage
{
 public Guid RouteId { get; set; }
 public IList<RouteRequestMessage>? Proposals { get; set; }
}

Давайте поместим этот класс в папку RouteNegotiation проекта SharedMessages.

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

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

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

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

На этом этапе вы можете спросить: зачем нам нужна внутренняя очередь, если у нас уже есть внешняя очередь посредника сообщений? Есть две причины; первая из них, в частности, довольно убедительна:

  1. Внутренняя очередь реализована с помощью таблицы базы данных, поэтому она заполняется в той же транзакции базы данных, что и обновление базы данных, которое вызвало событие вывода. Поэтому, если что-то пойдет не так, вся транзакция будет прервана, что дает возможность повторить ее позже.
  2. Стоимость производительности для достижения того же результата непосредственно с помощью очереди посредника сообщений выше: мы должны держать транзакцию базы данных открытой, пока не получим подтверждение, ошибку или таймаут от передачи сообщения посреднику сообщений. Это время становится на несколько порядков выше, если мы используем экспоненциальный повтор.
  3. Как только сообщение попадает во внутреннюю очередь, в случае сбоев нам не нужно отменять обновление базы данных, а просто повторить передачу сообщения позже.
  4. Из-за разных способов реализации баз данных и брокерских систем сообщений, а также из-за того, что база данных используется только репликами микросервисов, подтверждение успешного выполнения всей транзакции базы данных (необходимое обновление плюс регистрация выходного сообщения во внутренней очереди) происходит быстрее, чем подтверждение брокерской системы сообщений.

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

Обеспечение обработки сообщений в правильном порядке

Как обсуждалось в предыдущих подразделах, наш микросервис планирования маршрутов не требует соблюдения правильного порядка обработки сообщений. Однако бывают случаи, когда обработка всех сообщений в правильном порядке неизбежна, поэтому в этом подразделе мы обсудим, как обычно с ними обращаются. Стоит отметить, что стратегии обеспечения правильного порядка обработки сообщений оказывают незначительное влияние на производительность и масштабируемость, поэтому любые уловки, позволяющие избежать их использования, приветствуются. Обычно ограничения по порядку должны соблюдаться только в пределах одной группы связанных сообщений, поэтому достаточно обеспечить следующее: a. Все сообщения, принадлежащие одной группе связанных сообщений, обрабатываются одной и той же репликой микросервиса, поэтому параллелизм между репликами не может изменить порядок обработки сообщений. b. Каждая реплика обрабатывает сообщение только после того, как все предыдущие сообщения были успешно обработаны. Для правильной работы вышеописанной техники необходимо, чтобы каждое сообщение содержало свой порядковый номер в своей группе. Часто группы совпадают с сущностями базы данных или, точнее, с агрегатами базы данных. То есть два сообщения принадлежат одной группе, если они представляют разные операции, выполняемые над одной и той же сущностью. Таким образом, в случае нашего сервиса планирования маршрутов у нас может быть группа для каждого запроса и для каждого маршрута. Теперь предположим, что существует N реплик микрослужбы, индексированных целыми числами от 1 до N. Мы можем определить хеш-функцию, которая, получая идентификатор группы, возвращает число от 1 до N. Таким образом, если мы направим каждое сообщение к реплике, индексированной результатом хеш-функции, примененной к группе сообщения, все сообщения в одной и той же группе будут обработаны одной и той же репликой. На следующем рисунке показана стратегия маршрутизации сообщений:

image Figure 7.4: Message sharding

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

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

Шардинг также приведет к потере гибкости при масштабировании количества реплик. Фактически, изменение количества реплик изменяет как хеш-функцию, так и группу сообщений, получаемых каждой репликой. По этим причинам операции масштабирования будут иметь более высокую стоимость и, следовательно, могут выполняться реже. На практике большинство оркестраторов автоматически масштабируют неиндексированные реплики в соответствии с настраиваемыми критериями, но не предлагают такую же услугу для реплик, которые необходимо индексировать. Мы более подробно проанализируем разницу между этими различными наборами реплик и автоматизацией масштабирования в главе 8 «Практическая организация микрослужб с помощью Kubernetes». Шардинг может быть реализован с помощью микросервиса с одной репликой, который получает все сообщения от брокер сообщения и направляет их в соответствующие реплики, отправляя их в очередь брокера сообщений, специфичную для реплики. Эта техника более сложна и требует большего количества кода, но она более гибкая. Например, если она получает информацию об изменениях в количестве реплик, она может динамически адаптировать свое поведение к количеству реплик.

Шардинг также можно реализовать с помощью тем RabbitMQ. По сути, тема — это строка, прикрепленная к сообщению, и подписчики событий могут быть включены только для некоторых тем. Поэтому, если мы прикрепим результат хеш-функции к каждому сообщению в качестве темы, то каждая реплика сможет подписаться только на тему, равную ее индексу, тем самым реализуя шардинг без необходимости использования дополнительного компонента. Недостатком метода шардинга на основе тем является то, что количество реплик должно быть известно всем отправителям и может быть изменено просто путем перезапуска всего приложения. Кроме того, поскольку тема, которую нужно присвоить каждому сообщению, зависит как от того, как микросервис назначения определяет группы сообщений, так и от самого микросервиса назначения, метод количества реплик не может быть использован с паттерном «издатель/подписчик», где сообщения принимаются несколькими гетерогенными микросервисами. RabbitMQ также имеет плагин шардинга (https://github.com/rabbitmq/rabbitmq-server/tree/ main/deps/rabbitmq_sharding), который вычисляет хеш modulo N. Этот плагин определяет новый тип обмена с стратегией маршрутизации на основе шардинга, который мы можем прикрепить непосредственно перед каждой отдельной очередью подписчиков. Кроме того, плагин занимается разделением уникальной очереди подписчиков на N различных шардированных очередей и распределением всех подписчиков между N шардированными очередями. Эта техника полностью аналогична технике маршрутизации микросервисов с одной репликой, но ее интеграция в брокер сообщений требует снижения гибкости для повышения производительности. Эта техника решает все проблемы техники на основе тем, но не поддерживается высокоуровневым интерфейсом EasyNetQ, поэтому она увеличивает сложность кода и упрощает его обслуживание. Кроме того, она требует конфигурации брокера, которая зависит от точной топологии всех подписчиков, что подрывает расширяемость приложения. Подводя итог, при использовании коммуникации «издатель/подписчик» лучшим вариантом почти всегда является техника микросервиса с маршрутизацией одной копии. Обсудив входные и выходные данные микросервисов, мы можем перейти к проектированию входных параметров контейнера микросервиса.

Проектирование параметров среды образа Docker

Как уже упоминалось в подразделе «Еще несколько команд и опций Docker» главы 3 «Настройка и теория: Docker и луковичная архитектура», контейнеры обычно адаптируются к среде развертывания путем передачи в качестве переменных среды виртуальной файловой системы контейнера. В среде .NET параметры доступны через интерфейс IConfiguration вместе со всеми параметрами, определенными в конфигурационных файлах .NET, таких как appsettings.json. Вложенные пути JSON представляются в аргументах словаря IConfiguration путем разделения всех сегментов двоеточиями, как в случае IConfiguration[«ConnectionStrings:DefaultConnection»], который

представляет обычную строку подключения к базе данных по умолчанию. Когда вложенные пути представлены переменными среды, двоеточия заменяются двойными подчеркиваниями, чтобы получить действительные имена переменных среды. Поэтому ConnectionStrings:DefaultConnection должно быть определено с помощью переменной среды с именем ConnectionStrings__DefaultConnection. Если имена переменных среды имеют префикс ASPNETCORE_ или DOTNET_, эти префиксы удаляются; поэтому ASPNETCORE_ENVIRONMENT можно получить с помощью IConfiguration[«ENVIRONMENT»]. Эти префиксы используются для передачи настроек, специфичных для ASP.NET Core и .NET, таких как тестовая, производственная или разработчическая среда, а также используется ASPNETCORE_HTTP_PORTS, который содержит разделенный точкой с запятой список всех портов, которые Kestrel должен прослушивать. Вы также можете определить свой собственный префикс, который будет применяться ко всем вашим переменным среды, чтобы избежать конфликтов имен. Однако, поскольку каждый микросервис имеет частный контейнер, конфликты между переменными среды, используемыми разными приложениями, невозможны. В любом случае, новый префикс переменной среды можно определить в разделе определения служб приложения с помощью кода, аналогичного следующему: builder.Configuration.AddEnvironmentVariables(prefix: «MyCustomPrefix_»); Как мы увидим в главе 8 «Практическая организация микросервисов с помощью Kubernetes», определение настроек конфигурации с помощью переменных среды позволяет легко задавать их значения в кодовых файлах для выбранного оркестратора. Во время разработки значения переменных среды можно указать в файле Properties -> launchSettings.json проекта верхнего уровня архитектуры Onion, который в нашем случае является проектом RoutesPlanning. Следующий фрагмент кода показывает, где разместить значения переменных среды:

"Container (Dockerfile)": {
 "commandName": "Docker",
 "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
 "environmentVariables": {
 "ASPNETCORE_HTTP_PORTS": "8080"
 //place here your application specific environment variables
 },

В нашем случае нам нужно следующее:

  1. Строка подключения к базе данных.
  2. Строка подключения к RabbitMQ.
  3. Максимальное расстояние для предложения совпадения между запросом и маршрутом, а также максимальное количество лучших совпадений для извлечения из базы данных.
  4. Префикс ID подписки для всех наших реплик микрослужб. Эта строка используется в качестве префикса для всех имен очередей подписок в наших репликах микрослужб. На этом этапе вам не нужно находить все необходимые настройки, только те, которые играют фундаментальную роль в вашем микросервисе. Дополнительные настройки можно легко добавить позже. Поэтому давайте определим все настройки в файле launchSettings.json следующим образом:
"environmentVariables": {
 "ASPNETCORE_HTTP_PORTS": "8080",
 //place here your environment variables
 "ConnectionStrings__DefaultConnection": "",
 "ConnectionStrings__RabbitMQConnection":

"host=localhost:5672;username=guest;password=guest;publisherConfirms=true;
timeout=10",
 "Messages__SubscriptionIdPrefix": "routesPlanning",
 "Topology__MaxDistanceKm": "50",
 "Topology__MaxMatches": "5"
},

Мы оставили строку подключения к базе данных пустой. Мы заполним ее, как только определим базу данных разработки SQL Server. Строка подключения RabbitMQ содержит URL-адрес сервера и учетные данные по умолчанию. Обратите внимание, что учетные данные по умолчанию принимаются только при доступе к RabbitMQ с localhost, поэтому рекомендуется изменить их после установки сервера. publisherConfirms=true сообщает RabbitMQ, что он должен подтвердить, что сообщение было безопасно получено, а timeout=10 указывает время ожидания подключения в секундах.

Основной сервис микросервиса

Все современные приложения .NET, основанные на хосте, позволяют определять так называемые хостируемые сервисы, которые являются сервисами, аналогичными сервисам Windows, работающим в течение всего времени существования приложения. Их можно определить, реализовав интерфейс IHostedService и добавив их в раздел определения сервисов приложения с помощью следующего кода: builder.Services.AddHostedService(); На практике хостируемые службы определяются путем наследования от BackgroundService, который содержит частичную реализацию службы и предоставляет единственный метод ExecuteAsync, который мы должны переопределить. Нашему микросервису требуется три хостируемые службы. Основная служба прослушивает все входящие сообщения, поступающие от брокера сообщений, и обрабатывает их. Другая хостируемая служба извлекает сообщения из внутренней очереди вывода и отправляет их брокеру сообщений. Наконец, третья хостируемая служба выполняет задачи по обслуживанию, такие как удаление просроченных запросов и маршрутов. В этом подразделе описывается основная хостируемая служба. Задача этой хостируемой службы довольно проста она прослушивает все четыре входных сообщения, которые мы определили, и после получения сообщения создает команду, специфичную для этого сообщения, и вызывает обработчик команд, связанный с этой командой. Команды и обработчики команд являются строительными блоками архитектуры Onion, которые были рассмотрены в подразделе «Команды» главы 3 «Настройка и теория: Docker и архитектура Onion». Создадим папку HostedServices в проекте RoutesPlanning. Затем добавим в нее класс с именем MainService, который наследуется от BackgroundService:

public class MainService() : BackgroundService
{
 protected override Task ExecuteAsync(CancellationToken stoppingToken)
 {
 throw new NotImplementedException();
 }
}

За именем класса следует пара скобок, поскольку это основной конструктор, в который мы будем добавлять параметры. Фактически, все параметры конструктора размещенного сервиса автоматически берутся из контейнера механизма зависимостей, поэтому мы можем поместить туда все сервисы, необходимые для его работы: параметр IConfiguration и интерфейс IServiceProvider, который мы будем использовать для получения сервисов в рамках области действия. Фактически, обработчики команд являются услугами в области действия, поэтому нам нужно создать область действия запроса, прежде чем запрашивать их для контейнера введения зависимостей. Подводя итог, наш основной конструктор выглядит следующим образом: public class MainService(IConfiguration configuration, IServiceProvider services) : BackgroundService Прежде чем продолжить, давайте добавим эту размещенную службу в контейнер введения зависимостей, чтобы она была немедленно выполнена при запуске программы. Нам просто нужно добавить следующую инструкцию в Program.cs: builder.Services.AddHostedService(); В случае рабочего микросервиса существует однозначное сопоставление между сообщениями и командами, и все входные данные, необходимые для команды, содержатся в сообщении, поэтому достаточно уникальной общей команды с именем MessageCommand. Давайте определим ее в папке Commands проекта RoutesPlanningApplicationServices: public class MessageCommand(T message): ICommand { public T Message => message; } Теперь давайте определим метод, который по получении сообщения типа T создает область действия, запрашивает соответствующий обработчик команд и выполняет его:

protected async Task ProcessMessage<T>(T message)
{
 using (var scope = services.CreateScope())
 {
 var handler=scope.ServiceProvider.GetRequiredService<ICommandHandler<
 MessageCommand<T>>>();
 await handler.HandleAsync(new MessageCommand<T>(message));
 }
}

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

private readonly Lock _countErrorsLock = new();
private static int _errorCount = 0;
public static int ErrorsCount => _errorCount;
private void DeclareSuccessFailure(bool isFailure=false)
{
 using (_countErrorsLock.EnterScope())
 {
 if (isFailure) _errorCount++;
 else _errorCount = 0;
 }
}

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

protected async Task SafeProcessMessage<T>(T message)
{
 try
 {
 await ProcessMessage(message);
 DeclareSuccessFailure();
 }
 catch
 {
 DeclareSuccessFailure(true);
 throw;
 }
}

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

string SubscriptionId<T>()
{
 return string.Format("{0}_{1}",
 configuration["Messages__SubscriptionIdPrefix"],
 typeof(T).Name);
}

Теперь мы готовы определить наш основной метод ExecuteAsync, но прежде чем это сделать, мы должны добавить ссылку на пакет EasyNetQ NuGet. Выберите версию, равную или превышающую 8, даже если это предварительная версия. После установки этого пакета нам необходимо добавить его службы в инъекцию зависимостей в Program.cs, вызвав метод расширения AddEasyNetQ и передав ему строку подключения RabbitMQ:

builder.Services.AddEasyNetQ(
 builder.Configuration?.GetConnectionString(
"RabbitMQConnection")??string.Empty)
 .UseAlwaysNackWithRequeueConsumerErrorStrategy();;

Цепочка вызовов определяет, как обрабатывать ошибки в обработчиках полученных сообщений. Мы решили повторно помещать неисправные сообщения в очередь, чтобы их можно было повторно обработать. Если реплика микрослужбы неисправна и генерирует ошибку для всех сообщений, сообщение в конечном итоге будет обработано исправной репликой, а неисправная реплика в конечном итоге будет обнаружена благодаря количеству последовательных ошибок, которое мы будем отображать в конечной точке работоспособности. Неисправные реплики удаляются и воссоздаются всеми оркестраторами микросервисов. Стратегия повторной постановки в очередь обычно является лучшей стратегией обработки ошибок для корпоративных микросервисов. В любом случае, существуют и другие стратегии. Если стратегия не указана, неисправные сообщения, то есть сообщения, обработчики которых выбрасывают исключения, помещаются в специальную очередь ошибок, где они могут быть обработаны вручную с помощью административных инструментов (см. https://github.com/EasyNetQ/EasyNetQ/ wiki/Re-Submitting-Error-Messages-With-EasyNetQ.Hosepipe). Доступ ко всем средствам связи EasyNetQ осуществляется через интерфейс IBus. Добавим его в главный конструктор нашего хостируемого сервиса:

public class MainService(IConfiguration configuration, IBus bus,
IServiceProvider services): BackgroundService

Интерфейс IBus обрабатывает все коммуникации с помощью трех свойств:

  • PubSub: содержит все методы для отправки и получения сообщений с использованием модели «издатель/подписчик».

  • SendReceive: содержит все методы для отправки и получения сообщений с использованием прямой коммуникации

  • Rpc: содержит все методы для выдачи асинхронных удаленных вызовов процедур и возврата их ответов

Здесь мы опишем PubSub, но SendReceive полностью аналогичен. Единственное отличие заключается в том, что метод Send явно указывает имя очереди назначения, а Publish — нет. Имя обмена Publish RabbitMQ неявно определяется через тип сообщения. Ниже приведены методы публикации:

Task PublishAsync(T message, CancelationToken cancel = default)
Task PublishAsync(T message, string topic,
 CancelationToken cancel = default)
Task PublishAsync(T message, Action<IPublishConfiguration > configuration,
 CancelationToken cancel

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

SubscriptionResult Subscribe<T>(string subscriptionId,
Func<T, Task> messageHandler, CancelationToken cancel = default)
SubscriptionResult Subscribe<T>(string subscriptionId,
Func<T, CancelationToken , Task> messageHandler,
Action<IsubscriptionConfiguration> configuration,
 CancelationToken can

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

  • conf => conf.WithTopic(«mytopic»).WithTopic(«anothertopic»): потребитель будет получать только сообщения, помеченные одной из выбранных тем. conf => conf.WithPrefetchCount(N): N — максимальное количество сообщений, извлеченных из очереди потребителем и ожидающих обработки. По умолчанию N равно 20.
  • Conf => conf.WithDurable(durable): если durable равно true, все сообщения в очереди потребителя записываются на диск RabbitMQ. По умолчанию true. Если сообщения должны обрабатываться в том же порядке, в котором они были вставлены в очередь, количество предварительной выборки должно быть установлено на 1, и мы также должны применить одну из стратегий, описанных в подразделе «Обеспечение правильного порядка обработки сообщений». Если мы используем Subscribe, все предварительно загруженные сообщения помещаются во внутреннюю очередь в памяти и обрабатываются в единственном потоке. Однако существует также полностью аналогичный SubscribeAsync, который создает несколько параллельных потоков. Кроме того, SubscribeAsync, как обычно, возвращает Task. Мы будем использовать SubscribeAsync, чтобы лучше использовать ядра процессора и параллелизм между операциями диска/ базы данных и операциями процессора, но простой факт использования нескольких реплик микрослужбы уже использует параллелизм. Преимущество использования нескольких потоков заключается в том, что создание потока обходится дешевле, чем создание другой реплики, поэтому каждая реплика должна использовать несколько потоков для оптимизации производительности.

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

Теперь мы наконец готовы написать основной метод ExecuteAsync. После наших методов настройки и подготовки это стало простым делом:

protected override async Task ExecuteAsync(CancellationToken
stoppingToken)
{
 var routeOfferSubscription = await bus.PubSub.
 SubscribeAsync<RouteOfferMessage>(
 SubscriptionId<RouteOfferMessage>(),SafeProcessMessage,
 stoppingToken);
 var routeClosedAbortedSubscription = await bus.PubSub.SubscribeAsync<
 RouteClosedAbortedMessage>(
 SubscriptionId<RouteClosedAbortedMessage>(), SafeProcessMessage,
 stoppingToken)
var routeExtendedSubscription =
 await bus.PubSub.SubscribeAsync<RouteExtendedMessage>(
 SubscriptionId<RouteExtendedMessage>(), SafeProcessMessage,
 stoppingToken);
 var routeRequestSubscription = await bus.PubSub.
 SubscribeAsync<RouteRequestMessage>(
 SubscriptionId<RouteRequestMessage>(), SafeProcessMessage,
 stoppingToken);

 stoppingToken.WaitHandle.WaitOne();
 routeRequestSubscription.Dispose();
 routeExtendedSubscription.Dispose();
 routeClosedAbortedSubscription.Dispose();
 routeOfferSubscription.Dispose();
}

Мы просто подписываемся на все сообщения с помощью нашего уникального универсального обработчика сообщений, а затем ждем завершения работы реплики на дескрипторе ожидания stoppingToken.WaitHandle. Как только мы получаем уведомление о завершении работы реплики через WaitOne(), дескриптор ожидания разблокируется, и мы отписываемся от всех сообщений, вызывая методы Dispose всех SubscriptionResult. Прежде чем перейти к реализации двух оставшихся размещенных служб, для полноты картины мы также опишем средства RPC EasyNetQ.

Средства RPC EasyNetQ

Запрос RPC можно отправить следующими способами: Task bus.Rpc.RequestAsync<TRequest, TResponse>( TRequest request, CancelationToken cancel = default) Task bus.Rpc.RequestAsync<TRequest, TResponse>( TRequest request, Action configuration, CancelationToken cancel = default) После отправки запроса возвращаемая задача в конечном итоге предоставит ответ. Мы можем дождаться его с помощью await или указать обратный вызов, вызвав Task.ContinueWith.

Получатель может прослушивать запросы и предоставлять ответы с помощью следующего кода: Task bus.Rpc.RequestAsync<TRequest, TResponse>( Func<TRequest, Task< TResponse >> handler, CancelationToken cancel = default); Task bus.Rpc.RequestAsync<TRequest, TResponse>( Func<TRequest, Task< TResponse >> handler, Action configuration, CancelationToken cancel = default); Получатель может прекратить обработку запросов, удалив IDisposable, возвращаемый предыдущими методами. Теперь перейдем к остальным размещенным службам.

Другие необходимые хостинговые услуги

Начнем с хостинговой услуги по ведению домашнего хозяйства. Назовем ее HouseKeepingService и поместим ее в папку HostedServices вместе с MainService:

public class HouseKeepingService(IConfiguration configuration, IBus bus,
 IServiceProvider services): BackgroundService
{
 protected override Task ExecuteAsync(CancellationToken stoppingToken)
 {
 throw new NotImplementedException();
 }
}

Прежде чем продолжить, давайте добавим новую размещенную службу в контейнер введения зависимостей, чтобы она немедленно выполнялась при запуске программы. Для этого нам нужно просто добавить следующую инструкцию в файл Program.cs: builder.Services.AddHostedService(); Нам нужна команда HouseKeepingCommand, в конструкторе которой указывается количество дней, которое необходимо подождать после истечения срока действия маршрута или запроса, прежде чем удалять его. Как обычно, давайте определим ее в папке Commands в Ro utesPlanningApplicationServices: public record HouseKeepingCommand(int DeleteDelay): ICommand;

Нам также необходимо определить переменные среды Timing__HousekeepingIntervalHours и Timing__ HousekeepingDelayDays в файле launchSettings.json: «Topology__MaxDistanceKm»: «50», //новые переменные среды «Timing__HousekeepingIntervalHours»: «4», „Timing__HousekeepingDelayDays“: «10» Метод ExecuteAsync должен выполнять цикл до тех пор, пока приложение не подаст сигнал о завершении. Внутри этого цикла он выполняет обработчик, а затем переходит в режим ожидания на время, указанное в Timing__ HousekeepingIntervalHours, или до тех пор, пока реплика не завершит работу:

protected override async Task ExecuteAsync(CancellationToken
stoppingToken)
{
 //update interval in milliseconds
 int updateInterval = configuration.GetValue<int>(
 "Timing:HousekeepingIntervalHours")*3600000;
 int deleteDelayDays = configuration.GetValue<int>(
 "Timing:HousekeepingDelayDays");
 while (!stoppingToken.IsCancellationRequested)
 {
 try
 {
 using (var scope = services.CreateScope())
 {
 var handler = scope.ServiceProvider
 .GetRequiredService<
 ICommandHandler<HouseKeepingCommand>>();
 await handler.HandleAsync(new HouseKeepingCommand(
 deleteDelayDays));
 }
 }
 catch {
 // actual production application should log the error
 }
 await Task.Delay(updateInterval, stoppingToken);
 }
}

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

public class OutputSendingService(IConfiguration configuration, IBus bus,
 IServiceProvider services) : BackgroundService
{
 protected override Task ExecuteAsync(CancellationToken stoppingToken)
 {
 throw new NotImplementedException();
 }
}

Как обычно, добавим новый хостируемый сервис в контейнер инъекции зависимостей: builder.Services.AddHostedService(); На этот раз нам нужна команда, которая принимает Func<RouteExtensionProposalsMessage,Task> в качестве входных данных. Это входное действие оборачивает код для отправки RouteExtensionProposalsMessage в RabbitMQ, поскольку команды могут содержать код, который зависит от конкретного драйвера, который в нашем случае является клиентом RabbitMQ. Ему также нужен параметр batchCount, который указывает, сколько выходных сообщений одновременно извлекается из выходной очереди, и параметр requeueDelay, который указывает общее время ожидания, по истечении которого сообщение повторно помещается в очередь, если оно не было успешно получено брокер сообщениями. Мы можем определить общую команду, которая принимает только Func<T,Task>, чтобы мы могли повторно использовать ее с другими выходными сообщениями; назовем ее OutputSendingCommand:

public class OutputSendingCommand<T>(Func<T, Task> sender,
int batchCount, TimeSpan requeueDelay): ICommand
{
 public Func<T, Task> Sender => sender;
 public int BatchCount => batchCount;
 public TimeSpan RequeueDelay => requeueDelay;
 public bool OutPutEmpty { get; set; } = false;
}

Команда содержит флаг, с помощью которого ее обработчик будет сигнализировать, была ли найдена пустая очередь вывода. Мы будем использовать этот флаг, чтобы перевести поток обслуживаемой службы в режим ожидания на определенный интервал, чтобы избежать растраты ресурсов. Опять же, нам нужна переменная среды Timing__OutputEmptyDelayMS, чтобы настроить время ожидания, когда очередь вывода пуста. Добавим ее в launchSettings.json: «Timing__OutputEmptyDelayMS»: «500» Нам также нужны значения batchCount и requeueDelay, которые нужно передать команде: «Timing__OutputBatchCount»: «10», „Timing__OutputRequeueDelayMin“: «5» Предположим, у нас есть SafeInvokeCommand, который нам нужно реализовать и который также возвращает, пуста ли очередь вывода: protected Task SafeInvokeCommand() { throw new NotImplementedException(); } Затем метод ExetuteAsync можно реализовать следующим образом:

readonly int updateBatchCount =
 configuration.GetValue<int>("Timing:OutputBatchCount");
readonly TimeSpan requeueDelay = TimeSpan.FromMinutes(
 configuration.GetValue<int>("Timing:OutputRequeueDelayMin"));
protected override async Task ExecuteAsync(CancellationToken
stoppingToken)
{
 //update interval in milliseconds
 int updateInterval =
 configuration.GetValue<int>("Timing:HousekeepingIntervalHours") ;
 bool queueEmpty = false;
 while (!stoppingToken.IsCancellationRequested)
 {
 while (!queueEmpty && !stoppingToken.IsCancellationRequested)
 {
 queueEmpty=await SafeInvokeCommand();
 }
await Task.Delay(updateInterval, stoppingToken);
 queueEmpty = false;
 }
}

Наиболее внешний цикл, который завершается только тогда, когда реплика собирается быть прекращена, и внутренний цикл, который считывает внутреннюю очередь вывода и отправляет сообщения брокеру сообщений, пока очередь вывода не опустеет. Когда очередь вывода опустеет, служба переходит в режим ожидания новых сообщений, вставляемых во внутреннюю очередь вывода. Перед реализацией SafeInvokeCommand мы должны запрограммировать оболочку Func<T,Task>, чтобы передать ее команде:

protected Task SendMessage(RouteExtensionProposalsMessage message)
{
 return bus.PubSub.PublishAsync<
 RouteExtensionProposalsMessage>(message);
}

Теперь реализация аналогична вызову команды MainService:

protected async Task<bool> InvokeCommand()
{
 using (var scope = services.CreateScope())
 {
 var handler = scope.ServiceProvider.GetRequiredService<
 ICommandHandler<OutputSendingCommand<
 RouteExtensionProposalsMessage>>>();
 var command = new OutputSendingCommand<
 RouteExtensionProposalsMessage>(
 SendMessage,updateBatchCount, requeueDelay);
 await handler.HandleAsync(command);
 return command.OutPutEmpty;
 }
}
protected async Task<bool> SafeInvokeCommand()
{
 try
 {
 return await InvokeCommand();
}
 catch
 {
 return true;
 };
}

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

Обеспечение отказоустойчивого выполнения задач с помощью Polly

Отправка сообщений всегда должна быть защищена как минимум экспоненциальными повторными попытками и стратегиями прерывания цепи, которые мы проанализировали в подразделе «Устойчивое выполнение задач» главы 2 «Разгадка мистерии микросервисных приложений». В этом разделе мы сначала опишем библиотеку Polly, которая стала своего рода стандартом для обработки устойчивого выполнения задач, а затем применим ее к методу SendMessage OutputSendingService.

Библиотека Polly

Устойчивую коммуникацию и, в целом, устойчивое выполнение задач можно легко реализовать с помощью библиотеки .NET под названием Polly, проект которой является членом .NET Foundation. Polly доступна через пакет Polly NuGet. В Polly вы определяете политики, а затем выполняете задачи в контексте этих политик, как показано ниже:

var myPolicy = Policy
 .Handle<HttpRequestException>()
 .Or<OperationCanceledException>()
 .RetryAsync(3);
....
....
await myPolicy.ExecuteAsync(()=>{
//your code here
});

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

var retryPolicy= Policy
...
//Exceptions to handle here
.WaitAndRetryAsync(6,retryAttempt => TimeSpan.FromSeconds(Math.Pow(2,
retryAttempt)));

Первый аргумент WaitAndRetryAsync указывает, что в случае сбоя выполняется максимум шесть попыток. Функция lambda, передаваемая в качестве второго аргумента, определяет, сколько времени следует подождать перед следующей попыткой. В данном конкретном примере это время растет экспоненциально с числом попыток в степени 2 (две секунды для первой попытки, четыре секунды для второй попытки и так далее). Ниже приведена простая политика автоматического отключения:

var breakerPolicy =Policy
.Handle<SomeExceptionType>()
.CircuitBreakerAsync (6, TimeSpan.FromMinutes(1));

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

Policy
.BulkheadAsync(10, 15)

В методе Execute допускается максимум 10 параллельных выполнений. Дополнительные задачи вставляются в очередь выполнения. Она имеет ограничение в 15 задач. Если предел очереди превышен, генерируется исключение . Для правильной работы политики Bulkhead Isolation и, в целом, для правильной работы любой стратегии выполнение задач должно запускаться через один и тот же экземпляр политики; в противном случае Polly не сможет подсчитать, сколько выполнений конкретной задачи находится в активном состоянии. Политики можно комбинировать с методом Wrap:

var combinedPolicy = Policy
.WrapAsync(retryPolicy, breakerPolicy);

Polly предлагает еще несколько опций, таких как общие методы для задач, которые возвращают определенный тип, политики таймаута, кэширование результатов задач, возможность определять пользовательские политики и т. д. Также можно настроить Polly как часть определения HttpClient в разделе введения зависимостей любого приложения ASP. NET Core и .NET. Таким образом, можно довольно быстро определить отказоустойчивые HTTP-клиенты. Наконец, в версии 8 также был введен новый API, основанный на создании конвейеров стратегий. Официальная документация Polly находится в репозитории GitHub по адресу: https://github.com/ App-vNext/Polly. В следующем подразделе мы установим и будем использовать Polly для отказоустойчивой передачи выходных сообщений микрослужб в брокер сообщений.

Добавление Polly в наш проект

Использование Polly в нашем проекте очень просто. Прежде всего, необходимо добавить ссылку на последнюю версию пакета Polly NuGet в проект RoutesPlanning. Затем необходимо изменить метод SendMessage класса OutputSendingService следующим образом:

protected Task SendMessage(RouteExtensionProposalsMessage message)
{
 var retryPolicy = Policy
 .Handle<Exception>()
 .WaitAndRetryAsync(4,
 retryAttempt => TimeSpan.FromSeconds(Math.Pow(1,
 retryAttempt)));
 var circuitBreakerPolicy = Policy
 .Handle<Exception>()
 .CircuitBreakerAsync(4, circuitBreakDelay);
 var combinedPolicy = Policy
 .WrapAsync(retryPolicy, circuitBreakerPolicy);
 return combinedPolicy.ExecuteAsync(
 async () => await bus.PubSub.PublishAsync<
RouteExtensionProposalsMessage>(message));

}

Сначала мы определяем политику экспоненциального повторного попытки, затем политику автоматического отключения, а затем объединяем их и выполняем отправку сообщения внутри combinedPolicy.ExecuteAsync.

Все параметры стратегий можно было бы задать с помощью переменных среды, но для упрощения мы оставили постоянными все значения, кроме circuitBreakDelay, то есть времени, в течение которого должен действовать прерыватель цепи. Фактически, это единственный критический параметр, который может потребовать настройки. circuitBreakDelay можно настроить в переменной среды в launchSettings.json следующим образом : «Timing:OutputCircuitBreakMin»: «4» Затем его можно определить как поле OutputSendingService со следующим содержанием: readonly TimeSpan circuitBreakDelay = TimeSpan.FromMinutes( configuration.GetValue(«Timing:OutputCircuitBreakMin»));

От абстракции к деталям реализации

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

Уровень домена

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

Однако перед тем, как приступить к определению всех агрегатов, нам необходимо добавить известную библиотеку для обработки как геометрических, так и ГИС-вычислений: NetTopologySuite. Она доступна как для Java, так и для .NET, и все ее типы соответствуют стандарту, признанному всеми основными базами данных.

Версия .NET доступна через пакет NetTopologySuite NuGet. Поэтому давайте добавим этот пакет в проект RoutesPlanningDomainLayer. Значение координат объектов ГИС определяется в документах, классифицированных с помощью целых чисел, называемых пространственными идентификаторами (SRID). Каждый документ определяет значение координат x и y, способ вычисления расстояния между двумя точками и часть поверхности Земли, к которой он относится. Каждый объект ГИС должен указывать SRID, используемый его координатами, и только объекты с одинаковым SRID могут использоваться в одном вычислении. Мы будем использовать SRID 4326, который применяется ко всей поверхности Земли. X — это долгота в градусах, а Y — широта в градусах; расстояние вычисляется в метрах путем аппроксимации поверхности Земли эллипсоидом. Более точные результаты можно получить с помощью SRID, которые применяются к меньшим участкам поверхности Земли, но SRID 4326 поддерживается всеми основными базами данных.

Давайте определим наш общий SRID по умолчанию в статическом классе, определенном в корневом каталоге проекта RoutesPlanningDomainLayer:

namespace RoutesPlanningDomainLayer
{
 public static class GeometryConstants
 {
 public static int DefaultSRID => 4326;
 }
}

Как и в случае с сообщениями, нам нужны промежуточные типы. Давайте определим их в папке RoutesPlanningDomainLayer -> Models -> BasicTypes:

  • Статус маршрута: public enum RouteStatus { Open=0, Closed=1, Aborted=2 };
  • Временной интервал: public record TimeInterval { public DateTime Start { get; init; } public DateTime End { get; init; } }
  • Информация о городе: public record TownBasicInfo { public Guid Id { get; init; } public string Name { get; init; } = null!; public Point Location { get; init; } = null!; }
  • Информация о пользователе: public record UserBasicInfo() { public Guid Id { get; init; } public string DisplayName { get; init; } = null!; }

Point — это тип NetTopologySuite, который определяет точку на поверхности Земли. Обратите внимание, что все вышеперечисленные типы являются тем, что мы назвали объектами-значениями в подразделе «Уровень домена» главы 3 «Настройка и теория: Docker и луковичная архитектура». Поэтому, как предлагалось там, мы определили их как типы записей .NET. Теперь мы можем приступить к определению наших агрегатов. Для каждого из них мы сначала определим его интерфейс состояния, затем агрегат и, наконец, связанный интерфейс репозитория. Обычно определение всех этих типов данных является итеративным, то есть мы начинаем с первого черновика, а затем, когда понимаем, что нам нужно еще одно свойство или метод, добавляем его.

Агрегат запросов маршрута

Давайте создадим папку Models -> Request для всех типов, связанных с запросом пользователя. Статус запроса пользователя можно представить следующим образом:

public interface IRouteRequestState
{
 Guid Id { get; }
 TownBasicInfo Source { get; }
 TownBasicInfo Destination { get; }
 DateTime WhenStart { get; }
 DateTime WhenEnd { get; }
 UserBasicInfo User { get; }
 Guid? RouteId { get; set; }
 public long TimeStamp { get; set; }
}

Все свойства, которые не могут быть изменены агрегатами, были определены как свойства только для чтения. Id однозначно идентифицирует каждый запрос в приложении в целом. Source и Destination — это, соответственно, желаемые города отправления и прибытия, а WhenStart и WhenEnd определяют приемлемые дни для поездки. Затем у нас есть информация о пользователе, который отправил запрос, и текущая метка времени, связанная с запросом. Наконец, RouteId — это уникальный идентификатор маршрута, к которому был добавлен запрос, если таковой имеется. Если запрос все еще открыт, это свойство имеет значение null.

Агрегат можно определить следующим образом:

public class RouteRequestAggregate(IRouteRequestState state):
 Entity<Guid>
{
 public override Guid Id => state.Id;
public TownBasicInfo Source => state.Source;
 public TownBasicInfo Destination => state.Destination;
 TimeInterval _When = null!;
 public TimeInterval When => _When ??
 (_When=new TimeInterval {Start = state.WhenStart, End = state.
 WhenEnd });
 public UserBasicInfo User => state.User;
 public bool Open => state.RouteId == null;
 public long TimeStamp => state.TimeStamp;
 public void DetachFromRoute() => state.RouteId = null;
 public void AttachToRoute(Guid routeId) => state.RouteId = routeId;
}

Стоит отметить, что после создания запроса можно изменить только его state.RouteId. Это связано с тем, что после отправки каждый запрос не может быть изменен, а только сопоставлен с существующими маршрутами. Интерфейс репозитория выглядит следующим образом:

public interface IRouteRequestRepository : IRepository
{
 RouteRequestAggregate New(
 Guid id,
 TownBasicInfo source,
 TownBasicInfo destination,
 TimeInterval when,
 UserBasicInfo user
 );
 Task<RouteRequestAggregate?> Get(Guid id);
 Task<IList<RouteRequestAggregate>> Get(Guid[] ids);
 Task<IList<RouteRequestAggregate>> GetInRoute(Guid routeId);
 Task<IList<RouteRequestAggregate>> GetMatch(IEnumerable<Coordinate>
 geometry,
 DateTime when, double distance, int maxResults);
 Task DeleteBefore(DateTime milestone);
}

Метод New создает новый экземпляр агрегата и его состояние, привязанное к базе данных. Затем у нас есть методы для получения одного или нескольких существующих агрегатов по их идентификатору, а также всех агрегатов, которые обслуживаются одним и тем же маршрутом. Метод GetMatch возвращает все агрегаты, которые наилучшим образом соответствуют маршруту. Маршрут указывается координатами городов, через которые он проходит (геометрия), и датой (When). Coordinate — это тип NetTopologySuite, который содержит только координаты X и Y местоположения без его SRID (SRID по умолчанию, определенный ранее, является неявным). distance указывает максимальное расстояние между запросом и маршрутом, при котором может произойти совпадение. Все результаты упорядочены в соответствии с их расстоянием от маршрута, и возвращается максимум maxResults запросов. Метод DeleteBefore используется для выполнения некоторых административных задач, удаляя старые, просроченные запросы.

Агрегат предложений маршрутов

Создадим папку Models -> Route для всех типов, связанных с предложением маршрута пользователя. Статус запроса пользователя можно представить следующим образом:

public interface IRouteOfferState
{
 Guid Id { get; }
 LineString Path { get; set; }
 DateTime When { get; }
 UserBasicInfo User { get; }
 RouteStatus Status { get; set; }
 public long TimeStamp { get; set; }
}

LineString — это тип NetTopologySuite, который представляет собой путь, состоящий из последовательных сегментов на поверхности Земли. По сути, это последовательность координат с прикрепленным SRID. Статус — это статус маршрута (открыт для других участников, закрыт или прерван). Агрегат можно определить следующим образом:

public class RouteOfferAggregate
 (IRouteOfferState state): Entity<Guid>
{
 public override Guid Id => state.Id;
 IReadOnlyList<Coordinate>? _Path=null;
 public IReadOnlyList<Coordinate> Path => _Path != null ? _Path : (
 _Path = state.Path.Coordinates.ToIm
public DateTime When => state.When;
 public UserBasicInfo User => state.User;
 public RouteStatus Status => state.Status;
 public long TimeStamp => state.TimeStamp;
 …
 …
}

Здесь вместо методов, которые мы вскоре проанализируем, добавлены точки. Путь LineString, содержащийся в агрегированном состоянии, представлен в виде неизменяемого списка его координат, так что его нельзя изменять напрямую, а его SRID нельзя изменить. Он содержит метод Extend, который вызывается при получении сообщения, требующего расширения маршрута. Данные, содержащиеся в сообщении, передаются в качестве его параметров:

public void Extend(long timestamp,
IEnumerable<Guid> addedRequests,
Coordinate[] newRoute, bool closed)
{
 if (timestamp > TimeStamp)
 {
 state.Path = new LineString(newRoute)
 { SRID = GeometryConstants.DefaultSRID };
 _Path = null;
 state.TimeStamp = timestamp;
 }
 if(state.Status != RouteStatus.Aborted)
 AddDomainEvent(new AttachedRequestEvent {
 AddedRequests = addedRequests,
 RouteOffer = Id
 });
 Close();
}

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

Задача присоединения запросов к агрегату оставлена обработчику событий для большей модульности. Таким образом, метод Extend добавляет событие AttachedRequestEvent в список событий агрегата. Определение события должно быть помещено в папку Events и определяется следующим образом:

public class AttachedRequestEvent : IEventNotification
{
 public IEnumerable<Guid> AddedRequests { get; set; } = new
List<Guid>();
 public Guid RouteOffer { get; set; }
}

Наконец, если сообщение расширения объявляет маршрут закрытым, метод Extend закрывает его, вызывая метод Close(), который определяется следующим образом:

public void Close()
{
 state.Status = RouteStatus.Closed;
}

Существует также метод Abort, который объявляет маршрут прерванным:

public void Abort()
{
 state.Status = RouteStatus.Aborted;
 AddDomainEvent(new ReleasedRequestsEvent
 {
 AbortedRoute = Id
 });
}

Он устанавливает совокупный статус на «прерванный», а затем передает задачу освобождения всех связанных запросов обработчику событий для большей модульности с помощью события ReleasedRequestsEvent:

public class ReleasedRequestsEvent:IEventNotification
{
 public Guid AbortedRoute { get; set; }
}

Перейдем к интерфейсу репозитория:

public interface IRouteOfferRepository : IRepository
{
 RouteOfferAggregate New(Guid id, Coordinate[] path, UserBasicInfo
 user, DateTime When);
 Task<RouteOfferAggregate?> Get(Guid id);
 Task<IList<RouteOfferAggregate>> GetMatch(
 Point source, Point destination, TimeInterval when,
 double distance, int maxResults);
 Task DeleteBefore(DateTime milestone);
}

Новый метод создает новый агрегат, затем у нас есть метод для получения агрегата по его уникальному идентификатору. Методы GetMatch и DeleteBefore полностью аналогичны методам запросов, но в данном случае GetMatch возвращает все предложения маршрутов, соответствующие данному запросу.

Агрегат элемента выходной очереди

Этот агрегат представляет собой общий элемент выходной очереди. Файлы будут помещены в папку Models -> OutputQueue. Состояние агрегата можно определить следующим образом:

public interface IQueueItemState
{
 Guid Id { get; }
 int MessageCode { get; }
 public string MessageContent { get; }
}

Каждый элемент очереди имеет уникальный идентификатор и код сообщения, который указывает, какой тип сообщения хранится в элементе. Содержимое сообщения представляет собой JSON-представление выходных сообщений. Агрегат является тривиальным:

public class QueueItem(IQueueItemState state): Entity<Guid>
{
 public override Guid Id => state.Id;
 public int MessageCode => state.MessageCode;
 public T? GetMessage<T>()
 {
 if (string.IsNullOrWhiteSpace(state.MessageContent))
 return default
return JsonSerializer.Deserialize<T>(state.MessageContent);
 }
}

Метод GetMessage десериализует сообщение, содержащееся в элементе. Наконец, интерфейс репозитория выглядит следующим образом:

public interface IOutputQueueRepository: IRepository
{
 Task<IList<QueueItem>> Take(int N, TimeSpan requeueAfter);
 void Confirm(Guid[] ids);
 QueueItem New<T>(T item, int messageCode);
}

Каждый элемент очереди имеет привязанное к нему время, и элемент может быть извлечен из очереди только после истечения этого времени. Кроме того, элементы очереди извлекаются в порядке возрастания времени. Метод Take извлекает первые N элементов из очереди, а затем немедленно возвращает их в очередь, заменив их время временем извлечения плюс requeueAfter TimeSpan. Таким образом, если сообщения успешно отправлены до requeueAfter, они удаляются из очереди; в противном случае они снова становятся доступными для извлечения из очереди, и их передача повторяется. Метод Confirm удаляет все успешно отправленные сообщения, а метод New добавляет новый элемент в очередь вывода. Теперь мы можем перейти к реализации всех агрегированных состояний с помощью сущностей Entity Framework и к реализации всех репозиториев.

Драйвер базы данных

Прежде чем приступить к реализации драйвера RoutesPlanningDBDriver, необходимо добавить ссылку на пакет NuGet Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite, который добавляет поддержку всех типов NetTopolgySuite в Entity Framework Core. Затем необходимо объявить использование NetTopolgySuite в файле Extensions -> DBExtensions.cs:

options.UseSqlServer(connectionString,
 b => {
 b.MigrationsAssembly("DBDriver");
 // added code
 b.UseNetTopologySuite();
 }));

Теперь мы можем определить все необходимые нам сущности в папке Entities:

Route offer: internal class RouteOffer: IRouteOfferState { public Guid Id { get; set; } public LineString Path { get; set; } = null!; public DateTime When { get; set; } public UserBasicInfo User { get; set; } = null!; public RouteStatus Status { get; set; } public ICollection Requests { get; set; } = null!; public long TimeStamp { get; set; } } • Route request: internal class RouteRequest: IRouteRequestState { public Guid Id { get; set; } public TownBasicInfo Source { get; set; }=null!; public TownBasicInfo Destination { get; set; } = null!; public DateTime WhenStart { get; set; } public DateTime WhenEnd { get; set; } public long TimeStamp { get; set; } public UserBasicInfo User { get; set; } = null!; public Guid? RouteId { get; set; } public RouteOffer? Route { get; set; }

} • Queue item: internal class OutputQueueItem: IQueueItemState { public Guid Id { get; set; } public int MessageCode { get; set; } public string MessageContent { get; set; } = null!; public DateTime ReadyTime { get; set; } }

Затем в файле MainDBContext.cs необходимо добавить соответствующие коллекции:

public DbSet<RouteRequest> RouteRequests { get; set; } = null!;
public DbSet<RouteOffer> RouteOffers { get; set; } = null!;
public DbSet<OutputQueueItem> OutputQueueItems { get; set; } = null!;

Наконец, в методе OnModelCreating того же файла мы должны объявить связь между RouteOffer и RouteRequest:

builder.Entity<RouteOffer>().HasMany(m => m.Requests)
 .WithOne(m => m.Route)
 .HasForeignKey(m => m.RouteId)
 .OnDelete(DeleteBehavior

Мы также должны объявить некоторые индексы и использование объектов-значений (с их индексами) с OwnsOne:

builder.Entity<RouteRequest>().OwnsOne(m => m.Source);
builder.Entity<RouteRequest>().OwnsOne(m => m.Destination);
builder.Entity<RouteRequest>().OwnsOne(m => m.User);
builder.Entity<RouteRequest>().HasIndex(m => m.WhenStart);
builder.Entity<RouteRequest>().HasIndex(m => m.WhenEnd);
builder.Entity<RouteOffer>().OwnsOne(m => m.User);
builder.Entity<RouteOffer>().HasIndex(m => m.When);
builder.Entity<RouteOffer>().HasIndex(m => m.Status);
builder.Entity<OutputQueueItem>().HasIndex(m => m.ReadyTime);

Теперь перейдем к реализации всех репозиториев.

Реализация IOutputQueueRepository

Все реализации репозиториев следуют одному и тому же базовому шаблону:

internal class OutputQueueRepository(IUnitOfWork uow) :
IOutputQueueRepository
{
 readonly MainDbContext ctx = (uow as MainDbContext)!;
 public void Confirm(Guid[] ids)public QueueItem New<T>(T item, int messageCode)public async Task<IList<QueueItem>> Take(int N, TimeSpan requeueAfter)}
}

Они берут IUnitOfWork из своего основного конструктора и преобразуют его в контекст базы данных. Реализация метода New выглядит следующим образом:

public QueueItem New<T>(T item, int messageCode)
{
 var entity = new OutputQueueItem()
 {
 Id = Guid.NewGuid(),
 MessageCode = messageCode,
 MessageContent = JsonSerializer.Serialize(item)
 };
 var res = new QueueItem(entity);
 ctx.OutputQueueItems.Add(entity);
 return res;
}

Реализация Confirm также проста:

public void Confirm(Guid[] ids)
{
 var entities = ctx.ChangeTracker.Entries<OutputQueueItem>()
 .Where(m => ids.Contains(m.Entity.Id)).Select(m => m.Entity);
 ctx.OutputQueueItems.RemoveRange(entities);
}

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

public async Task<IList<QueueItem>> Take(int N, TimeSpan requeueAfter)
{
 List<OutputQueueItem> entities;
 using (var tx =
 await ctx.Database.BeginTransactionAsync(IsolationLevel.
 Serializable))
 {
var now = DateTime.Now;
 entities = await ctx.OutputQueueItems.Where(m => m.ReadyTime <=
 now)
 .OrderBy(m => m.ReadyTime)
 .Take(N)
 .ToListAsync();
 if (entities.Count > 0)
 {
 foreach (var entity in entities)
 { entity.ReadyTime = now + requeueAfter; }
 await ctx.SaveChangesAsync();
 await tx.CommitAsync();
 }
 return entities.Select(m => new QueueItem(m)).ToList();
 }
}

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

Реализация IRouteRequestRepository

Структура репозитория полностью аналогична структуре предыдущего репозитория:

internal class RouteRequestRepository(IUnitOfWork uow) :
IRouteRequestRepository
{
 readonly MainDbContext ctx = (uow as MainDbContext)!;
 public async Task DeleteBefore(DateTime milestone)public async Task<RouteRequestAggregate?> Get(Guid id)public async Task<IList<RouteRequestAggregate>> GetInRoute(Guid
 routeId)public async Task<IList<RouteRequestAggregate>> GetMatch(
 IEnumerable<Coordinate> geometry, DateTime when,
 double distance, int maxResults)
 …
 public RouteRequestAggregate New(Guid id,
 TownBasicInfo source, TownBasicInfo destination,
 TimeInterval when, UserBasicInfo user)}

Метод DeleteBefore легко реализуется с помощью недавнего расширения ExecuteDeleteAsync Entity Framework Core:

public async Task DeleteBefore(DateTime milestone)
{
 await ctx.RouteRequests.Where(m => m.WhenEnd < milestone).
ExecuteDeleteAsync();
}

В следующих блоках кода мы видим новый метод:

public RouteRequestAggregate New(Guid id, TownBasicInfo source,
TownBasicInfo destination, TimeInterval when, UserBasicInfo user)
{
 var entity = new RouteRequest()
 {
 Id = id,
 Source = source,
 Destination = destination,
 WhenStart = when.Start,
 WhenEnd = when.End,
 User = user
 };
 var res = new RouteRequestAggregate(entity);
 res.AddDomainEvent(new
NewMatchCandidateEvent<RouteRequestAggregate>(res));
 ctx.RouteRequests.Add(entity);
return res;
}

Он создает сущность Entity Framework Core, добавляет ее в ctx.RouteRequests и использует ее в качестве состояния для создания RouteRequestAggregate. Он также добавляет событие NewMatchCandidateEvent в агрегат. Связанный обработчик событий будет заниматься поиском всех маршрутов, которые соответствуют запросу, и созданием выходного сообщения для каждого из них. NewMatchCandidateEvent определено в папке Events проекта RoutesPlanningDomainLayer следующим образом:

public class NewMatchCandidateEvent<T>(T matchCandidate):
 IEventNotification
{
 public T MatchCandidate => matchCandidate;
}

Все остальные методы содержат довольно стандартный код Entity Framework Core, поэтому мы опишем здесь только метод GetMatch, поскольку он использует специальные расширения запросов Entity Framework. Код всех остальных методов доступен в папке ch07 репозитория GitHub книги (https:// github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp):

public async Task<IList<RouteRequestAggregate>> GetMatch(
 IEnumerable<Coordinate> geometry, DateTime when,
 double distance, int maxResults)
{
 var lineString = new LineString(geometry.ToArray())
 { SRID = GeometryConstants.DefaultSRID };
 var entities = await ctx.RouteRequests.Where(m =>
 m.RouteId == null &&
 when <= m.WhenEnd && when >= m.WhenStart &&
 lineString.Distance(m.Source.Location) < distance &&
 lineString.Distance(m.Destination.Location) < distance)
 .Select(m => new
 {
 Distance = lineString.Distance(m.Source.Location),
 Entity = m
 })
 .OrderBy(m => m.Distance)
Take(maxResults).ToListAsync();
 return entities
 .Select(m => new RouteRequestAggregate(m.Entity))
 .ToList();
}

Сначала мы создаем геометрию LineString из маршрута, а затем запускаем запрос. Условие Where сначала ограничивает поиск запросами, которые еще не привязаны к другим маршрутам. Затем оно проверяет совместимость по времени и, наконец, совместимость по расстоянию с помощью LineString. Метод Distance. Все геометрические объекты имеют метод Distance, поэтому мы можем выполнять геометрические запросы с участием любого типа геометрического объекта. Наконец, мы возвращаем анонимный объект с расстоянием и извлеченной сущностью. Таким образом, мы можем сортировать данные по расстоянию и извлекать лучшие совпадения maxResults.

Реализация IRouteOfferRepository

Снова, структура репозитория такая же, как и у всех предыдущих репозиториев:

internal class RouteOfferRepository(IUnitOfWork uow) :
IRouteOfferRepository
{
 readonly MainDbContext ctx = (uow as MainDbContext)!;
 public async Task DeleteBefore(DateTime milestone)public async Task<RouteOfferAggregate?> Get(Guid id)public async Task<IList<RouteOfferAggregate>> GetMatch(
 Point source, Point destination, TimeInterval when,
 double distance, int maxResults)
 …
 public RouteOfferAggregate New(Guid id, Coordinate[] path,
 UserBasicInfo user, DateTime When)}

Метод DeleteBefore аналогичен методу предыдущего репозитория:

public async Task DeleteBefore(DateTime milestone)
{
 await ctx.RouteOffers.Where(m => m.When < milestone).
ExecuteDeleteAsync();
}

Новый метод также аналогичен методу репозитория запросов, но он генерирует событие NewMatchCandidateEvent< RouteOfferAggregate>, обработчик которого ищет подходящие запросы. Опять же, мы описываем только метод GetMatch, поскольку все остальные методы являются довольно стандартными:

public async Task<IList<RouteOfferAggregate>> GetMatch(
 Point source, Point destination,
 TimeInterval when, double distance, int maxResults)
{
 var entities = await ctx.RouteOffers.Where(m =>
 m.Status == RouteStatus.Open &&
 m.When <= when.End && m.When >= when.Start &&
 source.Distance(m.Path) < distance)
 .Select(m => new
 {
 Distance = source.Distance(m.Path),
 Entity = m
 })
 .OrderBy(m => m.Distance)
 .Take(maxResults).ToListAsync();
 return entities
 .Select(m => new RouteOfferAggregate(m.Entity))
 .ToList();
}

Условие Where сначала ограничивает поиск только всеми открытыми маршрутами. Затем оно проверяет ограничения по времени и расстоянию, как и в том же методе GetMatch предыдущего репозитория. Кроме того, сортировка такая же, как и в предыдущем репозитории. Определив все, мы можем перейти к миграции.

Создание миграций и баз данных

Перед генерацией миграций базы данных мы должны реализовать интерфейс IDesignTimeDbContextFactory внутри драйвера базы данных. Все инструменты миграции ищут эту реализацию, чтобы создать экземпляр MainDbContext, необходимый для получения информации как о конфигурации базы данных, так и о строке подключения к базе данных. Поэтому давайте добавим класс LibraryDesignTimeDbC ontextFactory в корень проекта RoutesPlanningDBDriver:

internal class LibraryDesignTimeDbContextFactory :
 IDesignTimeDbContextFactory<MainDbContext>
{
 private const string connectionString =
 @"Server=<your sql server instance name>;Database=RoutesPlanning;
 User Id=sa;Password=<your password>;Trust Server Certificate=True;
 MultipleActiveResultSets=true ";
 public MainDbContext CreateDbContext(string[] args)
 {
 var builder = new DbContextOptionsBuilder<MainDbContext>();
 builder.UseSqlServer(
 connectionString,
 x => x.UseNetTopologySuite());
 return new MainDbContext(builder.Options);
 }
}

Замените заполнители, которые я оставил в строке, на имя вашего экземпляра SQL Server и пароль. Самый простой способ получить строку подключения — подключиться к базе данных из Visual Studio, а затем скопировать строки подключения из вкладки «Свойства». Не забудьте, что вы не можете использовать базу данных SQL, установленную с Visual Studio, поскольку она не может прослушивать TCP/IP-соединения, поэтому к ней нельзя получить доступ из образов Docker. Теперь мы также можем добавить строку подключения к SQL Server, которую мы оставили пустой в launchSettings.json:

"ConnectionStrings__DefaultConnection":
 "Server=host.docker.internal;Database=RoutesPlanning;User Id=sa;
 Password=<our password>;Trust Server
Certificate=True;MultipleActiveResultSets=true"

Повторно введите свой пароль. host.docker.internal — это сетевое имя вашего компьютера для разработки, на котором установлен Docker или локальный симулятор Kubernetes. Используйте его, если вы выполнили прямую установку на свой компьютер или запустили образ SQL Server Docker на своем компьютере. Замените его соответствующим именем, если вы используете облачный или другой сетевой экземпляр. Теперь сделаем RoutesPlanningDBDriver нашим стартовым проектом Visual Studio и выберем его в консоли диспетчера пакетов Visual Studio:

image Figure 7.5: Selecting the project in Package Manager Console

Мы готовы выполнить нашу первую миграцию в консоли Package Manager Console:

Add-Migration initial

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

Update-Database

Готово! Теперь мы можем перейти к реализации всех обработчиков команд и событий.

Службы приложения: определение всех обработчиков команд и событий

В этом разделе мы определим все необходимые обработчики команд и событий. Перед тем как начать, нам нужно добавить ссылку на пакеты Microsoft.Extensions.Configuration.Abstractions и Microsoft.Extensions.Configuration.Binder в проект RoutesPlanningAppli cationServices. Таким образом, мы позволяем всем обработчикам получать данные конфигурации от механизма впрыска зависимостей через интерфейс IConfiguration. Все конструкторы обработчиков команд требуют некоторых интерфейсов репозитория, IUnitofWork для завершения изменений и обработки транзакций, а также экземпляр EventMediator для запуска всех событий, добавленных к агрегатам.

Мы не будем описывать все обработчики, а только те, которые имеют дидактическую ценность. Полный код можно найти в папке ch07 репозитория GitHub книги (https://github.com/PacktPublishing/ Practical-Serverless-and-Microservices-with-Csharp). Все обработчики команд, которые обрабатывают сообщения, мы поместим в папку CommandHandlers -> Messages . Начнем с обработчика RouterOfferMessage:

internal class RouterOfferMessageHandler(
 IRouteOfferRepository repo,
 IUnitOfWork uow,
 EventMediator mediator
 ) : ICommandHandler<MessageCommand<RouteOfferMessage>>
{
 public async Task HandleAsync(MessageCommand<RouteOfferMessage>
 command)
 {
 var message = command.Message;
 var toCreate = repo.New(message.Id,
 message.Path!.Select(m =>
 new Coordinate(m.Location!.Longitude, m.Location.Latitude)).
 ToArray(),
 new UserBasicInfo { Id = message.User!.Id,
 DisplayName = message.User.DisplayName! },
 message.When!.Value
 );
 if (toCreate.DomainEvents != null && toCreate.DomainEvents.Count >
 0)
 await mediator.TriggerEvents(toCreate.DomainEvents);
 try
 {
 await uow.SaveEntitiesAsync();
 }
 catch (ConstraintViolationException) { }
 }
}

Обработчик извлекает из сообщения все данные, необходимые для создания нового агрегата, а затем передает их методу New репозитория. Затем он проверяет, содержит ли созданный агрегат события, и использует экземпляры EventMediator для запуска всех связанных обработчиков событий. ConstraintViolationException создается реализацией IUnitOdWork в случае нарушения уникальности ключа. В нашем случае это исключение может быть сгенерировано только при получении дубликата RouterOfferMessage. Поэтому мы просто фиксируем его и ничего не делаем, поскольку дубликаты сообщений должны игнорироваться. RouteRequestMessageHandler полностью аналогичен, поэтому мы не будем его описывать. Перейдем к обработчику RouteClosedAbortedMessage:

public async Task HandleAsync(MessageCommand<RouteClosedAbortedMessage>
command)
 {
 var message = command.Message;
 await uow.StartAsync(System.Data.IsolationLevel.Serializable);
 try
 {
 var route = await repo.Get(message.RouteId);
 if (route is not null)
 {
 if(!message.IsAborted)
 {
 if(route.Status != RouteStatus.Open)
 {
 await uow.RollbackAsync();
 return;
 }
 else route.Close();
 }
 else
 {
 if(route.Status == RouteStatus.Aborted)
 {
 await uow.RollbackAsync();
 return;
 }
 else route.Abort();
 }
if (route.DomainEvents != null && route.DomainEvents.Count
 > 0)
 mediator.Equals(route.DomainEvents);
 await uow.SaveEntitiesAsync();
 await uow.CommitAsync();
 }
 else
 {
 await uow.RollbackAsync();
 return;
 }
 }
 catch
 {
 await uow.RollbackAsync();
 throw;
 }

 }
}

Вся операция заключена в сериализуемую транзакцию, чтобы избежать конфликтов с другими репликами микросервиса, которые могут получить более старые или будущие сообщения, касающиеся того же предложения маршрута. Фактически, они могут изменить ту же сущность после ее чтения, но до ее изменения. Сериализуемая транзакция предотвращает такую возможность. Если мы не находим сущность, мы ничего не делаем и просто прерываем транзакцию. Фактически, такая ситуация может произойти только в том случае, если маршрут истек и был удален. Однако, если сущности удаляются по прошествии достаточного времени после истечения их срока действия, это должно быть практически невозможным событием. Если в сообщении указано, что маршрут должен быть закрыт, мы переводим агрегат в закрытое состояние, вызывая Close() только в том случае, если агрегат все еще открыт. Фактически, если он уже закрыт или прерван, это будет старое сообщение или дубликат, который необходимо игнорировать. Аналогично, если в сообщении указано, что маршрут должен быть прерван, оно обрабатывается только в том случае, если агрегат еще не находится в прерванном состоянии. Наконец, в случае ошибок мы прерываем транзакцию и повторно выбрасываем исключение, поэтому сообщение не будет подтверждено и будет обработано повторно позднее, возможно, другой репликой.

Теперь перейдем к обработчику RouteExtendedMessage:

internal class RouteExtendedMessageHandler(
 IRouteOfferRepository repo,
 IUnitOfWork uow,
 EventMediator mediator
 ) : ICommandHandler<MessageCommand<RouteExtendedMessage>>
{
 public async Task HandleAsync(MessageCommand<RouteExtendedMessage>
command)
 {
 var message = command.Message;
 await uow.StartAsync(System.Data.IsolationLevel.Serializable);
 try
 {
 var route = await repo.Get(message.ExtendedRoute!.Id);
 if (route is not null && route.TimeStamp != message.TimeStamp)
 {
 route.Extend(message.TimeStamp,
 message.AddedRequests!.Select(m => m.Id),
 message.ExtendedRoute.Path!
 .Select(m => new Coordinate(m.Location!.Longitude,
 m.Location.Latitude)).ToArray(),message.
 Closed);
 if (route.DomainEvents != null && route.DomainEvents.Count
 > 0)
 mediator.Equals(route.DomainEvents);
 await uow.SaveEntitiesAsync();
 await uow.CommitAsync();
 }
 else
 {
 await uow.RollbackAsync();
 return;
 }
 }
 catch
 {
await uow.RollbackAsync();
 throw;
 }
 }
}

Кроме того, в этом случае, поскольку обработчик команд выполняет как чтение, так и изменение, нам нужна явно заданная транзакция. Опять же, если сущность не найдена, мы ничего не делаем по тем же причинам, что и в случае с предыдущим обработчиком. Мы также ничего не делаем, если временная метка сообщения идентична той, что содержится в сущности, поскольку в этом случае сообщение является дубликатом. В противном случае мы просто вызываем агрегатный метод Extend, а затем запускаем возможные события, сгенерированные методом Extend. Теперь перейдем к обработчикам, не связанным с сообщениями. Они находятся в корневой папке CommandHandlers. Начнем с HouseKeepingCommandHandler, который удаляет старые просроченные запросы и маршруты:

internal class HouseKeepingCommandHandler(
 IRouteRequestRepository requestRepo,
 IRouteOfferRepository offerRepo
 ) : ICommandHandler<HouseKeepingCommand>
{
 public async Task HandleAsync(HouseKeepingCommand command)
 {
 var deleteTrigger = DateTime.Now.AddDays( -command.DeleteDelay );
 await offerRepo.DeleteBefore(deleteTrigger);
 await requestRepo.DeleteBefore(deleteTrigger);
 }
}

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

internal class OutputSendingCommandHandler(
 IOutputQueueRepository repo,
 IUnitOfWork uow
): ICommandHandler<
 OutputSendingCommand<RouteExtensionProposalsMessage>>
{
 public async Task HandleAsync(OutputSendingCommand<
 RouteExtensionProposalsMessage> command)
 {
 var aggregates =await repo.Take
 (command.BatchCount, command.RequeueDelay);
 if(aggregates.Count==0)
 {
 command.OutPutEmpty = true;
 return;
 }
 var allTasks = aggregates.Select(
 m => (m, command.Sender(m.GetMessage<
 RouteExtensionProposalsMessage>()!)))
 .ToDictionary(m => m.Item1!, m => m.Item2 );
 try
 {
 await Task.WhenAll(allTasks.Values.ToArray());
 }
 catch
 {
 }
 repo.Confirm(aggregates
 .Where(m =>!allTasks[m].IsFaulted && !allTasks[m].IsFaulted)
 .Select(m => m.Id).ToArray());
 await uow.SaveEntitiesAsync();
 }

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

Затем он десериализует все сообщения и передает их делегату Sender. Однако вместо того, чтобы ожидать выполнения каждой задачи, возвращаемой этим методом, он собирает их все, помещает в массив и ожидает выполнения всего массива с помощью Task.WhenAll. Таким образом, все сообщения отправляются одновременно, что повышает производительность. В случае исключений он просто ничего не делает, потому что неотправленные сообщения обнаруживаются в инструкции LINQ внутри repo.Confirm, а связанные с ними элементы очереди исключаются из массива всех элементов для подтверждения, поэтому они будут повторно отправлены позже. Мы закончили с обработчиками команд. Перейдем к обработчикам событий.

Кодирование всех обработчиков событий

Обычно обработчики событий не создают транзакций и не пытаются сохранять изменения в базе данных, поскольку они вызываются обработчиками команд, которые выполняют эту задачу за них; поэтому их код, как правило, немного проще. У нас есть четыре обработчика событий, которые все размещены в корневой папке EventHandlers. Начнем с обработчика AttachedRequestEvent:

internal class AttachedRequestEventHandler(
 IRouteRequestRepository repo
 ) : IEventHandler<AttachedRequestEvent>
{
 public async Task HandleAsync(AttachedRequestEvent ev)
 {
 var requests = await repo.Get(ev.AddedRequests.ToArray());
 foreach (var request in requests) request.AttachToRoute(
 ev.RouteOffer);
 }
}

Этот обработчик отвечает за присоединение запросов к маршруту. Его код прост: он просто извлекает все агрегаты по их ключам, а затем присоединяет их к маршруту, указанному в событии. Обработчик ReleasedRequestsEvent отвечает за освобождение всех запросов, присоединенных к прерванному маршруту. Его код тоже прост:

internal class ReleasedRequestsEventHandler(
 IRouteRequestRepository repo
 ) : IEventHandler<ReleasedRequestsEvent>
{
 public async Task HandleAsync(ReleasedRequestsEvent ev)
{
 var requests=await repo.GetInRoute(ev.AbortedRoute);
 foreach(var request in requests) request.DetachFromRoute();
 }
}

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

internal class RequestMatchCandidateEventHandler(
 IRouteOfferRepository offerRepo,
 IOutputQueueRepository queueRepo,
 IConfiguration configuration) :
 IEventHandler<NewMatchCandidateEvent<RouteRequestAggregate>>
{
 private RouteRequestMessage PrepareMessage(RouteRequestAggregate m)
 => new RouteRequestMessage
 …
 …
 public async Task HandleAsync(
NewMatchCandidateEvent<RouteRequestAggregate> ev)
 {
 double maxDistance = configuration
 .GetValue<double>("Topology:MaxDistanceKm") * 1000d;
 int maxResults = configuration
 .GetValue<int>("Topology:MaxMatches");
 var offers = await offerRepo.GetMatch(
 ev.MatchCandidate.Source.Location,
 ev.MatchCandidate.Destination.Location,
 ev.MatchCandidate.When, maxDistance, maxResults);
 var proposals = Enumerable.Repeat(ev.MatchCandidate, 1)
 .Select(m => PrepareMessage(m)).ToList();
 foreach (var offer in offers)
 {
var message = new RouteExtensionProposalsMessage
 {
 RouteId = offer.Id,
 Proposals = proposals,
 };
 queueRepo.New<RouteExtensionProposalsMessage>(message, 1);
 }
 }
}

Метод PrepareMessage просто заполняет RouteRequestMessage, используя данные, содержащиеся в соответствующем RouteRequest\regate. Мы не будем его описывать, так как он тривиален. Метод HandleAsync сначала извлекает параметры, необходимые для поиска, из данных конфигурации. Затем он вызывает метод GetMatch репозитория, чтобы найти все совпадения. Наконец, для каждого найденного маршрута он создает выходной сообщение и добавляет его во внутреннюю очередь. Запрос преобразуется в список сингла, поскольку для выходного сообщения требуется список. Код нашего микросервиса готов! Мы протестируем его в следующей главе после подключения к источникам сообщений и приемникам сообщений. Там мы также реализуем конечные точки проверки работоспособности микросервиса и подключим их к оркестратору.

Резюме

В этой главе подробно описано, как проектировать и кодировать микрослужбу на базе Docker. В частности, описано, как проектировать входные и выходные сообщения и конечные точки, а также как использовать брокер сообщений для реализации событийной коммуникации. Также описано, как обрабатывать неупорядоченные и дублирующиеся сообщения, одновременную генерацию выходных данных с несколькими репликами микрослужбы и транзакционные выходные данные с внутренней очередью базы данных. Затем описано, как организация рабочих сервисов основана на хостируемых сервисах и как в этом случае команды выполняются в соответствии со всеми входными сообщениями. Наконец, описано, как кодировать все уровни луковой архитектуры любого микросервиса. Все концепции были объяснены на практическом примере рабочего микросервиса планирования маршрутов из приложения к книге. Теперь вы должны понимать практическое использование брокер сообщений RabbitMQ и библиотеки NetTopologySuite для реализации пространственных вычислений и запросов.

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

Вопросы

  1. Требуют ли микросервисы работников аутентификации и авторизации? А как насчет зашифрованных протоколов связи? Они не требуют аутентификации, поскольку их обработка не связана с конкретным пользователем приложения. Зашифрованная связь рекомендуется, но не всегда необходима, поскольку они работают в изолированной среде.
  2. Где рекомендуется размещать все входные и выходные сообщения микросервисов? В каких-то очередях.
  3. Как называется техника, позволяющая поддерживать правильный порядок обработки сообщений при использовании нескольких реплик микросервисов? Шардинг.
  4. Верно ли, что если сообщения об изменениях содержат все обновленные сущности, а удаления являются логическими, то порядок сообщений не имеет значения? Да, это верно.
  5. Какая библиотека обычно используется в .NET для обработки сбоев с политиками повторных попыток? Polly используется в .NET для обработки сбоев с политиками повторных попыток.
  6. Где создаются события домена? Где они находятся до запуска их обработчиков? В списке, содержащемся в агрегатах, которые их создали.
  7. Почему обработчики событий обычно не используют транзакции и IUnitOfWork.SaveEntitiesAsync? Потому что транзакции создаются и обрабатываются обработчиками команд, которые вызвали события.
  8. При отправке нескольких одновременных выходных сообщений, как мы можем узнать, какие из них удались, какие не удались, а какие были отменены? Через подтверждения.
  9. Что такое SRID? Пространственные идентификаторы. Они обозначают системы географических координат.
  10. Можно ли использовать метод Distance всех геометрических объектов NetTopologySuite в запросах LINQ к базе данных SQL Server? Да.

Security and Observability for Serverless and Microservices Applications

Существуют исследования, которые показывают, что киберпреступность можно считать третьей экономикой в мире. Кроме того, в последние несколько лет значительно выросли инвестиции многих компаний в кибербезопасность. Когда мы говорим о бессерверных решениях и микросервисах, мы не можем игнорировать эту тему. Фактически, область атаки распределенной системы больше, чем простая монолитная приложение. Учитывая эту сложную ситуацию, безопасность и наблюдаемость нельзя обсуждать в отдельном моменте процесса разработки. Подход «безопасность и конфиденциальность по дизайну» указывает на то, что вы сможете добиться успеха и снизить риски в области кибербезопасности, только если начнете думать об этом сразу после того, как начнете разрабатывать свое решение. Цель этой главы — обсудить, как обеспечить безопасность приложений, настроить мониторинг как производительности, так и безопасности, а также улучшить реагирование на инциденты с учетом инструментов и методов, которые у нас есть в настоящее время.

Лучшие практики безопасности приложений

Хороший подход к рассмотрению безопасности приложения заключается в том, чтобы представить его в виде луковицы с различными слоями защиты. Самым важным в любом приложении являются данные, которые в нем хранятся и обрабатываются. Учитывая это, базы данных приложения должны быть спроектированы таким образом, чтобы обеспечивать правильный доступ и защиту. Однако для обеспечения хорошего решения недостаточно только защитить базу данных, необходимо также подумать о безопасности самого приложения, определив аутентификацию и авторизацию для любого пользователя, который будет к нему обращаться. Кроме того, необходимо понимать, что ваше приложение, вероятно, будет использовать сторонние компоненты, которые также должны быть защищены. Инфраструктура также должна контролироваться и быть защищена, и в настоящее время существуют сложные способы сделать это. И, наконец, что не менее важно, существуют альтернативные решения, которые могут контролировать наши приложения путем перехвата поступающего к ним трафика, гарантируя еще один уровень безопасности. Давайте рассмотрим каждый уровень безопасности в деталях.

Сетевая безопасность

Разработчикам может быть немного сложно представить себе управление сетью в облаке, поскольку можно подумать, что любой предоставляемый ресурс должен быть общедоступным. Дело именно в этом — мы не можем считать какой-либо компонент общедоступным, когда используем публичных поставщиков облачных услуг. Для этого необходимо спроектировать надлежащую сеть, которая будет защищать приложения. Для этого необходимо предоставить виртуальное частное облако (VPC). VPC предоставляет логически изолированный раздел в публичном облаке, где вы можете запускать ресурсы в виртуальной сети, которую вы определяете. Эта изоляция гарантирует, что ваши ресурсы защищены от внешних угроз и несанкционированного доступа. Основная цель этого — уменьшить площадь атаки. С помощью конфигураций VPC вы получите точный контроль над сетью. Определив подсети, таблицы маршрутизации и сетевые шлюзы, вы можете контролировать поток трафика к и от ваших бессерверных функций и микрослужб. Благодаря этому только доверенные источники могут получить доступ к вашим ресурсам, и только то, что вы хотите, будет доступно в публичном Интернете. Когда вы думаете о микрослужбах, нет прямой необходимости в их открытии для интернета. Таким образом, эта защита имеет решающее значение для конфиденциальных данных и критически важных приложений, сводя к минимуму риск внешних атак. В Azure есть два отличных сервиса, которые могут помочь вам настроить частную архитектуру ваших подсистем, гарантируя, что будут открыты только те поверхности, которые действительно необходимо открыть. Первый из них — Azure Virtual Network, компонент, который позволит вам спроектировать VPC в соответствии с выбранной вами конфигурацией. Второй — Azure Private Link, который позволит вашим службам подключаться через частную конечную точку в виртуальной сети. Это даст вам возможность уменьшить необходимость открытия службы для общего доступа в Интернете, используя для этого магистральную сеть Microsoft. Очевидно, что если у вас есть более эффективная сетевая архитектура, вы сможете более эффективно контролировать и защищать свое решение. Например, вы можете определить группы безопасности сети Azure, чтобы задать конкретные правила для каждой группы. У вас есть возможность контролировать трафик сети, включив журналы потоков виртуальной сети. Вы также можете определить входящий и исходящий трафик и запреты с помощью Azure Firewall. Таким образом, виртуальная сеть Azure и ее компоненты являются мощным инструментом для обеспечения безопасности связи между службами в облаке, гарантируя конфиденциальность, целостность и доступность данных.

Безопасность данных

Данные, поступающие в базу данных, как правило, исходят от пользователя или системы. Это означает, что необходимо обеспечить безопасность передачи этих данных, а также предусмотреть меры по защите от перехвата и возможного изменения этих данных. Лучший способ сделать это — зашифровать данные, передаваемые от клиента к серверу. Протокол HTTPS (Hyper Text Transfer Protocol Secure) — это альтернатива, которую, как правило, используют все веб-серверы для этой цели. Вместе с протоколом Transport Layer Security (TLS) мы обеспечиваем безопасный канал для передачи данных. Например, в функциональном приложении HTTPS является единственным протоколом, принимаемым по умолчанию. Это означает, что любой запрос HTTP (который не является безопасным) будет перенаправлен на HTTPS, что обеспечивает лучшую безопасность при передаче данных. Вы можете проверить это в настройках App Service.

image Figure 10.1: HTTPS Only in App Service

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

Аутентификация и авторизация

При создании приложения необходимо знать, кто будет иметь к нему доступ. Для этого необходимо предоставить метод аутентификации, то есть процесс проверки личности пользователя или системы, чтобы убедиться, что субъект, запрашивающий доступ, действительно является тем, за кого себя выдает. Для этого необходимо использовать такие учетные данные, как пароли, токены или биометрические данные. После идентификации пользователя или системы существует еще один процесс, который позволит этому участнику получить доступ к ресурсам или выполнять действия в разрабатываемой вами системе. Процесс, который это позволяет, называется авторизацией. Существует несколько альтернативных способов аутентификации и авторизации. В этой теме мы рассмотрим три из них: JSON Web Tokens (JWT), OAuth 2.0 и OpenID Connect. Это полезные методы для предоставления доступа к веб-сайтам и API, гарантирующие безопасность разрабатываемой вами системы.

JSON-токены

JSON-токен (JWT) обеспечивает безопасность между клиентом и сервером с помощью закодированного JSON-объекта, называемого токеном, который передается в заголовке HTTP в компактном и безсостоятельном формате. Токен создается сервером при проверке аутентификации запрашивающего. Авторизация предоставляется для обеспечения доступа запрашивающего к ресурсам. JWT соответствуют промышленному стандарту RFC 7519. Код, представленный в этой главе, даст вам представление о том, как реализовать JWT с помощью .NET. Стоит отметить, что этот код не готов к использованию, поскольку метод аутентификации не решен.

public class JWT
{
 // Private field to store the JWT token
 private JwtSecurityToken token;
 // Internal constructor to initialize the JWT with a given token
 internal JWT(JwtSecurityToken token)
 {
 this.token = token;
 }
 // Property to get the expiration date and time of the token
public DateTime ValidTo => token.ValidTo;
 // Property to get the string representation of the token
 public string Value =>
 new JwtSecurityTokenHandler().WriteToken(this.token);
}
internal class JWTBuilder
{
 public JWT Build() // Method to build the JWT. JWT is an object
{
 var claims = new List<Claim> // Creating a list of claims
 {
 new Claim(JwtRegisteredClaimNames.Sub,this.subject),
 new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
 }.Union(this.claims.Select(item => new Claim(item.Key, item.Value)));
 var token = new JwtSecurityToken(
 issuer: this.issuer,
 audience: this.audience,
 claims: claims,
 expires: DateTime.UtcNow.AddMinutes(expiryInMinutes),
 signingCredentials: new SigningCredentials(
 this.securityKey,
 SecurityAlgorithms.HmacSha256)
 );
 return new JWT(token);
 }
}

Метод Build в классе JWTBuilder отвечает за построение JWT на основе свойств и заявлений, которые были настроены в конструкторе. Список List инициализируется с двумя заявлениями по умолчанию: (1) sub (subject), которое представляет субъект токена; (2) jti (JWT ID), уникальный идентификатор токена, сгенерированный с помощью Guid.NewGuid(). Дополнительные заявления из словаря заявлений добавляются с помощью Union. Каждая пара ключ-значение в словаре преобразуется в объект Claim. Объект JwtSecurityToken создается со следующими параметрами:

  • issuer: сущность, выдавшая токен.
  • audience: предполагаемый получатель токена.
  • claims: список заявлений, созданных ранее.
  • expires: срок действия, рассчитанный как текущее время UTC плюс настроенное значение expiryInMinutes.
  • signingCredentials: указывает, как подписывается токен. Использует предоставленный securityKey и алгоритм HmacSha256. Метод оборачивает JwtSecurityToken в пользовательский объект JWT и возвращает его. Класс JWT предоставляет дополнительные свойства, такие как ValidTo (срок действия) и Value (строковое представление токена). Как только клиент-запрашивающий получает токен, он может быть инкапсулирован в следующие запросы к серверу в качестве информации заголовка авторизации с использованием префикса Bearer. Сервер, когда он получает эту информацию заголовка, реализует промежуточное программное обеспечение, которое анализирует, подходит ли запрос для запрашивающего. Преимущество этого заключается в том, что если путь запроса защищен процессом JWT, а отправленный запрос не имеет надлежащего токена, запрос не поступает на сервер для обработки, а обрабатывается только промежуточным программным обеспечением. В примере, представленном в этой главе, вы найдете два API. Первый дает вам токен для использования. Второй — это API WeatherForecast, который обычно доступен при создании приложения API с помощью .NET. Чтобы лучше использовать этот пример, была реализована документация Swagger.
image Figure 10.3: JWT Swagger implementation

Если вы попытаетесь запустить API WeatherForecast без предоставления токена Bearer, ответ будет отклонен с кодом ошибки 401, что означает отсутствие авторизации. С другой стороны, если вы используете API Token для генерации необходимого токена и используете этот токен для авторизации с помощью значка замка, доступного в интерфейсе Swagger, результат API будет доставлен правильно.

image Figure 10.4: Defining the Bearer token

Обратите внимание, что предоставленный токен соответствует стандарту JWT и может быть проверен на веб-странице jwt.io, что подтверждает то, что вы определили в своем решении.

image Figure 10.5: Decoding JWT on the jwt.io web page

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

OAuth 2.0 и OpenID Connect (OIDC)

OAuth 2.0 — это открытый стандарт, который позволяет сторонним поставщикам предоставлять приложениям авторизацию для доступа к ресурсам пользователей без раскрытия их учетных данных. Существует множество отличных поставщиков, которые позволяют использовать эту технологию, например Google, Microsoft, Facebook и GitHub. Простое использование логинов с паролями для авторизации в настоящее время считается слишком рискованным для предприятий. Кроме того, передача такого рода данных через API также очень опасна, учитывая потенциальные кибератаки, с которыми мы сталкиваемся в настоящее время. По этой причине OpenID Connect (OIDC) является хорошим вариантом для аутентификации, поскольку он позволяет подтвердить существование пользователя без раскрытия паролей. Для этого необходимо учитывать три важных момента. Во-первых, это также открытый стандарт, что означает, что существует много серверов, предлагающих эту услугу. Во-вторых, вам нужно будет рассмотреть возможность использования стороннего сервиса, поэтому необходимо определить хорошего поставщика . Третье, но не менее важное, — OIDC реализован над OAuth 2.0, что означает, что с его помощью вы получите комплексное решение для аутентификации и авторизации ваших пользователей.

В .NET у нас есть возможность использовать OAuth 2.0 и OIDC на основе библиотеки Microsoft Authentication Library (MSAL). Для этого с помощью Azure сначала необходимо зарегистрировать приложение в Microsoft Entra ID.

image Figure 10.6: Registering an App in Microsoft Entra ID

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

private static async Task GetUserProfile()
{
 IPublicClientApplication clientApp = PublicClientApplicationBuilder
 .Create(clientId)
 .WithRedirectUri(redirectUri)
.WithAuthority(AzureCloudInstance.AzurePublic, "common")

.Build();
 var resultadoAzureAd = await clientApp.AcquireTokenInteractive(scopes)
 .WithPrompt(Prompt.SelectAccount)
 .ExecuteAsync();
 if (resultadoAzureAd != null)
 {
 // Print the username of the authenticated user
 Console.WriteLine("User: " + resultadoAzureAd.Account.Username);
 }
}

В результате возникнет необходимость входа в систему с помощью Microsoft. В данном случае OIDC использует Microsoft Entra ID в качестве поставщика для идентификации пользователя.

image Figure 10.7: Log in using Microsoft Entra ID

После входа в систему Microsoft спросит, разрешаете ли вы ей делиться информацией о вашей учетной записи с желаемым приложением.

image Figure 10.8: Authorizing app to read your data

При использовании этого подхода есть два больших преимущества. Первое – вам не нужно беспокоиться об управлении пользователями. Это управление будет осуществляться Microsoft Entra ID, что означает, что оно будет централизовано и настроено с использованием опыта и знаний поставщика, даже в аспектах различных способов аутентификации, таких как многофакторная аутентификация. Второй и более важный преимущество заключается в том, что пользователю не нужно запоминать еще одну учетную запись, поскольку он будет использовать ту, которую уже использует в своей повседневной работе, что делает OIDC популярным выбором для создания безопасных и удобных для пользователя механизмов аутентификации.

Обеспечение безопасности зависимостей

Open Worldwide Application Security Project (OWASP) — это фонд, который занимается улучшением безопасности программного обеспечения на некоммерческой основе. Одной из их наиболее известных инициатив является список «Top 10», в котором представлены наиболее рискованные ситуации, связанные с вашим программным обеспечением. В этом списке указаны такие ситуации, как атаки по методу инъекции, нарушение аутентификации, раскрытие конфиденциальных данных и неправильная настройка безопасности. При разработке решений использование уязвимых и устаревших компонентов считается одним из 10 основных рисков. Библиотеки, фреймворки и API играют важную роль в современной разработке веб-приложений, но эти компоненты также могут вносить уязвимости в приложение, если не управлять ими тщательно. Решение использовать сторонние компоненты может предоставить злоумышленникам вектор для эксплуатации приложения, что может привести к утечке данных, несанкционированному доступу и другим инцидентам безопасности. Учитывая среду .NET, использование компонентов всегда связано с NuGet. Поскольку NuGet является поставщиком пакетов, в Visual Studio довольно просто проверить, используете ли вы устаревшую библиотеку.

image Figure 10.9: Using NuGet to check outdated libraries

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

Если вы используете GitHub в качестве репозитория, вы можете рассмотреть возможность использования GitHub Dependabot в качестве инструмента для автоматического сканирования ваших проектов GitHub на наличие устаревших зависимостей и известных уязвимостей, а затем открытия PR для их обновления. Sonar и Sync — это другие инструменты, которые вы можете рассмотреть в своем конвейере для предотвращения проблем безопасности третьих сторон. Цель программы CVE (https://www.cve.org/) — помочь нам в этом. CVE означает «Общие уязвимости и угрозы» (Common Vulnerabilities and Exposures) и представляет собой список публично раскрытых проблем компьютерной безопасности.

⚠️ **GitHub.com Fallback** ⚠️