Привет, Хабр! Меня зовут Руслан Сафин и я расскажу про микросервисы и как определить необходимую гранулярность. Статья написана по следам моего доклада на TechLeadConf 2022: видео, тезисы и презентация.

Я работаю техническим директором в Byndyusoft. Развиваю техническую культуру и участвую в проектах в роли IT-архитектора, а ещё преподаю авторский курс по IT-архитектуре в университете. В коммерческой разработке 15 лет. Из необычного — проектировал защиту от накруток в интернет-голосовании конкурса Мисс Россия и автоматическое определение предвзятости судей в танцевальном спорте.

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

Как спроектировать микросервисы с нуля

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

Теория идеального микросервиса

Тема микросервисов уже достаточно старая, ей больше 10 лет точно. За это время появилось много статей и книг по теории.

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

  1. Единственность ответственности. Это принцип из SOLID для проектирования классов. Он означает, что у одного микросервиса должна быть одна ответственность, а также что за одну ответственность отвечает ровно один микросервис.

  2. Ограниченный контекст. Этот термин из Domain-Driven Design Эванса и его последователей. С его перекладыванием на микросервисы уже возникают, на мой взгляд, некие сложности. Напомню, что ограниченный контекст — это набор ограниченного смысла или ограниченной модели, который отвечает за некую бизнес-логику.
    Ловушка в том, что при переходе на микросервисы и их проектировании есть соблазн выделить ограниченные контексты и сказать, что один контекст — это один сервис. На мой взгляд, это не так, по крайней мере, не всегда.

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

  3. Связанность и связность. Рассмотрим два варианта взаимодействия контекстов:

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

  4. Принципы проектирования пакетов/сборок. Одновременно с принципами SOLID Роберт Мартин сформулировал ещё и 6 принципов проектирования сборок, про которые часто незаслуженно забывают:

  • Reuse/Release Equivalence Principle

  • Common Reuse Principle

  • Common Closure Principle

  • Acyclic Dependencies Principle

  • Stable Dependencies Principle

  • Stable Abstractions Principle

Помимо описания в книге, принципы много раз перепечатаны и легко гуглятся

Как раз эти 6 принципов, на мой взгляд, идеально ложатся на проектирование микросервисов, в отличие от 5 принципов SOLID, которые все-таки больше про классы в ООП. Часть из них мы затронем в статье.

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

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

Противоречия и сложности на практике:

Баланс соблюдения принципов. Мы пытаемся соблюсти один принцип, нарушается другой. Нам нужно ловить баланс, а он достаточно непростой.

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

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

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

Примеры гранулярности из жизни

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

Крупно нарезанная система

Представим, что мы делаем систему доставки для интернет-магазина, в котором есть три типа доставки:

  • Курьерская доставка, когда курьер приходит к вам домой до двери;

  • Доставка в пункт выдачи заказов (ПВЗ);

  • Самовывоз из магазина.

Наверное, самое первое и логичное, что приходит в голову — разделить систему на 3 сервиса, каждый из которых будет отвечать за свой тип доставки. Если вы так сделали и у вас всё работает, ничего не трогайте! 

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

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

    Такие повторы легко увидеть в трассировке запросов (например, в Jaeger) — мы просто смотрим трассу и видим, что повторяется один и тот же сервис, один и тот же endpoint. Сложнее отследить повторы и дубли в данных: некритично если это небольшой JSON, но если у нас мегабайтные JSON передаются по цепочке трассировки из 5-го сервиса в 4-й, из 4-го во 2-й, из 2-го в 1-й, а на самом деле они нужны только в одном месте — это явно проблема.
    Кстати, в трассировках могут быть даже циклы — мы пошли в первый сервис, второй, в третий, потом снова в первый. Это уже совсем тревожный звоночек, что пора всё рефакторить.

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

    То есть небольшому микрофронтенду (отвечающему только за одну цифру минимального срока доставки) придется знать про огромные сервисы и про богатое API, которые они предоставляют, включая API по стоимости, списку ПВЗ и т.д., а ведь ему нужен совсем маленький кусочек — минимальное время доставки. Таким образом, у нас появляется избыточность интерфейсов, а в худшем случае — избыточность данных. К примеру, мы посчитали всё время доставки, отдали 10-мегабайтный JSON на тысячу пунктов выдачи, а нашему консьюмеру нужна была только одна цифра, потому что минимальное время — это 2 дня. Такая избыточность приводит не только к накладным расходам на сеть и процессорное время, но и в целом делает систему более хрупкой, т.к. позволяет использовать данные и контракты не по прямому назначению.

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

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

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

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

