Перейти к содержимому

Понемногу — но каждый день.

Из 10+ скопированных Angular-проектов в один Nx-монорепозиторий

5 мин чтения

На этой странице
Коротко
У нас было больше десяти копий одного Angular-приложения — по одной на каждого регионального заказчика. Баг чинили десять раз, фичу добавляли десять раз, и каждый раз где-то забывали. Мы собрали всё в один Nx-монорепозиторий: общий код в библиотеках, а приложение региона стало тонкой оболочкой из ~25 файлов. Ниже — в каком порядке переносить, как провести границы между библиотеками и на чём мы обожглись.

Проблема: копия — это долг, который растёт

Схема знакомая. Есть приложение для первого заказчика. Приходит второй: «нам то же самое, но с нашим логотипом и парой отличий». Самое быстрое — скопировать папку. Через год копий десять, и:

  • Баг живёт в N местах. Починил в одной копии — в остальных он остался. Узнаёшь об этом от пользователя.
  • Копии расходятся. В одной поправили форму, в другой — таблицу. Через полгода это уже десять разных приложений с похожими названиями файлов.
  • Обновление фреймворка умножается на N. Каждая копия обновляется отдельно, и почти всегда какая-то отстаёт на несколько версий.

Ключевая мысль: стоимость копии не в момент копирования, а в каждом следующем изменении. Если изменение надо сделать в N местах, его цена — N × (работа + шанс забыть).

Что такое Nx-монорепозиторий, если по-простому

Монорепозиторий — один git-репозиторий, в котором живут несколько приложений и библиотек. Nx — инструмент, который знает, кто от кого зависит, и умеет запускать сборку, тесты и линт только для того, что затронуто изменением.

Структура, к которой мы пришли:

apps/
  region-a/          ← тонкая оболочка: конфиг, логотип, переводы, окружения
  admin-region-a/
  region-b/
  ...
shared/
  assignments/
    data-access/     ← сервисы, HTTP, сторы
    feature-assignments/  ← страницы и диалоги
  meetings/
  users/
  ...
ui/                  ← презентационные компоненты без данных
ui-widgets/          ← компоненты, которым нужны данные

Приложение региона почти ничего не содержит. Оно подключает общие маршруты, общие интерцепторы и общий UI. Отличия — логотип, адрес API, переводы и набор включённых модулей.

Что переносить первым

Самая частая ошибка — пытаться перенести всё сразу. Порядок, который сработал:

1. Сначала самое «листовое»: UI-кит

Кнопки, инпуты, таблицы, диалоги. У них нет зависимостей от бизнес-логики, поэтому их можно вынести, ничего не сломав. Заодно сразу видно, сколько копий одной кнопки накопилось за годы.

2. Потом data-access: сервисы и модели

HTTP-сервисы, интерфейсы ответов, сторы. Здесь копии расходятся сильнее всего: где-то поле переименовали, где-то добавили параметр. Каждое расхождение — это вопрос «это особенность заказчика или чей-то баг?». Обычно — баг.

3. Потом feature: страницы целиком

Только когда UI и данные уже общие, страница переносится почти механически.

4. Последним — приложения-оболочки

Когда все модули в библиотеках, приложение региона сводится к конфигурации.

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

Границы библиотек: зависимости только в одну сторону

Правило, ради которого всё затевалось:

apps / feature-*  →  ui-widgets  →  data-access  →  ui

Стрелка значит «может импортировать». Обратных стрелок нет. ui — лист графа: он не знает ни о сервисах, ни о сторах.

Куда класть новый компонент — решается тремя вопросами:

ВопросОтвет «да» → куда
Нужен только одному экрану?в components/ своей feature-библиотеки
Нужны сервисы или сторы?в ui-widgets
Чистая презентация, нужна в нескольких местах?в ui

Как мы обожглись: кольцо импортов

Сначала ui-widgets не было, и компоненты с данными лежали прямо в ui. Получилось кольцо: ui импортирует сервис из data-access, а data-access импортирует тип из ui.

Пока кольцо маленькое, сборщик справляется. Когда оно выросло, в рантайме появилась ошибка Angular NG0919 («Cannot read @Component metadata»): класс компонента из imports: [...] на момент чтения ещё не был инициализирован, потому что модули в кольце грузятся в непредсказуемом порядке.

Лечение — не «поменять порядок импортов», а разорвать кольцо: вынести компоненты с данными в отдельную библиотеку ui-widgets, которая стоит между ui и feature-кодом.

Проверять границы должна машина

У Nx есть ESLint-правило @nx/enforce-module-boundaries. Учебный пример, как это выглядит с тегами:

// eslint.config.mjs (учебный пример)
'@nx/enforce-module-boundaries': ['error', {
  depConstraints: [
    { sourceTag: 'type:ui',          onlyDependOnLibsWithTags: ['type:ui'] },
    { sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:ui', 'type:data-access'] },
    { sourceTag: 'type:widget',      onlyDependOnLibsWithTags: ['type:ui', 'type:data-access'] },
    { sourceTag: 'type:feature',     onlyDependOnLibsWithTags: ['*'] },
  ],
}],

Теги ставятся в project.json каждой библиотеки: "tags": ["type:ui"].

Честно: у нас правило включено, но теги пока не размечены, и ограничения стоят в режиме «все могут всё». Направление держится на README и ревью. Это следующий шаг, и это именно тот случай, где правило дешевле ревью — подробнее в посте custom-eslint-rules.

5 минут на новый регион

Когда весь код общий, новый регион — это:

  1. Скопировать оболочку ближайшего региона (≈25 файлов, из них кода — пара сотен строк конфигурации).
  2. Поменять имя, логотип, адрес API, переводы.
  3. Добавить строку в список деплоя.

Никакой бизнес-логики в этих файлах нет, поэтому копировать их не страшно: они не расходятся, им нечему расходиться.

Следующий логичный шаг — Nx-генератор: команда, которая создаёт оболочку по шаблону и параметрам (nx g region --name=... --api=...). Тогда даже эти 5 минут уходят в одну команду, и ничего нельзя забыть.

Что я сделал бы иначе

  • Границы и теги — с первого дня, а не после первой ошибки NG0919. Разметить пять библиотек дешевле, чем потом разбирать кольцо из тридцати.
  • Расхождения копий записывать в таблицу, а не решать в голове. «Особенность заказчика» или «баг» — решение, которое потом захочется найти.
  • Одна версия зависимостей на весь репозиторий — это не ограничение, а главная выгода монорепо. Об этом в посте про обновление Angular: angular-6-to-22.

Итог

Монорепозиторий не делает код лучше сам по себе. Он делает так, что изменение делается один раз, а граф зависимостей становится явным — и его можно проверять. Остальное (сборка только затронутого, единый CI) строится поверх этого. Про CI — в посте ci-nx-affected.