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. 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 😉
.NET8 wyszedł już oficjalnie jakiś czas temu. Przez ten czas sprawdzałem co i jak się zmieniło w tej ostatecznej wersji. Ale pracy przy tym było na tyle dużo, że w przyszłości jednak będę to robił na bieżąco w wersjach preview.
W tym artykule opisuję większość zmian i nowości, jakie zostały dla nas przygotowane. Elementy, które w pewnych okolicznościach mogą być lub są breaking changem, zaznaczyłem w kolorze czerwonym.
Weź pod uwagę, że to nie jest w 100% pełna lista. Wybrałem najciekawsze i najbardziej przydatne rzeczy wg mnie. Jeśli uważasz, że coś więcej powinno się znaleźć w tym artykule, daj znać w komentarzu.
Przede wszystkim, .NET8 w przeciwieństwie do siódemki jest oznaczony jako LTS (long-time-support). Co oznacza, że będzie oficjalnie wspierany przez 3 najbliższe lata. Więc jeśli tworzysz nowy system i wahasz się między wersją 7, a 8, to zdecydowanie powinieneś wybrać ósemkę, chociażby z tego względu.
Serializacja JSON
Doszło kilka nowości i usprawnień jeśli chodzi o serializację JSON:
dodano wsparcie dla dużych liczb: Int128 i UInt128 (a także dla Half – float16)
Tablice bajtów serializowane na Memory<byte> i ReadOnlyMemory<byte> domyślnie zmieniane są na Base64
JsonSerializer.Serialize(new byte[] { 1, 2, 3 }); // da w efekcie "AQID"
Deserializacja obiektu z brakującymi właściwościami
W .NET8 mamy teraz wybór, co zrobić gdy deserializujemy jsona do obiektu, który nie ma niektórych danych.
Załóżmy, że masz taką klasę:
internal class JsonData
{
public int Id { get; set; }
}
I takiego JSONa:
{
"Id": 42,
"AnotherId": -1
}
Po staremu, jeśli takiego jsona chciałeś deserializować do klasy JsonData, wszystko było ok. Po nowemu domyślnie też tak jest, a więc nie jest to żaden breaking change. Natomiast masz wybór, co zrobić w takiej sytuacji. Możesz się na to nie zgodzić, ustawiając odpowiedni atrybut na klasie:
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
internal class JsonData
{
public int Id { get; set; }
}
Teraz, JsonSerializer.Deserialize wywali się.
Serializacja enumów
Do tej pory mieliśmy dostępny specjalny konwerter JsonStringEnumConverter. Teraz mamy dostępną jego generyczną wersję JsonStringEnumConverter<T>. Ma to związek tylko z kompilacją AOT. Wersja generyczna nie wykorzystuje refleksji.
Nowe polityki nazw atrybutów
Do tej pory, parsując JSONa, mogliśmy używać polityki camelCase. Dawała ona tyle, że nazwa atrybutu w JSON mogła być napisana właśnie tak:
{
"nazwaMojegoPola": "wartość"
}
Oczywiście, była opcja żeby sobie dopisać własne polityki. Teraz jednak mamy dodatkowe, których możemy użyć: snake_case i kebab-case. Możemy to ustawić w opcjach serializacji dokładnie w taki sam sposób, jak ustawialiśmy camelCase.
Deserializacja pól tylko do odczytu
Załóżmy, że mamy taki model:
class Person
{
public int Id { get; set; }
public string Name { get; set; }
}
i prostą klasę, która go używa:
class Data
{
public Person Person { get; } = new();
}
Zauważ, że właściwość Person nie ma żadnego settera. W poprzednich wersjach .NET, taka deserializacja po prostu nie działała:
string json = "{\"Person\":{\"Id\": 5, \"Name\": \"Stefan\"}}";
Data d = JsonSerializer.Deserialize<Data>(json); //właściwość Person miała domyślne dane
Teraz, wystarczy że klasę Data opatrzysz odpowiednim atrybutem: [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] i właściwość Person zostanie wypełniona odpowiednimi danymi.
Pamiętaj tylko, że klasa Person musi mieć przy swoich właściwościach settery.
Takie działanie możesz też ustawić globalnie w aplikacji:
Od .NET6 mamy możliwość (de)serializacji obiektów do JSON za pomocą wygenerowanego specjalnego kodu (kompilacja AOT), zamiast refleksji. Jest to szybsze (wg testów MS nawet 40%), jednak też ma minusy. Nie jest to artykuł o tym i jeśli nie robiłeś tego wcześniej, możesz pominąć ten fragment.
Przed .NET8 można było dodać własny JsonSerializationContext do opcji: JsonSerializerOptions.AddContext<TContext>. Teraz ta metoda jest oznaczona jako obsolete, ale mamy coś lepszego do dyspozycji. A mianowicie: TypeInfoResolverChain. Teraz można dodawać te konteksty swobodnie i swobodnie je usuwać:
Możliwość blokowania (de)serializacji opartej na refleksji
Możesz zablokować domyślną (de)serializację opartą na refleksji. Jeśli potrzebujesz zrobić bibliotekę AOT lub po prostu chcesz pokombinować z wydajnością. Wystarczy, że dodasz w pliku projektu odpowiedni wpis:
Potem, jeśli chcesz sprawdzić, czy refleksja jest dostępna, możesz posłużyć się właściwością JsonSerializer.IsReflectionEnabledByDefault
Używanie konstruktorów z parametrami
Domyślnie, JsonSerializer używa publicznego bezparametrowego konstruktora. Jednak możesz zmienić to zachowanie, używając odpowiedniego atrybutu:
public class MyClass
{
public int Data { get; private set; }
public MyClass()
{
}
public MyClass(string data)
{
Data = Convert.ToInt32(data);
}
[JsonConstructor]
internal MyClass(int data)
{
Data = data;
}
}
Zauważ, że w powyższym kodzie mamy 3 konstruktory. Jeden bez parametrów, drugi z jakimś parametrem string i trzeci, nie dość że oznaczony jako internal, to też z parametrem typu int.
Ze wszystkich konstruktorów możemy swobodnie korzystać. Dodatkowo JsonSerializer będzie korzystał tylko z konstruktora oznaczonego atrybutem JsonConstructor. Nie jest ważne, że jest to konstruktor wewnętrzny. JsonSerializer właśnie z niego skorzysta, przekazując w parametrze odpowiednią wartość.
UWAGA! W klasie możesz mieć tylko jeden konstruktor oznaczony tym atrybutem.
Jeśli chcesz zdeserializować jsona do takiego obiektu poza mechanizmem webowym (żądanie w kontrolerze), musisz stworzyć odpowiednie opcje:
string json = "{\"data\": 5}";
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var obj = JsonSerializer.Deserialize<MyClass>(json, options);
UWAGA! Ta forma deserializacji używa refleksji. Więc jeśli masz wyłączone używanie refleksji za pomocą np. właściwości JsonSerializerIsReflectionEnabledByDefault, wtedy przy próbie deserializacji dostaniesz błąd od Jsona.
Abstrakcje dla czasu
O, tego czasami brakowało i trzeba było samemu sobie robić takie abstrakcje, jeśli pewne rzeczy chciałeś mockować w testach jednostkowych. Teraz mamy to w standardzie.
W .NET8 powstała klasa abstrakcyjna TimeProvider, po której możemy dziedziczyć lub używać domyślnego, lokalnego czasu. Poniższy kod pokazuje te operacje:
internal class FakeTime : TimeProvider //tworzymy fake'ową klasę; można to też po prostu zamockować
{
public DateTimeOffset Now { get; set; }
public FakeTime(DateTimeOffset value)
{
Now = value;
}
public override DateTimeOffset GetUtcNow()
{
return Now;
}
}
internal class TimeProviderTest
{
public void Foo()
{
Bar(TimeProvider.System); //używamy domyślnej implementacji
TimeProvider fakeTime = new FakeTime(new DateTimeOffset(2000, 01, 01, 00, 00, 00, TimeSpan.Zero));
Bar(fakeTime); //używamy własnej implementacji
}
public void Bar(TimeProvider time)
{
Console.WriteLine("Aktualny czas: " + time.GetLocalNow()); //pobieramy czas z providera
}
}
Timer
Klasa TimerProvider daje nam też do dyspozycji całkiem fajny timerek. Możemy go utworzyć w taki sposób:
TimerCallback callback – delegat, który będzie cyklicznie uruchamiany. To po prostu metoda, która zwraca void i przyjmuje w parametrze nullable object: void Foo(object? state). Bardzo podobnie jak w ParametrizedThreadStart,
object? state – dane, które możemy przekazać do naszej metody. Jak widzisz, to może być dowolny obiekt,
TimeSpan dueTime – czas, jaki ma upłynąć od utworzenia timera, do pierwszego wywołania metody callback,
TimeSpan period – interwał uruchomienia metody callback. Czyli jeśli tu ustawimy np. 5 sekund, to metoda callback będzie wywoływana co 5 sekund.
ITimer ma też metodę Change, w której możesz zmienić interwał wywołań metody callback.
UWAGA! Timer różni się od tych, które możesz pamiętać z WinForms, czy starszych technologii. Ten timer wywołuje metodę callback na wątku branym z ThreadPoola. To oznacza, że metoda callback wywoływana jest na innym wątku. Pamiętaj o tym.
Co więcej, ITimer implementuje interfejs I(Async)Disposable. To znaczy, że musimy go zwalniać. Oczywiście tutaj zwykłe using ITimer... raczej nie wchodzi w grę, bo jest duża szansa, że timer zakończy swój żywot nim metoda callback się uruchomi.
Tak, jak inne tego typu timery, także ten ma ograniczony interwał, w którym może pracować. Ograniczenie jest spowodowane rozdzielczością zegara systemowego, którą możesz przyjąć na 15 milisekund. Jeśli podasz interwał mniejszy niż te 15 milisekund, to i tak, będzie pracował ze swoją minimalną.
Pomiar czasu między operacjami
TimeProvider umożliwia również mierzenie czasu między operacjami. Do tej pory mogliśmy stosować inne rozwiązania, np. Stopwatch. Tutaj mamy dokładnie to samo, tylko ładniej obudowane, spójrz na ten kod:
var start = TimeProvider.System.GetTimestamp();
Thread.Sleep(1025);
var elapsed = TimeProvider.System.GetElapsedTime(start);
System.Console.WriteLine("To trwało: " + elapsed);
//To trwało: 00:00:01.0326601
Metoda GetElapsedTime po prostu policzy różnicę między aktualnym Timestamp, a tym podanym. Jest jeszcze druga wersja, która liczy różnice pomiędzy dwoma timestampami.
Wywalenie ISystemClock
Wprowadzenie TimerProvider deaktualizuje interfejs ISystemClock, który być może ktoś z Was używał jako abstrakcję czasu. Jeśli tak, koniecznie zmień na TimerProvider.
Operacje losowe
Mamy kilka nowych metod, których możemy użyć do pracy z losowością.
GetItems<T>()
Metoda GetItems zwraca nam losowo wybrane elementy z jakiejś tablicy/listy/czegokolwiek. Spójrz na poniższy przykład, który mógłby zostać użyty do generowania haseł w systemie. Tylko dwie linijki:
Tablica chars zawiera wszystkie dostępne elementy, z których będziemy losować. Metoda GetItems losuje przekazaną ilość elementów (tutaj 10) z podanej tablicy wszystkich.
Oczywiście to nie ogranicza się do tablicy znaków. Możemy losować wszystko z każdej kolekcji (konwertując kolekcję na tablicę lub ReadOnlySpan).
Shuffle<T>()
Ta metoda, niby nowa, ale nie wymaga wyjaśniania. Jedna z moich ulubionych opcji w odtwarzaczu WinAmp 🙂 Metoda Shuffle, prawdopodobnie ze względu na optymalizację, miesza tylko w istniejącej tablicy / spanie. Tak więc jeśli masz listę, którą chcesz pomieszać, musisz ją najpierw przerzucić do tablicy. Dajmy trochę bardziej skomplikowany przykład – z listą obiektów, którą chcemy pomieszać:
class IdItem
{
public int Id { get; set; }
}
class RandomTestRunner
{
public static void Run()
{
List<IdItem> data = new();
for(int i = 1; i < 100; i++)
data.Add(new IdItem { Id = i });
var arr = data.ToArray();
Random.Shared.Shuffle(arr);
data = new(arr);
}
}
Najpierw tworzę sobie listę obiektów. Potem tworzę klasyczną tablicę z tej listy. Tablicę przekazuję do metody Shuffle. Ona tam sobie tą tablicę miesza. Na koniec tworzę listę z pomieszanej tablicy – obiekty są już w losowej kolejności.
Uważam że mimo swojej prostoty, te metody są całkiem fajne. Rzadko kiedy robię jakieś randomowe operacje, ale jak już robię, to musiałem takie rzeczy pisać sam. A teraz mamy gotowy mechanizm.
Nowe typy stworzone dla szybkości
.NET 8 wprowadził kilka typów, których zadaniem jest przyspieszenie niektórych operacji. Nie korzystałem z tego w rzeczywistych projektach, więc nie mogę powiedzieć, jak to faktycznie działa, ale postanowiłem zrobić mały benchmark. Zobaczmy po kolei.
System.Collections.Frozen
Ten namespace daje nam dwie nowe kolekcje – FrozenDictionary<TKey, TValue> i FrozenSet<T>. Są one tylko do odczytu. Jak już stworzysz, nie możesz niczego w nich zmienić. Teoretycznie zostały poczęte do tego, żeby operacje odczytu były szybsze niż w ich odpowiednikach. Sprawdźmy to.
Ring wolny
W lewym narożniku mamy klasyczne Dictionary<int, int>, a w prawym czeka FrozenDictionary<int, int>. Testy zostały przeprowadzone dla 12 przypadków. Różniły się:
ilość wpisów (1000, 10 000, 100 000, 1000 000)
ilość odczytów (1, 10, 100)
Kod, który był testowany wyglądał tak:
int result = 0;
for (int i = 0; i < Reads; i++)
result += dict[i];
return result;
Gdzie Reads to oczywiście ilość odczytów. Wyniki są jednoznaczne. Frozen wygrywa:
Method
ElementsCount
Reads
Mean
Ratio
RatioSD
ReadDictionary
1000
1
11.173 ns
1.00
0.00
ReadFrozen
1000
1
7.047 ns
0.64
0.11
ReadDictionary
1000
10
97.220 ns
1.00
0.00
ReadFrozen
1000
10
61.455 ns
0.64
0.08
ReadDictionary
1000
100
1,000.912 ns
1.00
0.00
ReadFrozen
1000
100
566.589 ns
0.57
0.07
ReadDictionary
10000
1
10.008 ns
1.00
0.00
ReadFrozen
10000
1
6.577 ns
0.70
0.08
ReadDictionary
10000
10
93.015 ns
1.00
0.00
ReadFrozen
10000
10
57.464 ns
0.62
0.05
ReadDictionary
10000
100
963.958 ns
1.00
0.00
ReadFrozen
10000
100
654.683 ns
0.69
0.11
ReadDictionary
100000
1
10.472 ns
1.00
0.00
ReadFrozen
100000
1
6.879 ns
0.67
0.12
ReadDictionary
100000
10
98.492 ns
1.00
0.00
ReadFrozen
100000
10
62.093 ns
0.64
0.09
ReadDictionary
100000
100
1,075.513 ns
1.00
0.00
ReadFrozen
100000
100
614.143 ns
0.58
0.09
ReadDictionary
1000000
1
10.387 ns
1.00
0.00
ReadFrozen
1000000
1
6.834 ns
0.66
0.08
ReadDictionary
1000000
10
89.935 ns
1.00
0.00
ReadFrozen
1000000
10
61.172 ns
0.69
0.06
ReadDictionary
1000000
100
1,013.573 ns
1.00
0.00
ReadFrozen
1000000
100
620.679 ns
0.62
0.08
System.Buffer.SearchValues<T>
SearchValues to swego rodzaju tablica. Używamy jej, gdy często szukamy jakiś wartości. Np. jeśli często byśmy szukali samogłosek w stringach, można by było napisać taki kod:
string str = "To jest jakiś tekst napisany przez Adama"; //tekst, w którym będziemy szukali
string chars = "aeiou"; //samogłoski, których szukamy
ReadOnlySpan<char> needles = new ReadOnlySpan<char>(chars.ToArray()); //teraz musimy skonstruować Span
SearchValues<char> values = SearchValues.Create(needles); //to przyjmuje tylko ReadOnlySpan
var result = str.AsSpan().IndexOfAny(values); //no i szukamy
SearchValues niestety może być utworzone jedynie z ReadOnlySpan byte'ów lub char'ów.
Generalnie powstało to po to, żeby optymalizować częste szukanie pewnych danych. Wszelkie optymalizacje są robione w momencie wywołania SearchValues.Create <– to tutaj dzieje się cała magia. Dlatego pamiętaj, że żeby to miało sens, metoda Create powinna być użyta tylko raz, a Twoje SearchValues powinno żyć tak długo, jak długo z niego korzystasz.
To ma być w przyszłości używane również przez operacje Regex, które dzięki temu mogą stać się szybsze. Z tego, co wiem, na moment pisania tego artykułu w .NET8 to jeszcze nie jest ogarnięte.
A teraz sprawdźmy przykładowy program. Sprawdzanie, czy string zawiera jedynie duże i małe litery bez znaków specjalnych. Sprawdzimy trzy metody:
[MemoryDiagnoser]
public class BufferTestBenchmark
{
private static SearchValues<char> _searchValues;
private string _allowedChars = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM";
[Params("qoiudhjkfdsbnfmaQOIUDHJKFDSBNFMA", "qoiudhjkfdsbnfm@QOIUDHJKFDSBNFM@", "@oiudhjkfdsbnfma@OIUDHJKFDSBNFMA")]
public string Value { get; set; }
public BufferTestBenchmark()
{
_searchValues = SearchValues.Create(_allowedChars.AsSpan());
}
[Benchmark(Baseline = true)]
public bool UsingSearchValues()
{
return !Value.AsSpan().ContainsAnyExcept(_searchValues);
}
[Benchmark]
public bool UsingContains()
{
foreach(var ch in Value)
{
if (!_allowedChars.Contains(ch))
return false;
}
return true;
}
[Benchmark]
public bool UsingSpan()
{
return !Value.AsSpan().ContainsAnyExcept(_allowedChars);
}
}
Czyli mamy string, który na początku ma specjalny znak, na końcu i tak, który nie ma go w ogóle (czyli jest zgodny z założeniami). Wyniki wyglądają następująco:
Method
Value
Mean
Ratio
RatioSD
UsingSearchValues
@oiud(…)BNFMA [32]
8.984 ns
1.00
0.00
UsingContains
@oiud(…)BNFMA [32]
7.922 ns
0.89
0.14
UsingSpan
@oiud(…)BNFMA [32]
116.279 ns
13.13
1.58
UsingSearchValues
qoiud(…)BNFM@ [32]
9.134 ns
1.00
0.00
UsingContains
qoiud(…)BNFM@ [32]
90.065 ns
10.12
1.76
UsingSpan
qoiud(…)BNFM@ [32]
119.263 ns
13.39
2.25
UsingSearchValues
qoiud(…)BNFMA [32]
6.140 ns
1.00
0.00
UsingContains
qoiud(…)BNFMA [32]
203.261 ns
33.54
4.97
UsingSpan
qoiud(…)BNFMA [32]
114.471 ns
18.97
2.55
Jak widać, w większości użycie SearchValues daje ogromne różnice na plus. Zatem jednoznacznie wychodzi, że jeśli robimy jakieś częste przeszukiwania konkretnych elementów, zdecydowanie warto zastanowić się nad SearchValues.
Formatowanie stringów – CompositeFormat
Ok, ogólnie rzecz biorąc mamy do dyspozycji trzy metody formatowania stringów (nie licząc niektórych przeciążeń metod w stylu Console.Write, które i tak sprowadzają się do użycia string.Format). A są to:
string.Format, który jest od początku
interpolacja stringów
i nowość – klasa CompositeFormat
Czy to nie jest jakaś klęska urodzaju? Nie.
Na początku był string.Format. Ogarniał wszystkie możliwe przypadki. Potem, wiele lat później doszły interpolowane stringi, czyli coś takiego:
int i = 10;
string s = $"Cześć, jestem interpolowanym stringiem. Wartość zmiennej to: {i}";
Początkowo interpolowane stringi pod spodem sprowadzały się do wykonania string.Format. Później jednak były w jakiś sposób optymalizowane. A i ich zapis wydaje się bardziej czysty.
Ale co zrobić w momencie, gdy nie znasz formatu stringa w czasie pisania apki? Na przykład ten format przychodzi z zasobów albo z innego miejsca?:
int data = 10;
string format = _resourceProvider.GetFormatForString();
string result = string.Format(format, data);
Wciąż musimy używać string.Format. Najczęściej taka sytuacja będzie związana pewnie z pobieraniem stringów z tłumaczeń.
Problem leży w tym, że takie rozwiązanie nie jest idealne. Na pewno można szybciej to rozwiązać… No i w .NET8 zrobili nam klasę CompositeFormat. Ta klasa w pewien sposób kompiluje sobie format, który ma użyć. I robi to tylko raz. String.Format robił coś takiego przy każdym użyciu. Dzięki czemu CompositeFormat wydaje się być bardziej wydajne… Sprawdźmy to.
Testujemy prędkość
Kod, który testuję wygląda tak:
[MemoryDiagnoser]
public class StringFormatBenchmark
{
private static string format = "Hello {0}, today is {1}";
private static readonly CompositeFormat s_cFormat = CompositeFormat.Parse(format);
[Benchmark(Baseline = true)]
public string FormatTest()
{
string result = string.Format(format, "Adam", DateTime.Now);
return result;
}
[Benchmark]
public string CompositeFormatTest()
{
string result = string.Format(null, s_cFormat, "Adam", DateTime.Now);
return result;
}
[Benchmark]
public string InterpolatedTest()
{
string result = $"Hello {"Adam"}, today is {DateTime.Now}";
return result;
}
}
Jak widać, użycie CompositeFormat jest szalenie proste. Grunt, żeby to było utworzone tylko raz. A potem i tak wykorzystujemy odpowiednie przeciążenie string.Format.
Wersję ze stringiem interpolowanym dodałem tylko ze względu na dopełnienie obrazu. Pamiętaj, że jednak chodzi o dwa różne scenariusze – interpolowane stringi zawsze znają format. String.Format dostaje go w trakcie trwania aplikacji. Spójrzmy na wyniki:
Method
Mean
Error
StdDev
Ratio
RatioSD
Gen0
Allocated
Alloc Ratio
FormatTest
494.9 ns
10.67 ns
28.84 ns
1.00
0.00
0.0801
128 B
1.00
CompositeFormatTest
468.4 ns
10.04 ns
27.66 ns
0.95
0.07
0.0658
104 B
0.81
InterpolatedTest
458.3 ns
13.24 ns
37.78 ns
0.93
0.09
0.0658
104 B
0.81
Tutaj widać, że string.Format i CompositeFormat mają bardzo podobny czas. CompositeFormat jest nieco szybszy, ale czy to będzie zauważalne w prawdziwej apce? Ciężko powiedzieć. Jeśli jednak ktoś zapytałby się mnie, czy warto zmieniać klasyczny string.Format na CompositeFormat, to zdecydowanie powiedziałbym, że nie. Jeśli jednak piszesz nową apkę, przemyśl to, bo w kolejnych wersjach CompositeFormat może dostać większego kopa.
Walidacja danych
W .NET8 mamy kilka nowych atrybutów DataAnnotations. Nie będę ich dokładnie opisywał, bo ich użycie jest dokładnie takie samo lub analogiczne. Po prostu wiedz, że zostały dodane:
RangeAttribute
RangeAttribute.MinimumIsExclusive – określa, czy wartość minimalna w [Range] jest otwarta (true), czy zamknięta. Innymi słowy, czy ta wartość ma się zawierać w zakresie,
RangeAttribute.MaximumIsExclusive – analogicznie jak wyżej, tylko dotyczy to maksymalnej wartości. Np.:
public class Model
{
[Range(18, 30, MinimumIsExclusive = true, MaximumIsExclusive = false)]
public int Age { get; set; }
}
Oznacza, że Age powinien być w zakresie (18, 30>, czyli 18 nie spełnia wymagań (MinimumIsExclusive). Czyli mamy tutaj od 19 do 30 włącznie (MaximumIsExclusive = false).
LengthAttribute
Dodano drugą wartość – maksymalną. Dzięki temu można ustawić walidację na stringa (lub kolekcję), która sprawdzi, czy string ma między min i maks znaków. Np.:
public class Model
{
[Length(3, 12)]
public string Name { get; set; }
}
Teraz Name powinno mieć między 3 i 12 znaków włącznie (3 też będzie ok).
Base64Attribute
O, to jest dość fajne. Sprawdza, czy string jest prawidłowym stringiem w formacie Base64:
public class Model
{
[Base64String]
public string Data { get; set; }
}
AllowedValuesAttribute i DeniedValuesAttribute
One sprawdzają, czy przekazany string znajduje się na liście zezwolonych lub zabronionych stringów:
public class Model
{
[AllowedValues("mama", "tata", "koń")]
[DeniedValues("koza")]
public string Data { get; set; }
}
Niestety te atrybuty działają tylko na stringach. Nie działają na kolekcjach ani na obiektach innych klas pomimo, że w konstruktorze przyjmują tablicę object.
Zipowanie do strumienia
Dostaliśmy nowe operacje zipowania, dzięki którym możemy pakować wszystkie pliki, znajdujące się w konkretnym katalogu, do strumienia.
using (var stream = new MemoryStream())
{
ZipFile.CreateFromDirectory("dataToZip", stream, CompressionLevel.SmallestSize, false);
}
Te przeciążenia powstały właśnie do tego, żeby nie robić operacji na plikach. Żeby bez sensu nie obciążać dysków, jeśli nie trzeba.
Dodatki w Dependency Injection
Chłopaki w .NET8 wprowadzili KeyedServices. Polega to na tym, że możesz zarejestrować w dependency injection różne serwisy pod różnymi indeksami dla jednego interfejsu.
Załóżmy, że mamy interfejs ISomeService:
public interface ISomeService
{
void Foo();
}
I teraz chcesz zarejestrować dwa różne serwisy dla tego interfejsu w dwóch „pulach”. Tak to by chyba można było nazwać. Spójrz na rejestrację:
Tutaj są zarejestrowane dwa serwisy pod różnymi kluczami (user i admin). Jednak to prowadzi do pewnego problemu. Jak je pobrać? Tak nie da rady:
public SomeController(ISomeService someService)
{
_someService = someService;
}
Apka się po prostu wywali. Musimy jasno określić, z którego klucza chcemy pobrać dany serwis. Służy do tego odpowiedni atrybut:
public SomeController([FromKeyedServices("user")]ISomeService someService)
{
_someService = someService;
}
Można to zrobić też bardziej dynamicznie. Najprostszy mechanizm, który tworzyłby odpowiedni serwis w zależności od zalogowanego użytkownika mógłby wyglądać tak:
public class SomeController
{
[HttpGet]
public IActionResult Get()
{
bool loggedIsAdmin = User.HasClaim("admin", "true");
string key = loggedIsAdmin ? "admin" : "user";
ISomeService service = HttpContext.RequestServices.GetRequiredKeyedService<ISomeService>(key);
service.Foo();
return Ok();
}
}
Oczywiście tutaj musimy pobierać serwis ręcznie. Nie da się wstrzyknąć do konstruktora serwisu w zależności od zalogowanego użytkownika. Przynajmniej nie w standardzie.
Oczywiście zawsze można sobie wstrzyknąć IServiceProvider, jednak jeśli to jest robione poza jakąś fabryką, to wtedy raczej będzie złym rozwiązaniem, które nam zrobi Abstract Service Locatora.
Dodatki do IHostedService, czyli większa kontrola nad cyklem życia
O interfejsie IHostedService i po co to jest pisałem w tym artykule – jak korzystać z dobrodziejstw Dependency Injection i tych wszystkich mechanizmów w aplikacji konsolowej (i nie tylko).
Natomiast w .NET8 doszedł nowy interfejs IHostedLifecycleService. On implementuje już znany nam IHostedService, dodając kilka metod, dzięki którym masz większą kontrolę nad cyklem życia Twojej aplikacji.
Więc albo implementujesz IHostedService do swojego hosta i nic się dla Ciebie nie zmienia, albo implementujesz IHostedLifecycleService i dostajesz dodatkową kontrolę nad cyklem życia. A metody wywoływane są w takiej kolejności:
IHostLifetime.WaitForStartAsync
IHostedLifecycleService.StartingAsync
IHostedService.Start
IHostedLifecycleService.StartedAsync
IHostApplicationLifetime.ApplicationStarted
IHostedLifecycleService.StoppingAsync
IHostApplicationLifetime.ApplicationStopping
IHostedService.Stop
IHostedLifecycleService.StoppedAsync
IHostApplicationLifetime.ApplicationStopped
IHostLifetime.StopAsync
Zmiany w konfiguracji aplikacji
Walidacja opcji bez użycia refleksji
O walidacji opcji pisałem już w tym artykule. Natomiast w .NET8 wszedł mechanizm, który możemy używać w kompilacjach AOT – nie używa refleksji. Załóżmy, że model, który chcesz walidować wygląda tak:
public class MyAppConfig
{
[EmailAddress]
[Required]
public string SmtpAdress { get; set; }
[Range(1, 10)]
public int TraceLevel { get; set; }
}
Aby teraz mieć walidację zgodną z AOT, musisz…. poniekąd sam ją napisać. Ale sprowadza się to jedynie do utworzenia pustej klasy:
[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. Na koniec musimy ją jeszcze zarejestrować:
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
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:
Tak! Dorzucili coś do WPFa 🙂 A konkretnie dwie rzeczy.
Przyspieszenie sprzętowe dla RDP
Mniej interesująca dla mnie jest taka, że aplikacje WPF dostępne zdalnie (za pomocą zdalnego pulpitu – RDP) używały softwarowego przyspieszenia graficznego. Teraz mogą używać hardwareowego, czyli są wspomagane kartą graficzną. Musisz jednak na to wyrazić zgodę. Trzeba w pliku runtimeconfig.json wpisać ustawienie Switch.System.Windows.Media.EnableHardwareAccelerationInRdp z wartością true.
Kontrolka do wyboru folderu
Wreszcie! Po tyyylu latach. Już nie trzeba używać third parties albo kontrolki z Win32, żeby dać użytkownikowi możliwość wyboru folderu. Trochę śmieje się przez łzy, że ktoś to wreszcie ogarnął 😀 W .NET8 możemy już wykorzystywać pełnoprawne okienko WPF:
var openFolderDialog = new OpenFolderDialog()
{
Title = "Wybierz folder...",
InitialDirectory = Environment.GetFolderPath(
Environment.SpecialFolder.ProgramFiles)
};
string folderName = "";
if (openFolderDialog.ShowDialog())
{
folderName = openFolderDialog.FolderName;
}
Typowe breaking change
MinimalApi i IFormFile
W .NET8, jeśli w MinimalAPI używasz IFormFile lub IFormFileCollection, teraz będzie wymagane posiadanie antiforgery token. I tak jest domyślnie. Jeśli chcesz z tego zrezygnować (zastanów się, czy na pewno powinieneś), możesz to zrobić w konfiguracji endpointa:
Jeśli posługujesz się zdarzeniami OpenIdConnect (np. OnTokenValidated), to w kontekście takiego zdarzenia jest do dyspozycji SecurityToken. SecurityToken to klasa abstrakcyjna. Wcześniej w tym kontekście była reprezentowana przez JwtSecurityToken, więc jeśli rzutujesz ten token na tą klasę, to Ci wszystko wybuchnie. Teraz JwtSecurityToken zostało wymienione na JsonWebToken.
Usunięte mapowanie separatora ścieżki dla systemów Unixowych
Do tej pory, jeśli podawałeś jakąś ścieżkę, np.:
var fileName = @"path\to\file\file.jpg"
na systemach unixowych separatory ścieżki (backslash) były zamieniane na slashe. Czyli to \ na to /. Od werji .NET8 tego nie ma. Usunięcie tego mapowania ma związek z różnymi problemami z uruchomieniem aplikacji, które się zdarzały. Jak sobie z tym poradzić?
nie hardkoduj znaków separatora ścieżki. Zamiast tego używaj zmiennej Path.DirectorySeparatorChar,
jeśli na systemach unixowych wywołujesz polecenie dotnet, przekazując mu jakieś ścieżki, upewnij się że to są slashe (/), a nie backslashe(\),
na systemach unixowych zaktualizuj wszystkie zmienne środowiskowe, które zawierają ścieżki – właśnie w taki sposób, żeby backslashe zamienić na slashe.
Rzucanie wyjątku przy próbie zapisu do zamkniętego pliku
Wcześniej, jeśli próbowałeś zapisać coś do zamkniętego pliku (za pomocą FileStream), był rzucany wewnętrzny wyjątek, jednak był on zupełnie ignorowany. Nic się w pliku nie zapisywało, a rezultat operacji wskazywał na powodzenie. Czyli „operacja się udałą, pacjent umarł”.
Teraz, w .NET8, próba takiego zapisu zakończy się rzuceniem wyjątku IOException. Miej to na uwadze.
Konwersja DMTF na DateTime
Do tej pory metoda ManagementDateTimeConverter.ToDateTime(String) zwracała obiekt DateTime z DateTime.Kind ustawionym na wartość Unspecified. Teraz ta wartość jest ustawiona na Local. Więc jeśli na niej polegałeś, koniecznie sprawdź swój kod.
Inne zmiany
W .NET8 wprowadzono też szereg zmian, o których nie piszę w tym artykule z różnych powodów. Niemniej jednak chcę dać Ci szybki dostęp do nich, więc wrzucam interesuące linki:
Breaking changes w .EFCore
Możesz spotkać problemy w starszych serwerach SQL od wersji 2014 w dół (razem z 2014). Co więcej, jeśli używasz JsonDocumentów, to zmienili sposób serializacji enumów. Teraz domyślnie idą intami. Wprowadzono też zmiany w SQLite, CosmosDB i mniejsze.
Mimo wszystko polecam zapoznać się z tym krótki artykułem, być może rozwiąże to Twoje problemy szybciej niż Stack Overflow 🙂
Zmiany w Blazor, SignalR, MinimalApis, NativeAOT, Kestrel, Identity
Jest jeszcze kilka fajnych zmian w Blazor. Ale to jest kwestia na zupełnie inny artykuł, więc odsyłam Cię po prostu do źródła.
Ciekawą zmianę dodali też w .NET Identity (o ile możemy to tak jeszcze nazywać). Wprowadzili m.in. endpointy do logowania, wylogowania, pobierania info o użytkowniku itp. Tak out of the box. Ale dla użycia tylko w apkach webowych (nie nadaje się raczej do WebApi).
Ufff, to tyle. Dzięki za przeczytanie tego artykułu. Mam głęboką nadzieję, że będzie naprawdę pomocny. A dla Ciebie jakie zmiany były najważniejsze? 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