Как провести успешный переход от архитектуры микросервисов к монолитной?
Многие разработчики предпочитают микросервисы монолитам, руководствуясь доступностью, легкостью масштабирования и пригодностью для высоких нагрузок. Высоконагруженные проекты, основанные на микросервисах, часто становятся объектами внимания статей и конференций.
Однако, в нашем опыте, мы столкнулись с ситуацией, когда такой подход не подходил. Это касалось крупного заказчика — PetTech-сервиса. Изначально проект планировался как максимально гибкий, с целью быстро реализовать сервис и проверить гипотезы. Требовалось внедрить идеи быстрого роста и разнообразные функции. Однако, по мере изменения требований и переориентации бизнеса, стартап превратился в продукт.
С течением времени стало ясно, что микросервисы не приносят существенных преимуществ, но их недостатки сказываются на качестве и скорости разработки. Это привело нас к пересмотру и возврату к монолитной архитектуре, о чем и рассказано в данной статье.
Почему микросервисы
Микросервисный подход возник в результате объединения двух полностью отличных систем — двух различных веб-сайтов. Первоначально у заказчика не было замысла создавать единую платформу. Обе системы служили разным целям и функционировали независимо друг от друга. Эти системы включают в себя:
- Информационный сайт, содержащий статьи о домашних питомцах, и мобильное приложение, предназначенное для доступа к этим статьям.
- CRM-система управления расписанием (СУР) ветеринарных клиник, созданная для автоматизации операций в клиниках.
Объединение этих систем в один микросервисный сервис не только обеспечило более эффективное взаимодействие, но и создало более единый и гармоничный опыт для пользователей.
Однако вскоре у бизнес-заказчика возникла гипотеза: возможно, эти системы могут взаимодействовать друг с другом, взаимно увеличивая трафик. В это время перед нами впервые поставили задачу объединить обе системы в один сервис.
Обе системы были полностью готовы и эффективно функционировали, поэтому мы приняли решение настроить их взаимодействие. Таким образом, мы заложили основу микросервисной архитектуры.
Развитие микросервисов
Поскольку проект изначально задумывался как стартап с целью изучения рынка и проверки гипотез, на начальном этапе отсутствовала четкая стратегия развития. Микросервисы отлично подходили для таких неопределенных задач, поскольку их ограниченная логика и индивидуальные сервисы успешно справлялись с гипотезами, а также выполняли свои функции.
В какой-то момент проверка гипотез привела к тому, что стартап эволюционировал в самостоятельный продукт с собственной командой и миссией. Сменился подход к проекту, а также изменились поставленные задачи. Одной из них было расширение функционала CRM, чтобы пользователи могли записываться на приемы в ветеринарные клиники.
Логика заключалась в том, что новостной сайт (первая система) пользовался популярностью, и с его помощью можно было генерировать трафик для клиник (вторая система). Обычные пользовательские данные хранились в базе знаний, а информация о клиниках и их расписании — в СУР. Пришлось решить, как совместить эти фрагменты. Было понятно, что трафик с новостного сайта будет значительным, и в этом контексте микросервисы также оказались полезными, поскольку их было проще масштабировать.
Оптимизировать всю систему путем рефакторинга в одну требовало полной переработки одного из сервисов, но на тот момент не было ясности о том, насколько это необходимо, и каким образом продукт будет развиваться.
Вместо этого был выбран более быстрый вариант: установление двустороннего взаимодействия через REST. Все данные, связанные с записями, складывались в СУР, а информация о новостях и мобильном приложении — в базу знаний. Процесс передачи данных также был довольно прост: было создано несколько вариантов ответов (minimal, short, maximum), и запрашивался нужный в зависимости от ситуации.
- minimal:
public function getMinimalApiView(Partner $partner): array
{
return [
'id' => $partner->getId(),
'name' => $partner->getName(),
];
}
- short:
public function getShortApiView(Partner $partner): array
{
$contacts = $this->getContacts($partner);
return [
'id' => $partner->getId(),
'name' => $partner->getName(),
'contacts' => $contacts ? $contacts : [],
'city' => ($partner->getCity()) ? $this->viewService->getApiView($partner->getCity()) : null,
'internet' => $partner->getInternet() ? $partner->getInternet() : [],
];
}
- maximum:
public function getApiView(Partner $partner): array
{
$logo = $partner->getLogo();
$contacts = $this->getContacts($partner);
$facilitiesCollection = new ApiViewCollection();
if ($partner->getFacilities()) {
foreach ($partner->getFacilities() as $facilityLink) {
$facilitiesCollection->add($facilityLink->getFacility());
}
}
$description = $partner->getDescription();
if (!$description) {
$description = new PartnerDescription();
}
return [
'id' => $partner->getId(),
'name' => $partner->getName(),
'fullName' => $partner->getFullName(),
'logo' => $logo ? $this->viewService->getApiView($partner->getLogo()) : null,
'contacts' => $contacts ? $contacts : [],
'specialization' => $this->viewService->getApiView($this->getSpecializationCollection($partner)),
'facilities' => $this->viewService->getApiView($facilitiesCollection),
'description' => $this->viewService->getApiView($description),
'schedule' => $this->viewService->getApiView(
$partner->getSchedule() ?
new ApiViewCollection($partner->getSchedule()) : new ApiViewCollection()
),
'internet' => $partner->getInternet() ? $partner->getInternet() : [],
'isApproved' => $partner->getIsApproved(),
'city' => ($partner->getCity()) ? $this->viewService->getApiView($partner->getCity()) : null,
'isOpen' => $partner->isOpen(),
'created' => $partner->getCreated()->format(DateTime::ISO8601),
'updated' => $partner->getUpdated()->format(DateTime::ISO8601),
'acceptsCustomTime' => $partner->isAcceptsCustomTime(),
'lat' => $partner->getLat(),
'lng' => $partner->getLng(),
'moderationStatus' => $partner->getModerationStatus() ? $partner->getModerationStatus() : ModerationStatus::STATUS_NEW_PROFILE,
];
}
Другими словами, мы создавали разнообразные уровни представлений (view) для API, каждый с разной степенью детализации для определенных действий. Когда нам требовалось получить определенные данные (например, идентификатор), мы создавали минимальное API_view. При необходимости более полной информации, мы добавляли краткое (например, при введении данных о контакте и городе), и если возникала необходимость в дополнительном уровне, мы просто расширяли набор полей. В результате у нас получались контроллеры, которые сущностью отдавали набор полей, необходимых для конкретного действия.
Однако в какой-то момент столкнувшись с ростом сложности, мы начали создавать различные варианты, такие как extendedApiView. Учитывая, что у каждой связанной сущности были свои уровни представлений (View), это привело к значительной путанице и усложнению системы.
public function getExtendedApiView(Partner $partner): array
{
$apiView = $this->getApiView($partner);
$apiView['equipment'] = $this->viewService->getApiView($this->getEquipmentCollection($partner));
$apiView['services'] = $this->viewService->getApiView($partner->getServices());
/** @var SpecialistRepository $specialistRepository */
$specialistRepository = $this->doctrine->getRepository(Specialist::class);
$specialists = $specialistRepository->findByPartnerId($partner->getId());
$apiView['specialists'] = $this->viewService->getApiView($specialists);
return $apiView;
}
Бум микросервисов
По результатам этой работы у нас уже было два взаимосвязанных сервиса, каждый из которых содержал данные пользователей (логины, пароли, ФИО, контакты и прочее). Изменения в одном из сервисов требовали синхронного обновления данных в другом через API. Это привело к появлению ошибок синхронизации, делая управление системой сложным.
Мы осознали неудобство такого способа хранения пользовательских данных. Поскольку полный рефакторинг обеих систем в одну казался нецелесообразным, мы приняли решение вынести пользователей в отдельный микросервис, ответственный исключительно за хранение и авторизацию пользователей. Так появился User API.
С течением времени этот сервис стал расширяться, появлялись новые возможности и функциональности. Мы продолжили выделять их в отдельные сервисы:
- аналитика;
- маркетинг;
- оплата;
- промокоды;
- уведомления;
- хранилище файлов;
- сервис для онлайн-консультаций и т. д.
На этом этапе мы уже приняли микросервисную архитектуру как основу проекта. Во-первых, потому что она успешно функционировала. Во-вторых, поскольку наша команда разрасталась вместе с проектом, мы пришли к простой стратегии: каждый разработчик отвечает за один микросервис. Это решение не только обеспечивало эффективное управление кодовой базой, но и позволяло быстро проверять бизнес-теории и избавляться от неподходящих решений.
Попытка стандартизировать микросервисы
С проектом происходило стремительное развитие, и количество пользователей постоянно увеличивалось. Поэтому при разработке новых микросервисов мы активно обращали внимание на их недостатки.
С появлением взаимодействия между сервисами возникали проблемы согласованности и идемпотентности. Возникала необходимость в клиентах для обеспечения коммуникации, общих пагинаторах, форматтерах, логгерах и так далее. Возникала общая логика обработки данных и другие аспекты.
Любое изменение или добавление функционала требовало внесения изменений во все микросервисы. Например, при внедрении системы оплаты, мы должны были добавить запросы для добавления карты в микросервис мобильного API — запросы типа Request-Response. Затем мы точно такие же Request-Response добавляли в billing-API. После этого эти же запросы нужно было внедрить в Gateway’и, связанные с CloudPayments. После успешного добавления карты данные запросы требовалось передать обратно. И, наконец, эти же запросы нужно было выполнить в сервисе онлайн-консультаций (телевете), чтобы указать, какую карту использовать при оплате. Каждый из адаптеров требовал внесения изменений для обработки этих запросов.
После внимательного рассмотрения возможных вариантов решения данных проблем, мы пришли к идее создания SDK — набора бандлов для взаимодействия с каждым из микросервисов. Мы выделили общие компоненты, такие как форматтеры логов, базовые клиенты, интерфейсы, в отдельный CoreBundle с целью уменьшить дублирование кода и повысить единообразие в проекте.
В дополнение к выносу дублирующего кода, мы также провели стандартизацию запросов и ответов в SDK. В нем содержались сущности, которые каждый из микросервисов использовал при ответе и аналогично получал при запросе в другом микросервисе. В качестве примера, при создании и обновлении плана заботы в SDK, использовались две сущности запросов:
public function createSubscriptionFromPet(
int $petId,
TelevetApiAdapterBundleRequestCatkitCreateSubscriptionFromPetRequest $request
): SubscriptionResponse {
return $this->request(
(new HttpRequestContext())
->setUrl(sprintf('somepath/catkit/subscription/pet/%s', $petId))
->setMethod(Request::METHOD_POST)
->setRequest($request)
->setDecodeResponseTo(SubscriptionResponse::class)
);
}
public function updateSubscription(
int $subscriptionId,
TelevetApiAdapterBundleRequestCatkitSubscriptionRequest $request
): SubscriptionResponse {
return $this->request(
(new HttpRequestContext())
->setUrl(sprintf('somepath/catkit/subscription/%s', $subscriptionId))
->setMethod(Request::METHOD_PATCH)
->setRequest($request)
->setDecodeResponseTo(SubscriptionResponse::class)
);
}
Эти же сущности определённые микросервисы использовали в качестве базовых при получении запроса:
public function createSubscriptionFromPetAction(
int $petId,
TelevetApiAdapterBundleRequestCatkitCreateSubscriptionFromPetRequest $request,
PetService $petService,
SubscriptionManager $subscriptionManager
): JsonResponse {
$pet = $petService->getPetById($petId);
if (!$pet) {
throw new NotFoundHttpException('Pet is not found.');
}
И при подготовке ответа:
$subscription = $subscriptionManager->create(
(new CreateSubscriptionParametersDto())
->setStep($request->getStep())
->setPet($pet)
->setPlatform(EnumPlatformType::MOBILE)
);
return $this->okResponse($this->subscriptionToResponseConverter->convert($subscription));
}
class SubscriptionToSubscriptionResponseConverter
{
public function convert(
Subscription $subscription
): TelevetApiAdapterBundleResponseCatkitSubscriptionResponse {
$this->logger->debug(sprintf('Converting Subscription %s to SubscriptionResponse', $subscription->getId()));
$compilationsResponses = $this->getCompilationResponses($subscription);
$summaryPackagesDryFood = $this->getSummaryPackagesDryFood($subscription);
.....
Как пришли к монолиту
Новый подход с единым SDK оказался удобным, но сопряжен с некоторыми минусами.
Сроки реализации многих функций оказались достаточно жесткими. С появлением каждого нового сервиса мы расширяли SDK, внедряли общие паттерны. Однако старые сервисы оставались неизменными и требовали рефакторинга, на который не всегда хватало времени. Это привело к проблемам со стандартизацией запросов, поскольку новые сервисы работали по новым стандартам, в то время как старые оставались прежними.
Кроме того, нагрузка на наш сервис оказалась не такой высокой, как ожидалось. За все время работы проекта нам не потребовалось более одного сервиса для каждого из микросервисов.
Возможность использовать различные технологии для микросервисов также оказалась малополезной. Идеи сложных вычислений и Highload-сервисов были отклонены, что устраняло необходимость в более «выносливых» языках программирования, таких как Java и Golang. Мы остановились на одном стеке технологий.
Еще одним существенным фактором стал вопрос о DevOps. У нас не было Kubernetes или Docker Swarm, что усложняло процессы CI/CD. Поддержка инфраструктуры требовала значительных временных и трудовых затрат.
Несмотря на продолжительный период существования проекта, мы все еще сохраняли стартап-мышление в бизнесе. Это означало, что разработка микросервисов требовала быстрого выполнения, проверки гипотез, и принятия решения о дальнейшем развитии или удалении функционала. В то же время, уже установившийся функционал приходилось постоянно рефакторить в таком режиме.
На определенном этапе мы осознали, что изначально микросервисы не полностью соответствовали нашим потребностям. В конечном итоге мы приняли решение о том, что основные функции, которые концептуально связаны между собой, не имеют смысла разделять. Во-первых, это было неудобно с точки зрения разработки. Во-вторых, управление всем в одном месте обеспечивало более эффективную работу.
Начали реализовать монолит
Мы взяли одно из наших API, которое, как представлялось, было наиболее сложным для переписывания, и решили попытаться объединить в нем какой-то функционал.
Для решения этой задачи мы выбрали стандартную структуру Symfony и начали внедрять в нее все остальные папки и дополнительный функционал. Однако возник вопрос по структурированию, в частности, где располагать функционал, ответственный за ту или иную фичу. Например, в случае с папкой «сервисы» было не совсем ясно, что именно относится к какому сервису, где происходит простое разделение функционала, а где располагаются отдельные фичи:
Ситуация стала запутанной. Было неясно, какая часть функционала относится к одной части, а какая к другой. Появились очень похожие классы и сущности, и когда мы пытались вынести их в общие неймспейсы, это только усугубило путаницу.
Для примера, у нас были различные виды подписок, такие как подписка на доставку корма, страховку, консультации и так далее. При попытке объединить их в один неймспейс Entity становилось трудно четко отличить один вид подписки от другого. Учитывая, что в корне неймспейса находились сущности базового сервиса, ситуация становилась еще более запутанной.
Понимая, что текущий подход смешивания функционала микросервисов в рамках монолита приводит к ошибкам, мы решили найти альтернативный вариант, который позволил бы нам объединить нужные сервисы без необходимости полного переписывания всего. В ходе поиска решения мы обратили внимание на гейтвеи и доменные области. Они предоставили возможность интегрировать все компоненты без радикального рефакторинга, решив проблему с многофункциональными контроллерами (отвечающими за множество вещей одновременно) и создав базу для безболезненного перехода с Symfony 3 на Symfony 4 и последующих версий.
Мы выделили отдельный неймспейс, где организовали свой уникальный функционал, аналогичный микросервисам, но в рамках монолита. Помимо обычных неймспейсов с функционалом, у нас появились два специализированных: General и Gateway. General отвечал за общие части, используемые во всем проекте — базовые типы и расширения Doctrine, расширения сериализатора, обработку логов, обработку исключений и т.д. Gateway занимался взаимодействием с фронтенд-частью: мобильным приложением, вебом, различными встраиваемыми виджетами.
По сути, мы получили набор классов — ядро проекта в виде доменных областей, и отдельно — контроллеры, сущности, конвертеры, ошибки и т. д. Это позволяло очень гибко и независимо от других фронтов менять поведение системы, уменьшить размеры запросов и ответов, оставив только необходимые поля, отделить представление от базовой логики всего приложения.
Какие сложности
Однако этот подход имеет свои недостатки, среди которых высокий уровень дублирования кода. Например, среди функций сервиса были консультации и возможность умного подбора корма для домашних животных. Обе функции одинаково функционировали как в мобильной, так и в веб-версии. Сущности, используемые в этих функциях, были практически идентичны, поскольку мы по-прежнему должны были внедрять новые функции и иметь возможность быстро проверять их. Любая функция могла быть запущена как минимально жизнеспособный продукт (MVP), а затем претерпевать изменения до полной переработки или полного отказа от нее. Для достижения такой гибкости приходилось платить дублированием кода. Однако у этого подхода есть и большой плюс: изменения в веб-версии не затрагивали мобильное приложение и наоборот.
Почему гибкость была для нас важна? У нас была функция умного подбора корма, которая выполняла три основные задачи:
- Обновление плана ухода за питомцем пошагово, заполняя его данные.
- Генерация предложения на основе заполненных данных.
- Выбор предложения и оплата этого плана ухода.
Когда мы приступили к созданию административной части для умного подбора корма, столкнулись с тем, что бизнес-заказчик также выразил желание редактировать предложение в админке, учитывая возможность отсутствия некоторых товаров. Это потребовало от нас использования тех же методов для реализации административной части. Поскольку у администратора было больше возможностей по сравнению с мобильным приложением, пришлось внедрять различные улучшения, добавляя новые поля. Однако такой подход негативно сказывался на читаемости и ясности кода, а также приводил к появлению странных ошибок.
Существуют и другие недостатки, помимо дублирования кода. После изменений в фронтенде нам приходится вносить изменения и в бэкенд. Например, в сервисе была функция покупки консультаций в пакете. Изначально у нас был один формат, но дизайнеры изменили его, добавив новые поля. Это привело к необходимости создания нового контроллера, модификации сущностей ответов и конвертеров. Следовательно, практически любое изменение во фронтенде, касающееся структуры данных, требовало корректировок в бэкенде. Это является серьезным недостатком, и мы в настоящее время работаем над уменьшением дублирования кода и разработкой способов избавления от зависимости от клиентской части и дизайна.
К чему пришли
аким образом, мы перешли к гибридному подходу, где внутри монолита существуют микросервисы, представляющие доменные области отдельных функциональных частей проекта. Эти доменные области были объединены в рамках одного проекта для удобства работы.
В результате мы достигли гибридной архитектуры, где микросервисы продолжают существовать в пределах системы, где они наиболее эффективны. Например, сервисы аналитики, хранения файлов, уведомлений и авторизации успешно выполняют свои функции и удобны в использовании.
Однако ядро системы теперь представляет собой монолит. Мы не полностью отказались от идеи сервисной архитектуры, но подход к каждому сервису существенно изменился.
Преимущества такого подхода для разработки:
- Преимущества микросервисов в части системы, где они самобытны: Мы сохраняем преимущества микросервисов для тех областей, где они наиболее эффективны и логичны.
- Упрощение CI/CD и DevOps: Единый монолит упрощает процессы непрерывной интеграции и доставки, а также управление инфраструктурой.
- Управляемое ядро системы: Мы стабилизировали и стандартизировали логику в ядре системы, что облегчает ее управление.
- Экономия на ресурсах: Объединение внутри монолита позволяет более эффективно использовать ресурсы.
- Быстрая проверка гипотез: При этом мы сохраняем возможность быстро и качественно проверять гипотезы, что является важным аспектом разработки.
Такой гибридный подход объединяет преимущества обеих архитектурных концепций, обеспечивая баланс между гибкостью и управляемостью.
Что в итоге
Наш проект изначально не имел высокой нагрузки. Важным для бизнеса стало проведение множества экспериментов, что вызывало хаос как в сервисах, так и в логике взаимодействия. Функциональность могла появиться в продакшне, работать неделю, а затем исчезнуть. Расходы на микросервисы оказались значительными, и в ответ на это мы решительно приступили к грамотной инкапсуляции кода, что позволило спокойно интегрировать все теории и функции бизнеса в монолитной архитектуре.
Вместе с ограниченной гибкостью инфраструктуры, мы приняли нестандартное решение, которое оказалось весьма успешным. Монолит справился на отлично, а микросервисы стали надежной его поддержкой.
Следует понимать, что микросервисы не представляют собой универсального решения, способного снимать с вас все заботы и трудности. На самом деле, они сами могут стать источником головной боли. С их гибкостью и возможностью масштабирования в проекте возникает множество дополнительных проблем: проблемы согласованности данных, CAP-теоремы, сложности в поддержке, трассировка запросов, требования к инфраструктуре и многое другое.
Важно тщательно анализировать проект на раннем этапе. Даже если кажется, что микросервисы идеально подходят, иногда стоит начать с запуска минимально необходимого продукта на основе монолитной архитектуры.