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

И хотелось бы рассказать о различных паттернах и антипаттернах разделении ответственностей на микросервисы.

Сервис-сущность как антипаттерн


“Сервис-сущность” — один из возможных (анти)паттернов проектирования микросервисной архитектуры, который приводит к сильно-зависимому коду в разных сервисах и слабосвязанному внутри сервисов.

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

Для примера возьмем интернет-магазин. Мы решили выделить сервисы «продукт», «заказ», «клиент».

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

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

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

Или какие изменения и в какие сервисы надо сделать, чтобы добавить скидки по промокоду?
Как минимум надо:

  • в сервис «заказ» добавить промокод
  • в сервисе «товар» добавить действует ли скидки по промокоду на этот товар
  • в сервисе «клиент» добавить список промокодов, которые выдали клиенту

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

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

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

Сервисы-хранилища


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

Если данные хранятся в разных базах, на разных машинах, то мы

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

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

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

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

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


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

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

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

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

Например, мы можем таким образом разделить на сервисы:

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

Для взаимодействия друг с другом сервисы могут использовать событийную модель или обмениваться друг с другом простыми объектами (restful api, grpc и т.д). Правда, стоит отметить, что правильно организовать взаимодействие между такими сервисами бывает не просто. Как минимум децентрализация данных имеет проблемы с консистетностью когда-нибудь (eventual consistency) и транзакционностью (в случае когда она важна).

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

В реально жизни часто встречается что-то среднее. Сервис сущность с минимальным набором атрибутов, который используется всеми сервисами потребителями. И какой-то минимальный слой логики — например, статусная модель, и события в очередь с оповещением всех об изменениях в сущности. При этом сервисы потребители все-равно довольно-таки часто держат у себя “кэш” данных. Делается все возможное, чтобы изменений в такой сервис было как можно меньше, и это в принципе тяжело сделать из-за того, что потребителей очень много.

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

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

И вопрос в отделении ответственностей и в высоте барьеров для абстракций.

Проектирование API сервиса


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

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

CRUD-интерфейсы для сервисов со сложной бизнес-логикой


Слишком широкий и неспецифичный интерфейс способствует либо размыванию ответственности, либо чрезмерному усложнению.

Например, CRUD API для сервисов со сложной бизнес-логикой.Такие интерфейсы не инкапсулируют поведение. Они не просто дают возможность бизнес-логике утечь в другие сервисы и размывают ответственность сервиса, они провоцируют растекание бизнес-логики — ограничения, инварианты и методы работы с данными теперь находятся в других сервисах. Сервисы-пользователи интерфейса (API) должны реализовывать логику сами.

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

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

Пусть API будет выглядеть так: методы POST/PATCH/GET, по урлу /api/v1/tickets/{ticket_id}.json

Вот так, можно обновить тикет

PATCH /api/v1/tickets/{ticket_id}.json 
{ 
    "type": "bug", 
    "status": "closed",
    "description": "на самом деле фича"
}

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

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

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

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

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

Что можно сделать, чтобы сделать RESTful более проблемно-ориентированным?
Во-первых, можно добавить методы к сущностям. Интерфейс становится менее restful. Но такая возможность есть. Мы все-таки не за чистоту расы боремся, а решаем практические задачи

Вместо универсального ресурса /api/v1/tickets.json добавить еще ресурсы:

/api/v1/tickets/{ticket_id}/migrate.json — смигрировать из одного типа в другой
/api/v1/tickets/{ticket_id}/status.json — если есть статусная модель

Во-вторых, можно представить любую операцию, как ресурс в рамках REST. Есть операция миграция тикета из одного типа в другой (или из одного проекта в другой?). Ок, значит будет ресурс
/api/v1/tickets/migration.json

Есть бизнес операция создать триальную подписку?
/api/v1/subscriptions/trial.json

Есть операция перевод денег?
/api/v1/money_transfers.json

И т.д.

Антипаттерн с дата-центричным API на самом деле относится к rpc взаимодействию в том числе. Например, наличие слишком общих методов типа editAccount(), или editTicket(). “Изменить объект” не несет смысловой нагрузки, связанной с проблемной областью. Это значит, что этот метод будут вызывать по разным причинам, по разным причинам менять.

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

Событийная модель


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

Например, если в сервисе при регистрации пользователя нам нужно отправить ему welcome-письмо, создать заявку в CRM на клиентского менеджера и т.д, то логично сделать не через явный вызов внешних сервисов, а в сервисе регистрации положить в очередь сообщение “пользователь 123 зарегистрирован”, а все нужные сервисы прочитают это сообщения и совершат необходимое действие. При этом изменение бизнес-логики не потребует менять сервис регистраций.

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

