Kategoria:Architektura

Monorepo z Lerna, Yarn Workspaces i TypeScript

  • Czas potrzebny na przeczytanie:6 minut
  • Opublikowane:11 października 2021

Jak zarządzać projektem, w którym mamy kilka paczek* z kodem? Utrzymywanie zewnętrznych zależności, sprawny development, bezbolesny release, to tylko niektóre z wyzwań, z którymi musimy się mierzyć przy większych codebasach. Jak to ogarnąć?

* paczka, inaczej projekt, wydzielony kawałek kodu

Rodzaje repozytoriów

Zanim zaczniemy, rozróżnijmy trzy podstawowe rodzaje repozytoriów:

  1. Multirepo - każda nasza paczka, jest w osobnym repozytorium - klasyczny podział.

  2. Monorepo - gwiazda dzisiejszego wieczoru, w tym podziale posiadamy wiele wyizolowanych projektów, ale są one zebrane w ramach jednego repo. Mogą (ale nie muszą) być od siebie zależne.

  3. Monolit - tutaj w przeciwieństwie do poprzednich rodzajów, cały nasz codebase jest zebrany w jednej paczce, w jednym repozytorium.

Rodzaje repozytoriów

Monorepo

Zatrzymajmy się na chwilę przy samym koncepcie monorepo, wiemy już, że w takim systemie przechowujemy wszystkie nasze paczki w jednym repozytorium, tylko po co?

Załóżmy, że tworzymy nowy projekty, backend i frontend będą napisane w tej samej technologii - TypeScripcie. Takie rozwiązanie jest super, bo po jakimś czasie będziemy mogli reużywać kawałki kodu w obu aplikacjach, oh wait, tylko jak to zrobić? To nie koniec problemów, podobne konfiguracje toolingu, powtarzające się zewnętrzne pakiety, które trzeba utrzymywać, wersjonowanie i publikowanie, to tylko niektóre rzeczy, z którymi musielibyśmy się mierzyć na co dzień...

Do plusów Monorepo możemy zaliczyć:

  • Reużywanie kodu
  • Łatwiejsze zarządzanie pakietami
  • Sprawniejsze procesy (lint, build, test, release)
  • Refactoring na dużą skalę
  • Jedno miejsce do zgłaszania issues (open source)

Oczywiście takie rozwiązanie nie jest złożone z samych zalet, do jakiego wad można zaliczyć:

  • Brak możliwości ograniczenia dostępu do pojedynczej paczki
  • Konieczność pobrania całego repo, nawet jeśli pracujemy tylko nad jedną paczką
  • Problemy z systemem kontroli wersji przy bardzooo dużej skali (typu Google/Facebook)
  • Dłuższy build

Kto korzysta z Monorepo?

Koncept pojedynczych repozytoriów nie jest znany od wczoraj, korzystają z niego tacy giganci jak Google, czy Facebook, ale również "mniejsi" gracze/projekty związane z technologiami, np. Next.js, Vue i Babel.

Firmy i projekty, które korzystają z Lerny

Lerna & Yarn Workspaces

Pierwszym ze składników naszego projektu będzie Lerna. To narzędzie jest bazą pod nasze monorepo, dostarcza nam tooling potrzebny do wykonywania skryptów pomiędzy paczkami, wersjonowanie i publikowanie paczek. Lernę możemy wykorzystać również jako managera zależności, ale w związku z tym, że na pokładzie mamy jeszcze Yarn Workspaces, podzielmy trochę odpowiedzialność.

Workspaces posłużą nam za inteligenty manager zewnętrznych pakietów, dzięki niemu połączymy ze sobą zależności, będziemy posiadali tylko jeden yarn.lock, co ułatwi nam późniejsze utrzymanie pakietów. Yarn Workspaces dostarczają nam również mechanizm, w którym jeden z naszych lokalnych projektów będzie mogł być zależny od drugiego.

Co ciekawe, te dwie technologie możemy wykorzystać niezależnie od siebie, jeśli chcesz się trochę bardziej wgłębić w różnice, to polecam świetny artykuł autorstwa Sebastiana Webera.

Diagram powiązania pomiędzy Lerną, Yarn workspaces i Yarnem

Monorepo w praktyce

Zanim przejdziemy do właściwej konfiguracji, z założenia nasz projekt będzie bardzo prosty, będziemy posiadali trzy niezależne paczki - backend, frontend i types. Dwie pierwsze będą współdzieliły typy z TypeScripta:

Przepływ danych pomiędzy typami z TypeScript, a repozytoriami

Konfiguracja

Jeśli już zainstalowaliśmy Lernę, to przechodzimy do jej konfiguracji, to czego potrzebujemy to plik lerna.json, a w nim:

{
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "1.0.0"
}

W tym miejscu określamy czy będziemy korzystali z NPM, czy z Yarna, możemy również ustawić miejsce składowania paczek i wersję projektu, korzystając z Yarna warto pominąć tutaj pole packages na rzecz ustawienia go w package.json, Lerna wyciągnie tą informację sama, a będziemy mieli jedno źródło prawdy.

W package.json, poza standardowymi rzeczami, wskazujemy workspaces, które potrzebne są Yarnowi:

{
  "name": "root",
  "version": "1.0.0",
  "license": "MIT",
  "private": true,
  "workspaces": {
    "packages": ["packages/*"]
  }
}

Instalacja zależności

