Jeśli szukasz szybkiego rozwiązania, kliknij tu. Jeśli chcesz się nieco więcej dowiedzieć, przeczytaj cały post.
Wstęp
Gdy tworzymy nową aplikację w VisualStudio z bazą danych, domyślny kreator tworzy jeden projekt, do którego pcha wszystkie klasy. Do malutkich rzeczy, czy nauki to w zupełności wystarczy. Jednak w świecie rzeczywistym chcielibyśmy mieć osobny projekt do modeli i osobny projekt dla warstwy danych (Data Access Layer).
Niby nie jest to trudne, wystarczy przenieść nasz DbContext do innego projektu i już. A co z migracjami? Migracje nadal będą się tworzyć w projekcie głównym. Nie o to chodzi. Chcemy migracje też w projekcie z danymi.
Dlaczego to nie jest oczywiste?
Musisz zdać sobie sprawę z tego, jak działają migracje w Entity Framework (czy też EfCore), a także jak działa aktualizacja bazy danych.
Gdy uruchamiasz polecenie Add-Migration lub dotnet ef migrations add, narzędzie uruchamia Twoją główną aplikację. Uruchomienie aplikacji następuje w sposób normalny. Czyli przy aplikacji konsolowej, odpalona zostanie metoda Main. Przy aplikacji webowej, pójdzie cała konfiguracja.
Jednym z kroków jest inicjalizacja Entity Framework, np:
W tym momencie tworzymy połączenie z bazą danych i migracje mogą zostać utworzone. Pamiętaj, że do utworzenia migracji konieczne jest połączenie z bazą danych. Narzędzie musi sprawdzić, jak wygląda baza i jak wygląda model – musi mieć możliwość porównania tego.
Teraz jeśli uruchomisz migrację z parametrem -p, wskazując na konkretny projekt, np:
Add-Migration InitialDbCreate -p DataAccessLayer
Entity Framework będzie próbowało uruchomić projekt DataAccessLayer. Jeśli jest to zwykła biblioteka klas (class library), no to co się uruchomi? Nic. Dlatego też migracja nie będzie mogła się odbyć.
Ale można to nieco obejść.
Mamy na to dwa sposoby. Którego użyć? To zależy od aplikacji, jaką tworzysz i od jej wymagań.
Metoda 1 – własna fabryka kontekstu bazy danych
Narzędzie podczas migracji poszuka klasy, która implementuje interfejs IDesignTimeDbContextFactory. Jeśli znajdzie taką, utworzy jej obiekt i za jej pomocą skonfiguruje połączenie z bazą danych.
W swoim projekcie z danymi (tam, gdzie masz DbContext i chcesz mieć migracje) musisz utworzyć klasę implementującą interfejs IDesignTimeDbContextFactory. Ef właśnie tego poszuka (jeśli używasz Sql Servera, dodaj pakiet nuget: Microsoft.EntityFrameworkCore.SqlServer):
public class DbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
public XMoneyDbContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder<ApplicationDbContext> optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
optionsBuilder.UseSqlServer("tutaj Twój connection string")
return new ApplicationDbContext(optionsBuilder.Options);
}
}
Przeanalizujmy go:
deklarujesz fabrykę kontekstu bazy danych (Ef poszuka właśnie klasy implementującej ten interfejs), parametrem generycznym jest oczywiście Twój kontekst bazy danych.
najpierw tworzysz buildera do opcji kontekstu
ustawiasz opcje (np. UseSqlServer) i connection string
tworzysz swój kontekst i zwracasz go
I to właściwie tyle. Możesz już teraz uruchomić migrację z przełącznikiem -p:
dotnet ef migrations add NazwaMigracji -p NazwaTwojegoProjektu
Metoda 2 – parametry w komendzie CLI
Znacznie prostszą metodą jest dodanie odpowiednich parametrów do komend migracyjnych. Mamy tutaj dwa fajne parametry:
-p – projekt, w którym chcemy mieć migracje
-s – główny projekt aplikacji
W tej sytuacji nie potrzebujemy już implementować IDesignTimeDbContextFactory. Po prostu możemy użyć komendy CLI:
dotnet ef migrations add -p 'projekt-z-migracjami.csproj' -s 'main-project.csproj'
Które rozwiązanie wybrać?
Powiem szczerze, że po kilku różnych projektach, zdecydowanie zawsze wybieram drugą metodę. Nie spotkałem się od kilku lat z sytuacją, gdzie pierwsze rozwiązanie faktycznie niosłoby ze sobą jakąś korzyść.
Niemniej jednak, niezależnie od tego, którą metodę wybierzesz, musisz pamiętać o jednym. Uruchamiając migrację, mechanizm CLI NIE WIE na jakim środowisku pracujesz, o ile nie masz ustawionej zmiennej środowiskowej.
Dlaczego to jest problem? Ponieważ jeśli przechowujesz connection stringi w plikach appsettings, zawsze będzie brany pod uwagę plik główny – „produkcyjny” – appsettings.json. Nigdy appsetting.Development.json ani żaden inny.
Żeby móc używać ustawień zależnych od środowiska, na swojej maszynie musisz mieć ustawioną zmienną środowiskową ASPNETCORE_ENVIRONMENT, która mówi na jakim środowisku pracujemy.
I teraz w zależności od potrzeb, możesz to ustawić na sztywno na poziomie maszyny (ja tego jednak nie zalecam), ale możesz też ustawić po prostu w sesji terminala – czyli przed uruchomieniem polecenia dotnet .
Wskazówka na koniec
Ponieważ jest dla mnie bardzo karkołomne pisać całe polecenia obsługujące migracje wraz ze ścieżkami do konkretnych projektów, w swoich rozwiązaniach stosuję bardzo prostą zasadę.
Mam katalog scripts na tym samym poziomie co katalog z kodami źródłowymi. W katalogu scripts trzymam proste skrypty do zarządzania migracjami. Np. create-migration.ps1, który wygląda dokładnie tak:
param ([Parameter(Mandatory)]$name)
Invoke-Expression "dotnet ef migrations add -p '..\src\<ścieżka do projektu DAL>' -s '..\src\<ścieżka do głównego projektu>' $name"
Do tego mam też skrypty usuwające migracje i aktualizujące bazę danych. Jest to bardzo wygodne rozwiązanie, którego się trzymam już jakiś czas i świetnie mi się sprawdza.
Aplikacje wielojęzykowe są mimo wszystko dość łatwe w pisaniu, jednak trzeba pamiętać o kilku rzeczach, które koniecznie musimy stosować w tego typu programach. Inaczej bardzo łatwo o bum. Dlatego też uważam, że są łatwe, jednak upierdliwe 🙂
Zagadnienie jest dość rozrośnięte, więc podzieliłem artykuł na kilka części.
W tym cyklu pokażę Ci, czym jest globalizacja, czym się różni od lokalizacji i jak to wszystko ogarnąć w różnych typach aplikacji (Xamarin, WPF, WebApp). Także bierz kawkę lub herbatę i zaczynamy.
Czym jest globalizacja?
Różne kraje mają różne formaty dat, liczb itd. Przykładowo w USA domyślnym formatem daty jest miesiąc/dzień/rok (MM/dd/yyyy). Jest to unikalne, że miesiąc występuje na pierwszym miejscu. W Polsce domyślny format to dzień-miesiąc-rok (dd-MM-yyyy), chociaż czasem stosuje się też odwrotny: rok-miesiąc-dzień (yyyy-mm-dd). Różne kraje też inaczej obsługują liczby. Np. w Polsce (i wielu innych krajach) separatorem dziesiętnym jest przecinek. Ale już np. w Australii, Irlandii, UK, czy USA (i wielu innych) separatorem dziesiętnym jest kropka.
Jakie to ma znaczenie?
Jeśli piszesz aplikację tylko dla swojego kraju, to małe. Twój program jednak może zostać uruchomiony przez dziwnych, złośliwych typów, którzy zrobili sobie inne ustawienia w systemie. Wtedy jest problem. Jeśli z aplikacji mogą korzystać osoby z innych państw, to musisz zatroszczyć się o globalizację.
Tzn. jeśli np. zapiszesz datę w bazie danych w formacie dd-mm-yyyy, a program później zostanie uruchomiony w innym kraju (albo przez złośliwego użytkownika, który zdefiniował sobie inny format), no to już będzie problem. W najgorszym przypadku program zadziała – pokazując złą datę. W najlepszym – po prostu wybuchnie, urywając użytkownikowi rączki i nóżki. Dlatego też powinieneś zatroszczyć się o to, żeby program pokazywał dobre dane przy różnych ustawieniach.
Tworzenie aplikacji wielojęzycznej
Dzisiaj dużo aplikacji jest tworzone od razu z myślą o innych krajach. I Ty także powinieneś tak je tworzyć (chyba, że robisz wewnętrzne narzędzia dla siebie/swojego zespołu, który mieści się tylko w Polsce). Tworzenie aplikacji wielojęzycznej składa się z dwóch procesów: globalizacji i lokalizacji. I ten cały proces nazywa się internacjonalizacją… Ufff.
Jak już wspomniałem, globalizacja polega na takim zaprojektowaniu aplikacji, żeby działała ona z różnymi kulturami (różne ustawienia dat, liczb itd). Lokalizacja natomiast polega na takim zaprojektowaniu aplikacji, żeby teksty dało się tłumaczyć. Czyli nie wpisujesz ich na sztywno, tylko pobierasz je skądś… No właśnie. To kolejny element lokalizacji. Musisz pomyśleć skąd masz pobierać teksty i w jaki sposób ustawiać aktualny język.
Globalizacja
Stringi
Zawsze używaj stringów UNICODE. Na szczęście .NET domyślnie używa UNICODE (UTF-16) dla wszystkich stringów. Jednak można używać też innych stron kodowych. W specyficznych przypadkach ma to sens. Ale nie kombinuj z tym bezmyślnie. Staraj się używać wszędzie UNICODE.
Traktuj string jako całość, a nie jako zbiór znaków. Jest to istotne jeśli chodzi o sortowanie lub porównywanie. W różnych krajach, różne znaki mogą być sortowane inaczej. Co więcej w niektórych sytuacjach jeden znak może być utworzony z kilku obiektów typu char. Dlatego też używaj przeciążenia String.IndexOf(String), zamiast String.IndexOf(char). Ponadto, to przeciążenie ze stringiem bierze pod uwagę kulturę.
Nie porównuj stringów w standardowy sposób: str1 == str2 ani str1.Equals(str2). Te metody nie biorą pod uwagę kultury.
Zawsze porównuj stringi używając metody String.Compare. ZAWSZE. Ta metoda ma dodatkowy parametr StringComparison, który może mieć takie wartości:
CurrentCulture – porównaj stringi używając aktualnej kultury i zasad sortowania
CurrentCultureIgnoreCase – tak jak wyżej, tylko nie bierz pod uwagę wielkości znaków
InvariantCulture – porównaj stringi używając niezmiennej kultury (invariant culture). Ta kultura jest związana z językiem angielskim.
InvariantCultureIgnoreCase – jak wyżej, ale nie bierz pod uwagę wielkości znaków
Ordinal – porównaj stringi binarnie
OrdinalIgnoreCase – jak wyżej, ale nie bierz pod uwagę wielkości znaków.
Najczęściej będziesz używał CurrentCulture(IgnoreCase) lub InvariantCulture(IgnoreCase) chyba, że ważne dla Ciebie są wielkości znaków. InvariantCulture używasz raczej wtedy, kiedy masz pewność, że dane mieszczą się w standardowym zestawie znaków (alfabet łaciński).
UWAGA! Porównanie stringów z CurrentCulture jest wolne. Jeśli widzisz u siebie problem z wydajnością w tym miejscu, zastanów się, czy możesz to zrobić inaczej
Generalnie podczas działań na stringach zawsze powinieneś szukać metod, które przyjmują w parametrze kulturę.
Daty
Często tutaj jest problem. Daty powinny być wyświetlane zgodnie z aktualną kulturą. Format daty w danej kulturze możesz wyciągnąć z CultureInfo.CurrentCulture.DateTimeFormat
Zróbmy małą symulację. Załóżmy, że ktoś z USA wpisał do bazy danych datę, a potem ktoś z Polski to odczytuje:
string dateStr = "02/01/2020"; //Polak odczytał z bazy danych
DateTimeOffset dt = DateTimeOffset.Parse(dateStr);
Amerykanin wprowadził datę po swojemu – 1 luty 2020. W efekcie Polak odczyta 2 stycznia 2020 -> w tych kulturach miesiąc i dzień zamieniają się miejscami. No i mamy najgorszy możliwy przypadek. Program się nie wywalił, ale zadziałał pokazując złą datę. Dlaczego tak się stało? Bo data została zapisana z użyciem kultury en-US, a odczytana z użyciem pl-PL. Jest to częsty błąd, ale łatwy do uniknięcia. Jak to powinno być zrobione poprawnie?
Poprawnie to Amerykanin powinien zapisać datę z InvariantCulture, a Polak ją tak odczytać. Spójrz na ten kod:
//Zapis w USA
DateTimeOffset dt = new DateTimeOffset(2020, 2, 1, 0, 0, 0, 0, TimeSpan.Zero);
string valueToDatabase = dt.ToString(CultureInfo.InvariantCulture);
//i zapis do bazy valueToDatabase
//Odczyt w Polsce
string valueFromDatabase = GetDateFromDbAsString();
DateTimeOffset plDt = DateTimeOffset.Parse(valueFromDatabase, CultureInfo.InvariantCulture);
string s = plDt.ToString();
Console.WriteLine(s);
Przeanalizujmy to:
Najpierw Amerykanin utworzył datę.
Zmienił ją na string, używając niezmiennej kultury („02/01/2020”)
Zapisał do bazy danych
Polak odczytał z bazy danych („02/01/2020”)
Sparsował tego stringa na datę, używając niezmiennej kultury – tu otrzymujemy już poprawną datę, a nie jak w poprzednim przypadku
Polak zamienił datę na stringa, używając aktualnej kultury, w efekcie zobaczył: „01.02.2020”
Zauważ, że DateTime.ToString() i DateTimeOffset.ToString() zmieniają datę na stringa, używając aktualnej kultury. I teraz jeśli Amerykanin wprowadzi w swoim formacie datę 5/20/2020 (20 maj), a Polak spróbuje to odczytać bez użycia InvariantCulture, to program się wywali, ponieważ DateTime.ToString() będzie próbowało odczytać tę datę jako piąty dzień dwudziestego miesiąca.
Jest jeszcze jeden problem. W grę wchodzi baza danych. Więc jeśli tam są robione jakieś operacje na datach, trzeba to też wziąć pod uwagę. Rozwiązania są dwa:
zawsze używamy niezmiennej kultury
dogadujemy się co do własnego formatu zapisu daty, np: „dd-MM-YYYY HH::ss”
Sam sobie odpowiedz, które rozwiązanie jest lepsze 🙂
Generalnie daty można zapisywać poprawnie na 3 sposoby:
binarnie, zamiast stringa (co może być uciążliwe)
używając Unix Timestamp – czyli ilość sekund, które upłynęło od 1 stycznia 1970 (co też może być uciążliwe)
jako string – używając niezmiennej kultury (lub ustalonego formatu, który NIGDY się nie zmieni).
Każdy z tych sposobów ma swoje plusy i minusy. Np. jeśli w bazie danych zapiszesz daty binarnie lub jako integer (Unix Timestamp), bazodanowe operacje będą szybsze. Ale dla kogoś, kto przegląda rekordy lub próbuje coś debugować, jest to mordęga. Zawsze jednak można sobie stworzyć widok, który doda kolumnę z datą przekonwertowaną na ludzki format.
UWAGA! Jeśli w bazie danych trzymasz czas jako Unix Timestamp w polu 32 bitowym, miej świadomość, że w 2038 roku będzie problem, bo wtedy zakres dat zostanie przekroczony!
To jednak nie wszystko, jeśli chodzi o datę. W grę wchodzą jeszcze strefy czasowe.
Strefy czasowe
W C# mamy dwie klasy, które ułatwiają posługiwanie się strefami czasowymi. TimeZone i TimeZoneInfo. Jednak jeśli chodzi o TimeZone, to klasa została uznana za przestarzałą już w .NetCore, zatem skupimy się tylko na TimeZoneInfo.
W skrócie, TimeZoneInfo pozwala na konwertowanie czasu pomiędzy dowolnymi strefami czasowymi. Ma kilka metod statycznych, które służą do utworzenia obiektu. Nie posługujemy się tutaj konstruktorem.
Informacje o strefie czasowej można w bardzo prosty sposób zapisać, np:
TimeZoneInfo tz = TimeZoneInfo.Local; //pobranie aktualnej strefy czasowej
string serialized = tz.ToSerializedString(); //serializacja do string
TimeZoneInfo restoredTimeZone = TimeZoneInfo.FromSerializedString(serialized);
Najpierw pobieramy aktualną strefę czasową, potem zapisujemy ją w specjalnym stringu za pomocą ToSerializedString(). Takiego stringa teraz możemy zapisać w bazie danych, a potem utworzyć strefę czasową z niego, używając metody FromSerializedString().
Jako twórca aplikacji, nie powinieneś zakładać, że wszystkie czasy są wyrażone w aktualnej strefie czasowej. Np. jeśli ktoś z USA doda post na forum o godzinie 13:00 swojego czasu, Ty mógłbyś w Polsce zobaczyć, że post został dodany o godzinie 13:00 Twojego, polskiego czasu, co oczywiście nie jest prawdą. To jest prosty przykład, ale są systemy, które mocno polegają na czasie i w takich przypadkach mogą nie działać lub być podatne na jakieś ciekawe ataki.
Na szczęście istnieje coś takiego jak UTC (Coordinated Universal Time) – uniwersalny czas koordynowany (Zulu), czyli po prostu niezmienny czas w strefie z południkiem 0. Wszystkie inne strefy czasowe są wyrażone w godzinach dodatnich lub ujemnych od czasu UTC. Zatem, jeśli do wyrażenia dat posługujesz się typem DateTime, zdecydowanie powinieneś dodać do tego TimeZoneInfo.
Teraz spójrz na taki ZŁY kod:
//zapis w USA
DateTime dt = new DateTime(2021, 6, 1, 15, 0, 0);
string usaDt = dt.ToString(CultureInfo.InvariantCulture);
//odczyt w Polsce
DateTime dtInPoland = DateTime.Parse(usaDt, CultureInfo.InvariantCulture);
Ostatecznie wychodzi, że ktoś w USA dodał jakiś zasób o godzinie 15:00 czasu Polskiego. To bzdura. Tylko z tego powodu, że pominęliśmy strefy czasowe. Jak taki kod naprawić? Na przykład przed zapisem zmień go na UTC:
//zapis w USA - symulacja wprowadzenia daty w USA
DateTime dt = new DateTime(2021, 6, 1, 23, 0, 0);
TimeZoneInfo pacificZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");
//konwersja daty na UTC
dt = TimeZoneInfo.ConvertTime(dt, pacificZone);
DateTime utcTime = TimeZoneInfo.ConvertTimeToUtc(dt, pacificZone);
//zapis do bazy
string usaDt = utcTime.ToString(CultureInfo.InvariantCulture);
//odczyt w Polsce
DateTime utc = DateTime.Parse(usaDt, CultureInfo.InvariantCulture);
TimeZoneInfo polishTimeZone = TimeZoneInfo.Local;
DateTime polishTime = TimeZoneInfo.ConvertTimeFromUtc(utc, polishTimeZone);
string plDt = polishTime.ToString();
Niech on Cię nie przeraża. Pierwsze cztery linijki to tak naprawdę SYMULACJA zapisu daty w USA. Musiałem zrobić tę symulację, bo na swoim komputerze mam ustawiony polski czas.
A teraz przeanalizujmy go:
Ktoś w USA zapisuje dane 1 czerwca o godzinie 15 (czasu USA)
Następnie ten czas jest zmieniany na czas UTC – czas uniwersalny
Czas UTC jest zapisywany w bazie w formacie InvariantCulture (o tym mówiliśmy w wyżej)
Ktoś z Polski odczytuje datę z bazy. Pamiętaj, że data jest zapisana w czasie uniwersalnym.
Pobieramy teraz lokalną strefę czasową ustawioną na komputerze (dla ludzi z Polski powinna to być aktualna polska strefa czasowa)
Za pomocą TimeZoneInfo.ConvertTimeFromUtc konwertujemy czas UTC na nasz polski czas
Na koniec możemy go wyświetlić.
Okazuje się, że wpis został dokonany 1 czerwca o godzinie 23:00 czasu polskiego. I teraz wszystko śmiga. Oczywiście w swoim programie pewnie wystarczy, że zamiast FindSystemTimeZoneById użyjesz po prostu TimeZoneInfo.Local, ponieważ to zwróci Ci lokalną strefę czasową klienta. Jeśli klient będzie w USA, zwróci to odpowiednią strefę z USA. Jeśli będzie z Polski, zwróci to aktualną strefę w Polsce.
UWAGA! NIGDY nie używaj TimeZoneInfo.Local na serwerze, ponieważ zwróci to strefę czasową SERWERA, a nie klienta!
Oczywiście można też zrobić coś innego. Możesz w bazie danych trzymać datę lokalną (zamiast UTC), ale to wymaga, żeby w innym polu były zawarte informacje o strefie czasowej. To z kolei wymaga odpowiedniego mechanizmu odczytu takich danych. I prędzej, czy później ktoś tu coś może spieprzyć.
DateTimeOffset
Staraj się unikać typu DateTime na rzecz DateTimeOffset. Ten drugi jest w pewnym sensie kolejną wersją DateTime. Dodatkowo zawiera informacje o przesunięciu czasowym. Ale nie zawiera pełnych informacji o strefie czasowej. Więc przykładowo porównywanie dat z użyciem DateTime ma sens tylko wtedy, kiedy obie daty pochodzą z tej samej strefy czasowej. DateTimeOffset nie ma już tego problemu. Poza tym, DateTime ma jeszcze kilka takich „kruczków”, m.in., jeśli dodajesz lub odejmujesz jakiś czas do DateTime, najpierw musisz przekonwertować to na UTC:
jeśli tego nie zrobisz, to w specyficznych przypadkach (dni zmiany strefy czasowej w kraju) otrzymasz złe dane.
Zatem używaj DateTimeOffset*
*Niestety niektóre kontrolki third party nie umieją w DateTimeOffset. Trzymają się sztywno DateTime, jak tonący brzytwy. Postaraj się wtedy o odpowiednie konwersje, chyba że faktycznie nie są potrzebne.
Liczby
Tutaj też istnieje różnica pomiędzy wyświetlaniem numerów, a ich składowaniem. Przede wszystkim musisz zapamiętać, że metoda ToString konwertuje liczby do stringa zgodnie z aktualną kulturą. Więc liczbę „jeden i pół” amerykanin zobaczy tak: „1.5”, a Polak tak: „1,5” (zakładając standardowe ustawienia).
I niby fajnie, ale… NIGDY nie przechowuj liczb w takiej postaci (stringa). Grozi to wybuchem i urwaniem rączek. Jeśli robisz czyste zapytania do bazy danych (mam nadzieję, że tego nie robisz), to pamiętaj też, że taką liczbę powinieneś przekonwertować do InvariantCulture. Spójrz na prostą tabelę z dwiema kolumnami:
Tabela(ID: BIGINT, number: float)
I teraz jeśli użytkownik wpisze daną w postaci „1,5”, to jak będzie wyglądać Twoje czyste zapytanie do bazy? INSERT INTO Tabela(number) VALUES(1,5);
SQL pomyśli, że chcesz wprowadzić dane dla dwóch kolumn (jedna o wartości 1, druga o wartości 5). Dlatego też konwersja do InvariantCulture lub zdecydowanie lepiej – używanie parametrów SQL (ale to historia na inny artykuł).
Ten sam błąd możesz uzyskać, zapisując dane w postaci tekstowej np. w XML. Jeśli zapiszesz w taki sposób:
<Data value="1,5" />
ten przykładowy Amerykanin też tak to odczyta. I później nastąpi konwersja ze stringa na double. I BUM! W USA separatorem dziesiętnym jest KROPKA, a nie przecinek. Dlatego też w takich sytuacjach powinieneś konwertować dane do InvariantCulture.
Przy okazji, skoro tu jesteśmy – jeśli zapisujesz jakieś sumy pieniędzy (czy to w bazie, czy w XML, czy jeszcze gdzieś indziej), nie zapisuj tego jako double. Zawsze zapisuj to jako liczbę całkowitą, czyli np. ilość groszy/centów, a nie złotówek/dolarów. Zamiast zapisać 1,23 (złoty dwadzieścia trzy), po prostu pozbądź się tej części ułamkowej, mnożąc przez 100. Zostanie zapisana liczba 123. Dane finansowe są niezwykle wrażliwe i każde nieodpowiednie (nieprzewidziane) zaokrąglenie może duuużo kosztować. Dlatego przejmij w tej kwestii całkowicie kontrolę i podawaj ilość groszy/centów itd.
CurrentCulture vs CurrentUICulture
Żeby dopełnić artykułu, muszę o tym wspomnieć. Microsoft wyprodukował nam dwie podobne właściwości: CurrentCulture i CurrentUICulture. Powinnśsmy dobrze poznać różnice między nimi, żeby nie było przykrych niespodzianek i niepotrzebnych nerwów.
CurrentCulture – używaj do formatowania dat, liczb itd.
CurrentUICulture – używaj do pobierania zasobów.
Czyli: Culture – wszelkie formatowanie; UICulture – zasoby.
To tyle w kwestii globalizacji. W kolejnym artykule z tej serii zajmiemy się lokalizacją, czyli skąd i jak wyświetlać stringi w różnych technologiach.
Jeśli masz jakieś problemy lub znalazłeś błąd w artykule, podziel się tym w komentarzu.
Hej, w pierwszej części artykułu pisałem o podstawach Tag Helpers w Razor. Jeśli nie czytałeś tego, to koniecznie nadrób zaległości, bo inaczej ten artykuł może być niezrozumiały.
Dzisiaj polecimy dalej i wyżej. Opowiem Ci o bardziej zaawansowanych rzeczach, które możemy zrobić z tag helperami i stworzymy coś miłego dla oka. Dlatego, na Boga, przeczytaj pierwszą część artykułu.
Co robimy?
Chcemy stworzyć tag helper, który ułatwi nam korzystanie z bootstrapowych kart. Jeśli nie wiesz, czym jest bootstrap, to w dużym skrócie można powiedzieć, że jest to zestaw styli css (framework css) gotowych do użycia na Twojej stronie, które bardzo przyspieszają zarówno tworzenie layoutu, jak i samej strony. Ponadto bootstrap posiada kilka ciekawych komponentów w JS takich jak np. karuzela, czy też okno modalne.
My zajmiemy się dzisiaj kartami. Chcemy uzyskać taki efekt:
Nie jest to nic wybitnego, po prostu korzystamy z kart. Ten Bootstrapowy komponent jest dokładnie opisany tutaj.
Szybki opis karty
Pojedyncza karta wygląda tak:
Na górze mamy nagłówek, pod nim obrazek, dalej tytuł (Pieseł), podtytuł (Hau hau) i zawartość. Na dole jest stopka, a cała karta jest w ramce. Wyłaniają nam się już poszczególne elementy, więc zacznijmy pisanie właśnie od tagu helper’a dla pojedynczej karty.
Zaczynamy pisanie tag helper’a
Pojedyncza karta
Jak już wiesz, karta składa się z kilku elementów. W tym momencie pominiemy ramkę, bo będzie za nią odpowiadało coś innego. A więc mamy:
tekst nagłówka
obrazek
tytuł i podtytuł
treść
tekst stopki
Przygotujmy taki szkielet tag helper’a:
public class CardTagHelper: TagHelper
{
public string Header { get; set; } = string.Empty;
public string ImgSrc { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string SubTitle { get; set; } = string.Empty;
public string Footer { get; set; } = string.Empty;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
base.Process(context, output);
}
}
Póki co, nie ma tutaj niczego nadzwyczajnego. Zupełny szkielet tag helper’a z wymaganymi właściwościami. Zobaczmy, jak powinna wyglądać taka karta w HTML:
Jak widzisz, najpierw jest główny div, trzymający w ryzach całą kartę. Następnie jest div z nagłówkiem, obrazek, div z ciałem i na końcu stopka. Zacznijmy od tego pierwszego – głównego:
Nie ma tu niczego nowego, może poza metodą AddClass. To jest metoda helperowa, aby ją zobaczyć i użyć, musisz dodać using: Microsoft.AspNetCore.Mvc.TagHelpers. To jest po prostu pewne ułatwienie. Równie dobrze można by dodać klasy za pomocą atrybutów.
Teraz w razor (cshtml) użyjemy naszego helper’a. Wpisz po prostu:
<card></card>
Zobacz, jaki HTML został wyrenderowany:
<div class="card mb-3"></div>
Czyli można powiedzieć, że mamy już początek. Zanim pójdziemy dalej, muszę opowiedzieć Ci o 4 właściwościach z klasy TagHelperOutput, o których wcześniej nie mówiliśmy. Dla ułatwienia opowiem to łopatologicznie, niekoniecznie zgodnie z poprawnym nazewnictwem:
PreElement – to jest treść HTML, która będzie umieszczona zaraz przed Twoim tagiem
PreContent – to jest treść HTML, która będzie umieszczona w Twoim tagu, ale zaraz przed jego zawartością
PostContent – to jest treść HTML, która będzie umieszczona w Twoim tagu, po jego zawartości
PostElement – to jest treść HTML, która będzie umieszczona zaraz po Twoim tagu.
Aby to lepiej zobrazować, napiszmy sobie krótki kod. Najpierw tag helper (możesz użyć tego, nad którym aktualnie pracujemy):
Jak widzisz, możemy dowolnie sterować tym, co znajduje się zarówno przed tagiem, po nim, jak i w środku. Możemy tam dać dowolny i dowolnie długi kod HTML. I to właśnie wykorzystamy.
Nagłówek
Przypomnijmy sobie zatem na szybko kod bootstrap’a:
Mamy zrobionego pierwszego diva. Zróbmy teraz nagłówek i obrazek:
public class CardTagHelper: TagHelper
{
public string Header { get; set; } = string.Empty;
public string ImgSrc { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string SubTitle { get; set; } = string.Empty;
public string Footer { get; set; } = string.Empty;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
base.Process(context, output);
output.TagName = "div";
output.AddClass("card", HtmlEncoder.Default);
output.AddClass("mb-3", HtmlEncoder.Default);
output.PreContent.AppendHtml(RenderHeader());
output.PreContent.AppendHtml(RenderImg());
}
string RenderHeader()
{
return $"<div class=\"card-header\">{Header}</div>";
}
string RenderImg()
{
return $"<img class=\"card-img-top\" src=\"{ImgSrc}\" />";
}
}
Zobacz, zrobiłem przy okazji dwie metody renderujące odpowiedni kod HTML. Teraz lekko zmieńmy wywołanie tego tag helper’a w Razor. Trzeba po prostu dodać brakujące treści:
Super, powoli do przodu. Jeśli uruchomisz teraz program, zobaczysz wielką mordkę uśmiechniętego pieseła. Dodajmy teraz resztę – czyli treść karty i jej stopkę:
Metody RenderFooter nie ma co omawiać, natomiast zatrzymajmy się przy RenderBody. Jak widzisz wyprodukowaliśmy tu diva, a także tytuł i podtytuł karty. Czyli wszystko zgodnie z wzorcowym HTMLem. I teraz powinna pokazać się cała treść. A gdzie ona jest?
Spójrz jeszcze raz na metodę Process. Dodajemy kod „body” w PreContent. Następnie dodajemy diva zamykającego „body” w PostContent. Coś już świta? Spójrz teraz jak zmieniło się wywołanie tag helper’a w Razor:
<card header="Nagłówek" img-src="imgs/dog.jpg" footer="Stopka" title="Pieseł" sub-title="Hau hau">
<p class="card-text">Pieseł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
Co się okazuje? Że to, co damy między znacznikami początku i końca jest zawartością (Content) tag helpera! Nieźle. Uruchom teraz aplikację i zobacz kod wynikowy:
<div class="card mb-3">
<div class="card-header">Nagłówek</div>
<img class="card-img-top" src="imgs/dog.jpg">
<div class="card-body">
<h5 class="card-title">Pieseł</h5>
<h6 class="card-subtitle">Hau hau</h6>
<p class="card-text">Pieseł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</div>
<div class="card-footer text-muted">Stopka</div>
</div>
Podświetlone linijki to właśnie te, które stanowią zawartość tag helper’a. Te 11 linijek kodu zamieniliśmy w 4 zdecydowanie prostsze! Hurra ja! Ale ale… Jeszcze nie pora na piwo. To dopiero pierwszy etap zadania.
Grupa kart
Skoro mamy już opracowaną kartę, zajmiemy się teraz grupą kart. Na początek zróbmy najprościej jak się da. Przypominam, jak w HTML wygląda taka grupa:
<div class="card-deck">
...
</div>
Klasa będzie na tyle prosta, że sam już powinieneś to napisać:
I to właściwie tyle. Zaskoczony? Popatrz teraz na Razor:
<card-group>
<card header="Nagłówek" img-src="imgs/cat.jpg" footer="Stopka" title="Koteł" sub-title="Miauuu">
<p class="card-text">Koteł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
<card header="Nagłówek" img-src="imgs/dog.jpg" footer="Stopka" title="Pieseł" sub-title="Hau hau">
<p class="card-text">Pieseł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
<card header="Nagłówek" img-src="imgs/pig.jpg" footer="Stopka" title="Świnieł" sub-title="Chrum chrum">
<p class="card-text">Chrumcia jaka jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
</card-group>
Dodajemy ramki
Czego jeszcze brakuje? Brakuje ramek. Ale ja chcę, żeby o istnieniu lub nieistnieniu ramki decydował card-group, a nie card. Jak to uzyskać? Z pomocą przychodzi pierwszy parametr metody Process – TagHelperContext.
TagHelperContext
To kontekst, który może być przekazywany między tag helperami. Ten kontekst będzie przekazany do metody Process (lub ProcessAsync) wszystkim tag helperom, które są w środku głównego TagHelpera.
Nie jest to wybitna klasa, ale ma jedną bardzo cenną właściwość: Items. Items to słownik, w którym zarówno kluczem i wartością jest object. Co znaczy, że możesz tam wrzucić dosłownie wszystko. Ten słownik posłuży do przekazywania danych między tag helperami. Stwórzmy sobie klasę o nazwie CardData:
class CardData
{
public bool ShowBorder { get; set; } = false;
}
Ta klasa przechowuje wartość, czy obramowanie ma być widoczne, czy nie. Uzupełnijmy teraz card-group o ten border:
public class CardGroupTagHelper: TagHelper
{
public bool ShowBorder { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
base.Process(context, output);
output.TagName = "div";
output.AddClass("card-deck", HtmlEncoder.Default);
CardData data = new CardData();
data.ShowBorder = ShowBorder;
context.Items[nameof(CardData)] = data;
}
}
Utworzyliśmy tu klasę CardData i przypisaliśmy jej właściwość ShowBorder. Następnie umieściliśmy utworzony obiekt w słowniku. Widziałem też kody, w których zamiast nameof(CardData) jako klucz, było typeof(CardData). Możesz sobie tam nawet wpisać „Słoń”. Dopóki wiesz jak odczytać te dane, nie ma to żadnego znaczenia. Jednak użycie nameof lub typeof wydaje się najbardziej racjonalne. Więc teraz dokończymy kod karty:
public override void Process(TagHelperContext context, TagHelperOutput output)
{
base.Process(context, output);
output.TagName = "div";
output.AddClass("card", HtmlEncoder.Default);
output.AddClass("mb-3", HtmlEncoder.Default);
CardData data = context.Items[nameof(CardData)] as CardData;
if (data.ShowBorder)
output.AddClass("border-primary", HtmlEncoder.Default);
output.PreContent.AppendHtml(RenderHeader());
output.PreContent.AppendHtml(RenderImg());
output.PreContent.AppendHtml(RenderBody());
output.PostContent.AppendHtml("</div>");
output.PostContent.AppendHtml(RenderFooter());
}
Pobraliśmy tutaj obiekt CardData stworzony w rodzicu tego tag helpera (card-group) i reszta jest już oczywista. To na tyle… Prawie…
Ograniczenia
Co się stanie, jeśli do card-group nie dodamy card, tylko coś innego? Np. jakiś obrazek? Być może nic złego, ale na pewno coś dziwnego. Nie chcemy takiej sytuacji. Chcemy się upewnić, że dziećmi card-group mogą być tylko elementy card. Z pomocą przychodzi…
RestrictChildren
To jest atrybut nakładany na głównego tag helpera. Powoduje, że jego dziećmi mogą być jedynie konkretne klasy, np:
[RestrictChildren("card")]
public class CardGroupTagHelper: TagHelper
{
//...
}
Spowoduje to, że dziećmi taga card-group mogą być jedynie tagi o nazwie card. Jeśli teraz w razor umieścisz taki kod:
<card-group show-border="true">
<mail>Mail</mail>
<card header="Nagłówek" img-src="imgs/cat.jpg" footer="Stopka" title="Koteł" sub-title="Miauuu">
<p class="card-text">Koteł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
</card-group>
to program się nie skompiluje i dostaniesz błąd: error RZ2010: The tag is not allowed by parent tag helper. Only child tags with name(s) 'card' are allowed.
Oczywiście ograniczenie nie dotyczy tylko tag helperów. Jeśli dasz tam jakiegoś diva (zamiast mail), img, czy cokolwiek innego, to program też się nie skompiluje.
Nie musimy się ograniczać do jednego typu dziecka. W końcu „wszystkie dzieci nasze są”. Możemy podać ich kilka, używając drugiego parametru atrybutu RestrictChildren:
[RestrictChildren("card", "img", "mail")]
public class CardGroupTagHelper: TagHelper
{
//...
}
Możemy podać tyle tagów, ile tylko chcemy.
Nic nie renderuj – czyli SupressOutput
Istnieje pewna metoda, która niejako niweczy całą pracę wykonaną przez TagHelpera. W klasie TagHelperOutput znajduje się SupressOutput. Ona po prostu powoduje brak wyświetlenia czegokolwiek w tym tagu. I tu pewnie zapytasz się – po co to komu? Czasem się to przydaje. Weźmy pod uwagę taki prosty kod:
class Data
{
public List<string> Children { get; set; } = new List<string>();
}
public class ParentTagHelper: TagHelper
{
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
await base.ProcessAsync(context, output);
output.TagName = "div";
Data data = new Data();
context.Items[nameof(Data)] = data;
await output.GetChildContentAsync();
output.Attributes.SetAttribute("count", data.Children.Count);
string content = "";
foreach (var child in data.Children)
content += child + "<br />";
output.Content.SetHtmlContent(content);
}
}
public class ChildTagHelper: TagHelper
{
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
await base.ProcessAsync(context, output);
TagHelperContent content = await output.GetChildContentAsync();
Data data = context.Items[nameof(Data)] as Data;
data.Children.Add(content.GetContent());
output.SuppressOutput();
}
}
Przeanalizujmy go. Najpierw deklarujemy klasę Data, która będzie przechowywać jakieś dane tagów. Następnie mamy tag helper’a – Parent. Spójrzmy co on robi:
zmienia swój tag na div
tworzy obiekt klasy Data
dodaje ten obiekt do kotekstu
i wreszcie wywołuje metodę GetChildContentAsync.
Metoda GetChildContentAsync() powoduje, że ruszają wszystkie tagi (metoda ProcessAsync()), będące dziećmi tego. Spójrzmy teraz na kod w ChildTagHelper, który właśnie w tym momencie zostanie wywołany:
tag helper pobiera swoją zawartość
pobiera obiekt data
na koniec dodaje swoją zawartość do listy
Wywołanie SupressOutput() spowoduje, że ten tag helper niczego nie wyświetli. Po prostu przekazał pewne dane do kontekstu i na tym zakończył swoją pracę.
Wróćmy teraz do ParentTagHelper. Gdy wszystkie dzieci wykonają swoją robotę, GetChildContentAsync się skończy i ParentTagHelper będzie kontynuował swoją pracę. W tym momencie odczyta z listy ilość dzieci i ustawi ją jako atrybut.
Następnie między wszystkie stringi (które pochodzą z dzieci) wstawi znacznik <br /> i wszystko to ustawi jako swoją zawartość.
Więc czasem ten SupressOutput jest przydatny. Nie tylko, żeby coś renderować warunkowo, ale też jeśli potrzebujesz mieć w Razor jakieś dzieci, ale tak naprawdę całą zawartością będzie z jakiegoś powodu sterował główny tag.
To na tyle, jeśli chodzi o TagHelpery w Razor. Mam nadzieję, że wszystko jest jasne. Jeśli jednak masz jakiś problem albo znalazłeś w artykule błąd, podziel się w komentarzu.