Всем привет! Меня зовут Марсель Абдрахманов, я фронтлид в Банки.ру. В статье хочу поделиться нашей историей ухода от монолита к микрофронтендам. В Банки.ру большой отдел веб-разработки, за последнее время мы сильно выросли, ежедневно катим десятки релизов на прод. Расскажу, как мы относительно быстро внедрили микрофронтенды и перешли на архитектуру, которая позволила сократить время доставки обновлений на прод с двух дней до нескольких часов.
В 2020 году мы до сих пор сидели на монолите и это действительно было удобно: все разделы реализовывались однообразно, никакого зоопарка технологий, мы довольно точно планировали фичи и быстро их разрабатывали.
Но были и минусы, с которыми мы не готовы были больше мириться:
Релиз монолита раз в день по Git Flow: практически каждая задача вынуждена была ждать следующего дня. К тому же, команды разделились по бизнес вертикалям, напрашивался раздельный деплой разделов.
Тестировали задачи отдельно в ветках, но в ежедневном регрессе релизной ветки почти всегда находили баги, из-за чего останавливался весь релиз до исправления. Нередко релизы переносились на следующий день.
Единая точка отказа: если падал монолит, то недоступны были все страницы.
Код в монолите был сильно связан, приходилось делать регрессионный тест при любом изменении кодовой базы, потому что изменения в одном разделе регулярно приводили к багам в совершенно другом.
Разделение монолита
Итак, мы окончательно решили делить монолит. Чтобы сделать это в разумные сроки и не останавливать продуктовые задачи, решили использовать те же технологии и версии библиотек, что были в монолите. Не писали ничего нового, а просто выносили общий код во внутренние пакеты компании и откалывали от монолита страницу за страницей.
Процесс выглядел следующим образом:
создавали новый проект и переносили код по одной странице;
тестировали и плавно переключали трафик пользователей в новый проект;
после успешного переключения удаляли код страницы из монолита.
В результате мы не останавливали поток бизнес задач и постепенно заменяли микросервисами функциональные элементы монолита.
Общая схема маршрутизации выглядит примерно так. В главном Nginx балансере мы по урлам разруливаем, куда будет идти запрос. Например, на стенд проекта кредитов или на стенд проекта вкладов.
В монолите жили 10 команд, каждая получала в рамках циклов целеполагания вынос своих разделов из монолита. За два года нам удалось вынести все разделы.
Внутренние библиотеки и разделяемый код
В монолите мы хаотично переиспользовали много общего кода, поэтому при выносе разделов в отдельные репозитории создали внутренние библиотеки-пакеты (composer, npm), описали их документацией и перевели на Semantic Versioning Flow. Позже выпустим отдельную статью про то как организовываем, версионируем и публикуем наши библиотеки в Nexus.
Это была самая затратная часть подготовки к выезду из монолита. Но дальше было проще: каждый следующий раздел выносился быстрее и легче т.к. тропа уже была протоптана и основной общий код уже был в библиотеках.
По такой же схеме мы создавали отдельные сервисы и выносили туда общее API. Практика создания backend-микросервисов у нас в компании уже давно была освоена.
Бойлерплейт
Чтобы упростить создание новых сервисов и не заставлять команды проделывать одну и ту же работу, мы подготовили бойлерплейт-проект с инструкцией и чек листом для запуска нового сервиса. Сделали минимальное количество необходимых шагов для разворачивания проекта и настройку в едином стиле.
Git flow -> Github flow
В монолите мы релизили около 40 задач в день. Использовали, как и многие в индустрии, классический Git flow.
В отдельных (уже вынесенных из монолита) проектах поток выпуска задач был около 2-3 в день на один репозиторий/сервис. Мы поняли, что гораздо проще релизить их по отдельности, и решили упростить процесс. Выбор остановили на Github flow.
Итоги
Мы сразу получили профит от перехода на отдельные репозитории, которые смогли увидеть стейкхолдеры:
Time to Market значительно снизился. Мелкие задачи закрываем буквально за час: от постановки до доставки на прод.
Команды стали более гибкими, перестали зависеть друг от друга и тратить время на передачу контекста.
Получили возможность отдельно управлять версиями внутренних и внешних библиотек.
У команд появилась ответственность и экспертиза в эксплуатации и поддержке своих проектов.
Нет единой точки отказа. Если падает один сервис, на другой это не оказывает влияния.
Однако, где плюсы, там и минусы. Мы получили следующие:
Между проектами дублировались vendor-библиотеки (React например), общие виджеты (header, footer), так как статика у каждого своя. При переходе в другой раздел у пользователя она загружалась заново. Это негативно влияло на performance загрузки страницы (метрики web vitals).
Сложность обновления сквозного функционала/виджетов. Например: обновить во всех 30-40 репозиториях футер занимало у нас около пары месяцев.
Наблюдался небольшой dependency hell: много продуктового кода и виджетов лежало в npm-пакетах. Приходилось по цепочке обновлять их, чтобы доставить бизнес-фичу на прод.
Конечно, мы были не первыми, кто распиливал монолиты и решал сопутствующие проблемы, и в индустрии уже было хорошее решение: микрофронтенды.
Внедрение SSR на Node.js
Но пока отложу микрофронтенды — стоит отдельно предварительно рассказать про переход на серверный рендер на Node.js. Не буду останавливаться на том, зачем он вообще нужен для рендера фронтенда, тут всё очевидно. С разделением монолита мы могли пробовать переписывать отдельные страницы на Node.js.
На клиенте мы использовали React, UI-kit тоже был построен на React, поэтому было очевидным, что использовать на серверной стороне.
А вот в качестве веб сервера мы рассматривали разные фреимворки/библиотеки – Next.js, Nest.js, After.js, Fastify, Express. Провели эксперимент: реализовали по одной странице разных продуктов на этих фреимворках.
В Next.js (уточню, что на тот момент была 11 версия) нам не хватало гибкого роутинга, плюс не устраивала полная гидрация приложения и только react-way: любой html в код страницы приходилось вставлять через dangerouslySetInnerHTML. Next.js — коробочное решение, которое не обеспечивало нам гибкости в решении нестандартных задач.
Nest.js показался избыточным для наших нужд и при этом там ничего нет для clientside решений, той же гидрации например.
Так же были минусы и в других фреймворках. Например, в After.js сложно было расширять нюансы сборки т.к. там используется под капотом Ruzzle.
В результате, когда мы на практике опробовали разные библиотеки, появилось много частных требований. Поэтому в итоге мы написали свое решение на базе старого доброго проверенного Express. Он конечно же тоже не без минусов (например, сложно поддерживать мидлвары), но тот факт, что он низкоуровневый, позволил нам внедрить все наши хотелки и ограничения. Мы реализовывали только frontend, никакой работы с базой, с очередями, вся сложная бизнес логика была в backend API-сервисах или в BFF.
В итоге мы подготовили npm пакет @bankiru/nodejs-core
который содержал в себе:
сборку (CSR, SSR, SSG, watcher-ы)
веб-сервер, nodemon, pm2
контроллеры (PageController, JSONController, RedirectController)
логирование (Sentry, Kibana)
кубер пробы
модуль работы с memcached
api-client методы
различные общие реакт компоненты (Hydrate, LazyComponent, Banner, Layout)
Впоследствии наш @bankiru/nodejs-core
разъехался на много независимых компонентов-библиотек, но это уже совсем другая история.
Таким же образом мы подготовили boilerplate проект для Node.js приложений, который включал в себя всегда последнюю версию @bankiru/nodejs-core.
С разделением монолита мы активно стали переводить наши разделы с Symfony на Node.js.
Переход к микрофронтендам
Пропущу теоретическую сторону микрофронтендов, в сети полно статей на эту тему. Реализации различны, объединяет их то, что части страницы собираются во время realtime работы приложения и деплоить их можно по отдельности. Расскажу конкретно про наш вариант.
Итак, 2023 год, у нас есть остатки монолита, несколько уже вынесенных разделов на таком же стеке (Symfony/Twig/PHP) и несколько на новом Node.js стеке.
Что хотим:
Релизить сквозные виджеты по-отдельности и доставлять бизнес-ценности на прод быстрее.
Уменьшить дублирование статики между проектами и, соответственно, средний вес страницы для пользователя.
Избежать дублирования реализации виджетов (некоторые сквозные виджеты имели реализацию и на Twig и на React одновременно).
По опыту разделения монолита мы также пробуем найти быстрое решение на основе текущей кодовой базы и текущих потребностей.
Первым делом рассмотрели Webpack Module Federation. Но так как у нас далеко не весь код на React стеке, это решение не подошло.
Также нам не подходила классическая схема микрофронтенда с едиными Host и Remote приложениями:
У нас уже был монолит, разделенный на разные приложения и к единой точке отказа возвращаться не хотелось.
Еще мы рассматривали различные библиотеки (Podium, Single SPA), но вскоре поняли, что нам проще сделать своё решение т.к. у нас уже был базовый framework на node.js.
За контракт взаимодействия решили брать HTML. И в новые Node.js приложения, и в старые Symfony приложения будет встраиваться готовый HTML от сервисов-виджетов. Таким образом, итоговая страница будет состоять из кусков HTML от разных сервисов.
Общая статика
Основная особенность микрофронтенд-приложений — у них единое окружение (например, одна версия React). Ситуация, когда один виджет на React, другой на Angular, и команды абсолютно независимы, не наш случай. Мы переходили с монолита и у нас остался приблизительно единый стек, так что мы хотели воспользоваться этим плюсом.
Был подготовлен npm-пакет @bankiru/build-tools
со сборкой, которая добавляла в webpack externals общие библиотеки (react, react-dom) и подключили её ко всем сервисам в layout, чтобы окружение в браузере было одинаковым. Сами общие библиотеки, шрифты и прочую общую статику вынесли в отдельный сервис (common-static), который тоже публикуется отдельно по Github flow, сразу на CDN.
Серверная сторона
Сервисы виджеты-микрофронтенды имели такую же природу как и наши node.js приложения. Поэтому для них мы так же использовали наши наработки — ранее созданный пакет @bankiru/nodejs-core
. Сервисы-виджеты так же обладают мемкешом, кубер пробами, логированием — всем, что есть в обычном фронтовом node.js приложении.
И так же, как для других типов виджетов-сервисов, создали бойлерплейт для быстрого развертывания.
Встраивание виджетов-сервисов в проектах
Важным требованием для нас было то, что с увеличением количества виджетов-сервисов приложение не должно отдавать страницу медленнее. Поэтому все запросы нужно делать параллельно. Мы написали общие модули с этой логикой и положили их в пакеты для Node.js и Symfony проектов. В основе модуля обычный Promise.all. После выполнения мы получаем готовые HTML строки виджетов, которые склеиваем с HTML приложения через dangerouslySetInnerHTML. Приведу ниже упрощённый пример кода.
import { makeParallelRequest } from '@bankiru/nodejs-core/requests';
import { pageController } from '@bankiru/nodejs-core/controllers';
import {
getHeaderWidgetHTML,
getFooterWidgetHTML,
getNewsWidgetHTML
} from '@bankiru/api-services';
import { withLayout } from '@bankiru/layout';
import { insertWidget } from '@bankiru/nodejs-core/widgets-utils';
import env from 'src/getEnvVars';
import { PageSection } from '@bankiru/ui-kit';
import { SearchForm } from 'src/components';
export default pageController(async (request, response, data)) => {
// Делаем параллельные запросы за виджетами
const {
headerHTML, footerHTML, newsWidgetHTML
} = makeParallelRequests({
headerHTML: getHeaderWidgetHTML({host: env.HEADER_WIDGET_SVC_HOST}),
footerHTML: getFooterWidgetHTML({host: env.FOOTER_WIDGET_SVC_HOST}),
newsWidgetHTML: getNewsWidgetHTML({host: env.NEWS_WIDGET_SVC_HOST}),
});
return withLayout({
header: headerHTML,
content: (
<PageSection>
<SearchForm />
// Метод вставки виджетов (под капотом dangerouslySetInnerHTML)
{insertWidget(newsWidgetHTML)}
</PageSection>
),
footer: footerHTML,
});
};
Немного про отказоустойчивость
Если раньше ответственность за работу всей страницы была на команде, то в микрофронтендах она размывается. Точнее, переходит со страниц в виджеты. Таким образом, команда уже не может гарантировать полную работоспособность страниц своих разделов. Если какой-то сервис упал, страница должна отдаваться клиенту.
Все запросы мы оборачиваем в Promise.race с setTimeout в секунду (хотели быстрее, но пришлось принимать компромиссную цифру). Если сервис не ответил за это время, страница его не ждёт и продолжает рендер. Мы всегда стараемся отдать пользователю страницу, а для поисковиков даем 503 статус, чтобы не портить индекс отсутствующими данными. Ошибки об упавшем сервисе записываем в логи.
Для оптимизации запросов используем memcached. Написали общий модуль, который на каждый URL API и набор параметров кэширует ответ сервисов на 15 минут. Мемкеш, конечно, тоже мониторим со всех сторон.
Гидрация виджетов
Вдохновившись Astro (https://astro.build/), мы реализовали частичную гидрацию, а точнее ленивую (lazy). Каждый сервис возвращает в ответе HTML, CSS и JS виджета. У нас есть общий js-модуль (опять же, это внутренний пакет – @bankiru/clientside
), который пробегается по всем виджетам и инициализирует их js (навешивает события) в момент попадания в видимую область. Далеко не все виджеты имеют clientside js (случаи когда только серверный рендер). Этот скрипт работает как на Node.js страницах, так и в Symfony, чтобы обеспечить единое clientside окружение.
Тестирование
На каждый сервис у нас около 30 тестовых стендов: 10 команд и по 3 тестовых стенда на команду. Есть несколько окружений — testing, stage, production, stable, reserve. Один и тот же собранный docker-контейнер можно раскатать на любой стенд за счет разных значений env-переменных. Приложение на тестовом стенде A-1 тянет сервисы со стендов A-1. На проде с прода, соответственно.
Команда, реализующая виджет, может задеплоить его и протестировать независимо от страниц, на которых он размещается. Так же и автотесты: они хранятся в общем репозитории с виджетами и откручиваются в их CI пайплайнах.
Переменные окружения
Исходя из принципов 12-факторного приложения (https://12factor.net/ru/), стараемся абстрагироваться от контекста выполнения. Микрофронтенды можно разрабатывать локально со сборкой в докере, а после коммита запускать обычный пайплайн. Для тестирования можно выбрать окружение (стенд) – test, stage, prod, reserve, stable) и запустить деплой. Тестовых стендов достаточно для всех команд. Подробнее про наши тестовые среды писал мой коллега аж в двух частях, тут и тут. Чтобы изменения приезжали быстрее, после мерджа в master деплоим сервисы на остальные стенды, на прод и стейбл. Так командам не нужно самостоятельно обновлять сервисы, в которых они не ведут разработку – и у них всегда самые свежие стабильные версии общих виджетов.
Минусы микрофронтендов
Не совсем минусы, а, скорее, особенности. В работе с общим кодом (библиотеками) мы привыкли работать по Semver: смело правим код, описывая инструкции (changelog), нередко делаем breaking changes. Микрофронтенды – это прежде всего контракты. Важно понимать, что API для получения HTML-виджета менять нельзя — это ломает контракт. Можно только добавлять поля. Об этом приходится помнить самому и напоминать коллегам. Помогают в этом API-тесты, можно даже назвать их контрактными тестами. Если всё же требуется изменить формат ответа, мы поднимаем версию API. Получается так: – /v1/, /v2/, … .
Иногда приходится полностью мигрировать: делать вторую версию API, постепенно переключать на нее все сервисы, а после отключать первую. В монолите это делается это одним пулреквестом (правда, тестировать его можно было неделю).
Итоги
Мы построили некий гибрид. Ушли от монолита, но при этом избежали зоопарка технологий благодаря npm-библиотекам и общим решениям. Какие получили плюсы:
Приложения стали собираться быстрее (с 30 минут для монолита пришли к 2-5 минутам), т.к. все общие/сквозные виджеты переехали в сервисы.
Релизим сквозные виджеты очень быстро (github flow) и по отдельности. Сервисы не связаны между собой, деплоим только нужное, без необходимости редеплоя остальных приложений.
Держим единый flow для всех сервисов: разработчик, работая с одним сервисом, легко разберется с остальными, достаточно знать название репозитория. Ниже порог входа, нет лишней дублирующей документации, меньше ошибок, быстрее онбординг.
Сократили трафик на статику, теперь она общая и не дублируется.
Реализовали плавный переход с Symfony на Node.js стек без дублирования кода.
Особенности у такого подхода тоже есть, но, во-первых, с ними тоже можно работать и оптимизировать, во-вторых, выгоды от использования микрофронтендов полностью их перекрывают.
Вот такая получилась история перехода от монолита к микрофронтенду. В этой статье я хотел показать что не всегда оптимальны готовые решения, иногда лучше разобраться в необходимых требованиях и текущих условиях и подготовить более подходящее решение. А вам спасибо за внимание!