Automatyzacja backupów w Node.js, czyli potrzeba matką wynalazków

Automatyzacja backupów w Node.js, czyli potrzeba matką wynalazków

Stare porzekadło mówi, że istnieją dwa rodzaje ludzi – ci, którzy robią kopie zapasowe, oraz ci, którzy będą je robić. Backupy i innego rodzaju zadania związane z utrzymaniem projektu są jednak o tyle problematyczne, że zawsze znajdzie się wymówka żeby robić coś innego. Rozumiejąc jednak wartość tworzenia backupów projektu postanowiłem zautomatyzować żmudny proces ich tworzenia – w tym celu napisałem prostą aplikację w Node.js, która będzie dla nas tworzyć backupy bloga którego właśnie czytacie. W dzisiejszym poście pokażę wam w jaki sposób została ona zaimplementowana.

Scenariusz podstawowy

Portale oparte o WordPressa, albo jakikolwiek inny CMS, składają się zazwyczaj z dwóch głównych elementów – hostowanej na serwerze aplikacji (czyli po prostu plików) oraz bazy danych. Pełna kopia zapasowa musi obejmować obie te części. W dzisiejszym poście skupimy się na scenariuszu podstawowym, w którym celem będzie utworzenie kopii wszystkiego co znajduje się na naszym serwerze.

Trzy punkty, które chcielibyśmy zrealizować, to:

  • pobranie plików z naszego serwera, np. poprzez FTP
  • archiwizacja i kompresja pobranych plików
  • upload archiwum w wybrane miejsce gdzie będziemy przetrzymywać kopie – w naszym przypadku będzie to Google Drive

Aplikacja która będzie realizować wymienione powyżej kroki zostanie stworzona w technologii Node.js.

Pobieranie kopii zapasowej

Kopię zapasową naszych plików możemy zrealizować na kilka sposobów. Pierwszym, co przyszło mi do głowy, byłoby użycie wtyczki do WordPressa zalogowanie się na nasz serwer poprzez protokół SSH, spakowanie wszystkiego co tam znajdziemy do jednego archiwum, a następnie pobranie tego archiwum w taki sam sposób na swój dysk. W tej metodzie mógłbym się ograniczyć do prostego skryptu napisanego w bashu, jednak plany związane z SSH skasował nasz dostawca hostingu (a konkretnie ograniczenia planu hostingowego). 🙁

Naturalnym zastępstwem dla SSH okazał się stary dobry FTP. Pracę z tym protokołem zacząłem od sprawdzenia, jak klocek o nazwie Node.js ma się do klocka o nazwie FTP – czy istnieją jakieś klienty do node’a które pozwolą mi poruszać się po naszym serwerze? Istnieją – jednym z nich jest node-ftp, który patrząc po dokumentacji wydaje się być odpowiednim do tego, co chciałbym osiągnąć. Niestety, biblioteka ta ma już swoje lata (dosłownie) i nie znajdziemy tam takich udogodnień jak np. Promise’y. Wszystko realizowane jest poprzez callbacki co w dłuższej perspektywie może okazać się zabójcze. No ale nic – na callbacki istnieją lekarstwa, więc warto spróbować.

Zacznijmy naszą pracę od przeglądu struktury projektu w którym będziemy pracować:

Najważniejsze dla nas będą następujące elementy:

  • package.json – dokument w którym zdefiniujemy wszystkie zależności których nasza aplikacja potrzebuje do działania (wygenerujesz go poleceniem npm init)
  • index.js – “entry point” do naszej aplikacji
  • config – folder dla plików konfiguracyjnych
  • node_modules – folder w którym npm instaluje zależności
  • data – folder w którym będziemy przechowywać kopie zapasowe
  • src – kod źródłowy naszej aplikacji

Pracę zaczynamy od wygenerowania package.json’a, którego dodatkowo rozszerzamy o dwa skrypty:

Cała obsługa naszej aplikacji ograniczy się więc do dwóch podstawowych komend – npm start którą będziemy uruchamiać cały proces, oraz npm run clean do czyszczenia folderu z kopiami zapasowymi (przydatne na czas developmentu).