Tak jak wcześniej wspomniałem, zarządzaniem zewnętrznymi zależnościami będą się zajmowały Yarn Workspaces. Żeby dodać jakiś pakiet, wystarczy stosować się takiego patternu:

yarn workspace <nazwa_lokalnej_paczki> add <nazwa_zewnętrznej_zależności>

W kontekście naszego projektu będzie to wyglądało w ten sposób:

yarn workspace frontend parcel-bundler --save-dev

Jeśli chcemy dodać pakiet, który będzie występował we wszystkich naszych paczkach, wystarczy, że zrobimy:

yarn add <nazwa_zewnętrznej_zależności> -W

Powiązanie lokalnych paczek

Esencja dzisiejszego artykułu, czyli jak reużywać typy pomiędzy frontendem, a backendem?

Załóżmy, że w paczce types posiadamy typ użytkownika:

export type User = {
  id: string;
  name: string;
  points: number;
};

Aby skorzystać z niego w paczce backend, musimy wykonać taką komendę:

yarn workspace backend add types@1.0.0
Diagram lokalne powiązania między TypeScriptowymi typami, a paczkami frontend i backend

Ważne jest, aby uważać tutaj z wersjami - lepiej zadeklarować konkretną wersję, w moim przypadku yarn "źle" domyślał się, jaką naprawdę ma dodać

Jeśli wszystko poszło zgodnie z planem, powinniśmy zobaczyć nową zależność w package.json w paczce backend:

"dependencies": {
   "types": "^1.0.0"
}

Korzystamy z niej dokładnie tak samo, jak z każdej innej zależności:

import express from 'express';
import type { User } from 'types';
import { v4 as uuidv4 } from 'uuid';

const app = express();

const user: User = {
  id: uuidv4(),
  name: 'User',
  points: 10012,
};

app.get('/', (req, res) => res.send('Monorepo Backend'));
app.listen(8000);

Praca z Lerną

Uff, udało nam się wykorzystać Yarn Workspaces, czas na Lernę, po co tak właściwie nam ona w projekcie? 🤔

Procesy

Jak już wspomniałem na samym początku, Lerna świetnie usprawnia nam pracę z różnymi procesami. Często spotykane procesy w aplikacjach TypeScriptowych:

  • lint
  • test
  • format
  • build

My zajmiemy się tym pierwszym, ale cały przepis będzie również działał na inne procesy. Do naszego procesu lint wykorzystamy dobrze znanego wszystkim ESlinta.

Instalacja:

yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser prettier eslint-config-prettier eslint-plugin-prettier -W

Konfiguracja:

{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "sourceType": "module",
    "ecmaVersion": 2021
  },

  "plugins": ["@typescript-eslint"],
  "extends": ["prettier", "plugin:prettier/recommended", "plugin:@typescript-eslint/recommended"]
}

Chcemy wykonywać ten proces w paczkach frontend i backend, dodajemy w obu miejscach skrypt w package.json:

"scripts": {
    "lint": "eslint . --ext ts"
    }

Dla uproszczenia, w naszym projekcie posiadamy tylko jeden plik konfiguracyjny ESlinta oraz takie same skrypty, ale nic nie stoi nam na przeszkodzie, żeby zrobić je zupełnie inne w każdej z naszych paczek 📦

W katalogu głównym projektu, do package.json dodajemy odpowiedni skrypt:

"scripts": {
    "lint": "lerna run lint --stream"
    }

A następnie wywołujemy:

lerna run lint

Dzięki temu we wszystkich naszych paczkach zostanie wywołany eslint . --ext ts. Ten i podobne procesy wraz z Lerną, super się łączą z narzędziami typu Lint Staged i Husky, zdecydowanie warto spróbować!

Diagram procesu lintowania wraz z wykorzystaniem Lerny

Wersjonowanie

Wykorzystanie Lerny do wersjonowania paczek to łatwizna wystarczy jedna komenda:

lerna version [major | minor | patch | premajor | preminor | prepatch | prerelease]

Co się dzieje po wykonaniu tej komendy? Lerna za kulisami sprawdzi jakie zmiany zostały zaaplikowane w danych paczkach, otaguje nową wersję i wypushuje na zdalne rezpotorium Gita.

Wersjonowanie: Patch (1.0.1), Minor (1.2.0), Major (2.0.0)

Warto jeszcze wspomnieć o fladze --conventional-commits, która korzysta z Conventional Commits i automatycznie określi wersje oraz wygeneruje changelog:

lerna version --conventional-commits

Publikowanie do rejestru NPM

Tutaj sprawa wygląda bardzo podobnie jak poprzednio. Wystarczy jedna komenda:

lerna publish

Po jej wykonaniu wszystkie paczki, które zostały zmienione od poprzedniej wersji, zostaną opublikowane.

Paczki muszą mieć ustawione "private": false w package.json, inaczej Lerna nie rozpocznie procesu publikacji

Podsumowanie

To by było na tyle, jeśli chodzi o krótkie wprowadzenie do tematu Monorepo 🙏

Zarówno Lerna, jak i Yark Workspaces oferują znacznie więcej, a komfort pracy z takim połączeniem stoi na wysokim poziomie - zachęcam do dalszego zgłębiania 😎

Do usłyszenia!

Źródła

O autorze

Olaf Sulich

Olaf jest Frontend Developerem, blogerem i nosi rybacki kapelusz 🎩 Pisze o wszystkim co związane z frontendem, ale nie boi się backendu i designów 🦾 Ma głowę pełną pomysłów i nadzieję, że znajdziesz tutaj coś dla siebie!