Всем привет! Меня зовут Иван Кузнецов, я Head of Frontend в Uzum Market. Расскажу о сложностях, с которыми мы столкнулись на пути к реализации микрофронтендовой архитектуры, и поделюсь результатами, которые мы получили в процессе пакетирования наших решений с помощью Lerna. Надеюсь, тебе, дорогой читатель, будет очень интересно :)

Для лучшего понимания этой статьи рекомендую сначала прочитать:

  1. Управление зависимостями JavaScript

  2. Установка и настройка Nexus Sonatype, используя подход infrastructure as code (тут в конфиги не вдаемся, просто для общего понимания, что такое Nexus)

Дано

После выхода на новое место работы я и моя команда начали изучать проект, экосистему внутри компании, и выделять точки роста фронтенда. Вот что выяснили:

  1. Все клиенты — монолиты с довольно большими командами, и не получается часто релизиться из-за долгих регрессов.

  2. Версия используемого фреймворка (Vue 2) больше не поддерживается.

  3. Интересы SEO не учитываются, используется Client-side Rendering (далее CSR).

  4. Нет корпоративного хранилища артефактов.

  5. Для разворачивания проекта на стенде приходилось ставить задачу на DevOps.

Что мы решили сделать:

  1. Перейти на микрофронтенды и независимые релизы команд.

  2. Обновиться до Vue 3.

  3. Перейти на SSR (Nuxt 3).

  4. Создать корпоративное хранилище артефактов (выбрали Nexus).

  5. Сделать переиспользуемый шаблон для разворачивания на стендах и публикации пакетов в приватном registry.

Формируем первоначальные требования

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

Из этой схемы мы видим, что нужно поднять хранилище артефактов (далее Nexus) и положить туда переиспользуемые модули.

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

  • Old CSR — старое приложение на Vue 2;

  • Nuxt 3 — новое приложение на Nuxt 3.

Для начала мы решили перехватывать запросы к Nginx и отправлять их в Nuxt 3, если эта страница готова. Поясню на примере. Предположим, мне нужно перевести страницу /user с Vue 2 на SSR. Я указываю в конфигурации Nginx, что если /path === /user, то отправлять не в старый контейнер с CSR на Vue 2, а в новенький с Nuxt 3 на SSR. Таким образом, не блокируя бизнес-процессы, мы постранично перепишем наше приложение на новый фреймворк.

Исходя из придуманной схемы и описанных тонкостей перехода мы сформировали первые требования к нашим библиотекам:

  1. На момент перехода каждая из них должна поддерживаться в двух версиях (Vue 2 и Vue 3).

  2. У каждой библиотеки должно быть свое окружение для тестирования.

  3. Все они публикуются по правилу semver в Nexus без ручного инкремента разработчика.

  4. Должен быть общий CI-шаблон.

  5. Соответственно, должен быть общий релизный процесс.

Релизный процесс

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

Прокомментирую схему. Есть ветки release, stage и develop в нескольких вариациях для поддержки разных мажорных версий (в нашем случае это нужно для Vue 2 и Vue 3), то есть:

  1. release/1.x.x — это версия библиотеки для Vue 2 (внутри package.json → 1.x.x);

  2. release/2.x.x — это версия библиотеки для Vue 3 (внутри package.json → 2.x.x).

Очень важно не плодить много релизных веток, их должно быть ровно столько, сколько мажорных версий вы собираетесь поддерживать. Например, если у нас две мажорные под Vue 2 и Vue 3, то всего будет шесть веток:

  1. release/1.x.x и release/2.x.x;

  2. stage/1.x.x и stage/2.x.x;

  3. develop/1.x.x и develop/2.x.x.

Думаю, принцип понятен: первое число в названии ветки означает номер мажорной версии.

Сформируем список требований

Для Nexus:

  1. Аккаунты на запись и чтение.

  2. Проксирующие репозитории в другие экземпляры Nexus холдинга.

