Вещи, которые мы узнали за 2 года поддержки JavaScript / React Monorepo

Два года назад наша команда переместила все наши приложения JavaScript в единый монорепозиторий. У нас было время позволить пыли осесть на нашем решении и ударить по нескольким стенам по пути - мы думали, что поделимся ими со всем миром.

Почему монорепозиторий?

У нас, в Botify, много разных SPA. Наш продукт представляет собой набор различных компонентов и инструментов, которые могут использоваться разными клиентами по-разному. Наша команда JavaScript, состоящая примерно из 10 инженеров, поддерживает:

  • 4 основных СПА
  • Еще 3 второстепенных веб-приложения
  • Сборник рассказов на внутреннем сервере
  • JS SDK
  • расширение Chrome
  • Генераторы PDF (пакеты узлов)
  • Пакеты встроенного браузера

Это очень много кода в разных местах. Более того, некоторые из этих пакетов имеют много общего (SPA), а некоторые сильно различаются по функциям и создаваемым артефактам (Chrome Extension, Embeds, SDK).

Первоначально все эти проекты были отдельными репозиториями git со своим собственным lint, сценариями сборки, CI / CD, стилем кода и иногда даже технологиями. Это вызвало массу проблем:

  • Кодовая база разнообразна, некое наследие сложно сохранить или оставить позади
  • Сложно унифицировать UI / CSS (создание представлений для нескольких приложений)
  • Единообразие кода хуже
  • Скрипты Lint / Build / Deploy / Release должны управляться отдельно
  • CI / CD должны управляться отдельно
  • PR должны управляться в разных репозиториях
  • Зависимости между пакетами требуют нескольких PR с изменениями зависимостей
  • Команда медленнее масштабируется

Два года назад мы решили объединить их все в один монорепозиторий, чтобы попытаться решить эти проблемы. Вот что мы узнали в процессе.

Как мы монорепо?

Каталог верхнего уровня нашего JS-интерфейса monorepo выглядит так:

├── .nvmrc
├── .prettierrc
├── .sass-lint.yml
├── .travis.yml
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .babelrc
├── CONVENTIONS.md
├── README.md
├── apps/
│ ├── common-style/
│ ├── common-js/
│ ├── chrome-extension/
│ ├── botify-embeddable/
│ ├── sdk-js/
│ ├── app1/
│ ├── app2/
│ ├── …
├── config/
│ ├── apps.js
│ ├── …
├── package.json
├── postcss.config.js
├── script/
│ ├── …
├── webpack.config.js
└── yarn.lock

Каждый из каталогов в ./apps имеет свой собственный package.json и зависимости, и каждое приложение создает отдельный артефакт с версией и выпускается отдельно. Некоторые приложения, такие как common-style и common-js, представляют собой просто общий код, который импортируется внутри некоторых приложений, они не создают никаких артефактов.

Совместное использование общего ядра

Самым большим преимуществом, которое мы получили от перехода на монорепозицию, стало увеличение степени унификации и унификации нашего кода. В максимально возможной степени мы стараемся повторно использовать код и иметь общие правила, которые соблюдаются во всей кодовой базе. Это позволяет каждому инженеру работать над любой частью кодовой базы и чувствовать себя как дома.

Вот что мы сделали общим для всей нашей кодовой базы:

Общие правила

У нас есть набор правил и стиль кода, и мы обеспечиваем его соблюдение с помощью CI (PR не удается построить CI из-за проблем со стилем). Это позволяет исключить обсуждение стиля из обзоров и повысить удобочитаемость кода. Как указано ниже, мы регулярно обсуждаем эти правила и при необходимости развиваем их.

  • ESLint // Красивее // PostCSS

В корне монорепозитория находятся файлы конфигурации для eslint, postcss и prettier с нашими правилами стиля кода. Они применяются к каждой строке кода в репозитории и позволяют нам поддерживать согласованный стиль кодирования между различными приложениями. Это помогает разработчикам чувствовать себя как дома в любой части кодовой базы.

Кстати, prettier и eslint autofix действительно помогли нам получить рабочую среду без суеты, как с точки зрения стиля кода при проверке, так и простоты использования с интеграцией IDE. Тот факт, что весь код отформатирован и проверяется на соответствие рекомендациям Botify каждый раз, когда кто-то из нас нажимает «Сохранить» в нашей среде IDE, значительно экономит время для команды.

  • Вавилон

