Создаем монорепозиторий с помощью lerna & yarn workspaces

learn-and-yarn

За последние несколько лет концепция монорепозиториев успешно зарекомендовала себя, так как позволяет значительно упростить процесс разработки модульных программных проектов, таких как инфраструктуры на основе микросервисов. Основные преимущества такого архитектурного подхода очевидны на практике, поэтому предлагаю создать свой тестовый монорепозиторий с нуля, попутно разбираясь в нюансах работы с yarn workspaces и lerna. Ну что ж, начнём!

Рассмотрим структуру нашего проекта, который будет представлять собой три библиотеки расположенные в папке packages/, а также package.json в корневой директории.

├── package.json
└── packages
    ├── app
    │   ├── index.js
    │   └── package.json
    ├── first
    │   ├── index.js
    │   └── package.json
    └── second
        ├── index.js
        └── package.json

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

Библиотеки first и second для простоты эксперимента будут содержать всего по одной функции в index.js, каждая из которых будет возвращать строку приветствия от имени модуля. На примере first выглядеть это будет следующим образом:

// packages/first/index.js
const first = () => 'Hi from the first module';

module.exports = first;

В модуле app мы будем выводить в консоль сообщение Hi from the app, а также приветствия из двух других пакетов:

// packages/app/index.js
const first = require('@monorepo/first');
const second = require('@monorepo/second');

const app = () => 'Hi from the app';

const main = () => {
  console.log(app());
  console.log(first());
  console.log(second());
};

main();

module.exports = { app, main };

Чтобы first и second были доступны в app, обозначим их как зависимости в dependencies.

Кроме того, для каждой библиотеки добавим в локальный package.json префикс @monorepo/ в значении name перед основным именем пакета.

// packages/app/package.json
{
  "name": "@monorepo/app",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "@monorepo/first": "^1.0.0",
    "@monorepo/second": "^1.0.0"
  }
}

Зачем нужен префикс со значком собачки перед именем npm пакета (@monorepo/)?
Добавление префикса необязательно, но именно такой конвенции именования пакетов придерживаются многие монорепозитории: babel,
material ui, angular и другие. Дело в том, что каждый пользователь или организация имеет свой собственный scope на сайте npm, благодаря чему имеется гарантия того, что все модули с постфиксом @somescope/ созданы именно командой somescope, а не злоумышленниками. Более того, появляется возможность называть модули именами, которые уже заняты. Например, нельзя просто взять и создать собственный модуль utils, ведь такая библиотека уже существует. Однако добавив постфикс @myscopename/ мы сможем получить свой utils (@myscopename/utils) с блэкджеком и барышнями.

Аналогией из реальной жизни для нашего тестового проекта могут быть различные библиотеки для работы с данными, инструменты валидации, аналитики или просто набор UI-компонентов. Если же предположить, что мы собираемся разрабатывать web и mobile приложение (например, используя React и React Native соответственно), и у нас есть часть переиспользуемой логики, возможно, стоит вынести её в отдельные компоненты, чтобы потом использовать в других проектах. Добавим к этому сервер на Node.js и получится вполне реальный кейс из жизни.

Yarn workspaces