Шаблон для публикации:

  1. Простой деплой.

  2. Feature-ветки (на каждый пуш поднимаем стенд).

  3. Публикация в Nexus, если это библиотека.

  4. Поддержка Lerna.

  5. Автоматический DNS.

  6. Простой доступ к ним для менеджеров, дизайнеров и тестировщиков.

  7. Dockerfile — контракт для деплоя на стенде.

  8. На каждый пуш в семейство веток release, stage и develop выпускаем тег.

  9. Если пуш в семейство веток stage или develop**,**, то выпускается тег с пометкой beta и суффиксом stage или develop соответственно (пример: 1.0.0-develop.1). 

Примечание: если пометки beta не будет, то автоматически проставится latest, и тогда при команде npm install в приложение отправится dev-версия, а такого лучше не допускать.

  1. На выпуск тега заливаем версию в Nexus. Оставляем программисту возможность залить версию вручную.

В итоге у нас получился такой API шаблона:

kind: template
load: template_name.jsonnet
data:
  nodeImage: "node:16.18.0" // *
  dockerfilePath: "Dockerfile" // * (relative path to dockerfile)
  projectName: "ui-kit" // * (name repo)
  deployStand: true // * (false)
  publishType: "lerna" // * (or publishType: "npm", если стандартная репа)
  dockerfileArgs: '{}' // * 
  publish: true // * (false) выпуск библиотеки

DNS формируется из названия ветки и projectName: https://customers-footer-develop1xx

  1. customers-footer — берем из поля projectName (желательно брать просто имя репозитория, которое дает уникальность в рамках кластера или вашей организации).

  2. develop1xx — формируем из имени ветки develop/1.x.x.

Причем тут Lerna?

У вас уже могло сложиться впечатление, что я рассказываю не о том, о чем заявил в самом начале, однако это не так. Все это нужно было для создания правильной общей системы публикации пакетов, причем как с помощью Lerna, так и с помощью npm.

Основная причина, по которой мы воспользовались Lerna, заключалась в том, чтобы внутри репозитория библиотеки лежало приложение, которое использует эту библиотеку. Это было сделано для дополнительной документации. Можно сколько угодно распинаться о том, что мы прекрасно документируем свой продукт, однако очень часто информация в ReadMe.md устаревает, потому что ее перестают поддерживать. Лично я считаю, что самая лучшая документация — это рабочий код. Только он актуален всегда. Поэтому мы и применили Lerna.

Структура монорепозитория пакета на Lerna

Рассмотрим структуру на примере нашего пакета customer-footer:

В папке uz-footer лежит сам пакет @uzum/customers-footer, а в test-app — хост-приложение, которое его запускает в своей среде. То есть на откуп командам разработки отдано классическое фронтенд-приложение со своей архитектурой.

Также у нас есть ключевой файл .npmrc:

@uzum:registry=адрес вашего репозитория на скачивание

@market-tech:registry=адрес вашего репозитория на скачивание

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

Настройка HMR и подключение к test-app

После подключения Lerna по всем правилам документации мы столкнулись с проблемой HMR: приходилось вечно пересобирать проект, чтобы обновить сборку пакета для запускающей его хост-машины (test-app). Это немного раздражало, однако мы придумали обход.

Настройка package.json пакета

Заходим в package.json внутри нашего пакета (в данном случае в папке uz-footer/package.json) и проставляем exports:

"exports": {
  ".": "./dist/index.common.js", -> Для клиента как будем использовать на проде
  "./styles": "./dist/index.css", -> Для клиента как будем использовать на проде
  "./test": {
    "development": "./src/index.ts",
    "production": "./dist/index.common.js"
  }, -> Для test-app и только для него
  "./test/styles": {
    "development": "./src/index.css",
    "production": "./dist/index.css"
  } -> Для test-app и только для него
}

При сборке ваш бандлер (Webpack/Vite) будет смотреть на свой mode, который зачастую равен NODE_ENV. Соответственно, в production он будет брать билдовый экспорт, а в development — обычные файлы, и индексировать их для HMR. Очень удобно!

