Jak zbudować własną bibliotekę w oparciu o Angulara
Pierwsze pytanie jakie się nasuwa to „po co miałbym budować własną bibliotekę?” Jedni powiedzą: „Po to, aby stworzyć nowy framework, który będzie lepszy od framework’a X!” Dla mnie budowanie biblioteki jest jednak bardziej prozaiczne. Po prostu nie lubię się powtarzać.
Piotr Czech. .NET Developer w firmie HURO, gdzie buduje systemy oparte o RODO. Poprzednio pracował dla londyńskiej firmy, gdzie budował mobilny system telemetryczny zbierający i przetwarzający dane o nowych kierowcach w celu obniżenia ubezpieczeń, między zadaniami na poprawianie bugów. Entuzjasta podejść architektonicznych w systemach oraz budowania wydajnych rozwiązań opartych o platformę .NET poprzez eksploracje nowych technik oraz uczenia innych… i gonienia ich, jeśli nie przykładają do kodu.
W myśl zasady DRY (ang. Don’t Repeat Yourself) budowanie małych rozwiązań w oparciu o maksymę, że elementy mogą być używane w wielu projektach spłaca się w dłuższej perspektywie. Dlatego też powstały takie framework’i jak Angular, React czy Vue. A co w przypadku nas samych? Czy jesteśmy w stanie budować własne rozwiązania w oparciu o ten sam model? Hell yeah!
Spis treści
Plusy i minusy DRY
Jakie są plusy i minusy takiego podejścia?
Część osób zgodzi się ze mną, że do plusów można zaliczyć takie aspekty:
1. Uniwersalne rozwiązanie dla wielu projektów.
2. Skrócenie czas tworzenia projektu.
3. Mniej kodu.
4. Mniej błędów!
5. Przy znalezieniu błędu, każde rozwiązanie dostanie poprawkę od razu.
6. Mamy własny pakiet w NPM!
A co z minusami?
1. Biznes uzna, że bawimy się w piaskownicy zamiast pracować.
2. Jedna zmiana ma wpływ na wszystkie systemy.
3. Budowanie własnej biblioteki wymaga czasu (i chęci dalszego rozwoju).
4. Złe fundamenty i zasady na jakich oprzemy budowanie biblioteki będą miały znaczny wpływ na wszystkie systemy z niego korzystające.
Skupiając się na ostatnim punkcie, zgodzicie się ze mną, że według zasady Garbage In, Garbage Out jest to najważniejsza i zarazem krytyczna część procesu budowania własnej biblioteki, jak i dowolnego projektu.
Koncepcja
Jak zatem zbudować własną bibliotekę? Projekt i bibliotekę najprościej stworzyć z komend Angular CLI:
ng new angular-foundation-app rename angular-foundation-app angular-foundation cd angular-foundation ng g library angular-foundation
Folder główny będzie nazywał się angular-foundation, w sobie będzie posiadał aplikacje angular-foundation-app, a w folderze projects naszą bibliotekę o nazwie angular-foundation.
Jako szkielet wykorzystamy zasadę trzech modułów tj. Core/Features/Shared, które pozwalają w pierwszej kolejności rozgraniczyć typ, a dopiero potem funkcjonalność.
Wiele osób się ze mną nie zgodzi, przecież powinniśmy budować odwrotnie! Najpierw mamy funkcjonalność jako główne spoiwo, a dopiero potem typy w nim (tj. interfejsy, serwisy, klasy itd.)
Nie do końca, z pragmatycznego punktu widzenia lepiej ustalić zasady, np. serwisy są inicjalizowane w Core i nigdzie indziej. Po co szukać błędów, których nie widać, gdyż ktoś przez niewiedzę lub przypadek dodał instancję do dostawców (ang. providers) w komponencie? A jak wiemy, nowa instancja komponentu, nowa instancja serwisu i tak za każdym razem.
Core
W tym celu powstało pojęcie Core, przez wielu nazywane po prostu miejscem dla serwisów. Sam w sobie moduł wygląda mniej więcej tak:
import { NgModule, Optional, SkipSelf } from '@angular/core'; import { SharedModule } from './shared.module'; @NgModule({ providers: [], imports: [SharedModule] }) export class CoreModule { constructor( @Optional() @SkipSelf() parentModule: CoreModule ) { if (parentModule) { throw new Error('CoreModule is already loaded. Import only in AppModule'); } } }
Jego zadaniem jest rejestracja wszystkich serwisów oraz dbanie o to, aby nie został przez przypadek zarejestrowany dwukrotnie, dba o to referencja do samego siebie. Dekorator Optional dba o to, że jak serwis nie zostanie zarejestrowany to wstawi w jego miejsce null’a, a SkipSelf oznacza, że rejestracja zależności w kontenerze DI powinna się rozpocząć od rodzica tj. CoreModule. Dodatkowo sam w sobie Core importuje SharedModule. Jakie on ma zadanie?
Shared
Shared sam w sobie przetrzymuje wszystkiego rodzaju referencje do usług trzecich, które importujemy w projekcie, ma to na celu zebranie w jednym miejscu wszystkich takich paczek i w momencie, gdy ktoś będzie potrzebował dodatkowej funkcjonalności wystarczy, że zaimportuje ją w Shared, a ona sama będzie dostępna dla każdego.
Jego drugim zadaniem jest wydzielenie naszych rozwiązań, które są wspólne w Core i Features, zarazem, które nie mają referencji do żadnego serwisu zbudowanego przez nas samych. W dużym uproszczeniu pozwala nam skopiować folder Shared, przenieść go do innego projektu, załadować zewnętrzne paczki i możemy dalej pracować, bez potrzeby szukania po systemie komponentów, usług, które od niego zależą, na przykład zapytań do API.
Sam w sobie moduł wygląda mniej więcej tak:
import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { BrowserModule } from '@angular/platform-browser'; @NgModule({ declarations: [], imports: [ CommonModule, BrowserModule, ], exports: [ CommonModule, BrowserModule ] }) export class SharedModule {}
Trzeba zaznaczyć z miejsca, że sam w sobie Angular i jego moduły również są usługami zewnętrznymi, które importujemy i eksportujemy. Dzięki takiemu podejściu reszta naszych modułów jest czysta i wystarczy, że zaimportuje wyżej wymieniony moduł.
Features
Zadaniem tego modułu jest zbieranie wszystkiego rodzaju komponentów, które nie są wspólne dla całego systemu (czyt. przynajmniej dwa moduły z niego korzystają) i są zależne od naszych wewnętrznych serwisów z Core. Domyślnie będą tutaj trafiały wszystkie moduły i komponenty.
Przykładowy moduł będzie wyglądał mniej więcej tak:
import { NgModule } from '@angular/core'; import { SharedModule } from './shared.module'; @NgModule({ declarations: [], imports: [SharedModule], exports: [] }) export class FeaturesModule {}
Dodatkowe foldery i budowanie w oparciu o interfejsy
Twórcą TypeScript’a i C# jest ta sama osoba, dlatego wiele rozwiązań jest wspólnych dla obu języków, osobiście wyróżniam jeszcze jeden typ folderu, czyli interfejsy. Siłą TS’a jest jego silne typowanie, czyli każdy obiekt ma swój typ i kompilator dba o to, aby się zgadzał. Interfejsy są potocznie nazywane kontraktami (chociaż jest to błędem!), czyli spełniasz go, jest dobrze, brakuje jednego pola lub jest niepoprawne, popraw, bo nie uruchomisz programu.
Te właściwości pozwalają rozwiązać wszystkie problemy od literówek po walidacje typu pola aż po otrzymywanie i walidacje złożonych obiektów, które posiadają wielokrotne dziedziczenie. Najważniejsze w tym wszystkim jest to, że o błędzie dowiemy się przed kompilacją, taki TSLint będzie dbał o powiadomienie nas o błędzie w modelu.
Interfejsy mają jeszcze jeden duży atut. Kiedy nowy deweloper trafia do projektu i rozwiązanie samo w sobie oparte jest o DDD lub potrzebna jest wiedza domenowa jak np. nt. systemów bankowych to zbudowanie szkieletu w oparciu o interfejsy przyspiesza jego wdrożenie. Nie trzeba szukać po systemie co od czego jest zależne, gdyż wszystkie zależności definiują interfejsy. Jestem pewny, że o wiele łatwiej czyta się interfejs niż spaghetti z 10 tys. liniami kodu.
Zależności
Jednym z problemów, które często się przydarzają podczas budowania biblioteki i zarazem, o których dowiadujemy się dopiero podczas budowania projektu z flagami pod produkcje, jest jedna z nich, czyli AOT (ang. Ahead of Time). Jest to optymalizacja, która zachodzi podczas budowania projektu i polega na przejściu po wszystkich plikach, optymalizacji i skompilowaniu ich do JavaScript’u. Jedną ze sztuczek kompilatora jest wstawianie gotowego wyniku, na przykład funkcja, która liczy 2+2 zostanie usunięta i w jej miejsce zostanie wstawiona liczba stała.
Gdy mamy zależności w dwóch kierunkach, czyli jeden obiekt zależy od drugiego i odwrotnie w tym samym czasie to AOT nie pozwoli na zbudowanie projektu. Dzieje się tak, ponieważ aplikacja w momencie cyrkulacyjnej zależności wpada w pętlę bez wyjścia. Czyli odwieczne pytanie, co było pierwsze kura czy jajko?
Innym typem optymalizacji jest JIT (ang. Just In Time), gdzie optymalizacja kodu następuje w momencie użycia danego wycinka. Ten tryb wykorzystujemy domyślnie podczas pracy deweloperskiej przy użyciu komend ng build lub ng serve.
Często takie problemy sprawia reeksportowanie pliku tj. tworzymy plik index.ts i w nim wpisujemy jaki dany folder ma pliki, aby w momencie importowania każdy komponent i serwis nie posiadał osobnego importu a coś takiego:
import { NgModule, Optional, SkipSelf } from '@angular/core';
Głównym indeksem w naszej bibliotece jest public_api.ts. Jego zadaniem jest zebranie wszystkich indeksów, plików jakie posiadamy w bibliotece, aby projekt, który z niej będzie korzystał mógł pobierać moduły i komponenty w taki sposób:
import { SharedModule, CoreModule, FeaturesModule } from 'angular-foundation';
Jednak wracając do public_api.ts, wygląda on tak:
export * from './lib/core.module'; export * from './lib/core'; export * from './lib/features.module'; export * from './lib/features'; export * from './lib/shared.module'; export * from './lib/shared'; export * from './lib/foundation.module';
Specjalnie wydzielam moduły do osobnego reeksportu, ponieważ najczęstszym problem są one. Przykładem jest moment, w którym deklarujemy komponenty np. w Features i zarazem używamy skróconej składni importowania całego folderu za pomocą index.ts zamiast osobnych plików tj.:
import { NgModule } from '@angular/core'; import { SharedModule } from './shared.module'; // tutaj nastąpi błąd podczas kompilacji projektu(nie biblioteki) wyskoczy // zależność w dwóch kierunkach import { HelloWorldComponent, AnotherHelloWorldComponent } from './features'; // w tym momencie cyrkulacja zależności nie nastąpi. import { HelloWorldComponent} from './features/hello-world.component'; import { AnotherHelloWorldComponent } from './features/another-hello-world.component'; @NgModule({ declarations: [HelloWorldComponent, AnotherHelloWorldComponent], imports: [SharedModule], exports: [HelloWorldComponent, AnotherHelloWorldComponent] }) export class FeaturesModule {}
Projekt, który użyje biblioteki z cyrkulacją zależności dostanie taki o to komunikat:
ERROR in : Unexpected value 'undefined' exported by the module 'FeaturesModule in C:/Users/piter/source/repos/angular-foundation/node_modules/angular-foundation/angular-foundation.d.ts'
Dlatego zasadą jest podanie pełnej ścieżki do pliku importowanych komponentów zależnych od modułu, a index.ts zostawić wszystkim zewnętrznym usługom, które ze skróconej wersji będą korzystać.
Elementy dodatkowe – Assets’y, skrypty
Często się zdarza, że potrzebujemy dodatkowych elementów i jednym z nich na pewno są Assets’y czyt. obrazki, ikony, zdjęcia itd., które wykorzystują komponenty w bibliotece, a zarazem nie chcemy, aby każdy projekty ręcznie je importował.
Jako że Angular (7.1.0) na obecną chwilę nie wspiera takiej funkcjonalności musimy ją sobie sami zbudować za pomocą skryptów.
Wszystko najlepiej zrobić jedną komendą:
npm run package
Do tego celu będziemy potrzebowali komend, które wykonają się w takiej kolejności:
npm run build_lib // zbuduj bibliotekę npm run copy_assets //przekopiuj wszystkie assets’y do dist npm run npm_pack // spakuj dist npm run copy_tgz // wdróż do prywatnego repozytorium npm run clear // usuń dist
I komendy w całej okazałości:
"clear": "@powershell -NoProfile -ExecutionPolicy Unrestricted -Command ./clear_directory.ps1", "build_lib": "ng build angular-foundation", "npm_pack": "cd dist/angular-foundation && npm pack", "copy_assets": "cpx "projects/angular-foundation/src/assets/**/*" "dist/angular-foundation/assets"", "copy_tgz": "cpx "dist/angular-foundation/*.tgz" "packages/"", "package": "npm run clear && npm run build_lib && npm run copy_assets && npm run npm_pack && npm run copy_tgz && npm run clear"
Do kopiowania wykorzystałem bibliotekę cpx. Spostrzegawczy czytelnik zauważy, że wywołuje komendę clear na początku i na końcu skryptu, dzieje się tak, gdyż ng build nie jest w stanie usunąć folderów, które są używane przez inne usługi, w tym wypadku działająca w tle usługa blokuje usunięcie folderu dist w całości co powoduje, że komendy trzeba uruchamiać dwukrotnie. W tym wypadku komenda powershell’owa czeka aż będzie w stanie usunąć cały folder przed rozpoczęcie budowania i po zbudowaniu paczki.
Żeby assets’y zaczęły działać w naszym projekcie musimy jeszcze je zaimportować w angular.json, w tym wypadku będzie to wyglądało tak:
"assets": [ "src/favicon.ico", "src/assets", { "glob": "**/*", "input": "./node_modules/angular-foundation/assets", "output": "./assets/" } ],
Podczas budowania, system pobiera assets’y z naszej wdrożonej biblioteki w node_modules i przenosi je do folderu wynikowego w dist.
Bibliotekę instalujemy tak samo jak resztę paczek tj. w pliku package.json tworzymy zależność, gdzie podajemy nazwę paczki i ścieżkę do pliku .tgz lub numer wersji, jeśli jest to paczka publiczna, czyli:
"dependencies": { // reszta paczek pominięta dla przejrzystości "angular-foundation": "file:packages/angular-foundation-0.0.1.tgz", // lub "angular-foundation": "^0.0.1", },
Główny package.json posiada w sobie wszystkie zależności z jakich korzysta angular-foundation-app oraz angular-foundation.
Package.json, który znajduje się w folderze projects/angular-foundation definiuje jakie zależności musi posiadać projekt, korzystający z biblioteki i wygląda to tak:
"peerDependencies": { "@angular/material": "^7.1.0", "@angular/cdk": "^7.1.0", "@angular/animations": "^7.1.0", "@angular/common": "^7.1.0", "@angular/compiler": "^7.1.0", "@angular/core": "^7.1.0", "@angular/forms": "^7.1.0", "@angular/http": "^7.1.0", "@angular/platform-browser": "^7.1.0", "@angular/platform-browser-dynamic": "^7.1.0", "@angular/router": "^7.1.0", "core-js": "^2.5.7", "rxjs": "^6.3.3", "zone.js": "^0.8.26" }
Czyli główny package.json posiada zależności dependencies i devDependencies, a package.json w bibliotece peerDependencies.
Ten ostatni typ zależności pozwala na definiowanie przedziałów paczek z jakich projekty mogą korzystać i nic nie stoi na przeszkodzie, aby zmienić zakres na:
"@angular/material": "^6.0.0 || ^7.1.0"
Przy budowaniu bibliotek jest taka zasada, że jako twórcy biblioteki staramy się zejść do najniższej możliwej wersji paczek z jakich korzystamy, a zarazem projekt, który będzie korzystał z naszej biblioteki był w stanie korzystać z najwyższej wersji.
Publikowanie do NPM
Aby opublikować paczkę w NPM musimy lekko zmodyfikować package.json w naszej bibliotece, dodając m.in. autora, licencję, opis i repozytorium, więc plik w całej okazałości będzie wyglądał tak:
{ "name": "angular-foundation", "version": "0.0.1", "author": "Piotr Czech", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/Xeinaemm/angular-foundation" }, "description": "Simple example how to build a library", "peerDependencies": { "@angular/material": "^6.0.0 || ^7.1.0", "@angular/cdk": "^6.0.0 || ^7.1.0", "@angular/animations": "^6.0.0 || ^7.1.0", "@angular/common": "^6.0.0 || ^7.1.0", "@angular/compiler": "^6.0.0 || ^7.1.0", "@angular/core": "^6.0.0 || ^7.1.0", "@angular/forms": "^6.0.0 || ^7.1.0", "@angular/http": "^6.0.0 || ^7.1.0", "@angular/platform-browser": "^6.0.0 || ^7.1.0", "@angular/platform-browser-dynamic": "^6.0.0 || ^7.1.0", "@angular/router": "^6.0.0 || ^7.1.0", "core-js": "^2.0.0 || ^2.5.7", "rxjs": "^5.0.0 || ^6.3.3", "zone.js": "^0.7.0 || ^0.8.26" } }
Dodatkowo nasza paczka powinna zawierać pliki README i licencje, więc dodajemy nowe skrypty kopiujące pliki z katalogu głównego, czyli:
"copy_license": "cpx "./LICENSE" "dist/angular-foundation/"", "copy_readme": "cpx "./README.md" "dist/angular-foundation/"",
I modyfikujemy komendę package:
"package": "npm run clear && npm run build_lib && npm run copy_assets && npm run copy_license && npm run copy_readme && npm run npm_pack && npm run copy_tgz && npm run clear"
Samo w sobie publikowanie paczki wymaga posiadania konta na npmjs.com, gdy posiadamy takie konto wystarczy, że wykonamy dwie komendy, aby opublikować paczkę, czyli:
npm login
A potem:
npm publish ./packages/angular-foundation-0.0.1.tgz
Podsumowanie
Samo w sobie budowanie biblioteki nie jest skomplikowane, zresztą jak każdego rozwiązania, jednak różnica między dobrym, a złym rozwiązaniem jest jedna i nazywa się: dyscyplina. Budowanie rozwiązań, z których będą korzystać inni, niesie za sobą odpowiedzialność, aby wszystko było pisane jednym stylem, najlepiej w oparciu o wzorce, ponieważ jest to coś, co łączy wszystkie technologie.
Podsumowując, każdy idiota jest w stanie napisać kod, który zrozumie komputer. Niewielu jest w stanie napisać go tak, aby zrozumiał go człowiek.
W razie jakichkolwiek pytań, piszcie śmiało w komentarzach! Link to repozytorium znajdziecie tutaj.