Монорепозиторий на TypeScript: история о том, как мы все сломали и сделали лучше

Монорепозиторий на TypeScript: история о том, как мы все сломали и сделали лучше

6 мая 2023 г.

Всем привет, dev.family на связи. Хотим рассказать вам об интересном проекте, над которым мы работали почти полгода и продолжаем до сих пор. За это время в ней многое произошло, многое изменилось. Открыли для себя кое-что интересное, успели набить шишки.

Какой будет наша история?

  • Что мы сделали
  • Как мы начинали
  • К чему это нас привело?
  • С какими проблемами мы столкнулись
  • Почему монорепозиторий?
  • Почему ПНПМ
  • Почему TS Как это работает сейчас
  • Насколько мы упростили себе жизнь

Немного о проекте

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

Начало работы

Необходимо было разработать Shopify модули для подключения к магазинам Shopify, портал для брендов, расширение для Google Chrome, мобильное приложение + сервер с базой данных (ну собственно без них никуда). В общем, с тем, что нам нужно, мы определились и начали работать. Поскольку проект сразу предполагался большим, все понимали, что он может расти, как волшебные бобы отсроченного действия.

Было решено сделать все «правильно» и «по всем стандартам». То есть все написано на одном языке — TypeScript. Чтобы все писали одинаково, и не было лишних изменений в файлах, линтеров (много линтеров), чтобы все было «легко» переиспользовать, ВСЕ в отдельные модули, и чтобы не воровали под токен доступа Github.

Итак, мы начали:

  • Отдельный репозиторий для линтеров и конфигурации ts (руководство по стилю)
  • Репозиторий для мобильного приложения (react native) и расширения для Chrome (react.js) (вместе, поскольку они повторяют один и тот же функционал, только для разных пользователей)
  • Еще один репозиторий для портала
  • Два репозитория для модулей Shopify
  • Репозиторий для API-репозитория блокчейна (express.js) Репозиторий для инфраструктуры

An example of our repositories at the time

Ха... кажется, я все перечислил. Получилось многовато, но ладно, продолжим. Ах да, почему для модулей Shopify было выделено два репозитория? Потому что первый репозиторий — это UI-модули. Там вся красота наших малышек и их настроек. И второе — это интеграции — Shopify. На самом деле это его реализация в Shopify со всеми жидкими файлами. Всего у нас 8 репозиториев, где некоторые должны общаться друг с другом.

Так как мы говорим о разработке на TypeScript, то нам также понадобятся менеджеры пакетов для установки модулей, библиотек. Но мы все работали самостоятельно в своих репозиториях, и никому не было важно, что использовать. Например, разрабатывая мобильное приложение на React Native, я долго не раздумывал и оставил YARN1. Кто-то может привыкнуть к старому доброму NPM, а кто-то любит все новое и использует свежий YARN3. Таким образом, где-то был NPM, где-то YARN1, а где-то YARN3.

Итак, мы все начали делать свои приложения. И почти сразу началось веселье, но не то чтобы полное. Во-первых, некоторые не задумывались над тем, для чего нужен TypeScript, и использовали «Any» там, где им было лень, или там, где они «не понимали», как они не могли его написать. Кто-то не осознавал всей мощи TypeScript и того, что местами все можно сделать намного проще. Поэтому типы вышли из космических измерений. Да, забыл сказать, мы решили использовать Hasura GraphQL в качестве базы данных. Ручная типизация всех ответов из него иногда выглядела как-то иначе. А в одном случае некоторые даже писали на старом добром Javascript. Да, ситуация получилась прикольная: первый парень поставил лишний раз "Любой", чтобы не особо напрягаться, второй пишет холсты шрифтов своими руками, а третий до сих пор вообще не пишет шрифтов.

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

Куда это нас привело?

Что у нас есть? У нас есть 8 репозиториев с разными приложениями. Одни нужны везде, другие общаются друг с другом. Поэтому мы все создаем файлы .NPMrc, прописываем кредиты, создаем гитхаб-токен, потом через пакетный менеджер-модуль. В общем небольшая нервотрепка, хоть и неприятная, но ничего необычного.