Последним штрихом перед созданием полноценного монорепозитория будет оформление package.json в корне нашего репозитория. Обратите внимание на свойство workspaces — мы указали значение packages/*, что означает «все подразделы в папке packages». В нашем случае это app, first, second.

// package.json
{
  "name": "monorepo",
  "version": "1.0.0",
  "main": "packages/app/index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

Кроме того, в package.json нужно обязательно указать «private»: true, так как workspaces доступны только в приватных проектах.

Для того чтобы всё взлетело, выполним команду yarn (аналог yarn install или npm install) из корневой директории. Поскольку зависимости, которые есть в модуле app, определены как workspaces в корневом package.json, фактически, мы ничего не скачаем из npm-registry, а просто свяжем («залинкуем») наши пакеты.

yarn

image

Теперь мы можем выполнить команду node . из корневой директории, которая запустит скрипт из файла packages/app/index.js.

node .

image

Давайте разберемся, как это работает. Вызвав yarn, мы создали в node_modules символические ссылки на наши директории в папке packages.

image

Благодаря такой связи в зависимостях, мы получили одно большое преимущество — теперь при изменении в модулях first и second наше приложение app получит актуальную версию этих пакетов без пересборки. На практике это очень удобно, т.к. мы можем вести локальную разработку пакетов, по-прежнему определяя их как сторонние зависимости (какими они и становятся в конечном итоге).

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

Подробнее о хранении зависимостей на верхнем уровне
Предположим, что мы захотели использовать библиотеку lodash в first и second. Выполнив команду yarn add lodash из соответствующих директорий мы получим обновление локальных package.json — в dependencies появится актуальная версия пакета.
"dependencies": {
   "lodash": "^4.17.11"
 }

Что же касается самого пакета lodash — физически библиотека будет установлена в node_modules на корневом уровне один раз.
Если же необходимая версия внешнего пакета (в нашем случае lodash) отличается для first и second (например в first нужна lodash v3.0.0, а в second v4.0.0), то в корневой node_modules попадет пакет с более низкой версией (3.0.0), а версия lodash для модуля second будет храниться в локальном packages/second/node_modules.
Кроме плюсов у такого подхода могут быть незначительные минусы, которые yarn позволяет обойти с помощью дополнительных флагов. Подробнее о таких нюансах можно прочитать в официальной документации.

Добавляем Lerna


Первым шагом работы с lerna является установка пакета. Обычно совершают глобальную установку (yarn global add lerna или npm i -g lerna), но если вы не уверены, что захотите использовать эту библиотеку, можно воспользоваться вызовом с помощью npx.

Из корневой директории проинициализируем lerna:

lerna init

image

Фактически, мы выполнили сразу несколько действий с помощью одной команды: создали git репозиторий (если он не был проинициализирован до этого), создали файл lerna.json и обновили наш корневой package.json.

Теперь в только что созданном файле lerna.json добавим две строчки — «npmClient»: «yarn» и «useWorkspaces»: true. Последняя строка говорит о том, что мы уже используем yarn workspaces и нет необходимости создавать папку app/node_modules с символические ссылками на first и second.

// lerna.json
{
  "npmClient": "yarn",
  "packages": [
    "packages/*"
  ],
  "version": "1.0.0",
  "useWorkspaces": true
}

Тесты с Lerna


Для того чтобы показать удобства работы с lerna добавим тесты для наших библиотек.
Из корневой директории выполним установку пакета для тестирования — jest. Выполним команду:

yarn add -DW jest

Зачем нужен флаг -DW?
Флаг -D(--dev) нужен, чтобы пакет jest установился как dev зависимость, а флаг -W(--ignore-workspace-root-check) позволяет совершить установку на корневом уровне (что нам и необходимо).

Следующим шагом добавим по одному тестовому файлу в наш пакет. Для удобства нашего примера сделаем все тесты похожими. На примере first файл с тестом будет выглядеть следующим образом:

// packages/first/test.js
const first = require('.');

describe('first', () => {
  it('should return correct message', () => {
    const result = first();
    expect(result).toBe('Hi from the first module');
  });
});

Также нам необходимо добавить скрипт для запуска тестов в package.json каждой из наших библиотек:


  // packages/*/package.json
  ...
  "scripts": {
    "test": "../../node_modules/.bin/jest --colors"
  },
  ...

Последним штрихом будет обновление корневого package.json. Добавим скрипт test, который будет вызывать lerna run test --stream. Параметр, следующий после lerna run определяет команду, которая будет вызвана в каждом из наших пакетов из папки packages/, а флаг --stream позволит нам увидеть вывод результатов работы в терминале.

В итоге package.json из корневой директории будет выглядеть следующим образом:

// package.json
{
  "name": "monorepo",
  "version": "1.0.0",
  "main": "packages/app/index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "test": "lerna run test --stream"
  },
  "devDependencies": {
    "jest": "^24.7.1",
    "lerna": "^3.13.2"
  }
}

Теперь, чтобы запустить тесты нам достаточно из корня нашего проекта выполнить команду:

yarn test

image

Обновление версий с Lerna


Следующей популярной задачей, с которой lerna может качественно справиться будет обновление версий пакетов. Представим, что после имплементации тестов мы решили обновить версию наших библиотек с 1.0.0 до 2.0.0. Для того чтобы это сделать, достаточно добавить в поле scripts корневого package.json строку «update:version»: «lerna version --no-push», а затем выполнить yarn update:version из корневой директории. Флаг --no-push добавлен, чтобы после обновления версии изменения не отправлялись в удаленный репозиторий, что lerna делает по умолчанию (без данного флага).

