Blog
W poprzednim artykule starałem się w wielkim skrócie przybliżyć podstawy narzędzia do automatyzacji zadań jakim jest Grunt. Dziś zgodnie z obietnicą zajmiemy się tematem tworzenia własnych rozszerzeń do Grunta.
Kiedy własne rozszerzenie?
“Nie wynajduj koła od nowa”. To maksyma jaką każdy programista powinien sobie wyrecytować, zanim na dobre przystąpi do pisania nowego kodu. Nie inaczej jest z rozszerzeniami dla Grunta. Zawsze kiedy pojawi się potrzeba wykonania w Gruncie niestandardowej operacji, warto sprawdzić czy ktoś już nie rozwiązał problemu podobnego do naszego. W tym celu najlepiej udać się na stronę z lista dostępnych pluginów Grunta lub bezpośrednio do repozytorium npm i wyszukać hasło grunt plugin. Bardzo możliwe że znajdziemy to czego nam trzeba. Ale jeśli nic nie spełnia naszych oczekiwań, to pora zakasać rękawy i samemu napisać potrzebne rozwiązanie.
Pamiętaj że zawsze można zacząć od nowa, ale czasami wystarczy też zrobić fork do istniejącego rozszerzenia, lub wysłać pull request z propozycją zmiany do autora orginalnego rozszerzenia, które prawie spełnia twoje potrzeby.
Standardy pisania rozszerzeń
Pisząc rozszerzenie do Grunta warto zdawać sobie sprawę z przyjętych przez jego społeczność standardów. To sprawi że innym będzie łatwiej partycypować w rozwoju rozszerzenia, pomagać w rozwiązywaniu problemów, czy choćby zapewnisz sobie pełną zgodność z flagami linii poleceń które wpływają na zachowanie się zadań (np. to w jaki sposób Grunt raportuje postęp prac).
Najwięcej cennych porad możemy znaleźć w dokumentacji Grunta dotyczącej tworzenia zadań oraz rozszerzeń. Warto też zerknąć w dokumentację trybu CLI. Reasumując, w trakcie prac należy pamiętać aby:
- Stworzyć nazwę zadania z prefiksem “grunt-“, ale nie “grunt-contrib-” które jest zarezerwowane dla zadań rozwijanych przez twórców Grunta.
- Nie zmieniać katalogu bazowego (
process.cwd()
) wewnątrz zadania. To może zrobić użytkownik z poziomu Gruntfile.js, więc wskazane jest aby nie robiło tego żadne z zadań. - Pliki tymczasowe tworzyć w katalogu bazowym w
.grunt/nazwa-twojego-zadania
i czyścić po zakończeniu, lub użyć rozszerzeń które dają dostęp do systemowego katalogu tymczasowego (np. temporary lub tmp). - Do wykonywania operacji na plikach posługiwać się w pierwszej kolejności API Grunta, a dopiero w ostateczności API Node.js.
- Stosować odpowiednie logowanie i działania wewnątrz naszego skryptu, które będzie współgrać z flagami linii poleceń Grunta, w szczególności z:
--debug
,--verbose
,--no-color
,--no-write
. Jest to istotne zwłaszcza jeśli nie trzymamy się w 100% zaleceń z punktu poprzedniego.
Ostatnie i najważniejsze. Pluginu nie musisz pisać w całości od nowa, a wręcz nie powinieneś tego robić. Aby stworzyć sobie właściwe rusztowanie (scaffolding) naszego rozszerzenia, powinniśmy posłużyć się generatorem wbudowanym w Grunta. W tym celu musimy najpierw zainstalować globalnie bibliotekę grunt-init:
npm install -g grunt-init
Teraz możemy z linii poleceń generować różnego rodzaju rusztowania, bazując na dostępnych szablonach. Dla nas najbardziej interesujące będą oczywiście wbudowane szablony grunt-init-gruntplugin
oraz grunt-init-gruntfile
. W wybranym przez nas katalogu uruchamiamy polecenie:
grunt-init gruntplugin
Skrypt zapyta nas o kilka rzeczy po czym utworzy odpowiednio spreparowane pliki i katalogi na których będziemy mogli zacząć pracę.
https://gist.github.com/kwiniarski/2e3f39a246e6313ea3d3
Jeśli ktoś korzysta z Yeomana może wykorzystać jego generator, który jest de facto nakładką na grunt-init-gruntplugin
.
Szkielet rozszerzenia
W poprzednim kroku stworzyliśmy szkielet naszego rozszerzenia. Zerknijmy co mamy w środku:
- katalog
task
z definicją naszego zadania, - katalog
test
z testami jednostkowymi, - plik
Gruntfile.js
, głównie z zadaniami uruchamiającymi testy naszego rozszerzenia, - plik
package.json
z opisem naszego rozszerzenia i wymaganymi zależnościami, .jshintrc
z ustawieniami standardów kodowania,README.md
ze standardowym opisem jaki zawierają wszystkie rozszerzenia Grunta.
Nasze zadanie już od początku nie jest puste i wykonuje jakąś przykładową czynność. Aby przetestować jej działanie powinniśmy w pierwszej kolejności doinstalować wymagane pakiety określone w package.json
poleceniem npm i
, a następnie uruchomić domyślne zadanie wpisując po prostu grunt
.
W niniejszym artykule pominiemy kwestie testów jednostkowych i skupimy się jedynie na samym rozszerzeniu. Niemniej jednak należy pamiętać że rozszerzenie nie może być uznane za kompletne jeśli tych testów nie posiada.
Piszemy kod
Większość zadań w Gruncie polega na odczytaniu pewnego zbioru plików, następnie wykonanie na nich przewidzianych w zadaniu transformacji, a na końcu zapisanie ich ponownie na dysku we wskazanej w zadaniu lokalizacji. Podobnie postąpimy w naszym zadaniu. Jego celem będzie przygotowanie pliku JSON z jakąś konfiguracją, która będzie pochodzić z kilku innych, rożnych plików. Dość proste, ale wystarczające aby zademonstrować pisanie rozszerzeń.
Aby nie wynajdywać koła od nowa, posłużymy się w naszym rozszerzeniu biblioteką ESON. Pozawala ona wykonać kilka ciekawych operacji na plikach JSON. Zakładając że mamy już wygenerowany szkielet rozszerzenia, przystępujemy do edycji pliku, który mamy w katalogu tasks
.
Na samym początku dodamy dwa dodatkowe moduły, których użyjemy w naszym rozszerzeniu:
npm i eson chalk --save
Moduły załączamy do naszego zadania:
var eson = require('eson');
var chalk = require('chalk');
W kolejnym kroku definiujemy nasze zadanie. Będzie to zadanie o wielu celach, więc używamy metody grunt.registerMultiTask
:
grunt.registerMultiTask('eson', 'Create JSON configuration file using ESON.', function() { });
Metoda przyjmuje 3 argumenty. Pierwsza to nazwa zadania, pod którą będziemy przekazywać konfigurację w pliku Gruntfile.js
. Kolejny argument to krótki opis zadania, który pokaże się między innymi kiedy zostanie użyta flaga --help
podczas wywołania Grunta. Ostatni argument to funkcja definiująca nasze zadanie.
Wewnątrz zadania w pierwszej kolejności zajmiemy się obsługa opcji jakie przekazujemy do zadania lub konkretnego celu. Więcej na ten temat w poprzednim artykule. Do pobrania opcji dla aktualnie przetwarzanego celu służy metoda this.options()
. Jako argument, przyjmuje ona wartości domyślne i zwraca finalny obiekt opcji. Wartości jakie obiekt opcji przybierze są w pierwszej kolejności brane z obiektu options
zdefiniowanego na poziomie celu, następnie na poziomie zadania, a na samym końcu są one brane z wartości domyślnych przekazanych do this.options()
. W praktyce w naszym rozszerzeniu wygląda to tak:
https://gist.github.com/kwiniarski/f6df8716bd87a764272d
W kolejnym etapie przechodzimy do właściwej operacji na plikach. Jak pamiętamy z poprzedniego artykułu, konfiguracje plików źródłowych i docelowych możemy podać na kilka różnych sposobów. Niezależnie od tego, jak przekażemy konfigurację, informacja ta będzie przetworzona przez Grunta i wystawiona w naszym zadaniu jako właściwość this.files
, która jest tablicą obiektów. Każdy z nich zawiera 2 właściwości: src
, która jest getterem zwracającym tablicę ze ścieżkami do plików wejściowych oraz dest
, która jest obiektem typu String ze ścieżką do pliku docelowego. Wszystkie ścieżki podane w src
jak i dest
są relatywne w stosunku do katalogu naszego projektu. Operacje łączenia plików JSON i ich przetwarzania przez ESON wykonujemy iterując po tablicy plików this.files
:
https://gist.github.com/kwiniarski/68bd10146ee722456381
Iterując po zestawach plików, mamy do dyspozycji w zasadzie 4 obiekty. file
, który jest obiektem-zestawem plików src
oraz dest
i jest zwracany jako argument do funkcji zwrotnej dla this.files.forEach
. json
, który będzie zawierał dane ze scalonych plików JSON, do których ścieżki są zdefiniowane w file.src
. data
to obiekt tekstowy z zserailizowanym i przetworzonym obiektem json
. Jest jeszcze obiekt conf
, który jest instancją ESONa, z przekazaną z opcji konfiguracją pluginów.
W pierwszej kolejności iterujemy po plikach źródłowych file.src
, odczytujemy ich zawartość za pomocą API Grunta i osadzamy w obiekcie json
. Istotne jest użycie właśnie API Grunta, zamiast np. modułu fs
Node.js. Używając API, mamy pewność że w przypadku błędów zostaną one obsłużone zgodnie z flow Grunta, ale przede wszystkim ułatwiamy sobie bardzo wiele rzeczy, np. nie musimy się martwic czy przy zapisie pliku do wskazanej lokalizacji, ścieżka docelowa istnieje – jeśli nie, Grunt sam ją za nas stworzy.
W następnym kroku parsujemy przy pomocy ESONa obiekt json
, w wyniku czego otrzymamy jego przetworzoną wersję. Teraz ponownie musimy go zamienić na tekst aby było możliwe jego zapisanie w pliku docelowym. Przy tej okazji użyjemy opcji beautify
, która jeśli ustawiona sprawi że zapisany plik będzie posiadał formatowanie zwiększające jego czytelność. W kolejnej linijce kodu umieszczamy dodatkowe logowanie, które zapisze do konsoli zawartość zmiennej data
jeśli użyta zostanie flaga --verbose
. Ponownie używając API Grunta zapisujemy ostatecznie zawartość zmiennej data
do pliku w lokalizacji określonej przez file.dest
i zapisujemy do konsoli informacje o zakończeniu operacji.
Warto zwrócić uwagę na kolorowanie składni. Używamy modułu chalk
do kolorowania pewnych treści. Moduł ten jest zgodny z flagami linii poleceń jakich używa Grunt i potrafi je automatycznie wykrywać. Tym samym użycie flagi --no-color
spowoduje że kolorowanie zniknie.
Gotowe rozszerzenie można znaleźć na GitHubie. Po sklonowaniu wystarczy zainstalować wymagane moduły poprzez npm install
a następnie przetestować działanie rozszerzenia komendami grunt eson
, grunt eson:defaults
, grunt test
.
Co więcej?
Przykład zaprezentowany powyżej na pewno nie wyczerpuje całości tematu, zwłaszcza dlatego że jest on bardzo prosty. Sprawa trochę bardziej się komplikuje kiedy nasze zadanie niekoniecznie operuje na plikach, a wykonuje jakieś dodatkowe operacje, niejednokrotnie asynchroniczne (np. uruchamia server), a konfiguracja naszego zadania nie jest zgodna z zaleceniami Grunta.
Grunt jest na to jak najbardziej przygotowany. Po pierwsze zawsze mamy dostęp do pełnego obiektu konfiguracyjnego danego celu poprzez właściwość this.data
. Jeśli przekazaliśmy w tej konfiguracji listę plików w jakiś inny sposób niż standardowo (co oczywiście nie jest wskazane), możemy ją przetworzyć posługując się API grunt.file.expandMapping.
O zadaniu asynchronicznym musimy Grunta poinformować explicite. W tym celu pobieramy za pomocą metody this.async()
funckję, którą należy wywołać kiedy wiemy że nasze zadanie się zakończyło. Najczęściej implementacja wygląda następująco:
https://gist.github.com/kwiniarski/98a93fe46a7f58313fe9
Jest jeszcze bardzo wiele rzeczy które możemy w gruncie zrobić, ale których nie sposób opisać w jednym artykule. Dobrym miejscem aby zacząć szerszą edukacje jest dokumentacja API Grunta.
Co dalej?
- Wstęp do automatyzacji za pomocą Grunta.
- Tworzenie zadań w Grunt, czyli jak krok po kroku stworzyć własne rozszerzenie.
- Grunt na sterydach, czyli kilka porad na temat tego jak przyspieszyć wykonywanie zadań.
- Grunt dla zaawansowanych, czyli przykłady zadań i konfiguracji które zmienią sposób w jaki pracujesz.