Только в случае обновления чего-то в пакете, вам нужно обновить его версию, потом загрузить, потом обновить в своем приложении/модуле, и только потом вы увидите, что изменилось. Но это совершенно неуместно! Особенно, если можно просто где-то поменять цвет. Кроме того, некоторый код повторяется и не используется повторно, а просто незаметно переписывается. Если речь идет о мобильном приложении и расширении для браузера, то там полностью повторяется магазин редуктов и вся работа с API, что-то просто полностью переписывается или немного дорабатывается.

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

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

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

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

Наверное, самое главное, что на многое повлияло в дальнейшем, — это осознание того, что мы строим не конкретное приложение, а платформу. У нас несколько типов пользователей, для них разные приложения, но работают они в рамках одной платформы. Поэтому мы сразу закрыли вопрос с большим количеством репозиториев: если мы работаем на одной платформе, зачем разбивать ее на репозитории, когда проще работать в одном.

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

Давай продолжим. Мы переместили все в один репозиторий. Прохладный! Мы продолжали работать в том же темпе, пока дело не дошло до многоразовости. По сути, это «правило хорошего тона» в нашей работе. Поняв, что где-то мы используем одни и те же алгоритмы, функции, код, а где-то отдельные пакеты, которые устанавливали через github, мы решили, что все это «не очень приятно пахнет» и начали складывать все в отдельные пакеты внутри монорепозитория. с помощью рабочих пространств.

<цитата>

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

По сути, это пакеты внутри одного пакета, которые связываются через определенный пакетный менеджер (любой YARN/NPM/PNPM), а затем используются в другом пакете. Честно говоря, мы не сразу все переписали на рабочие области, а сделали по мере необходимости.

Вот как это выглядит:

Из одного файла

{ "type": "module", "name": "package-name-1", ... "types": "./src/index.ts", "exports": { ".": "./src/index.ts" }, },

В другой файл

<код>{ "тип": "модуль", "имя": "имя-пакета-2", ... "зависимости": { "имя-пакета-1": "рабочая область:*", }, },

Пример использования PNPM

Ничего сложного, собственно, если подумать: напиши пару команд и строк, а потом используй что хочешь и где хочешь. Но «есть одна оговорка, товарищи». Ранее я писал, что все пользовались тем менеджером пакетов, который хотели. Короче говоря, у нас есть репозиторий с разными менеджерами. Местами было смешно, когда кто-то писал, что не может слинковать тот или иной пакет, имея в виду, что он использует NPM, а есть YARN.

Добавлю, что проблема была не из-за разных менеджеров, а из-за того, что люди использовали неправильные команды или что-то не так настроили. Например, некоторые люди через YARN 3 просто сделали ссылку YARN и все, но для YARN 1 это не сработало так, как они хотели, из-за отсутствия обратной совместимости.

После перехода на монорепозиторий

Почему ПНПМ?

К этому моменту стало понятно, что лучше использовать тот же менеджер пакетов. Но нужно выбрать какой, поэтому на тот момент мы рассматривали только 2 варианта — YARN и PNPM. Мы сразу отказались от NPM, потому что он был медленнее других и уродливее. Был выбор между PNPM и YARN.

YARN изначально работал хорошо — он был быстрее, проще и понятнее, поэтому его тогда все использовали. Но человек, который сделал YARN, ушел из Facebook, а разработка следующих версий была передана другим. Так появились YARN 2 и YARN 3 без обратной совместимости с первым. Так же помимо файла yarn.lock они генерируют папку yarn, которая иногда весит как node_modules и хранит в себе кеши.

Поэтому мы, как и многие другие разработчики, обратили свое внимание на PNPM. Он оказался таким же удобным, как и первая ПРЯЖА в свое время. Здесь можно легко использовать рабочие пространства, некоторые команды выглядят так же, как и в первом YARN. Кроме того, позорно-подъем оказался приятной дополнительной опцией - удобнее сразу везде установить node_modules, чем каждый раз ходить в какую-то папку и делать PNPM install.

