Привет! Меня зовут Дарья, и я разработчик в юните Frontend Architecture в Авито. Недавно мы с Димой Лощининым зарелизили внутреннее решение для микрофронтендов с поддержкой серверного рендеринга (SSR) и хотим поделиться своим опытом. Микрофронтенды в Авито существуют уже больше года, но раньше они поддерживали рендеринг только на стороне клиента. Чтобы сделать серверный рендеринг, нам пришлось всё переписать. Расскажу, почему так и как это теперь работает.
Что такое микрофронтенды и зачем они нам
Обычно архитектуру микрофронтендов описывают как подход, в котором независимые клиентские приложения соединяются в единое веб-приложение. Прежде всего они нужны для независимой разработки и независимого деплоя. Если правильно разделить приложение на микрофронтенды, то это улучшит Time To Market (TTM) фич. В этом обычно заинтересован бизнес.
Давайте посмотрим на архитектуру фронтенда Авито в 2021 году.
У нас были два основных Backend-for-Frontend (BFF) сервиса: Desktop и Mobile, в которые подключались npm-пакеты.
Как это было устроено на примере десктопного сайта: команды писали код в npm-пакетах, затем подключали их в Desktop, далее код собирался, тестировался и деплоился. Когда браузер запрашивал страницу, запрос приходил в Desktop, в котором NodeJs рендерил и отдавал HTML.
При этом в Авито было уже достаточно много команд для того, чтобы коммитить в монолит и не испытывать проблем с билдами и конфликтами на PR. Часто основные изменения в Desktop заключались только в правке версий npm-пакетов в package.json и обновлению package-lock.json. Это приводило к появлению конфликтов, решение которых запускало все билды с тестами заново. Чтобы снять нагрузку с разработчиков и не превращать деплой в хаос, юнит Frontend Architecture делал релизы монолитов самостоятельно несколько раз в день.
На момент декабря 2021-го у нас были:
Разрабатывать проект масштаба Авито при таком подходе не очень удобно. Мы поняли, что нам нужны микрофронтенды. Тогда разработчики будут деплоить свой код независимо и быстрее, запускать только необходимые E2E тесты на PR, а также иметь возможность откатить сломавшие продакшн изменения, не затрагивая функциональность других команд.
Реализация CSR
Для того чтобы сделать микрофронтенды, мы сравнили существующие подходы и остановились на решении с Webpack Module Federation. Но у него был один недостаток: не было поддержки SSR, который нужен многим страницам на Авито. Поэтому мы морально подготовились к тому, что если в ближайшее время она не появится, то нам придётся писать своё решение. Но сначала я опишу решение, которое у нас получилось в первом подходе к микрофронтендам. Пока без SSR.
Подключить Module Federation довольно просто. Всё, что нужно сделать в модуле, это добавить ModuleFederationPlugin в Webpack-конфиг и передать необходимые настройки. Мы указали в секции shared только те зависимости, которые однозначно нужны на всех страницах. В нашем случае это react и react-dom. Благодаря этому они не грузятся на странице несколько раз в каждом модуле.
Также мы подключили ModuleFederationPlugin с похожими настройками в Desktop. Мы не указали в настройках секцию remotes, так как в этом случае remoteEntry.js всех модулей будут грузиться на всех страницах. Это негативно скажется на перфомансе приложения. Такой вариант нам не подходит, поэтому мы загрузили модули по-другому. На GitHub вы можете найти подробное описание этой проблемы и ее решение.
В результате архитектура фронтенда Авито выглядела следующим образом: часть npm-пакетов все еще подключалась в монолиты, а часть была переведена на микрофронтенды. Оставшийся в npm-пакетах код либо редко обновлялся, либо ему требовался SSR.
У каждого модуля появился отдельный независимый деплой, и на схеме это можно представить так:
TTM определённо улучшился: в среднем деплой микрофронтенда длился около 10 минут, а на PR запускались только нужные E2E-тесты. И самое главное: разработчики релизили свои приложения по мере необходимости.
За год существования этого решения 36 команд перевели на него часть своих npm-пакетов. На конец 2022 года у нас было примерно 70 модулей в продакшн. Пришло время подумать про SSR.
Реализация SSR
У нас был план, но мы его не придерживались
Изначально мы планировали развивать решение, которое у нас было. Для этого нам нужно было решить ряд вопросов: как подгружать модули, как вставлять статику и где отправлять запросы за данными.
Мы думали взять за основу @module-federation/node. Но когда сделали прототип, обнаружили такие баги, как дополнительные сетевые походы за статикой микрофронтендов. Также мы столкнулись с проблемами кэширования, которые не удалось бы быстро решить. Помимо этого, у каждого микрофронтенда появлялась неявная зависимость от версии NodeJS в основном приложении. Получалось, что при таком подходе невозможно было бы обновить версию NodeJS независимо. А это потенциальная проблема в будущем.
В этот момент мы подумали, что будет неплохо, если микрофронтенд будет сам заниматься рендерингом. Для этого нужно, чтобы он стал полноценным сервисом на NodeJS. Благо, наша PaaS инфраструктура позволяет создавать сервисы одной командой. Поэтому основной вопрос был в ресурсах — хватит ли нам мощностей, чтобы к примерно 3000 микросервисов добавить, к примеру, 500 микрофронтендов? У команды PaaS были мысли, как это оптимизировать, чтобы не тратить ресурсы впустую, и они дали нам добро на такое решение.
Как это работает
Есть наши основные PaaS-сервисы Desktop и Mobile на NodeJs, в которые подключаются микрофронтенды и которые
определяют, какую страницу нужно отрендерить;
делают запросы за микрофронтендами;
формируют и отдают HTML страницы;
подготавливают статику.
Микрофронтенды — это тоже PaaS-сервисы на NodeJs, которые:
рендерят и отдают HTML запрошенного компонента;
делают запросы за данными для инициализации компонента, если это необходимо;
подготавливают статику.
Сборка микрофронтенда — это запуск Webpack все с тем же ModuleFederationPlugin.
Когда пользователь десктопного приложения делает запрос за страницей, запрос приходит в Desktop, который роутит его на нужную страницу. Если на этой странице есть подключение микрофронтенда, делается запрос за компонентом в нужный сервис. Если для отрисовки компонента необходимы данные из стороннего сервиса, делается запрос за ними, далее сервис рендерит компонент и возвращает в ответ HTML. Desktop отдаёт полный HTML страницы в браузер.
Для наглядности отразим на схеме то, что происходит на этапе SSR:
После получения разметки страницы, браузер начинает грузить и выполнять js. Сначала грузится remoteEntry.js, который затем подгружает всю необходимую для микрофронтенда статику. На схеме это можно отразить так:
Далее я приведу упрощенные примеры кода, которые нужны для отражения сути решения и не являются полностью рабочими.
Подключение компонента в Desktop очень условно выглядит следующим образом:
const SomePage = () => {
const { SomeComponent } = useComponent('some-mfe');
return (
<SomeComponent someProp='someProp' />
);
};
Микрофронтенд на сервере и в браузере загружается по-разному. Сначала рассмотрим реализацию для сервера.
class ComponentClient {
constructor(serviceURL) {
this.serviceURL = serviceURL;
}
get(componentName) {
return (props) => {
const Component = React.lazy(
async () => {
// делаем POST-запрос за нужным компонентом
const response = await fetch(`${this.serviceURL}/${componentName}`, {
body: JSON.stringify(props),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
});
const html = await response.text();
return {
default: (props) => {
return React.createElement(
componentName,
{
'data-props': JSON.stringify(props),
dangerouslySetInnerHTML: {
__html: html
}
}
);
}
};
}
);
return React.createElement(Component, props);
};
}
}
Теперь рассмотрим код, который выполняется в браузере.
class ComponentClient {
components = {};
constructor(name) {
this.name = name;
// загружаем микрофронтенд
new Container(name);
}
get(componentName) {
return this.components[this.name] ||= React.lazy(
async () => {
if (!customElements.get(componentName)) {
// получаем компонент микрофронтенда, который мы уже загрузили
const component = (await (window)[this.name].get(`./${componentName}`))().default;
// регистрируем элемент
customElements.define(componentName,
class extends HTMLElement {
static get observedAttributes() {
return ['data-props'];
}
get props() {
return JSON.parse(this.getAttribute('data-props') || '{}');
}
connectedCallback() {
this.component = component;
this.component.mount(this, this.props);
}
attributeChangedCallback() {
this.component?.update(this.props);
}
disconnectedCallback() {
this.component?.unmount(this);
}
}
);
}
return {
default: (props) => {
return React.createElement(
componentName,
{
'data-props': JSON.stringify(props),
dangerouslySetInnerHTML: {
__html: ''
},
style: { display: 'contents' },
suppressHydrationWarning: true
}
);
}
};
}
);
}
}
То есть, Desktop создает и регистрирует веб-компонент, методы которого вызывают методы компонента микрофронтенда.
Как это выглядит для микрофронтендов с React:
class Component {
constructor(createComponent) {
this.createComponent = createComponent;
}
mount(element, props) {
this.RootComponent = this.createComponent();
let container = element.querySelector('[data-root="true"]');
// если элемент существует, гидрируем компонент
if (container) {
this.root = ReactDOM.hydrateRoot(container, <this.RootComponent {...props} />);
return;
}
// иначе создаем элемент и делаем рендер
container = document.createElement('div');
container.setAttribute('data-root', 'true');
element.appendChild(container);
this.root = ReactDOM.createRoot(container);
this.root.render(
<this.RootComponent {...props} />
);
}
update(props) {
if (this.RootComponent) {
this.root?.render(
<this.RootComponent {...props} />
);
}
}
unmount() {
this.root?.unmount();
}
}
Мы используем веб-компоненты по ряду причин. Главное, что они позволяют:
скрыть детали реализации, предоставляя простой и понятный контракт;
сделать микрофронтенд независимым от фреймворка. Поскольку пользовательские элементы являются веб-стандартом, их поддерживают все основные фреймворки, такие, как Angular, React и Vue;
сделать микрофронтенд менее зависимым от страницы и иметь отдельную гидратацию в компоненте.
Подробнее про веб-компоненты в микрофронтендах можно прочитать в этой статье.
Чтобы передать данные из микрофронтенда в основное приложение, мы используем CustomEvent. У нас нет общего стора, чтобы не создавать лишнюю связность и держать микрофронтенды как можно более изолированными.
Какие результаты получили
Итак, наша цель заключалась в снижении TTM. Приведу средние показатели длительности билдов, запущенных в основном десктопном сервисе и в микрофронтендах.
Для монолита:
длительность деплоя = ~20 мин
длительность всех билдов, запускаемых на PR = ~50 мин
Для микрофронтендов:
длительность деплоя = ~5 мин
длительность всех билдов, запускаемых на PR = ~10 мин
Помимо снижения TTM мы имеем и другие плюсы микросервисной архитектуры:
уменьшение связности за счет четкого разделения ответственности и ограничения доступа к внешним ресурсам;
повышение надежности за счет уменьшения связности с основным приложением;
возможность точечного отката конкретной части сайта вместо отката всех задач, попадавших в релиз монолита;
возможность постепенного обновления технологий;
гибкость в выборе технологий.
Конечно, микрофронтенды имеют свои минусы, основными из которых являются увеличение потребления ресурсов и ухудшение перформанса. Для контроля ресурсов мы осознанно подходим к тому, какие части страницы выделять в микрофронтенды. Для контроля перформанса мы запускаем A/B-тесты и следим за перформанс-метриками. Запуски показали, что метрики просаживаются незначительно. Поэтому для нас профит от микрофронтендов перевешивает недостатки.
Что дальше
Сейчас у нас есть несколько микрофронтендов с SSR в продакшн. Полёт нормальный. Решение с отдельными сервисами помогает нам унифицировать подход к микросервисной архитектуре в Авито.
Следующим шагом мы планируем придумать концепцию разделения наших монолитов на микрофронтенды. Если из этого получится что-то интересное, мы обязательно расскажем.
Предыдущая статья: Мапы в Go: уровень Pro