Наш файл правил транспилятора также находится на верхнем уровне, и, за исключением некоторых очень специальных приложений, весь код JavaScript Botify соответствует одной и той же спецификации ECMA. В основном мы используем языковые функции, которые относятся к LTS, но у нас есть несколько плагинов babel для предложений на поздних этапах. Наш babelrc позволяет нам убедиться, что весь код Botify написан на одном и том же JavaScript, и сводит к минимуму ошибки в производстве из-за того, что разработчики не имеют необходимых языковых инструментов в старых стеках. Опять же, как и наше руководство по стилю, это также позволяет сделать наш код более единообразным.

Общие скрипты

Наша папка верхнего уровня ./scripts выглядит так:

├── script/
│ ├── build
│ ├── deploy
│ ├── install
│ ├── lint
│ ├── release
│ ├── start
│ ├── stats
│ ├── test
│ └── utils

Все эти скрипты в значительной степени делают то, что говорят их имена, и работают для всех приложений в репозитории. Это означает, что линтинг, сборка, тестирование, развертывание и выпуск приложения выполняются с помощью одних и тех же сценариев. Он также позволяет любому члену команды Botify Product сделать новый выпуск и развернуть его быстро, просто и независимо от выпускаемого приложения.

Бонус (потому что я знаю, что вам любопытно и интересно): «stats» - это стандартный сценарий для запуска сборки со статистикой модуля (webpack - stats) для отображения древовидной карты веса созданного пакета. Это позволяет нам изучить наше дерево зависимостей и выполнить оптимизацию зависимостей, чтобы быстро уменьшить размер пакета.

  • Webpack

Наш сценарий сборки является совершенно обычным, и все приложения создаются с помощью одного и того же сценария с использованием webpack. Однако, поскольку артефакты, которые мы создаем, очень разные (пакеты браузера, пакеты узлов, расширение Chrome и т. Д.), Мы создали способ как можно больше унифицировать наши сборки веб-пакетов.

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

Webpack.config.js верхнего уровня:

module.exports = (app, { _ = [] } = {}) => {
  const appWebpackConfig = require(`./apps/${app}/webpack.config`);
  const baseConfig = {
    …
  };
  return appWebpackConfig(baseConfig, { isProduction });
};

Это вызывает appWebpackConfig, функцию, экспортируемую дочерним приложением webpack.config.js, которая может изменять правила в конфигурации в соответствии со своими потребностями.

Это позволяет нам запускать `yarn build ‹app›` с верхнего уровня и иметь сборку приложения, используя общие конфигурации сборки, при этом настраивая сборку для нужд приложения.

Конечно, все сборки создаются в каталоге приложения ./dist.

  • Скрипты интеграции CI / CD / Workflow

Линтинг, сборка, тестирование и развертывание выполняется на CI. Опять же, мы создали сценарии верхнего уровня для каждого из этих действий, выполняемых для каждого приложения.

При работе с CI мы создали простую функцию для определения того, какое приложение было изменено и какое приложение необходимо протестировать на CI. Когда начинается сборка CI, Трэвис выполняет git diff, чтобы определить, какие приложения изменили файлы в ветке. Затем он проверяет эти измененные файлы на предмет зависимостей, объявленных между приложениями (например: все приложения зависят от common-js и перестраиваются, если общий пакет изменяется) для построения дерева зависимостей. Затем дерево выравнивается, и CI тестирует только те приложения, которые необходимо протестировать в PR. Это экономит нам много времени на сборку Travis.

Мы используем Travis Build Stages для последовательной и модульной работы. Мы планируем поэкспериментировать с матрицами сборки, чтобы учесть полностью расширенную архитектуру CI в едином контейнере для каждого приложения.

# Что мы узнали

Создайте общий пакет кода, используйте его как можно больше

СУХОЙ - это принцип, который мы придерживаемся в Botify. Мы, как команда, очень твердо верим, что лучший код - это отсутствие кода вообще, и максимальное повторное использование между нашими приложениями устраняет лишнюю поверхность кода, которую мы должны поддерживать.

Мы создали два «приложения»: monorepo, common-style и common-js, которые являются репозиториями для всего общего кода, используемого в других приложениях. Они содержат что угодно, от служебных функций для обработки строк до общих компонентов и представлений, используемых между приложениями.

