Pomijam fakt, że większość osób, którym robiłem techniczne review, jedyne typy kolekcji jakie znała to List<T>, Dictionary<K, V> i to właściwie wszystko (czasem jeszcze HashSet), to jest jeszcze jeden problem. Różne typy kolekcji są lepsze w niektórych przypadkach, a gorsze w innych. Głównie chodzi tutaj o performance. Ja wiem… Nie optymalizujemy za wcześnie. Niemniej jednak warto wiedzieć, które kolekcje sprawdzają się lepiej w konkretnych sytuacjach.
U jednego ze swoich klientów, zmieniając tylko typy niektórych kolekcji, udało mi się zauważalnie skrócić czas głównej operacji.
W tym artykule robię zatem krótki przegląd większości kolekcji, jakie mamy do dyspozycji.
Dla maruderów
Nie opisuję tutaj rzeczy takich jak BitArray (i podobnych) ani kolekcji niegenerycznych. Niektóre klasy są wysoko wyspecjalizowane do konkretnych zadań (jak np. BitArray), a kolekcji niegenerycznych… po prostu nie powinniśmy raczej używać.
Jakie mamy kolekcje?
Pierwszy podział będzie ze względu na „thread safety”.
Kolekcje dla scenariuszy jednowątkowych
List<T>
To zdecydowanie najbardziej znana kolekcja w C#. Lista to po prostu tablica na sterydach i nic więcej. Elementy listy zajmują ciągły obszar w pamięci. Lista na dzień dobry rezerwuje sobie pewien obszar o wielkości określonej w Capacity (capacity to ilość elementów, jakie chcemy mieć, a nie wielkość wyrażona w bajtach). Gdy dodajemy nowe elementy i Capacity się kończy, wtedy lista rezerwuje nowy obszar w pamięci o rozmiarze Capacity * 2 (szczegół implementacyjny) i kopiuje tam swoje elementy. Itd. To oznacza, że dodawanie elementów do listy w pewnych sytuacjach może być kosztowne. Niemniej jednak jest najpopularniejszą kolekcją. Dostęp do elementu mamy przez jego indeks.
W skrócie:
kolejność elementów zachowana
elementy umieszczone w ciągłym miejscu w pamięci
może zawierać duplikaty
łatwy dostęp do poszczególnych elementów za pomocą indeksu
SortedList<K, V>
Lista sortowana. Coś w stylu połączenia List<V> z SortedDictionary<K, V>, chociaż nieco bliżej jej do słownika niż do listy. Elementy znajdują się wciąż w tablicach, czyli zajmują ciągłe miejsce w pamięci. Ale uwaga, kolekcja tak naprawdę zawiera dwie tablice – jedna trzyma klucze, druga wartości. Oczywiście wszystko ze sobą jest sprytnie zsynchronizowane. Uważam to jednak za szczegół implementacyjny, który być może kiedyś ulegnie zmianie.
Elementy są sortowane za pomocą klucza, czyli klucz powinien implementować interfejs IComparable<K>.
W skrócie:
elementy sortowane na podstawie klucza
elementy zajmują ciągłe miejsce w pamięci (osobne tablice dla kluczy i wartości)
nie może zawierać duplikatów
łatwy dostęp do poszczególnych elementów za pomocą indeksu
LinkedList<T>
To jest prawilna lista dwukierunkowa (kłaniają się struktury danych :)). Składa się z elementów (LinkedListNode<T>), które oprócz konkretnej wartości trzymają jeszcze referencje do następnego i poprzedniego elementu. Przez co pobieranie konkretnego elementu, np. 5tego może być nieco problematyczne, bo tak naprawdę wymaga przejścia przez wszystkie poprzednie elementy, aby uzyskać ten nas interesujący.
W skrócie:
kolejność elementów zachowana
elementy umieszczone w różnych miejscach w pamięci
może zawierać duplikaty
dostęp do poszczególnych elementów jest łatwy, ale może być wolniejszy
ObservableCollection<T>
To bardzo dobrze znają osoby piszące w WPF. Ta kolekcja po prostu daje Ci znać, kiedy coś się w niej zmieni. Tzn. element zostanie dodany, usunięty lub zamieniony. Pod spodem jest zwykła lista.
Queue<T>, Stack<T>
To po prostu typowa kolejka FIFO (queue) i stos LIFO (stack). Przydają się wtedy, kiedy chcemy się pozbyć elementu po zdjęciu go z kolekcji i chcemy mieć zapewniony konkretny porządek.
W skrócie:
queue to kolejka FIFO, a stack to stos LIFO
elementy umieszczone w ciągłym miejscu w pamięci
może zawierać duplikaty
element jest automatycznie zdejmowany po odczytaniu go z kolejki (stosu)
PriorityQueue<T, P>
Kolejka priorytetowa. Zawiera elementy typu T i priorytet typu P. Przydatna kiedy chcemy niektóre elementy w kolejce traktować priorytetowo.
W skrócie:
kolejka z priorytetem
brak kolejności elementów
może zawierać duplikaty
element jest automatycznie usuwany po odczytaniu go z kolejki
Dictionary<K, T>
Słownik to kolekcja, która przetrzymuje obiekty na podstawie jakiegoś klucza. Klucz jest obliczany za pomocą metody GetHashCode z obiektu typu K. A wartości to typy T. Wyróżnia się szybkim dostępem do danych. Oczywiście to jeszcze zależy jak napiszesz swoją metodę GetHashCode, ale trzymając się standardowych podejść, wszystko powinno być dobrze 🙂
Słownik może mieć tylko jeden wpis z danym kluczem. Jeśli spróbujemy dodać kolejny z kluczem, który już istnieje w słowniku, rzucony będzie wyjątek.
UWAGA! NIGDY nie przechowuj wartości zwracanej z GetHashCode między uruchomieniami aplikacji. Po zmianie wersji .NET (nawet minor albo build) może okazać się, że GetHashCode zwraca inne wartości. Microsoft nie daje gwarancji, że tak nie będzie. A i ja się miałem okazję kiedyś przekonać o tym, że nie można na tym polegać. GetHashCode jest użyteczne tylko w tej samej wersji frameworka.
W skrócie:
brak kolejności elementów
elementy umieszczone w różnych miejscach w pamięci
nie może zawierać duplikatów
szybki dostęp do elementu po kluczu
ReadOnlyDictionary<K, T>
Daję go w sumie jako ciekawostkę. To słownik tylko do odczytu. Każda zmiana elementów po utworzeniu tego słownika kończy się rzuceniem wyjątku. Ja zdecydowanie wolę posługiwanie się odpowiednim interfejsem (np. IReadOnlyDictionary) niż tym słownikiem.
SortedDictionary<K, T>
To po prostu słownik, który jest posortowany. Każdy element w słowniku jest sortowany na podstawie klucza. Jeśli klucz implementuje interfejs IComparable<K>, wtedy ten właśnie komparator jest używany do porównania wartości. Sortowanie odbywa się automatycznie po zmianie na liście elementów. Natomiast klucz musi być niezmienny.
Potrzebuje nieco więcej pamięci niż SortedList, za to operacje dodawania i usuwania elementów są szybsze.
W skrócie:
elementy są posortowane względem klucza
elementy umieszczone w różnych miejscach w pamięci
nie może zawierać duplikatów
szybki dostęp do elementu po kluczu
FrozenDictionary<K, V>
To jest nowość w .NET 8. Pisałem już o tym w tym artykule. Generalnie jest to słownik tylko do odczytu, który jest zoptymalizowany pod kątem szukania elementów. I naprawdę daje radę. Zresztą zobacz sobie porównanie w moim artykule o nowościach w .NET 8.
W skrócie:
brak kolejności elementów
elementy umieszczone w różnych miejscach w pamięci
nie może zawierać duplikatów
szybki dostęp do elementów po kluczu (szybszy niż w Dictionary<K, V>)
TYLKO DO ODCZYTU
HashSet<T>
To jest zbiór niepowtarzalnych elementów o typie T. „Powtarzalność” jest sprawdzana na podstawie metody GetHashCode. Można go uznać za coś w rodzaju słownika bez wartości.
W skrócie:
brak kolejności elementów
elementy umieszczone w różnych miejscach w pamięci
nie może zawierać duplikatów
szybki dostęp po kluczu
SortedSet<T>
To, podobnie jak HashSet, też jest formą zbioru. Ale SortedSet to struktura drzewa binarnego (czerwono-czarne drzewo binarne). Elementy są posortowane zgodnie z przekazanym w konstruktorze Comparerem (lub domyślnym, jeśli nie przekazano).
Utrzymuje porządek sortowania bez dodatkowego narzutu podczas dodawania lub usuwania elementów. Nie daje jednak bezpośredniego dostępu do konkretnego elementu. Są oczywiście rozszerzenia, które to umożliwiają, np. IEnumerable.ElementAt, jednak związane jest to z przechodzeniem przez drzewo. Zatem używaj tylko wtedy, jeśli potrzebujesz mieć posortowane elementy, gdzie dodawanie i usuwanie nie daje dodatkowego narzutu.
elementy posortowane według przekazanego lub domyślnego Comparera
elementy umieszczone w różnych miejscach w pamięci
nie może zawierać duplikatów
brak bezpośredniego dostępu do konkretnego elementu
struktura drzewiasta
FrozenSet<T>
Analogicznie jak FrozenDictionary. Tylko reprezentuje HashSet zoptymalizowany do odczytu.
W skrócie:
brak kolejności elementów
elementy umieszczone w różnych miejscach w pamięci
nie może zawierać duplikatów
szybki dostęp do elementów po kluczu (szybszy niż w HashSet<T>)
TYLKO DO ODCZYTU
Immutable*<T>
Jest do tego kilka kolekcji niezmienialnych, które są oparte dokładnie na tych wymienionych wyżej. Jednak… nie można ich zmienić. To znaczy, że jeśli np. umożliwiają dodawanie lub usuwanie elementów, to tworzą i zwracają tym samym zupełnie nową kolekcję, a oryginalna pozostaje bez zmian. Czyli przy każdym dodaniu/usunięciu, cała kolekcja jest kopiowana.
Kolekcje dla scenariuszy wielowątkowych
Tutaj najpierw słowo wstępu. Istnieje namespace System.Collections.Concurrent i to klasami z tego namespace powinniśmy się posługiwać jeśli chodzi o wielowątkowość. Są jeszcze inne kolekcje w innych namespace’ach (starszych), jak np. SynchronizedCollection opisane niżej. Ale tych raczej powinniśmy unikać, chyba że naprawdę z jakiegoś powodu musimy tego użyć.
SynchronizedCollection<T>
Czuję się trochę w obowiązku wspomnieć o tym starym tworze. TEORETYCZNIE to coś w rodzaju Listy do scenariuszy wielowątkowych. Jednak jego „thread-safe” ogranicza się tylko do tego, że każda operacja jest zamknięta w instrukcji lock. A blokowany jest obiekt dostępny przez SyncRoot.
Ostatecznie może to prowadzić do problemów w sytuacjach wielowątkowych, ponieważ operacje nie są atomowe. Czyli jeśli przykładowo próbujesz wykonać dwie operacje, które zasadniczo logicznie są jedną, wtedy może to prowadzić do poważnych problemów. Przykład?
Tutaj po wywołaniu metody Contains, wątek może się przełączyć i ostatecznie możesz skończyć z kilkoma takimi samymi elementami w kolekcji.
ConcurrentBag<T>
O, to chyba zasługuje na osobny artykuł. Ale krótko mówiąc, to taka kolejka, która działa najlepiej we wzorcu Producer-Consumer. Słowo „kolejka” jest tutaj bardzo dobrym wyznacznikiem, bo ConcurrentBag to kolejka. Tyle że to nie jest to ani LIFO, ani FIFO – ot taka kolejka o nieokreślonej kolejności 🙂
Kilka wątków (A, B, C) może wkładać do niej elementy. Kolejka ma swoją własną listę elementów dla każdego wątku. Jeśli teraz pobieramy z niej elementy w wątku A, a ConcurrentBag nie ma elementów dla wątku A, wtedy „kradnie” element z puli innego wątku. Tak to mniej więcej można opisać. Więc najlepiej sprawdza się, gdy wątek często obsługuje własne zadania. Wtedy jest najbardziej efektywny.
W skrócie:
brak zachowania kolejności elementów
elementy umieszczone w różnych miejscach w pamięci
może zawierać duplikaty
brak dostępu do konkretnego elementu, można jedynie pobrać kolejny element z kolejki
ConcurrentStack<T>, ConcurrentQueue<T>
To zasadniczo są odpowiedniki zwykłego Stack i Queue, tyle że wątkowo bezpieczne. Tzn., że różne wątki mogą zapisywać i odczytywać elementy z kolejek w tym samym czasie. Oczywiście elementy będą zdejmowanie zgodnie z porządkiem kolejki.
W skrócie:
queue to kolejka FIFO, a stack to stos LIFO
elementy umieszczone w różnych miejscach w pamięci – w przeciwieństwie do jednowątkowych odpowiedników
może zawierać duplikaty
element jest automatycznie zdejmowany po odczytaniu go z kolejki (stosu)
BlockingCollection<T>
To też jest rodzaj kolejki, ale nie musi. W swojej domyślnej postaci, w środku ma ConcurrentQueue<T>. Podczas tworzenia można jej podać maksymalną ilość elementów. Wtedy, jeśli jakiś wątek będzie chciał dodać kolejny element (gdy już cała kolekcja jest zapełniona), wątek zostanie wstrzymany aż inny pobierze coś z kolekcji i zwolni miejsce. W drugą stronę zadziała to podobnie – jeśli wątek będzie chciał pobrać coś z pustej kolekcji, to będzie czekał.
Podczas tworzenia kolekcji można podać dowolny „kontener” dla itemów, który implementuje interfejs IProducerConsumerCollection<T>.
W skrócie:
elementy mogą być różnie umieszczone w zależności od przekazanego wewnętrznego kontenera – domyślnie jest to ConcurrentQueue
może zawierać duplikaty (jeśli wewnętrzny kontener na to pozwala)
brak dostępu do dowolnego elementu – można tylko ściągnąć aktualny element
UWAGA! Tutaj występuje jeszcze taka metoda jak CompleteAdding(). Wywołanie jej mówi kolekcji: „Słuchaj misiu, już nikt Ci nigdy niczego nie doda, a jeśli będzie próbował, zdziel go w twarz wyjątkiem”. To kwestia optymalizacyjna. Jeśli kolekcja jest oznaczona w ten sposób, wtedy wątek, który próbuje coś pobrać przy pustej kolekcji, nie będzie czekał, bo wie, że już nic nie zostanie dodane.
ConcurrentDictionary<K, T>
Nie ma co tu dużo mówić. To po prostu zwykły słownik przeznaczony do scenariuszy wielowątkowych. Różne wątki mogą jednocześnie zapisywać i odczytywać elementy. Poza tym niczym szczególnym nie różni się od zwykłego Dictionary<K, T>.
To tyle jeśli chodzi o standardowe kolekcje, których możemy używać. Dzięki za przeczytanie artykułu. Jeśli czegoś nie zrozumiałeś albo znalazłeś w tekście jakiś błąd, koniecznie daj znać w komentarzu.
A, no i koniecznie podziel się tym artykułem z osobami, które znają jedynie listę i słownik 😉
Rekordy weszły do C# w wersji 9. Obok struktur i klas stały się trzecim „ogólnym” typem. Chociaż są podobne zarówno do klas jak i struktur, to jednak dostarczają pewnych mechanizmów, które mogą przyspieszyć i ułatwić pisanie.
Zanim przeczytasz ten artykuł, powinieneś znać dokładne różnice między klasami i strukturami, stosem i stertą. Na szczęście opisałem je w tym artykule 🙂
Czym właściwie jest rekord?
Rekord to typ danych, który może zachowywać się tak jak struktura (być typem wartościowym) lub klasa (być typem referencyjnym). Jednak jasno musimy to określić podczas jego deklaracji. Rekordy rządzą się też pewnymi prawami. Gdy tworzymy rekord, kompilator dodaje automatycznie kilka elementów. O tym za chwilę. Najpierw spójrz jak rekordy się deklaruje.
Jest kilka możliwości deklaracji rekordu, najprostszą z nich jest:
public record MyRecord(int Id, string Name);
I tyle. Jedna linijka. Czy to nie jest piękne? Gdyby to przetłumaczyć na deklarację klasy, otrzymalibyśmy mniej więcej coś takiego:
public class MyRecord
{
public int Id { get; init; }
public string Name { get; init; }
public MyRecord(int id, string name)
{
Id = id;
Name = name;
}
public static bool operator==(MyRecord x, MyRecord y)
{
//o tym później
}
public static bool operator!=(MyRecord x, MyRecord y)
{
return !(x == y);
}
public override bool Equals(object? obj)
{
//o tym później
}
public void Deconstruct(out int id, out string name)
{
id = Id;
name = Name;
//o tym później
}
}
A to i tak tylko szkielet 🙂
Jak można z tego korzystać? Dokładnie tak samo jak z każdej innej klasy:
static void Main(string[] args)
{
var rec = new MyRecord(5, "Adam");
}
Jednak, jak wspomniałem wcześniej, rekord to coś więcej niż tylko cukier składniowy na klasę, czy strukturę. Gdy deklarujemy typ rekordowy, kompilator dokłada co nieco od siebie:
Porównanie wartościowe
Rekordy są automatycznie wyposażone w operatory ==, != i metodę Equals. Te elementy porównują dwa rekordy wartościowo. Tzn., że rekordy są takie same wtedy i tylko wtedy, gdy:
są tego samego typu
WARTOŚCI wszystkich właściwości są takie same.
Zwróć uwagę na słowo „WARTOŚCI”. Jeśli porównujesz ze sobą dwa obiekty, one domyślnie są porównywane referencyjnie. Czyli dwa obiekty będą takie same, jeśli wskazują na to samo miejsce w pamięci. Przy rekordach porównywane są domyślnie WARTOŚCI wszystkich pól.
Jednak pamiętaj, że to muszą być typy wartościowe. Jeśli rekord będzie zawierał klasę, to referencje obiektów tych klas zostaną ze sobą porównane (można powiedzieć, że wartością klasy jest referencja). Lepiej to wygląda na przykładzie. Porównajmy rekordy, które mają te same dane, których wartości można porównać:
var rec = new MyRecord(5, "Adam");
var rec2 = new MyRecord(5, "Adam");
Console.WriteLine(rec == rec2); // TRUE
Dla przykładu, jeśli to byłyby klasy, zostałyby porównane referencyjnie, a nie wartościowo:
var c1 = new MyClass //KLASA
{
Name = "Adam"
};
var c2 = new MyClass //KLASA
{
Name = "Adam"
};
Console.WriteLine(c1 == c2); // FALSE
Jednak gdy teraz dodamy klasę (typ referencyjny) do rekordu i będziemy chcieli porównać:
public record MyRecord(MyClass data);
//
var c1 = new MyClass
{
Name = "Adam"
};
var c2 = new MyClass
{
Name = "Adam"
};
var rec1 = new MyRecord(c1);
var rec2 = new MyRecord(c2);
Console.WriteLine(rec1 == rec2); //FALSE
No to obiekty c1 i c2 zostaną jednak porównanie referencyjnie i te dwa rekordy będą różne.
Czyli – 2 rekordy są takie same, gdy są tego samego typu i WARTOŚCI ich pól są takie same. W przypadku klasy, można powiedzieć, że jej wartością jest ADRES, na jaki wskazuje na stercie.
Niezmienialność (Immutability)
Rekordy są domyślnie niezmienialne (jest od tego wyjątek). Jeśli tak zadeklarujesz rekord:
public record Person(string Name);
to po utworzeniu obiektu tego rekordu nie będziesz mógł już go zmienić. Czyli wszystkie dane przekazujesz w konstruktorze i dalej nie można już niczego modyfikować.
Ale to nie dotyczy rekordów „strukturowych”, o których za chwilę.
Dekonstrukcja rekordów
Deklarując rekord, kompilator automatycznie tworzy też specjalną metodę Deconstruct, która w C# ma konkretne znaczenie. Służy do… dekonstrukcji obiektu 🙂 Dekonstrukcja jest znana z Tupli. O co chodzi? Spójrz na to:
public record struct Person(int Id, string FirstName, string LastName);
//
var p1 = new Person(1, "Adam", "Jachocki");
var (id, fName, lName) = p1;
Przyjrzyj się temu kodowi, bo jeśli nie słyszałeś o dekonstrukcji, to może wydawać się błędny. Ale jest zupełnie poprawny. Teraz wszystkie wartości z obiektu trafią do poszczególnych zmiennych: id, fName i lName. Osobiście bardzo uwielbiam dekonstrukcję.
Metoda ToString()
Rekordy mają również predefiniowaną metodę ToString, która zwraca wszystkie wartości w takiej postaci:
Person { Name = Adam, Age = 38 }
Różne rodzaje rekordów
Rekordy ze względu na swoją specyfikę, mogą zachowywać się na trzy różne sposoby. Chociaż wszystkie gwarantują porównywanie wartościowe i inne te rzeczy, o których pisałem wyżej, to jednak są pewne minimalne różnice.
Rekord klasowy (record)
Deklarujesz go w ten sposób:
public record Person(string Name);
//od C#10 możesz napisać również
//public record class Person(string Name);
Wyróżnia się tym, że jest tworzony na stercie. Jest domyślnie niezmienialny. Ma tylko jeden konstruktor.
Rekord strukturowy (record struct)
Deklarujesz go w taki sposób:
public record struct Person(string Name);
Wyróżnia się tym, że jest tworzony na stosie. Domyślnie jest zmienialny – możesz nadpisywać właściwości ile tylko chcesz. Ma też dwa konstruktory – ten z parametrami i domyślny – bezparametrowy.
Rekord strukturowy tylko do odczytu (readonly record struct)
Deklarujesz go tak:
public readonly record struct Person(string Name);
Wyróżnia się tym, że jest tworzony na stosie. Domyślnie jest NIEZMIENIALNY. Jednak kompilator tworzy dla niego dwa konstruktory – domyślny (bezparametrowy) i ten z parametrami.
Jeśli używasz konstruktora bezparametrowego, jedyną opcją na przypisanie wartości poszczególnym polom, jest przypisane podczas tworzenia obiektu:
var p = new Person { Name = "Adam" };
Dodatkowe pola w rekordzie
W rekordach możesz umieszczać dodatkowe pola i metody. Robisz to w sposób standardowy:
//Deklaracja
public readonly record struct Person(string Name)
{
public int Age { get; init; }
}
//użycie
var p1 = new Person("Adam") { Age = 38 };
Age jest zadeklarowane z setterm inicjującym. Dlatego możesz nadać mu wartość tylko w trakcie tworzenia obiektu.
Rekordy mogą mieć też normalne metody.
Dziedziczenie
Rekordy mogą dziedziczyć po innych rekordach. Ale nie mogą dziedziczyć po klasach, a klasy nie mogą dziedziczyć po rekordach. Dotyczy to jednak tylko rekordów klasowych. Rekordy strukturowe nie dziedziczą. Rekordy klasowe mogą też implementować interfejsy:
public interface IIface
{
void Foo();
}
public record Person(string Name);
public record Emloyee(int DeptId, string Name) : Person(Name), IIface
{
public void Foo()
{
throw new NotImplementedException();
}
}
Kopiowanie rekordów
Fajną sprawą jest kopiowanie rekordów. W bardzo łatwy sposób można stworzyć rekord na podstawie istniejącego. Służy do tego słowo with:
var p1 = new Person("Adam") { Age = 38 };
var p2 = p1 with { Name = "Janek" };
Teraz obiekt p2 będzie miał Name ustawiony na Janek, a Age na 38.
Pamiętaj jednak, że jeśli w rekordzie jest pole z klasą, to wtedy zostanie skopiowana referencja do tego obiektu, a nie jego wartości. Czyli mając taki program:
//deklaracja
public class MyClass
{
public int Id { get; set; }
}
public readonly record struct Person(string Name, MyClass Data)
{
public int Age { get; init; }
}
//użycie
var c1 = new MyClass { Id = 1 };
var p1 = new Person("Adam", c1) { Age = 38};
var p2 = p1 with { Name = "Janek" };
c1.Id = 10;
pamiętaj, że klasa w strukturach wskazuje na to samo miejsce na stercie. Czyli p2.Data.Id będzie również równe 10.
Kiedy używać rekordów
Z zasady, jeśli potrzebujesz danych, które są:
porównywane wartościowo
niezmienialne
Rekordy są idealnym kandydatem dla bindingu w kontrolerach. Chociaż ja głównie ich używam, jeśli mój serwis musi zwrócić do kontrolera więcej niż jedną wartość.
To tyle, jeśli chodzi o rekordy. Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz już znasz różnice między typami rekordów i, że będziesz ich używał bardziej świadomie. Albo w ogóle zaczniesz 🙂
Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu 🙂
W tym artykule wyjaśnię Ci, kiedy używać klasy, a kiedy struktury w C#. Granica czasem wydaje się bardzo rozmyta, ale są pewne zasady, które pomagają wybrać tę „lepszą” w danej sytuacji drogę. Zazwyczaj jednak jest to klasa.
Żeby lepiej zrozumieć różnice między nimi, muszę Cię zapoznać z takimi pojęciami jak stos, sterta, typ referencyjny i typ wartościowy. Jeśli znasz dobrze te zagadnienia, możesz przejść od razu na koniec artykułu.
Obszary w pamięci – stos i sterta
Pamięć programu jest zazwyczaj dzielona na stos i stertę. Wyobraź sobie to dokładnie w taki sposób. Możesz mieć w kuchni stos talerzy (jeden na drugim), a w pokoju stertę pierdół (rzeczy rozsypane w nieładzie wszędzie). Każdy z tych obszarów jest przeznaczony do różnych zastosowań.
Stos
Stos (ang. stack) przechowuje zmienne lokalne, parametry metody i wartości zwracane – generalnie dane krótko żyjące. To jest pewne uproszczenie, ale na potrzeby tego artykułu przyjmijmy to za pewnik.
Pamięć na stosie jest zarządzana automatycznie przez system operacyjny. Jest też mocno ograniczona (w zależności od tego systemu). Dlatego też, jeśli próbujesz na stosie umieścić zbyt wiele danych, dostaniesz błąd „przepełnienie stosu” (stack overflow). Z tego wynika, że możesz mieć ograniczoną ilość zmiennych lokalnych, czy też parametrów metody. Jednak w dzisiejszych czasach ten błąd sugeruje problem z rekurencją (czego wynikiem jest właśnie przepełnienie stosu).
Dane ze stosu są ściągane po wyjściu z jakiegoś zakresu – np. po wyjściu z metody lub jakiegoś bloku. No i wkładane są na stos podczas wchodzenia do bloku (metody).
Sterta
Sterta (ang. heap) jest dynamicznym obszarem pamięci. Nie ma takich limitów jak stos. Sam możesz decydować ile chcesz pamięci zarezerwować na stercie i co tam przechowywać. W językach bez pamięci zarządzalnej (C, C++, Delphi) sam musisz też troszczyć się o to, żeby usuwać dane ze sterty w odpowiednim momencie. W C# troszczy się o to zazwyczaj Garbage Collector (w specyficznych rozwiązaniach też programista musi się tym zająć).
Sterta jest przeznaczona do przechowywania danych długożyjących. A także takich, których rozmiar nie jest znany w momencie kompilacji programu.
Typ referencyjny i wartościowy
W C# mamy dwa rodzaje typów – referencyjne i wartościowe. Wszystkie typy proste (int, double, float, bool itd.) oraz struktury są typami wartościowymi. Typy referencyjne to klasy lub… referencje. Ale o tych drugich nie będziemy za bardzo tutaj mówić 🙂
Czyli podsumowując: klasy – typy referencyjne; struktury i typy proste – typy wartościowe (pamiętaj, że string nie jest typem prostym. Jego wartość jest przechowywana na stercie).
To rozróżnienie sprowadza się do kilku rzeczy.:
Struktury (typy wartościowe)
Przechowywane są w pamięci na stosie (to pewne uproszczenie, ale na razie potraktuj je za pewnik)
Automatycznie tworzone i zwalniane przez system operacyjny
Klasy (typy referencyjne)
Przechowywane w pamięci na stercie
Programista sam musi utworzyć obiekt takiego typu
Typ referencyjny jest zwalniany przez Garbage Collector (w C# programista bardzo rzadko musi taki typ sam zwolnić)
Ubrudźmy sobie ręce
Teraz z użyciem debuggera pokażę Ci dokładnie jak to wygląda od praktycznej strony w pamięci.
Spójrz na poniższy kod:
static void Main(string[] args)
{
int number = 5;
}
Na początku tworzymy zmienną typu int. A właściwie robi to za nas system operacyjny. To jest zmienna lokalna, a więc jest tworzona automatycznie na stosie. Co to oznacza? Że w momencie wejścia do metody Main system tworzy stos dla tego bloku* i dodaje do niego wszystkie zmienne lokalne i parametry metody. Stos w tym momencie wygląda tak:
A tak to widać w debugerze:
*Dla czepialskich, dociekliwych i maruderów
Stos nie jest tak naprawdę tworzony za każdym razem – to skrót myślowy. Istnieje pojęcie ramki stosu (stack frame). Ramka stosu to fragment pamięci wydzielony jako… „stos” dla danej metody, czy zakresu. Po wejściu/wyjściu z tej metody zmienia się aktualna ramka stosu na inną (a konkretnie rejestr procesora *SP wskazuje na inny obszar w pamięci jako początek stosu). Na stosie jest oczywiście więcej informacji niż te tutaj pokazane (m.in. parametr args), ale zostały pominięte ze względu na czytelność i nie mają związku z artykułem.
Jeśli nie wiesz, co tu widzisz, to już tłumaczę.
W górnym okienku jest uruchomiony program w trybie debugowania. W dolnym widzimy podgląd fragmentu pamięci programu. Zmienna number dostała konkretny adres w pamięci: 0x000000C4F657E52C. Jak widzisz, pod tym adresem znajdują się jakieś dane: 05 00 00 00. Te 4 bajty mają wartość 5. Widzimy też, że żeby zapisać zmienną typu int, potrzebujemy do tego 4 bajtów.
A teraz co się stanie, jeśli będziemy mieć dwie zmienne lokalne?
static void Main(string[] args)
{
int number = 5;
int score = 1;
}
Stos będzie wyglądał tak:
A w debugerze zobaczymy:
Jak widać z rysunku i debugera, stos rośnie „w górę”. Są na nim odkładane poszczególne wartości.
A jeśli teraz dodamy do tego strukturę?
struct Point3d
{
public int x;
public int y;
public int z;
}
internal class Program
{
static void Main(string[] args)
{
int number = 5;
int score = 1;
Point3d point;
point.x = 2;
point.y = 3;
point.z = 4;
}
}
Teraz stos wygląda tak:
Robi się gęsto. Na wierzchu stosu mamy wszystkie składowe struktury – x, y i z. Potem jakiś pusty element – tutaj oznaczony na niebiesko, a potem to co już było – zmienne score i number.
Z tego obrazka wynika kilka rzeczy:
Nie musisz tworzyć struktury przez new, bo skoro jest ona tworzona na stosie, to tworzy ją tam system operacyjny automatycznie – a o tym pisałem wcześniej – dane na stosie są tworzone automatycznie przez system operacyjny.
Struktura jest niczym innym jak workiem na dane.
Czym jest ten pusty, niebieski element? Padding – tylko dla dociekliwych
Spójrz na cztery ostatnie „niebieskie” bajty struktury: 00 00 00 00. Co reprezentują? Zupełnie nic. To jest tzw. padding. Padding to mechanizm, który polega na dopełnieniu wielkości struktury do „naturalnej” wielkości adresowej. Czyli wielokrotności 4 (32 bitowy system) lub 8 (64 bitowy system) bajtów. To znaczy, że domyślnie wielkość wszystkich struktur jest podzielna przez 4 lub 8. Padding niekoniecznie występuje na końcu struktury. Będzie raczej występował po konkretnych jej elementach. Np. strukturę:
struct MyStruct
{
public bool b;
public int i;
}
gdzieś tam na niskim pozimie zapisalibyśmy tak:
struct MyStruct
{
public bool b;
byte[3] _padding_b;
public int i;
}
To nie jest domena C#, to jest domena wszystkich znanych mi języków programowania.
Po co ten padding? Dzięki niemu pewne rzeczy mogą działać szybciej. Nie będę zagłębiał się w szczegóły, ale uwierz mi na słowo, że procesorowi łatwiej przenieść w inne miejsce 4 bajty niż 3.
Istnieje też w pewnym sensie nieco odwrotna operacja – pack. Powoduje ona usunięcie tego paddingu. To się przydaje gdy np. Twoja struktura lata pomiędzy bibliotekami dll pisanymi w różnych językach. Wtedy „pakowanie” ich znaaaaacznie ułatwia życie programiście.
Czym jest typ wartościowy?
Skoro już wiesz, że struktura i typy proste są typami wartościowymi, to teraz zadajmy sobie pytanie – czym do cholery są te typy wartościowe?
Typ wartościowy jest domyślnie przekazywany do metody przez wartość. Co to oznacza? Powinieneś już wiedzieć, jednak jeśli masz zaległości w temacie, już tłumaczę. Spójrz na poniższy kod:
static void Main(string[] args)
{
int number = 5;
Foo(number);
Console.WriteLine($"number: {number}");
}
static void Foo(int data)
{
data = 1;
}
Co tu się dzieje? Najpierw tworzymy zmienną lokalną number. Jak już wiesz, zmienna lokalna jest tworzona na stosie. Przed wejściem do metody Foo pamięć wygląda tak:
Teraz wchodzimy do metody Foo. I komputer widzi, że przekazujemy zmienną przez wartość, a więc najpierw przydziela miejsce dla parametru data, następnie KOPIUJE zawartość zmiennej number do zmiennej data. I tutaj „kopiuje” jest słowem kluczem. Po przypisaniu wartości data = 1 pamięć wygląda tak (patrzymy na większą część pamięci niż stos dla metody):
I teraz widzisz dokładnie, że w metodzie Foo pracujemy na kopii zmiennej number, a nie na zmiennej number. A właściwie na kopii jej wartości. Zmienna number cały czas „wskazuje” na zupełnie inny obszar w pamięci. Dlatego też po wyjściu z metody Foo zmienna number cały czas będzie miała warość 5.
Tak działa przekazywanie zmiennych przez wartość. I tak też są przekazywane struktury. Jaka jest tego konsekwencja? Jeśli masz strukturę składającą się z 10 zmiennych typu int (załóżmy 40 bajtów), to podczas wchodzenia do funkcji (lub wychodzenia z niej – jeśli dane zwracasz) te 40 bajtów jest kopiowane. W pewnych sytuacjach może się to odbić na szybkości działania programu.
Czym jest typ referencyjny?
Przekazywanie wartości przez referencję
Zacznę od tego, że w C# (i w innych językach też) możesz przekazać typ wartościowy za pomocą referencji. W C# robi się to za pomocą słowa ref lub out. Spójrz na ten przykład:
static void Main(string[] args)
{
int number = 5;
Foo(ref number);
Console.WriteLine($"number: {number}");
}
static void Foo(ref int data)
{
data = 1;
}
Dodałem tu jedynie słowa kluczowe ref, które mówią kompilatorowi, że tę wartość trzeba przekazać przez referencję. Co to znaczy? Że nie zostanie utworzona nowa zmienna w pamięci i nie zostaną skopiowane do niej dane. Po prostu komputer tak uruchomi metodę Foo:
Hej Foo, mam dla Ciebie dane w pamięci o adresie 0xblabla. Pracuj bezpośrednio na tych danych. Uruchom się.
I metoda Foo będzie pracować bezpośrednio na tych danych:
W konsekwencji pracujemy na tej samej pamięci, na którą wskazuje zmienna number. A więc zmienna number po wyjściu z metody Foo będzie miała już inną wartość.
Strukturę też możesz przekazać przez referencję dokładnie w taki sam sposób.
Typ referencyjny
No dobrze, dochodzimy wreszcie do tego, czym jest typ referencyjny. W C# każda klasa jest typem referencyjnym. Spójrz na ten kod:
class MyClass
{
public int Data { get; set; }
}
internal class Program
{
static void Main(string[] args)
{
MyClass obj;
}
}
Stos dla tego kodu będzie wyglądał tak:
Co widać w debugerze:
Obiekt tej klasy nie został utworzony. Klasa jest typem referencyjnym, a typy referencyjne nie są tworzone przez system operacyjny. Dlaczego? Ponieważ są to elementy „dynamiczne” – są tworzone w „dynamicznej” części pamięci – na stercie. Jak pisałem na początku, to programista odpowiada za tworzenie obiektów na stercie. I to programista musi przydzielić pamięć takiemu obiektowi. W C# robimy to za pomocą słowa new. Stwórzmy więc taki obiekt i zobaczmy co się stanie:
internal class Program
{
static void Main(string[] args)
{
MyClass obj = new MyClass();
obj.Data = 5;
}
}
Tworząc nowy obiekt najpierw musimy przydzielić mu miejsce na stercie. Takimi szczegółami zajmuje się już system operacyjny. Najpierw szuka odpowiedniego miejsca na stercie, które będzie na tyle duże, żeby pomieścić nasz obiekt, następnie rezerwuje tam pamięć dla niego, a na koniec adres do tej pamięci zwraca do zmiennej obj:
W debugerze stos teraz wygląda tak:
Tutaj zmienna obj jest na stosie i wskazuje na adres: 0x000001af16552cc0. A teraz spójrzmy, co pod tym adresem się znajduje:
Pod tym adresem znajdują się jakieś dane należące do tego konkretnego obiektu. Jest ich więcej niż tylko nasza zmienna int, ale nie jest istotne teraz co zawierają, istotne jest, że znajdują się na stercie.
Potem, gdy zmienna obj nie będzie już nigdzie używana, Garbage Collector zwolni to miejsce na stercie, które zajmuje ten obiekt.
Przekazanie obiektu do metody
Skoro klasy są typami referencyjnymi, to pewnie już się domyślasz, że obiekty są domyślnie przekazywane przez referencję. I dokładnie tak jest. Skoro zmienna obj posiada tylko adres, to ten adres zostanie przekazany do metody Foo:
Metoda Foo teraz wie, że dane ma pod konkretnym adresem. I na tych danych pracuje. Po wyjściu z metody Foo obj.Data będzie teraz miało wartość 1.
Dla dociekliwych – kopiowanie…
To nie jest tak, że w momencie wejścia do metody Foo nie jest robiona żadna kopia. Jest. Bo musi być. Bo metoda Foo ma własny stos i własne dane na tym stosie. A jedną z tych danych jest parametr data. Tyle, że ten parametr jest referencją, a więc przechowuje adres to obszaru na stercie. Zatem kopiowany jest tylko ten adres – te kilka bajtów. Niezależnie od tego jak wielka jest klasa i co w niej masz, jest tylko kopiowane te 8 bajtów (lub 4 w zależności od bitowości systemu operacyjngeo).
Użycie pamięci
Jak już wiesz, klasy są tworzone na stercie, a struktury na stosie.
Alokacja pamięci na stercie jest „droższa” od alokacji pamięci na stosie. Przede wszystkim trzeba znaleźć odpowiednio duży obszar. A na koniec Garbage Collector musi posprzątać. Jednak nie myśl o tym przy normalnym tworzeniu aplikacji – to zazwyczaj nie jest problemem. Chyba, że pracujesz nad naprawdę jakimś hiper krytycznym systemem. Ale wtedy nie czytałbyś tego artykułu 😉
Jednak pojawia się pewien mały szczegół podczas przekazywania typów wartościowych przez referencje. Ten szczegół to boxing i unboxing i jest pomysłem na inny artykuł. Generalnie chodzi o to, że typ wartościowy jest „opakowywany” w jakąś klasę, której obiekt jest tworzony na stercie. Później ten obiekt jest sprzątany przez Garbage Collector.
Jednak jeśli robisz dużo takich operacji (przekazywanie przez referencję), wtedy dużo dzieje się boxingów i unboxingów. To może mieć już wpływ na ogólną szybkość działania aplikacji.
Ogólnie przyjęte zasady
Z powyższych wynikają pewne ogólnie przyjęte zasady:
ZAWSZE STOSUJ KLASY – to jest obywatel lepszego sortu. Są jednak pewne wytyczne co do tego, kiedy bardziej opłaca stosować się struktury.
Rozważ (czytaj „możesz rozważyć) użycie struktury, gdy
struktura jest mała (poniżej 16 bajtów)
dane są niezmienne (przypisujesz tylko w konstruktorze i już ich nie zmieniasz)
nie będziesz przekazywał jej przez referencję
zawiera tylko typy proste
żyje krótko
reprezentuje „jedną fizyczną daną”, np. liczbę zespoloną, datę, punkt.
Ufff, ze trzy razy podchodziłem do tego artykułu. Mam nadzieję, że rozwiałem wątpliwości raz na zawsze, kiedy w C# stosować struktury, a kiedy klasy. Jeśli czegoś nie zrozumiałeś lub znalazłeś błąd w tekście, koniecznie daj znać w komentarzu.
Aha, no i udostępnij artykuł osobom, które ciągle się zastanawiają nad tematem 🙂
Kiedy tworzysz opcje do swojego programu, warto dodatkowo je walidować. Pewnie, że nie wszystkie. Jednak daje to większe poczucie spokoju, zwłaszcza kiedy aplikacja chodzi na różnych środowiskach (chociażby produkcyjne i deweloperskie). Jeśli ktoś przez pomyłkę źle skonfiguruje system, to nie będzie on działał poprawnie. Co więcej, przez długi czas możesz się o tym nie dowiedzieć. Niepoprawne opcje mogą przez dłuższy czas nie dawać o sobie znaku. Walidacja ich od razu może rozwalić program. Od razu będzie wiadomo, że coś jest nie tak.
W tym artykule pokażę Ci jak możesz walidować swoje opcje zarówno za pomocą adnotacji, jak i fluent validation.
.NET umożliwia Ci walidację opcji na kilka różnych sposobów. Możesz sprawdzać typowymi adnotacjami (DataAnnotations) w klasie modelu opcji. Pisałem już o tym w artykule o walidacji.
Załóżmy więc, że mamy taki prosty model opcji:
public class SimpleOptions
{
[EmailAddress]
public string SenderEmail { get; set; }
[Required]
public string SmtpAddress { get; set; }
}
Jak widać, walidujemy tutaj za pomocą adnotacji. Pole SenderEmail musi być adresem e-mail, natomiast pole SmtpAddress jest wymagane.
Teraz, żeby uruchomić walidację, trzeba nieco inaczej skonfigurować te opcje niż w sposób domyślny opisany w tym artykule. Teraz zamiast metody Configure, użyjemy AddOptions, które zwraca obiekt klasy OptionBuilder, który z kolei umożliwia walidacje:
Zauważ, że używając OptionBuildera, trzeba użyć metody Bind do powiązania tych opcji i na koniec ValidateDataAnnotations, co uruchomi walidację tych opcji, używając adnotacji. Tylko adnotacji. Pamiętaj o tym.
Teraz, jeśli jakieś opcje nie będą spełniały założeń, podczas ich wstrzykiwania pójdzie wyjątek. Np. spójrz na taki appsettings.json:
Jak widzisz, nie ma tutaj w ogóle pola SmtpAddress, które jest wymagane w naszym modelu. Teraz, jeśli chcielibyśmy takie opcje odczytać np.:
[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
private readonly SimpleOptions _simpleOptions;
public TestController(IOptions<SimpleOptions> simpleOptions)
{
_simpleOptions = simpleOptions.Value;
}
}
to w linijce 8 dostaniemy wyjątek. Takich opcji nie można pobrać, bo nie spełniają warunków.
Problemem jest to, że program musi dojść do tego miejsca, żeby opcje zostały zwalidowane. Na szczęście w .NET6 można sprawdzić konkretne opcje już podczas uruchamiania aplikacji, co jest naprawdę mega użyteczne. Piszę o tym później.
Oczywiście sam możesz pisać własne atrybuty walidacyjne, o czym pisałem tutaj. Wystarczy napisać klasę dziedziczącą po ValidationAttribute.
To prosta walidacja. Nie można za jej pomocą zrobić bardziej wyrafinowanych sprawdzeń. A jeśli można to jest to uciążliwe. Dlatego dla takich scenariuszy przychodzi kolejna możliwość…
Własny walidator
Wystarczy stworzyć własny walidator – klasę, która implementuje interfejs IValidateOptions. Nic nie stoi na przeszkodzie, żeby Twój model ten interfejs implementował, jednak z punktu widzenia czystości kodu, to nie jest dobre rozwiązanie. Pamiętaj o tym.
Stworzę zatem osobną klasę, która będzie walidować taki model:
public class ApiOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string ClientUri { get; set; }
}
Te przykładowe opcje pozwalają się łączyć z hipotetycznym API. Założenie jest takie, że albo podajemy ClientId i ClientSecret (który musi być odpowiedniej długości), albo podajemy ClientUri. Napiszmy teraz walidator do tego. Zacznijmy od pustego:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
}
}
Jak widzisz, interfejs IValidateOptions posiada tylko jedną metodę do implementacji. W parametrze name dostaniesz nazwę tych opcji, jeśli używasz named options. Natomiast w parametrze options otrzymasz cały model odczytany z konfiguracji. I teraz możesz go sprawdzić np. w taki najprostszy sposób:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
bool isIdAndSecret = IsIdAndSecret(options);
bool isUri = IsUri(options);
if (isIdAndSecret && isUri)
return ValidateOptionsResult.Fail("Nie możesz jednocześnie podać ClientUri i sekretów");
if (!isIdAndSecret && !isUri)
return ValidateOptionsResult.Fail("Musisz podać jakieś dane do połączenia z API");
if (isIdAndSecret && options.ClientSecret.Length < 5)
return ValidateOptionsResult.Fail("Client secret jest za krótki");
return ValidateOptionsResult.Success;
}
private bool IsIdAndSecret(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
}
private bool IsUri(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientUri);
}
}
Po prostu wykonujemy kilka prostych sprawdzeń i albo zwracamy ValidateOptionsResult.Success, albo Fail. W przypadku, jeśli walidacja się nie powiedzie, zachowanie będzie identyczne jak przy walidacji adnotacjami. Program się wywali na próbie pobrania opcji.
Teraz tylko trzeba to zarejestrować w nieco inny sposób. Możemy posłużyć się zarówno OptionsBuilderem jak i konfiguracją, jednak trzeba dodatkowo zarejestrować takiego walidatora:
W .NET8 wprowadzono budowniczego ValidateOptionsResultBuilder, którym możesz sobie zbudować cały rezultat jeśli chcesz. Dzięki temu możesz zwrócić kilka błędów. Powyższy kod mógłby wyglądać tak:
public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
public ValidateOptionsResult Validate(string? name, ApiOptions options)
{
bool isIdAndSecret = IsIdAndSecret(options);
bool isUri = IsUri(options);
ValidateOptionsResultBuilder builder = new();
if (isIdAndSecret && isUri)
builder.AddError("Nie możesz jednocześnie podać ClientUri i sekretów");
if (!isIdAndSecret && !isUri)
builder.AddError("Musisz podać jakieś dane do połączenia z API");
if (isIdAndSecret && options.ClientSecret.Length < 5)
builder.AddResult(ValidateOptionsResult.Fail("Client secret jest za krótki"));
return builder.Build();
}
private bool IsIdAndSecret(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
}
private bool IsUri(ApiOptions options)
{
return !string.IsNullOrWhiteSpace(options.ClientUri);
}
}
Zaznaczyłem znaczące linie. Jak widzisz, możesz do buildera dodawać zarówno errory jak i całe obiekty ValidateOptionsResult.
Dzięki temu możesz pokazać wszystkie problemy związane z opcjami, a nie tylko jeden.
Walidacja w OptionsBuilderze
Ten sposób walidacji zostawiam raczej jako ciekawostkę. W małych systemach pewnie się sprawdzi, natomiast w większych lepiej go unikać.
Można napisać kod walidacyjny podczas rejestrowania opcji:
To tylko fragment wcześniejszej walidacji. Musiałbym napisać resztę przypadków, ale to nie ma sensu (bo to tylko przykład). Tutaj metoda Validate przyjmuje delegat – funkcję, która zwraca bool, a w parametrze ma model opcji.
Dlaczego to nie ma sensu? To chyba widać. W przypadku większej ilości opcji lub bardziej wyrafinowanych walidacji w kodzie po prostu zrobi się burdel i całość stanie się mało czytelna.
Tak jak mówiłem wcześniej – w małych, szybkich projektach to się może sprawdzić. Natomiast w większych raczej nie.
Walidacja przy starcie systemu
Domyślny mechanizm będzie walidował opcje dopiero w momencie próby ich pobrania: var myOptions = options.Value. Natomiast możesz sobie życzyć, żeby opcje były sprawdzane podczas uruchamiania programu. Plusem tego jest to, że od razu dowiesz się, że coś jest nie tak, bo apka wywali się podczas uruchamiania. Minus? Aplikacja będzie potrzebować nieco więcej czasu, żeby się uruchomić, ponieważ będzie sprawdzać opcje, które wskażesz. Myślę jednak, że warto to zrobić, bo od razu dostajesz wiedzę, że coś jest źle skonfigurowane.
Wystarczy, że wywołasz metodę ValidateOnStart z OptionsBuilder:
Pamiętaj, że metoda ValidateOnStart doszła dopiero w .NET6. Dlatego jeśli masz projekt we wcześniejszej wersji przemyśl migrację do .NET6.
Walidacja typu FLUENT
Na koniec pokażę jak zaprząc do walidacji opcji znaną i lubianą bibliotekę open source – FluentValidation. Od razu zaznaczam, że ten artykuł nie jest kursem ani nawet nie muska działania tej biblioteki. Jeśli wiesz, co ona robi, ten akapit może Ci się przydać. W innym przypadku spróbuj się z nią najpierw zapoznać.
FluentValidation umożliwia walidacje w sposób „fluent” modeli. Jednak standardowo nie obsługuje opcji. Można to w dość prosty sposób zmienić.
Spójrzmy na przykładowy model opcji:
public class FluentApiOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string ClientUri { get; set; }
public string ApiUrl { get; set; }
}
Założenia będą takie jak w poprzednim przykładzie, tzn. podajemy albo clientId i secret, albo clientUri. Jedno z dwóch. Dodatkowo zawsze musi być ApiUrl.
Walidator Fluenta
Napiszmy do tego standardowy walidator fluentowy:
public class FluentApiOptionsValidator: AbstractValidator<FluentApiOptions>
{
public FluentApiOptionsValidator()
{
RuleFor(x => x.ApiUrl)
.NotEmpty();
RuleFor(x => x.ClientUri)
.NotEmpty()
.When(x => string.IsNullOrWhiteSpace(x.ClientId) && string.IsNullOrWhiteSpace(x.ClientSecret))
.WithMessage("Jeśli nie podajesz clientId i sekretu, musisz podać ClientUri")
.MinimumLength(5)
.WithMessage("ClientUri jest za krótkie");
RuleFor(x => x.ClientId)
.NotEmpty()
.When(x => string.IsNullOrWhiteSpace(x.ClientUri))
.WithMessage("Musisz podać ClientId i sekret, jeśli nie podajesz ClientUri");
RuleFor(x => x.ClientSecret)
.NotEmpty()
.When(x => !string.IsNullOrWhiteSpace(x.ClientId))
.WithMessage("Brak client secret");
}
}
I dopiero teraz zacznie się zabawa.
Mając już walidator do konkretnego modelu, musimy teraz stworzyć swój własny walidator opcji – ten, implementujący interfejs IValidateOptions. Dlaczego?
Integracja z opcjami
Jak już mówiłem, FluentValidation nie jest domyślnie zintegrowany z mechanizmem opcji w .NET. A szkoda, bo mógłby być. Zatem sami musimy sobie taką integrację zapewnić. I tutaj przychodzi z pomocą IValidateOptions. Utworzymy generyczny walidator, żeby można go było używać z każdym typem opcji. To w najprostszej postaci może wyglądać tak:
public class GenericFluentValidator<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IServiceProvider _serviceProvider;
private readonly string _name;
public GenericFluentValidator(string name, IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_name = name;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (_name != null && _name != name)
return ValidateOptionsResult.Skip;
using var scope = _serviceProvider.CreateScope();
var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();
ValidationResult res = validator.Validate(options);
if (res.IsValid)
return ValidateOptionsResult.Success;
var errorArray = res.Errors.Select(e => e.ErrorMessage).ToArray();
var msg = string.Join(Environment.NewLine, errorArray);
return ValidateOptionsResult.Fail(msg);
}
}
To na pierwszy rzut oka może okazać się zawiłe, ale jest naprawdę bardzo proste.
Pomińmy na razie konstruktor – w jakiś sposób dostaniemy IServiceProvider i nazwę (jeśli używamy named options). Przejdźmy od razu do metody Validate.
Najpierw sprawdzamy, czy używamy named options i czy to odpowiedni do tego walidator. Jeśli nie, to olewamy.
Następnie tworzymy sobie scope’a, żeby pobrać z niego serwis implementujący IValidator<TOptions>. A teraz pytanie – co to takiego? Interfejs IValidator<T> pochodzi z FluentValidation. Wszystkie walidatory ten interfejs implementują. A więc po prostu szukamy walidatora dla konkretnego typu.
Gdy już mamy go, to w linijce 21 uruchamiamy walidację. Jeśli się udała, zwracamy sukces. Jeśli nie, zwracamy listę błędów z tego walidatora w postaci jednego stringa.
Rejestracja
Teraz, jak już wiesz, trzeba zarejestrować ten GenericFluentValidator:
builder.Services.AddSingleton<IValidateOptions<FluentApiOptions>>(sp =>
{
return new GenericFluentValidator<FluentApiOptions>("", sp);
});
Po prostu dodajemy go tak jak w poprzednich przykładach, tyle że z wykorzystaniem fabryki – dzięki czemu możemy przekazać IServiceProvidera. Parametrem name na razie się nie przejmuj, zajmiemy się nim później.
Na koniec wystarczy zarejestrować konkretny walidator modelu:
No i wszystko śmiga. Ale przyznasz, że żeby ogarnąć jeden model w taki sposób, trzeba się sporo napisać. Właściwie upierdliwe są te rejestracje. Ale jest na to metoda…
Upraszczamy rejestracje
Posłużymy się extensioniem, żeby ułatwić sobie pracę. Całą rejestrację przeniesiemy do extensiona. Przy okazji załatwi nam to problem named options:
public static class OptionsBuilderExtensions
{
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions, TValidator>(this OptionsBuilder<TOptions> builder)
where TOptions : class
where TValidator: class, IValidator<TOptions>
{
builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
{
return new GenericFluentValidator<TOptions>(builder.Name, sp);
});
builder.Services.AddSingleton<IValidator<TOptions>, TValidator>();
return builder;
}
}
Dzięki takiemu rozwiązaniu, model opcji możemy zarejestrować w taki sposób:
W tym pakiecie znajdują się metody, które automatycznie rejestrują wszystkie walidatory ze wskazanego assembly. Więc teraz wystarczy uprościć nasze rozszerzenie i wywalić z niego rejestrację walidatora:
public static class OptionsBuilderExtensions
{
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(this OptionsBuilder<TOptions> builder)
where TOptions : class
{
builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
{
return new GenericFluentValidator<TOptions>(builder.Name, sp);
});
return builder;
}
}
I tutaj rodzi się pytanie, czy walidowanie opcji za pomocą FluentValidation ma sens i czy nie jest to przerost formy nad treścią. Jak zwykle – to zależy. Jeśli szybciej/lepiej pracuje Ci się z FluentValidation i widzisz zysk w takim sprawdzaniu, zamiast pisać własny kod walidujący, to na pewno ma to sens, a czas włożony w konfigurację tego ustrojstwa szybko się zwróci. Zwłaszcza, że jest już sporo gotowych walidatorów na „dzień dobry”. A jak widzisz, konfiguracja nie jest aż taka straszna.
Nowości w .NET8
Walidacja opcji bez użycia refleksji – zgodność z AOT
.NET8 przynosi pewną, małą nowość. Kod, który używa refleksji (na przykład ten standardowy sposób walidacji powyżej), nie jest zgodny z AOT. Dlatego też nie moglibyśmy używać walidacji opcji w kompilacji AOT.
Możemy napisać częściowego walidatora, którego kod zostanie wygenerowany automagicznie i ten kod nie będzie już używał refleksji.
Brzmi jak kupa roboty? Może i tak, ale spójrz na to:
public class MyAppConfig
{
[EmailAddress]
[Required]
public string SmtpAdress { get; set; }
[Range(1, 10)]
public int TraceLevel { get; set; }
}
To jest model, który będziemy walidować. A walidator będzie wyglądał tak:
[OptionsValidator]
public partial class MyAppConfigValidator: IValidateOptions<MyAppConfig>
{
}
I to jest dokładnie tyle. Dobrze widzisz. Tutaj są istotne dwie rzeczy:
klasa musi być oznaczona jako partial
klasa musi posiadać atrybut [OptionsValidator]
W innym wypadku po prostu się nawet nie skompiluje.
W efekcie zostanie wygenerowany kod dla klasy MyAppConfigValidator, który będzie miał zaszytą całą logikę walidacji w sobie. I to wszystko zadzieje się bez wykorzystania refleksji. Dzięki temu możesz tego używać w kompilacjach AOT.
Bindowanie opcji bez użycia refleksji – zgodność z AOT
Jeśli chodzi o rejestracje opcji, tj. Configure(TOptions), Bind i Get, to standardowo była do tego wykorzystywana refleksja. W .NET8 w aplikacjach internetowych domyślnie konfiguracja jest realizowana przez generator kodu. Czyli jest to zgodne z AOT i nie wymaga żadnych zmian.
Jeśli jednak chcesz być zgodny z AOT i nie tworzysz aplikacji webowej, musisz na takie działanie jawnie wyrazić zgodę. Wystarczy dodać ustawienie w projekcie:
A czy ty walidujesz swoje opcje? Daj znać w komentarzu 🙂
Dziękuję Ci za przeczytanie tego artykułu. Wierzę, że walidacja opcji stała się dla Ciebie jasna i będziesz jej używać. Jeśli znalazłeś w artykule jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
No i podziel się tym artykułem z kimś, komu uważasz że się przyda 🙂
Witaj podróżniku. Skoro już tu jesteś, prawdopodobnie wiesz czym jest EntityFramework. Jeśli jednak nie, to przeczytaj kolejny akapit. W tym artykule postaram szybko wprowadzić Cię w jego świat, żebyś w ciągu kilku minut był w stanie zacząć z nim wspólną przygodę.
Czym jest Entity Framework? – rozwiń jeśli nie wiesz
EntityFramework Core zwany również EfCore to narzędzie typu ORM – Object Relational Mapper. Prostymi słowami to mechanizm, który umie zamienić (mapować) rekordy znajdujące się w bazie danych, na obiekty modelowe. Po co to? To jest zwykły „pomagacz”. Bo oczywiście możesz napisać w starym dobrym ADO.NET kod w stylu:
//pseudokod
var resultList = new List<Employee>();
string sql = "SELECT * from employees";
var reader = _db.OpenQuery(sql);
while(reader.Read())
{
var employee = new Employee();
employee.Id = reader.GetGuid("id");
employee.Name = reader.GetString("name");
employee.Salary = reader.GetDecimal("salary");
resultList.Add(employee);
reader.Next();
}
return resultList;
ale ostatecznie większość programistów, pisząc w ADO.NET i tak kończyła z jakąś ubogą formą własnego ORMa.
Dlatego powstały właśnie takie narzędzia, żeby ułatwić pracę. Programista wie jak ma pobrać dane i wie jaki obiekt chce zwrócić. Nie musi się zajmować czarną robotą w stylu mapowania tak jak wyżej.
Jest sporo różnych ORMów na rynku. Myślę, że najpopularniejsze z nich (przynajmniej w mojej subiektywnej ocenie) to Dapper, nHibernate i właśnie EfCore. Przy czym Dapper zalicza się do grupy tzw. micro orm. Dlatego, że nie potrafi tyle, co EfCore, czy nHibernate. Wciąż musisz pisać własnego SQLa, ale za to dostajesz obiekt już elegancko zmapowany. No i Dapper jest zdecydowanie szybszy.
Kwestia nazwy
Pewnie zobaczysz nie raz obok siebie nazwy w stylu „Entity Framework”, „EfCore”, „Entity Framework Core”. Czy to jest ten sam produkt? Nie do końca. Jeśli chodzi o „Entity Framework” jest on używany w .NET Framework (czyli w tej starszej technologii przed .NET Core). Natomiast „EfCore”, czy po prostu „Entity Framework Core” to wersja dla .Net Core i nowszych. Czasem możesz widzieć te nazwy zamiennie. W tym artykule, pisząc „Ef”, „Entity Framework” zawsze będę miał na myśli „EfCore”.
Kwestia prędkości
Oczywiście, że ORMy są wolniejsze niż ręczne mapowanie. Jednak to zwolnienie w większości przypadków jest niezauważalne. Jeśli jednak faktycznie w niektórych miejscach potrzebujesz większej szybkości, możesz tylko te miejsca traktować inaczej – bez ORMa.
Instalacja narzędzi EfCore
Na początek sprawdź, czy masz już zainstalowane narzędzia do EF. Wklep taką komendę do command line’a:
dotnet ef --version
Jeśli Ci się wykrzaczyło, znaczy że nie masz. W takim przypadku zainstaluj:
dotnet tool install --global dotnet-ef
Powyższe polecenie zainstaluje globalnie narzędzia do EF, a poniższe zaktualizuje do najnowszej wersji:
dotnet tool update --global dotnet-ef
Przykładowa aplikacja
Jak zwykle, przygotowałem małą przykładową aplikację do tego artykułu. Możesz ją pobrać z GitHuba.
Aplikacja to prosty programik w stylu ToDoList. W ramach ćwiczeń polecam Ci go wykończyć.
Wymagane NuGety:
EfCore nie jest standardową biblioteką. Musisz pobrać sobie minimum dwa NuGety:
Microsoft.EntityFrameworkCore – cały silnik dla EfCore
Microsoft.EntityFrameworkCore.Design – to jest zestaw narzędzi potrzebny programiście podczas „projektowania” aplikacji. Są to narzędzia, które np. pomagają utworzyć migracje (o tym później). Nie są jednak dostępne w czasie działania aplikacji (runtime). Więc jeśli nie będziesz robił migracji, nie musisz tego instalować. Ale lepiej mieć, niż nie mieć.
Biblioteka do obsługi konkretnego typu bazy danych (DBMS) – inna jest dla MSSQL, inna dla Sqlite, inna dla Oracle itd. Ich nazwy określają do jakiej bazy danych się odnoszą, np: Microsoft.EntityFrameworkCore.SqlServer – do obsługi MSSQL.
Modele bazodanowe
Na początek stwórzmy sobie podstawowy model bazodanowy do aplikacji.
public enum ToDoItemStatus
{
NotStarted,
Running,
Done
}
public class ToDoItem: BaseDbItem
{
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
Mamy tutaj zwykły ToDoItem, który może być w 3 stanach:
Nierozpoczęty (NotRunning)
Rozpoczęty (Running)
Zakończony (Done)
Zauważysz też, że mój model dziedziczy po BaseDbItem. To zwykła klasa abstrakcyjna, która zawiera Id:
public abstract class BaseDbItem
{
public Guid Id { get; set; }
}
Oczywiście nie musisz jej tworzyć i możesz umieścić w każdym modelu pole Id. Ja jednak wolę to zrobić, bo to pomaga i może uprościć wiele rzeczy w późniejszym utrzymaniu takiego systemu. Dlatego też zachęcam Cię, żebyś stosował taką klasę bazową.
Kontekst bazy danych
To jest najważniejszy element EfCore. Technicznie to klasa, która dziedziczy po DbContext. Odzwierciedla całą zawartość bazy danych – wszystkie tabele, które chcesz mieć. Dlatego też musisz napisać taką klasę. W swojej najprostszej i NIEWYSTARCZAJĄCEJ postaci może wyglądać tak:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
}
Właściwie to zbiór właściwości typu DbSet. DbSet odnosi się do tabeli w bazie danych. Dlatego też dla każdej tabeli będziesz miał osobne właściwości DbSet. Stwórzmy zatem drugi model – użytkownik. Dodamy też od razu go do kontekstu:
public class User: BaseDbItem
{
public string Name { get; set; }
}
//dodajmy go też jako właściciela konkretnego ToDoItem:
public class ToDoItem: BaseDbItem
{
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
//no i do kontekstu:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
public DbSet<User> Users { get; set; }
}
Połączenie z bazą danych
Mając stworzony DbContext, możemy teraz utworzyć połączenie do bazy danych. Sprowadza się to do trzech kroków:
Przechowywanie ConnectionString
ConnectionString do bazy będzie przechowywany oczywiście w ustawieniach – appsettings. Ja trzymam w głównym pliku: appsettings.json zamiast appsettings.Development.json, ponieważ jest mi prościej (za chwilę dowiesz się dlaczego).
Pamiętaj, że ten plik ląduje w repozytorium na serwerze, więc jeśli umieszczasz tam jakieś hasło, to pamiętaj, że to zła praktyka i powinieneś posłużyć się sekretami. W przeciwnym razie każdy z dostępem do repozytorium zobaczy Twoje hasło.
To teraz jak utworzyć ConnectionString do bazy lokalnej? Bardzo prosto. Najpierw otwórz sobie okienko SQL Server Object Explorer:
To okienko pozwala Ci zarządzać lokalnymi bazami. Teraz odnajdź bazę master na lokalnym serwerze i wejdź w jej właściwości (ważne, żebyś rozwinął bazę master. Jeśli jej nie rozwiniesz, nie zobaczysz connection stringa):
Z właściwości możesz odczytać connection stringa:
Skopiuj go i wklej do ustawień w pliku appsettings.json lub w secrets.json:
Teraz zwróć uwagę, że w connection string jest zaszyta nazwa bazy – master. Dlatego też powinieneś ją zmienić na swoją docelową bazę. Ta baza nie musi istnieć, EfCore sam ją sobie utworzy, ale zmień tą nazwę. Nigdy nie pracuj na bazie master (traktuj ją tylko jako do odczytu):
Musimy teraz w jakiś sposób powiedzieć EfCore’owi z jaką bazą chcemy się łączyć. Robimy to poprzez kontekst bazy danych. Wystarczy oprogramować konkretną wersję konstruktora w taki sposób:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
public DbSet<User> Users { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
I to w zasadzie wystarczy. Oczywiście nic Ci nie broni, żebyś w tym konstruktorze posiadał jakiś kod. Ale to w domyślnym mechanizmie wystarczy.
Pamiętaj tylko, że jeśli używasz EfCore z dependency injection, stwórz konstruktor z generyczną wersją DbContextOptions – tak jak wyżej: DbContextOptions<ApplicationDbContext> zamiast samego DbContextOptions.
Dlaczego? Jeśli masz w aplikacji jeden DbContext, to nie ma żadnej różnicy. Jednak, jeśli masz ich kilka, to użycie generycznej wersji DbContextOptions<T> zapewni, że do odpowiednich kontekstów zawsze trafią odpowiednie opcje. W przeciwnym razie dependency injection może coś pochrzanić.
Więc lepiej wyrób sobie taki nawyk.
Teraz dwa słowa wyjaśnienia. EfCore działa w taki sposób, że to kontekst bazy danych musi wiedzieć z jaką bazą się łączy. I ten connection string przekazuje się w konstruktorze. Jednak nie możesz go tam zahardkodować, bo to zła praktyka z punktu widzenia bezpieczeństwa i utrzymania takiego kodu.
Oczywiście mógłbyś posłużyć się mechanizmem konfiguracji i wstrzyknąć tutaj jakieś IOptions. Ale to już jest domyślnie. Ten domyślny sposób korzysta z tego przeciążenia konstruktora – wstrzykuje do kontekstu odpowiednie opcje.
Podczas rejestracji kontekstu, rejestrowane są też jego opcje (DbContextOptions), w którym connection string jest już obecny. Jeśli dobrze zarejestrujemy 🙂
Rejestracja kontekstu bazy danych
Kontekst rejestrujemy tak jak każdy inny serwis, dodatkowo podając connection stringa i mówiąc mu, jakiej bazy chcemy użyć.
var config = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(o =>
{
o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
});
Uwaga! GetConnectionString to metoda pomocnicza, która pobiera sekcję z ustawień aplikacji o nazwie „ConnectionStrings”. Więc jeśli Twoja sekcja nazywa się inaczej lub w innym miejscu masz tego connection stringa, wtedy posłuż się standardowym wywołaniem w stylu config[„MojaSekcja:Connection”].
Pamiętaj, że UseSqlServer to extension method pochodzące z NuGet: Microsoft.EntityFrameworkCore.SqlServer. Jeśli zainstalujesz inny NuGet, np. do obsługi SQLite, wtedy będziesz używał metody UseSqlite. Analogicznie z innymi bazami danych.
Inne sposoby
Są też inne prawilne sposoby rejestracji takiego kontekstu. Np. za pomocą DbContextFactory, o którym za chwilę.
Czym są migracje w EfCore?
Nieodłącznym mechanizmem EfCore są migracje. EfCore jest w stanie sam utworzyć bazę danych. Co więcej, jest w stanie pilnować, żeby struktura bazy danych odpowiadała strukturze w Twoich modelach (choć pamiętaj, że świat relacyjny i obiektowy to zupełnie dwa różne miejsca). Co więcej, od jakiegoś czasu EfCore potrafi też stworzyć modele na podstawie tabel istniejących w bazie danych.
Na pewno spotkałeś się z pojęciami Code First i Database First. To właśnie opisuje sposób tworzenia bazy danych. Podejście Code First tworzy bazę danych z istniejących modeli obiektowych. Database First z kolei na odwrót – tworzy modele obiektowe na podstawie struktury bazy danych. W EfCore jeszcze do niedawna było możliwe użycie jedynie podejścia CodeFirst.
EfCore, aby to wszystko było możliwe, posługuje się migracjami. Z technicznego punktu widzenia, migracja to zwykła klasa, która posiada przepis na utworzenie konkretnej wersji bazy danych.
Gdy zmieniasz swój model i uznajesz go za pewną skończoną wersję, powinieneś zrobić migrację. Robi się to za pomocą narzędzi EfCore. Wystarczy, że w konsoli przejdziesz do folderu, w którym jest Twój projekt i wpiszesz tam takie polecenie:
dotnet ef migrations add "InitialDbCreate"
InitialDbCreate to oczywiście nazwa Twojej migracji. Ta jest standardową nazwą na pierwszą migrację. Poza tym przyjęło się, że nazwa migracji powinna mówić o tym, co migracja zawiera, np. „AddedClientName” albo „RemovedOwnerModel” itd.
Wykonanie tego polecenia stworzy folder Migrations i dwa pliki w środku. Pierwszy to migracja (klasa dziedzicząca po Migration), a drugi to snapshot aktualnej wersji – powstaje na podstawie migracji. Jeśli chcesz od nowa zbudować bazę danych, po prostu możesz usunąć cały folder i bazę danych.
Mając taką migrację, możesz teraz uaktualnić / stworzyć bazę danych poleceniem:
dotnet ef database update
To polecenie po pierwsze połączy się z bazą danych, a po drugie utworzy lub odczyta specjalną tabelę, w której są zapisane nazwy migracji, z których powstaje baza danych. Patrzy na ostatni wpis i wykonuje kolejną migrację – aktualizuje strukturę bazy danych.
Oczywiście dokładnie tak samo, jak tworzyłbyś skrypty SQL aktualizujące strukturę – tutaj też to może się nie udać. Przykładowo, jeśli istnieją rekordy i jakaś kolumna ma wartości NULL, a teraz chcesz zrobić, żeby ta kolumna nie mogła być NULLem, to się nie uda. Więc trzeba na takie rzeczy zwracać uwagę.
Jak to działa?
Zarówno tworzenie migracji jak i aktualizacja bazy uruchamia Twoją aplikację. Najpierw apka jest budowana (choć to można pominąć, dodając do polecenia argument --no-build, ale uważaj na to, bo może się okazać że migracja nie zostanie przeprowadzona), potem uruchamiana w specjalny sposób. I tutaj może pojawić się problem z odczytem connection stringa. W .NET6 narzędzia szukają connection stringa na produkcyjnej wersji środowiska (chyba że masz globalnie ustawioną zmienną środowiskową ASPNET_ENVIRONMENT z wartością Development) – dlatego wcześniej pisałem o tym, że dla ułatwienia connection string trzymany jest w głównym appsettings.json (lub secrets.json).
Pewnie można to jakoś zmienić, ale jeszcze nie rozkminiłem jak 🙂
Konfiguracja EfCore
EfCore jest na tyle „mądry”, że dużo potrafi wywnioskować na temat modeli będących w DbContext. I tak naprawdę jeśli w ogóle nie skonfigurujesz swoich modeli, to on i tak rozkimi, że pole o nazwie Id jest kluczem głównym; że pole OwnerId jest jakimś kluczem obcym. Co więcej nawet jak zobaczy właściwość wskazującą na inny obiekt, też stworzy z tego relację.
Jednak nie wszystko jest w stanie wydedukować. Poza tym mamy w naszym przypadku jeden poważny problem. Kolumna Status w tabeli ToDoItems domyślnie została utworzona jako int (EfCore tak domyślnie mapuje enumy). Czy to dobrze? Zdecydowanie nie. W tym momencie mamy możliwe 3 wartości:
public enum ToDoItemStatus
{
NotStarted,
Running,
Done
}
NotStarted zostało zmapowane do 0, Running to 1, a Done to 2. Fajnie. A jak ktoś dołoży kolejną?
public enum ToDoItemStatus
{
NotStarted,
Running,
Paused,
Done
}
Wtedy w bazie wszystko się pop…rzy. Bo nagle Paused przyjmie wartość 2, a Done 3. Z bazy będzie wynikało, że żadne zadanie nie zostało ukończone. Za to te, co były ukończone będą uważane za wstrzymane. Dlatego mimo wszystko lepiej enumy trzymać jako string.
To wszystko i wiele więcej możemy uzyskać dzięki konfiguracji modeli. Konfigurację można przeprowadzić na dwa sposoby:
za pomocą adnotacji
używając fluentowej konfiguracji
Osobiście jestem zwolennikiem tej drugiej, bo dzięki temu modele bazodanowe są czyste. Poza tym adnotacje nie pozwolą na wszystko.
Przykład konfiguracji za pomocą adnotacji może wyglądać tak:
public class ToDoItem: BaseDbItem
{
[Required]
public Guid OwnerId { get; set; }
[ForeignKey(nameof(OwnerId)]
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
[MaxLength(30)]
public string Title { get; set; }
public string Description { get; set; }
}
Tutaj mówimy do ORMa trzy rzeczy:
Pole OwnerId ma być wymagane (not null)
Owner to klucz obcy do tabeli Users, odpowiada mu właściwość OwnerId
Pole Title ma mieć maksymalnie 30 znaków
Tych adnotacji jest oczywiście więcej, ale moim skromnym zdaniem szkoda na nie czasu. Przejdźmy od razu do Fluent Configuration.
Konfiguracja typu Fluent
Możesz ją przeprowadzić na dwa sposoby. Czysto – tzn. oddzielne klasy odpowiadają za konfigurację konkretnych modeli lub brudno – wszystkie modele w jednej metodzie. Najpierw pokażę Ci tę „brudną” konfigurację.
Przejdźmy do naszego DbContext. Tam jest do przeciążenia taka metoda jak OnModelCreating, w której możemy te modele pokonfigurować. Ale zanim do tego dojdzie, zróbmy małą, ale cholernie istotną zmianę w modelu:
public class ToDoItem: BaseDbItem
{
public Guid OwnerId { get; set; }
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
Dodałem właściwość OwnerId. Dzięki temu będziemy mogli przeszukiwać tabelę ToDoItems pod kątem konkretnych użytkowników z użyciem Linq. Jednocześnie nie będziemy musieli robić joina z tabelą Users. Skonfigurujmy to teraz:
Ta konfiguracja fluentowa również sama się świetnie opisuje 🙂 Założę się, że bez tłumaczenia od razu widzisz jakie masz możliwości i co robi powyższy kod.
Generalnie, posługując się metodą Property, możemy ustawiać ograniczenia, konwertery itd. dla konkretnych właściwości.
Zwróć uwagę szczególnie na linijki 10 i 11, gdzie konwertujemy enum do stringa.
Czy warto bawić się w konfigurację, skoro EfCore robi dużo za nas? Moim zdaniem warto. Ja nie za bardzo lubię oddawać kontrolę nad kodem, są rzeczy które wolę jasno określić. Jednak, jeśli Ty jesteś bardziej otwarty lub nie potrzebujesz niektórych ograniczeń, możesz spróbować dać EfCore wolną wolę i zobaczyć co się stanie.
Ignorowanie właściwości
EfCore domyślnie umieszcza w bazie wszystkie właściwości z modelu (w przeciwieństwie do nHibernate). Tutaj też możemy mu powiedzieć jakie właściwości ma ominąć. Np. jeśli bym nie chciał, żeby do bazy trafiał czas zakończenia zadania, mógłbym to skonfigurować tak:
todoItemBuilder.Ignore(x => x.EndDate);
Jak widzisz sam konkretny builder danego modelu ma w sobie też różne ciekawe elementy. Pozwala między innymi na zmianę nazwy tabeli.
Klucze
Możesz ustawić dodatkowy klucz na jednym lub na kilku polach. Przy czym jeśli chodzi o klucz ustawiany na kilku polach, jest to możliwe tylko za pomocą fluent configuration. Nie można tego zrobić adnotacjami:
todoItemBuilder.HasKey(x => x.Status);
todoItemBuilder.HasKey(x => new {x.OwnerId, x.Status});
Tutaj utworzyłem dwa klucze – jeden na pole Status, drugi jest kluczem na dwóch polach – OwnerId i Status. Jak widzisz, jest to banalnie proste.
Relacje w EfCore
Relacje to dość ważny aspekt w bazach relacyjnych 😀 W EfCore konfiguruje się je bardzo prosto, trzeba pamiętać tylko o kilku rzeczach i pojęciach:
relację konfigurujemy tylko z jednej strony. Tzn. możemy ustawić, że ToDoItem ma jednego ownera albo User ma wiele ToDoItem'ów. Nie musimy konfigurować dwóch modeli. Tzn. nie musimy w User podawać, że ma wiele ToDoItem'ów i jednocześnie w ToDoItem, że ma jednego usera. Wystarczy, że skonfigiujemy to po jednej stronie
encja zależna (dependent entity) – to jest ta encja, która zawiera klucz obcy. Czyli w naszym przypadku encją zależną jest ToDoItem – ponieważ to w tej klasie zdefiniowaliśmy klucz obcy
encja główna (principal entity) – to jest ta encja, która jest „rodzicem” encji zależnej. W naszym wypadku będzie to User. No, jeśli usuniemy Usera, jego encje zależne też powinny zostać usunięte (czyli jego wszystkie zadania).
właściwość nawigacyjna (navigation property) – to jest właściwość w encji zależnej i/lub głównej, która wskazuje na tę drugą stronę. Bardziej po ludzku, w modelu ToDoItems naszym navigation property jest Owner – to ta właściwość wskazuje na konkretny model encji głównej. Możemy dodać takie navigation property również do modelu User, dodając np. listę todo itemów:
public class User: BaseDbItem
{
public string Name { get; set; }
public List<ToDoItem> Items { get; set; }
}
Generalnie navigation property może posiadać kilka typów:
kolekcję zależnych encji (jak wyżej właściwość Items)
właściwość wskazującą na jedną powiązaną encję (np. w naszym modelu ToDoItems jest to Owner)
Czasem mówimy też o inverse property. To jest zwykłe navigation property tyle, że po drugiej stronie relacji. Jakby to powiedzieć prościej… Jeśli nasza encja User miałaby listę Itemów jak w powyższym przykładzie, wtedy ta lista Itemów byłaby takim „inverse property”. Kiedy to się przydaje i po co?
Generalnie EfCore potrafi sam wykminić wiele rzeczy na podstawie konwencji nazewnictwa, zależności itd. Ale w pewnych sytuacjach nie ogranie. Czasem możesz dostać błąd, że nie może „wydedukować” rodzaju relacji. Wtedy, żeby mu ułatwić możesz zastosować ten inverse property. I konfiguracja takiej relacji wyglądałaby tak:
Mam tu na myśli taką konfigurację, że poszczególne klasy są konfigurowane w osobnych plikach – tak jak ma to miejsce przy nHibernate. Najpierw warto utworzyć sobie konfiguracyjną klasę bazową, np:
public abstract class BaseModelConfig<TModel> : IEntityTypeConfiguration<TModel>
where TModel : BaseDbItem
{
public virtual void Configure(EntityTypeBuilder<TModel> builder)
{
builder.Property(x => x.Id)
.IsRequired()
.ValueGeneratedOnAdd();
}
}
Zwróć uwagę, że nasza klasa bazowa implementuje interfejs IEntityTypeConfiguration. Tutaj właśnie przyda się też bazowy model, o czym pisałem wcześniej. Naprawdę warto go mieć.
Następnie konfiguracja pozostałych modeli polega na dziedziczeniu po klasie BaseModelConfig:
Oczywiście nie musisz tworzyć abstrakcyjnej klasy bazowej. Ważne, żeby konkretne konfiguracje implementowały interfejs IEntityTypeConfiguration. Jednak taka klasa bazowa dużo ułatwia.
Na koniec musisz jeszcze powiedzieć w kontekście, gdzie ma szukać konfiguracji. DbContext będzie szukał implementacji interfejsu IEntityTypeConfiguration w Assembly, które mu podasz. W tym przypadku wykorzystujemy aktualne, główne:
Przede wszystkim pamiętaj, że EfCore pełni rolę repozytorium. Dlatego nie twórz repozytorium (mówię tutaj o wzorcu projektowym), używając EfCore – to jest częsty błąd w tutorialach na YouTubie i zbyt wielu artykułach. Pamiętaj – EfCore to repozytorium.
Jeśli chodzi o programowanie webowe, to najczyściej jest używać EfCore w serwisach. Kontroler odwołuje się do serwisu, serwis ma wstrzyknięty DbContext. A teraz zobaczmy, w jaki sposób można dodać rekord. Spójrz na poniższy serwis:
public class UserService
{
private readonly ApplicationDbContext _dbContext;
public UserService(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task AddOrUpdateUser(User data)
{
_dbContext.Users.Update(data);
await _dbContext.SaveChangesAsync();
}
}
Najpierw wstrzykujemy do konstruktora ApplicationDbContext. Następnie możemy się nim posługiwać tak jakbyśmy używali zwykłego wzorca repozytorium. A nawet lepiej, bo można używać Linq do pobierania danych.
Zwróć tylko uwagę na to, że dodawanie/modyfikacja danych w poszczególnych DbSet'ach jeszcze niczego nie zmienia. Na tym poziomie działa taki sprytny wewnętrzny mechanizm EfCore (change tracker), który oznacza odpowiednie rekordy w odpowiedni sposób (ten rekord będzie zmodyfikowany, ten trzeba dodać, a tu jest jakiś do usunięcia).
Faktyczne operacje do bazy danych pójdą dopiero po wywołaniu SaveChanges. I do tego pójdą w transakcji.
Czas życia DbContext – ważne
DbContext nie jest thread-safe. Nie powinien żyć długo. Domyślnie jest wstrzykiwany jako scope (choć to można zmienić podczas konfiguracji). Zalecam zostawić domyślne zachowanie, chyba że pojawiają się problemy, to wtedy można zmienić na transient:
Jeśli używasz zwykłego WebApplication albo WebApi, to takie konstrukcje ze wstrzykiwaniem DbContext są jak najbardziej poprawne. Ale problem pojawia się przy Blazor lub aplikacjach desktopowych.
Inaczej dla Blazor i aplikacji desktopowych
Dlaczego? Jak już mówiłem, DbContext jest standardowo wstrzykiwany jako scoped. Możesz też zmienić na transient. Jednak co to oznacza dla Blazor? Tam, jeśli masz serwis oznaczony jako scope, to właściwie zachowuje się to jak singleton. Dokładnie tak samo jest w aplikacjach desktopowych.
Nie oznacza to teraz, że w Blazor wszystkie serwisy korzystające pośrednio lub bezpośrednio z bazy danych, mają być wstrzykiwane jako transient. O nie. Jest lepszy sposób. Spójrzmy na fabrykę.
Fabryka DbContext
Zamiast rejestrować DbContext, możesz zarejestrować fabrykę:
Przyznasz, że wielkiej różnicy nie ma. Różnica pojawia się w momencie używania tego kontekstu. Teraz wstrzykujemy fabrykę do serwisu:
public class UserService
{
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
public UserService(IDbContextFactory<ApplicationDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task AddOrUpdateUser(User data)
{
using var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Users.Update(data);
await dbContext.SaveChangesAsync();
}
}
Tutaj do dostępu do bazy danych wykorzystujemy fabrykę. Po prostu ona nam tworzy ten kontekst. Pamiętaj, że musisz tworzyć to przez using. DbContext implementuje interfejs IDisposable i jest cholernie ważne, żeby po sobie posprzątał.
Nie ma nic złego w takim tworzeniu kontekstu. Co więcej – to naturalny sposób w przypadku aplikacji desktopowych no i we wspomnianym Blazor.
Pamiętaj, że jeśli chodzi o WebApplication lub WebApi – gdzie każdy request tworzy i usuwa scope, to możesz posłużyć się tą wersją ze wstrzykiwaniem samego kontekstu.
Zapytania SELECT
Oczywiście jest to tak samo proste jak pobieranie danych ze zwykłej listy. Zwróć tylko uwagę na to, czym jest IQueryable. Spójrz na poniższy kod:
var data = dbContext.Users.Where(x => x.Name.Length > 3);
Tutaj pobieramy użytkowników, których imię ma więcej niż 3 znaki. Czy to zapytanie się wykona?
Nie wykona się. Dlatego, że metoda Where zwraca obiekt IQueryable. A to taki twór, który możesz uznać za zapytanie SQL. Możesz mu zatem dodać inne warunki, np:
var data = dbContext.Users.Where(x => x.Name.Length > 3);
data = data.Where(x => x.Name.Contains("A"));
A kiedy IQueryable zostanie wysłane do bazy danych? We wszystkich tych metodach, które zwracają coś innego niż IQueryable (a konkretnie nasz model lub jego listę), np:
var list = await data.ToListAsync();
var first = await data.FirstAsync();
var fod = await data.FirstOrDefaultAsync();
Itd. Generalnie zawsze spójrz na to, co zwraca konkretna metoda. Tam, gdzie masz IQueryable, to nie ma jeszcze żadnej pracy na bazie danych.
Join
A teraz spójrz na takie zapytanie:
using var ctx = _dbContextFactory.CreateDbContext();
var data = await ctx.ToDoItems.ToListAsync();
Co zostanie pobrane?
Same zadania. Bez ich właścicieli. To super wiadomość. EfCore domyślnie zwróci tylko te dane, o które pytamy. Nie jest to wprawdzie takie „lazy loading” jak w nHibernate (choć to też da się ustawić), jednak do bazy idą tylko najbardziej potrzebne zapytania.
Oczywiście możemy do tego zrobić joina, żeby uzyskać zadania wraz z właścicielami. Ale musimy to jasno powiedzieć. W taki sposób:
using var ctx = _dbContextFactory.CreateDbContext();
var data = await ctx.ToDoItems
.Include(x => x.Owner)
.ToListAsync();
Metoda Include powie EfCore’owi, że chcemy pobrać również właściciela zadania. Istnieje również metoda ThenInclude, która pobiera dane z kolejnej encji (w naszym przypadku byłby to User).
Uważaj na podwójny INSERT
Spójrz na tan kod:
public async Task AddOrUpdateTask(ToDoItem item)
{
User owner;
using var ctx = _dbContextFactory.CreateDbContext();
owner = await ctx.Users.FirstAsync();
item.Owner = owner;
ctx.ToDoItems.Add(item);
await ctx.SaveChangesAsync();
}
Tak naprawdę nie jest ważne skąd pochodzi owner, ważne jest że ten owner znajduje się już w bazie danych. Taki kod wybucha błędem o niepoprawnym kluczu. EfCore myśli, że skoro dodajemy nowy rekord (ToDoItem), to dodajemy go z całym inwentarzem, czyli chcemy też dodać jego ownera (technicznie – INSERT owner i INSERT todoItem). Ale User o takim id już istnieje w bazie danych.
Czemu tak się stało?
EfCore ma taki sprytny mechanizm, który nazywa się ChangeTracker. On po prostu sprawdza, co się zmieniło w encji podczas życia kontekstu bazy danych. Akurat ta encja (TodoItem) jest zupełnie nowa. Więc w ChangeTrackerze, który żyje razem z kontekstem bazy danych ma oznaczenie „New”. Wszystko co ma oznaczenie „New” musi zostać dodane do bazy przez INSERT. EfCore uznaje również, że wszystkie encje zależne też są nowe.
Ale to nie koniec niespodzianki. Jeśli stworzymy jeden DbContext i pobierzemy z niego TodoItem, następnie kontekst zostanie ubity, to po utworzeniu drugiego kontekstu ten TodoItem, który został pobrany też będzie widoczny jako nowy – w tym nowym DbContext. To jest pułapka, na którą często sam się łapałem i na którą zwracam Tobie szczególną uwagę.
Więc jak sobie z czymś takim poradzić? Po prostu:
public async Task AddOrUpdateTask(ToDoItem item)
{
User owner;
using var ctx = _dbContextFactory.CreateDbContext();
owner = await ctx.Users.FirstAsync();
item.OwnerId = owner.Id;
ctx.ToDoItems.Add(item);
await ctx.SaveChangesAsync();
}
Czyli zamiast obiektu, przypisuję Id. Czasem nawet będziesz musiał zrobić takie cudo:
Musisz znullować ownera jako obiekt, ale zostawiasz jego Id. To oczywiście dotyczy tej samej sytuacji, czyli jedna encja pochodzi z jednego kontekstu, a druga z drugiego. Może się tak zdarzyć chociażby przy WebApi. Dlatego też pomocne może okazać się przesyłanie między końcówkami modelu DTO, zamiast bazodanowego (i mocno Cię do tego zachęcam, żebyś nie przesyłał modeli bazodanowych).
Różne bazy danych, różne możliwości
Możliwości EfCore zależą trochę od rodzaju bazy danych, na jakiej pracujesz. Niektóre operacje nie będą dozwolone np. na Sqlite. Zazwyczaj pracujemy na jednym typie bazy. Ale nie zawsze. Czasem zdarza się, że potrzebujesz dwóch rodzajów. Dlatego ważne jest, żeby przetestować wszystkie funkcje na wszystkich DBMS, które wykorzystujesz.
Dziękuję Ci za przeczytanie tego artykułu. To oczywiście nie wyczerpuje tematu. To są podstawy podstaw jeśli chodzi o EfCore. Na tym blogu mam jeszcze kilka tekstów na ten temat, jeśli Cię zainteresowało, to polecam:
Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu. No i oczywiście udostępnij artykuł osobom, które powinny wiedzieć jak podejść do EfCore.
Pewnie nie raz spotkałeś się z sytuacją, gdzie próba wywołania API z Blazor albo JavaScript zakończyła się radosnym błędem
XMLHttpRequest cannot load http://…. No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://…’ is therefore not allowed access.
Czym jest CORS, dlaczego jest potrzebne i jak się z nim zaprzyjaźnić? O tym dzisiaj.
Co to CORS
Cross Origin Request Sharing to mechanizm bezpieczeństwa wspierający politykę same-origin. Same-origin polega na tym, że JavaScript z jednej domeny (a konkretnie origin) nie może komunikować się z serwerem z innej domeny (origin).
Innymi słowy, jeśli masz stronę pod adresem: https://example.com i chciałbyś z niej wywołać za pomocą JavaScript coś ze strony https://mysite.com, to musisz mieć na to specjalne pozwolenie wydane przez mysite.com.
Czyli jeśli u siebie lokalnie z końcówki https://localhost:3000 będziesz chciał zawołać jakieś API z końcówki: https://localhost:5001, to też się to nie uda bez specjalnego pozwolenia. Tym wszystkim zarządza przeglądarka.
Czym jest ORIGIN
Już wiemy, że żeby nie było problemów, obydwie strony żądania muszą należeć do tego samego originu. Czym jest zatem origin?
To połączenie: protocol + host + port, czyli np:
https://example.com i https://example.com:443 – należą do tego samego originu. Pomimo, że w pierwszym przypadku nie podaliśmy jawnie portu, to jednak protokół https domyślnie działa na porcie 443. A więc został tam dodany niejawnie.
http://example.com i https://example.com – nie należą już do tego samego originu. Różnią się protokołem i portem (przypominam, że https działa domyślnie na porcie 443, a http na 80).
https://example.com:5000 i https://example.com:5001 – też nie należą do tego samego originiu, ponieważ różnią się portem.
https://api.example.com i https://example.com – też nie należą do tego samego originu, bo różnią się hostem. Zasadniczo origin definiuje aplikację internetową.
Polityka same-origin
Jak już pisałem wcześniej, polityka same-origin zakazuje jednej aplikacji korzystać z elementów innej aplikacji. Skryptów js, arkuszy css i innych… Ale…
No, ale jak to? A CDN? A linkowanie bootstrapa itd?
No właśnie. Przede wszystkim przeglądarki nie są zbyt rygorystyczne pod tym względem. Głównie ze względu na kompatybilność wsteczną. Pół Internetu przestałoby działać. Jednak to „rozluźnienie” niesie za sobą pewne zagrożenia. Np. może dawać podatność na atak XSS lub CSRF (pisałem o Cross Site Request Forgery w książce o podstawach zabezpieczania aplikacji internetowych).
Wyjątki polityki same-origin
Skoro przeglądarki niezbyt rygorystycznie podchodzą do polityki same-origin, to znaczy że są pewne luźniejsze jej elementy. Oczywiście, że tak. Przeglądarki pozwalają ogólnie na:
zamieszczanie obrazków z innych originów
wysyłanie formularzy do innych originów
zamieszczanie skryptów z innych originów – choć tutaj są już pewne ograniczenia
Na co same-origin nie pozwoli
Przede wszystkim nie pozwoli Ci na dostęp do innych originów w nowych technologiach takich jak chociażby AJAX. Czyli strzały HTTP za pomocą JavaScriptu. Co to oznacza? Zacznijmy od najmniejszego problemu – jeśli piszesz aplikację typu SPA w JavaScript lub Blazor, to chcesz się odwoływać do jakiegoś API. W momencie tworzenia aplikacji prawdopodobnie serwer stoi na innym originie niż front. Na produkcji może być podobnie. W takiej sytuacji bez obsługi CORS po stronie serwera, nie połączysz się z API.
Idąc dalej, jeśli chcesz na swojej stronie udostępnić dane pobierane z innego źródła – np. pobierasz AJAXem kursy walut – to też może nie zadziałać. W prawdzie użyłem tych kursów walut jako być może nieszczęśliwy przykład. Jeśli to działa to tylko ze względu na luźną politykę CORS. W przeciwnym razie musiałbyś się kontaktować z dostawcą danych, żeby pozwolił Ci na ich pobieranie. I tak też często się dzieje. I on może to zrobić właśnie dzięki CORS.
Więc jak działa ten CORS?
Pobierz sobie przykładową solucję, którą przygotowałem na GitHub. Jest kam kilka projektów:
WebApiWithoutCors – api, które w żaden sposób nie reaguje na CORS – domyślnie uniemożliwi wszystko
WebApiWithCors – api z obsługą CORS
ClientApiConfig – podstawowy klient, który chciałby pobrać dane i zrobić POST
DeletableClient – klient, któremu polityka CORS pozwala jedynie na zrobienie DELETE
BadClient – klient, któremu żadne API na nic nie pozwala
Każdy projekt pracuje w HTTP (nie ma SSL/TLS) specjalnie, żeby umożliwić w łatwy sposób podsłuchiwanie pakietów w snifferze.
Przede wszystkim działanie CORS (Cross Origin Request Sharing) jest domeną przeglądarki. Jeśli uruchomisz teraz przykładowe projekty: ClientApp (aplikacja SPA pisana w Blazor) i WebApiWithoutCors i wciśniesz guzik Pobierz dane, to zobaczysz taki komunikat:
A teraz wywołaj tę samą końcówkę z PostMan, to zobaczysz że dane zostały pobrane:
Co więcej, jeśli posłużysz się snifferem, np. WireShark, zobaczysz że te dane do przeglądarki przyszły:
To znaczy, że to ta małpa przeglądarka Ci ich nie dała. Co więcej, wywaliła się wyjątkiem HttpRequestException przy pobieraniu danych:
var client = HttpClientFactory.CreateClient("api");
try
{
var response = await client.GetAsync("weatherforecast");
if (!response.IsSuccessStatusCode)
ErrorMsg = $"Nie można było pobrać danych, błąd: {response.StatusCode}";
else
{
var data = await response.Content.ReadAsStringAsync();
WeatherData = new(JsonSerializer.Deserialize<WeatherForecast[]>(data));
}
}catch(HttpRequestException ex)
{
ErrorMsg = $"Nie można było pobrać danych, błąd: {ex.Message}";
}
Co z tą przeglądarką nie tak?
Przeglądarka odebrała odpowiedź z serwera, ale Ci jej nie pokazała. Dlaczego? Ponieważ nie dostała z serwera odpowiedniej informacji. A konkretnie nagłówka Access-Control-Allow-Origin, o czym informuje w konsoli:
XMLHttpRequest cannot load http://.... No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://...’ is therefore not allowed access.
To poniekąd serwer zdecydował, że nie chce klientowi danych pokazywać. A dokładniej – serwer nie zrobił niczego, żeby te dane pokazać. Po prostu na dzień dobry obsługa CORS na serwerze jest wyłączona. Można powiedzieć, że jest zaimplementowana jedynie w przeglądarce.
Niemniej jednak przeglądarka wysłała do serwera zapytanie GET, które odpowiednie dane pobrało i zwróciło. Czyli jakaś operacja na serwerze się wykonała. Pamiętaj, że zapytanie GET nie powinno mieć żadnych skutków ubocznych. Czyli nie powinno zmieniać żadnych danych. Powinno dane jedynie pobierać. A więc teoretycznie nic złego nie może się stać.
A gdyby tak przeglądarka wysłała POST? Zadziała czy nie? No właśnie nie w każdej sytuacji.
Jeśli teraz w przykładowej aplikacji uruchomisz narzędzia dewelopera (Shift + Ctrl + i) i wciśniesz guzik Wywołaj POST, to zobaczysz coś takiego:
Zanim przeglądarka wyśle to żądanie, najpierw wykona specjalne zapytanie, tzw. preflight. Czyli niejako zapyta się serwera: „Hej serwer, jestem z takiego originu i chciałbym wysłać do Ciebie POST z takimi nagłówkami. Mogę?”
To specjalne żądanie wysyłane jest na adres, na który chcesz rzucić POSTem. Z tym że tutaj metodą jest OPTIONS. Poza tym w nagłówkach są zaszyte informacje:
Access-Control-Request-Headers – lista nagłówków z jakimi chcesz wysłać POST
Access-Control-Request-Method – metoda, jaką chcesz wywołać (POST, DELETE itd)
Origin – origin, z którego żądanie będzie wysłane
Możesz to podejrzeć zarówno w narzędziach dewelopera jak i w Wireshark:
A co zrobił serwer? Zwrócił błąd: 405 - Method not allowed. Co znaczy, że pod takim endpointem serwer nie obsługuje zapytań typu OPTIONS. Co dla przeglądarki daje jasny komunikat: „Nie wysyłaj mi tego, nie obsługuję CORS”. Przeglądarka więc zaniecha i nie wyśle takiego zapytania.
Wyjątkowe formularze
Jak już pisałem wcześniej, formularze są pewnym wyjątkiem. Przeglądarka i tak je wyśle. To kwestia kompatybilności wstecznej. Jeśli będziesz chciał wysłać metodę POST z Content-Type ustawionym na multipart/form-data, to takie zapytanie zostanie wykonane bez żadnego preflight'u. Takich wyjątków jest więcej i są bardzo dobrze opisane na stronie Sekuraka, więc nie będę tego powielał. Jeśli masz ochotę zgłębić temat, to polecam.
Obsługa CORS w .NET
Skoro już wiesz z grubsza czym jest CORS i, że to serwer ostatecznie musi dać jawnie znać, że zgadza się na konkretne zapytanie, to teraz zaimplementujmy ten mechanizm po jego stronie. Spójrz na projekt WebApiWithCors z załączonej solucji.
Jeśli pracujesz na .NET < 6, to pewnie będziesz musiał dorzucić Nugeta: Microsoft.AspNetCore.Cors.
Przede wszystkim musisz dodać serwisy obsługujące CORS podczas rejestracji serwisów:
builder.Services.AddCors();
a także wpiąć obsługę CORS w middleware pipeline. Jeśli nie wiesz, czym jest middleware pipeline, przeczytaj ten artykuł.
Pamiętaj, że UseCors musi zostać wpięte po UseRouting, ale przed UseAuthorization.
Takie dodanie jednak niczego nie załatwi. CORS do odpowiedniego funkcjonowania potrzebuje polityki. I musimy mu tę politykę ustawić.
Polityka CORS
CORS Policy mówi jakich dokładnie klientów i żądania możesz obsłużyć. Składa się z trzech części:
origin – obsługuj klientów z tych originów
method – obsługuj takie metody (POST, GET, DELETE itd…)
header – obsługuj takie nagłówki
To znaczy, że klient aby się dobić do serwera musi spełnić wszystkie trzy warunki – pochodzić ze wskazanego originu, wywołać wskazaną metodę i posiadać wskazany nagłówek.
Wyjątkiem jest tu POST. Co wynika z wyjątkowości formularzy. Jeśli będziesz chciał wysłać POST, przeglądarka zapyta się o to jedynie w przypadku, gdy Content-Type jest odpowiedni (np. nie wskazuje na formularz). Co to dalej oznacza? Jeśli stworzysz na serwerze politykę, która nie dopuszcza POST, ale dopuszcza wszystkie nagłówki (AllowAnyHeader), to ten POST i tak zostanie wysłany. Kwestia kompatybilności wstecznej.
Ona zezwala na połączenia z dowolnego originu, wykonanie dowolnej metody z dowolnymi nagłówkami. Czy to dobrze? To zależy od projektu.
Co więcej, metoda AddDefaultPolicy doda domyślną politykę. Wpięty w pipeline UseCors będzie używał tej domyślnej polityki do sprawdzenia, czy żądanie od klienta może pójść dalej.
Za pomocą metody AddPolicy możesz dodać politykę z jakąś nazwą. Tylko wtedy do UseCors musisz przekazać w parametrze nazwę polityki, której chcesz używać domyślnie. UseCors wywołany bez parametrów będzie używał polityki dodanej przez AddDefaultPolicy. Jeśli jej nie dodasz, wtedy CORS nie będzie obsługiwany.
Konkretna polityka
Oczywiście możesz w polityce wskazać konkretne wartości, np.:
To spowoduje, że polityka dopuści strzały tylko z originu http://localhost:5001 z jakąkolwiek metodą i dozwolonym nagłówkiem X-API-KEY.
I tutaj dwie uwagi. Po pierwsze – pamiętaj, żeby originu nie kończyć slashem: / . Jeśli tak wpiszesz http://localhost:5001/, wtedy origin się nie zgodzi i mechanizm CORS nie dopuści połączeń. Czyli – brak slasha na końcu originu. Idąc dalej, nie podawaj pełnych adresów w stylu: https://localhost:5001/myapp – to nie jest origin.
A teraz pytanie za milion punktów. Co się stanie, gdy mając taką politykę z poprawnego originu wywołasz:
var data = new WeatherForecast
{
Date = DateTime.Now,
Summary = "Cold",
TemperatureC = 5
};
var client = HttpClientFactory.CreateClient("api");
client.DefaultRequestHeaders.Add("X-API-KEY", "abc");
var response = await client.PostAsJsonAsync("weatherforecast", data);
Dodałeś nagłówek X-API-KEY do żądania i wysyłasz JSONa za pomocą post (dowolna metoda).
Zadziała?
Przemyśl to.
Jeśli powiedziałeś „nie”, to zgadza się. Gratulacje 🙂 A teraz pytanie dlaczego to nie zadziała. Spójrz jaki przeglądarka wysyła preflight:
O co pyta przeglądarka w tym żądaniu?
„Hej serwer, czy mogę ci wysłać POST z nagłówkami contenty-type i x-api-key? A co odpowiada serwer?
„Ja się mogę zgodzić co najwyżej na metodę POST i nagłówek X-API-KEY„.
Przeglądarka teraz patrzy na swoje żądanie i mówi: „Ojoj, to nie mogę ci wysłać content-type. Więc nie wysyłam”. To teraz pytanie skąd się wzięło to content type? Spójrz jeszcze raz na kod:
var response = await client.PostAsJsonAsync("weatherforecast", data);
Wysyłasz JSONa. A to znaczy, że gdzieś w metodzie PostAsJsonAsync został dodany nagłówek: Content-Type=application/json. Ponieważ w zawartości żądania (content) masz json (czyli typ application/json).
Uważaj na takie rzeczy, bo mogą doprowadzić do problemów, z którymi będziesz walczył przez kilka godzin. Ale w tym wypadku już powinieneś wiedzieć, jak zaktualizować politykę CORS:
Jeśli wydaje Ci się, że CORS powinien zadziałać, a nie działa, w pierwszej kolejności zawsze zobacz jaki preflight jest wysyłany, jakie nagłówki idą w żądaniu i czy są zgodne z polityką.
Pamiętaj też, że w żądaniu nie muszą znaleźć się wszystkie nagłówki. Jeśli nie będzie w tej sytuacji X-API-KEY nic złego się nie stanie. Analogicznie jak przy polityce dotyczącej metod. Możesz wysłać albo GET, albo POST, albo DELETE… Nie możesz wysłać kilku metod jednocześnie, prawda? 🙂
CORS tylko dla jednego endpointu
Corsy możesz włączyć tylko dla konkretnych endpointów. Możesz to zrobić za pomocą atrybutów. I to na dwa sposoby. Jeśli chcesz umożliwić większość operacji, możesz niektóre endpointy wyłączyć spod opieki CORS. Spójrz na kod kontrolera:
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
[HttpPost]
[DisableCors]
public IActionResult PostTest([FromBody]WeatherForecast data)
{
return Ok();
}
Atrybut DisableCors spowoduje, że mechanizm CORSów uniemożliwi wywołanie tej końcówki. Jeśli przeglądarka użyje preflight, wtedy serwer odpowie, ale nie pozwalając na nic.
Kilka polityk CORS
Skoro można zablokować CORS na pewnych końcówkach, to pewnie można też odblokować na innych. No i tak. Zgadza się. Zróbmy sobie takie dwie polityki:
Zwróć uwagę na to, że nie dodajemy teraz żadnej domyślnej polityki (AddDefaultPolicy), tylko dwie, które jakoś nazwaliśmy. Teraz każdy endpoint może mieć swoją własną politykę:
[HttpGet]
[EnableCors("get-policy")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
[HttpPost]
[EnableCors("set-policy")]
public IActionResult PostTest([FromBody]WeatherForecast data)
{
return Ok();
}
Każdy endpoint dostał swoją własną politykę za pomocą atrybutu EnableCors. Jako parametr przekazujemy nazwę polityki. Jeśli w takim przypadku nie podasz atrybutu EnableCors, to końcówka będzie zablokowana. Dlaczego? Spójrz na middleware:
app.UseCors();
Taki middleware po prostu będzie chciał użyć domyślnej polityki (AddDefaultPolicy), której jednak nie ma. Dlatego też zablokuje wszystko. Oczywiście możesz w tym momencie podać konkretną politykę, jaka ma być używana przez middleware:
app.UseCors("get-policy");
Wtedy każdy endpoint bez atrybutu [EnableCors] będzie używał tej polityki.
Dynamiczna polityka CORS
Czasem możesz potrzebować bardziej płynnej polityki, która może zależeć od konkretnego originu. Możesz chcieć wpuszczać tylko te originy, które są zarejestrowane w bazie albo dla różnych originów mieć różne polityki.
Wtedy sam musisz zadbać o to, żeby mechanizm CORS dostał odpowiednią politykę. Na szczęście w .NET6 jest to banalnie proste. Wystarczy zaimplementować interfejs ICorsPolicyProvider, np. w taki sposób:
public class OriginCorsPolicyProvider : ICorsPolicyProvider
{
private readonly CorsOptions _corsOptions;
public OriginCorsPolicyProvider(IOptions<CorsOptions> corsOptions)
{
_corsOptions = corsOptions.Value;
}
public Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName)
{
var origin = context.Request.Headers.Origin;
var policy = _corsOptions.GetPolicy(origin);
if (policy == null)
policy = _corsOptions.GetPolicy(policyName ?? _corsOptions.DefaultPolicyName);
return Task.FromResult(policy);
}
}
Interfejs wymaga tylko jednej metody – GetPolicyAsync. Najpierw jednak zobacz w jaki sposób zarejestrowałem odpowiednie polityki podczas rejestracji serwisów CORS:
Nazwa polityki to po prostu origin, dla którego ta polityka jest utworzona. A teraz wróćmy do providera. Spójrz najpierw na metodę GetPolicyAsync.
Najpierw pobieram origin z requestu, następnie pobieram odpowiednią politykę. Metoda GetPolicy z obiektu _corsOptions zwraca politykę po nazwie. Te polityki są tam dodawane przez setup.AddPolicy. Gdzieś tam pod spodem są tworzone jako dodatkowe opcje, co widzisz w konstruktorze – w taki sposób możesz pobrać zarejestrowane polityki.
Oczywiście nic nie stoi na przeszkodzie, żebyś w swoim providerze połączył się z bazą danych i na podstawie jakiś wpisów sam utworzył odpowiednią politykę dynamicznie i ją zwrócił.
Teraz jeszcze tylko musimy zarejestrować tego providera:
Słowem zakończenia, pamiętaj żeby nie podawać w middleware kilka razy UseCors, bo to nie ma sensu. Pierwszy UseCors albo przepuści żądanie dalej w middleware albo je sterminuje.
To tyle jeśli chodzi o CORSy. Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz się poruszał po tym świecie z większą pewnością. Jeśli znalazłeś błąd w artykule albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
Transakcje są nieodłącznym elementem baz danych SQL. Z tego artykułu dowiesz się, po co się je stosuje, a także jak je obsłużyć w EfCore.
Ten artykuł ma jedynie w prosty sposób wyjaśnić czym jest transakcja, po co stosować i jak ją ogarniać w EfCore. O transakcyjności jako takiej napisano niejedną książkę, więc ramy prostego artykułu zdecydowanie nie pozwalają na wejście w temat głębiej. Osoby znające zagadnienie nie wyniosą z artykułu raczej niczego nowego (może poza tym, że w EfCore właściwie nie trzeba stosować transakcji – o tym niżej).
Czym jest transakcja w bazie danych?
Przedstawię Ci oklepany przykład przelewu bankowego. Jednak nie myl transakcji bankowej z transakcją bazodanową. To dwa różne pojęcia.
Co system musi zrobić, jeśli przelewam Ci pieniądze? Musi wykonać przynajmniej dwie operacje:
odjąć sumę przelewu z mojego konta
dodać sumę przelewu do Twojego konta
System przy tym musi cały czas pracować. Co się stanie, jeśli wykona tylko jedną operację, a potem serwer straci zasilanie albo po prostu, po ludzku się spali? Z mojego konta znikną pieniądze, a na Twoim się nie pojawią. Jak się przed czymś takim zabezpieczyć? Transakcją.
Transakcja powoduje to, że wszystkie zmiany (UPDATE, INSERT itd.) w niej zawarte wykonają się w całości albo nie wykonają się w ogóle. Czyli w powyższym przykładzie, jeśli sprzęt straci zasilanie po wykonaniu pierwszej instrukcji, nic złego się nie stanie. Na moim koncie wciąż będą pieniądze – transakcja się nie powiodła, więc została (zostanie) wycofana.
Jak to działa? O tym można napisać książkę, ponadto różne systemy bazodanowe obsługują transakcje na różne sposoby. Najważniejsze jest to, że transakcja zapewnia atomowość operacji – czyli wszystkie operacje wykonują się jako jedna – czyli albo wszystko zostanie zmienione, albo nic.
Rozpoczętą transakcję możesz zakończyć na dwa sposoby. Albo ją zaakceptować (COMMIT) albo wycofać (ROLLBACK).
Transakcja musi być szybka – to NIE jest backup danych.
Transakcje jawne i niejawne
W świecie SQL każde zapytanie INSERT, UPDATE, DELETE itd. wykonuje się w transakcji niejawnej. Jeśli robisz UPDATE, możesz zmienić kilka pól. A co jeśli w pewnym momencie, podczas tej operacji coś się stanie? Zmienisz tylko pół rekordu? No nie. Dlatego, że nad całością czuwa transakcja niejawna.
Gdzieś tam na niskim poziomie, gdy wykonujesz instrukcję SQL, najpierw system uruchamia transakcję i jeśli wszystko przejdzie ok, transakcja jest akceptowana.
Transakcja jawna to taka, którą Ty sam wywołujesz. Jak w powyższym przykładzie z przelewem.
Transakcje przyspieszają
Z powyższego akapitu wyłania się pewien wniosek. Jeśli masz do zrobienia 100 insertów, każdy z nich musi utworzyć nową transakcję, zmienić dane i zakończyć transakcję. Czyli tak naprawdę wykonywane są 3 operacje dla jednego insertu. To znaczy, że przy 100 insertach wykona się na niższym poziomie 300 operacji.
Jeśli jednak te wszystkie 100 insertów wykonasz w transakcji jawnej, wykonają się tylko 102 operacje – rozpoczęcie transakcji jawnej, wykonanie insertów (bez transakcji niejawnych, ponieważ transakcja już istnieje) no i zakończenie Twojej transakcji.
I faktycznie – transakcja tak użyta powoduje, że cała operacja wykona się szybciej.
Kiedy używać transakcji?
W tym momencie artykułu sam powinieneś być w stanie odpowiedzieć na to pytanie. Generalnie, podsumowując, transakcji używamy głównie w dwóch przypadkach:
Gdy wykonujemy operacje w bazie danych, które w jakiś sposób są od siebie zależne. Tzn. jedna operacja bez drugiej nie ma sensu albo spowoduje błędy w danych. Przykład – przelew bankowy. Albo bardziej przyziemny – system fakturowy. Dodajesz rekord faktury do jednej tabeli, a do drugiej jej elementy. Faktura bez zawartości nie ma sensu. Poza tym, gdyby coś poszło nie tak, do bazy trafiło by tylko część zawartości faktury. Widzisz, że te dane muszą być spójne.
Gdy wykonujemy operacje w pętli (nie muszą być ze sobą powiązane), wtedy transakcja może przyspieszyć całość.
Automatyczne transakcje w EfCore – czyli kiedy NIE używać jawnych transakcji
Metoda Save(Async) automatycznie robi wszystkie zmiany w transakcji. Czyli jeśli masz kod w stylu:
foreach(var item in list)
_dbContext.Items.Add(item);
await _dbContext.SaveAsync();
to wszystkie zapytania wykonają się w transakcji. Metoda Add po prostu dodaje element do listy (i oznacza je jako dodane), ale w żaden sposób nie dotyka bazy danych. To dopiero robi SaveAsync. To tutaj jest tworzona transakcja i są wysyłane zapytania. Czyli nawet w sytuacji, w której dodajesz, usuwasz i modyfikujesz wiele rekordów, ale masz tylko jedno wywołanie SaveAsync – nie potrzebujesz transakcji – tą zapewnia Ci SaveAsync (chyba że masz naprawdę źle zrobioną konfigurację modeli, ale wtedy się raczej wszystko wywali).
No to teraz rodzi się pytanie – po co używać kilku SaveAsync?
Czasem nie ma innej możliwości. Np. masz dwa zależne od siebie rekordy, ale powiązane są jedynie za pomocą Id, np:
public class Parent
{
public int Id { get; set; }
}
public class Child
{
public int Id { get; set; }
public int ParentId { get; set; }
}
W takim wypadku, jeśli chcesz dodać do bazy rekord Parent wraz z kilkoma Childami, musisz najpierw dodać Parenta, zrobić SaveAsync i dopiero w tym momencie dostaniesz jego Id. Po SaveAsync. W drugim zapytaniu możesz dodać Childa z konkretnym Id Parenta.
Tutaj pojawia się mała uwaga dygresyjna – w pewnych przypadkach Id rekordu możesz dostać już podczas dodawania do kolekcji, np: _dbContext.Parents.Add(parent) – jeśli masz własny generator Id lub Id jest tworzone z całkowitym pominięciem bazy danych.
Drugim powodem jest to, że nie zawsze masz wpływ na to, kiedy SaveAsync się wywoła. Spójrz na ten przykład z użyciem Identity:
IdentityUser user = new IdentityUser("test");
user.Email = "test@example.com";
await _userManager.CreateAsync(user);
Invoice inv = new Invoice();
inv.UserId = user.Id;
inv.Items.Add(new InvoiceItem());
_db.Invoices.Add(inv);
await _db.SaveChangesAsync();
Masz tutaj DWA wywołania SaveAsync. Jak to? Pierwsze jest ukryte gdzieś w środku CreateAsync z UserManagera. I pamiętaj, że wszystkie metody z UserManager lub RoleManager, które faktycznie wprowadzają jakąś zmianę, mają w pewnym miejscu wywołanie SaveAsync.
Na początku rozpoczynamy nową transakcję (przez właściwość Database z kontekstu EfCore), w której wykonujemy operacje na bazie danych, na koniec ją zatwierdzamy lub wycofujemy.
Musisz zwrócić uwagę na dwie rzeczy:
Transakcja implementuje IDisposable, to znaczy, że trzeba ją zniszczyć po użyciu. Najwygodniej używać jej wraz z using – tak jak w przykładzie powyżej. Staraj się tak działać.
Jeśli transakcja zostanie zniszczona, a wcześniej nie zostanie zaakceptowana, to automatycznie zostanie wycofana. Tzn., że powyższy kod można by zapisać równie dobrze w taki sposób:
Działanie będzie dokładnie takie samo – transakcja zostanie wycofana, jeśli nie zostanie użyty Commit – czyli jeśli podczas dodawania rekordu do bazy danych wystąpi jakiś błąd.
Punkty kontrolne
EfCore w wersji 5 wprowadziło możliwość utworzenia punktu kontrolnego. Ale zadziała to tylko na połączeniach, które NIE używają Multiple Active Result Sets (popatrz w swoim connection stringu, czy masz ustawioną tą właściwość).
Punkty kontrolne czasem mogą się przydać, jeśli nie chcesz wycofać całej transakcji, np:
Tutaj, jeśli nie powiedzie się dodanie faktury, transakcja zostanie wycofana do zdefiniowanego punktu kontrolnego – BeforeInvoice. Tzn., że użytkownik pozostanie w bazie. W tym momencie możesz spróbować jeszcze raz dodać faktury (lub wycofać całą transakcję). Kiedy to ma sens? Przykładowo, kiedy kilka osób pracuje na jakimś rekordzie i on aktualnie jest zablokowany. Po chwili może zostać odblokowany. To oczywiście może być dużo bardziej skomplikowane. Ale takie użycie save pointów samo się narzuca.
Ograniczenia
Musisz pamiętać o tym, że EfCore to jest ORM na dopalaczach. EfCore jest coraz mniej zależny od bazy danych, a właściwie od źródła danych. Niektóre źródła mogą nie obsługiwać transakcji i wtedy nawet dobry Boże nie pomoże. Wywołanie transakcji w takim przypadku może zakończyć się albo wyjątkiem, albo po prostu nic się nie stanie.
Na szczęście bazy danych SQL (MSSQL, MariaDb, MySQL, Oracle, SQLite itd) wspierają transakcje.
Dobrnęliśmy do końca. Dzięki za przeczytanie tego artykułu, jeśli był dla Ciebie pomocny, podziel się nim w swoich mediach społecznościowych.
Jeśli znalazłeś w artykule błąd lub czegoś nie rozumiesz, koniecznie daj znać 🙂
„Po co interfejsy, skoro mamy klasy po których można dziedziczyć” – wielu młodych programistów zadaje takie pytanie. Sam też kiedyś o to pytałem, nie rozumiejąc w ogóle istoty interfejsów. No bo po co używać tworu, który niczego nie robi i niczego nie potrafi?
Zrozumienie tego może zabrać trochę czasu. Przyjmuję wyzwanie i postaram Ci się to wszystko wyjaśnić w tym artykule.
Co to interfejs?
Prawdopodobnie już wiesz, że interfejs to taki twór, który niczego nie potrafi. Ma tylko nagłówki metod i tyle. W C# interfejs może mieć też zdefiniowane właściwości (a i od jakiegoś czasu nieco więcej…)
Interfejs a klasa abstrakcyjna
„Czym się różni interfejs od klasy abstrakcyjnej” – to częste pytanie, które pada w rozmowach o pracę dla juniorów, ale uwaga… też i dla seniorów. Okazuje się, że zbyt dużo programistów (co mnie osobiście bardzo szokuje) nie jest w stanie przedstawić jasnych różnic między interfejsem a klasą abstrakcyjną. Zacznijmy więc od podobieństw:
Podobieństwa:
nie można utworzyć instancji interfejsu ani instancji klasy abstrakcyjnej
klasa abstrakcyjna i interfejs zawierają nagłówki metod – bez ciał
I to właściwie tyle jeśli chodzi o podobieństwa. Różnic jest znacznie więcej:
Różnice:
klasa może dziedziczyć po jednej klasie abstrakcyjnej, ale może implementować wiele interfejsów
klasa abstrakcyjna może zawierać metody nieabstrakcyjne (z ciałami) – interfejs nie może*
klasa abstrakcyjna może zawierać pola i oprogramowane właściwości – interfejs nie może
klasa abstrakcyjna może dziedziczyć po innej klasie, a także implementować interfejsy; interfejs może jedynie rozszerzać inny interfejs
*UWAGA! Od C# 8.0 interfejs może zawierać domyślną implementację metod. Nie jest to jednak oczywiste działanie i nie zajmujemy się tym w tym artykule. Wspominam o tym z poczucia obowiązku.
Definiowanie interfejsu
W C# interfejs jest definiowany za pomocą słówka interface:
Utworzyliśmy sobie interfejs IFlyable. Konwencja mówi tak, że nazwy interfejsów zaczynają się literką I (i jak igła).
Interfejs sam w sobie może mieć (tak jak klasa) określoną widoczność. W tym przypadku IFlyable jest publiczny. Natomiast jego składniki nie mogą mieć określanych widoczności. Wszystkie właściwości i metody są publiczne.
Stara konwencja, która właściwie już nie obowiązuje (ale pomoże Ci zrozumieć interfejs), mówi że nazwa interfejsu powinna OPISYWAĆ cechę (kończyć się na -able). Np: IFlyable, ITalkable, IWalkable, IEnumerable…
Interfejs w roli opisywacza
Końcówka -able w nazwie interfejsu powinna dać Ci do myślenia… „Interfejs OPISUJE jakieś zachowanie.” – tak. Interfejs opisuje zachowanie, a właściwie cechę. Klasa, która implementuje dany interfejs, musi też utworzyć dane zachowanie.
Z pomocą przyjdzie przykład. Załóżmy, że tworzysz świat. I masz taką fantazję, że tworzysz organizmy żywe. Podzieliłeś je na jakieś grupy – ssaki, ptaki, gady, owady.
I utworzyłeś analogiczne klasy abstrakcyjne:
public abstract class Mammal
{
}
public abstract class Reptile
{
}
public abstract class Bird
{
}
itd. Każda z tych klas abstrakcyjnych ma jakieś elementy wspólne dla całej grupy. I teraz zaczynasz tworzyć sobie konkretnych osobników:
public class Human: Mammal
{
}
public class Bat: Mammal
{
}
public class Pigeon: Bird
{
}
public class Pingeuin: Bird
{
}
I już coś Ci przestaje działać. Dlaczego?
No spójrz. Pewnie na początku wyszedłeś z założenia, że ptaki latają. Okazuje się, że latanie nie jest domeną ptaków. Pingwin nie lata, kura nie lata, struś nie lata… Oczywiście większość ptaków jednak lata ale nie wszystkie. I co dalej? Mamy nietoperza. Lata, ale nie jest ptakiem. Jednak większość ssaków nie lata.
Skoro większość ssaków nie lata, klasa Mammal nie może w żaden sposób zaimplementować latania. Klasa Bird też nie może zaimplementować latania, ponieważ nie wszystkie ptaki latają. No a nie będziesz przecież latania implementował od nowa w każdym gatunku, no bo to jednak jakaś cecha grupy. A my nie chcemy dublować kodu.
Ok, idźmy dalej tą drogą i zróbmy latające ssaki i latające ptaki:
public abstract class Mammal
{
}
public abstract class FlyingMammal: Mammal
{
public abstract void Fly();
}
public class Human: Mammal
{
}
public class Bat: FlyingMammal
{
public override void Fly()
{
}
}
public abstract class Bird
{
}
public abstract class FlyingBird
{
public abstract void Fly();
}
public class Pigeon: FlyingBird
{
public override void Fly()
{
}
}
public class Pingeuin: Bird
{
}
Uff, udało się napisać abstrakcyjne klasy i nawet po nich dziedziczyć. Super.
I teraz przychodzi Ci napisać metodę, która w parametrze przyjmuje zwierzęta latające… BUM. Wszystko wybuchło…
public void StartFlying(FlyingBird bird)
{
bird.Fly();
}
public void StartFlying(FlyingMammal mammal)
{
mammal.Fly();
}
Zamiast jednej prostej metody, masz ich wiele (tyle, ile masz klas zwierząt latających). Sam widzisz, że nie tędy droga. I teraz na scenę wkraczają interfejsy. Wróćmy do naszego interfejsu, który napisaliśmy na początku:
Okazuje się, że interfejs pełni rolę cechy. W tym przypadku tą cechą jest latanie. Więc zmieńmy teraz nasze klasy w taki sposób, żeby pozbyć się latających klas abstrakcyjnych na rzecz interfejsu:
public abstract class Mammal
{
}
public class Human: Mammal
{
}
public class Bat: Mammal, IFlyable
{
public bool IsFlying { get; private set; }
public void Fly()
{
}
}
public abstract class Bird
{
}
public class Pigeon: Bird, IFlyable
{
public bool IsFlying { get; private set; }
public void Fly()
{
}
}
public class Pingeuin: Bird
{
}
I co się okazuje? Niektóre ssaki (ale nie wszystkie) potrafią latać. Niektóre ptaki (ale nie wszystkie) potrafią latać. I jak teraz będzie wyglądała metoda, która w parametrze przyjmuje latające zwierzę?
public void StartFlying(IFlyable f)
{
if (!f.IsFlying)
f.Fly();
}
Mamy tutaj polimorfizm w najczystszej postaci. Metoda StartFlying nie wie jakie zwierzę dostanie w parametrze. Wie natomiast, że to co dostanie – na pewno umie latać.
Skoro wiemy, że jednak większość ptaków lata, możemy w tym momencie pokusić się o stworzenie dodatkowej klasy – latające ptaki:
public abstract class FlyingBird: Bird, IFlyable
{
public bool IsFlying { get; private set; }
public void Fly()
{
}
}
Zauważ, że metoda StartFlying w ogóle się nie zmieni, ponieważ latające ptaki implementują interfejs IFlyable. Klasa FlyingBird jest taką trochę pomocniczą. Ona wie jak ptak powinien latać. A chyba wszystkie latające ptaki robią to w ten sam sposób.
Można napisać taką klasę, ponieważ większość ptaków lata. Jednak nie tworzyłbym klasy FlyingMammal, ponieważ latanie wśród ssaków jest wyjątkową cechą. Zatem zachowałbym ją dla konkretnych gatunków (chociaż to wymaga przemyślenia).
Zróbmy trochę bardziej śmieszny świat. Ssaki… Niektóre są żyworodne, niektóre są jajorodne (kolczatka). Jeśli chodzi o ptaki… to chyba wszystkie są jajorodne (chociaż aż tak się nie znam ;)). Ale widzisz, że tutaj żyworodność, czy też jajorodność nie jest domeną całej grupy, więc idealnie nadaje się na interfejs. Tak samo jak umiejętność pływania. Są ptaki, ssaki, owady i inne, które potrafią pływać, ale są też takie, które tego nie potrafią. Tak jak z lataniem.
Klasa abstrakcyjna zamiast interfejsu
Czasem możesz mieć taką pokusę, żeby zastosować klasę abstrakcyjną zamiast interfejsu. Zwłaszcza jak w przykładzie ze zwierzętami latającymi. I czasem będzie to dobre rozwiązanie. Jednak w powyższym przykładzie widzisz, że nie zadziała. Zarówno latające ptaki, jak i latające ssaki powinny dziedziczyć po takiej klasie – a to się nie da. W C# nie mamy wielodziedziczenia.
W niektórych językach (np. C++) jest możliwość dziedziczenia po wielu klasach. Wtedy takie rozwiązanie jest jak najbardziej ok. Ale nie w C#. Czy to ograniczenie? Może i tak. Ale takie samo jak to, które zabrania Ci jechać na czerwonym świetle. Wielodziedziczenie bardzo łatwo może stać się powodem problemów. Do tego stopnia, że niektórzy programiści uważają, że jeśli musisz dziedziczyć po wielu klasach, to coś pewnie zaprojektowałeś źle.
I dlatego mamy też interfejsy. Żeby zaimplementować cechy z różnych „światów”.
Interfejs i wstrzykiwanie zależności
Jeśli nie wiesz, czym jest wstrzykiwanie zależności, koniecznie przeczytaj ten artykuł.
Jeśli chodzi o DI, to interfejsy jakoś tak samoczynnie stały się standardem. Do obiektu nie wstrzykujemy klasy, tylko interfejs. Chociaż czasem wstrzykiwanie klasy abstrakcyjnej jest jak najbardziej ok. Jednak wstrzykiwanie interfejsu czasem jest po prostu szybsze do zaimplementowania. Załóżmy, że masz taką klasę:
public class ConsoleWriter
{
public void Write(string msg)
{
Console.WriteLine(msg);
}
}
I chcesz ją wstrzyknąć do innej klasy. Więc z ConsoleWriter wyekstrahujesz albo interfejs IWriter, albo klasę abstrakcyjną AbstractWriter. Interfejs w tym momencie jest zdecydowanie bardziej naturalnym podejściem. Daje Ci pewność, że w przyszłości niczego Ci nie popsuje. Interfejs żyje trochę z boku wszystkiego.
Jeśli wyekstrahowałbyś klasę abstrakcyjną, musiałbyś się zastanowić, czy to dobre rozwiązanie. Czy nagle nie pojawi się kiedyś potrzeba, żeby jakaś klasa dziedziczyła po takim writerze, np:
public class MySuperStringList: List<string>//, AbstractWriter???
{
}
Oczywiście nie jesteś w stanie przewidzieć przyszłości. Ale jesteś w stanie przewidzieć, że w DI klasa abstrakcyjna może w pewnym momencie coś zblokować. Natomiast interfejs nigdy niczego nie zablokuje.
Interfejs w roli pluginu / adaptera
W takim przypadku wybór interfejsu jest raczej jednoznaczny. Załóżmy, że piszesz odtwarzacz mp3. I chcesz, żeby taki odtwarzacz mógł być rozszerzany przez pluginy. W pierwszym kroku musisz jakoś zaprojektować taki plugin:
W taki sposób Twoja aplikacja będzie mogła poinformować pluginy, że piosenka się zaczęła lub skończyła. Pluginy będą implementować ten interfejs i odpowiednio reagować na zmiany. Np. może być plugin, który napisze post na FB o tym, że po raz piąty w tym dniu słuchasz tej samej piosenki. Może być inny plugin, który będzie zapisywał do bazy danych historie słuchanych przez Ciebie utworów i analizował ją. Itd.
Ty z poziomu swojej aplikacji mp3 musisz tylko wywołać metodę z interfejsu.
public void PlayMp3(string fileName)
{
//w jakiś sposób odtwórz piosenkę, a potem poinformuj pluginy
foreach(IMyMp3Plugin plugin in plugins)
{
plugin.Mp3Started(fileName);
}
}
No to tyle jeśli chodzi o interfejsy. Mam nadzieję, że wszystko udało mi się wyjaśnić. Jeśli jednak nadal czegoś nie rozumiesz lub też znalazłeś błąd w tekście, koniecznie daj znać w komentarzu.
Osoby niezdające sobie sprawy, jak pod kapeluszem działa HttpClient, często używają go źle. Ja sam, zaczynając używać go kilka lat temu, robiłem to źle – traktowałem go jako typową klasę IDisposable. Skoro utworzyłem, to muszę usunąć. W końcu ma metodę Dispose, a więc trzeba jej użyć. Jednak HttpClient jest nieco wyjątkowy pod tym względem i należy do niego podjeść troszkę inaczej.
Raz a dobrze!
HttpClient powinieneś stworzyć tylko raz na cały system. No, jeśli strzelasz do różnych serwerów, możesz utworzyć kilka klientów – po jednym na serwer. Oczywiście przy założeniu, że nie komunikujesz się z kilkuset różnymi serwerami, bo to zupełnie inna sprawa 😉
Przeglądając tutoriale, zbyt często widać taki kod:
using var client = new HttpClient();
To jest ZŁY sposób utworzenia HttpClient, a autorzy takich tutoriali nigdy (albo bardzo rzadko) o tym nie wspominają albo po prostu sami nie wiedzą.
Dlaczego nie mogę ciągle tworzyć i usuwać?
To jest akapit głównie dla ciekawskich.
Każdy request powoduje otwarcie nowego socketu. Spójrz na ten kod:
for(int i = 0; i < 10; i++)
{
using var httpClient = new HttpClient();
//puszczenie requestu
}
On utworzy 10 obiektów HttpClient, każdy z tych obiektów otworzy własny socket. Jednak, gdy zwolnimy obiekt HttpClient, socket nie zostanie od razu zamknięty. On będzie sobie czekał na jakieś zagubione pakiety (czas oczekiwania na zamknięcie socketu ustawia się w systemie). W końcu zostanie zwolniony, ale w efekcie możesz uzyskać coś, co nazywa się socket exhaustion (wyczerpanie socketów). Ilość socketów w systemie jest ograniczona i jeśli wciąż otwierasz nowe, to w końcu – jak to mawiał klasyk – nie będzie niczego.
Z drugiej strony, jeśli masz tylko jeden HttpClient (singleton), to tutaj pojawia się inny problem. Odświeżania DNSów. Raz utworzony HttpClient po prostu nie będzie widział odświeżenia DNSów.
Te problemy właściwie nie istnieją jeśli masz aplikację desktopową, którą użytkownik włącza na chwilę. Wtedy hulaj dusza, piekła nie ma. Ale nikt nie zagwarantuje Ci takiego używania Twojej apki. Poza tym, coraz więcej rzeczy przenosi się do Internetu. Wyobraź sobie teraz kontroler, który tworzy HttpClient przy żądaniu i pobiera jakieś dane z innego API. Tutaj katastrofa jest murowana.
Poprawne tworzenie HttpClient
Zarówno użytkownicy jak i Microsoft zorientowali się w pewnym momencie, że ten HttpClient nie jest idealny. Od jakiegoś czasu mamy dostęp do HttpClientFactory. Jak nazwa wskazuje jest to fabryka dla HttpClienta. I to przez nią powinniśmy sobie tego klienta tworzyć.
Ta fabryka naprawia oba opisane problemy. Robi to przez odpowiednie zarządzanie cyklem życia HttpMessageHandler, który to bezpośrednio jest odpowiedzialny za całe zamieszanie i jest składnikiem HttpClient.
Jest kilka możliwości utworzenia HttpClient za pomocą fabryki i wszystkie działają z Dependency Injection. Teraz je sobie omówimy. Zakładam, że wiesz czym jest dependency injection i jak z niego korzystać w .Net.
Podstawowe użycie
Podczas rejestracji serwisów, zarejestruj HttpClient w taki sposób:
services.AddHttpClient();
Następnie, w swoim serwisie, możesz pobrać HttpClient w taki sposób:
class MyService
{
IHttpClientFactory factory;
public MyService(IHttpClientFactory factory)
{
this.factory = factory;
}
public void Foo()
{
var httpClient = factory.CreateClient();
}
}
Przy wywołaniu CreateClient powstanie wprawdzie nowy HttpClient, ale może korzystać z istniejącego HttpMessageHandler’a, który odpowiada za wszystkie problemy. Fabryka jest na tyle mądra, że wie czy powinna stworzyć nowego handlera, czy posłużyć się już istniejącym.
Takie użycie świetnie nadaje się do refaktoru starego kodu, gdzie tworzenie HttpClient’a zastępujemy eleganckim CreateClient z fabryki.
Klient nazwany (named client)
Taki sposób tworzenia klienta wydaje się być dobrym pomysłem w momencie, gdy używasz różnych HttpClientów na różnych serwerach z różną konfiguracją. Możesz wtedy rozróżnić poszczególne „klasy” HttpClient po nazwie. Rejestracja może wyglądać tak:
services.AddHttpClient("GitHub", httpClient =>
{
httpClient.BaseAddress = new Uri("https://api.github.com/");
// The GitHub API requires two headers.
httpClient.DefaultRequestHeaders.Add(
HeaderNames.Accept, "application/vnd.github.v3+json");
httpClient.DefaultRequestHeaders.Add(
HeaderNames.UserAgent, "HttpRequestsSample");
});
services.AddHttpClient("MyWebApi", httpClient =>
{
httpClient.BaseAddress = new Uri("https://example.com/api");
httpClient.RequestHeaders.Add("x-login-data", config["ApiKey"]);
});
Zarejestrowaliśmy tutaj dwóch klientów. Jeden, który będzie używany do połączenia z GitHubem i drugi do jakiegoś własnego API, które wymaga klucza do logowania.
A jak to pobrać?
class MyService
{
IHttpClientFactory factory;
public MyService(IHttpClientFactory factory)
{
this.factory = factory;
}
public void Foo()
{
var gitHubClient = factory.CreateClient("GitHub");
}
}
Analogicznie jak przy podstawowym użyciu. W metodzie CreateClient podajesz tylko nazwę klienta, którego chcesz utworzyć. Z każdym wywołaniem CreateClient idzie też kod z Twoją konfiguracją.
Klient typowany (typed client)
Jeśli Twoja aplikacja jest zorientowana serwisowo, możesz wstrzyknąć klienta bezpośrednio do serwisu. Skonfigurować go możesz zarówno w serwisie jak i podczas rejestracji.
Kod powie więcej. Rejestracja:
services.AddHttpClient<MyService>(client =>
{
client.BaseAddress = new Uri("https://api.services.com");
});
Taki klient zostanie wstrzyknięty do Twojego serwisu:
class MyService
{
private readonly HttpClient _client;
public MyService(HttpClient client)
{
_client = client;
}
}
Tutaj możesz dodatkowo klienta skonfigurować. HttpClient używany w taki sposób jest rejestrowany jako Transient.
Zabij tego HttpMessageHandler’a!
Jak już pisałem wcześniej, to właśnie HttpMessageHandler jest odpowiedzialny za całe zamieszanie. I to fabryka decyduje o tym, kiedy utworzyć nowego handlera, a kiedy wykorzystać istniejącego.
Jednak domyślna długość życia handlera jest określona na dwie minuty. Po tym czasie handler jest usuwany.
Jeśli pobierasz dane lub pliki większe niż 50 MB powinieneś sam je buforować zamiast korzystać z domyślnych mechanizmów. One mogę mocno obniżyć wydajność Twojej aplikacji. I wydawać by się mogło, że poniższy kod jest super:
Niestety nie jest. Przede wszystkim zajmuje taką ilość RAMu, jak wielki jest plik. RAM jest zajmowany na cały czas pobierania. Ponadto przy pliku testowym (około 1,7 GB) nie działa. Task, w którym wykonywał się ten kod w pewnym momencie po prostu rzucił wyjątek TaskCancelledException.
Co więcej w żaden sposób nie możesz wznowić takiego pobierania, czy też pokazać progressu. Jak więc pobierać duże pliki HttpClientem? W taki sposób (to nie jest jedyna słuszna koncepcja, ale powinieneś iść w tę stronę):
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Test", "1.0"));
var httpRequestMessage = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = uri
};
using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead);
if (!httpResponseMessage.IsSuccessStatusCode)
return;
var fileSize = httpResponseMessage.Content.Headers.ContentLength;
using Stream sourceStream = await httpResponseMessage.Content.ReadAsStreamAsync();
using Stream destStream = File.Open("D:\\test.avi", FileMode.Create);
var buffer = new byte[8192];
ulong bytesRead = 0;
int bytesInBuffer = 0;
while((bytesInBuffer = await sourceStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
{
bytesRead += (ulong)bytesInBuffer;
Downloaded = bytesRead;
await destStream.WriteAsync(buffer);
await Dispatcher.InvokeAsync(() =>
{
NotifyPropertyChanged(nameof(Progress));
NotifyPropertyChanged(nameof(Division));
});
}
W pierwszej linijce ustawiam przykładową nazwę UserAgenta. Na niektórych serwerach przy połączeniach SSL jest to wymagane.
Następnie wołam GET na adresie pliku (uri to dokładny adres pliku, np: https://example.com/files/big.avi).
Potem już czytam w pętli poszczególne bajty. To mi umożliwia pokazanie progressu pobierania pliku, a także wznowienie tego pobierania.
Możesz poeksperymentować z wielkością bufora. Jednak z moich testów wynika, że 8192 jest ok. Z jednej strony jego wielkość ma wpływ na szybkość pobierania danych. Z drugiej strony, jeśli bufor będzie zbyt duży, to może nie zdążyć się zapełnić w jednej iteracji i nie zyskasz na prędkości.
Koniec
No, to tyle co chciałem powiedzieć o HttpClient. To są bardzo ważne rzeczy, o których trzeba pamiętać. W głowie mam jeszcze jeden artykuł, ale to będą nieco bardziej… może nie tyle zaawansowane, co wysublimowane techniki korzystania z klienta.
Dzięki za przeczytanie artykułu. Jeśli znalazłeś w nim błąd lub czegoś nie zrozumiałeś, koniecznie podziel się w komentarzu.
Z tego artykułu dowiesz się na czym polega konfiguracja w .NET i jak odczytywać ustawienia na różne sposoby w swoich klasach (IOptions, IOptionsSnapshot, IOptionsMonitor), a także czym są opcje nazwane (named options).
Konfiguracja w .NET to nie tylko IConfigure, czy też IOptions. To naprawdę bardzo fajnie przemyślany mechanizm, który zdecydowanie warto poznać.
Na szybko (kliknij, by rozwinąć)
Jak odczytać ustawienia zagnieżdżone?
Posłuż się dwukropkiem, np. jeśli w plik appsettings.json masz konfigurację:
Na koniec możesz odczytać ich wartości w poszczególnych obiektach za pomocą IOptions<T>, IOptionsSnapshot<T> lub IOptionsMonitor<T> – szczegółowo to jest opisane niżej
Dlaczego .NET nie odczytuje zmiennych środowiskowych do konfiguracji?
Spokojnie, odczytuje. Jeśli uruchamiasz aplikację z wiersza poleceń przez dotnet run, to po zmianie zmiennych środowiskowych zrestartuj wiersz poleceń. Jeśli uruchamiasz z Visual Studio – to po zmianie zrestartuj Visual Studio. Szczegóły w treści artykułu.
appsettings, czy nie appsettings… – czyli dostawcy konfiguracji
Być może nie wiesz, ale w .NET nie musisz trzymać konfiguracji w pliku appsettings. Co więcej, NIE jest to zalecane miejsce dla danych wrażliwych – tak jak skrupulatnie przekonują Cię o tym tutoriale na YouTube.
Jest wiele miejsc, w których możesz trzymać swoją konfigurację (zwłaszcza wrażliwe dane) i sam nawet możesz dopisać własne mechanizmy (np. odczyt konfiguracji z rejestru przy aplikacji desktopowej).
Taki mechanizm odczytywania danych nazywa się configuration provider – czyli dostawca konfiguracji. W .NET masz do dyspozycji kilku takich dostawców, którzy są w stanie pobrać Twoją konfigurację z miejsc takich jak:
plik appsettings.json
zmienne środowiskowe
Azure Key Vault (polecam do trzymania danych wrażliwych)
argumenty linii poleceń
Odczytywanie konfiguracji
Tworząc aplikację poprzez WebApplication.CreateBuilder lub Host.CreateDefaultBuilder, dodajemy m.in. kilku domyślnych providerów, którzy odczytują konfigurację z różnych miejsc i wszystko umieszczają w jednym obiekcie IConfiguration (a konkretniej, to providerzy ze swoimi danymi siedzą w IConfiguration). Konfiguracja jest dostarczana w dwóch etapach (w kolejności):
Konfiguracja hosta, w której są odczytywane:
zmienne środowiskowe z prefixem DOTNET_
zmienne środowiskowe z prefixem DOTNET_ z pliku launchSettings.json
zmienne środowiskowe z prefixem ASPNETCORE_
zmienne środowiskowe z prefixem ASPNETCORE_ z pliku launchSettings.json (przy czym specjalna zmienna: ASPNETCORE_ENVIRONMENT wskazuje na aktualne środowisko (produkcja, development, staging -> to jest ładowane do HostingEnvironment. Jeśli tej zmiennej nie ma, to .NET traktuje to jako środowisko produkcyjne)
parametry z linii poleceń
Konfiguracja aplikacji – w tym momencie znamy już HostingEnvironment (czyli wiadomo, czy to produkcja, develop, staging…)
konfiguracja z pliku appsettings.json
konfiguracja z pliku appsettings.environment.json – gdzie „environment” to określenie aktualnego środowiska („Production”, „Development”, „Staging”…)
konfiguracja z secrets.json
wszystkie zmienne środowiskowe
Pamiętaj, żeby nigdy nie odczytywać aktualnego środowiska z konfiguracji: Configuration["ASPNETCORE_ENVIRONMENT"], bo może to być błędne. Środowisko jest trzymane w IHostingEnvironment i to tego powinieneś używać do odczytu.
Dlaczego możesz się na tym przejechać? Załóżmy, że ktoś z jakiegoś powodu wpisze ustawienie ASPNETCORE_ENVIRONMENT do pliku appsettings.json. I już będzie klops. Bo owszem, ustawienie w obiekcie IConfiguration zostanie „nadpisane”, jednak IHostingEnvironment będzie trzymał zupełnie inne dane.
Co z tymi zmiennymi środowiskowymi i co to launchSettings.json?
Dlaczego .NET nie odczytuje zmiennych środowiskowych?
Czasami możesz odnieść takie wrażenie, że to po prostu nie działa. Też tak miałem, dopóki nie zdałem sobie sprawy z tego, jak naprawdę działają zmienne środowiskowe.
Program odczytuje te zmienne w momencie swojego uruchamiania. I to jest najważniejsze zdanie w tym akapicie. Zmienne środowiskowe nie są „aktualizowane” w aplikacji. Jeśli uruchomisz swoją aplikację z wiersza poleceń (dotnet run), to Twój program otrzyma takie zmienne jakie otrzymał wiersz poleceń podczas swojego uruchamiania.
Jeśli uruchamiasz program z VisualStudio, to Twój program otrzyma takie zmienne, jakie dostał VisualStudio podczas swojego uruchamiania.
Dlatego, jeśli zmieniasz wartości zmiennych środowiskowych, pamiętaj żeby zrestartować wiersz poleceń / Visual Studio. Wtedy Twoja aplikacja dostanie aktualne zmienne.
Jeśli zmieniasz zmienne na poziomie IIS, zrestartuj IIS.
Jest to pewna upierdliwość. Dlatego mamy plik launchSettings.json, w którym możesz sobie poustawiać różne zmienne środowiskowe. Te zmienne będą odczytywane podczas każdego uruchamiania Twojego programu – nie musisz niczego restartować.
Oczywiście pamiętaj, że plik launchSettings.json służy tylko do developmentu. Więc jeśli poustawiasz tam jakieś zmienne, których używasz, pamiętaj żeby ustawić je też na środowisku produkcyjnym.
Nie zdradzaj tajemnicy, czyli secrets.json
Domyślne pliki z ustawieniami – appsettings.json i appsettings.Development.json są przesyłane do repozytorium kodu. Jeśli pracujesz w zamkniętym zespole, to nie ma to większego znaczenia – dopóki w programie nie używasz jakiś swoich prywatnych subskrypcji.
Jeśli w plikach appsettings trzymasz dane wrażliwe (connection stringi, hasła, klucze), to miej świadomość, że one będą widoczne w repozytorium kodu i KAŻDY z dostępem będzie mógł z nich skorzystać (w szczególności GitHub).
Dlatego też powstał plik secrets.json. Aby go utworzyć/otworzyć, kliknij w Visual Studio prawym klawiszem myszy na swój projekt i z menu wybierz Manage User Secrets:
Możesz też użyć .NetCli i wykonać polecenie dotnet user-secrets
Wywołanie w VisualStudio otworzy Ci edytor tekstu taki sam jak dla pliku appsettings. Zresztą secrets.json ma dokładnie taką samą budowę.
Różnica między secrets.json a appsettings.json jest taka, że secrets.json nie znajduje się ani w katalogu z kodem (leży gdzieś tam w AppData), ani w repozytorium. Więc możesz sobie w nim bezkarnie umieszczać wszystkie klucze, hasła itd, których używasz w programie.
Oczywiście możesz mieć różne pliki sekretów w różnych projektach.
Gdzie dokładnie leży plik secrets.json?
W takiej lokalizacji: AppData\Roaming\Microsoft\UserSecrets\{Id sekretów}\secrets.json
Id sekretów to GUID, który jest przechowywany w pliku (csproj) konkretnego projektu.
Kolejność konfiguracji
Jak już zapewne wiesz – .NET odczytuje konfigurację w konkretnej kolejności – opisanej wyżej. A co jeśli w różnych miejscach (np. appsettings.json i secrets.json) będą ustawienia, które tak samo się nazywają? Nico. Ustawienia, które odczytują się później będą tymi aktualnymi. Czyli jeśli w pliku appsetting.json umieścisz:
"tajne-haslo" : ""
I to samo umieścisz w pliku secrets.json, który jest odczytywany później:
"tajne-haslo" : "admin123"
To z konfiguracji odczytasz „admin123”.
Dla wścibskich
Tak naprawdę te wartości nie są nadpisywane i przy odrobinie kombinowania możesz odczytać konkretne wartości z konkretnych miejsc (jako że IConfiguration nie trzyma bezpośrednio tych wartości, tylko ma listę ConfigurationProviderów). Domyślnie .NET szuka klucza „od tyłu” – w odwrotnej kolejności niż były dodawane do IConfiguration, ale moim zdaniem może to być szczegół implementacyjny, który w przyszłości może ulec zmianie. Jednak nie czytałem dokumentacji projektowej.
Pobieranie danych z konfiguracji
Prawdopodobnie to wiesz. Do klasy Startup wstrzykiwany jest obiekt implementujący IConfiguration i wtedy z niego możemy pobrać sobie dane, które nas interesują:
To oczywiście podstawowe pobieranie danych z konfiguracji, przejdźmy teraz do fajniejszych rzeczy.
Tworzenie opcji dla programu
Dużo lepszym i fajniejszym rozwiązaniem jest tworzenie opcji dla komponentów Twojego programu. Załóżmy, że masz serwis do wysyłania e-maili. On może wyglądać tak:
public class EmailService
{
const string OPTION_SMTP_ADDRESS = "https://smtp.example.com";
const string OPTION_FROM = "Admin";
const string OPTION_FROM_EMAIL = "admin@example.com";
public void SendMail(string msg, string subject)
{
}
}
Tutaj najważniejsze jest to, jak masz nazwane poszczególne właściwości. Muszą być tak samo nazwane jak właściwości w Twojej klasie EmailOptions.
A w klasie EmailOptions to MUSZĄ być właściwości do publicznego odczytu i zapisu (nie mogą to być pola).
Jeśli już masz skonstruowaną klasę opcji (EmailOptions) i fragment konfiguracji (np. ten powyżej), możesz podczas konfiguracji serwisów dodatkowo skonfigurować te opcje:
Czyli mówisz: „Klasa EmailOptions ma trzymać dane odczytane z sekcji w konfiguracji o nazwie „EmailSettings”.
Od teraz możesz klasę EmailOptions z wypełnionymi wartościami wstrzykiwać do swoich obiektów na trzy sposoby… Każdy z nich ma swoje wady i zalety.
Interfejs IOptions<T>
To pierwszy sposób pobrania opcji i chyba najprostszy. Wystarczy, że wstrzykniesz IOptions<T> do obiektu, w którym chcesz mieć swoją konfigurację:
public class EmailService
{
EmailOptions options;
public EmailService(IOptions<EmailOptions> options)
{
this.options = options.Value; //pamiętaj, że opcje będziesz miał we właściwości Value
}
public void SendMail(string msg, string subject)
{
}
}
Zobacz jak sprytnie pozbyliśmy się tych brzydkich stałych z kodu na rzecz opcji trzymanych w odpowiednim obiekcie.
Plusy:
IOptions jest zarejestrowane jako singleton
Może być wstrzyknięte do każdego obiektu niezależnie od jego cyklu życia (Scoped, Singleton, czy Transient)
Minusy:
Odczytuje konfigurację TYLKO podczas uruchamiania systemu – to moim zdaniem jest najważniejsza kwestia. Przy niektórych opcjach to będzie wystarczające, przy innych nie.
Nie pozwala na „named options” (o tym za chwilę)
Interfejs IOptionsSnapshot<T>
Przykład wstrzyknięcia:
public EmailService(IOptionsSnapshot<EmailOptions> options)
{
this.options = options.Value;
}
Czyli dokładnie tak samo. Różnice natomiast są trzy.
Plusy:
daje Ci aktualne opcje – nawet jeśli zmienią się w pliku – bez konieczności restartu aplikacji
obsługuje „named options”, o czym później
Minusy:
zarejestrowane jako scoped – odczytuje opcje z każdym requestem, jednak nie wstrzykniesz tego do serwisów rejestrowanych jako singleton.
Interfejs IOptionsMonitor<T>
To wygląda trochę jak hybryda dwóch poprzednich interfejsów.
jest rejestrowany jako singleton, więc może być wstrzyknięty do serwisu niezależnie od jego cyklu życia
potrafi zaktualizować opcje, gdy się zmienią – bez restartu aplikacji
obsługuje „named options”
Użycie tego jest nieco bardziej skomplikowane. Oto przykład:
Pierwsza różnica jest taka, że nie trzymasz w swoim serwisie obiektu klasy EmailOptions tak jak to było do tej pory. Zamiast tego trzymasz cały monitor. A gdy potrzebujesz odczytać AKTUALNE opcje, posługujesz się właściwością CurrentValue tego monitora.
Teraz jeśli opcje fizycznie zostaną zmienione (np. w pliku appsettings.json), tutaj będziesz miał aktualne wartości – bez potrzeby restartowania aplikacji.
UWAGA! Zmiany zmiennych środowiskowych nie będą uwzględnione.
Masz tutaj różne ustawienia dla maili serwisowych i newslettera. Nie musisz tworzyć całej takiej struktury klas. Zwróć uwagę na to, że zarówno ServiceMailing jak i NewsletterMailing mają dokładnie takie same pola. Dokładnie też takie, jak klasa EmailOptions.
Możesz się tutaj posłużyć IOptionsSnapshot lub IOptionsMonitor, żeby wydobyć konkretne ustawienia (przypominam – IOptions nie obsługuje named options).
Najpierw trzeba jednak skonfigurować opcje, przekazując ich nazwy:
w pierwszym parametrze podajesz „nazwę zestawu opcji” – po tej nazwie będziesz później pobierał opcje do obiektu
w drugim pobierasz konkretną sekcję, w której są umieszczone te dane (tak jak do tej pory)
Teraz możesz odpowiednie opcje odczytać w taki sposób:
public class EmailService
{
EmailOptions serviceMailOptions;
EmailOptions newsletterMailOptions;
public EmailService(IOptionsSnapshot<EmailOptions> options)
{
serviceMailOptions = options.Get("ServiceMailing");
newsletterMailOptions = options.Get("NewsletterMailing");
}
}
Odłóż wczytywanie opcji na później
Odraczanie czytania opcji może być przydatne dla twórców bibliotek. Więc jeśli tego nie robisz, możesz śmiało opuścić ten akapit. Jeśli Cię to interesuje, to rozwiń go:
Kliknij tu, żeby rozwinąć ten akapit
Przedstawię trochę bezsensowny przykład, ale dzięki temu załapiesz jak wczytywać konfigurację później.
Pomyśl sobie, że tworzysz jakąś bibliotekę do wysyłania maili. Użytkownik może ją skonfigurować jak w powyższych przykładach:
Użytkownik może podać oczywiście dowolne dane. Ale Ty chcesz w swojej bibliotece mieć pewność, że jeśli FromEmail zawiera słowo „admin”, to pole From będzie zawierało „Admin”. Czyli konfiguracja taka jak poniżej będzie niepoprawna:
Wtedy EmailService otrzyma niepoprawne ustawienia (właściwość From znów będzie zawierała „John Rambo”).
Post konfiguracja
.NET przeprowadza konfigurację w dwóch etapach. Możesz posłużyć się metodą services.Configure lub services.PostConfigure.
.NET najpierw zbuduje CAŁĄ konfigurację, która została zarejestrowana metodą Configure (skrótowo powiedzmy, że „zbuduje wszystkie wywołania Configure”). A w drugim kroku zbuduje CAŁĄ konfigurację zarejestrowaną metodą PostConfigure. I teraz jeśli zmienisz kod swojej biblioteki:
w taki sposób, że zamiast Configure użyjesz PostConfigure, to wszystko zadziała. EmailService otrzyma poprawne dane.
Pewnie zapytasz teraz – „No dobrze, a czy użytkownik nie może użyć PostConfigure i znowu nadpisać mi opcje?” – pewnie, że może i nadpisze. Tak jak mówiłem na początku – to niezbyt udany przykład, ale chyba załapałeś o co chodzi z odroczoną konfiguracją 🙂 Walidację opcji tak naprawdę powinno się robić inaczej…
Jeśli spotkałeś się z przykładem z życia, gdzie PostConfigure jest lepsze albo pełni ważną rolę – daj znać w komentarzu.
Dobre praktyki
Jest kilka dobrych praktyk, które powinieneś stosować przy opcjach i naprawdę warto je stosować. Zdecydowanie mogą ułatwić Ci życie.
Twórz różne środowiska
Przede wszystkim, twórz w swoim projekcie różne środowiska. Development i Production to obowiązkowe minimum. Po prostu upewnij się, że masz takie pliki:
appsettings.json – ustawienia dla wersji produkcyjnej
appsettings.Development.json – ustawienia dla wersji developerskiej.
Tych plików możesz tworzyć znacznie więcej, np:
appsettings.Staging.json – ustawienia dla wersji przedprodukcyjnej (ostateczne testy przed wydaniem)
appsettings.Testing.json – jakieś ustawienia np. dla testów integracyjnych
appsettings.Local.json – jakieś typowe ustawienia dla środowiska lokalnego – Twojego komputera, na którym piszesz kod.
Pamiętaj, że o środowisku świadczy zmienna środowiskowa ASPNETCORE_ENVIRONMENT. Ona musi przyjąć jedną z nazw Twoich środowisk (Development, Production, Staging…). Jeśli tej zmiennej nie ma w systemie – uznaje się, że jest to wersja produkcyjna.
Pamiętaj, że pliki appsettings*.json lądują w repozytorium kodu. Chyba, że zignorujesz je w swoim systemie kontroli wersji. Jeśli tworzysz plik appsettings.Local.json – powinieneś automatycznie wyrzucać go z kontroli wersji.
Do trzymania wrażliwych danych używaj pliku secrets.json lub (w przypadku produkcji) – Azure KeyVault – jak to zrobić opiszę w osobnym artykule (zapisz się na newsletter lub polub stronę na fejsie, żeby go nie przegapić :)).
Nie używaj stringów (jako identyfikatorów) bezpośrednio
To chyba dotyczy wszystkiego – nie tylko opcji. Posługuj się w tym celu stałymi lub operatorem nameof. Np. zamiast wklepywać:
services.Configure<EmailOptions>(Configuration.GetSection("EmailSettings")); //nazwa sekcji na sztywno
wykorzystaj stałe:
public class EmailOptions
{
public const string EmailOptionsSectionName = "EmailSettings";
public string SmtpAddress { get; set; }
public string From { get; set; }
public string FromEmail { get; set; }
}
//
services.Configure<EmailOptions>(Configuration.GetSection(EmailOptions.EmailOptionsSectionName));
Sprawdzaj poprawność swoich opcji
Swoje opcje możesz walidować przez DataAnnotation (standard) lub FluentValidation (osobna biblioteka) i faktycznie powinieneś to robić, jeśli opcje mają jakieś ograniczenia lub z jakiegoś powodu mogą być niepoprawne.
To tyle, jeśli chodzi o zarządzanie opcjami w .NET. Jak pisałem wyżej – są jeszcze dwa aspekty, które na pewno będę chciał poruszyć w osobnych artykułach – walidajca opcji i odczytywanie opcji z Azure KeyVault. Być może napiszę też artykuł o tworzeniu własnego ConfigurationProvidera.
Jeśli znalazłeś w artykule jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu 🙂
Obsługujemy pliki cookies. Jeśli uważasz, że to jest ok, po prostu kliknij "Akceptuj wszystko". Możesz też wybrać, jakie chcesz ciasteczka, klikając "Ustawienia".
Przeczytaj naszą politykę cookie