Недостатки крупно нарезанной системы

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

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

Мелко нарезанная система

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

В этом варианте нарезки мы оттолкнемся не от типа доставки, а от разных ограниченных контекстов — например, контекстов стоимости доставки, срока доставки и контекста пунктов выдачи. В контекст стоимости добавим два микросервиса с разным расчетом стоимости доставки для первого и второго доставщиков. Точно также поступим с контекстом срока, т.к. алгоритмы расчёта срока тоже могут разниться между разными службами доставки. В контексте пунктов выдачи — выделим мастер-данные со списком ПВЗ, доступных в каждом городе, и (зачем-то) отдельно выделим время работы этих пунктов.

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

  1. Максимальная сцепленность (cohesion). Максимальная сцепленность (или неотделимость) может быть по данным и по ответственностям. В нашем примере — мы понимаем, что отдельно время работы пунктов выдачи нам вообще ни в одном сценарии не требуется: всегда, когда мы загружаем список пунктов выдачи, должны сразу подтягивать туда и время их работы. Нет такого сценария, что мы отдельно берем список без времени либо, наоборот, берем время без списка. Это и есть максимальная сцепленность — настолько прочны эти атомы-микросервисы, что могут слиться в одну молекулу и нет смысла их дробить.

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

  3. Линейный рост инфраструктурных работ. Если мы достаточно мелко нарезали систему, то сервисов к нас получилось много. И мы можем заметить, что с увеличением числа сервисов линейно растет инфраструктурная ручная работа и нагрузка на девопсов. И рано или поздно у нас остается только два варианта: либо нам необходимо повысить уровень инфраструктуры и автоматизации, либо — признать, что пока уровень инфраструктуры не тянет нашу архитектуру, и нужно отказываться от мелкой гранулярности и переходить на более крупную. Иначе мы не сможем вечно добавлять сервисы и просто упремся в наш DevOps.
    Подробно о повышении уровня инфраструктуры я рассказываю в этом докладе: Как снизить накладные расходы на добавление +1 микросервиса / Руслан Сафин

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

Недостатки мелконарезанной системы

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

  • Выше риски избыточной работы. Достаточно высокий шанс, что мы где-то будем over-engineer’ить. Если достаточно мелко нарезать сервисы, то часть фич нам будет сложно реализовать — например, для выкатки небольшого нового функционала придется деплоить по 5 сервисов за раз.

Как достичь баланс

Мы рассмотрели два противоположных примера. Но вопрос остался открытым — как же всё-таки спроектировать “правильно”? Я постарался сформулировать 4 общих принципа, которые помогут приблизиться к балансу при нарезке микросервисов. В детали каждого из них можно погружаться долго и глубоко (например, под мастер-данными понимаю подход, описанный в статье Управление мастер-данными в микросервисной архитектуре), пока попробуем их назвать кратко.

Путь к золотой пуле — выделение корней агрегации согласно домену и контексту видимости

  1. Выделение корней агрегации согласно домену и контексту видимости. Как правило, многие выделяют корни агрегации, но часто забывают про контекст видимости. Например, есть контекст, который рассчитывает клиентскую стоимость доставки, но нам ещё нужна себестоимость доставки для интернет-магазина. Скорее всего, у этих стоимости и себестоимости будут разные контексты видимости: стоимость нужна для показа клиенту на витрине; а себестоимость не интересует сервисы, которые отвечают за витрину. Себестоимость нужна для аналитических систем и внутренней кухни (например, проверки выставленного счета службой доставки), в которых, скорее всего, клиентская стоимость уже не так важна.
    Если у корней агрегации разные контексты видимости, то советую разделять их на два агрегата.

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

  3. Сокрытие костылей нюансов интеграции на периметре, так называемый anti corruption layer. Наверняка у многих были ситуации, когда мы интегрируемся с внешней системой, и нам приходится писать какие-то «if’чики»: если external ID = 108, то сделай это. Эту ID = 108 лучше не пропускать внутрь периметра нашей системы. Давайте ее обработаем где-то на входе в периметр и забудем. Т.е. если ID = 108, то мы идем по такому-то бизнес-процессу, и никакие сервисы внутри нашего периметра не знают про этот костыль с магической айдишкой.

  4. Разделение BFF согласно архитектуре фронтенда. Этот принцип также касается организации периметра нашей системы. Периметр — это или интеграция с внешними системами, или с пользователем (взаимодействие с нашим UI-интерфейсом). Если у нас микрофронтенды на UI, то хорошо сработает вариант, когда наши BFF-сервисы (Backend-for-Frontend) повторяют архитектуру на фронтенде. Т.е. под каждый микрофронт — свой BFF. Другой пример — это отдельные BFF для мобильных приложений, десктоп-приложений и так далее. В любом случае, советую делить BFF для крупных систем, чтобы не выставлять всё развесистое API в одном единственном BFF.

