Fake it... ale nie tak jak myślisz - NServiceBus Web Host
Posty z tej serii:
- Fake it… ale nie tak jak myślisz - NServiceBus Web Host
- Fake it… ale nie tak jak myślisz - ASP.NET Web Host
- Fake it… ale nie tak jak myślisz - NServiceBus Windows Service Host
- Fake it… ale nie tak jak myślisz - Build - Run Unit tests - Publish
Jakie jest twoje pierwsze skojarzenie z frazę Fake it w kontekście wytwarzania oprogramowania? U mnie jest to/było Fakeowanie zależności w celu jednostkowego przetestowania konkretnego kawałka funkcjonalności. Przykłady zależności? Warstwa dostępu do bazy danych, zależność do zewnętrznego serwisu lub do systemu plików. W skrócie, każda zależność do zasobu, który trzeba przygotować przed uruchomieniem testu. Fakeując taki zasób, symulujemy jego istnienie, wykorzystując do tego pamięć operacyjną maszyny na której uruchamiany jest test. Z takim podejściem związana jest cała filozofia, która definiuje pojęcia, nazwy, sposób zastosowania, itd. W wersji uproszczonej, chodzi o możliwość jednostkowego testowania logiki biznesowej w oderwaniu od zewnętrznych zależności. A czy istnieją biblioteki, wspomagające w realizacji takiego podejścia? Tak? A jakie? A proszę bardzo:
Od momentu, kiedy natrafiłem na narzędzie FAKE fraza Fake it nabrała dla mnie innego znaczenia, a mianowicie, przygotowanie napisanego kodu do wdrożenia oraz jego wdrożenie:
FAKE - A DSL for build tasks and more The power of F# - anywhere - anytime (https://fake.build/)
FAKE pozwala pisać skrypty wdrożeniowe w języku F#. Samo narzędzie również napisane jest w języku F#.
Artykuł ten jest pierwszym z serii, w której pokażę Ci, w jaki sposób wykorzystałem FAKEa do buildowania oraz wdrażania systemu komentarzy używanego na niniejszym blogu. Do tej pory używałem skryptów napisanych w PowerShellu. O szczegółach implementacji możesz przeczytać w jednym z moich poprzednich artykułów.
Zacznijmy od pewnej nowości. Właścicielem agregatu CommentPolicy jest Endpoint o nazwie BlogComments. Framework NServiceBus umożliwia hostowanie Endpointów na wiele sposobów. Jednym z nich jest możliwość wykorzystania któregoś z frameworków webowych. Dzięki temu możemy wysyłać wiadomości z aplikacji webowej za pomocą Endpointa typu Send-only. Jeśli używamy technologii kolejkowej opartej o model centralnego Message Brokera, to nic nie stoi na przeszkodzie, aby w procesie aplikacji webowej hostować pełny Endpoint, który oprócz wysyłania wiadomości potrafi je również przetwarzać. W wersji 2.1.0-beta.1 systemu komentarzy na blogu przeszedłem z hostingu Windows Service na hosting Web Application.
W dalszej części artykułu przejdziemy przez funkcjonalności wdrażania aplikacji webowej, której jedynym zadaniem jest hostowanie Endpointa NServiceBus.
Design
Wdrożenie nowej wersji Endpointa składa się z minimum czterech operacji:
- Zatrzymanie uruchomionego Endpointa
- Usunięcie artefaktów z aktualnymi funkcjonalnościami
- Wgranie artefaktów z nowymi funkcjonalnościami
- Wystartowanie Endpointa
Powyższe kroki pokrywają się 1 do 1 z krokami wdrażania nowych wersji hostowanych jako Windows Service, w których mechanizm wdrażający ma uprawnienia do zatrzymywania oraz uruchamiania usług Windows. Jeśli mechanizm nie ma takich uprawnień to hmm…nie spotkałem się z tego typu rozwiązaniami.
Jeśli chodzi o aplikacje webowe, mając odpowiednie uprawnienia na serwerze webowym, możemy wykonać takie same kroki. Jedna ważna uwaga. Pamiętaj, że wdrażamy Endpoint NServiceBusa, co oznacza, że używamy mechanizmu kolejkowego, co z kolei oznacza, że zatrzymanie aplikacji webowej nie wstrzymuje użytkowników przed dalszym korzystaniem z systemu. Wszystkie wygenerowane dane w trakcie wdrażania nowej wersji dodawane są do kolejki i zostaną przetworzone, jak tylko pojawi się nowa wersja aplikacji. Jest to odwrotne zachowanie w stosunku do aplikacji webowych przetwarzających żądania HTTP, gdzie zatrzymanie aplikacji powoduje niemożliwość korzystania z systemu. W takich przypadkach można np. stosować podejście Blue-Green-Deployment. Więcej o takim podejściu przeczytasz we wcześniej wspomnianym artykule.
No dobrze, a co jeśli mechanizm wdrażający nie ma uprawnień zatrzymywania oraz startowania aplikacji na serwerze webowym? W takim przypadku trzeba przedefiniować pojęcia Startu i Stopu. Wystartować aplikację możemy poprzez wywołanie dowolnego żądania HTTP. Jeśli używamy np. frameworka ASP.NET to wystarczy, że mamy jeden Controller z jedną metodą typu GET, którą zawołamy w ostatnim kroku wdrożeniowym. Sposób zatrzymywania aplikacji zależny jest od dostawcy. Może to być np. wgranie na serwer pliku z odpowiednim rozszerzeniem, a następnie wywołanie żądania HTTP.
Do powyższego schematu możemy dodawać elementy opcjonalne np. backup aktualnej wersji, wysyłanie powiadomień o pojawieniu się nowej wersji itp. Schemat, którego ja używam, przy wdrażaniu nowej wersji systemu komentarzy na blogu zawiera kroki:
- Zatrzymać uruchomiony Endpoint
- Zrobić backup aktualnej wersji
- Usunąć artefakty z aktualnymi funkcjonalnościami
- Wgrać artefakty z nowymi funkcjonalnościami
- Wystartować Endpoint
Develop
Pisząc w języku F# mamy możliwość uruchamiania kodu w tzw. trybie interaktywnym. Oznacza to, że nie musimy tworzyć projektów *.csproj. W Visual Studio kod możemy pisać oraz uruchamiać w oknie F# Interactive. Możemy też dodać implementację do pliku z rozszerzeniem .fsx, a następnie uruchomić program z wiersza poleceń za pomocą narzędzia fsi.exe.
FAKE umożliwia uruchamianie kodu napisanego w plikach .fsx. Zobaczmy, w jaki sposób możemy zakodować prototyp skryptu wdrożeniowego np. w pliku o nazwie host_ftp_deploy.fsx:
#r "paket:
nuget Fake.Core.Target //"
#load "./.fake/host_ftp_deploy.fsx/intellisense.fsx"
open Fake.Core
// Targets
Target.create "Stop Endpoint" (fun _ ->
Trace.trace "..."
)
Target.create "Backup Endpoint" (fun _ ->
Trace.trace "..."
)
Target.create "Deploy Endpoint" (fun _ ->
Trace.trace "..."
)
open Fake.Core.TargetOperators
// Dependencies
"Stop Endpoint"
==> "Backup Endpoint"
==> "Deploy Endpoint"
// Start build
Target.runOrDefaultWithArguments "Deploy Endpoint"Kroki do uruchomienia definiuje się w tzw. Targetach. Następnie za pomocą operatora ==> definiuje się kolejność wykonywania poszczególnych Targetów. Ostatnim krokiem jest uruchomienie Targetu rozpoczynającego proces wdrażania. W naszym przykładzie uruchamiamy Target Deploy Endpoint. FAKE rozpozna zależność do Targetu Backup Endpoint, a następnie do Targetu Stop Endpoint, co spowoduje uruchamianie Targetów w kolejności:
- Stop Endpoint
- Backup Endpoint
- Deploy Endpoint
Po uruchomieniu skryptu dzieją się ciekawe rzeczy. Kod napisany jest w języku F#, także mamy pełne wsparcie kompilatora! Jeśli implementacja jest niepoprawna, to nic się nie uruchomi. FAKE zwróci błąd kompilacji. Przykładowo, jeśli zastąpisz, któreś z wywołań Trace.trace na Trace.trac, to uruchamiając skrypt, otrzymasz komunikat:
Script is not valid: C:\...\host_ftp_deploy.fsx (9,8)-(9,12):
Error FS0039: The value, constructor, namespace or type 'trac' is not defined.
Maybe you want one of the following:
trace
tracef
tracefn
traceTag
traceFAKEDrugą pomocną rzeczą jest pełne wsparcie Intellisense! Otwierając skrypt w Visual Studio zobaczysz kolorowanie składki, podpowiedzi dostępnych funkcji, błędy kompilacji itd.
Trzecim elementem jest wsparcie dla Nugeta. Pierwsze uruchomienie skryptu spowoduje pobranie zależności zdefiniowanych na początku pliku.
Moją ulubioną cechą w programowaniu funkcyjnym jest możliwość dzielenia kodu na mniejsze kawałki, poprzez definiowanie kolejnych funkcji. W programowaniu obiektowym tymi kawałkami są klasy, natomiast mam takie odczucie, że proces decyzyjny w przypadku funkcji jest prostszy niż w przypadku klas. Funkcje mogą być zarówno proste, jak i złożone. Sama funkcja jest oddzielnym elementem. W przypadku klas trzeba się trochę bardziej nagimnastykować, aby dobrze zdefiniować jej dobrą odpowiedzialność, dobrać odpowiednie pola, metody, itp. W przypadku FAKEa możemy określić, że pojedynczą odpowiedzialnością jest sam skrypt wdrożeniowy. W naszym przypadku jest to wdrożenie Endpointa hostowanego w aplikacji webowej. Poszczególne Targety są funkcjami, które wyznaczają kroki wdrożeniowe. Każdy krok może być w całości zakodowany w Targecie lub podzielony na mniejsze elementy - funkcje.
Przejdźmy teraz do szczegółów. Skrypt powinien być uniwersalny tak, aby można go było wykorzystać do wdrażania na różne środowiska. Pierwszym krokiem jest wydzielenie kodu pobierającego parametry wdrożeniowe:
let winSCPExecutablePathParamName = "winSCPExecutablePath"
let ftpHostNameParamName = "ftpHostName"
let ftpUserNameParamName = "ftpUserName"
let ftpPasswordParamName = "ftpPassword"
// idt.FAKE posiada wbudowane moduły oraz funkcje, których możemy używać. Jedna z nich umożliwia odczytanie parametrów wejściowych skryptu wewnątrz Targetu - Environment.environVarOrFail. Opakujmy wbudowaną funkcję naszą funkcją, której będziemy używać we wszystkich Targetach:
let retrieveParam paramName =
Environment.environVarOrFail paramNameFAKE w piątej wersji nie posiada modułu pozwalającego wykonywać operacje na protokole FTP. Z tego względu musimy taką funkcjonalność, zakodować sami używając np. WinSCP. Trzy podstawowe kroki wykonania żądania FTP to:
- Otwórz sesję
- Wykonaj operację
- Zamknij sesję
Idealna funkcjonalność do zamknięcia w osobnej funkcji:
let makeFtpAction action =
let winSCPExecutablePath = retrieveParam winSCPExecutablePathParamName
let ftpHostName = retrieveParam ftpHostNameParamName
let ftpUserName = retrieveParam ftpUserNameParamName
let ftpPassword = retrieveParam ftpPasswordParamName
let ftpTlsHostCertificateFingerprint = retrieveParam ftpTlsHostCertificateFingerprintParamName
let sessionOptions = new SessionOptions()
sessionOptions.Protocol <- Protocol.Ftp
sessionOptions.FtpSecure <- FtpSecure.Explicit
sessionOptions.TlsHostCertificateFingerprint <- ftpTlsHostCertificateFingerprint
sessionOptions.HostName <- ftpHostName
sessionOptions.UserName <- ftpUserName
sessionOptions.Password <- ftpPassword
use session = new Session()
session.ExecutablePath <- winSCPExecutablePath
session.Open(sessionOptions)
action sessionA teraz dwa magiczne elementy języka F#:
-
Słowo kluczowe
useużyte przy tworzeniu obiektuSessionsprawi, że po zakończeniu cyklu życia obiektu automatycznie zostanie zawołana metodaDispose. -
Parametr
actionfunkcjimakeFtpActionjest również funkcją, która jako parametr bierze obiektSessioni zwraca generyczną wartość. Oznacza to, że do funkcjimakeFtpActionmożemy przekazać dowolną operację zgodną z sygnaturą funkcjiaction.
Zobaczmy, jak można wykorzystać funkcję makeFtpAction w kroku zatrzymywania Endpointa:
Target.create "Stop Endpoint" (fun _ ->
let ftpEndpointPath = retrieveParam ftpEndpointPathParamName
let ftpOfflineHtm = retrieveParam ftpOfflineHtmParamName
let ftpOnlineHtm = retrieveParam ftpOnlineHtmParamName
let endpointUrl = retrieveParam endpointUrlParamName
Trace.trace ("-> Endpoint " + ftpEndpointPath)
let offline = sprintf @"%s/%s" ftpEndpointPath ftpOfflineHtm
let online = sprintf @"%s/%s" ftpEndpointPath ftpOnlineHtm
let getEndpointState () =
let isDirectoryEmpty = makeFtpAction (fun ftp -> ftp.EnumerateRemoteFiles(ftpEndpointPath, null, EnumerationOptions.None) |> Seq.isEmpty)
match isDirectoryEmpty with
| true -> NotExists
| false ->
let isStopped = makeFtpAction (fun ftp -> ftp.FileExists(online))
match isStopped with
| true -> Stopped
| false -> Running
let stopEndpoint () =
makeFtpAction (fun ftp -> ftp.MoveFile(offline, online))
try
Trace.trace ("-> Call URL " + endpointUrl)
Http.get "" "" endpointUrl |> ignore
with
| :? System.Net.Http.HttpRequestException as ex ->
Trace.trace ex.Message
let isStatusCorrect = ex.Message.Contains("503");
match isStatusCorrect with
| true -> ()
| false -> reraise()
match getEndpointState with
| NotExists -> Trace.trace (sprintf "-> Endpoint %s is not exists yet." ftpEndpointPath)
| Stopped -> Trace.trace (sprintf "-> Endpoint %s is already stopped." ftpEndpointPath)
| Running ->
Trace.trace ("-> Stop Endpoint " + ftpEndpointPath)
stopEndpoint ()
Trace.trace (sprintf "-> Endpoint %s stopped successfully." ftpEndpointPath)
)Przejdźmy przez powyższy kod, zaczynając analizę od końca. Najpierw sprawdzamy stan Endpointa poprzez wywołanie zakodowanej lokalnej funkcji getEndpointState. Jeśli Endpoint nie istnieje lub jest już zatrzymany, to nie robimy nic. Jeśli jest uruchomiony, to wywołujemy zakodowaną lokalną funkcję stopEndpoint, która zatrzyma Endpoint. W lokalnych funkcjach używany wcześniej zdefiniowanej funkcji makeFtpAction. Na przykład:
makeFtpAction (fun ftp -> ftp.EnumerateRemoteFiles(ftpEndpointPath, null, EnumerationOptions.None- pobranie listy plików ze zdalnego zasobu
makeFtpAction (fun ftp -> ftp.FileExists(online))- sprawdzenie, czy plik istnieje na zdalnym zasobie
makeFtpAction (fun ftp -> ftp.MoveFile(offline, online))- zmiana nazwy pliku na zdalnym zasobie
Do podejmowania decyzji o kolejnych krokach programu, bazujących na wartościach zwracanych przez funkcje, używamy konstrukcji języka F# zwanej Pattern Matching.
A jak może wyglądać implementacja backupu aktualnej wersji?
Target.create "Backup Endpoint" (fun _ ->
let localEndpointBackupPath = retrieveParam localEndpointBackupPathParameName
let ftpEndpointPath = retrieveParam ftpEndpointPathParamName
let ftpEndpointBackupPath = retrieveParam ftpEndpointBackupPathParamName
Trace.trace ("-> Endpoint " + ftpEndpointPath)
Trace.trace ("-> Clean " + localEndpointBackupPath)
Shell.cleanDir localEndpointBackupPath
Trace.trace ("-> Download files from " + ftpEndpointPath)
makeFtpAction (fun ftp -> ftp.GetFiles(ftpEndpointPath, localEndpointBackupPath).Check())
Trace.trace ("-> Remove files from " + ftpEndpointBackupPath)
makeFtpAction (fun ftp -> ftp.RemoveFiles(ftpEndpointBackupPath + "/*").Check())
Trace.trace ("-> Upload files to " + ftpEndpointBackupPath)
makeFtpAction (fun ftp -> ftp.PutFiles(localEndpointBackupPath, ftpEndpointBackupPath).Check())
Trace.trace (sprintf "-> Endpoint %s backuped successfully." ftpEndpointPath)
)Funkcjonalność zakodowana jest wprost, zgodnie z algorytmem:
- Wyczyść lokalny zasób
- Pobierz do lokalnego zasobu aktualną wersję z zasobu zdalnego
- Wyczyść zdalny zasób przeznaczony na backup
- Wgraj na zdalny zasób wersję z zasobu lokalnego
Do operacji na zdalnym zasobie ponownie używamy funkcji makeFtpAction
Ostatnim krokiem w procesie Deploy’u jest wgranie nowej wersji na zdalny zasób:
Target.create "Deploy Endpoint" (fun _ ->
let ftpEndpointPath = retrieveParam ftpEndpointPathParamName
let deployArtifactsPath = retrieveParam deployArtifactsPathParamName
let buildArtifactsPath = retrieveParam buildArtifactsPathParamName
let settingsPath = retrieveParam settingsPathParamName
let nservicebusPath = retrieveParam nservicebusPathParamName
let endpointUrl = retrieveParam endpointUrlParamName
let removeRemoteItem item =
let isItemExists = makeFtpAction (fun ftp -> ftp.FileExists(item))
if isItemExists then makeFtpAction (fun ftp -> ftp.RemoveFiles(item).Check())
Trace.trace (sprintf "-> Endpoint %s." ftpEndpointPath)
Trace.trace ("-> Clean " + deployArtifactsPath)
Shell.cleanDir deployArtifactsPath
Trace.trace ("-> Copy " + buildArtifactsPath)
Shell.copyDir deployArtifactsPath buildArtifactsPath FileFilter.allFiles
Trace.trace ("-> Copy " + settingsPath)
Shell.copyDir deployArtifactsPath settingsPath FileFilter.allFiles
Trace.trace ("-> Copy " + nservicebusPath)
Shell.copyDir deployArtifactsPath nservicebusPath FileFilter.allFiles
Trace.trace ("-> Clean " + ftpEndpointPath)
// protects against starting Endpoint after removing offline htm
removeRemoteItem(ftpEndpointPath + "/web.config")
makeFtpAction (fun ftp -> ftp.RemoveFiles(ftpEndpointPath + "/*").Check())
Trace.trace ("-> Upload files to " + ftpEndpointPath)
makeFtpAction (fun ftp -> ftp.PutFiles(deployArtifactsPath, ftpEndpointPath).Check())
Trace.trace ("-> Call URL " + endpointUrl)
Http.get "" "" endpointUrl |> ignore
Trace.trace (sprintf "-> Endpoint %s deployed successfully." ftpEndpointPath)
)Ponownie kod odzwierciedla poszczególne kroki algorytmu:
- Wyczyszczenie lokalnego zasobu
- Przegranie do lokalnego zasobu skompilowanych artefaktów wraz z plikami konfiguracji
- Wyczyszczenie zasobu zdalnego
- Wgranie nowej wersji z zasobu lokalnego na zasób zdalny
- Wystartowanie Endpointa poprzez wywołanie żądania HTTP
Jeśli w którymkolwiek Targecie wystąpi wyjątek, którego nie obsłużyliśmy, dostaniemy pełną informację w kliencie uruchamiającym skrypt np. oknie konsoli.
Implementację całej funkcjonalności możesz podejrzeć na moim GitHubie.
Test & Deploy
Skrypty możemy uruchamiać poleceniem fake run nazwa_pliku.fsx. Do przekazywania parametrów, które można odczytać w Targetach służy parametr -e. Przykład wywołania:
fake run host_ftp_deploy.fsx -e p1 -e p2
W przypadku gdy liczba parametrów jest spora, możemy skorzystać z narzędzia PowerShell i napisać skrypt .ps1, który uruchomi skrypt napisany w FAKE:
#fake build parameters
$winSCPExecutablePath = "winSCPExecutablePath=C:\deploy\blog-comments\winscp\WinSCPnet.dll"
$ftpHostName = "ftpHostName=[host_name_or_host_ip_address]"
$ftpUserName = "ftpUserName=[ftp_user_name]"
$ftpPassword = "ftpPassword=[ftp_user_password]"
#idt.
#execute script
fake run host_ftp_deploy.fsx -e $winSCPExecutablePath -e $ftpHostName -e $ftpUserName -e $ftpPassword itd.Konwencja nazewnictwa parametrów rozpoznawanych przez FAKE to key=value.
Dzięki parametrom możemy użyć tego samego skryptu przy wdrożeniach na różne środowiska, zarówno testowe, jak i produkcyjne. Całość sprowadza się do utworzenia osobnego skryptu PowerShell z odpowiednimi parametrami dla każdego środowiska. Ja posiadam dwa takie skrypty:
- run_deploy_test.ps1
- run_deploy_production.ps1
Kiedy natrafiłem na FAKEa, od razu zainteresowałem się jego możliwościami. Pisanie kodu dla funkcjonalności oraz kodu wdrażającego w tym samym języku z użyciem tych samych narzędzi developerskich mocno upraszcza cały proces wytwarzania oprogramowania. Podejście funkcyjne, kompilacja kodu, Intellisense, obsługa Nugeta itp. znacząco zwiększa komfort pracy nad skryptem. Dodatkowym bonusem jest frajda z pisania kodu w F#, a następnie obserwowanie jak napisany kod działa i robi to, co ma robić :)
W następnym artykule przejdziemy przez funkcjonalność wdrażania aplikacji webowej obsługującej żądania HTTP.
=