Важная проблема: мы используем псевдонимы webpack для псевдонимов наших общих пакетов кода, так что импорт в общие пакеты также везде одинаков (это значительно упрощает рефакторы). Во всех наших приложениях код из общего пакета можно импортировать с помощью «import {…} from« common-js /… »». При рефакторинге кода в common-js мы можем легко найти и заменить импорты, потому что они никогда не являются относительными. Это правило объявлено в общей конфигурации веб-пакета и автоматически добавляется ко всем приложениям.

  • Общие утилиты

Насколько это возможно, мы хотим повторно использовать небольшие функции и утилиты между нашими различными приложениями. Наша папка common-js / utils выглядит так:

utils
├── async.js
├── chart.js
├── common.js
├── constants.js
├── datamodel.js
├── dimension.js
├── dom.js
├── filter.js
├── filterBlock.js
├── layout.js
├── moment.js
├── predicates.js
├── propTypes.js
├── query.js
├── react.js
├── reporting.js
├── routing.js
├── searchableTree.js
├── segments.js
└── trackUser.js

Большой набор утилит, очень разнообразных по своему назначению, некоторые чисто технические (например, dom, react), а некоторые более функциональные / бизнес (например: фильтр, модель данных, сегменты). Они используются во всех приложениях Botify в репозиториях и помогают нам быстро переходить к крупным системам.

У нас также есть некоторые утилиты, которые представляют собой просто оболочки для реализаций, которые различаются в разных приложениях. Например, у нас есть устаревшее приложение, использующее BackboneJS, и все, что мы написали за последние два года, использует React-Router, поэтому у нас есть утилита для абстрагирования маршрутизации в этих двух конкретных реализациях. Наша утилита маршрутизации может выполнять маршрутизацию независимо от того, в каком приложении мы находимся, а детали стека остаются неизвестными для потребителя модуля - потому что они не важны, все, что они хотят сделать, это маршрут.

  • Общие взгляды

Наша команда по пользовательскому интерфейсу внутренне создала UI Kit, набор компонентов, общих для наших пользовательских интерфейсов, которые можно использовать при составлении представлений.

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

Например, ‹Button /›, который мы используем в нашем расширении Botify, точно такой же компонент, как тот, который используется на нашей странице входа.

Это позволяет нам обновлять основной пользовательский интерфейс в одном месте и применять изменения ко всем нашим приложениям.

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

  • Общий код redux

Мы широко используем Redux и Redux-Sage, и нам удалось унифицировать большую часть бизнес-логики, обработки данных и кодирования диаграмм, чтобы мы могли повторно использовать его в различных приложениях.

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

Наши магазины для каждого приложения обычно структурированы одинаково, и редукторы работают по всем направлениям одинаково для того или иного приложения.

Мы также используем Redux-Saga для инициализации всех наших приложений. Инициализация приложения Botify обычно означает получение большого количества метаданных о запрашиваемом проекте Botify и различных его компонентах (сканирование, журналы, ключевые слова, аналитика и т. Д.). Одна из сильных сторон наших продуктов - способность Botify передавать данные из нескольких источников (сканирование, данные журналов, аналитика и т. Д.). Наши общие саги позволяют нам извлекать данные и обрабатывать их одинаковым образом независимо от продукта и, следовательно, позволяют нам легче создавать визуализации, которые пересекают эти наборы данных.

Наши общие саги заполняют наши магазины стандартизированным образом, и наши общие редукторы могут легко работать в любом магазине в соответствии со стандартом.

Всегда связывайте версию реакции из приложения

Никто не сказал это лучше, чем @danabramov (здесь):

Two Reacts не станут друзьями

Распространенная проблема в React monorepos - это непреднамеренная загрузка двух копий React. Ваш общий код имеет зависимость `react`, ваши библиотеки имеют зависимости `react`, а ваше приложение имеет зависимость `response`, так что Webpack легко, если он неправильно настроен, чтобы загрузить неправильную копию `react`.

Мы решили их, используя нашу общую конфигурацию Webpack, которая заставляет сборщика пакетов в первую очередь искать React в зависимостях приложения.

Унифицируйте свои версии реакции