Поскольку события предметной области практически 1 в 1 транслируются в синхронные API методы, то иногда даже предлагают вместо вызовов API использовать поток событий вместо потока вызовов (Event Sourcing). По потоку событий всегда можно восстановить состояние объектов, но при этом еще и иметь забесплатно историю. По факту, обычно такой подход не очень гибок — надо поддерживать все события, и зачастую проще вести историю рядом обычным API.

Микросервисы и производительность. CQRS


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

Например, есть cpu-bound метод калькулятор в сервисе, написанном на PHP, выполняющий сложные расчеты. С ростом нагрузки и количества данных он перестал справляться. И конечно же как один из вариантов, имеет смысл делать вычисления не в php коде, а в отдельном высокопроизводительном си-шном демоне.

Как один из примеров деления по сервисов по принципу производительности — разделение сервисов на читающих и изменяющих (CQRS). Такое разделение часто предлагают потому, что требования к производительности у читающих сервисов и пишущих разные. Нагрузка на чтение зачастую на порядок выше, чем на запись. И требования к скорости ответа запросов на чтение, намного выше, чем на запись.

Клиент 99% процентов времени проводит в поисках товара, и лишь 1% времени в процессе заказа. Для клиента в состоянии поиска важна скорость отображения, и фичи, связанные с фильтрам, различными варианты отображения товара и т.д. Поэтому имеет смысл выделить отдельный сервис, который отвечает за поиск, фильтрацию и отображение товаров. Такой сервис скорее всего будет работать на каком-нибудь ELK, документоориентированной БД с денормализованными данными.

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

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

Комментарии (14)


  1. Yeah
    27.09.2019 09:49

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

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


    1. ggo
      27.09.2019 10:12

      Topic vs Queue

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


    1. tody59rus
      27.09.2019 11:49

      например Apache Kafka создана для решения этой задачи


      1. cudu
        27.09.2019 14:59

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


  1. wolfer
    27.09.2019 11:49

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


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


    1. ggo
      27.09.2019 13:46

      queue correlation id


    1. VolCh
      29.09.2019 19:57

      Request-Response вполне себе события.


  1. leschenko
    27.09.2019 12:03

    Не совсем про микросервисы, а про REST и «чистоту расы», коммент.
    Если есть необходимость выполнения бизнес-действий (а они как правило есть) над данными (которыми оперирует REST), то есть смысл вводить понятие задач. Задачами являются бизнес-действия (возможно длительные), которые представлены в REST существительными, а не глаголами. Т.е. не надо в URL пихать глаголы.

    Например:
    Создаем задачу:
    POST /api/v1/tickets/{ticketid}/tasks
    {'action': 'migrate', ...params}
    В ответ получаем модель задачи и если ее статус не завершен, то через некоторое время делаем так:
    GET /api/v1/tickets/{ticketid}/tasks/{taskid}

    Можем отменить задачу
    DELETE /api/v1/tickets/{ticketid}/tasks/{taskid}

    Если выполнение задачи еще не началось, то можно разрешить изменение ее параметров
    PUT /api/v1/tickets/{ticketid}/tasks/{taskid}

    Итого: Можно и REST сохранить, и бизнес-действия выполнить, и за чистоту расы не бояться.


    1. VolCh
      29.09.2019 19:59

      Это если нужно сохранять rest. Часто объяснить не могут, а почему, собственно, rest.


  1. Ekstrem
    27.09.2019 16:41

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

    Мне показалось, что автор интуитивно подводит к понятию предметно-ориентированного проектирования.
    Разве лучший способ декомпозиции микросервисов не ограниченный контекст?


    1. VolCh
      29.09.2019 20:01

      Не всегда он лучший. Но в целом, да, хорошо выделенный контекст хороший кандидат на вынесение в ммкросервис


  1. vsespb
    27.09.2019 21:42
    +1

    Отличная статья! Большинство статей про сервисную архитектуру, что я видел на хабре, сводятся к низкоуровневым темам в зоне devops. А про высокоуровневое проектирование эта лучшая статья что я видел здесь.


  1. stone_evil
    28.09.2019 09:09

    Сервис-сущность как антипаттерн

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

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

    Этот отдельный слой называется хореограф или оркестратор, паттерн типа Саги и т.д. Все верно, его задача — реализовывать бизнес-логику и манипулировать данными из микросервисов-хранилищ (забирая из через API). Что в этом автор увидел плохого, непонятно.

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

    А зачем в данном примере что-то разделять? CQRS надо использовать только там, где это необходимо.


    1. zloy_stas Автор
      28.09.2019 09:43

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


      Если эти сервисы-сущности используются только в микросервисе витрине, то зачем они вообще нужны? Не проще ли будет сразу ходить в БД?

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

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

      Если интересно, что по этому поводу считают люди в целом по индустрии, то Фаулер тоже считает сервис-сущность антипаттерном, например.