To jest kolejny artykuÅ‚ z serii o globalizacji i lokalizacji. JeÅ›li nie czytaÅ‚eÅ› poprzednich, koniecznie to nadrób. W tym artykule opisujÄ™ TYLKO jak Å‚adować tÅ‚umaczenia w aplikacjach internetowych tworzonych w RAZOR. Poprzedni artykuÅ‚ – TÅ‚umaczenie aplikacji cz. 3 – jak to ogarnąć? – daje całą podstawÄ™.
W aplikacjach internetowych możemy uwzględniać język na kilka sposobów:
informacji wysyłanej z przeglądarki (nagłówek żądania)
parametru w zapytaniu (np. https://example.com?lang=en)
ciasteczka
fragmentu URL (np. https://example.com/en-US/)
Popatrzymy na te wszystkie możliwości.
Å»eby w ogóle caÅ‚a machina ruszyÅ‚a, trzeba skonfigurować lokalizacjÄ™… To naprawdÄ™ proste, wystarczy zrozumieć 🙂
Czym jest middleware pipeline?
JeÅ›li wiesz, czym jest middleware pipeline w .NetCore, możesz przejść dalej. JeÅ›li nie wiesz – też możesz, ale dalsza część artykuÅ‚u bÄ™dzie trochÄ™ niejasna.
Pipeline (czyli potok) to seria akcji wykonywanych jedna po drugiej podczas odbierania żądania od klienta i wysyłania odpowiedzi. W metodzie Configure ustawiasz właśnie te komponenty w pipelinie za pomocą metod, których nazwy rozpoczynają się zwyczajowo od Use. Np. UseAuthentication, UseAuthorization itd. Spójrz na przykładowe kody:
Żądanie przejdzie najpierw przez HttpsRedirection, który może sobie na nim pracować i może przekazać wywoÅ‚anie do kolejnego middleware (ale wcale nie musi). Żądanie może nastÄ™pnie trafić do RouterMiddleware, który wie, jakÄ… stronÄ™ ma pokazać. NastÄ™pnie generowana jest odpowiedź, która przechodzi przez middleware’y w odwrotnej kolejnoÅ›ci (w tym momencie nie można już zmodyfikować nagłówków).
Najpierw trzeba skonfigurować języki w aplikacji RAZOR. Przede wszystkim zajrzyj do pliku Startup.cs i tam odnajdź metodę ConfigureServices. (jeśli używasz .NET6, możesz nie widzieć Startup.cs, wszystko dzieje się w pliku Program.cs)
Teraz musisz w niej skonfigurować serwis odpowiedzialny za lokalizację. Są takie metody (extensions) w IServiceCollection jak AddControllers*, AddMVC*, czy też AddRazorPages. Każda z nich zwraca obiekt implementujący IMvcBuilder. Z kolei ten, ma w sobie rejestrację lokalizacji (AddViewLocalization()), a więc np:
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.DependencyInjection;
//...
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews()
.AddViewLocalization();
}
NajprostszÄ… konfiguracjÄ™ lokalizacji robimy w metodzie Configure – PRZED mapowaniem Å›cieżek. A wiÄ™c dodajemy to do pipeline. WyglÄ…da to tak:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
app.UseRequestLocalization(localizationOptions);
Teraz przyda się kilka słów wyjaśnienia.
Najpierw trzeba użyć oprogramowania pośredniczącego (middleware) do lokalizacji. Robimy to przez włączenie do pipeline UseRequestLocalization. Można to zrobić na kilka sposobów:
app.UseRequestLocalization() – bez parametrów – odczyta lokalizacjÄ™ z nagłówka żądania, który wysyÅ‚any jest przez przeglÄ…darkÄ™. I tyle. Niczego tu nie można zmienić.
app.UseRequestLocalization(RequestLocalizationOptions) – od razu skonfiguruje middleware RequestLocalization zgodnie z przekazanymi opcjami
app.UseRequestLocalization(Action) – podobnie jak wyżej, tyle że przekazujemy tutaj akcjÄ™, w której konfigurujemy middleware.
W naszym przykładzie włączamy RequestLocalization do pipeline (pamiętaj, że ZANIM zmapujemy ścieżki), przekazując opcje.
Wróćmy do kodu:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
app.UseRequestLocalization(localizationOptions);
Najpierw tworzona jest lista kultur, które wspieramy, a w drugim kroku ustawiamy opcje lokalizacji:
odczytu lokalizacji z przeglądarki (z nagłówka żądania)
odczytu lokalizacji z parametrów zapytania (?culture=pl-PL)
odczytu lokalizacji z ciasteczka
Czyli konfigurując w taki sposób (z przekazaniem RequestLocalizationOptions) mamy dużo więcej niż po prostu włączając middleware do pipeline bez jego konfiguracji.
To teraz pytanie, skÄ…d system wie, w jaki sposób ma pobrać dane o aktualnej kulturze? Czary? Nie! Z pomocÄ… przychodzi…
RequestCultureProvider
To jest klasa abstrakcyjna, której zadaniem jest zwrócić informacje o kulturze na podstawie danych z żądania. Kilka domyślnych providerów jest już utworzonych i właściwie nie potrzeba więcej, chociaż możesz stworzyć własne (np. odczyt kultury z bazy danych).
W klasie RequestLocalizationOptions (opcje lokalizacyjne) poza obsługiwanymi kulturami znajduje się też lista RequestCultureProvider. Domyślnie utworzone są takie:
QueryStringRequestCultureProvider
zwraca kulturę z zapytania w adresie, np: https://example.com/Home/Index?culture=en-US; świetnie nadaje się to do debugowania. Domyślnie operuje na dwóch kluczach: culture i ui-culture. Wystarczy, że w zapytaniu będzie jeden z nich, drugi otrzyma taką samą wartość. Jeśli są oba, np: ?culture=en-US&ui-culture=en-GB, wtedy inne będą ustawienia dla CurrentCulture i CurrentUICulture.
Oczywiście klucze możesz sobie zmieniać za pomocą właściwości
QueryStringKey (domyÅ›lnie „culture”)
UIQueryStringKey (domyÅ›lnie „ui-culture”)
Także zamiast ?culture=en-US będziesz mógł podać np. ?lang=en
CookieRequestCultureProvider
zwraca kulturÄ™ z ciasteczka. Sam możesz zdecydować o tym, jak ma nazywać siÄ™ dane ciasteczko (za pomocÄ… wÅ‚aÅ›ciwoÅ›ci CookieName). DomyÅ›lnie to: „.AspNetCore.Culture”.
Żeby to zadziałało, oczywiście jakieś ciasteczko musi zostać wcześniej zapisane. Ta klasa ma dwie przydatne metody statyczne: ParseCookieValue i MakeCookieValue. MakeCookieValue zwróci Ci dokładną zawartość ciasteczka, jakie musisz zapisać.
AcceptLanguageHeaderRequestCultureProvider
zwraca kulturę zapisaną w przeglądarce (a właściwie wysłaną przez przeglądarkę w nagłówkach).
Kolejność tych providerów jest istotna. Jeśli pierwszy nie zwróci danych, drugi spróbuje. Jeśli w przeglądarce masz zapisaną kulturę pl-PL, ale w zapytaniu w adresie strony wpiszesz ?culture=en-US, zobaczysz stronę po angielsku, ponieważ pierwszy w kolejności jest QueryStringRequestCultureProvider.
Oczywiście manipulując tą listą możesz zmienić kolejność providerów, usuwać ich i dodawać nowych.
Pobieranie języka z adresu
Pewnie nie raz widziałeś (chociażby na stronach Microsoftu) taki sposób przekazywania kultury: https://example.com/en-US/Home/Index
gdzie informacje o niej są zawarte w adresie (w URL). Tutaj też tak można, a z pomocą przychodzi RouteDataRequestCultureProvider. Ten provider nie jest domyślnie tworzony, więc trzeba stworzyć obiekt tej klasy samemu i dodać go do RequestLocalizationOptionsna pierwszym miejscu:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
var requestProvider = new RouteDataRequestCultureProvider();
localizationOptions.RequestCultureProviders.Insert(0, requestProvider);
app.UseRequestLocalization(localizationOptions);
Żeby to zadziałało, trzeba jeszcze poinformować router, że w ścieżce są informacje o kulturze:
Tutaj analogicznie jak przy QueryStringRequestCultureProvider możesz zmienić wÅ‚aÅ›ciwoÅ›ciami klucze culture i uiculture. OczywiÅ›cie musisz pamiÄ™tać wtedy o zmianie template’a Å›cieżki.
TÄ… metodÄ™ wywoÅ‚aj w metodzie Configure, która jest odpowiedzialna za konfiguracjÄ™ zarejestrowanych serwisów – zrób to przed konfiguracjÄ… endpointów.
Pobieranie tłumaczenia na widoku
Teraz już możesz pobierać tłumaczenia. Wystarczy, że dodasz do usingów w widokach: Microsoft.AspNetCore.Mvc.Localization i wstrzykniesz interfejs IStringLocalizer:
Jak widzisz, możesz wstrzyknąć do jednego widoku kilka takich „lokalizerów”. W zmiennej generycznej okreÅ›lasz tylko klasÄ™ z Twoimi zasobami (czyli to, co robiliÅ›my w tym artykule). Ja tutaj mam dwa takie zasoby – jeden główny w jakimÅ› projekcie współdzielonym (LangRes) i drugi tylko w projekcie MVC (WebLangRes), w którym sÄ… teksty bardzo Å›ciÅ›le zwiÄ…zane z serwisem www.
Przy takim prostym wywołaniu jak wyżej (tekst w tagu HTML) nic więcej nie trzeba robić. Natomiast jeśli chcesz przekazać tłumaczenie do tag helpera, musisz dołożyć po prostu właściwość Value, np.:
Mamy do dyspozycji jeszcze coś takiego jak IHtmlLocalizer. Działa prawie tak samo jak IStringLocalizer, z tą różnicą, że możesz mu przekazać zasoby z tagami html, np: <b>Hello!</b>. Jednak nie używam go, bo trochę mi śmierdzi wpisywanie kodu html do zasobów.
To tyle. Jeśli czegoś nie zrozumiałeś lub znalazłeś w tekście błąd, daj znać w komentarzu.
Jeśli uważasz ten artykuł za przydatny, udostępnij go.
W tym artykule opiszÄ™ kilka bardziej zaawansowanych metod, które stosuje siÄ™ wÅ‚aÅ›ciwie na co dzieÅ„. Jednak nie bój siÄ™. SÅ‚owo „zaawansowane” w tym kontekÅ›cie nie oznacza niczego trudnego…
Kod testowalny vs nietestowalny
Każdy system można napisać w taki sposób, że nie da się do niego zrobić testów lub zrobienie ich będzie zupełnie nieopłacalne. Taki projekt nazywamy nietestowalnym. Można system projektować też tak, żeby testy były całkowicie normalnym zjawiskiem. I do tego dążymy.
Jak zwykle kod powie wiÄ™cej niż 1000 słów…
class UserData
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
class UserDataProvider
{
public UserData ReadData(int userId)
{
string fileName = $@"C:\dane\{userId}.txt";
if (!File.Exists(fileName))
return null;
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
}
Metoda ReadData sprawdza, czy plik o konkretnej nazwie istnieje (1), jeśli tak odczytuje go z dysku (2) i tworzy obiekt klasy UserData (3; metoda ma aż 3 odpowiedzialności)
Jak teraz przetestujesz jednostkowo metodÄ™ ReadData? Nie da siÄ™, bo jest silnie zwiÄ…zana z klasÄ… File, a problem klasy File polega na tym, że odnosi siÄ™ do konkretnych zasobów, których po prostu podczas jednostkowego testowania nie bÄ™dzie. Co wiÄ™cej, jeÅ›li chciaÅ‚byÅ› zapisać dane użytkownika, klasa File zapisze plik na dysku – to jest tzw. „efekt uboczny”. Testy jednostkowe nie mogÄ… mieć żadnych efektów ubocznych. Jest to bardzo niepożądane.
Dlatego też, żeby uczynić klasÄ™ UserDataProvider testowalnÄ…, musimy zaprojektować jakÄ…Å› abstrakcjÄ™ – zastosować DependencyInjection. JeÅ›li nie wiesz co to, przeczytaj artykuÅ‚, w który opisujÄ™ ten mechanizm.
Stosuj abstrakcje
Zamiast posługiwać się bezpośrednio klasą File, utworzymy interfejs, który zostanie wstrzyknięty do UserDataProvider. Jeśli nie rozumiesz pojęcia wstrzyknięcie, koniecznie przeczytaj ten artykuł.
class UserDataProvider
{
IDataRepository repo;
public UserDataProvider(IDataRepository repo)
{
this.repo = repo;
}
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
}
Zobacz, co się przy okazji stało. Metoda ReadData robi już tylko jedną rzecz, a nie kilka jak to było na początku.
Ale jak teraz testować tÄ™ klasÄ™? Musimy stworzyć JAKIÅš obiekt implementujÄ…cy interfejs IDataRepository…
Co to jest Fake Object?
Fake Object to nic innego jak obiekt oszukany. Ma się zachować dokładnie tak, jak tego chcemy w danej sytuacji. Napiszmy więc sobie taki FakeObject, który implementuje IDataRepository:
class FakeRepository : IDataRepository
{
public string[] DataToReturn { get; set; } = null;
public string[] GetData(int id)
{
return DataToReturn;
}
}
Po prostu metoda GetData zwróci takie dane, jakie przekażemy wczeÅ›niej do wÅ‚aÅ›ciwoÅ›ci DataToReturn. Teraz przyszedÅ‚ czas na napisanie pierwszego testu z Fake’iem. Przygotuj zatem nowy projekt testowy (jeÅ›li nie wiesz jak, to przeczytaj artykuÅ‚ o podstawach testów jednostkowych).
Testy z użyciem Fake
Ja w swoim przykładzie będę stosował bibliotekę nUnit.
Tak jak mówiłem, testujemy klasę UserDataProvider i metodę ReadData. Przypomnę kod:
class UserDataProvider
{
IDataRepository repo;
public UserDataProvider(IDataRepository repo)
{
this.repo = repo;
}
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
}
Jakie chcemy przetestować przypadki?
nie ma użytkownika o takim id
odczytane dane sÄ… niepoprawne
odczytane dane są prawidłowe
Test – brak użytkownika
Napiszmy więc pierwszy test:
[Test]
public void ReadData_NoSuchUser_ReturnsNull()
{
FakeRepository repo = new FakeRepository();
repo.DataToReturn = null;
UserDataProvider udp = new UserDataProvider(repo);
UserData result = null;
Assert.DoesNotThrow(() => result = udp.ReadData(0));
Assert.IsNull(result);
}
Najpierw zostaÅ‚ utworzony obiekt fake’owy. Chcemy, żeby zwracaÅ‚ null – zakÅ‚adamy, że tak bÄ™dzie, gdy użytkownika nie bÄ™dzie w systemie.
NastÄ™pnie utworzyliÅ›my prawdziwy obiekt – UserDataProvider, korzystajÄ…cy z oszukanego FakeRepository.
I sprawdzamy, czy metoda się nie wywala (nie chcemy tego) i czy nie zwróciła żadnego użytkownika.
Po uruchomieniu testu okazuje siÄ™, że aplikacja siÄ™ wykrzacza – jest rzucony wyjÄ…tek NullReferenceException. No oczywiÅ›cie, że tak bo okazuje siÄ™, że w metodzie ReadData nigdzie nie sprawdzamy, co zostaÅ‚o zwrócone z repozytorium. Poprawmy to:
public class UserDataProvider
{
IDataRepository repo;
public UserDataProvider(IDataRepository repo)
{
this.repo = repo;
}
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
if(data == null)
return null;
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
}
Super, teraz działa. Sprawdźmy zatem drugi przypadek.
Test – poprawne dane
[Test]
public void ReadData_UserExists_ReturnsUser()
{
FakeRepository repo = new FakeRepository();
repo.DataToReturn = new string[]
{
"Adam",
"Jachocki"
};
UserDataProvider udp = new UserDataProvider(repo);
UserData user = null;
Assert.DoesNotThrow(() => user = udp.ReadData(0));
Assert.IsNotNull(user);
Assert.AreEqual("Adam", user.FirstName);
Assert.AreEqual("Jachocki", user.LastName);
}
Najpierw skonfigurowaliÅ›my obiekt fake’owy tak, żeby zwróciÅ‚ tablicÄ™ z dwoma elementami – dokÅ‚adnie w takiej formie dostaniemy dane z pliku tekstowego.
Na koniec sprawdziliśmy kilka rzeczy:
czy program się nie wysypał
czy user jest prawidłowym obiektem
czy user posiada odpowiednie wartości
Tym razem test zadziaÅ‚aÅ‚. No to zostaÅ‚ ostatni przypadek…
Test – nieprawidÅ‚owe dane
[Test]
public void ReadData_InvalidData_ThrowsException()
{
FakeRepository repo = new FakeRepository();
repo.DataToReturn = new string[]
{
"Adam",
};
UserDataProvider udp = new UserDataProvider(repo);
UserData user = null;
Assert.Throws<InvalidDataException>(() => user = udp.ReadData(0));
}
Przede wszystkim chcemy, żeby program się wygrzmocił, jeśli dane będą w niepoprawnym formacie (np. repo zwróci tablicę jednoelementową zamiast dwuelementową). To zdecydowanie jest sytuacja wyjątkowa, w której zastosowanie wyjątków ma jak najbardziej sens. Program ma się wywalić, więc nie stosujemy już innych sprawdzeń.
Po uruchomieniu tego testu dostajemy brzydki błąd na twarz z komunikatem:
Expected: <System.IO.InvalidDataException>
But was: <System.IndexOutOfRangeException
Oznacza to, że owszem zostaÅ‚ rzucony wyjÄ…tek, ale IndexOutOfRangeException zamiast tego, który chcemy – InvalidDataException. No racja. JeÅ›li spojrzysz na klasÄ™ UserDataProvider, zobaczysz że nigdzie nie rzucamy takiego wyjÄ…tku. Natomiast IndexOutOfRange jest rzucany przez system, ponieważ odwoÅ‚ujemy siÄ™ do nieistniejÄ…cego elementu w tablicy. Naprawmy to:
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
if (data == null)
return null;
if (data.Length < 2)
throw new InvalidDataException("Dane w niepoprawnym formacie!");
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
Testy poszły, ale ja teraz mam duże zastrzeżenia do tego kodu. Metoda ReadData nie dość, że tworzy użytkownika, to jeszcze sprawdza poprawność danych. Czyli znów ma dwie odpowiedzialności. Powinniśmy teraz trochę ten kod wyczyścić i walidację danych zrobić w osobnej metodzie:
TrochÄ™ czyszczenia
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
if (!ValidateData(data))
return null;
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
bool ValidateData(string[] data)
{
if (data == null)
return false;
if(data.Length < 2)
throw new InvalidDataException("Dane w niepoprawnym formacie!");
return true;
}
Kod stał się bardziej czytelny i nadal działa. SUPER! Zwróć uwagę na dwie rzeczy:
to co wÅ‚aÅ›nie zrobiliÅ›my (czyszczenie kodu, rozdzielanie go) nazywa siÄ™ refactoring. Podczas refactoringu czasami dochodzi do błędów. Gdyby nie testy jednostkowe, moglibyÅ›my ich nie wychwycić, a przynajmniej nie tak szybko. Jest taka zasada, która mówi – nie refaktoruj kodu, do którego nie masz testów.
podczas poprawiania kodu może okazać się, że musisz pewne rzeczy przemyśleć lub przeprojektować
Wiesz już czym jest Fake Object i jak go używać w testach. Ale jest jeszcze jedno… Fajne…
Czym jest Mock?
Mock to imitacja (dosłowne tłumaczenie) jakiegoś obiektu. To jest alternatywa dla FakeObject. W niektórych językach programowania może być trudne lub niemożliwe stworzenie mocka. Na szczęście my jesteśmy w świecie .NET, gdzie z odpowiednią biblioteką jest to oczywiste i proste jak beknięcie po piwie.
Różnica między Mock a Fake
GłównÄ… różnicÄ… jest to, że jeÅ›li tworzysz FakeObject, musisz zaimplementować wszystkie metody z interfejsu. Gdy tworzysz Mock – implementujesz tylko to co chcesz i tak jak chcesz. I to ad hoc!
Jednak Mock nie jest złotym środkiem. Czasami lepiej się sprawdzi Mock, a w niektórych przypadkach lepiej będzie napisać FakeObject.
Teraz siÄ™ pobawimy. ZmieÅ„my testy w taki sposób, żeby nie używać Fake, tylko Mock (bÄ™dziemy „mokować”). Najpierw pierwszy przypadek:
[Test]
public void ReadData_NoSuchUser_ReturnsNull()
{
var mockRepository = new Mock<IDataRepository>();
mockRepository.Setup(m => m.GetData(It.IsAny<int>())).Returns<string[]>(null);
UserDataProvider udp = new UserDataProvider(mockRepository.Object);
UserData result = null;
Assert.DoesNotThrow(() => result = udp.ReadData(0));
Assert.IsNull(result);
}
Co tu się stało?
Utworzyliśmy obiekt Mock, mówiąc mu jaki interfejs ma imitować
Za pomocą metody Setup możemy skonfigurować Mocka w taki sposób, żeby powiedzieć mu:
jakie argumenty przyjmuje metoda (może to być konkretny argument albo tak jak tutaj – jakikolwiek int: It.IsAny<int>()
jakÄ… wartość ma zwracać metoda – w zwiÄ…zku z tym, że zwracamy null, musimy podać typ zwracanej wartoÅ›ci
W jednym Setupie konfigurujemy jedną metodę. Nic nie stoi na przeszkodzie, żeby skonfigurować ich więcej.
Nie pisząc żadnej nowej klasy otrzymaliśmy coś, co potrafi imitować działanie obiektu.
Klasa Mock ma właściwość Object, która jest żądanego typu (w naszym przypadku IDataRepository), dlatego też to tę właściwość wstrzykujemy do konstruktora.
A jaki jest kod? Nie ma to znaczenia. To jest zwykÅ‚a imitacja – najbardziej CiÄ™ interesuje, co metoda zwraca (czasami, jaki argument przyjmuje). Co wiÄ™cej, możesz skonfigurować tak, żeby mock zwracaÅ‚ różne wartoÅ›ci dla różnych parametrów, np:
W ramach ćwiczeÅ„ zachÄ™cam CiÄ™ do przerobienia pozostaÅ‚ych testów z Fake’ów na Mocki.
Dokumentacja
Trochę mnie korci, żeby napisać coś więcej o bibliotece Moq, ale to nie jest o tym artykuł. Jeśli będzie jakaś prośba, na pewno to zrobię. Póki co odsyłam do:
Biblioteka Moq potrafi zrobić właściwie chyba wszystko, co sobie wymyślisz. Dlatego polecam poczytać o niej i potestować.
To właściwie wszystko jeśli chodzi o testy jednostkowe. Jeśli czegoś nie rozumiesz, coś pominąłem lub znalazłeś błąd, podziel się w komentarzu. Jeśli uważasz artykuł za przydatny, podziel się nim z innymi 🙂
Ten artykuł opisuje czym jest dependency injection. A także jak z tego korzystać w .NET i po co. Jeśli wiesz, znasz, stosujesz, to raczej niczego nowego się tutaj nie dowiesz 🙂 On jest kierowany głównie do młodych programistów lub programistów nie znających tych mechanizmów.
O co chodzi we wstrzykiwaniu zależności?
Przede wszystkim musimy zdefiniować sobie zależność. O zależności mówimy wtedy, kiedy jedna klasa zależy od drugiej. Weźmy sobie przykładową klasę Writer, która umie wypisywać komunikaty i klasę Worker, która wykonuje jakąś operację i posługuje się klasą Writer.
class Writer
{
public void Write(string message)
{
Console.WriteLine(message);
}
}
class Worker
{
Writer writer = new Writer();
public void Foo()
{
writer.Write("Rozpoczynam pracÄ™...");
}
}
Jak widzisz, klasa Worker zależy od klasy Writer.
Co więcej, klasa Worker samodzielnie tworzy i używa obiekt klasy Writer. Takie coś nazywamy silnym związaniem (tight coupling). I chociaż tight coupling to pojęcie szersze, to jednak dobrze jest prezentowany przez ten przykład. Dwie klasy są mocno ze sobą związane.
Takie zakodowane na sztywno zależności (silne związania) są złe dla aplikacji i powinieneś ich unikać. Dlaczego?
jeśli chciałbyś aby komunikaty były wpisywane do pliku, a nie na konsolę, musiałbyś zmienić klasę Writer lub utworzyć nową i zmienić klasę Worker (sprzeczność z zasadą OpenClose).
jeśli klasa Writer miałaby inne zależności, te zależności musiałby także zostać utworzone (lub w jakiś sposób przekazane) przez klasę Worker. Lub utworzone w klasie Writer, co jeszcze bardziej zacieśnia kod. Ponadto daje nam już zbyt dużo odpowiedzialności (możliwa sprzeczność z zasadą Single Responsibility) i zdecydowanie zaciemnia obraz.
Te wszystkie problemy można rozwiÄ…zać stosujÄ…c wstrzykiwanie zależnoÅ›ci…
Siostro! Zastrzyk!
OK, teraz wyobraźmy sobie jak lepiej mogłaby wyglądać klasa Worker:
class Worker
{
Writer writer;
public Worker(Writer writer)
{
this.writer = writer;
}
public void Foo()
{
writer.Write("Rozpoczynam pracÄ™...");
}
}
Spójrz, co się stało. Wstrzyknęliśmy obiekt klasy Writer do Worker za pomocą konstruktora. Obiekt Writer w tym momencie jest już poprawnie stworzony (ma utworzone swoje wszystkie zależności) i można go używać. Klasa Worker nie musi tworzyć już tego obiektu i nie daj Boże innych jego zależności.
Po prostu Worker używa Writer. A skąd go ma? To w zasadzie nie jest istotne. Czy Ciebie interesuje kto zrobił Ci przelew na konto? Czy ważne, że masz te pieniądze? 😉
Jednak cały czas nie rozwiązaliśmy jednego problemu. Silnego związania. Klasa Worker cały czas jest silnie związana z Writer. A gdyby tak posłużyć się interfejsem?
interface IWriter
{
void Write(string message);
}
class Writer: IWriter
{
public void Write(string message)
{
Console.WriteLine(message);
}
}
class Worker
{
IWriter writer;
public Worker(IWriter writer)
{
this.writer = writer;
}
public void Foo()
{
writer.Write("Rozpoczynam pracÄ™...");
}
}
Na poczÄ…tku zdefiniowaliÅ›my sobie interfejs IWriter – z jednÄ… metodÄ…. Potem utworzyliÅ›my klasÄ™ Writer implementujÄ…cÄ… ten interfejs i na koniec do klasy Worker wstrzyknÄ™liÅ›my interfejs.
W .NET można wstrzykiwać zależności przez konstruktor (najczęściej używane), właściwość (częściej używane w Blazor, gdzie to jest jedyna możliwość w komponencie będącym widokiem), a nawet przez parametr (stosowane raczej w kontrolerze webowej aplikacji)
To nam rozwiązuje ostatni problem. Dlaczego? Bo możemy sobie teraz rozszerzyć naszą aplikację, pisząc nieco inną implementację klasy Writer:
class FileWriter : IWriter
{
public void Write(string message)
{
File.AppendText(message);
}
}
Teraz klasa Worker może dostać obiekt Writer lub FileWriter. Nie ma już silnego związania z klasą Writer. Otrzymaliśmy luźne powiązanie (loose coupling). Daje to też możliwość napisania oszukanej klasy (Fake), którą można wykorzystać później w testach automatycznych:
class FakeWriter : IWriter
{
public void Write(string message)
{
//żadnego ciała albo Debug.WriteLine
}
}
Powyższy przykÅ‚ad pokazuje również wzorzec projektowy „Strategia”. Wzorzec ten jest poniekÄ…d jednym z przykÅ‚adów wstrzykiwania zależnoÅ›ci.
Kontenery IoC
Zostaje jeszcze pytanie, jak tworzyć obiekty jak np. Writer? NAJPROSTSZYM przykładem Dependency Injection jest po prostu:
Worker worker = new Worker(new Writer());
To jest NAJPROSTSZY przykÅ‚ad, najbardziej banalny i caÅ‚kowicie bezużyteczny w prawdziwym Å›wiecie (chociaż czasem nie da siÄ™ inaczej). Co wiÄ™cej, powoduje dużo problemów. Załóżmy, że masz taki kod rozsiany po caÅ‚ej aplikacji i nagle konstruktor klasy Writer potrzebuje jeszcze jednego obiektu… Musisz to zmieniać w wielu miejscach. ZupeÅ‚na strata czasu.
Na szczęście powstało coś takiego jak kontenery DI.
Czym jest kontener DI
To specjalny kontener (pomyÅ›l o tym jak o klasie Dictionary<Type, object> na mocnych sterydach), który konfigurujesz podczas inicjalizowania aplikacji. Np. w metodzie Main. Możesz spotkać siÄ™ też z okreÅ›leniem „kontener IoC” – IoC to „Inversion of Control” – wzorzec projektowy, którego jednÄ… z implementacji jest wstrzykiwanie zależnoÅ›ci.
Konfigurując taki kontener, rejestrujesz w nim klasy, interfejsy, długości życia, a także sposoby w jakie konkretne obiekty mają zostać tworzone. Kontenery dają też możliwość rejestrowania własnych metod do tworzenia obiektów. Na końcu to właśnie kontener tworzy dla Ciebie w pełni działający obiekt.
W C# mamy do dyspozycji różne kontenery IoC. Najbardziej znane to chyba Autofac i Microsoft.Extensions.DependencyInjection. Autofac był wcześniej, natomiast w .NetCore przyszły mechanizmy z Microsoftu. Jako, że Autofac jest dużo starszy, PRAWDOPODOBNIE ma więcej możliwości, ale Microsoftowy odpowiednik jest wystarczający. Moim zdaniem jest też prostszy w użyciu i dlatego to nim się zajmiemy.
Długość życia serwisu
W związku z tym, że mechanizm DI musi widzieć kiedy tworzyć i zwalniać obiekty, podczas konfiguracji podajemy długość życia. Czyli mówimy kontenerowi jak długo obiekt powinien żyć, czy też kiedy go tworzyć. To może wyglądać strasznie, ale w rzeczywistości jest bardzo proste.
Niezależnie od tego, czy używasz Autofaca, Microsoft Dependency Injection, czy jeszcze innego mechanizmu, długości życia będą analogiczne:
Transient
Obiekt zarejestrowany jako transient będzie tworzony za każdym razem, gdy będzie potrzebny. To znaczy, że każda klasa, do której wstrzykujesz obiekt transient, będzie miała własną niepowtarzalną instancję, np:
class Worker
{
IWriter writer;
public Worker(IWriter writer)
{
this.writer = writer;
}
}
class Manager
{
IWriter writer;
public Manager(IWriter writer)
{
this.writer = writer;
}
}
Jeśli klasa Writer zostanie zarejestrowana jako transient, to instancje Writera w Worker i Manager zawsze będą różne. Po prostu klasa Writer zostanie utworzona na nowo przy każdym takim wstrzyknięciu.
Można by to przyrównać do tego kodu:
Writer w1 = new Writer();
Worker worker = new Worker(w1);
Writer w2 = new Writer();
Manager manager = new Manager(w2);
Scoped
W przypadku aplikacji desktopowych i mobilnych nie różni się to od singleton niczym (chyba że sam tworzysz scope). W przypadku aplikacji webowych, powstanie tylko jedna instancja takiego obiektu na żądanie http. Tzn.:
class Worker
{
IWriter writer;
public Worker(IWriter writer)
{
this.writer = writer;
}
}
class Manager
{
IWriter writer;
public Manager(IWriter writer)
{
this.writer = writer;
}
}
Jeśli teraz Worker zostanie zarejestrowany jako scoped i jesteśmy w obrębie jednego żądania HTTP, wtedy w Worker i Manager będziemy mieli tę samą instancję klasy Writer. Po prostu obiekt Writer zostanie utworzony raz i wstrzyknięty do wszystkich innych obiektów w ramach jednego żądania. Obiekt umiera, gdy żądanie się kończy i nie jest już dłużej używany.
Można by to zademonstrować takim kodem:
Response ManageRequest()
{
Writer scopedWriter = new Writer();
Worker worker = new Worker(scopedWriter);
Manager manager = new Manager(scopedWriter);
//tutaj praca na żądaniu..., a na koniec
worker = null;
manager = null;
scopedWriter = null;
}
Singleton
Zostanie utworzona TYLKO JEDNA instancja takiej klasy w całej aplikacji. I ta jedna instancja będzie przekazywana innym obiektom przez cały okres działania aplikacji. Obiekt umiera wraz z aplikacją.
Można to porównać do takiego kodu:
if (mainWriter == null)
mainWriter = new Writer();
Worker worker = new Worker(mainWriter);
Manager manager = new Manager(mainWriter);
DI od Microsoftu
Jeśli tworzysz aplikację webową, to masz już to w standardzie. Natomiast aplikacja konsolowa, WPF, czy WinForms wymaga, żebyś zainstalował paczkę NuGet: Microsoft.Extensions.DependencyInjection
Następnie musisz dodać do usings:
using Microsoft.Extensions.DependencyInjection;
Kontenery
Zwróć teraz uwagÄ™ na dwie klasy: ServiceCollection i ServiceProvider. W klasie ServiceCollection dodajemy wszystkie nasze serwisy – konfigurujemy kontener DI. Klasa ServiceProvider tworzy i zwraca nam konkretny obiekt, który potrzebujemy w danej chwili.
Weźmy teraz prostą aplikację konsolową:
class MainClass
{
static void Main()
{
Console.Write("Podaj imiÄ™: ");
string name = Console.ReadLine();
Console.WriteLine($"Cześć {name}!");
Console.ReadKey();
}
}
Spróbujmy ją przerobić tak, żeby używała Dependency Injection. Standardowe podejście jest takie:
w metodzie main konfigurujemy dependency injection
pobieramy obiekt jakiejś głównej klasy (np. w WPF/WinForms byłoby to najpewniej MainForm, czy też MainWindow)
wywołujemy metodę z tej klasy
Ponieważ pracujemy na konsoli, musimy stworzyć główną klasę aplikacji. Klasa Main w tym wypadku służy tylko do konfiguracji:
class App
{
public void Run()
{
}
}
W metodzie Main utworzymy instancję klasy App, następnie wywołamy metodę Run, która zrobi dokładnie to samo, co Main po staremu. Moglibyśmy tutaj użyć klasy Console, ale żeby pobawić się dependency injection, zrobimy to inaczej.
Używaj abstrakcji
Zamiast posługiwać się bezpośrednio klasą Console, utworzymy interfejs i zaimplementujemy go:
interface IUserInteraction
{
void Print(string message);
string Read();
}
class ConsoleInteraction : IUserInteraction
{
public void Print(string message)
{
Console.Write(message);
}
public string Read()
{
return Console.ReadLine();
}
}
Konfiguracja
Teraz w metodzie Main skonfigurujemy nasze DI. Aby to zrobić, najpierw trzeba utworzyć obiekt klasy ServiceCollection (pamiętaj, że w aplikacjach webowych masz już to dostępne):
class MainClass
{
static void Main()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddTransient<IUserInteraction, ConsoleInteraction>();
serviceCollection.AddSingleton<App>();
using(var serviceProvider = serviceCollection.BuildServiceProvider())
{
var app = serviceProvider.GetService<App>();
app.Run();
}
}
}
W linijce 5 tworzymy ServiceCollection, a następnie rejestrujemy dwa serwisy:
ConsoleInteraction jako interfejs IUserInteraction – pamiÄ™taj, że w Microsoft DI najpierw podajesz interfejs, a później klasÄ™, która ten interfejs implementuje (jeÅ›li zrobisz na odwrót, aplikacja siÄ™ nie skompiluje). Teraz mechanizm DI wszÄ™dzie tam, gdzie zobaczy interfejs IUserInteraction, utworzy obiekt klasy ConsoleInteraction.
KlasÄ™ App jako singleton. Jak widzisz – nie jest wymagany interfejs żeby zarejestrować klasÄ™. W przypadku klasy App interfejs nie ma wiÄ™kszego sensu, bo to główna klasa aplikacji. Ale czÄ™sto interfejs ma sens (głównie tam, gdzie jest zależnoÅ›ciÄ… dla innej klasy), wiÄ™c pamiÄ™taj o tym.
JeÅ›li nie wiesz czym jest singleton to po prostu obiekt który jest utworzony raz w caÅ‚ej aplikacji i żyje przez caÅ‚y cykl jej życia. Możesz siÄ™ też spotkać z okreÅ›leniem, że singleton jest antywzorcem, ale tu chodzi o innÄ… sytuacjÄ™ – rÄ™czne klasyczne tworzenie singletona. W naszym przypadku rejestrujemy klasÄ™ jako singleton w kontenerze DI i wszystko jest w porzÄ…dku.
Teraz tak. Dlaczego klasę ConsoleInteraction zarejestrowaliśmy jako transient? W przypadku tej aplikacji nie ma to żadnego znaczenia, bo i tak użyjemy jej tylko w jednym miejscu. Chciałem po prostu pokazać, że tak to się robi. Zazwyczaj klasy logujące będziesz rejestrował jako singletony.
A dlaczego klasa App jest jako singleton? No przypomnij sobie czym jest singleton – jedna instancja klasy, która żyje przez caÅ‚y czas życia aplikacji. Czyli idealny zakres dla klasy, która reprezentuje całą aplikacjÄ™.
Pobieranie serwisów
W linijce 9 tworzymy ServiceProvider ze skonfigurowanego ServiceCollection. Od tego momentu ServiceProvider będzie dostarczał nam obiekty, których potrzebujemy. Nie można już niczego zmienić w service collection (oczywiście nikt Ci nie broni, żeby mieć kilka ServiceCollection i Providerów, ale jakoś nie widzę w tym sensu).
Na koÅ„cu używamy ServiceProvider, żeby otrzymać obiekt klasy App. DosÅ‚ownie –
– Ej Ty! ServiceProvider, dej mnie no w peÅ‚ni dziaÅ‚ajÄ…cy obiekt klasy App!
To może nie jest niczym wyglądającym super. Po prostu nie musiałeś tworzyć obiektu przez new, tylko za pomocą ServiceProvidera. Ale pamiętasz IUserInteraction? Teraz dodajmy go do klasy App:
class App
{
readonly IUserInteraction ui;
public App(IUserInteraction ui)
{
this.ui = ui;
}
public void Run()
{
ui.Print("Podaj imiÄ™: ");
string name = ui.Read();
ui.Print($"Cześć {name}!");
Console.ReadKey();
}
}
W metodzie Main – już nic wiÄ™cej nie musisz robić. Otrzymasz znowu w peÅ‚ni dziaÅ‚ajÄ…cy obiekt klasy App! Klasa implementujÄ…ca IUserInteraction zostanie automagicznie utworzona i wstrzykniÄ™ta do App.
ServiceProvider i zwalnianie zasobów
Pewnie chodzi Ci po gÅ‚owie pytanie – co z klasami IDisposable? I czy sÄ… one zwalniane?
Zasadniczo tak. Spójrz, jak został utworzony ServiceProvider:
using (var serviceProvider = serviceCollection.BuildServiceProvider())
{
var app = serviceProvider.GetRequiredService<App>();
app.Run();
}
Teraz wszystkie obiekty zostaną zwolnione po zwolnieniu ServiceProvidera. Natomiast ServiceProvider może też utworzyć tzw. zakres:
using (var serviceProvider = serviceCollection.BuildServiceProvider())
{
using (var scope = serviceProvider.CreateScope())
{
var app = scope.ServiceProvider.GetRequiredService<App>();
app.Run();
}
}
W aplikacji możemy mieć wiele takich zakresów. Np. w aplikacji webowej taki zakres jest tworzony na całe żądanie HTTP. Obiekty zarejestrowanie jako scope i transient zostaną usunięcie po wyjściu z takiego zakresu, chyba że są zależnościami dla innych obiektów. Jeśli implementują interfejs IDisposable lub IAsyncDisposable, odpowiednie metody Dispose też zostaną automatycznie wywołane. Singletony zostaną zwolnione po zakończeniu życia ServiceProvidera.
Ale uwaga! Jeśli zarejestrowałeś singleton, który jest zależny od innych klas i np. taką zależność zarejestrowałeś jako transient (lub scoped), to zwróć uwagę na to, że te zależności zostaną zwolnione dopiero po śmierci singletona. Przecież singleton na nich polega, dlatego też musi mieć dostęp do nich przez cały czas. W niektórych bibliotekach (np. boost::di dla C++) musisz rejestrować zależności dla singletona jako singleton. W .NET tak nie jest (przynajmniej nie w chwili pisania artykułu), ale miej świadomość długości życia.
Więcej na ten temat w kolejnym artykule, opisujących typowo Microsoft Dependency Injection
SkÄ…d ten ServiceProvider jest taki mÄ…dry?
W baaaardzo dużym skrócie można by opisać jego działanie tak:
Jaki chcesz typ? App? OK, sprawdźmy go
Typ App ma konstruktor z parametrami. Jaki jest pierwszy parametr? IUserInteraction
OK, muszę stworzyć teraz obiekt IUserInteraction. Co go implementuje? Z konfiguracji wynika, że ConsoleInteraction.
Jaki konstruktor ma ConsoleInteraction? Domyślny. No to tworzymy ConsoleInteraction
Skoro mam utworzone ConsoleInteraction, mogę teraz utworzyć App
ServiceProvider przeleci przez wszystkie zależności (i zależności tych zależności) i utworzy je (jeśli musi) w odpowiedniej kolejności. Na koniec zwróci Ci w pełni działający obiekt, o który prosiłeś.
Możesz teraz uruchomić aplikację i zobaczyć jak działa. Przedebuguj ją sobie linijka po linijce i sprawdź kiedy wywołują się poszczególne konstruktory.
Wydajność aplikacji
Oczywiście taki mechanizm musi wpływać na wydajność aplikacji. Jednak w dzisiejszych czasach nie ma się tym co przejmować. Raczej nie powinno być to zauważalne. Używaj tego i będzie git 🙂 Pamiętaj, żeby nie optymalizować programu jeśli faktycznie nie musisz. Jeśli jest to problem, pomyśl czy klas, które są tworzone najdłużej nie zarejestrować jako singletony.
To na tyle, jeÅ›li chodzi o wstrzykiwanie zależnoÅ›ci. W innym artykule opiszÄ™ niedÅ‚ugo co jeszcze bardziej zaawansowanego można osiÄ…gnąć tym mechanizmem. WiÄ™c koniecznie zapisz siÄ