Czas na zaimplementowanego pierwszej części naszego scenariusza – w pliku index.js zaczniemy od wyznaczenia identyfikatora naszej kopii zapasowej. Naturalnym rozwiązaniem będzie tutaj użycie daty i czasu w którym dana kopia została utworzona:

W momencie uruchomienia skryptu do zmiennej backupName przypiszemy sformatowaną datę w formacie ISO, natomiast do backupPath przypiszemy ścieżkę do naszego folderu data.  Jeśli wiemy gdzie zapisać nasz backup i znamy jego nazwę, to czas uzupełnić jego zawartość plikami z serwera.

Dla kodu związanego z pierwszym krokiem tworzenia kopii zapasowych utwórzmy folder src/download – tam będziemy przechowywać funkcjonalność pobierania plików z serwera pod wskazane przez nas miejsce. W folderze download utwórzmy też plik FtpDownloader.js, w którym zaimplementujemy nasz moduł do pobierania plików z serwera poprzez FTP.

Wygląda on następująco:

Klasa FtpDownloader składa się z dwóch głównych elementów. Pierwszym jest konstruktor, do którego przekazujemy konfigurację naszego klienta ftp (o tym zaraz). Drugim jest natomiast metoda downloadBackup, która przyjmuje dwa parametry – nazwę nowego backupu, oraz ścieżkę w którym ma on być umieszczony.

Na początku metody downloadBackup wypisujemy na konsolę informacje o tym, że rozpoczęto tworzenie nowej kopii zapasowej o podanej nazwie. Użyta w tym miejscu biblioteka chalk pomoże nam sformatować tę wiadomość w odpowiedni sposób.

Następnie za pomocą fs.mkdirSync tworzymy w folderze data folder na naszą kopię zapasową, o nazwie którą zdefiniowaliśmy na początku skryptu index.js.

Kiedy folder zostanie utworzony, tworzymy nową instancję klasy FtpIterator, czyli helpera który pomoże nam w poruszaniu się po wszystkich plikach na serwerze, wykonując na nich odpowiednią akcję – teraz będzie to po prostu zapis pliku. Metoda forEachFile zwraca Promise, który po pomyślnym zakończeniu pomoże nam rozłączyć się z serwerem, oraz powiadomić użytkownika o tym, że kopia zapasowa została utworzona.

Jak wygląda klasa FtpIterator? 

Jest ona wrapperem na klienta udostępnianego przez bibliotekę node-ftp, więc referencję do tego klienta przekażemy za pomocą konstruktora. Dwie pierwsze metody, czyli connect disconnect służą odpowiednio do inicjalizowania, oraz kończenia połączenia z naszym serwerem.

Kolejna metoda, czyli forEachFile jest w zasadzie najważniejszym elementem który dana klasa udostępnia. Jest to generyczna metoda służąca do wykonania akcji na danym pliku. Akcja jest przekazywana w formie parametru i może być dowolną operacją którą chcemy na danym pliku wykonać.

Metoda forEachFile opiera się na wynikach “natywnej” metody klienta z biblioteki node-ftp, czyli list. List zwraca nam tablicę obiektów reprezentujących wszystko co znajduje się w danym katalogu na serwerze ftp. Każdy obiekt zawiera m.in. nazwę elementu, typ (d – directory, “-” – file), rozmiar oraz uprawnienia. Zaraz po połączeniu z serwerem znajdujemy się w katalogu głównym, więc musimy teraz odpowiednio przejść po wszystkich folderach do momentu kiedy natrafimy na plik – w tym przypadku wykonamy naszą akcję (zapis pliku na dysk). Do wykonania pojedynczej iteracji służy metoda doIteration, która przyjmuje jeden obiekt zwrócony przez metodę list, i w zależności od jego typu a) wchodzi do środka, w przypadku folderu, albo b) wykonuje na nim akcję.

Akcje obsługiwane przez iterator muszą spełniać następujący “kontrakt”:

Przekazujemy do nich cztery parametry – klienta, katalog w którym znajdujemy się aktualnie, aktualny plik który procesujemy oraz ścieżkę do niego. Oczekujemy na to, że dana akcja zwróci nam Promise, na który będziemy mogli odpowiednio zareagować.

Idąc dalej, listDir i changeDir to opakowane natywne metody biblioteki node-ftp w taki sposób, że zamiast callbacków posługujemy się w nich Promise’ami. Ostatnia z metod, czyli getNameWithParent służy do określenia poprawnej ścieżki do danego elementu z uwzględnieniem ścieżki nadrzędnej.

W pliku z klasą FtpDownloader widać, że do naszego iteratora przekazujemy akcję zapisywania plików na dysk. Jak wygląda implementacja takiej akcji? Znajduje się ona w folderze src/saveFile.js:

Po pierwsze widzimy tutaj, że w zapisywaniu plików na dysk posługujemy się tzw. “blacklistą” – chcemy zignorować pliki z wybranych folderów. W naszym przypadku zignorujemy pliki do których ścieżka zawiera fragmenty /tmp oraz /wp-content/uploads.

Drugim interesującym elementem jest wykorzystanie biblioteki mkdirp pozwalającej nam tworzyć zagnieżdżone katalogi. Wykorzystamy ją do odwzorowania struktury folderów serwera na dysku lokalnym. Plik na serwerze znajdujący się pod ścieżką /a/b/c.txt będziemy chcieli zapisywać w folderze /data/[nazwa_kopii]/a/b/c.txt.

I to w zasadzie tyle, jeśli chodzi o funkcjonalność pobierania plików poprzez ftp. Nasz FtpDownloader możemy teraz zaimportować w pliku index.js i użyć go w następujący sposób:

Plik konfiguracyjny (config/config.json), w którym będziemy przechowywać login i hasło użytkownika ftp, oraz adres serwera, ma następujący format:

Mając taki plik możemy teraz uruchomić tworzenie kopii zapasowej poleceniem npm start w katalogu głównym naszej aplikacji:

W momencie natrafienia na ignorowany plik będziemy o tym informowani na bieżąco:

Teraz pozostanie nam jedynie czekać na wiadomość informującą o stworzeniu kopii zapasowej o podanej nazwie.

Pierwsza część zadania za nami – pobieramy na dysk pliki wchodzące w skład kopii zapasowej, przy pomocy protokołu FTP!

Archiwizacja i kompresja plików

Wykorzystując fakt, że nasz FtpDownloader zwraca Promise który zostanie rozwiązany w momencie utworzenia kopii zapasowej, użyjmy go do rozpoczęcia procesu archiwizacji naszej kopii.

Najpierw zobaczmy, jak wygląda klasa BackupArchiver którą utworzyłem w folderze src/archive:

Tutaj praktycznie w całości opieramy się o bibliotekę node-archiver, która archiwizuje nasz folder z plikami oraz kompresuje go.

Naszą klasę importujemy w pliku index.js i wywołujemy zaraz po pobraniu plików z serwera:

Ważne jest, żeby rezultat metody archiveBackup zwrócić w sekcji then. Dzięki temu utworzymy tzw. promise chain i kolejne operacje asynchroniczne będziemy mogli wykonywać jedna po drugiej.

Skoro zrealizowaliśmy już pobieranie plików oraz ich archiwizację, to kopię zapasową należy jeszcze wysłać w miejsce gdzie będziemy przechowywać całą kolekcję backupów. Jest to krok opcjonalny, ponieważ sam fakt regularnych zrzutów plików z serwera już sam w sobie polepszy jakość naszego snu. Zobaczmy co możemy osiągnąć dodając do naszej aplikacji integrację z Google Drive.

Upload kopii zapasowych

Do pracy z Google Drive wykorzystamy Google Drive API w wersji 3, którego dokumentację znajdziecie pod tym linkiem. Kluczowe dla nas są dwa pierwsze kroki które musimy wykonać do korzystania z API, czyli 1) Turn on the Drive API oraz 2) Install the client library. W pierwszym kroku wygenerujemy tokeny którymi będziemy się autoryzować i dodamy je do naszego projektu, natomiast krok drugi to zainstalowanie klientów serwisów Google’a.