Если попробовать применить эти принципы к нашему примеру, то получится такой вариант:

Выделяем три контекста:

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

  • Расчет стоимости. В этом контексте есть общий расчет стоимости доставки (допустим, междугородней Москва-Самара), а уже в доставке по Самаре могут быть варианты: или мы рассчитываем стоимость доставки до пункта выдачи, или нужно дополнительно включить услуги подъема на этаж, сборку и т.д. То есть внутри контекста стоимости мы уже выделяем отдельно расчет общей доставки для разных типов и отдельно расчет услуг, допустим, для курьерской доставки.

  • Расчет сроков. Здесь ситуация аналогична расчету стоимости — есть общий расчет срока доставки и специфика для конкретного типа, например, расчет доступных временных слотов доставки для курьеров. 

Запроектированная архитектура и выбор целесообразного способа разделения на микросервисы, не должны быть статичны. Я на самом деле удивился, что даже в российских ГОСТах черным по белому написано, что архитектура изменяема во всем жизненном цикле проекта. Об этом же говорит Эрик Эванс в докладе. Там он представляет слайд, который, на мой взгляд, заслуживает внимания:

https://www.domainlanguage.com/ddd/whirlpool/

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

Эрик Эванс говорит про этот подход в докладе и противопоставляет его водопаду.

С чего начать нарезку сервиса

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

  1. Мастер-данные, то есть агрегаты.

  2. Ответственности и намерения. Особенно важны намерения. Когда говорят про классы в ООП, про ответственности обычно помнят, а про намерения, на мой взгляд, незаслуженно забывают. Если в целом класс — это ответственность, то намерение — это, скорее, метод класса или функция. У сервисов точно также — есть базовая общая ответственность, за которую он отвечает, и есть REST API с набором методов-намерений. Например, намерение определенного endpoint’а — расчет минимальной стоимости доставки или расчет доставки Москва-Петербург. 

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

Вопросы практику

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

  1. Что будет, если мы раздробим/попарно объединим мастер-данные? Мы произвели разбивку системы на несколько сервисов, отвечающих за те или иные мастер-данные, и теперь попробуем челленджить решение с помощью этого вопроса.

  2. Что будет, если мы преобразуем ответственность (микросервис) к намерению (endpoint) более крупного сервиса и наоборот? Например, у нас есть сервис, рассчитывающий стоимость доставки с endpoint’ом, который считает минимальную стоимость доставки. Что будет, если этот endpoint вынести в отдельный микросервис расчета минимальной стоимости? Чтобы узнать это, мы визуализируем архитектуру и проверяем её нашими принципами (coupling, cohesion и др.) и бизнес-сценариями. И наоборот, если мы сразу выделили микросервис для расчета минимальной стоимости, то выясняем, что будет, если преобразовать этот микросервис к endpoint’у более крупного сервиса с более крупной ответственностью?

  3. Насколько влияют на разработку внешние факторы? Например, каковы: наш уровень инфраструктуры, готовность нашего DevOps к большому или малому количеству сервисов, квалификация и опыт команд. Если команда всю жизнь писала монолиты, то сложно сразу перейти на систему, когда один человек разрабатывает 5-10 сервисов, лучше начать с небольшого числа сервисов на человека.

Выводы

  • Мы рассмотрели теорию «идеального» микросервиса, попробовали выделить ее основные моменты (объять необъятное ?).

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

  • Выделили три чек-листа:

    • Принципы разделения, на что нужно обращать внимание;

    • Факторы-пререквизиты, которые при этом нужно учитывать;

    • Контрольные вопросы для проверки, крупно мы нарезали, мелко или идеально.

На DevOpsConf 2023 будет трек полезный техлидам и много других актуальных кейсов. Пока не закрыт приём заявок. Ещё 3 дня, до 1 декабря включительно, можно подать свой доклад на выступление на конференции. Ждём ваши предложения и заявки на участие.