В итоге наш корневой package.json будет выглядеть следующим образом:

// package.json
{
  "name": "monorepo",
  "version": "1.0.0",
  "main": "packages/app/index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "test": "lerna run test --stream",
    "update:version": "lerna version --no-push"
  },
  "devDependencies": {
    "jest": "^24.7.1",
    "lerna": "^3.13.2"
  }
}

Запустим скрипт обновления версии:

yarn update:version

Далее нам будет предложено выбрать версию, на которую мы хотим перейти:

image

Кликнув Enter мы получаем список пакетов, в которых обновлена версия.

image

Подтверждаем обновление вводом y и мы получаем сообщение об успешном обновлении.

image

Если попробовать выполнить команду git status, мы получим сообщение nothing to commit, working tree clean, т.к. lerna version не только обновляет версии пакетов, но и затем создаёт git коммит и тег с указанием новой версии (v2.0.0 в нашем случае).

Особенности работы с командой lerna version
Если в поле scripts корневого package.json добавить строку «version»: «lerna version --no-push» вместо «update:version»: «lerna version --no-push», то с большой вероятностью можно наткнуться на неожиданное поведение и красную консоль. Дело в том, что npm-scripts по умолчанию вызывает команду version(зарезервированный скрипт) сразу же после обновления версии пакета, что приводит к рекурсивному вызову lerna version. Чтобы избежать такой ситуации достаточно дать скрипту другое название, например update:version, как и было сделано в нашем примере.

Заключение


Приведенные примеры показывают одну сотую всех возможностей, которыми обладает lerna в связке с yarn workspaces. К сожалению, пока я не находил подробных инструкций по работе с монорепозиториями на русском языке, поэтому можем считать, что начало положено!

Ссылка на репозиторий тестового проекта.
Поделиться публикацией