Turborepo и повторное использование кода

Кроме того, мы решили попробовать турборепо. Turborepo — это инструмент CI/CD, который имеет собственный набор параметров, cli и конфигурацию через файл turbo.json. Устанавливается и настраивается максимально просто. Ставим глобальную копию turbo cli через

PNPM add turbo --global.

Добавление turbo.json в проект

турбо.json

{ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { "dependsOn": ["^build"] } }

После этого мы можем использовать все доступные функции турборепозитория. Нас больше всего привлекли его функции и возможность использования в монорепозитории.

Что нас зацепило:

  • Инкрементные сборки (Инкрементальные сборки — сбор билдов довольно болезненный, Турборепо запомнит, что было построено, и пропустит то, что уже рассчитано);
  • Хеширование с учетом содержимого (Хеширование с учетом содержимого — Turborepo просматривает содержимое файлов, а не временные метки, чтобы выяснить, что нужно построить);
  • Удаленное кэширование (Удаленное хэширование — совместное использование кэша удаленных сборок с командой и CI/CD для еще более быстрой сборки.);
  • Конвейеры задач (конвейеры задач, которые определяют отношения между задачами, а затем оптимизируют, что и когда создавать).
  • Параллельное выполнение (выполнение сборок с использованием каждого ядра с максимальным параллелизмом, без использования простаивающих ЦП).

Мы также взяли рекомендацию по организации монорепозитория из документации и реализовали ее на нашей платформе. То есть мы разбиваем все наши пакеты на приложения и пакеты. Для этого также создаем файл PNPM-workspace.yaml и пишем:

PNPM-workspace.yaml

пакеты:

'приложения/**/*'

'пакеты/**/*'

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

Теперь у нас есть монореп с настраиваемыми рабочими пространствами и удобным повторным использованием кода. Добавлю еще несколько моментов, которые мы делали параллельно. Ранее я упоминал о двух вещах: у нас было расширение для Chrome, и мы решили, что делаем платформу.

Так как наша платформа работала с Shopify в приоритете, мы решили, что вместо расширения для Chrome или в дополнение к нему неплохо было бы сделать еще один модуль для Shopify, который можно было бы просто установить на сайт, чтобы не один раз снова заставить людей загружать мобильное приложение или расширение Chrome. Но оно должно полностью повторять пристройку. Изначально мы делали их параллельно, но поняли, что делаем что-то не так, потому что просто дублировали код. Во всех смыслах мы пишем одно и то же в разных местах. Но так как теперь у нас настроены все рабочие области и повторное использование, мы легко переместили все в один пакет, который мы вызвали в модуле Shopify и расширении Chrome. Таким образом, мы сэкономили себе много времени.

Now this and index.html are the whole Chrome extension

Второе, что сэкономило нам кучу времени, — это устранение вебпака и, местами, билдов вообще. Что не так с вебпаком? На самом деле критических моментов два: сложность и скорость. То, что мы выбрали, это vite. Почему? Он проще в настройке, быстро набирает популярность и уже имеет большое количество работающих плагинов, а для установки достаточно примера из доков. Для сравнения, сборка веб-пакета нашего веб-расширения для Chrome заняла около 15 секунд на vite.js

около 7 секунд (с генерацией файла dts).

Почувствуйте разницу. Что за отказ от билдов? Все просто, как оказалось они нам особо и не нужны, так как это переиспользуемые модули и в package.json, в экспортах можно было просто заменить dist/index.js на src/index.ts.

Как это было

{... "exports": { "import": "./dist/built-index.js" }, ... }

Как сейчас

{ ... "types": "./src/index.ts", "exports": { ".": "./src/index.ts" }, ... }< /p>

Таким образом, мы избавились от необходимости запускать PNPM watch для отслеживания обновлений приложений, связанных с этими модулями, и выполнять сборку PNPM для извлечения обновлений. Не думаю, что стоит объяснять, сколько времени это нам сэкономило.

