В этом посте расскажу о том, какой вред может нанести межсервисная коммуникация по HTTP в микросервисной архитектуре и предложу альтернативный способ совместного использования данных в распределенной системе.
Микросервисы, REST, API… даже не уверен, можно ли впихнуть в заголовок поста еще больше модных словечек, но знаю наверняка: все эти словечки вворачиваются для того, чтобы заронить сомнения в душе разработчиков, архитекторов и управляющих директоров. Сомнения таковы: если не «делать» микросервисов, если не предусмотреть API на все случаи жизни, а также не придерживаться REST – то что-то пойдет не так. И вы определенно что-то делаете не так, если не проводите все операции по HTTP.
Так что, держитесь, сейчас будет бомба: я утверждаю, что микросервисы – это не REST по HTTP.
В этом посте будет проиллюстрировано, какой вред может нанести межсервисная коммуникация по HTTP в микросервисной архитектуре, а также будет предложен альтернативный способ совместного использования данных в распределенной системе.
История о двух подходах к использованию API
Прежде, чем показать, как можно злоупотреблять REST API, начну с некоторого позитива и приведу пример грамотного использования API в микросервисной архитектуре.
API для составного UI
На этой схеме у нас два микросервиса и составной UI. Чтобы в UI можно было собрать полную картину данных и вывести ее пользователю, эти составные части UI обращаются к каждому сервису и извлекают ту информацию, которую нужно показать.
Разве здесь что-то не так? Нет, все как и должно быть!
Это совершенно нормальный и ожидаемый вариант использования API. Поскольку в наше время большинство веб-разработчиков к клиентским фреймворкам, например, Angular, Vue и React (также остается надежда, что в ближайшем будущем на клиенте будет гораздо шире представлен Blazor), существует неписанное ожидание, что также будет построен API, через который клиентский код сможет считывать данные из приложения и записывать их в приложение.
Такие API становятся очень важными контрактами и предоставляют элементам фронтенда весь необходимый функционал бекенда, вызываемый этими элементами. Естественно, тратится много времени на проектирование и реализацию таких API. Их стараются сделать ясными, корректно работающими, причем, добиваются, чтобы они передавали в клиентскую часть ровно ту информацию, которая нужна клиенту для выполнения задачи.
Хороший API критически важен для успеха всего проекта.
API для межсервисной коммуникации
Учитывая, как много труда вкладывается в доведение таких API до совершенства, — представьте, каков соблазн поскорее воспользоваться уже проделанной работой. Да, бывает, что такой соблазн действительно значителен, и тут бекенд-разработчики обнаруживают, что оказались в следующей ситуации.
API для составного UI
Сервис Blue выполняет операцию, для завершения которой требуется обратиться к сервису Purple и взять оттуда информацию, необходимую для выполнения задачи Blue.
В чем же проблема? В том, что это делается с помощью вызова HTTP. Протокол HTTP – синхронный, блокирующий. Обычно он требует, чтобы вызывающая сторона дожидалась ответа. Разумеется, в .NET есть соответствующие оптимизации, например async/await, но они всего лишь позволяют вызывающей стороне работать в режиме многозадачности после того, как был совершен запрос. Это никак не отменяет того факта, что вы по-прежнему вынуждены дожидаться отклика.
Почему это плохо?
Чтобы обработать любую команду, Blue вынужден отправить вызов к Purple – только так он сможет выполнить свою задачу. Поскольку вызов является блокирующим, а как сеть, так и брокер не отличаются надежностью, либо сам Purple может работать медленно или отказать, на Blue может обрушиться каскад задержек и исключений. В свою очередь, из-за этих ошибок сам Blue может перестать отвечать, и так до тех пор, пока не обвалится вся система. Одна большая блокирующая цепочка вызовов, выстроившаяся по HTTP.
Не выглядит ли все это как автономия сервисов? Никак нет! Это сильная связанность. А в данном случае мы имеем дело с конкретным типом связанности, так называемой временной связанностью.
Вернемся на шаг назад. Почему же Blue, чтобы справиться с поставленной перед ним задачей, нужно стабильно получать данные от Purple? В соответствии с Постулатами сервис-ориентированной архитектуры (SOA), каждый сервис должен быть автономен. Это означает, что как поведение, так и данные, необходимые сервису для выполнения его работы должны локализоваться именно в этом сервисе. Если Blue обращается к Purple за данными, нужными ему для выполнения его работы, то, определенно, Blue не автономен, а Purple потенциально также не автономен.
Важнее, что за данные Blue разрешено получать от Purple? Зачастую это данные, которые должны инкапсулироваться Purple. Поэтому, когда Purple свободно отдает эти данные Blue, создается логическое связывание.
Теперь, убедившись в порочности временного и возможного логического связывания, возникающего при межсервисной коммуникации по протоколу HTTP в микросервисной архитектуре, давайте разберемся, как избавиться от таких проблем.
Даже в распределенной системе требуется обеспечить разделяемость некоторых данных между сервисами. Ни одна (полезная) система не может работать в полной изоляции. Бывает, что одному сервису для выполнения задачи требуется подмножество данных от другого сервиса.
Вместо того, чтобы полагаться на межсервисную коммуникацию по протоколу HTTP, можно воспользоваться системой сообщений.
Отступление: а что можно сказать о кэшировании?
Когда я предлагаю «давайте использовать сообщения», один из первых ответов, который мне поступает – «это решаемо при помощи кэширования данных».
Сколько времени нужно на кэширование данных? Сколько – на обработку инвалидации кэша? Может ли инвалидация зависеть от нагрузки? Нужен ли мне распределенный кэш? Уйдя далеко по этой дорожке, можно столкнуться со сложностями при реализации. Я ведь уже упомянул инвалидацию кэша?
Ирония судьбы в данном случае такова: те, кто хочет обойтись без согласованности в конечном счете, настаивая, что им нужно получать данные «в режиме реального времени» и использовать для этого блокирующие вызовы по HTTP, в итоге все равно могут столкнуться с необходимостью так или иначе обеспечивать согласованность в конечном счете – реализовав ее в виде кэша. Да, кэширование – это вариант согласованности в конечном счете. Почему? Потому что в любой момент те данные, с которыми работает Blue (извлеченные и кэшированные с Purple) могут устареть, и это означает, что в конечном счете данные будут согласованы.
Окей, возвращаемся к сообщениям!
Исследование обмена сообщениями
Итак, обсудив проблему кэширования, давайте обсудим, как может выглядеть модель совместного использования данных, основанная на обмене сообщениями между сервисами.
Как это можно сделать? Поставим проблему с ног на голову.
Теперь не Blue будет обращаться к Purple за данными. Теперь мы перейдем на модель «публикация-подписка», где Purple публикует события, а Blue может на них подписываться.
Не спешите приниматься за реализацию этого подхода, так как предварительно нужно рассмотреть еще пару важных аспектов.
Во-первых, в таком случае те данные, которыми, возможно, решит поделиться Purple, могут представлять собой стабильные бизнес-абстракции. Purple делится данными не только с Blue, но и с любой другой пограничной единицей, которая подписывается на данное событие. Как только событие опубликовано, данные об этом событии оказываются «в доступе», и любой подписавшийся сервис окажется связан с данными об этом сообщении. Здесь действует следующий руководящий принцип: добиваться, чтобы для решения задачи требовалось поделиться как можно меньшим объемом данных.
Во-вторых, предпринимая такой подход, мы вводим в систему согласованность в конечном счете. Вполне возможно, что сообщения, опубликованные Purple, будут задерживаться из-за состояния сети, кратковременных ошибок, недоступности брокера или Еще Миллиона Вещей, Которые Могут Нарушиться В Продакшене. Мы не контролируем этих условий. Поэтому наш код и, что еще важнее, весь наш бизнес, должны учитывать: Blue может выполнить какую-нибудь недопустимую операцию из-за того, что устарели данные, которыми он оперирует. Такое несогласованное состояние можно купировать путем Компенсирующих действий, но это тема для отдельного поста.
Теперь, когда мы обрисовали контекст, и у нас есть «карта местности», давайте перейдем к конкретике и разберем практический пример, относящийся к предметной области «Доставка».
The Shipping Domain
В предметной области «Доставка» (Shipping) у нас два сервиса: «Выполнение» (Fulfillment) и «Склад» (Warehouse).
• Fulfillment отвечает за выполнение заказа. Для простоты предположим, что пока каждому заказу соответствует один товар.
• Warehouse – это источник истины, характеризующий, какие складские запасы каждого товара доступны в настоящий момент; на основе этой информации будут выполняться заказы.
Имея эти базовые определения, давайте разберемся, как в данном случае при помощи сообщений организовать совместное использование данных с передачей их из Warehouse в Shipping, так, чтобы Shipping мог судить: возможно ли выполнить заказ, исходя из складских запасов данного товара в Warehouse.
Не звоните мне, я сам вам позвоню
Теперь не Fulfillment будет обращаться к Warehouse, запрашивая, в каком количестве на складе имеется нужный товар и, соответственно, определять, может ли быть выполнен заказ.
Напротив, мы будем следить за Warehouse, который станет публиковать события и таким образом широковещательно сообщать, как изменяются складские запасы на уровне каждого товара. Например, давайте очень грубо обрисуем событие и назовем его ProductInventoryUpdated.
public class ProductInventoryUpdated
{
public Guid ProductId { get; set; }
public int UpdatedAmount { get; set;}
}
UpdatedAmount может выражаться положительным числом (запасы товара были восполнены в результате новой поставки, либо из-за того, что на склад пришел возврат, т.д.) либо отрицательным числом (заказы выполняются, товар убывает). UpdatedAmount – это дельта.
Fulfillment подписывается на ProductInventoryUpdated. Обрабатывая событие, Fulfillment считывает последние данные об имеющемся количестве товара с заданным id, опираясь на сообщение из локальной базы данных. Далее применяется дельта для пересчета доступного количества и для записи в локальную базу данных обновленной информации о доступном количестве товара.
Что мы выиграли, предприняв такой подход?
• Устранили временное связывание между сервисами.
• Внедрили для Склада асинхронный подход «выстрелил и забыл», организовав широковещательную передачу данных об обновлении склада методом «публикация/подписка».
• Локализовали в Выполнении все данные, которые нужны этому сервису, чтобы справиться со своими задачами. Все это – без блокирующих HTTP-запросов. Вот как, например, это может выглядеть:
ProductInventoryUpdated содержит только стабильные бизнес-абстракции: contains id товара и дельту изменения складских запасов.
Я уже пару раз упоминал о стабильных бизнес-абстракциях и приводил примеры данных, которые таковыми считаются. А какие данные не являются стабильной бизнес-абстракцией (то есть, какие данные не следует разделять)?
Здесь это могут быть такие данные, как название товара, SKU-номер (складской номер товара), bin # (указание, где именно товар расположен на складе)… все, что нужно Складу для выполнения своей работы. Данные и поведение, используемые при расчете дельты, должны оставаться как следует инкапсулированы в сервис Warehouse.
Рефакторинг: от дельты к доступному количеству
Итак, мы уже достаточно хорошо спроектировали систему, но всегда можно сделать ее лучше. Что, если все расчеты у нас будут выполняться в Fulfillment? В настоящий момент Warehouse должен публиковать дельту товара в соответствии со скорректированной информацией о складских запасах. Ведь Warehouse известно, сколько штук товара есть на складе – почему бы не опубликовать эту информацию?
Давайте попробуем, подходит ли это нам:
Все, что теперь нужно сделать Fulfillment – это обработать событие и вставить AvailableAmount для товара с подходящим id в свое локальное хранилище данных. Никаких вычислений, просто вставить значение и готово.
Сообщения – это не магия
В конце концов, сообщение – это просто контракт. Точно как API должен предоставлять контракты вызывающим сторонам, это касается и сообщений: они тоже контракты, разделяемые между отправителем и получателем(ями). Для контрактов все равно нужна стратегия версионирования и очень избирательный подход к тому, какие данные можно разделять и с кем.
Так, если вы планируете открыть в сообщении доступ ко всем данным, содержащимся в Warehouse – то вас ждут те же проблемы с сильным связыванием, которые возникали, когда в качестве контракта использовался API. Хотя механизмы доставки здесь и отличаются, логически допускается ровно та же ошибка, и в результате вы получите… все ту же гигантскую запутанную кучу.
Заключение
В этом посте была представлена жизнеспособная альтернатива межсервисной коммуникации по протоколу HTTP, применительно к микросервисной архитектуре. Использование публикации/подписки – отличный подход к устранению временной связанности между вашими микросервисами, а также первый шаг к внедрению работы с сообщениями.
Как и при любом подходе, не нужно просто сносит всю базу кода и все делать по-новому.
Ищите цепочки HTTP-вызовов, охватывающие множество сервисов, в особенности те, что вызывают проблемы с производительностью или провоцируют масштабное снижение надежности. Просмотрите ваш код и найдите в нем сервисы, которые частично зависят от данных других сервисов. Подумайте, есть ли возможность либо консолидировать их, либо ослабить связь между ними, реализовав для этого обмен сообщениями.
Брокеры могут послужить отличным входным решением для реализации дешевой и сердитой функциональности публикация/подписка. В RabbitMQ можно работать с Топиками, которые вписываются в модель «публикация/подписка». Azure Service Bus – также отличный кандидат на эту роль, тем более, что в него внедрено еще множество вкусностей, например, дедупликация сообщений, объединение сообщений в пакеты, транзакции и прочий функционал, свойственный сервисным шинам.
Если вас интересуют фреймворки, поддерживающие семантику публикации/подписки через API, посмотрите в сторону шины событий CAP. Семантика публикации/подписки + уровень надежности, достаточный для большого предприятия, обеспечивается в NServiceBus или ReBus.