Вместо предисловия
Доброго времени суток! Меня зовут Сергей, и я тимлид в компании Медпоинт24-Лаб. Я занимаюсь разработкой на nodejs чуть больше полутора лет - до этого был C#, ну а ещё до того, всякое разное и не очень серьёзно. Ну то есть, опыта у меня не так чтобы вагон, и иногда приходится серьёзно поломать голову при решении возникающих проблем. Решив такую, всегда хочется поделиться находками с товарищами по команде.
И вот несколько дней назад, они посоветовали мне завести блог... а я подумал, может тогда просто написать на Хабр?
Возможно, примеры практических ситуаций, из которых со скрипом вылезает толковый, но не очень опытный разработчик, будут интересны таким же толковым и неопытным )) А может и ещё кому пригодится.
Рассказывать постараюсь без погружения в теорию, но со ссылками на оную.
О чём пойдёт речь?
Пилот будет посвящён интересной проблеме с которой мы столкнулись при попытке организовать CI/CD для монорепозитория с lerna. Сразу скажу, что этот пост:
не про монорепозитории. Плюсы и минусы монорепы, как концепции, уже давно описаны в множестве постов, в том числе на хабре (этот довольно холиварный, кстати)
не про инструменты для управления монорепозиториями. Монорепу можно реализовать при помощи Nx, rush, даже просто yarn workspaces. Но так получилось, что мы выбрали lerna и поживём с ней какое то время.
не про пакетные менеджеры. Могу порекомендовать хороший видос со сравнением npm, yarn и pnpm и офигенную серию постов в которой работа c npm объясняется с самых азов и очень тщательно. А у нас npm (пока)...
не про nestjs. Но он классный!
Обо всём этом будет рассказано только в том объёме, который нужен для понимания проблематики.
Тогда о чём?
Дано:
Имеется маленький монорепозиторий, в котором лежит сервер на несте и npm-пакет, который содержит всё необходимое для клиентского приложения, которое будет этот сервер вызывать.
packages
+-- @contract
| +-- src
| +-- package.json
| ...
|
+-- application
| +-- src
| +-- package.json
| ...
|
+-- package.json
+-- lerna.json
...
Зачем пакет?
Клиентский пакет или, как мы его называем, "контракт" нужен в первую очередь для обеспечения проверки типов в клиентском коде и ускорении разработки.
То есть, вызывающий код делает не просто axios.post(....) передавая туда никак не проверяемые статически параметры (any), а вызывает метод с типизированным входом и выходом.
import { Client } from '@contract/some-service';
const client = new Client(options);
const filters: StronglyTypedObject = ...
const data = await client.getSomeData(filters)
/*
* И результат у нас тоже типизированный.
* А ещё из getSomeData() вылетают типизированные ошибки известного формата,
* чем в нас обычно кидается, axios.
*/
Наша практика показала, что это очень облегчает и ускоряет разработку для всех, кто использует наш сервис, включая фронтенд. Так что нам подход понравился.
Кроме того, недавно мы пошли чуть дальше и сделали так:
const query = new SomeQuery({ ... });
const data = await client.call(query);
/*
* Теперь клиент у нас общий на все сервисы, а вот запросы и команды -
* волшебные и сами знают, что делать. Это мы используем с rabbitMQ.
*/
Такой подход мы используем не только для http-сервисов, но и для сервисов, которые обрабатывают сообщения из RabbitMQ, а также при помощи одного из самописных средств транспорта на основе redis. Но усложнять статью этими деталями не будем.
Так вот, у нас есть монорепа, что она нам даёт? В первую очередь, удобство разработки. Базовая фича для всех релевантных инструментов - это то, что в lerna называется bootstrap.
lerna bootstrap --hoist
Флаг --hoist
- самая приятная часть. Он говорит лерне, что все зависимости, если можно, надо ставить в node_modules в корне проекта. Мы на этом экономим место + получаем ещё бонус, который нам пригодится дальше.
Помимо установки пакетов lerna bootstrap
создаёт симлинки на пакеты имеющиеся в репозитории. То есть, хотя в application/package.json указано
"dependencies": {
"@contract/core": "^1.0.0"
}
в действительности, этот пакет не будет установлен из npm-реестра, а просто прилинкуется в node_modules из папки packages. Таким образом, мы его меняем, собираем и сразу используем новую версию в нашем приложении.
Задача
Мы строим систему CI/CD. И нам нужно научиться красиво вписать наш монорепозиторий в конвеер. Казалось бы, задача должна была уже 1000 раз быть решена - настолько она очевидна.
И действительно, есть куча issues на github, посты на Stackoverflow и др. ресурсах. Но нет рецептов.. костыли есть, и то не все рабочие, а нормального "штатного" решения я не нашёл (искал, чесслово).
Так вот, когда наш сервис готов к релизу:
Мы хотим смержить PR и, таким образом, запустить пайплайн.
Мы хотим собрать проект, прогнать линтер, unit-тесты.
Мы хотим поднять версию приложения и пакета (а в чём не было изменений - там не поднимать).
Мы хотим опубликовать пакет @contract в npm registry (в нашем случае, приватном).
Мы хотим собрать из нашего приложения артефакт, который потом отдать тестировщикам, ну а в конце скопировать на рабочую виртуалку и запустить. (да, да, я знаю - docker, но не все сразу. В любом случае, проблемы там будут те же самые)
Ну и мы хотим, чтобы наш артефакт был разумного размера и содержал только то, что ему реально нужно для работы. node_modules по ГБ - не совсем то, что нам нужно.
Поехали!
Первый пункт берут на себя CI/CD системы.
Со вторым проблем быть не должно:
Для третьего lerna нам предлагает две прекрасные команды: lerna version и lerna publish (последняя включает в себя первую и мы будем использовать её). Примерно так:
lerna publish --conventional-commits --yes
# На заметку: команда publish принимает все флаги команды version.
# В доке это есть, но я с первого раза пропустил.
Чуть подробнее про conventional commits.
Если делать команду lerna publish
без указанных ключей, то поднятие версии будет интерактивным,что для CI-конвейра не годится. На помощь приходит спецификация Conventional Commits. Соблюдение этого простого и понятного соглашения по структуре commit-сообщений, позволяет lerna автоматически определить, какую как правильно по semver поднять (минор, мажор или патч). Самое милое, что мы можем вынудить наших разработчиков писать правильные коммиты (что само по себе хорошо)! Вот достаточно подробная инструкция.
С пунктом 4 у нас тоже нет проблем.lerna publish
нам это уже сделала, а если нас это почему-то не устраивает (ну, к примеру, мы не хотим ставить теги или ещё что), то используем lerna version
в сочетании с npm publish
из директории пакета. Забавно, что npm publish не имеет ключа --registry
, чтобы указать, куда пушить пакет. В случае lerna publish
, нас выручит lerna.json (стр. 7):
{
"version": "1.2.2",
"npmClient": "npm",
"command": {
"publish": {
"message": "chore(release): publish",
"registry": ....
}
},
"packages": [
"packages/@contract",
"packages/application"
]
}
Иначе нам понадобится файл .npmrc (файл с настройками npm) в директории пакета.
Первые сложности
Итак, наша CI-машина должна делать примерно следующее (без привязки к конкретной системе CI/CD):
# Pull и checkout
lerna bootsrap --hoist
lerna run build # Запустит команду npm run build в каждом пакете.
lerna publish --conventional-commits --yes
cp packages/application/build /tmp/place/for/artifact
...
Но для работы нашему приложению нужны ещё node_modules.
Попытка №1. Можно конечно взять да и скопировать папку node_modules из корня проекта в наш /tmp/place/for/artifact. Но тогда:
Мы точно получим лишние зависимости (всякие jest, typescript и кучу ещё всего ненужного в рантайме). А если у нас в репозитории не 2 пакета, а 22, то размер node_modules может быть неприличным.
Мы, возможно, недополучим нужные зависимости, т. к. lerna поднимает пакеты в корень только если может. Так бывает не всегда - могут быть где-то разные версии, например.
Попытка №2. Не вопрос. У нас же есть package.json внутри packages/application. Там ведь перечисленно всё, что надо! Копируем package.json в папку с артефактом, запускаем npm i
- профит! Но:
Дело в том, что для обеспечения повторяемости билда, в CI среде вместо команды npm install принято использовать npm ci
. Основное отличие от npm install в том, что пакеты ставятся не из package.json, а из package-lock.json или shrinkwrap.json (смысл тот же). Подробнее о lock-файлах можно почитать в хорошем переводе от Андрея Мелихова.
Для моего рассказа важно понимать следующее:
Обойтись без lock-файла никак нельзя. Даже если указать в dependencies точные версии зависимостей без всяких "~" и "^" - это не поможет, т. к. транзитивные зависимости (то есть зависимости ваших зависимостей) вы не контролируете.
lock-файл должен быть синхронизирован с package.json. Это значит, что если у нас в package.json появилась новая зависимость (или наоборот), а в package-lock.json её нет, то npm ci будет ругаться:
Приятно, что текст ошибки содержит прямое указание на то, что нам нужно сделать - npm install.
Давайте вспомним, с чего мы начинали нашу сборку: lerna bootstrap --hoist
Эта команда на самом деле уже создала нам package-lock.json в корне проекта. Однако, это нам мало помогает.
Можете убедиться в этом сами, скопировав в артефакт package.json из packages/application и lock-файл из корня - получите ошибку. Конечно, ведь там никакого намёка не будет на синхронизацию! А в application lock-файла у нас нет. Поэтому:
Попытка №3. Давайте попробуем обойтись без "всплытия". Да, не супер удобно, зато lock-файл будет там где надо. Делаем просто:
lerna bootstrap
Это действительно даст нам по lock-файлу на каждый проект. Но и тут всё не слава Богу! Потому что при попытке с этим файлом сделать npm ci
, нам опять скажут нехорошие слова про синхронизацию. Как так?
Изучаем файл package-lock.json и видим.. что там не хватает пакета @contract/core!Ну и правильно, мы его не устанавливали, а делали симлинк...
Попытка №4. Ок, делаем просто npm install внутри каждого пакета. Тут нам поможет:
lerna exec -- npm i
Ура, теперь lock-файл и манифест внутри пакета синхронизированы! npm ci
работает! Победа!
Запускаем наше приложение и при первом же запросе...
Оно падает
Говорит, что с модулем @contractчто-то не то. Конечно не то! Ведь npm i
поставил этот модуль из npm registry. Ну тогда понятно - это только формально та же версия. А по факту, мы локально могли внести изменения, в том числе и ломающие, но версию ещё не поднимали и пакет не пушили (напоминаю, что build у нас до publish). Если же никаких изменений в пакете не было, то всё сработает.. и это скорее плохо, чем хорошо. Лучше пусть всегда не работает, чем то так, то сяк.
Думали гадали насчёт того, как бы делать publish
сначала. Но это не логично - код надо собрать, потом протестировать, потом только паблишить - а он, зараза, не собирается.
Попытка №4. Ну ок, нам нужен симлинк, давайте его сделаем...
Выполняем:
lerna exec -- npm i # Создаётся lock-файлы в пакетах.
lerna link # Создаются симлинки.
lerna run build
lerna publish --conventional-commits ...
cp packages/application/build /path/to/artifact
# Можно ещё вместо копирования сделать production сборку
# - без sourceMaps и деклараций.
cp packages/application/package*.json /path/to/artifact
(cd /path/to/artifact && npm ci --production)
И оно даже работает! Только мы кое-что забыли.. Добавим вызов jest где-нибудь между 3-й и 4-й строкой...
И внезапно падают уже тесты
Могут конечно и не упасть... особенно, если их нет. Но приложение может работать некорректно. А точнее, не так, как на машине разработчика, где он делает lerna bootstrap --hoist
а потом билд.
До этого момента все проблемы были в общем-то тривиальны. Времени много ушло потому, что не было некоторых базовых знаний платформы и инструментов. Сейчас - после нескольких часов проведённых в гугле (хабре, медиуме, гитхабе...) - уже кажется, что всё просто. А вот новая проблема прямо мистическая. И возможно, вы с ней не столкнётесь. Но в чём тут суть, ИМХО понимать полезно.
Итак, lerna bootstrap --hoist
и lerna exec -- npm i && lerna link
- в чём может быть разница? Ведь второе - это по сути lerna bootstrap
, но без --hoist
. Пробуем на машине разработчика убрать флаг hoist... тесты падают. Добавляем - проходят.
На самом деле, когда первый приступ отупения проходит, можно осознать следующее:
packages
+-- @contract
| +-- node_modules
| +-- class-transformer
| +-- src
| +-- package.json
| ...
|
+-- application
| +-- node_modules
| +-- class-transformer
| +-- @contract -> символическая ссылка
| +-- src
| +-- package.json
| ...
|
+-- package.json
+-- lerna.json
...
На схеме подсказка. И application и contract зависят от пакета class-transformer. Вообще-то, там есть и другие общие зависимости, но, к счастью, не все зависимости ломаются, когда в структуре node_modules они присутствуют в двойном экземпляре.
class-transformer - из тех, что ломается.
Подробнее о том, почему
class-transformer - удобная библиотека для преобразования объектов основанная на декораторах. В nestjs она встроена в дефолтную систему валидации (ValidationPipe). Самый простой пример её использования может быть такой:
import { Type } from 'class-transformer';
import { IsInt, IsPositive } from 'class-validator';
export class Query {
@IsInt()
@IsPositive()
@Type(() => Number)
id: number;
}
При этом мы это посылаем в GET запросе (?id=100500) и получается, что nest на вход получит строчку, а не число. И валидатор IsInt() на это ругнётся (может и нет, но IsPositive() ругнётся 100%).
Поэтому мы говорим несту: преобразуй пожалуйста в число. Декоратор @Type() - самый простой способ. Если я не ошибаюсь, то он сделает просто return Number(id)
Для более сложных случаев можно использовать декоратор @Transform() в который можно передать функцию преобразования.
Всё это вы можете найти в доке по class-validator и class-transformer.
Но вот только будте осторожны - функция трансформации НИ В КОЕМ СЛУЧАЕ не должна бросить ошибку. Это положит поток (на горьком опыте - потерянные 3 часа жизни)
Так вот:
Что произойдёт в нашем случае. Когда отработает декоратор @Type(), он он запишет в специальный объект в недрах class-transformer метаданные: "у вот этого класса надо преобразовывать входящее значение в число". Потом, когда объект придёт в ваше приложение nest вызовет функцию plainToClass из той же самой библиотеки, передав туда данные и конструктор Query. Она из того же объекта считает метаданные и проведёт преобразование.
В этом "том же" вся проблема. Если копий библиотеки у нас две, то это может быть два разных объекта и когда plainToClass будет работать, метаданных установленных в декораторе @Type() там не окажется!
Это экспериментально подтверждённый факт. Но вот почему это так работает, я не до конца понимаю. Всегда считал, что
import
повторно одни и те же сущности не загружает, но видимо всё сложнее.
Если кто-то уже достиг дзен - напишите пожалуйста в комментах, кому-то точно пригодится.
А мы как раз поместили наш класс Query в один пакет, а приложение в другой и если внимательно посмотреть на структуру проекта выше, но становится понятно, что у @contractнет никаких шансов найти нужную копию class-transformer.
Забавно, что у class-validator такой проблемы нет. Возможно, они хранят метаданные иначе (в global?). Как именно ещё не успел посмотреть.
Вот собственно ответ и найден. Получается, из-за того, как работает резолв зависимостей в ноде (ищем в node_modules, потом поднимаемся выше, ищем в node_modules... и так до рута) в случае симлинков нам очень выгоден --hoist. В случае установки из registry, пакетный менеджер сделает примерно (меня пугает это примерно...) то же - поднимет всё, что сможет поднять.
Дальше исследовать ситуацию было уже физически невозможно, поэтому было принято решение - переспать...
Что в итоге?
Чуток устаканив в голове новое понимание (и вопросы), я родил примерно следующее:
Когда программист начинает работу над репозиторием (только что слонировав его), он действует по плану:
lerna bootstrap --hoist # Не npm i в корне! Это сломает ваш lock-file!
lerna run build
jest
# ну и работаем...
В CI делаем то же самое, потом
lerna publish
, а затем:
# Makefile
# Получаем версию приложения.
BUILD:=build.$(shell jq .version packages/application/package.json | sed 's/"//g')
artifact:
# Скрипт build/prod убирает sourceMap'ы и декларации, которые не нужны в продакшене
(cd packages/application && npm run build:prod -- --outDir ../../deploy/$(BUILD))
cp -r packages/application/package*.json deploy/$(BUILD)
# Ставим только рантайм-зависимости из package-lock.json
(cd deploy/$(BUILD) && npm ci --production)
# Я вырезал кое-какие несущественные подробности, типа удаления package*.json и
# создания tar.gz архива.
rm deploy/$(BUILD)/package*.jsosdf
Мы используем make, но это не суть. В итоге, это всё планируется перенести в Dockerfile, когда наша инфраструктура будет к этому готова.
Как создать корректные lock-файлы, если их нет?
lerna exec -- npm i
lerna clean --yes
# Именно так. Ставим модули, а потом удаляем. Но это единственный способ получить
# lock-файлы
lerna bootstrap -- hoist
Ну и последнее и, пожалуй, самое неприятное. Как установить новый пакет в наш application (или @contract)таким образом, чтобы сохранить консистентность lock-файлов:
# Makefile
add:
# (ОЧЕНЬ ВАЖНО) Здесь обновится только package.json
lerna add --scope=$(scope) $(package) --no-bootstrap
# Обновится package-lock.json внутри пакета
lerna exec --scope=$(scope) -- npm i
# node_modules внутри units/application нам не нужны!
lerna clean --yes
# вернёт нам все зависимости в корень и обновит рутовый package-lock.json
lerna bootstrap --hoist
# Запускаем так (в scope надо передавать имя пакета из package.json):
$ make add scope=app_name package left-pad
Почему так сложно? Потому что команда lerna add не обновляет package-lock.json, а только сам манифест. Не понятно почему. Может быть я не нашёл чего-то. Подскажите...
Выводы:
Зависимости в ноде - это сложно.
Управление зависимостями в монорепозиториях в условиях CI/CD - это ещё сложнее.
Но самое главное, свет в конце всегда туннеля есть! И пока решаешь такого рода заморочки, частенько удаётся поднять хороший пласт новых знаний.
Уверен, что это не последняя итерация. Меня не покидает ощущение, что всё можно сделать проще, чище - буду рад мнениям и идеям в комментариях.
Надо ещё поиграться с командой npm shrinkwrap, например...
Большое спасибо тем, кто дочитал до конца... Если здесь ещё кто-нибудь есть?
Если такой формат "история из практики" интересен, напишите пожалуйста, что "так", что "не так". Потому что историй... их есть у меня.
Спасибо за внимание!
VolCh
Есть нюансы в резолверах модулей (не проверял по стандарту это или нет): "логически" один и тот же модуль, может считаться в других модулях разными (свой скоуп объявлений сущностей типа переменных, функций и т. д.), если импортируется по разному, например в одном месте как ../../packages/some-service/contract (характерно для автоимпорта в ИДЕ в монорепе), а в другом как some-service/contract, благодаря конфигам резолвера, даже если физический путь в итоге один… А уж если они разные (разные папки нодемодулес или, может быть, симлинки шалят), то шансов вообще нет