Комментарии 21

    +1
    Я ничего не хочу сказать плохого про монорепы или лерну, но вот совсем недавно по долгу службы пришлось немного влезть в как раз такую монорепу на четыре пакета. А дальше я мягко говоря поразился — приложение автогенеренное, не супербольшое (дев-билд «кишками наружу» даёт бандл всего лишь 20Мб), а чтоб лерна накатила зависимости — мне пришлось убить почти все остальные процессы, ибо одного ядра на 3ГГц и 4Гб памяти ей тупо не хватало (тормоза адские). Серьезно? Чтоб зависимости накатить?
      0
      Возможно, у вас попался специфический проект или не было workspaces. В случае отказа от монорепы вам пришлось бы накатить зависимости в каждый из четырёх пакетов отдельно. Вполне вероятно, не пришлось бы убивать процессы, а вот по времени было бы однозначно дольше из-за дублей пакетов.
        0
        Нет, монорепа создана именно по вот этому описанию, которое вы перевели. Я потому и вспомнил, что начал читать статью и сразу же «где-то я это видел, именно то, что тут написано».

        ЗЫ: Пардон, это не перевод, а ваше собственное :-) Ну, видимо, где-то в мире подобные инструкции уже где-то были.
          0
          Понял, спасибо за комментарий! Я попробую побаловаться с производительностью и поделюсь с вами результатами.
            0
            С вашей статьёй в любом случае всё в порядке :-) Что у меня в запасе есть камни в адрес ярна и лерны — это вашу статью не ухудшает, да репа в любом случае была не моя, и сходу так сказать, что проблема там была в самих инструментах, а не в способе их использования — я не могу. У меня и в адрес npm камни есть, но только в прошедшем времени, нынче npm как-то на удивление не создаёт проблем.
      0
      ‍Ссылка на репозиторий с примером: github.com/SmolinPavel/monorepo
        0
        Существует альтернатива такому подходу (в случае использования транспиллера) — alias в webpack или paths в Typescript.

        Не могу не отметить, что уже глаз дергается когда вижу подобное использование стрелочных функций. Простите за занудство.
          +1
          Да, я тоже использовал webpack alias для возможности вызывать локальные файлы как сторонние зависимости. Тем не менее, это только часть удобств, которые даёт связка lerna + yarn workspaces.
          Подскажите, почему вас смутило использование стрелочных функций? :)
            –2
            Это конечно все IMHO.
            • с классическими функциями код читабельнее (легче выхватываются при беглом чтении);
            • используя классический синтаксис, мы явно выражаем свое намерение — объявить функцию;
            • классический синтаксис объявляет функцию сразу после окончания парсинга, в то время как анонимная функция объявляется только во время выполнения.

            Когда стрелочные функции предпочтительны:
            • конечно, при создании анонимной функции с одной целью — передача без промежуточного присваивания переменной;
            • когда локальный контекст внутри мешает;
            • когда выражается намерение объявить код, который нужен только в узком локальном контексте;
            • при функциональном программировании они смотрятся гармоничнее;
              0
              Согласен со всеми пунктами, которые вы привели, кроме стрелочные функции предпочтительны, когда локальный контекст внутри мешает.
              Возможно, это как раз то, что вы и имели в виду, но приведу пример с функцией merge:

              // merge объявлена как классическая функция (через function)
              const person = {
                name: 'MegaHertz',
                pull() {
                  console.log(this);
                  const merge = function() {
                    console.log('merge', this);
                  };
                  merge();
                }
              };
              
              person.pull(); // Window {postMessage: ƒ, blur: ƒ, parent: Window, …}
              

              Когда merge объявлена как классическая функция (через function) мы получили глобальный контекст в this, т.е. this === window.

              // merge объявлена как стрелочная функция
              const person = {
                name: 'MegaHertz',
                pull() {
                  const merge = () => {
                    console.log(this); 
                  };
                  merge();
                }
              };
              
              person.pull(); // {name: "MegaHertz", commit: ƒ}
              

              Во втором примере, использование стрелочной функции как раз и позволяет нам привязать контекст, ведь такого же результата мы бы добились, если бы в первом примере вместо merge() вызывали merge.bind(this)(). В итоге this === person.

              Данным примером я хотел показать, что если «локальный контекст внутри мешает», то, возможно, это как раз следствие использования стрелочных функций, ведь иначе контекст не был бы привязан (в случае использования функции через function).
                0
                Да, я не точно выразился, но идея именно эта. То что внутри стрелочной функции не создается локальный контекст, который в этом примере не желателен.
          0
          «test»: "../../node_modules/.bin/jest --colors"
          Под windows такое будет работать (там ведь cmd скрипты)?
            0
            Хорошее замечание. Сам работаю на Mac, а для Windows, чтобы 100% заработало, можно изменить эту строку на «test»: «cross-env ../../node_modules/.bin/jest --colors», при условии что пакет cross-env предварительно установлен как dev зависимость.
              0
              для Windows, чтобы 100% заработало, можно изменить эту строку на «test»: «cross-env ../../node_modules/.bin/jest --colors»
              А такое решение проверялось вами в действии на windows?
                0
                Проверялось раньше, но для 100% уверенности проверю еще раз на windows машине.
                У вас есть сомнения по поводу cross-env?
                  0
                  Сомнений в том что cross-env позволяет кросс-платформенно устанавливать переменные окружения у меня нет, но здесь несколько другая задача.
                    0
                    Да, сейчас у меня точно такие же сомнения зародились. Давайте я в понедельник посмотрю на своей windows машине и отпишусь по результатам + обновлю пост (в случае необходимости).
                    Вам в любом случае спасибо за замечание!
                      0
                      cross-env работает, да.
                        0
                        Проверил на windows. Оказалось ничего дополнительно ставить не нужно, работает как и на mac/linux. Привожу скрины из cmd и git-bash:
                        image
            0
            Как будет выглядеть workflow (и git flow) в такой ситуации:
            В монорепе есть несколько пакетов (библиотеки компонентов, утилиты и т.д.) и несколько приложений (приложения между собой не связаны, но зависят от одних и тех же пакетов).
            Допустим, мы сделали критическое изменение в одном из пакетов, поднимаем версию, в одном из приложение обновляем версию до последней, второго приложения наши действия никак не коснутся?
              0
              Если приложения между собой не связаны, но зависят от одних и тех же пакетов, можно использовать lerna c флагом --independent, который задается при инициализации lerna. Подробнее использование описано в документации, но фактически всё сводится к обновлению строки с версией на «version»: «independent» в файл lerna.json:

              // lerna.json
              {
                "npmClient": "yarn",
                "packages": [
                  "packages/*"
                ],
                "version": "independent",
                "useWorkspaces": true
              }


              Использование такого «независимого» режима позволяет обновлять версии пакетов независимо друг от друга.
              Если у вас похожий flow, скорее всего стоит посмотреть в сторону lerna-semantic-release.
              Я правильно понял ваш вопрос?

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое