Entity Framework w osobnym projekcie

Entity Framework w osobnym projekcie

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:

services.AddDbContext<ApplicationDbContext>(options =>
	options.UseSqlServer(
		Configuration.GetConnectionString("DefaultConnection")));

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.

  1. 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:

Add-Migration NazwaMigracji -p NazwaTwojegoProjektu

lub

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.

Podziel się artykułem na:
Idziemy w świat! Czyli aplikacje wielojęzykowe

Idziemy w świat! Czyli aplikacje wielojęzykowe

Wstęp

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

  1. 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.
  2. 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ę.
  3. Nie porównuj stringów w standardowy sposób: str1 == str2 ani str1.Equals(str2). Te metody nie biorą pod uwagę kultury.
  4. 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:

  1. Najpierw Amerykanin utworzył datę.
  2. Zmienił ją na string, używając niezmiennej kultury („02/01/2020”)
  3. Zapisał do bazy danych
  4. Polak odczytał z bazy danych („02/01/2020”)
  5. Sparsował tego stringa na datę, używając niezmiennej kultury – tu otrzymujemy już poprawną datę, a nie jak w poprzednim przypadku
  6. 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:

DateTime dt = DateTime.Parse("Oct 26, 2003 12:00:00 AM");
dt = d.ToUniversalTime().AddHours(3).ToLocalTime();

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.

Podziel się artykułem na:
Tag helpers na poważnie

Tag helpers na poważnie

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:

efekt końcowy tag helpers

HTML do tego wygląda tak:

<div class="row">
    <div class="card-deck">
        <div class="card border-primary mb-3">
            <div class="card-header">Nagłówek</div>
            <img class="card-img-top" src="~/imgs/cat.jpg" alt="Obraz kota" />
            <div class="card-body">
                <h5 class="card-title">Koteł</h5>
                <h6 class="card-subtitle">Miauuu</h6>
                <p class="card-text">Kot jaki jest, każdy widzi</p>
                <a href="#" class="btn btn-primary">Guzik</a>
            </div>
            <div class="card-footer text-muted">Stopka</div>
        </div>

        <div class="card border-primary mb-3">
            <div class="card-header">Nagłówek</div>
            <img class="card-img-top" src="~/imgs/dog.jpg" alt="Obraz pieseła" />
            <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>

        <div class="card border-primary mb-3">
            <div class="card-header">Nagłówek</div>
            <img class="card-img-top" src="~/imgs/pig.jpg" alt="Obraz świni" />
            <div class="card-body">
                <h5 class="card-title">Świnieł</h5>
                <h6 class="card-subtitle">Chrum chrum</h6>
                <p class="card-text">Chrumcia jaka jest, każdy widzi</p>
                <a href="#" class="btn btn-primary">Guzik</a>
            </div>
            <div class="card-footer text-muted">Stopka</div>
        </div>
    </div>
</div>

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:

karta bootstrap card

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:

<div class="card mb-3">
    <div class="card-header">Nagłówek</div>
    <img class="card-img-top" src="~/imgs/dog.jpg" alt="Obraz pieseła" />
    <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>

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:

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);
}

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):

public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
	output.TagName = "div";
	output.Attributes.Add("info", "main-div");
	output.PreElement.SetHtmlContent("<p>Przed elementem</p>");
	output.PreContent.SetHtmlContent("<p>Przed zawartością</p>");
	output.PostContent.SetHtmlContent("<p>Po zawartości</p>");
	output.PostElement.SetHtmlContent("<p>Po elemencie</p>");
}

Uruchom stronę z tym tag helperem i podejrzyj źródło HTML. Zobaczysz coś takiego:

<p>Przed elementem</p>
<div info="main-div">
    <p>Przed zawartością</p>
    <p>Po zawartości</p>
</div>
<p>Po elemencie</p>

Teraz tak się składa, że to, co wpisaliśmy w Razor nie ma żadnej zawartości. Wewnątrz tagu card nic się nie znajduje. Zmieńmy to:

<card>Zawartość okna</card>

Uruchom teraz swoją stronę i zobacz wynikowy HTML:

<p>Przed elementem</p>
<div info="main-div">
    <p>Przed zawartością</p>
    Zawartość okna
    <p>Po zawartości</p>
</div>
<p>Po elemencie</p>

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:

<div class="card mb-3">
    <div class="card-header">Nagłówek</div>
    <img class="card-img-top" src="~/imgs/dog.jpg" alt="Obraz pieseła" />
    <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>

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:

<card header="Nagłówek" img-src="imgs/dog.jpg"></card>

Wynikiem tego kodu będzie:

<div class="card mb-3">
    <div class="card-header">Nagłówek</div>
    <img class="card-img-top" src="~/imgs/dog.jpg"/>
</div>

Treść karty i stopka

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ę:

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());
	
	output.PreContent.AppendHtml(RenderBody());
	output.PostContent.AppendHtml("</div>");

	output.PostContent.AppendHtml(RenderFooter());
}
string RenderBody()
{
	return $@"
		<div class='card-body'>
			<h5 class='card-title'>{Title}</h5>
			<h6 class='card-subtitle'>{SubTitle}</h6>";
}

string RenderFooter()
{
	return $"<div class=\"card-footer text-muted\">{Footer}</div>";
}

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ć:

public override void Process(TagHelperContext context, TagHelperOutput output)
{
	base.Process(context, output);

	output.TagName = "div";
	output.AddClass("card-deck", HtmlEncoder.Default);
}

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ść.

Zobacz, jak to wygląda od strony Razor:

<parent>
    <child>Wiersz 1</child>
    <child>Wiersz 2</child>
    <child>Wiersz 3</child>
</parent>

A wyrenderowany HTML będzie wyglądał tak:

<div count="3">Wiersz 1<br />Wiersz 2<br />Wiersz 3<br /></div>

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.

Podziel się artykułem na:
</