На самом деле одной из причин, по которой мы собирали билды, были TypeScript, точнее файлы index.d.ts. Чтобы при импорте наших модулей/пакетов мы знали, какие типы ожидаются в одних функциях или какие типы нам вернут другие, например здесь:

All expected parameters are immediately visible

Но учитывая, что из index.tsx можно просто экспортировать, была еще одна причина отказаться от сборок.

TypeScript + GraphQL

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

Как я говорил в самом начале, мы решили написать все на одном языке, чтобы, если кто-то перестанет работать или уйдет, мы могли поддержать или подстраховать. Сначала мы выбрали JS. Но JS не очень безопасен, и без тестов на больших проектах довольно больно. Поэтому мы решили в пользу ТС. Как показала практика, в монорепозитории это очень удобно, за счет того, что можно просто экспортировать файлы *.ts, а при использовании компонентов сразу понятны ожидаемые данные и их типы.

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

запросить getShop {shop { shopName shopLocation }

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

Вернемся к нашему избранному президенту. Мы использовали Hasura, которая представляет собой оболочку GraphQL поверх PostgreSQL. Так как мы работаем с TS, то по-хорошему мы должны набирать данные как из запросов, так и из тех, что отправляем в пейлоад. Если мы говорим о коде из примера выше, то вроде проблем быть не должно. Но на практике запрос может доходить до сотни строк, плюс какие-то поля могут прийти, а могут и не прийти, или иметь разные типы данных. А набирать такие холсты - дело очень долгое и неблагодарное.

Альтернатива? Конечно, у меня есть! Пусть типы генерируются с помощью команд. В нашем проекте мы сделали следующее:

* Мы использовали следующие библиотеки: graphql и graphql-request * Сначала создавались файлы с разрешением *.graphql, в которых записывались запросы и мутации.

Например:

test.graphql

запрос getAllShops {test_shops { идентификатор имя расположение owner_id URL тип домена владелец { имя owner_id } } }

* Далее мы создали codegen.yaml

codegen.yaml

схема: ${HASURA_URL}:headers: x-hasura-admin-secret: ${HASURA_SECRET}

emitLegacyCommonJSImports: false

config: gqlImport: graphql-tag#gql scalars: numeric: string uuid: string bigint: string timestamptz: string smallint: number

генерирует: src/infrastructure/api/graphQl/operations.ts: документы: 'src/**/*.graphql' плагины: - TypeScript - TypeScript-operations - TypeScript- graphql-запрос

Там мы указывали, куда едем, а в конце — куда сохраняем файл со сгенерированным API (src/infrastructure/api/graphQl/operations.ts) и откуда берем наши запросы (src/**/*. графql).

После этого в package.json был добавлен скрипт, который генерировал нам такие же типы:

пакет.json

{... "scripts": { "generate": "HASURA_URL=http://localhost:9696/v1/graphql HASURA_SECRET=secret graphql-codegen-esm --config codegen.yml", ... } , ...

Они указывали URL-адрес, к которому скрипт обращался для получения информации, секрет и саму команду.

* Наконец, мы создаем клиент:

import {GraphQLClient} из "graphql-request"; импортировать {getSdk} из "./operations.js"; export const createGraphQlClient = ({ getToken }: CreateGraphQlClient) => { const graphQLClient = new GraphQLClient('ваш URL идет сюда...'); вернуть getSdk (graphQLClient); };

Таким образом, мы получаем функцию, которая генерирует клиент со всеми запросами и мутациями. Бонусом в Operations.ts заложены все наши типы, которые мы можем экспортировать и использовать, и есть полная типизация всего запроса: мы знаем, что нужно отдать и что придет. Вам не нужно думать ни о чем другом, кроме как запустить команду и насладиться красотой набора текста.

Заключение

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

В заключение хочу сказать, что никогда не следует торопиться. Лучше понять, что вы делаете и как это сделать проще, чем намеренно усложнять себе жизнь. Проблемы есть везде и всегда, рано или поздно они где-то вылезут наружу, и тогда намеренное усложнение прострелит вам колено, но никак не поможет.

С вами была команда dev.family, до скорой встречи!


Оригинал