Nie ukrywam, że do zaimplementowania podstawowej funkcjonalności wgrywania plików opierałem się bardzo mocno na przykładach zamieszczonych w stronie którą zamieściłem, więc teraz pokażę jedynie efekt końcowy.

Zacznijmy od przebiegu całego procesu w pliku index.js:

Do serii Promise’ów związanych z pobieraniem i archiwizowaniem kopii, doszło wywołanie metody związanej z uploadem backupu w wybrane przez nas miejsce.

Plik konfiguracyjny został rozbudowany o sekcję związaną z Google Drive i tokenami OAuth:

W sekcji googledrive pozycja destination to ID folderu w którym chcemy umieszczać nasze kopie (więcej o strukturze dysku tutaj), natomiast pozostałe dane potrzebne są do autoryzacji – uzyskamy je podążając za krokami tutoriala zamieszczonego powyżej.

Do uploadu wykorzystuję klasę GoogleDriveUploader którą umieściłem w folderze src/upload:

Metodą którą w tym przypadku chcemy wykorzystać jest uploadBackup. Wywołuje ona metodę authorize którą znajdziecie w dokumentacji, a która służy do autoryzowania zapytań do API z którego korzystamy. Autoryzacja opiera się na poprawnym zainicjalizowaniu klienta protokołu OAuth2, a to daje nam możliwość wywoływania poprzez callback wybranych zapytań:

Po pierwszym uruchomieniu naszego kodu zostaniemy poproszeni o odwiedzenie strony do której link zostanie wypisany na konsoli (fragment metody getNewToken) – podążamy więc wskazówkami, pobieramy token, zapisujemy go na dysku i od tego momentu możemy się cieszyć możliwością wysyłania autoryzowanych zapytań do API Google’a.

Po uruchomieniu całego procesu tym razem w konsoli powinniśmy zobaczyć następujące komunikaty:

Warto jeszcze sprawdzić, czy na Google Drive cokolwiek się pojawiło ;):

Kopie zapasowe w jednym miejscu – czy nie wygląda to dobrze?

Potrzeba matką wynalazków

Niektórzy twierdzą, że jeśli zauważasz wokół siebie powtarzające się zadanie które zajmuje ci więcej niż 90 sekund, to powinieneś je jak najszybciej zautomatyzować.

W przypadku naszego bloga tworzenie kopii zapasowych zajmowało zdecydowanie więcej czasu. Do tej pory każdy z kroków, czyli pobranie plików przez FTP, kompresja oraz upload wykonywany był oddzielnie. Teraz, dzięki proste aplikacji w Node.js cały proces stanie się o wiele prostszy i mogę śmiało powiedzieć, że… przyjemny. Do tego możemy go dowolnie modyfikować, lub też wykonywać poszczególne kroki oddzielnie. Funkcjonalności takie jak blacklistowanie plików pozwalają nam lepiej kontrolować zawartość naszej kopii zapasowej.

Cały kod aplikacji znajdziecie tutaj – https://github.com/psmyrdek/node-backup

Oczywiście sama aplikacja nie jest szczytem “clean code’u” oraz wzorcowego stosowania wzorców projektowych. Na teraz to MVP które realizuje podstawowe założenia. Najważniejsze jednak, że robi to dobrze. Kopie zapasowe powstają, a więc zrealizowaliśmy cel “biznesowy” naszego projektu. Teraz mogę poprawiać fragmenty kodu które wyglądają nie tak jak powinny, albo rozbudowywać wszystko o nowe funkcjonalności. To jednak dopiero przede mną, a być może przed nami – zapraszam wszystkich chętnych do kontrybucji i wspólnej pracy nad tym narzędziem!

Zachęcam was do przyjrzenia się z boku zadaniom które realizujecie – być może niektóre z nich można zautomatyzować w podobny sposób? A może macie przykłady takich automatyzacji którymi chcecie się pochwalić? Zapraszamy do dyskusji w komentarzach!