Унификация вашей версии React между пакетами важна по простой причине: компоненты, которые повторно используются от приложения к приложению, которые находятся в общем пакете, должны работать одинаково во всех приложениях. Это не происходило в течение некоторого времени, поскольку React вносил все меньше и меньше критических изменений, но причуды между версиями React все еще могут происходить, особенно при смешении ‹16 /› 16 с React Fiber. Унификация наших версий React по всем направлениям устраняет подобные головные боли до их получения.

Кстати, в прошлом мы использовали кодовые модификации Facebook для обновления устаревшего кода, и до сих пор это работало для нас очень хорошо.

Не используйте заводскую выкройку

Любой, кто работает с таким разделением кода, испытает соблазн использовать шаблон Factory - мы были! И мы обнаружили, что это была огромная ошибка.

Вот пример, объясняющий, почему:

У вас есть форматирование, функция. Он форматирует поля в соответствии с их типом (целое число, число с плавающей запятой, строка и т. Д.). Для 99% наших полей этот код будет общим, поэтому он находится в общем пакете:

const formatField = (field) => ...

Однако в некоторых приложениях мы захотим переопределить это средство форматирования для особых нужд приложения. Допустим, мы дополняем это поведение нашим собственным средством форматирования в приложении:

import { formatField } from ‘common’;
const applicationFormatField = (field) => {
   if (…) { return applicationSpecificFormat; }
   return formatField(field);
}

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

Скажем, мы потом создаем компонент форматирования:

const formattingComponent = (value) => <div> {formatFunction(value)}</div>

Теперь, если мы хотим сделать этот компонент общим, мы получим:

const formattingComponent = (formatFunction) => (value) => 
  <div> {formatFunction(value)}</div>

По нашему опыту, этот шаблон очень плохо масштабируется, когда есть много вещей, которые нужно ввести. Со временем нам потребовалось предоставить множество различных модулей для конкретных приложений (средства форматирования, сборщики, кэшеры, преобразователи) для общих компонентов. В этом случае фабрики быстро становятся непрактичными.

Решением здесь является использование некоторой формы внедрения зависимостей, например, «контекстного» API React (я бы не рекомендовал по другим причинам) или классов (трудно использовать с компонентами, поскольку в JavaScript нет множественного наследования).

Мы выбрали простой самодельный инжектор зависимостей:

import { mapValues } from 'lodash';

// Plain object that will store all provided dependency
const _dependencies = {};
export function provide(dependencyName, dependency) {
  _dependencies[dependencyName] = dependency;
}

export function inject(dependencies) {
  return (wrapped) => {
    return (...args) => {
      const resolvedDependencies = mapValues(dependencies, (dependency) => {
        return _dependencies[dependency];
      });
      return wrapped(resolvedDependencies)(...args);
    };
  };
}

В какой-то момент приложение делает:

provide('formatField', formatField);

А потребляющие компоненты могут просто запросить его у инжектора зависимостей:

const getFormatField = inject({ format: 'formatField' })(
  ({ format }) => format)
);
const formattingComponent = (value) => 
  <div> {getFormatField()(value)} </div>

Это устраняет необходимость изменять поверхностный API общего компонента при создании общих компонентов и помогает упростить рефакторы.

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

Часто обсуждайте поправки к соглашениям и делайте это

Как упоминалось в нашем предыдущем посте, мы в Botify считаем очень важным проводить частые обсуждения наших правил и нашего руководства по стилю и часто вносить в них поправки. Мы проводим ежемесячные встречи, на которых любой разработчик может внести такие поправки, и мы можем их обсудить.

За последние два года с помощью этого метода мы следили за изменениями в языке JavaScript, а сегодня мы используем новейшие функции LTS JavaScript в нашей кодовой базе.

Есть еще много вещей, которые мы обсуждали внутри компании, но не имели возможности реализовать или опробовать, в том числе:

  • Использование рабочих пространств пряжи для сегментирования рабочих пространств наших приложений
  • Использование lerna или других инструментов для js monorepos
  • Использование матриц сборки Travis для одновременной сборки приложений на CI

Как у вас работает репо? У нашего подхода могут быть пределы, с которыми мы еще не столкнулись. Не стесняйтесь присоединяться к комментариям!

Хотите присоединиться к нам? Мы нанимаем"! Не стесняйтесь отправить мне электронное письмо, если нет открытых вакансий, соответствующих вашим навыкам, мы всегда ищем увлеченных людей.