Привет, Хабр! Меня зовут Руслан Сафин и я расскажу про микросервисы и как определить необходимую гранулярность. Статья написана по следам моего доклада на TechLeadConf 2022: видео, тезисы и презентация. Я работаю техническим директором в Byndyusoft. Развиваю техническую культуру и участвую в проектах в роли IT-архитектора, а ещё преподаю авторский курс по IT-архитектуре в университете. В коммерческой разработке 15 лет. Из необычного — проектировал защиту от накруток в интернет-голосовании конкурса Мисс Россия и автоматическое определение предвзятости судей в танцевальном спорте. Byndyusoft занимается заказной разработкой с продуктовым подходом. Так как наша компания работает с крупными заказчиками, мы постоянно учимся новому, перенимаем и сами делимся практиками, наблюдаем и используем разные подходы и приёмы проектирования. Этим практическим опытом я и поделюсь в статье. Представим, что нам нужно спроектировать систему полностью с нуля. Уже есть предварительные требования, проведен системный и бизнес анализ. Что делать дальше? Как определить, сколько сервисов нужно и какими они должны быть.  Тема микросервисов уже достаточно старая, ей больше 10 лет точно. За это время появилось много статей и книг по теории. Я попробовал обобщить всю теорию, и у меня получилось выделить 4 принципа идеального микросервиса:  Единственность ответственности. Это принцип из SOLID для проектирования классов. Он означает, что у одного микросервиса должна быть одна ответственность, а также что за одну ответственность отвечает ровно один микросервис. Ограниченный контекст. Этот термин из Domain-Driven Design Эванса и его последователей. С его перекладыванием на микросервисы уже возникают, на мой взгляд, некие сложности. Напомню, что ограниченный контекст — это набор ограниченного смысла или ограниченной модели, который отвечает за некую бизнес-логику.Ловушка в том, что при переходе на микросервисы и их проектировании есть соблазн выделить ограниченные контексты и сказать, что один контекст — это один сервис. На мой взгляд, это не так, по крайней мере, не всегда. К примеру, гайдлайн от Microsoft по проектированию микросервисной архитектуры утверждает, что вторым шагом мы должны определить bounded contexts, и спроектировать наполнение каждого из них. То есть внутри ограниченной модели определить сущности, агрегаты и микросервисы. Несмотря на то, что смысловой периметр логики и данных ограничен, нам нужно в процессе работы контекста, к примеру, сформировать сущность, её сохранить, обсчитать и на что-то ещё и смаппить. Как правило, это делает не один сервис, а целая пачка микросервисов с отдельными ответственностями. Связанность и связность. Рассмотрим два варианта взаимодействия контекстов: Сверху слева мы видим низкий coupling (низкую связанность) и высокий cohesion (высокую связность или прочность). В такой архитектуре мы получаем прочные контексты, слабо связанные с друг с другом. Второй вариант хуже — контексты рыхлые, у них низкая прочность внутри, и они лапшеобразно связаны друг с другом, то есть высокая связанность снаружи. Более предпочтителен первый вариант. Принципы проектирования пакетов/сборок. Одновременно с принципами 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 сервиса, каждый из которых будет отвечать за свой тип доставки. Если вы так сделали и у вас всё работает, ничего не трогайте!  Но с развитием проекта, могут появляться тревожные сигналы, что что-то идёт не так: Повторы или циклы в трассировках.  Рассмотрим частый сценарий, когда мы хотим на витрине показать все наши пункты выдачи заказов на карте, и туда же добавить магазины, чтобы на одной карте, на одном экране видеть все варианты пунктов выдачи. Как правило, интернет-магазины показывают и пункт выдачи, и дату доставки для каждого.Например, чтобы посчитать время доставки, каждый из наших сервисов (типов доставки) должен знать время комплектации — когда товар соберут, когда его можно забрать со склада и когда его доставят в пункт. Может оказаться, что расчет времени комплектации при обращении к сервису расчета комплектации произойдет два раза, то есть мы просто два раза произведем один и тот же расчет, что не очень хорошо. Такие повторы легко увидеть в трассировке запросов (например, в Jaeger) — мы просто смотрим трассу и видим, что повторяется один и тот же сервис, один и тот же endpoint. Сложнее отследить повторы и дубли в данных: некритично если это небольшой JSON, но если у нас мегабайтные JSON передаются по цепочке трассировки из 5-го сервиса в 4-й, из 4-го во 2-й, из 2-го в 1-й, а на самом деле они нужны только в одном месте — это явно проблема.Кстати, в трассировках могут быть даже циклы — мы пошли в первый сервис, второй, в третий, потом снова в первый. Это уже совсем тревожный звоночек, что пора всё рефакторить. Нарушение инкапсуляции и избыточность. Представим другой сценарий, что один из потребителей нашего API хочет показывать минимальный срок доставки — например, “доставка от 2 дней”. Он должен знать про все три типа доставки, сходить во все три сервиса и вычислить минимальное из времени доставки, которое там есть. То есть небольшому микрофронтенду (отвечающему только за одну цифру минимального срока доставки) придется знать про огромные сервисы и про богатое API, которые они предоставляют, включая API по стоимости, списку ПВЗ и т.д., а ведь ему нужен совсем маленький кусочек — минимальное время доставки. Таким образом, у нас появляется избыточность интерфейсов, а в худшем случае — избыточность данных. К примеру, мы посчитали всё время доставки, отдали 10-мегабайтный JSON на тысячу пунктов выдачи, а нашему консьюмеру нужна была только одна цифра, потому что минимальное время — это 2 дня. Такая избыточность приводит не только к накладным расходам на сеть и процессорное время, но и в целом делает систему более хрупкой, т.к. позволяет использовать данные и контракты не по прямому назначению. Дублирование. С ростом нашего проекта в сервисах, скорее всего, дублироваться начнут ответственности. В каждом из наших сервисов, к примеру, появится расчет стоимости и расчет времени, про который мы уже говорили. Соответственно, нашим сервисам потребуется дублировать и интерфейсы (контракты API), которые отвечают за стоимость и время. Может появиться даже дублирование кода. От него, конечно, легко избавиться при помощи выноса логики в общий пакет или сборку, но с точки зрения архитектуры — это все равно дублирование, т.к. мы один и тот же функционал используем в разных сервисах. Мы обсудили три звоночка о слишком крупном проектировании сервисов, которые наиболее часто встречаются и которые можно отследить. Теперь рассмотрим плюсы и минусы варианта архитектуры с более крупными сервисами. К преимуществам крупно нарезанной системы в первую очередь можно отнести простоту и более низкие требования к инфраструктуре, разработке и тестированию. Эти преимущества вытекают из того, что подход более классический, похожий на монолит. Например, систему с небольшим количеством сервисов можно локально развернуть на компьютере разработчика или QA, инфраструктура не требует полной автоматизации (какие-то ручные действия можно и повторить, если это не десятки сервисов) и т.д. Недостатки крупно нарезанной системы:  Повышенные риски техдолга для комплексных фич и интеграций. В нашем примере рассмотрим сценарий, когда нужно рассчитать оптимальное соотношение стоимости и времени доставки. Такой расчет придется или дублировать во всех сервисах каждого типа доставки, или создавать отдельные потребители соответствующего API каждого из сервисов. Как и комплексные фичи комплексные интеграции, скорее всего, также приведут к техдолгу: если у нас было два доставщика, и мы решили добавить ещё парочку, то нам придется опять обращаться ко всем сервисам и каким-то образом реализовать интеграцию в каждом. Сложнее обеспечить гибкую отказоустойчивость, быстродействие и масштабирование. Пожалуй, это главный и часто упоминаемый минус монолитов, также свойственный и крупным микросервисам. Теперь рассмотрим тот же самый пример системы, но когда в архитектуре мы нарезали сервисы, наоборот, мельче чем нужно. В этом варианте нарезки мы оттолкнемся не от типа доставки, а от разных ограниченных контекстов — например, контекстов стоимости доставки, срока доставки и контекста пунктов выдачи. В контекст стоимости добавим два микросервиса с разным расчетом стоимости доставки для первого и второго доставщиков. Точно также поступим с контекстом срока, т.к. алгоритмы расчёта срока тоже могут разниться между разными службами доставки. В контексте пунктов выдачи — выделим мастер-данные со списком ПВЗ, доступных в каждом городе, и (зачем-то) отдельно выделим время работы этих пунктов. Посмотрим теперь в таком варианте, какие могут быть тревожные сигналы о слишком мелкой нарезке, и на что можно обратить внимание: Максимальная сцепленность (cohesion). Максимальная сцепленность (или неотделимость) может быть по данным и по ответственностям. В нашем примере — мы понимаем, что отдельно время работы пунктов выдачи нам вообще ни в одном сценарии не требуется: всегда, когда мы загружаем список пунктов выдачи, должны сразу подтягивать туда и время их работы. Нет такого сценария, что мы отдельно берем список без времени либо, наоборот, берем время без списка. Это и есть максимальная сцепленность — настолько прочны эти атомы-микросервисы, что могут слиться в одну молекулу и нет смысла их дробить. Нарушение инкапсуляции. Следующий сигнал — это опять нарушение инкапсуляции (как и в случае с крупными сервисами), но уже с другой точки зрения. Тут нарушается инкапсуляция контекста либо сервиса.Напомню, в нашем примере есть контексты стоимости и срока доставки. Если мы хотим добавить третью-четвертую-пятую службу доставки, то нам придется в каждый из этих контекстов добавлять микросервисы. Проблема не в том, что функционал расширяется — это нормально. А проблема в том, что мы добавляем сервисы в нескольких контекстах. Т.е. одна причина — добавление службы доставки — требует изменения не в одном, а в нескольких контекстах. Таким образом нарушается инкапсуляция и единственность ответственности на уровне ограниченных контекстов сервисов. Линейный рост инфраструктурных работ. Если мы достаточно мелко нарезали систему, то сервисов к нас получилось много. И мы можем заметить, что с увеличением числа сервисов линейно растет инфраструктурная ручная работа и нагрузка на девопсов. И рано или поздно у нас остается только два варианта: либо нам необходимо повысить уровень инфраструктуры и автоматизации, либо — признать, что пока уровень инфраструктуры не тянет нашу архитектуру, и нужно отказываться от мелкой гранулярности и переходить на более крупную. Иначе мы не сможем вечно добавлять сервисы и просто упремся в наш DevOps.Подробно о повышении уровня инфраструктуры я рассказываю в этом докладе: Как снизить накладные расходы на добавление +1 микросервиса / Руслан Сафин К преимуществам мелконарезанной системы, помимо стандартных плюсов микросервисов в гибкости работы с отказоустойчивостью и масштабированием, я бы отнес гибкую работу с техдолгом на уровне архитектуры. Когда сам сервис совсем небольшой, буквально 10-20-50 строк кода, которые отвечают только за бизнес-логику, вряд ли мы накопим большой технический долг на уровне кода. Техдолг перетекает на уровень архитектуры, и уже там мы можем вынести новый сервис, переписать сервис заново, либо объединить три сервиса в один и т.д.. Такая работа с техдолгом более гибкая, простая и, как правило, команды проще за это берутся т.к. не нужно переписывать и рефакторить огромные куски кода — можно что-то небольшое просто отключить и заменить новым. Недостатки мелконарезанной системы Выше требования ко всем этапам разработки: проектированию, инфраструктуре и разработке, тестированию, деплою и поддержке Выше риски избыточной работы. Достаточно высокий шанс, что мы где-то будем over-engineer’ить. Если достаточно мелко нарезать сервисы, то часть фич нам будет сложно реализовать — например, для выкатки небольшого нового функционала придется деплоить по 5 сервисов за раз. Мы рассмотрели два противоположных примера. Но вопрос остался открытым — как же всё-таки спроектировать “правильно”? Я постарался сформулировать 4 общих принципа, которые помогут приблизиться к балансу при нарезке микросервисов. В детали каждого из них можно погружаться долго и глубоко (например, под мастер-данными понимаю подход, описанный в статье Управление мастер-данными в микросервисной архитектуре), пока попробуем их назвать кратко. Выделение корней агрегации согласно домену и контексту видимости. Как правило, многие выделяют корни агрегации, но часто забывают про контекст видимости. Например, есть контекст, который рассчитывает клиентскую стоимость доставки, но нам ещё нужна себестоимость доставки для интернет-магазина. Скорее всего, у этих стоимости и себестоимости будут разные контексты видимости: стоимость нужна для показа клиенту на витрине; а себестоимость не интересует сервисы, которые отвечают за витрину. Себестоимость нужна для аналитических систем и внутренней кухни (например, проверки выставленного счета службой доставки), в которых, скорее всего, клиентская стоимость уже не так важна. Если у корней агрегации разные контексты видимости, то советую разделять их на два агрегата. Отделение бизнес-логики от данных и состояния. Это принцип из классического программирования. Если сервис является мастер-системой, то он отвечает за хранение данных, их обновление и предоставление доступа к чтению. Не нужно в тот же сервис вмещать бизнес-логику, лучше её вынести в отдельный микросервис.  Сокрытие костылей нюансов интеграции на периметре, так называемый anti corruption layer. Наверняка у многих были ситуации, когда мы интегрируемся с внешней системой, и нам приходится писать какие-то «if’чики»: если external ID = 108, то сделай это. Эту ID = 108 лучше не пропускать внутрь периметра нашей системы. Давайте ее обработаем где-то на входе в периметр и забудем. Т.е. если ID = 108, то мы идем по такому-то бизнес-процессу, и никакие сервисы внутри нашего периметра не знают про этот костыль с магической айдишкой. Разделение BFF согласно архитектуре фронтенда. Этот принцип также касается организации периметра нашей системы. Периметр — это или интеграция с внешними системами, или с пользователем (взаимодействие с нашим UI-интерфейсом). Если у нас микрофронтенды на UI, то хорошо сработает вариант, когда наши BFF-сервисы (Backend-for-Frontend) повторяют архитектуру на фронтенде. Т.е. под каждый микрофронт — свой BFF. Другой пример — это отдельные BFF для мобильных приложений, десктоп-приложений и так далее. В любом случае, советую делить BFF для крупных систем, чтобы не выставлять всё развесистое API в одном единственном BFF. Если попробовать применить эти принципы к нашему примеру, то получится такой вариант: Выделяем три контекста: Интеграция со службами доставки. В данный контекст мы можем добавлять сколько угодно сервисов для каждой службы доставки. Допустим в приведенном примере нам необходимо два разных сервиса для стоимости и сроков. Главное, что при добавлении одной службы доставки добавятся несколько новых микросервисов только в один этот контекст.  Расчет стоимости. В этом контексте есть общий расчет стоимости доставки (допустим, междугородней Москва-Самара), а уже в доставке по Самаре могут быть варианты: или мы рассчитываем стоимость доставки до пункта выдачи, или нужно дополнительно включить услуги подъема на этаж, сборку и т.д. То есть внутри контекста стоимости мы уже выделяем отдельно расчет общей доставки для разных типов и отдельно расчет услуг, допустим, для курьерской доставки. Расчет сроков. Здесь ситуация аналогична расчету стоимости — есть общий расчет срока доставки и специфика для конкретного типа, например, расчет доступных временных слотов доставки для курьеров.  Запроектированная архитектура и выбор целесообразного способа разделения на микросервисы, не должны быть статичны. Я на самом деле удивился, что даже в российских ГОСТах черным по белому написано, что архитектура изменяема во всем жизненном цикле проекта. Об этом же говорит Эрик Эванс в докладе. Там он представляет слайд, который, на мой взгляд, заслуживает внимания: https://www.domainlanguage.com/ddd/whirlpool/ Если коротко изложить идею, то есть поток сценариев, который приносит аналитический отдел. Этот поток запускает вихри моделирования и проектирования — в том числе моделирование данных и проектирование архитектуры. В свою очередь этот процесс запускает третий вихрь — пробу кода. Мы пробуем это реализовать, и в определенный момент все эти вихри сходятся в одной точке. А дальше мы смотрим — подошли ли это моделирование, проектирование и проба реализации или нет. Эрик Эванс говорит про этот подход в докладе и противопоставляет его водопаду. Вернемся к нашей базовой проблеме — мы подошли к новому проекту и не знаем, с чего начать. Я предлагаю следующий список того, что нужно определить и прозрачно зафиксировать для старта проектирования: Мастер-данные, то есть агрегаты. Ответственности и намерения. Особенно важны намерения. Когда говорят про классы в ООП, про ответственности обычно помнят, а про намерения, на мой взгляд, незаслуженно забывают. Если в целом класс — это ответственность, то намерение — это, скорее, метод класса или функция. У сервисов точно также — есть базовая общая ответственность, за которую он отвечает, и есть REST API с набором методов-намерений. Например, намерение определенного endpoint’а — расчет минимальной стоимости доставки или расчет доставки Москва-Петербург.  Внешние влияющие факторы. Всегда есть внешние факторы, влияющие на разработку — сроки, бюджет, опыт команды, уровень инфраструктуры и т.д. Нужно честно себе в них признаться и не обманывать себя, иначе это повредит проекту. Лучше визуализировать  и рассказать о них всей команде и бизнесу. Для проверки того или иного вариантов разделения на микросервисы, я советую использовать следующий список контрольных вопросов, помогающий понять насколько крупно, мелко или нормально мы нарезали сервисы.  Что будет, если мы раздробим/попарно объединим мастер-данные? Мы произвели разбивку системы на несколько сервисов, отвечающих за те или иные мастер-данные, и теперь попробуем челленджить решение с помощью этого вопроса. Что будет, если мы преобразуем ответственность (микросервис) к намерению (endpoint) более крупного сервиса и наоборот? Например, у нас есть сервис, рассчитывающий стоимость доставки с endpoint’ом, который считает минимальную стоимость доставки. Что будет, если этот endpoint вынести в отдельный микросервис расчета минимальной стоимости? Чтобы узнать это, мы визуализируем архитектуру и проверяем её нашими принципами (coupling, cohesion и др.) и бизнес-сценариями. И наоборот, если мы сразу выделили микросервис для расчета минимальной стоимости, то выясняем, что будет, если преобразовать этот микросервис к endpoint’у более крупного сервиса с более крупной ответственностью? Насколько влияют на разработку внешние факторы? Например, каковы: наш уровень инфраструктуры, готовность нашего DevOps к большому или малому количеству сервисов, квалификация и опыт команд. Если команда всю жизнь писала монолиты, то сложно сразу перейти на систему, когда один человек разрабатывает 5-10 сервисов, лучше начать с небольшого числа сервисов на человека. Мы рассмотрели теорию «идеального» микросервиса, попробовали выделить ее основные моменты (объять необъятное 🙂). Разобрали варианты проектирования системы для конкретной задачи: крупнее целесообразного, мельче целесообразного и целесообразный. Выделили три чек-листа: Принципы разделения, на что нужно обращать внимание; Факторы-пререквизиты, которые при этом нужно учитывать; Контрольные вопросы для проверки, крупно мы нарезали, мелко или идеально. На DevOpsConf 2023 будет трек полезный техлидам и много других актуальных кейсов. Пока не закрыт приём заявок. Ещё 3 дня, до 1 декабря включительно, можно подать свой доклад на выступление на конференции. Ждём ваши предложения и заявки на участие.  CTO, IT Архитектор