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

в 7:52, , рубрики: javascript, jest, lerna, monorepo, npm, YARN, 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. К сожалению, пока я не находил подробных инструкций по работе с монорепозиториями на русском языке, поэтому можем считать, что начало положено!

Ссылка на репозиторий тестового проекта.

Автор: Смолин Павел

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js