Пример подключения:

<script setup lang="ts">
// Для тестового приложения импорты идут из @uzum/customers-footer/test на маркете используем @uzum/customers-footer
import '@uzum/customers-footer/test/styles';
import CustomersFooter from '@uzum/customers-footer/test';
</script>

<template>
  <customers-footer />
</template>

В корне проекта Lerna лежат скрипты для запуска:

"scripts": {
	"build": "lerna run build",
	"dev": "cd ./packages/test-app && npm run dev",
	"start": "npm run build && cd ./packages/test-app && cross-env NODE_ENV=production npm run dev",
},

Специфика сборки внутри Dockerfile

Опишем Dockerfile, который лежит в корне проекта Lerna:

FROM node:16.18.0 AS build-stage

WORKDIR .

ARG UM_NPM_USER_READ
ARG UM_NPM_PASS_READ
ARG UM_NPM_EMAIL_READ
ARG UM_NEXUS_ENDPOINT_READ

RUN npx npm-cli-login -u $UM_NPM_USER_READ -p $UM_NPM_PASS_READ -e $UM_NPM_EMAIL_READ -r $UM_NEXUS_ENDPOINT_READ

# Установка зависимостей
COPY . .

RUN npm install 
RUN cd ./packages/uz-footer && npm run build
RUN cd ./packages/test-app && npm run build

FROM nginx:1.23
COPY --from=build-stage packages/test-app/dist /usr/share/nginx/html
COPY  packages/test-app/config/nginx.conf /etc/nginx/conf.d/default.conf

Мы не можем использовать команды Lerna из-за виртуальной файловой структуры, при вызове lerna cli будут ошибки OOM. Поэтому да, это костыль, но он работает. Однако разработчики Lerna эту ошибку воспроизвести не смогли. В общем, если у кого-то ошибки тут не будет, отпишитесь, пожалуйста, в комментариях, будет интересно посмотреть.

Падение сборки

Иногда сборка внутри Docker может падать, потому что nx просит установить драйверы. Ошибка в CI будет выглядеть так:

Error: Cannot find module '@nx/nx-linux-x64-gnu'

Аналогичная ошибка может выскочить и при работе с Lerna внутри вашей ОС. Не пугайтесь, просто добавьте в корне репозитория в package.json эти библиотеки, желательно последних версий.

"optionalDependencies": {
  "@nx/nx-darwin-arm64": "^18.0.4",
  "@nx/nx-darwin-x64": "^18.0.4",
  "@nx/nx-linux-x64-gnu": "^18.0.4",
  "@nx/nx-win32-x64-msvc": "^18.0.4"
},

Правила публикации в корпоративный Nexus

В корпоративном мире в Nexus часто используют отдельные репозитории на запись и на чтение, поэтому при вышеописанной конфигурации вы будете постоянно падать с ошибкой авторизации при попытке публикации. Причина в областях видимости пакета внутри .npmrc: когда Lerna читает этот файл, она пытается их опубликовать в репозиторий на чтение, а не в репозиторий на запись. Поэтому нужно переопределить внутри нашего пакета его scope-registry:

"publishConfig": {
  "access": "restricted",
  "@uzum:registry": "репозиторий на запись",
  "registry": "репозиторий на запись"
},

Добавьте эту строчку в package.json вашего пакета, в нашем случае это ./packages/uz-footer/package.json.

Только тогда публикация будет правильно отрабатывать.

Вывод

Lerna — отличный инструмент для работы с пакетами. Наверное, в качестве полноценного оркестратора монорепозиториев лучше выбрать все-таки nx, но если вам нужно пакетировать, то Lerna куда более удачный вариант, потому что она гораздо проще. Благодаря ей при интеграции того или иного пакета команда может зайти на стенд и в код репозитория и посмотреть, как это делают сами разработчики, что сильно упрощает коммуникацию между командами. 

Всех благ, рад, что вы прочитали мою статью!