MemoryCache i jego pułapki

MemoryCache i jego pułapki

Wstęp

IMemoryCache to jeden z mechanizmów pamięci podręcznej (cache) jakie mamy dostępne w .NET. Wykorzystujemy go w sytuacji, kiedy nasza aplikacja działa tylko na jednej maszynie (nie jest skalowana).

Jednakże domyślna implementacja tego interfejsu może czasem odnieść odwrotny skutek lub przyprawić nas o dziwne problemy. Chyba że poznamy jej zawiłości. I o tym jest ten artykuł.

IMemoryCache czyli najprostsze cachowanie w pamięci

IMemoryCache domyślnie jest zaimplementowany przez… niesamowite… MemoryCache. A, żeby móc tego użyć, wystarczy zarejestrować sobie ten cache podczas rejestracji usług:

builder.Services.AddMemoryCache();

To rejestruje nam klasę MemoryCache jako singleton z ewentualnymi opcjami. Żeby potem użyć tego w swoim serwisie, wystarczy wstrzyknąć IMemoryCache.

Pobieranie i dodawanie danych

I tutaj zaczyna się ciekawie. Najpierw powiedzmy sobie o ICacheEntry.

ICacheEntry

To właśnie obiekty implementujące ICacheEntry mogą być elementami IMemoryCache. ICacheEntry trzyma w sobie kilka przydatnych dla mechanizmu informacji. Poza oczywiście wartością, którą trzymamy w cache, posiada jeszcze informacje na temat długości swojego życia, priorytetu itd. Dojdziemy do szczegółów później.

Domyślnie ICacheEntry jest implementowany przez wewnętrzną klasę CacheEntry. Wewnętrzną (internal), a więc nie możemy utworzyć sami jej obiektu.

Dodawanie elementu do cache

IMemoryCache ma kilka metod do dodawania i pobierania danych, które jednak potrafią nam dołożyć kilku siwych włosów. Zacznijmy bez extensionów, od zupełnej podstawy

{
    using var entry = memoryCache.CreateEntry(city);
    entry.Value = new WeatherForecast();
}

Żeby lepiej to uwidocznić, to:

var entry = memoryCache.CreateEntry(city);
entry.Value = new WeatherForecast();
entry.Dispose();

W powyższych kodach city to po prostu miasto dla którego chcemy zapamiętać pogodę, a więc KLUCZ, pod którym zapisujemy konkretną wartość.

Tak, żeby dodać wartość do cache, musimy:

  • utworzyć CacheEntry za pomocą metody CreateEntry z IMemoryCache – inaczej się nie da, bo CacheEntry jest tylko internal, nie możemy sami utworzyć jego instancji,
  • przypisać wartość do CacheEntry – bez tego nie zadziała
  • wywołać Dispose na rzecz tego CacheEntry – albo jawnie, albo tak jak wyżej za pomocą using – bez tego nie zadziała.

Jak widać, musimy zrobić z tym CacheEntry zrobić dwie rzeczy:

  • dodać wartość (Value)
  • wywołać Dispose()

Dyskusji na temat tego Dispose() było już kilka, m.in. tu: https://github.com/dotnet/runtime/pull/42355 i tu: https://github.com/dotnet/runtime/issues/36392.

Nie jest to naturalne użycie, ponieważ po tym jak wywołamy Dispose(), to CacheEntry dopiero jest wrzucane do MemoryCache i tam wykorzystywane (sic!). Nie mniej jednak MS tłumaczy, że zrobienie tego inaczej byłoby breaking changem (prawda) i nie przyniosłoby wartości (zasadniczo też prawda). Więc… „jest jak jest”. I trzeba o tym pamiętać.

Musimy ten Dispose() potraktować bardziej jako „koniec konfiguracji CacheEntry” niż koniec jego życia.

Dodawanie za pomocą rozszerzenia

Dodawanie CacheEntry za pomocą metody rozszerzeń jest zdecydowanie bardziej naturalne dla nas. Rozszerzenie już samo w sobie pamięta o wykonaniu Dispose(), więc my się tym nie musimy przejmować. Wystarczy:

memoryCache.Set(city, new WeatherForecast());

Jak widać, takie dodawanie jest zdecydowanie bardziej naturalne. Musimy jednak być świadomi, że Set jest tu metodą rozszerzenia (extension method).

Używając powyższych rozwiązań musimy zrobić dwie rzeczy, żeby cache zadziałał. Spójrz na poniższy kod:

public async Task<WeatherForecast?> GetWeatherFor(string city)
{
    if (memoryCache.TryGetValue<WeatherForecast>(city, out var weather))
    {
        return weather!;
    }

    return memoryCache.Set(city, new WeatherForecast());
}

Jeśli mamy w serwisie metodą GetWeatherFor, która w parametrze przyjmuje nazwę miasta, dla którego chcemy zwrócić prognozę pogody, to żeby to wszystko elegancko zadziałało z cachem, musimy:

  • sprawdzić, czy mamy taką wartość już w cache zapisaną, jeśli tak, zwrócić ją stamtąd
  • jeśli wartości nie ma w cache, musimy ją w jakiś sposób zdobyć (np. poprzez zewnętrzne API – nie jest to pokazane w kodzie) i dopiero potem zapisać w cache i zwrócić. Metoda Set na szczęście zwraca nam wartość, którą przekazaliśmy w parametrze, więc możemy po prostu zrobić return z niej.

To jest taka opcja „krok po kroku”, która zasadniczo nie powinna rodzić pytań. Wszyscy się spodziewamy, że cache właśnie tak działa. Ale jest inna fajna metoda rozszerzenia:

public async Task<WeatherForecast?> GetWeatherFor(string city)
{
    return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
    {
        await Task.Delay(1000); //kwestia poglądowa, symulacja długiej operacji

        var result = new WeatherForecast
        {
            Date = DateOnly.FromDateTime(DateTime.Now),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = "Sunny"
        };

        entry.SetValue(result);
        return result;
    });
}

GetOrCreateAsync. Co to robi? W pierwszej kolejności próbuje zwrócić z cache istniejącą wartość na podstawie przekazanego klucza. Jeśli jest, to super, zwraca i tyle. Jeśli jednak takiej wartości nie ma, to ta metoda odpala naszą funkcję, którą podaliśmy w drugim parametrze – to jest to miejsce do pobrania wartości i SKONFIGUROWANIA CacheEntry.

Co się dzieje w tej lambdzie?

Na początku robię jakiś Task.Delay, ale tylko jako kwestia poglądowa, że tu może mieć miejsce jakaś długotrwała operacja (np. pobranie danych z zewnętrznego API).

Dalej w jakiś sposób pozyskuję WeatherForecast, ustawiam go jako wartość w entry i zwracam.

Parametr entry, który otrzymujemy w parametrze naszej funkcji to jest właśnie utworzony CacheEntry. Dlatego w naszej funkcji musimy go skonfigurować.

Którą opcję wybierzesz do dodawania elementu do cache jest Twoją indywidualną sprawą. Wszystkie te metody zasadniczo robią to samo, tylko w nieco inny sposób. No i oczywiście GetOrCreateAsync jest na tyle uniwersalne, że daje nam jakby 100% użyteczności całego cache.

Typ klucza

Każda wartość w cache musi mieć swój klucz. Inaczej nie wiedzielibyśmy co dodajemy lub pobieramy. I mimo, że parametr klucz jest zawsze typu object, to tak naprawdę może być czystym stringiem. Ważne, żeby klucz miał dobrze zaprogramowaną metodęy GetHashCode i Equals.

To może być szczegół implementacyjny, ale pod spodem mamy dwa słowniki, które przechowują nasze wartości. A konkretniej dwa ConcurrentDictionary (ale nie daj się zwieść, że MemoryCache jest thread safe – o tym później). Jeden z tych słowników przechowuje wartości z kluczem object, a drugi ze stringiem. I po prostu mechanizm sam rozpoznaje, czy podaliśmy w kluczu string, czy nie (if key is string...). Następnie dodaje wartość do odpowiedniego słownika.

Dlaczego tak? To jest tak naprawdę zależne od wersji .NET. Domyślny hash stringów kiedyś pozwalał na atak Hash Collision Atack. Natomiast teraz używany jest przy stringach „Marvin Hash”, który jest odporny na te ataki, czyli mamy zwiększone bezpieczeństwo.

Jednak trzeba pamiętać, że stringi są porównywane w zależności od wielkości znaków. Czyli klucze „Łódź” i „łódź” będą innymi kluczami.

Ustawienia CacheEntry

CacheEntry, jak i całe MemoryCache ma swoje ustawienia, które KONIECZNIE powinniśmy uwzględnić. Albo podczas dodawania (konfiguracji tak naprawdę) CacheEntry do cache, albo podczas rejestracji usług.

Co jest problemem, jeśli tego nie zrobimy? Cache jest przechowywany w pamięci RAM. Domyślnie wszelkie wartości jakie wrzucamy, nigdy nie giną. No, dopóki aplikacja żyje oczywiście 🙂

Co nam to daje? Pewnie się domyślasz. Ciągle tylko dokładamy do RAMu nowe dane, co w końcu doprowadzi do Out Of Memory.

Zatem musimy koniecznie zatroszczyć się o odpowiedni czas przechowywania danych w cache. Co to znaczy „odpowiedni”? To oczywiście zależy od konkretnej aplikacji. Bo czasem wartości mogą żyć dłużej (jak w przypadku prognozy pogody), czasem powinny nieco krócej, a w specyficznych aplikacjach używanie cache w ogóle nie ma sensu. Dlatego sam musisz odpowiedzieć na pytanie – jak długo ta dana może leżeć w cache.

Czas życia CacheEntry

Mamy kilka metod, żeby określić czas życia konkretnej danej w cache:

  • AbsoluteExpiration – gdzie podajemy konkretną datę i czas, do której wartość ma żyć (np. do 5 stycznia 2026 roku, do godziny 17:03)
  • AbsoluteExpirationRelativeToNow – zdecydowanie bardziej użyteczna, gdzie podajemy po prostu ile czasu ma żyć jako TimeSpan. Czyli konkretnie: 10 minut, 1 dzień itd.
  • SlidingExpiration – analogicznie jak AbsoluteExpirationRelativeToNow z tą różnicą, że ten czas odświeża się zawsze po pobraniu danej.
  • ExpirationToken – o tym niżej.

Czyli jeśli aktualnie mamy godzinę 17:00:00 i ustawimy AbsoluteExpirationRelativeToNow na 10 minut, to niezależnie od tego, ile razy w ciągu tych 10 minut będziemy pobierać daną z cache, to o 17:10:00 skończy się jej życie i zostanie usunięta.

Jeśli jednak użyjemy SlidingExpiration, to te 10 minut będzie liczone od nowa po każdym pobraniu danej. Czyli jeśli daną pobierzemy o 17:05, to życie skończy się jej dopiero o 17:15. Ale jeśli pobierzemy ją znowu o 17:07, to wydłuży jej życie do 17:17. Właściwość SlidingExpiration możesz znać z mechanizmu ciastek, gdzie działa dokładnie w taki sam sposób.

Ale uwaga, tutaj jest kolejna pułapka. Mechanizm odpowiedzialny za usuwanie danych z cache, bo ich czas się skończył, nie obsługuje żadnego timera. Czas ich życia jest sprawdzany po prostu przy każdej operacji z nimi związanej, czyli dodawanie nowej wartości, odczyt itd. Więc w bardzo specyficznych warunkach (raz dodamy daną do cache i nigdy nic więcej z tym cachem nie zrobimy) wartość może żyć wiecznie 🙂 Ale to raczej warunki niespotykane.

ExpirationToken

Mamy w .NET taki interfejs jak IChangeToken. Służy on do implementacji klas, które „monitorują” jakieś zdarzenia. Ustawienie ExpirationToken spowoduje usunięcie wartości z cache, gdy dane zdarzenie wystąpi. A co to może być za zdarzenie? Jakiekolwiek. Zmiana zawartości pliku na dysku, event związany z bazą danych albo cokolwiek sobie wymyślimy. Weźmy pod uwagę przykładowo zmianę zawartości pliku na dysku.

Scenariusz jest taki, że trzymamy dane z pliku w cache i monitorujemy zmianę tego pliku. Można by to ogarnąć w taki sposób:

Najpierw rejestrujemy odpowiedniego FileProvidera:

builder.Services.AddSingleton<IFileProvider>(new PhysicalFileProvider(@"C:\temp"));

A teraz możemy już pobrać token, który powie nam, czy plik się zmienił i dodać go do CacheEntry:

public class WeatherService(IMemoryCache memoryCache, 
    IFileProvider _fileProvider) : IWeatherService
{
    public async Task<WeatherForecast?> GetWeatherFor(string city)
    {
        return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
        {
            // zaczynamy plik obserwować
            IChangeToken changeToken = _fileProvider.Watch("data.txt");

            // pobieramy dane z pliku
            var data = await File.ReadAllTextAsync(@"C:\temp\data.txt");

            // dodajemy token zmiany do pamięci podręcznej
            entry.AddExpirationToken(changeToken);

            // zapisujemy dane do pamięci podręcznej
            entry.SetValue(data);

            return JsonSerializer.Deserialize<WeatherForecast>(data);
        });
    }
}

Tylko UWAGA!

Tutaj za czas życia wartości w cache odpowiada ten token, a nie czas. Natomiast zasada sprawdzania, czy czas życia danej w cache się skończył jest dokładnie taka sama jak w poprzednim przypadku. Czyli mechanizm sprawdza tylko przy jakiejś operacji na danych (dodawanie, odczyt, usuwanie).

Możemy też jednocześnie ustawić konkretny czas życia (np. na 5 minut), jak również dodać token. Jednak nie łączmy ze sobą różnych form wygasania po czasie, bo to po prostu nie ma sensu.

A może by tak IDisposable?

A gdybyśmy chcieli trzymać obiekt implementujący IDisposable? Hmm. Stwórzmy sobie do tego celu czystą klasę implementującą IDisposable:

public class MyDisposable : IDisposable
{
    private bool disposedValue;

    protected virtual void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            disposedValue = true;
        }
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

I teraz lekko zmienię metodę GetWeatherFor, żebyśmy mogli tę klasę wykorzystać:

public async Task<IDisposable?> GetWeatherFor(string city)
{
    return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
    {
        var d = new MyDisposable();
        entry.SetValue(d);
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
        return d;
    });
}

Tworzymy sobie nasz obiekt i dodajemy go do cache w taki sposób, żeby żył tylko 10 sekund. I co się okazuje? Klops, dupa zbita. Obiekt implementujący IDisposable nigdy nie jest zwalniany, co może doprowadzić do poważnych wycieków pamięci i w gruncie rzeczy do Out Of Memory.

Jak sobie z tym poradzić?

Na szczęście CacheEntry ma specjalną metodę, którą wywołuje w momencie gdy jest usuwany z cache. Czyli nasz kod, powinniśmy zmienić na taki:

public async Task<IDisposable?> GetWeatherFor(string city)
{
    return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
    {
        var d = new MyDisposable();
        entry.SetValue(d);
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
        entry.RegisterPostEvictionCallback((key, value, reason, state) =>
        {
            if (value is IDisposable disposable)
            {
                disposable.Dispose();
            }
        });
        return d;
    });
}

Metoda rozszerzenia RegisterPostEvictionCallback rejestruje nam w prosty sposób właśnie taki callback, który zostanie wywołany, gdy z jakiegoś powodu wartość będzie usuwana z cache. To może być po prostu koniec życia albo po prostu zwykłe usunięcie przez użytkownika. To jest to miejsce, gdzie możemy po sobie posprzątać.

A kiedy wybuchnie?

Niestety trzymanie w cache obiektu implementującego IDisposable niesie za sobą jeszcze jedno niebezpieczeństwo. Mianowicie możemy ten obiekt zdisposować w innym miejscu.

Wtedy w cache będziemy przetrzymywać obiekt, który… no jest trupem. Takie „zombie”. Jeśli teraz pobierzemy takie zombie i będziemy chcieli użyć, no to BUM!

Zatem rada jest taka:

Nie przechowuj w cache obiektów implementujących IDisposable

A jak z wielowątkowością?

Memory cache NIE JEST thread safe

W związku z tym, że MemoryCache pod spodem używa ConcurrentDictionary, uważa się, że jest wątkowo bezpieczny. Ale nie jest tak do końca.

Fakt – MemoryCache jest bezpieczny pod kątem samego dodawania lub pobierania wartości (w końcu mamy Concurrent Dictionary). Ale problemem jest to, co dzieje się na zewnętrz tego cache – w naszym kodzie.

Możemy doświadczyć race condition i innych problemów z wątkami. Przykładowo callback zarejestrowany w CacheEntry może przyjść z zupełnie innego wątku, niż ten, w którym utworzyliśmy i dodaliśmy obiekt do cache. To może być istotne w pewnych sytuacjach. A co do race condition… Spójrz na ten prosty kodzik:

if (!cache.TryGetValue("key", out var value))
{
    // <- w tym momencie inny wątek może już dodać wartość
    value = LoadFromDb();
    cache.Set("key", value);
}

Co się może zadziać w tym przypadku?

  • wątek A robi TryGetValue – dostaje false, bo w cache nie ma jeszcze takiej wartości
  • wątek B robi TryGetValue – dostaje false, bo w cache nie ma jeszcze takiej wartości
  • oba wątki pobierają dane z bazy
  • oba wątki dodają wartość do cache

W efekcie:

  • powielamy zapytanie do bazy danych (co może być kosztowną operacją)
  • jeden wpis nadpisuje drugi

Ale jeszcze ciekawszy przypadek, który może pomóc zobrazować skalę problemu:

int data = 0;

Parallel.For(0, 10, _ =>
{
    var cachedItem = memoryCache.GetOrCreate<int>("data", _ => Interlocked.Increment(ref data));
    Console.WriteLine($"Cached Item: {cachedItem}");
});

Co tu chcemy osiągnąć?

Robimy równoległą pętlę, która ma 10 iteracji. W każdej iteracji pętli pobieramy z cache wartość pod kluczem „data” lub dodajemy, jeśli takiej nie ma. W efekcie chcielibyśmy otrzymać na konsoli dziesięć jedynek – ponieważ na początku dodaliśmy wartość 1 pod kluczem data. A co otrzymujemy? U mnie to było:

Cached Item: 3
Cached Item: 5
Cached Item: 6
Cached Item: 2
Cached Item: 1
Cached Item: 4
Cached Item: 4
Cached Item: 4
Cached Item: 4
Cached Item: 7

U Ciebie będą pewnie nieco inne wyniki, natomiast nie uzyskamy tego, czego się spodziewamy. Ponieważ operujemy na MemoryCache z różnych wątków.

Zatem mimo wątkowo bezpiecznych mechanizmów w środku, MemoryCache nie jest wątkowo bezpieczny.

Jak temu zaradzić? Mamy dwie opcje:

  • używać podwójnych locków (podobnie jak przy klasycznym singletonie)
  • używać biblioteki LazyCache.

Biblioteka LazyCache gwarantuje nam już jednorazową inicjalizację cache nawet z wielu wątków. Nie jest jednak spójna z interfejsem IMemoryCache, bo służy do trochę innych rzeczy. Ale to nie jest artykuł o tej bibliotece.

Usuwanie danych

Pomówmy sobie jeszcze o usuwaniu danych. Mamy do tego właściwie trzy możliwości:

  • automatyczne usuwanie danych na podstawie czasu ich życia lub tokenu,
  • usunięcie danych ręcznie,
  • usuwanie danych, gdy cache przekroczy ustawiony limit.

O pierwszym sposobie już pisałem wyżej. Drugi – no to po prostu wywołanie metody Remove, więc nie ma o czym gadać. Natomiast dość ciekawy jest trzeci sposób.

Limitowanie wielkości cache

Jak już pisałem wyżej, domyślnie MemoryCache nie ma żadnych ograniczeń. Więc jeśli nie ustawiamy czasu życia poszczególnych elementów, możemy dojść do OutOfMemory.

Ale jest dodatkowa opcja limitowania cache, którą warto rozważyć. A mianowicie ustawianie limitu wielkości cache. Robimy to podczas tworzenia cache lub jego rejestracji:

builder.Services.AddMemoryCache(o =>
{
    o.SizeLimit = 100;
    o.CompactionPercentage = 0.2f;
});

I co tu się zadziało?

Ustawiliśmy limit cache na 100. Teraz pytanie – czym jest to 100. To jest jednostka wybrana przez Ciebie. Sam decydujesz o tym, ile to jest 1. Tu nie chodzi o bajty, megabajty ani inne takie. Programista sam sobie ustala jednostkę, a potem musi się jej trzymać.

To teraz drugie pytanie – skąd cache ma wiedzieć, kiedy doszedł do limitu pamięci?

Ponieważ, gdy ustawiasz limit pamięci, to każdemu elementowi, który wkładasz do cache, musisz ustawić rozmiar, jaki zajmuje w TWOJEJ JEDNOSTCE (jeśli tego nie ustawisz, apka się wywali).

Np.:

memoryCache.Set("test", 200, new MemoryCacheEntryOptions
{ 
    Size = 1
});

Dodaję wartość 200 do cache. Dodatkowo podaję, ile przestrzeni zajmuje w moich jednostkach. W moim przypadku wartość 200 zajmuje 1.

Takie zarządzanie ma swoje plusy i minusy. Z jednej strony, przy moim pierwszym spotkaniu z tym limitem byłem przekonany, że wszędzie są po prostu bajty. A to oczywiście bzdura. Dajmy na to, jeśli w cache umieszczasz stringi, sam określasz ich długość. Czy to jest JEDEN string, czy ten string zajmuje przestrzeń równą ilości znaków.

Czyli sam sobie ustalasz tę jednostkę i musisz się jej spójnie trzymać w systemie.

To teraz kolejne pytanie – czym jest CompactionPercentage?

To jest informacja, o ile procent ma się zmniejszyć cache w momencie przekroczenia limitu.

Czyszczenie cache po przekroczeniu limitu

Weźmy pod uwagę taki kodzik:

for (int i = 0; i < 200; i++)
{
    memoryCache.Set("test" + i, i, new MemoryCacheEntryOptions
    { 
        Size = 1
    });
}

Wcześniej ustawiłem rozmiar cache na 100. Tutaj każdy element ma rozmiar 1. To znaczy, że w cache mogę mieć 100 takich elementów. W momencie, gdy będę chciał dodać kolejny, mechanizm się jorgnie, że cache przekroczyłby swój limit, zatem robi „kompakt”. A więc usuwa inne elementy. Ile? Tyle procent, ile ma określone w CompactionPercentage. Określiłem to na 20%, więc w tym wypadku usunie 20 elementów (bo każdy z nich ma rozmiar 1). A skąd wie, co ma usunąć?

Najpierw usuwa elementy, którym skończył się czas życia (jeśli w ogóle takie są).

Jeśli nadal musi usuwać elementy, to usuwa elementy względem nadanego im priorytetu:

  • Niski – CacheItemPriority.Low
  • Normalny (domyślny priorytet dla CacheEntry) – CacheItemPriority.Normal
  • Wysoki – CacheItemPriority.High

Z każdej tej listy usuwa elementy zgodnie z polityką LRU – Least Recently Used. To znaczy, że zaczyna usuwać najpierw te elementy, które były najdłużej nieużywane.

Mechanizm cache po każdym pobraniu elementu zmienia czas jego ostatniego użycia. To znaczy, że jeśli długo nie odczytujesz jakiegoś elementu, to ostatnio był używany „dawno temu”. Dla cache to informacja, że możliwe że już nie będzie używany.

Więc usuwa te elementy w konkretnej kolejności – od najdłużej nieużywanego (czas ostatniego dostępu najmniejszy).

Rzecz jasna po usunięciu każdego elementu sprawdza, ile odzyskał miejsca. Jeśli dojdzie do tej wartości określonej w CompactionPercentage przestaje usuwać dalej.


Chyba nie ma więcej problemów związanych z tym mechanizmem, jednak jeśli mi coś umknęło koniecznie daj znać w komentarzu.

Dzięki za przeczytanie tego artykułu. Jeśli coś nie jest jasne lub zauważyłeś gdzieś błąd, koniecznie daj znać. A może chcecie więcej artykułów o cache? W sumie i tak zamierzam je napisać 😀

Podziel się artykułem na:
Jak trzymać tokeny w SPA, czyli Backend For Frontend

Jak trzymać tokeny w SPA, czyli Backend For Frontend

Wstęp

Gdy łączymy aplikację frontową (typu SPA) z jakąś formą autoryzacji, np. OAuth, bardzo chętnie trzymamy sobie tokeny autoryzacyjne gdzieś na froncie. Spora ilość odpowiedzi na pytanie „gdzie trzymać tokeny” sugeruje, że to powinien być local storage. A niektóre, że ciastko. Ale odpowiedź jest jedna:

Nigdy nie trzymaj tokenów autoryzacyjnych na froncie.

W tym artykule pokażę jak to zrobić poprawnie – i będziemy mimo wszystko robić tylko backend. Artykuł nie ma nic wspólnego z OAuth, jest bardziej ogólny, ale to co tutaj zrobimy bez problemu nadaje się do zaadaptowania w systemie, w którym dostajemy jakiś token autoryzacyjny.

Przygotowałem też na GitHub prostą implementację tego, co tu robimy. Uwaga! Potraktuj ten kod jako wyznacznik, a nie jako gotową implementację. Ten kod raczej obrazuje jak takie BFF zrobić i należy go dostosować do własnych wymagań. Pobierz go stąd.

Dlaczego nie mogę trzymać tokenu na froncie?

Odpowiedź na to pytanie jest szalenie prosta. Nigdy nie wiesz, jaki kod działa na froncie. I to właściwie tyle. Token autoryzacyjny może zostać wykradziony. Wtedy dupa zbita. Konto może zostać przejęte.

I już widzę te słowa oburzenia: „Jak to nie wiem, jaki kod działa na froncie? W końcu sam go pisałem!”.

Jesteś pewien, że jeśli tworzysz front to sam piszesz kod? 🙂

Przy Blazor jest trochę lepiej, ale jeśli piszesz w JavaScript, używasz różnych bibliotek, które też mają swoje zależności, a te zależności mają inne zależności. Niestety mało kto zwraca uwagę na bezpieczeństwo na etapie zależności bibliotek, chociaż npm ma do tego jakieś narzędzia.

I co w związku z tym? Na jakimś etapie możesz niejawnie wykorzystywać bibliotekę, która jest podatna na różne ataki.

Ale ok, załóżmy że masz super restrykcyjną politykę bezpieczeństwa i każda zależność każdej zależności dla każdej biblioteki którą używasz jest sprawdzana i walidowana. I tu wchodzi rola użytkownika 😉

Użytkownik może mieć zainstalowany w przeglądarce złośliwy lub podatny dodatek. No i sorry – na to już żadnego wpływu nie masz.

Czyli – nigdy nie wiesz, jaki kod jest wykonywany na froncie. A jeśli kod Twojej aplikacji ma dostęp do tokenu, to każdy kod, który działa na Twoim froncie też ma do niego dostęp.

No to gdzie mam trzymać tokeny?

Nigdzie. Jeśli tworzysz aplikację typu SPA nigdy nie trzymaj tokenu. Istnieją dwie inne opcje, którymi możesz się posłużyć.

Service Worker

Service Worker to stosunkowo młody byt. To w gruncie rzeczy jakiś fragment funkcjonalności, który pełni rolę reverse proxy. Ale jeśli chciałbyś prawilnie i bezpiecznie używać np. OAuth (albo innego systemu autoryzacji), to musiałbyś całą funkcjonalność z tym związaną przenieść do Service Workera, co jednak nie jest tak prostym i trywialnym zadaniem.

Nie będziemy się zagłębiać w ten temat, bo jest to blog jednak backendowy, ale jeśli chcesz poczytać więcej na ten temat, to masz tutaj dokumentację: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API

BFF, czyli Backend For Frontend

No i teraz dochodzimy do sedna. BFF to zwykła aplikacja backendowa, która w całym systemie pełni rolę prostego reverse proxy. Zasada jest taka, że to BFF otrzymuje tokeny autoryzacyjne. Co więcej, to BFF komunikuje się z WebApi, które gdzieś tam jest pod spodem, a nie aplikacja frontowa. Podsumowując:

  • BFF to proste reverse proxy
  • Front komunikuje się TYLKO z BFF
  • BFF komunikuje się z serwerem autoryzacyjnym i WebApi
  • BFF przechowuje i/lub szyfruje tokeny
  • Front NIGDY nie komunikuje się z WebApi bezpośrednio

Jeśli weźmiemy sobie pod uwagę autoryzację OAuth, to uwzględniając BFF będzie to wyglądało tak:

Oczywiście istotne jest, że jeśli używamy OAuth, to musimy w tym momencie przestać używać przestarzałego Implicit flow, a zamiast tego użyć prawilnego Authorization Code flow i nasze BFF wtedy przejmuje rolę klienta, a nie apka frontowa.

Po lewej stronie mamy aplikację SPA, po środku nasze BFF, na górze „fabryka” reprezentuje serwer autoryzacyjny, a na niebiesko po prawej stronie przedstawione jest nasze API, do którego dobijamy się tokenem.

I teraz tak:

  1. Aplikacja SPA strzela do serwera autoryzacyjnego po Authorization Code
  2. Serwer autoryzacyjny oddaje SPA authorization code
  3. Aplikacja SPA przekazuje Authorization Code do BFF i BFF rozpoczyna już prawilną sesję logowania
  4. BFF otrzymuje w końcu od serwera autoryzacyjnego Access Token i opcjonalny Refresh Token
  5. BFF albo to sobie gdzieś zapisuje albo szyfruje i następnie tą informację wysyła do frontu jako HTTP Only, Secure cookie.
  6. Aplikacja z każdym żądaniem do BFF dołącza to cookie
  7. BFF odczytuje sobie dane z cookie i pobiera / odszyfrowuje tokeny
  8. BFF dołącza tokeny do requestu wysyłanego do WebApi
  9. BFF wysyła request do WebApi

Jak niby aplikacja SPA ma dołączyć ciastko, które jest HttpOnly?

No to tak. Ciastko HttpOnly to takie ciastko, którego żaden JavaScript nie będzie w stanie odczytać. To jasne – tu jesteśmy bezpieczni. Nie dość, że jeśli access token jest w ciastku, to jest też tak zaszyfrowany, że tylko BFF potrafi go odszyfrować. No i fajnie, bo Access Tokeny nie są przewidziane do używania na froncie. Czyli jesteśmy podwójnie chronieni nawet jeśli ten token jest zapisany w ciastku.

Ale, ale. Na froncie są metody, żeby dołączyć takie ciastko do wysyłanego żądania. Niezależnie od tego, czy używasz fetcha, axiosa, czy czegoś innego, każda z tych bibliotek ma (albo mieć powinna) taką opcję jak credentials, withCredentials, includeCredentials itp. W zależności od biblioteki. Przy takim ustawieniu HttpOnly cookie zostanie dołączone do requesta.

Jedziemy z implementacją

Implementacja tego cuda jest zasadniczo prosta. Istnieją jakieś biblioteki, które nam w tym pomagają (np. Duende coś ma), ale zdecydowanie polecam napisać własny fragment kodu, który będzie stosowany tylko w naszej apce. Niemniej jednak będziemy posługiwać się biblioteką Yarp.ReverseProxy.

A więc w pierwszej kolejności utwórz sobie nowy projekt WebApi, z którego zrobimy BFF i zainstaluj ReverseProxy:

dotnet add package Yarp.ReverseProxy

Jeśli nie posługiwałeś się wcześniej tą biblioteką, możesz sobie poczytać więcej w dokumentacji.

W każdym razie takie ReverseProxy trzeba wstępnie skonfigurować, można to zrobić albo przez appsettings albo w kodzie. Ja Ci pokażę to na przykładzie appsettings, żeby nie zaciemniać obrazu.

Konfiguracja proxy

Zrobimy podstawową konfigurację w pliku appsettings.json. Można oczywiście zrobić konfigurację w odpowiednich klasach w runtime, ale na potrzeby prostego BFF taka konfiguracja jest wystarczająca.

"ProxyConfig": {
  "Routes": {
    "ApiRoute": {
      "ClusterId": "ApiCluster",
      "Match": {
        "Path": "/api/{**catch-all}"
      }
    }
  },
  "Clusters": {
    "ApiCluster": {
      "Destinations": {
        "ApiDestination": {
          "Address": "https://localhost:7034/"
        }
      }
    }
  }
}

Co się dzieje w tej konfiguracji?

To najprostsza chyba konfiguracja Reverse Proxy. Czyli: „gdy przyjdzie żądanie na Route o danym adresie (Match/Path), prześlij je na Cluster o danym Id (ClusterId)”.

U nas wszystkie żądania jakie przyjdą do BFF na adres /api/* zostaną przesłane dalej na adres odpowiedniego klastra, czyli w tym przypadku – klaster o Id ApiCluster, który ma zdefiniowany adres: https://localhost:7034. W rzeczywistej aplikacji to będzie adres Twojego WebApi.

Teraz trzeba dodać sobie Proxy do naszej apki. Po zainstalowaniu pakietu Yarp.ReverseProxy, nasz plik Program.cs może wyglądać tak:

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddReverseProxy()
            .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

        var app = builder.Build();

        // Configure the HTTP request pipeline.

        app.UseHttpsRedirection();

        app.MapReverseProxy();

        app.Run();
    }
}

Dodajemy sobie serwisy odpowiedzialne za działanie Reverse Proxy (linia 7) wraz z konfiguracją (linia 8) i wpinamy proxy do pipeline (linia 16) – bo tak trzeba.

W tym momencie mamy już działające proxy. Ale to jeszcze nie jest BFF, to jest samo właściwie proxy, które nie robi niczego poza przekazywaniem żądania dalej.

Dodajemy CORSy

Kolejny krokiem jest obsługa CORS. Jeśli nie wiesz, czym jest CORS, miałem o tym artykuł: Ten cholerny CORS dogłębnie.

Teraz metoda Main może wyglądać tak:

public static void Main(string[] args)
{
    var builder = WebApplication.CreateBuilder(args);

    // Add services to the container.

    builder.Services.AddReverseProxy()
        .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

    var corsOrigins = builder.Configuration.GetSection("CorsOrigins").Get<string[]>()!;
    var corsExposedHeaders = builder.Configuration.GetSection("CorsExposedHeaders").Get<string[]>()!;
    builder.Services.AddCors(o =>
    {
        o.AddDefaultPolicy(builder =>
        {
            builder.AllowAnyMethod()
                .AllowAnyHeader()
                .WithOrigins(corsOrigins)
                .AllowCredentials()
                .WithExposedHeaders(corsExposedHeaders);
        });
    });

    var app = builder.Build();

    // Configure the HTTP request pipeline.

    app.UseHttpsRedirection();
    app.UseCors();

    app.MapReverseProxy();

    app.Run();
}

ZAKŁADAJĄC, że w konfiguracji mamy dozwolone originy, z których przychodzą żądania (sekcja CorsOrigins), musimy je dodać do metody WithOrigins (linia 18). Analogicznie z nagłówkami. Jeśli nasze PROXY może emitować niestandardowe nagłówki, musimy je dodać do WithExposedHeaders.

Tutaj cholernie ważna jest metoda AllowCredentials. Bez tego nie otrzymamy z frontu ciastka HttpOnly. A właściwie żądanie z takim ciastkiem zostanie zablokowane przez politykę CORS, więc zakończy się failem. Dlatego AllowCredentials musi tutaj być.

Przypominam – nie wiesz dokładnie co to CORS? To przeczytaj tutaj.

Transformacje – czyli serce BFF

Ok, mamy już reverse proxy, mamy politykę CORS, ale brakuje nam ostatniej rzeczy.

Jakoś tokeny autoryzacyjne musimy zapisać i jakoś je musimy odczytać. Do tego służą transformacje, które są częścią Yarp.ReverseProxy.

Czym jest transformacja? Generalnie możemy w jakiś sposób zmienić request, który do nas przychodzi (np. z frontu) przed przesłaniem go do WebApi, ale też możemy zmienić odpowiedź, która do nas przychodzi np. z WebApi zanim zostanie przekazana na front.

W związku z tym mamy jakby dwa rodzaje transformacji:

  • RequestTransform
  • ResponseTransform

Weźmy się najpierw za RequestTransform, czyli transformacja, gdzie działamy na danych pochodzących z frontu.

Request Transform

Najpierw musimy stworzyć sobie klasę dziedziczącą po RequestTransform. Pusta wygląda tak:

internal class AccessTokenRequestTransform : RequestTransform
{
    public override async ValueTask ApplyAsync(RequestTransformContext context)
    {
        
    }
}

Metoda ApplyAsync zostanie wywołana, gdy przyjdzie do nas żądanie z frontu, zanim zostanie przekazane dalej. W parametrze context mamy wszystko, co potrzebujemy, żeby bawić się żądaniem.

I w gruncie rzeczy to, jak powinna wyglądać ta metoda dokładnie, to już zależy od konkretnego systemu. Ja Ci podam tylko przykład, który wydaje się być w miarę uniwersalny.

UWAGA! W moim przykładzie trzymam zaszyfrowane tokeny w ciastku. Można też to zrobić inaczej. W ciastku trzymać jakieś Id (zaszyfrowane najlepiej), które doprowadzi do tokenu zapisanego w jakiejś bazie danych (chociażby Redis).

Tutaj scenariusz jest taki, że BFF po otrzymaniu tokenów szyfruje je, wrzuca w ciastko i wysyła na front.

Gdy front chce wysłać żądanie do API, przesyła z nim to ciastko (oczywiście HttpOnly, Secure) i BFF je odczytuje, a więc bardzo abstrakcyjnie:

internal class AccessTokenRequestTransform(IAuthCookieService _authCookieService, 
    IAccessTokenEncoder _tokenEncoder) : RequestTransform
{
    public override async ValueTask ApplyAsync(RequestTransformContext context)
    {
        var cookieValue = _authCookieService.ReadFromRequest(context);
        if (string.IsNullOrEmpty(cookieValue))
            return;

        var tokens = _tokenEncoder.DecodeTokens(cookieValue!);
        if (tokens != null)
            tokens = await RefreshTokensIfNeeded(context, tokens);

        if (tokens != null)
            AddAuthHeader(context, tokens.AccessToken);

        _authCookieService.RemoveAuthenticationCookie(context);
    }

    private Task<AuthTokenModel> RefreshTokensIfNeeded(RequestTransformContext context, AuthTokenModel tokens)
    {
        tokens.ValidTo = tokens.ValidTo.AddMinutes(15); //duży skrót myślowy, tu powinno być prawilne odświeżenie
        return Task.FromResult(tokens);
    }

    private void AddAuthHeader(RequestTransformContext context, string accessToken)
    {
        context.ProxyRequest.Headers.Add("Authorization", $"Bearer {accessToken}");
    }
}

Zadziało się sporo, ale już wszystko tłumaczę.

Na początku wstrzykujemy sobie dwa HIPOTETYCZNE serwisy, których zadaniem jest:

  • IAuthCookieService – odczyt danych z konkretnego ciasteczka
  • IAccessTokenEncoder – szyfrowanie i deszyfrowanie tokenów

Podstawowa implementacja tych serwisów jest zrobiona na GitHub. Nie ma tam niczego ciekawego, więc się tym nie zajmujemy.

I teraz metoda ApplyAsync:

  1. Pobieramy wartość z otrzymanego ciasteczka (powinien tam być zaszyfrowany token).
  2. Jeśli wartości nie ma (nie ma ciasteczka) nie robimy transformacji. Request jest po prostu przekazywany dalej bez żadnych zmian.
  3. Jeśli wartość była, to ją odszyfrowujemy. Po odszyfrowaniu powinniśmy otrzymać jakiś obiekt, który przechowuje token i informacje o nim.
  4. Sprawdzamy, czy token należy odświeżyć. W tym przykładowym systemie token powinniśmy odświeżyć jeśli wygaśnie w ciągu 5 minut. Pamiętaj, że to jest tylko przykład. W Twoim systemie wymaganie może być zupełnie inne. Zwróć też uwagę na metodę RefreshTokensIfNeeded – gdzie pokazałem zupełnie niepoprawne odświeżanie tokenów. Jednak przykładowy kod nie ma żadnego miejsca, które tokeny wydaje, więc potraktuj metodę RefreshTokensIfNeeded jako duży skrót myślowy.
  5. Dalej mamy metodę AddAuthHeader, która dodaje AccessToken do nagłówka autoryzacyjnego. Tutaj to też jest tylko przykład. Ale w prawdziwym systemie tokeny będą pewnie dodawane do jakiegoś nagłówka.
  6. No i na koniec USUWAMY ciasteczko z requestu, bo nie chcemy go przesyłać do WebApi.

Response Transform

W znakomitej większości przypadków nie będziesz potrzebował tej transformacji. Tutaj wchodzi żądanie z WebApi i przekazywane jest na front.

Jednak tokeny autoryzacyjne przeważnie będziesz otrzymywał z innego źródła. Jednak jeśli Twoje WebApi wystawia taki token, w takim przypadku powinieneś utworzyć taką transformację. Tutaj też tylko zarys, bo zasada jest dokładnie taka sama jak wyżej:

internal class AccessTokenResponseTransform(IAccessTokenEncoder _tokenEncoder,
    IAuthCookieService _authCookieService) : ResponseTransform
{
    public override async ValueTask ApplyAsync(ResponseTransformContext context)
    {
        var tokenModel = await ReadTokenModelFromBody(context) ?? context.HttpContext.GetTokens();
        if (tokenModel == null)
            return;

        var cookieValue = _tokenEncoder.EncodeTokens(tokenModel);
        _authCookieService.AddAuthCookieToResponse(context, cookieValue);
    }


    private async Task<AuthTokenModel?> ReadTokenModelFromBody(ResponseTransformContext context)
    {
        if (context.ProxyResponse == null)
            return null;

        if (!context.ProxyResponse.Headers.HasAuthToken())
            return null;

        var result = await context.ProxyResponse.Content.ReadFromJsonAsync<AuthTokenModel>();
        context.SuppressResponseBody = true;
        return result;
    }
}

Przyjrzyjmy się od razu metodzie ApplyAsync.

Ona w jakiś sposób pobiera sobie token, wygenerowany przez WebApi. Taki token może siedzieć albo w BODY, albo w jakimś nagłówku. Stąd najpierw jest próba pobrania go z BODY, a potem z nagłówka.

To teraz spójrz na metodę ReadTokenModelFromBody. Tu się dzieje jedna istotna rzecz.

Odczytujemy dane z BODY. To jest ważne – ODCZYTUJEMY DANE Z BODY. Po takim odczycie koniecznie trzeba przypisać TRUE do SupressResponseBody w kontekście. Jeśli to jest ustawione na TRUE, proxy podczas przesyłania requestu dalej, nie bierze pod uwagę zawartości BODY.

Niestety odczytujemy tutaj całe body, czyli niejako pobieramy je z oryginalnego żądania. Kradniemy je z niego. W oryginalnym żądaniu nie ma już tego body po naszym odczycie, jednak cały czas jest ustawiony nagłówek Content-Length, który mówi o tym, jak długie jest Body. W efekcie wszystko wybuchnie 🙂

To jest pewna pułapka biblioteki Yarp.ReverseProxy. Jeśli musisz odczytać body, żeby przykładowo pobrać jakąś wartość, ale chcesz żeby body było później przesłane dalej, to musisz je na nowo zapisać.

W przypadku tego kodu nie ma takiej potrzeby. Odczytujemy Body i jesteśmy z tym ok, że dalej nie pójdzie. Bo w MOIM systemie ja wiem, że jeśli metoda z linii 26: context.ProxyResponse.Headers.HasAuthToken() zwróci mi TRUE, to znaczy, że w body mam TYLKO tokeny autoryzacyjne.

Oczywiście to jest domena TYLKO I WYŁĄCZNIE mojej aplikacji i moich założeń. A czym jest metoda HasAuthToken? To jakieś rozszerzenie, które może sprawdzać, czy istnieje jakiś nagłówek w żądaniu. Czyli coś w stylu: „Jeśli w żądaniu występuje nagłówek MOJA-APKA-MAM-TOKEN, to znaczy, że w body znajduje się token autoryzacyjny. Chcę, żeby to dobrze wybrzmiało.

To teraz wróćmy do głównej metody – ApplyAsync:

    public override async ValueTask ApplyAsync(ResponseTransformContext context)
    {
        var tokenModel = await ReadTokenModelFromBody(context) ?? context.HttpContext.GetTokens();
        if (tokenModel == null)
            return;

        var cookieValue = _tokenEncoder.EncodeTokens(tokenModel);
        _authCookieService.AddAuthCookieToResponse(context, cookieValue);
    }

Co tu się konkretnie dzieje?

  1. Pobieramy sobie token z body lub jakiegoś nagłówka.
  2. Jeśli istnieje, to szyfrujemy go.
  3. Tworzymy ciasteczko z tym zaszyfrowanym tokenem i dodajemy je do żądania, które jest dalej przesyłane na front.

I to w zasadzie tyle.

Rejestracja transformacji

Rejestracja transformacji nie jest taka oczywista w Yarp.ReverseProxy. Można to zrobić na kilka sposobów. Ale jeśli używamy dependency injection w naszych transformacjach, powinniśmy stworzyć sobie jeszcze jedną klasę: TransformProvider:

internal class TransformProvider : ITransformProvider
{
    public void Apply(TransformBuilderContext context)
    {
        var requestTransform = context.Services.GetRequiredService<AccessTokenRequestTransform>();
        var responseTransform = context.Services.GetRequiredService<AccessTokenResponseTransform>();
        context.RequestTransforms.Add(requestTransform);
        context.ResponseTransforms.Add(responseTransform);
    }

    public void ValidateCluster(TransformClusterValidationContext context)
    {

    }

    public void ValidateRoute(TransformRouteValidationContext context)
    {

    }
}

Wystarczy zaimplementować interfejs ITransformProvider i wypełnić metodę Apply. Potem tworzymy nasze transformacje (w tym przypadku pobieramy z dependency injection) i dodajemy je do odpowiednich kolekcji. Bo transformacji możemy mieć wiele – zarówno dla requestu jak i dla responsu.

Na koniec trzeba to porejestrować:

builder.Services.AddSingleton<AccessTokenRequestTransform>();
builder.Services.AddSingleton<AccessTokenResponseTransform>();
builder.Services.AddReverseProxy()
    .AddTransforms<TransformProvider>()
    .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

W przykładowej solucji na GitHub są dwa projekty, które muszą zostać uruchomione razem – BFF i WebApi. W projekcie BFF w pliku BFF.http jest napisane podstawowe żądanie, które możesz uruchomić, żeby zobaczyć sobie jak to wszystko działa.

Zachęcam Cię do pobrania sobie tego „szkieletu” i pobawienie się trochę nim.


Jeśli pracujesz (albo jesteś częścią teamu) nad aplikacją SPA i jest tam jakaś forma autoryzacji z tokenami, to BFF jest poprawną drogą. Jest to również oficjalne zalecenie w OAuth, żeby używać BFF przy aplikacji typu SPA.

Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie zrozumiałeś lub zauważyłeś gdzieś błąd, koniecznie daj znać w komentarzu 🙂

Jeśli masz dodatkowe pytania – też napisz. Z chęcią odpowiem.

Podziel się artykułem na:
Przegląd typów kolekcji – ściąga

Przegląd typów kolekcji – ściąga

Wstęp

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?

if(!collection.Contains(element))
    collection.Add(element);

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 😉

Podziel się artykułem na:
Czym jest ten Span<T>?

Czym jest ten Span<T>?

Wstęp

W pewnym momencie powstał (a właściwie „został odkryty”) typ Span<T>. Nagle Microsoft zaczął go wszędzie używać, twierdząc że jest zajebisty. No dobra, ale czym tak naprawdę jest ten Span? I czy faktycznie jest taki zajebisty? Zobaczmy.

Jedno jest pewne. Używasz go, być może nawet o tym nie wiedząc (jest rozlany wszędzie we Frameworku).

Czym jest Span<T>?

Żeby była jasność. Span to NIE JEST kontener.

W zasadzie Span<T> to takie oczko, które gapi się na przekazany fragment pamięci:

I właściwie mógłbym na tym artykuł skończyć, bo obrazek powyżej idealnie oddaje sens i działanie Spanu. Ale ok, jeśli chcesz więcej, to czytaj dalej 🙂

Przede wszystkim musisz zrozumieć różnice między stosem i stertą. Jeśli nie jesteś pewien, koniecznie przeczytaj ten artykuł, w którym nieco nakreślam sytuację.

Span jako wskaźnik

Span<T> jest w pewnym sensie wskaźnikiem w świecie zarządzanym. Wskazuje na pewien obszar pamięci na stercie (zarządzanej i niezarządzanej). Ale może też wskazywać na pamięć na stosie. Pamiętaj, że jako wskaźnik, sam Span jest alokowany na stosie i zasadniczo składa się z dwóch elementów:

  • adresu miejsca, na który wskazuje
  • wielkości tego miejsca

Można by powiedzieć, że Span<T> wygląda mniej więcej tak:

public readonly ref struct Span<T>
{
    private readonly ref T _pointer;
    private readonly int _length;
}

Tak, mniej więcej taka jest zawartość spanu (+ do tego kilka prostych metod i rozszerzeń). Z tego wynika pierwsza rzecz:

  • Span jest niemutowalny (immutable) – raz utworzonego Spanu nie można zmienić. Tzn., że raz utworzony Span zawsze będzie wskazywał na to samo miejsce w pamięci.

W pamięci może to wyglądać mniej więcej tak:

Na stosie mamy dwie wartości, które składają się na Span. Pointer, jak to pointer, wskazuje na jakiś obszar w pamięci na stercie, natomiast length posiada wielkość tego obszaru. A co to za obszar? Ten, który wskażesz przy tworzeniu Spana.

Uniwersalność tworzenia

Utworzyć Spana możemy właściwie ze wszystkiego. Z tablicy, z listy, z Enumerable, a nawet ze zwykłego niskopoziomowego wskaźnika. To jest jego główna moc i jeden z powodów jego powstania.

I, powtarzam – Span to NIE JEST żadna kolekcja. To NIE JEST tablica. Span nie służy do przechowywania danych, tylko pokazuje Ci fragment pamięci z tymi danymi, które zostały zaalokowane gdzieś wcześniej.

A po co to?

Wyobraź sobie teraz, że masz listę intów, której elementy chcesz zsumować. Możesz przecież zrobić coś takiego:

static void Main(string[] args)
{
    List<int> list = [1, 2, 3];
    var value = Sum(list);
}

private static int Sum(List<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

I to zadziała super. Masz metodę, która sumuje jakieś dane.

Ok, a teraz załóżmy, że w pewnym momencie ktoś zamiast listy chce dać Ci tablicę. I czym to się kończy? Czymś takim:

static void Main(string[] args)
{
    int[] arr = [1, 2, 3];
    var value = Sum(arr.ToList());
}

private static int Sum(List<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

Czy to jest w porządku?

Spójrz co się dzieje w linii 4. TWORZYSZ listę. W sensie dosłownym – tworzysz zupełnie nowy obiekt (wywołując ToList()). Wszystkie wartości tablicy są kopiowane i jest tworzona nowa lista, która będzie obserwowana przez Garbage Collector na stercie zarządzanej.

Czyli masz już dwa obiekty obserwowane na tej stercie – tablicę i listę. Pamiętaj, że tworzenie obiektów na stercie jest stosunkowo kosztowne. Poza tym, gdy pracuje Garbage Collector, Twoja aplikacja NIE pracuje, tylko czeka.

I teraz z pomocą przychodzi Span:

static void Main(string[] args)
{
    int[] arr = [1, 2, 3];
    var span = new Span<int>(arr);
    var value = Sum(span);
}

private static int Sum(Span<int> source)
{
    var sum = 0;
    foreach (var item in source)
    {
        sum += item;
    }

    return sum;
}

Co się dzieje w tym kodzie? Przede wszystkim zwróć uwagę, że metoda Sum w ogóle się nie zmieniła (poza typem argumentu, który przyjmuje). Span daje Ci możliwość iterowania bez żadnych przeszkód.

To, co zostało zmienione, to zamiast Listy na stercie, utworzony został Span. A gdzie? Na STOSIE! Ponieważ, jak pisałem na początku – Span to STRUKTURA i jako taka jest tworzona na stosie. Nie ma też narzutu związanego z kopiowaniem danych i tworzeniem obiektu na stercie.

Dlaczego nie ma narzutu związanego z kopiowaniem danych? Bo nic nie jest kopiowane – powtarzam – Span to wskaźnik – on wskazuje na obszar zajmowany przez dane w tablicy arr. I to jest też mega ważne – wskazuje na konkretne dane, a nie na cały obiekt. Innymi słowy, wskazuje na miejsce arr[0]. I to jest właśnie druga główna supermoc Spana (tak samo wskaże na początek danych listy itd).

Porównanie

Zróbmy sobie teraz małe porównanie. Span idealnie działa ze stringami, gdzie widać jego moc już na pierwszy rzut oka. Więc napiszmy sobie prostą apkę, która zwróci ze stringa jakieś obiekty. String będzie w tej postaci:

string data = "firstname=John;lastname=Smith";

Stwórzmy też wynikową klasę:

class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

Aby wykonać to zadanie bez użycia Spana, napisalibyśmy kod mniej więcej taki:

private static Person ProcessDataUsingString(string data)
{
    var values = data.Split(";"); //alokacja dwóch nowych stringów i tablicy
    var firstName = values[0].Substring(values[0].IndexOf('=') + 1); //alokacja nowego stringu
    var lastName = values[1].Substring(values[1].IndexOf('=') + 1); //alokacja nowego stringu

    return new Person
    {
        FirstName = firstName,
        LastName = lastName
    };
}

Natomiast ze Spanem mogłoby to wyglądać tak:

private static Person ProcessDataUsingSpan(string data)
{
    var span = data.AsSpan();

    var fNameValues = span.Slice(0, span.IndexOf(';'));
    var lNameValues = span.Slice(span.IndexOf(';') + 1);

    var firstName = fNameValues.Slice(fNameValues.IndexOf('=') + 1);
    var lastName = lNameValues.Slice(lNameValues.IndexOf('=') + 1);

    return new Person
    {
        FirstName = firstName.ToString(), //alokacja nowego stringu
        LastName = lastName.ToString() //alokacja nowego stringu
    };
}

Zrobiłem Benchmark dla jednego takiego rekordu:

MethodMeanErrorStdDevRatioGen0AllocatedAlloc Ratio
ProcessWithString48.81 ns0.231 ns0.193 ns1.000.0421264 B1.00
ProcessWithSpan26.69 ns0.534 ns0.525 ns0.550.0179112 B0.42

Jak widać Span jest zdecydowanie bardziej wydajny. Nie tylko pod względem czasu wykonania, ale i alokacji pamięci. Właściwie jedyne alokacje, jakie się tu odbyły są już podczas tworzenia nowego obiektu Person – gdy przypisywane są konkretne nowe stringi do obiektu.

Span tylko do odczytu

Zwykły Span pozwala na zmianę konkretnych danych w pamięci. A jeśli jednak chciałbyś użyć jego bezpieczniejszej wersji, to możesz ReadOnlySpan. Działa dokładnie tak samo, tylko nie umożliwia zmiany danych.

I ta właśnie wersja jest zwracana przez stringa – czyli wciąż nie możesz zmienić raz utworzonego stringa (bez użycia kodu unsafe i niskopoziomowych wskaźników).

Możesz mieć tu teraz mały mindfuck – jak to span jest niemutowalny, ale można zmienić mu dane?

Span jest niemutowalny pod tym względem, że raz utworzony zawsze będzie wskazywał na ten sam fragment pamięci. Ale to, co jest w tej pamięci możesz zmienić (chyba że masz ReadOnlySpan). Zobaczysz to dalej w artykule.

Na co nie pozwala?

Nie można zmienić stringu

Jak już pisałem, Span nie pozwala na zmianę elementów stringu. String jest zawsze niemutowalny i nic na to nie poradzisz (poza niskopoziomowymi wskaźnikami).

Nie może być częścią klasy

Z tego powodu, że Span MUSI BYĆ alokowany na stosie, nie może być elementem klasy. W sensie właściwością, czy polem. Elementy klasy mogą być alokowane na stercie, a Span nie może, bo ma to zabronione. Za to Span może być częścią ref struct. Ale w takiej sytuacji musisz uważać.

Musisz używać go mądrze

Pamiętasz jak pisałem, że raz utworzony Span zawsze będzie wskazywał dokładnie to samo miejce w pamięci? Musisz wiedzieć, co masz w tym miejscu, żeby nie doszło do wykrzaczenia:

byte[] arr = [1, 2, 3];
var span = new Span<byte>(arr);
arr = null!;

GC.Collect();

span[0] = 10;

Spójrz, najpierw alokujemy pamięć na tablicę. Na stosie znajduje się adres do elementów tej tablicy (czyli zmienna arr). Tworzymy sobie Span dla tej tablicy – Span teraz wskazuje na elementy tablicy na stercie.

Następnie usuwamy naszą tablicę – w efekcie nic tego miejsca już nie używa. Garbage Collector w pewnym momencie może je wyczyścić albo mogą zostać tam zaalokowane inne rzeczy. Natomiast Span cały czas wskazuje na tę pamięć. I przypisując jakąś wartość możesz doprowadzić do Access Violation.

Więc pod tym względem musisz być uważny.

Nie możesz go używać w asynchronicznych operacjach

Jako, że każdy wątek ma swój oddzielny stos, a Stack jest alokowany na stosie, to nie może być używany w operacjach asynchronicznych. Jeśli potrzebujesz czegoś takiego, użyj Memory<T>. O tym może też coś napiszę.

Czy Span jest zajebisty?

To w sumie tylko narzędzie. Odpowiednio użyte może sprawdzić, że aplikacje w C# będą naprawdę wydajne. Niemniej jednak cieszę się, że coś takiego powstało, ponieważ to jest jedna z rzeczy, których brakowało mi trochę, a które używałem w innych językach. Czyli wskaźniki. I to bez użycia kodu unsafe 🙂


Dzięki za przeczytanie artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu 🙂

Podziel się artykułem na:
Z pogranicza – używamy DLL pisanej w C++ – część 2 – struktury

Z pogranicza – używamy DLL pisanej w C++ – część 2 – struktury

Wstęp

W pierwszej części artykułu opisałem podstawy mieszania języków – C# i C++. Skupiliśmy się tam nieco bardziej na stringach, które nie są tak oczywiste i czasem sporo z nimi zabawy. Dzisiaj będzie coś jeszcze mniej oczywistego – struktury. Jeśli jednak nie ogarniasz podstaw, koniecznie przeczytaj pierwszą część artykułu.

Język C++ jest tylko przykładowy. Za każdy razem, gdy piszę o funkcji lub strukturze C++, mam na myśli funkcję / strukturę pisaną w dowolnym języku niskiego poziomu (niezarządzanego) typu C, C++, Pascal.

Przypominam, że cykl jest dość zaawansowany. Wymaga trochę wiedzy na temat pamięci i wskaźników. Co nieco pisałem w tym artykule. Ale jeśli czegoś nie rozumiesz, to koniecznie daj znać w komentarzu.

Przykładowy projekt

Przygotowałem prostą solucję, którą możesz pobrać z GitHuba. To są dwa projekty – jeden C++, drugi C#. Projekt C++ to prosta biblioteka DLL, którą będziemy używać w C#. Nie ma żadnego konkretnego sensu. Napisałem ją tylko na potrzeby tego artykułu. Na dzień pisania artykułu (lipiec 2024) biblioteka nie jest w pełni ukończona. Będę dodawał jej funkcjonalność przy kolejnych częściach artykułu. Niemniej jednak, projekty się budują i działają w obrębie tego artykułu jak i jego pierwszej części.

Jeśli masz problem z uruchomieniem solucji, jest to dokładnie opisane w pierwszej części artkułu.

Struktury w pamięci

Niestety struktury zarządzane i niezarządzane są nieco inaczej rozłożone w pamięci. Dlatego też trzeba zastosować kilka dodatkowych środków, żeby to ładnie pożenić. Ale od początku.

Co ciekawe, nazwy struktur nie mają tutaj żadnego znaczenia. Po stronie C++ możesz mieć strukturę nazwaną UserInfo, natomiast po stronie C# to może być AdditionalUserData. Nie ma to żadnego znaczenia.

To, co faktycznie ma znaczenie, to pola w tych strukturach. Muszą być zdefiniowane dokładnie w takiej samej kolejności i muszą zajmować dokładnie tylko samo bajtów w pamięci:

  • ta sama kolejność pól
  • pola tej samej długości

Niestety nie ma opcji, żeby w czasie kompilacji sprawdzić to. Po prostu jeśli zrobisz coś źle, to aplikacja się wywali w runtime. Z tego wychodzi pierwsze niebezpieczeństwo, które wiele lat temu (gdy na muchy mówiłem jeszcze „ptapty”) pozbawiło mnie kilku dni życia. I wcale to nie jest takie oczywiste.

Atrybut StructLayoutAttribute

W C#, ze względów wydajnościowych, pola w strukturze czasami są zamieniane miejscami. I zupełnie inaczej wyglądają w pamięci niż w kodzie. Dlaczego? Bo procesor najłatwiej i najszybciej odczytuje bloki po 4/8 bajtów w pamięci. Dochodzi do tego jeszcze padding.

Czym jest padding?

Jak już pisałem, procesor najlepiej radzi sobie (najbardziej optymalnie) z blokami po 4/8 bajtów w pamięci. Zatem ile miejsca w pamięci zajmie taka struktura?

struct MyStruct
{
    public bool b;
    public int i;
}

Odpowiedź może Cię zdziwić. Pomimo, że zmienne typu bool potrzebują 1 bajta w pamięci, a int 4 bajtów, to taka struktura zajmie 8 bajtów, a nie 5. Dlaczego? Właśnie przez optymalizację. Gdy kompilator zobaczy zmienną typu bool, będzie chciał dokonać wyrównania (alignment), czyli zarezerwuje jej pamięć tak, żeby zmienna znajdowała się w 4 bajtowym bloku. Czyli dopełni do 4 bajtów. To dopełnienie nazywa się paddingiem. Int zajmuje już 4 bajty, czyli tutaj nic się nie stanie. Dokładniej rzecz biorąc nie chodzi konkretnie o 4 bajty, tylko o największy składnik w strukturze. Ale to już naprawdę głębokie szczegóły.

Zobacz, jak ta struktura wygląda w pamięci:

MyStruct mstr;
mstr.b = true;
mstr.i = 10;

Jeśli będziesz mieć taką strukturę:

struct MyStruct
{
    public bool b;
    public bool c;
    public int i;
}

to ona też zajmie 8 bajtów. Kompilator zobaczy, że dwie pierwsze zmienne mają 2 bajty, więc dopełni je do 4. Czyli w tym przypadku nie ma różnicy, czy masz jedno pole typu bool, czy 4.

MyStruct mstr;
mstr.b = true;
mstr.c = true;
mstr.i = 10;

A co się stanie teraz?

struct MyStruct
{
    public bool b;
    public int i;
    public bool c;
}

Prawdę mówiąc, nie jestem w stanie odpowiedzieć na to pytanie. Różne ustawienia i wersje kompilatora mogą doprowadzić do różnych wyników. Jednym z nich jest zarezerwowanie 12 bajtów w pamięci (b + 3 bajty paddingu, i, c + 4 bajty paddingu). Innym jest pomieszanie pól w taki sposób, żeby uzyskać strukturę jak z poprzedniego kodu – czyli 2 zmienne bool będą w pamięci obok siebie.

Załóżmy, że mamy taką strukturę:

struct MyStruct
{
    public bool b;
    public bool c;
    public int i;
}

Domyślna wielkość paddingu to 4 bajty. W związku z tym, że 2 pierwsze elementy zajmują dwa bajty, kompilator doda kolejne 2 bajty przed zmienną int. Dzięki czemu mamy 2 bloki po 4 bajty.

Jednak jeśli ustawiłbyś jawnie padding na wielkość 2 bajtów – żaden padding nie zostanie dodany – w tym konkretnym przypadku. Dwa pierwsze pola zajmują już 2 bajty. Czyli mamy tutaj blok o określonej wielkości.

A jeśli ustawimy padding na 8 bajtów? Też nic się nie stanie. I dlaczego do cholery? Dlaczego? Ponieważ obliczając wielkość wyrównania, kompilator bierze pod uwagę nie tylko tą żądaną wielkość, lecz również wielkość największego pola w strukturze. A skoro tutaj największym polem jest int – 4 bajty, zatem do tych 4 bajtów będzie dokładane dopełnienie. Oczywiście, jeśli struktura posiadałaby pole typu long (8 bajtów), wtedy zastosowany będzie padding 8 bajtów. W jaki sposób?

struct MyStruct
{
    public bool b;
    public bool c;
    public int i;
    public long l;
}

Pierwsze dwa pola zajmą 8 bajtów – każde z nich będzie dopełnione do 4. Kolejne pole – int zajmie 8 bajtów – zostanie dopełnione do 8, No i long ma już 8 bajtów.

A co się stanie, jeśli padding ustawimy na 1?

Żaden padding nie zostanie zastosowany i pola nie będą dopełniane. Ale o tym za chwilę.

I jak to się ma z tym, co wcześniej napisałem? Że struktury w C++ i w C# muszą mieć pola o tej samej wielkości i w tym samym porządku?

Po stronie C# służy do tego wspomniany atrybut StructLayout.

Jak używać StructLayout?

Atrybut StructLayout mówi kompilatorowi w jaki sposób ma potraktować daną strukturę w pamięci. Atrybut posiada 4 parametry:

  • LayoutKind
  • CharSet
  • Pack
  • Size

LayoutKind

Możemy tutaj ustawić 3 wartości:

  • Sequential – pola w strukturze będą ustawione dokładnie w takiej samej kolejności, w jakiej napisaliśmy w kodzie. Powinieneś użyć jeszcze parametru Pack, żeby określić dokładnie padding. I wtedy możesz przekazywać taką strukturę między światem niezarządzanym i zarządzanym.
[StructLayout(LayoutKind.Sequential, Pack = 1)] //Pack ustawiony na 1 zabrania dokonywania jakiegokolwiek paddingu
struct MyStruct
{
    public byte b;
    public byte b2;
    public int i;
}
  • Explicit – pola w strukturze są tak ustawione, jak je określisz za pomocą atrybutów FieldOffset. To znaczy, że możesz dokładnie i jawnie (explicitly) podać w którym miejscu w pamięci ma się zacząć konkretne pole.
[StructLayout(LayoutKind.Explicit)]
struct MyStruct
{
    [FieldOffset(0)] public byte b; //w bajcie 0
    [FieldOffset(1)] public byte b2; //w bajcie 1
    [FieldOffset(2)] public int i; //w bajcie 2
}

W tej sytuacji pole b będzie na początku struktury. b2 zacznie się w pierwszym bajcie, a i w drugim. Oczywiście musisz na to uważać, bo możesz zrobić głupotę:

[StructLayout(LayoutKind.Explicit)]
struct MyStruct
{
    [FieldOffset(0)] public byte b;
    [FieldOffset(2)] public byte b2
    [FieldOffset(1)] public int i;
}

Pamiętaj, że pole int zajmie 4 bajty. Byte zajmie 1 bajt. I co się stanie teraz? Pole int przesłoni pole byte. I w zależności od tego, co przypiszesz jako ostatnie, będą różne dziwne wyniki. Więc jeśli stosujesz layout typu explicit, to uważaj na to.

  • Auto – domyślny układ – czyli pola mogą być w różnym miejscu z paddingiem.

Pack

Parametr Pack określa wielkość paddingu. Opisałem to w akapicie dla dociekliwych, więc nie będę tutaj się powtarzał. Wartość 0 jest domyślna, natomiast jeśli w ogóle nie chcesz paddingu, daj tam wartość 1.

Size

Określić tym możesz wynikowy rozmiar CAŁEJ struktury. Jeśli jednak podasz zbyt małą liczbę, to spokojnie. Struktura i tak zajmie swoją minimalną wymaganą przestrzeń.

Padding po stronie C++

Po stronie C++ też należy określić padding dla struktury. Tam stosujesz dyrektywę pragma pack:

#pragma pack(push, 1)
	struct MyData
	{
		USHORT b;
		USHORT b2;
		int i;
	};
#pragma pack(pop)

Pobieranie danych z C++ – struktury kopiowalne

Pobierzemy sobie wreszcie jakieś dane z C++. Na razie spójrzmy na struktury z polami kopiowalnymi (blitable). Jeśli nie pamiętasz, czym są takie pola, to jest to opisane w pierwszej części artykułu.

Po stronie C++ mamy taką strukturę:

#pragma pack(push, 1)
struct Point3d
{
	float x;
	float y;
	float z;
};
#pragma pack(pop)

Prosta struktura reprezentująca trójwymiarowy punkt. Po stronie C# musimy napisać analogiczną:

[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct Point3d
{
    public float X;
    public float Y;
    public float Z;
}

Zmienne float w C++ i w C# zajmują tyle samo miejsca – 4 bajty. Dodatkowo zapewniliśmy integralność obu struktur – brak paddingu.

W tym momencie możemy traktować naszą zarządzaną strukturę tak jakby to była zwykła natywna struktura. A więc możemy zrobić tak:

//deklaracja w C++
DLL_EXPORT Point3d __stdcall getPoint3d();

//definicja w C++
DLL_EXPORT Point3d __stdcall getPoint3d()
{
	return { 1.5f, 2.25f, 3.3f };
}

A po stronie C#:

//deklaracja w C#
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern Point3d getPoint3d();

Możemy też bawić się strukturą przez referencję:

//deklaracja w C++
DLL_EXPORT void __stdcall updatePoint3d(Point3d& point);

//definicja w C++
DLL_EXPORT void __stdcall updatePoint3d(Point3d& point)
{
	point.x += 1.0f;
	point.y += 1.0f;
	point.z += 1.0f;
}

I po stronie C#:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void updatePoint3d(ref Point3d pt);

I teraz uwaga – jeśli w strukturze chcesz mieć jakieś metody – w niczym to nie przeszkadza. Dane przejdą normalnie.

Ten sam padding

Wcześniej pisałem, że procesor działa najbardziej optymalnie, gdy odczytuje bloki o odpowiedniej długości. Ustawiając padding na 1, wyłączamy go w ogóle i powodujemy, że kod nie jest super optymalny. Oczywiście w standardowych przypadkach to pewnie będzie niezauważalne.

Ale chcę żebyś miał świadomość, że to nie chodzi o to, żeby tego paddingu nie było w ogóle, tylko żeby był taki sam po stronie natywnej i .NET.

Czyli równie dobrze nasze struktury mogłyby wyglądać tak:

#pragma pack(push, 8)
struct Point3d
{
	float x;
	float y;
	float z;
};
#pragma pack(pop)

I po stronie C#:

[StructLayout(LayoutKind.Sequential, Pack = 8)]
public struct Point3d
{
    public float X;
    public float Y;
    public float Z;
}

Padding jest taki sam po stronie C++ i C#. Dopełnienie jest do 8 bajtów. Procesor skorzysta na tym 🙂

Struktury niekopiowalne

Struktury ze stringami

Zacznijmy od struktury ze stringami.

Załóżmy, że po stronie C++ mamy taką strukturę:

#pragma pack(push, 1)
struct FileInfo
{
	wchar_t fileName[MAX_PATH];
	size_t fileSize;
};
#pragma pack(pop)

To po prostu nazwa pliku i rozmiar pliku. Istotne tutaj jest, że nazwa pliku jest przedstawiona jako statyczna tablica – tzn. tablica o stałej długości. W C# powinniśmy tę strukturę zadeklarować tak:

[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)]
public struct CppFileInfo
{
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
    public string fileName;
    public long fileSize;
}

Użycie StructLayout już znasz. Dodatkowo, w związku z tym, że w strukturze występują stringi, musimy w struct layout zaznaczyć, że te stringi są unicodowe. A są unicodowe, bo po stronie C++ mamy tablicę wchar_t, a nie char.

Ale druga rzecz jest taka, że w związku z tym, że łapiemy zwykłego stringa po stronie C#, musimy zrobić na nim Marshaling. W marshalingu zaznaczamy, że jest to string. Po stronie C++ mamy string w najczystszej postaci, więc musimy powiedzieć .NETowi, że właśnie tak ma traktować ten fragment pamięci. Rozmiar tego stringu ustawiamy na 260, bo taką wartość ma stała MAX_PATH.

I teraz możemy już wywołać funkcję, która zwróci nam informacje o jakimś pliku. Po stronie C++:

//deklaracja:
DLL_EXPORT FileInfo __stdcall getFileInfo(const wchar_t* pFilePath);

//definicja
DLL_EXPORT FileInfo __stdcall getFileInfo(const wchar_t* pFilePath)
{
	std::filesystem::path path{ pFilePath };

	FileInfo result = { 0 };
	result.fileSize = std::filesystem::file_size(path);
	
	std::wstring filePath = pFilePath;
	CopyMemory(result.fileName, pFilePath, filePath.size() * sizeof(wchar_t));

	return result;
}

Po stronie C#:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern CppFileInfo getFileInfo(string filename);

Zauważ, że tutaj CharSet też ustawiamy, ale on odnosi się do zmiennej, którą przekazujemy do C++, a nie do tej ze struktury.

Podsumowując, ze stringami w strukturach radzimy sobie dokładnie tak samo, jak ze zwykłymi stringami – opisanymi w poprzednim artykule. Wszystko dotyczące stringów jest prawdą i tutaj.

Tylko różnica jest taka, że odpowiedni Marshaling musimy ogarnąć na poziomie pola w strukturze, a nie na poziomie definicji funkcji.


To wszystko jeśli chodzi o przekazywanie struktur. W kolejnej części zajmiemy się klasami i wskaźnikami. Będzie zabawa 🙂

Jeśli znalazłeś błąd w artykule lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

Podziel się artykułem na:
Code Europe 2024 nadchodzi!

Code Europe 2024 nadchodzi!


Czas akcji: 10 – 11.06.2024
Miejsce akcji: ICE Kraków (ul. Marii Konopnickiej 17, Kraków)


Gotowy na Code Europe — największy festiwal technologiczny w Polsce? 🎆

Hej, tegoroczna edycja Code Europe odbędzie się już w czerwcu w Krakowie. Tym razem będą to dwa dni konferencji, a więc warto sobie zabukować jakiś hotel. Jest to prawdopodobnie największa tego typu impreza w Polsce.

Byłem na Code Europe dwa lata temu ostatnim razem i potwierdzam, że było ciekawie. Niestety w tym roku nie będę mógł dołączyć, a szkoda, bo agenda wygląda całkiem fajnie, co możecie zobaczyć na oficjalnej stronie wydarzenia: https://www.codeeurope.pl/pl/

Prelegenci

Lista jest jak zwykle długa, ale możemy się spodziewać między innymi takich postaci jak:

  • Kenneth Rohde Christiansen – Intel
  • Ekaterina Sirazitdinova – NVidia, ekspertka od AI, która rozwijała m.in. analizy medyczne oparte na obrazach
  • Michelle Sandford – ewangelistka z Microsoftu
  • Shaundai Person – Netflix
  • dr Robert Gentleman – Harvard Medical School – współtworzył język R, mocno związany z biomedycyną

i inni.

Czemu warto?

/akapit organizatora/

Code Europe to nie tylko zwykłe wydarzenie IT — to miejsce, gdzie najlepsi eksperci z całego świata gromadzą się, by dzielić się wiedzą, wymieniać doświadczeniami i przekraczać granice technologii, a to wszystko w otoczce festiwalowej zabawy!

To Twoja szansa, by odkryć najnowsze trendy i nawiązać kontakty, które mogą kształtować Twoją przyszłość zawodową w branży technologicznej. Z ponad 15 000 już zaangażowanych uczestników, możesz być pewien, że każda chwila spędzona na Code Europe będzie wartościowa i zdecydowanie nie chcesz tego przegapić!

Code Europe zabłyśnie na ICE Kraków 10-11 czerwca 2024 r. Festiwal jest dla wszystkich entuzjastów technologii, deweloperów, architektów oprogramowania, specjalistów DevOps, zapaleńców bezpieczeństwa, profesjonalistów ds. produktu, specjalistów ds. danych i pasjonatów QA, którzy chcą dzielić się wiedzą i razem ją zdobywać!

Dlaczego warto być częścią Code Europe?

🚀 Unikalne spojrzenie: ucz się od najlepszych prelegentów, którzy dzielą się najświeższymi spojrzeniami i doświadczeniami z branży technologicznej.

🤝 Networking: poznaj innych entuzjastów technologii, współpracuj nad pomysłami i nawiąż kontakty, które mogą wpłynąć na Twoją dalszą ścieżkę kariery.

💼 Paliwo karierowe: odkryj możliwości, połącz się z gigantami branży i zdobądź wgląd w najgorętsze trendy i możliwości w branży IT i technologii.

🎤 Najlepsi prelegenci: przygotuj się na prelekcje z liderami technologicznymi takimi jak Venkat Subramaniam, José Valim, Sébastien Chopin, Sven Peters, Michelle Sandford i wielu, wielu innych!

UWAGA! Zniżka!

CodeEurope jest oczywiście płatnym eventem. Jednak moi subskrybenci dostają 20% zniżki na bilety. Zniżka już poleciała do Was w osobnym mailu 🙂

Jeśli jeszcze nie subskrybujesz, koniecznie zapisz się na newsletter, a też dostaniesz kod zniżkowy 🙂

Podziel się artykułem na:
Kiedy i jak ponawiać żądania HTTP? Najlepsze praktyki

Kiedy i jak ponawiać żądania HTTP? Najlepsze praktyki

Wstęp

Jeśli Twoja apka używa WebAPI, warto zatroszczyć się o ponawianie żądań HTTP właśnie do tych API. Stosuje się to z kilku powodów, o których piszę niżej.

W tym artykule przedstawię Ci najlepsze praktyki, jakie możesz wykorzystać.

O tworzeniu własnego klienta WebAPI i różnych uproszczeniach, które możemy zastosować, pisałem już w tym artykule. Ten, który czytasz, potraktuj jako rozszerzenie i coś, co warto zastosować w prawdziwym kodzie.

Jeśli jesteś zainteresowany tylko konkretnym rozwiązaniem, rozwiń poniższy akapit:

Na szybko

  1. Pobierz Nuget: Microsoft.Extensions.Http.Polly
  2. Przy rejestracji klienta Http dodaj kod:
builder.Services.AddHttpClient<IndexModel>((client) =>
{
    client.BaseAddress = new Uri("https://example/com/");
}).AddTransientHttpErrorPolicy(policy => 
        policy.OrResult(x => x.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 3)));

Teraz możesz już używać HttpClienta otrzymanego z dependency injection w standardowy sposób. Wszystko załatwia pobrana biblioteka i metoda AddTransientHttpErrorPolicy.

Jeśli chcesz wiedzieć więcej, przeczytaj cały artykuł.

Po co ponawiać żądania?

Jeśli pobierasz jakieś dane z WebApi możesz spotkać się z kilkoma odpowiedziami poza poprawną. Wtedy masz dwie opcje – pokazać użytkownikowi błąd. No i cześć. Albo spróbować ponowić żądanie, może za drugim razem się uda, a doświadczenie użytkownika z Twoją aplikacją będzie lepsze.

Odpowiedzi, po których warto ponowić żądanie to na przykład:

Wewnętrzny błąd serwera

Czyli kody odpowiedzi 5xx.

Oznacza to, że serwer ma aktualnie problem ze sobą. Mamy nadzieję, że chwilowy. W najgorszym wypadku, programiści czegoś nie przewidzieli i kod po prostu się wywala. Jeśli jednak jest to chwilowy problem, warto spróbować ponowić żądanie. Być może problem za chwilę zniknie.

Throttling

Kod 429: Too Many Requests.

Ten problem oznacza, że klient (Twoja aplikacja) zbyt często odpytuje serwer. Serwer ma ustawiony jakiś rate limit, co oznacza że możemy do niego strzelić określoną ilość razy w określonym czasie. To może być też ograniczone do ilości przesłanych danych. Więc jeśli otrzymasz odpowiedź 429 oznacza to, że za jakiś czas powinieneś ponowić żądanie i będzie git.

A co przy braku szukanego zasobu, skoro został utworzony?

Stare, dobre 404.

No… tutaj ponawianie ma sens tylko w jednej sytuacji. Kiedy wcześniej próbowałeś stworzyć zasób, ale to chwilę może zająć. A samo WebApi jest asynchroniczne. O asynchronicznych WebApi pisałem w tym artykule. W innym wypadku powtarzanie żądania przy tym kodzie nie ma żadnego sensu. Czyli zasadniczo nie powinieneś dostać takiej sytuacji, jeśli poprawnie obsługujesz asynchroniczne WebApi.

Ponawianie żądania – z czym to się je?

Zasadniczo sytuacja jest ciekawa. Bo nie ma innej opcji jak tylko ponawianie żądania w jakiejś pętli. Jednakże można to robić zarówno źle jak i dobrze. I źle to np. samemu tworzyć takie mechanizmy.

I możesz napisać sobie coś najprostszego w stylu (tylko fragment kodu):

HttpResponseMessage response;
try
{
    response = await _httpClient.GetAsync("api/get-data");
    switch(response.StatusCode)
    {
        case ....
        //jakoś zrób retry
    }
}catch(HttpRequestException ex)
{
    switch(ex.StatusCode)
    {
        case ...
        //jakoś zrób retry
    }
}

To oczywiście nie dość, że ciężko jest reużyć, to jest brzydkie. Nie dość, że jest brzydkie, to sam musisz oprogramować jakieś standardowe zachowania. Sam musisz:

  • napisać kolejne mechanizmy do ponawiania requestu,
  • pilnować, czy nie ponawiasz requestu nieskończoną ilość razy,
  • pilnować, czy ponawiasz go dostatecznie długo, ale nie za długo,
  • napisać coś, co pozwoli Ci ponawiać request po jakimś delayu, a nie od razu,
  • i pewnie mnóstwo innych rzeczy.

Wpadasz w dużą ilość pułapek, zaczynasz trafiać na mnóstwo zduplikowanego kodu i koniec końców okazuje się, że tworzysz jakiś skomplikowany mechanizm albo nawet cały projekt, którego jedynym zadaniem jest tak naprawdę ponowienie requestu w pewnych warunkach… Zamiast skupić się na faktycznej robocie.

Co jaki czas ponawiać request?

Na to pytanie będziesz musiał odpowiedzieć sobie tak, czy inaczej. Nie możesz ponawiać requestu bez żadnej przerwy, np. tak

public async Task<HttpResponseMessage> SendRequest()
{
    HttpResponseMessage response;
    try
    {
        return await _httpClient.GetAsync("api/get-data");
    }catch(HttpRequestException ex)
    {
        return await SendRequest();
    }
}

Weź pod uwagę kilka rzeczy:

  • ten kod jest pozbawiony kluczowych elementów (np. sprawdzania kodu odpowiedzi)
  • zakładamy, że posiadamy tutaj ochronę przed nieskończoną rekukrencją

Tutaj będziesz ponawiał requesty jeden za drugim bez żadnej przerwy. Nie jest to dobre podejście, bo bombardujesz zupełnie bez sensu API. No i nie dajesz odetchnąć procesorowi.

Lepiej zrobić chociażby coś takiego:

public async Task<HttpResponseMessage> SendRequest()
{
    HttpResponseMessage response;
    try
    {
        return await _httpClient.GetAsync("api/get-data");
    }catch(HttpRequestException ex)
    {
        await Task.Delay(1000);
        return await SendRequest();
    }
}

Requestów wyjdzie od Ciebie duuużo mniej i będą dużo bardziej sensowne. No bo jeśli uzyskałeś odpowiedź 429, to mało prawdopodobne, że od razu w następnym requeście otrzymasz poprawną. Odczekaj chwilę – dokładnie to mówi ten błąd: „Wstrzymaj konie kowboju, daj odetchnąć… albo wykup wyższy pakiet dostępu”.

To samo tyczy się innych kodów, które warto ponawiać.

Takie „stałe” (co jedną sekundę) ponawianie pewnie jakoś wygląda i pomaga. Natomiast można to zrobić duuuużo lepiej.

Jitter

Jitter (możesz wyszukać w necie pod hasłem „retry with jitter”) to pewna zmienna, która pomaga lepiej ustalić ten czas. To może być jakaś losowość, czyli raz czekasz sekundę, raz czekasz dwie, raz czekasz pół.

Ale to może być też exponential backoff.

Exponential backoff

Zapamiętaj to pojęcie dobrze, bo może pojawiać się na pytaniach rekrutacyjnych 😉

Exponential backoff to ogólnie przyjęta strategia do obliczania czasu, jaki musi minąć pomiędzy ponawianiem konkretnego żądania. Polega na tym, że pierwsze ponowienia są dość szybko, a kolejne mają coraz większą przerwę. Zobacz ten prosty przykład ponawiania requestu w pseudokodzie:

Request();
Czekaj(1000);
Request();
Czekaj(2000);
Request();
Czekaj(4000);
Request();
Czekaj(8000);

Jeśli pierwszy request trzeba ponowić, odczekaj sekundę.

Jeśli i to nie poszło, odczekaj 2 sekundy.

Jeśli nadal nie działa, odczekaj 4 sekundy… (to jest potęgowanie) Itd.

Jest to dość eleganckie rozwiązanie i szeroko stosowane.

Oczywiście nie musisz tego wszystkiego robić sam. W .NET jest paczka, która całą tą czarną robotę z ponawianiem requestów robi za Ciebie. I to jest prawidłowy mechanizm i bardzo dobra praktyka.

Przywitaj Polly

Jest taki Nuget:

Użycie tej paczki bardzo ułatwia całą pracę, co za chwilę zobaczysz, ale można jeszcze prościej (co zobaczysz później).

Polly przedstawia mechanizm AsyncPolicy, w którym po prostu budujesz sobie politykę ponawiania requestów. Oczywiście politykę możesz zbudować raz i używać ją wszędzie albo możesz też mieć różne polityki. Zbudujmy swoją pierwszą polityke:

private readonly IAsyncPolicy<HttpResponseMessage> _retryPolicy =
    Policy<HttpResponseMessage>
        .Handle<HttpRequestException>()
        .OrResult(x => (int)x.StatusCode >= 500 || x.StatusCode == HttpStatusCode.TooManyRequests)
        .RetryAsync(3);

//wywołanie requestu nieco się teraz zmienia:
public async Task<HttpResponseMessage> SendRequest()
{
   return await _retryPolicy.ExecuteAsync(() => _httpClient.GetAsync("api/get-data"));
}

W pierwszych linijkach stworzyliśmy politykę ponawiania requestów. To jest bardzo prosty builder i ma oczywiście dużo więcej możliwości niż tylko to, co pokazałem. Ale nie chcę w tym artykule pisać dokumentacji Polly, którą znajdziesz tutaj 🙂

Generalnie to mówi tyle:

  • IAsyncPolicy<HttpResponseMessage> – stwórz politykę dla typu zwracanego HttpResponseMessage
  • Handle<HttpRequestException> – użyj, jeśli pójdzie exception typu HttpRequestException (handle exception)
  • OrResult…. – lub rezultatem będzie – i tu przekazany delegat
  • RetryAsync(3) – ponów taki request 3 razy

I zobacz teraz co się stało w metodzie SendRequest. Została tylko jedna linijka, a mamy załatwione ponawianie requestu dla konkretnych StatusCode’ów i dla exceptiona, który może być rzucony. Wszystko się dzieje wewnątrz metody ExecuteAsync. My tylko musimy przekazać jej funkcję, która ma zostać wykonana – czyli konkretny strzał do API.

ExecuteAsync zwróci HttpResponseMessage, ponieważ z takim typem została zadeklarowana nasza polityka.

Jednak tak stworzona polityka nie jest idealna, bo będzie ponawiała request za requestem bez żadnej przerwy. Czy możemy dodać jakiś delay? Oczywiście, że tak:

private readonly IAsyncPolicy<HttpResponseMessage> _retryPolicy =
    Policy<HttpResponseMessage>
        .Handle<HttpRequestException>()
        .OrResult(x => (int)x.StatusCode >= 500 || x.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(3, retryCount => TimeSpan.FromSeconds(Math.Pow(2, retryCount)));

Tutaj metodę RetryAsync zamieniliśmy na WaitAndRetryAsync. Ta metoda w pierwszym parametrze przyjmuje ilość żądanych powtórzeń – tak jak RetryAsync, natomiast w drugim podajesz czas jaki ma upłynąć przed kolejnymi powtórzeniami.

Drugim parametrem jest oczywiście funkcja, która ten czas oblicza. W parametrze funkcji dostajesz zmienną int – retryCount, która Ci mówi, które powtórzenie aktualnie się odbywa. Za pomocą tej informacji w bardzo łatwy sposób możemy stworzyć swój exponential backoff, co zostało zrobione w tym kodzie.

Wygląda skomplikowanie? Jasne, że można prościej.

Rozszerzenia do Polly

W Nuget znajdziesz różne rozszerzenia do Polly, między innymi Polly.Contrib.WaitAndRetry. Celem tego rozszerzenia jest dostarczenie Ci już gotowych mechanizmów „backoff”, czyli tych do obliczania czasu między powtórzeniami żądania. I powyższy kod może być zamieniony na taki:

private readonly IAsyncPolicy<HttpResponseMessage> _retryPolicy =
    Policy<HttpResponseMessage>
        .Handle<HttpRequestException>()
        .OrResult(x => (int)x.StatusCode >= 500 || x.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(Backoff.ExponentialBackoff(TimeSpan.FromSeconds(1), 3));

W rozszerzeniu Polly.Contrib.WaitAndRetry dostaliśmy klasę Backoff i metodę ExponentialBackoff, której przekazaliśmy dwa parametry:

  • jaki czas musi upłynąć przed PIERWSZYM ponowieniem (tutaj sekunda)
  • ile razy ponawiać

Jest jeszcze lepsza metoda – do exponential backoff można dodać element losowości. Czyli przerwy nie będą idealnymi potęgami dwójki, ale będą trwały trochę mniej lub trochę więcej:

private readonly IAsyncPolicy<HttpResponseMessage> _retryPolicy =
    Policy<HttpResponseMessage>
        .Handle<HttpRequestException>()
        .OrResult(x => (int)x.StatusCode >= 500 || x.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 3));

Jak widzisz, w bardzo łatwy sposób możesz zmieniać sobie strategie liczenia czasu.

Ale można jeszcze prościej… 😉

Integracja .NET z Polly

Microsoft w całej swojej dobroci zrobił już integrację z Polly, dzięki czemu możemy używać tego mechanizmu właściwie bez większych zmian w kodzie. Wszystko jest wpięte do HttpClientFactory, o którym pisałem trochę w artykule jak używać HttpClient.

Przede wszystkim pobierz sobie NuGeta: Microsoft.Extensions.Http.Polly. On ma już wszystkie zależeności.

Teraz, gdy rejestrujesz swojego klienta Http:

builder.Services.AddHttpClient<IndexModel>((client) =>
{
    client.BaseAddress = new Uri("https://example/com/");
});

możesz dodać swoją politykę Polly:

builder.Services.AddHttpClient<IndexModel>((client) =>
{
    client.BaseAddress = new Uri("https://example/com/");
})
    .AddPolicyHandler(Policy<HttpResponseMessage>
        .Handle<HttpRequestException>()
        .OrResult(x => (int)x.StatusCode >= 500 || x.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 3)));

Zwróć uwagę, że dodałem dokładnie tą samą politykę, co w kodzie wyżej, bo to jest dokładnie takie samo działanie.

Oczywiście swoje polityki możesz trzymać w różnych miejscach (i zmiennych) i mieć je bardziej scentralizowane, jeśli tego chcesz.

Teraz już możesz HttpClienta uzywać w sposób klasyczny:

public class IndexModel : PageModel
{
    private readonly HttpClient _httpClient;

    public IndexModel(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<HttpResponseMessage> SendRequest()
    {
       return await _httpClient.GetAsync("api/get-data");
    }
}

Jeśli jeszcze nie wiesz, czemu akurat w taki sposób używamy HttpClient (przez dependency injection), KONIECZNIE przeczytaj ten artykuł.

Można jeszcze prościej

Ludzie, trzymajcie mnie, bo można jeszcze prościej:

builder.Services.AddHttpClient<IndexModel>((client) =>
{
    client.BaseAddress = new Uri("https://example/com/");
}).AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 3)));

Powtarzanie requestu w konkretnych warunkach jest na tyle pospolite, że Microsoft zrobił dodatkowe rozszerzenie do tego. Metoda AddTransientHttpErrorPolicy dodaje politykę domyślnie ustawioną na:

  • obsługę exceptiona typu HttpRequestException,
  • obsługę rezultatu, gdy status >= 500
  • obsługę timeout.

Musimy dodać tylko backoff jaki chcemy mieć (czyli ten delay pomiędzy powtórzeniami).

Ale uwaga. Uważny czytelnik zorientował się, że metoda AddTransientHttpErrorPolicy nie dodaje do polityki statusu kodu 429 Too may requests. Zgadza się. Jeśli chcemy to mieć, musimy sami to dodać:

builder.Services.AddHttpClient<IndexModel>((client) =>
{
    client.BaseAddress = new Uri("https://example/com/");
}).AddTransientHttpErrorPolicy(policy => 
        policy.OrResult(x => x.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 3)));

Przyznasz jednak, że rozwiązanie jest duuużo bardziej czytelne i dużo lepsze niż mechanizmy, które tworzyłbyś sam, prawda? W zasadzie cały ten mechanizm ograniczył się do wywołania trzech metod przy konfiguracji. Piękna sprawa.


To tyle. Dzięki za przeczytanie artykułu. Jak zwykle, jeśli czegoś nie rozumiesz lub znalazłeś błąd w tekście, koniecznie daj mi znać w komentarzu.

A i sprawdź swoje apki, gdzie używasz zewnętrznych Api. Czy w którejś z nich masz czasem problem z dostępnością?

Podziel się artykułem na:
Badaj swoje API, czyli healthcheck

Badaj swoje API, czyli healthcheck

Spis treści

  1. Wstęp
  2. Czym jest healthcheck?
  3. Konfiguracja healthcheck
  4. Jak działa ten mechanizm?
  5. Badanie zdrowia standardowych serwisów
  6. Sprawdzenie bazy danych
  7. Jak zrobić niestandardowe sprawdzenie
  8. Jak pokazywać wynik healthcheck w niestandardowy sposób
  9. Zabezpieczanie healthcheck – uwierzytelnianie
  10. Kilka różnych końcówek – filtrowanie
  11. Dodanie healthcheck do Swaggera

Wstęp

Czy Twoja webówka działa? A jaką masz pewność? Musiałbyś co chwilę klikać i sprawdzać. Ale są też inne, lepsze metody. Możesz na przykład posłużyć się rozwiązaniem chmurowym, które cyklicznie będzie badać stan Twojej aplikacji. Co więcej, jest opcja, że nawet wyśle Ci maila albo SMSa, jeśli coś będzie nie tak.

Ten artykuł nie opowiada jednak o chmurowej części rozwiązania (jeśli chcesz taki materiał, daj znać w komentarzu), a o aplikacyjnej części. Czyli o healthcheck.

Czym jest Healthcheck?

Healthcheck jest sprawdzeniem stanu Twojej aplikacji. Czy działa wszystko ok, ewentualnie co nie działa. I oczywiście mógłbyś napisać sobie własny kontroler z odpowiednimi endpointami, w których to wszystko sprawdzasz, ale w .NET mamy już taki mechanizm w standardzie. I działa całkiem przyzwoicie.

Po co to właściwie?

Dzisiaj utrzymanie niezawodności i ciągłości działania aplikacji jest priorytetem. Stworzenie skutecznego mechanizmu healthcheck pozwala na szybką reakcję w razie wystąpienia jakiś problemów z jednym z kluczowych elementów systemu.

Jak już pisałem wcześniej, można to nawet spiąć z chmurą i spodziewać się maila albo nawet SMS gdy tylko coś niedobrego zacznie się dziać w Twojej aplikacji.

Przykładowa apka

Do tego artykułu stworzyłem przykładową aplikację, którą możesz pobrać z GitHuba. Po jej pobraniu, koniecznie uruchom migracje Entity Framework.

Niech nasza aplikacja zwraca różne dane pogodowe. Jeśli chodzi o prognozy, będą pobierane z zewnętrznego serwisu, a jeśli chodzi o dane archiwalne (prognoza z przeszłości), będą pobierane z naszej bazy danych.

Zewnętrzny serwis to oczywiście jakiś mock, który będzie udawał połączenie z zewnętrznym API.

Konfiguracja healthcheck

Podstawowa konfiguracja jest zabójczo prosta, bo sprowadza się tylko do rejestracji odpowiednich serwisów i dodania middleware. Czyli mamy coś takiego:

builder.Services.AddControllers();

//healthcheck
builder.Services.AddHealthChecks();

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseHttpsRedirection();

app.UseAuthorization();

app.MapHealthChecks("/_health");

app.MapControllers();

app.Run();

Jeśli chodzi o linię 4 – to dodajemy do dependency injection serwisy do sprawdzenia stanu zdrowia. Co się tyczy linii 13, to mapujemy te healthchecki do konkretnego endpointa. W parametrze przekazujemy, pod jakim adresem ma być ten healthcheck. W tej sytuacji to będzie https://localhost:pppp/_health, gdzie pppp to oczywiście numer portu na środowisku lokalnym.

Teraz jeśli uruchomimy aplikację i sprawdzimy ten healthcheck, dostaniemy zwrotkę ze StatusCode 200 OK i wartością:

Healthy

Przyznasz jednak, że takie sprawdzenie niewiele nam daje. No właśnie, domyślny mechanizm właściwie niczego nie sprawdza. Jeśli aplikacja chodzi, to zawsze zwróci Healthy. A my chcemy sprawdzić przynajmniej dwie rzeczy:

  • czy działa połączenie z bazą danych
  • czy działa połączenie z zewnętrznym serwisem

Możemy wszystko napisać ręcznie, ale jest lepsza metoda. Nudesy…eeee Nugetsy 😉 O tym za chwilę.

Jak działa mechanizm healthcheck?

Metoda AddHealthChecks zwraca nam interfejs IHealthChecksBuilder, dodając jednocześnie domyślny serwis do sprawdzenia HealthChecków, który wszystkim zarządza. I tak naprawdę możemy sobie stworzyć listę healthchecków, jakie chcemy mieć. To wszystko sprowadza się do dodania do tego buildera klasy, która implementuje odpowiedni interfejs (o tym też będzie za chwilę).

Potem ten domyślny serwis bierze sobie te wszystkie klasy, tworzy je i wywołuje po kolei metodę sprawdzającą. Ot, cała magia. Dzięki czemu możemy tworzyć sobie właściwie nieograniczone sprawdzenia stanu zdrowia apki.

Badanie standardowych serwisów

Jeśli wejdziesz sobie do managera nugetów i zaczniesz wpisywać AspNetCore.Healthchecks, oczom Twym ukaże się całkiem pokaźna lista z już oprogramowanymi sprawdzeniami do konkretnych serwisów:

To nie są w prawdzie oficjalne Microsoftowe paczki, jednak społeczność która za tym stoi, to (w momencie pisania artykułu) ponad 150 osób. Jeśli używasz jakiegoś standardowego serwisu, to jest duża szansa, że sprawdzenie healthcheka do niego już istnieje.

Sprawdzanie bazy danych

Oczywiście możemy sobie sprawdzić różne bazy danych, w tym MSSQL, Postgre, MySQL, Redis itd. – używając bibliotek z powyższej listy. Możemy też użyć oficjalnej paczki Microsoft.Extensions.Diagnostics.Healthcheck, która umożliwia testowanie całego kontekstu bazy danych (EFCore). A jak używać tych wszystkich bibliotek?

Metoda AddHealthChecks zwraca nam interfejs IHealthChecksBuilder i wszystkie rozszerzenia jakie mamy dostępne są rozszerzeniami właśnie tego interfejsu. A prostymi słowami:

//healthcheck
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(); //rozszerzenie z Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore

W taki sposób możemy dodać sprawdzenie, czy baza danych działa. Domyślnie, sprawdzane jest połączenie z bazą danych za pomocą metody dbContext.Database.CanConnectAsync(cancellationToken);

Jednak niech nie zwiedzie Cię ta pozorna prostota. Jeśli chodzi o bazę MSSQL, to ta metoda faktycznie próbuje połączyć się z bazą danych, a potem wysyła zapytanie SELECT 1.

Oczywiście można zrobić więcej – wystarczy dodać jakieś parametry, np.:

//healthcheck
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(customTestQuery: async (ctx, token) =>
    {
        await ctx.WeatherArchives.CountAsync();
        return true;
    });

W tym momencie domyślne sprawdzenie zostanie zamienione na nasze. Czyli podczas sprawdzenia stanu zdrowia bazy danych, baza zostanie odpytana o ilość rekordów w tabeli WeatherArchives – którą mamy zdefiniowaną w naszym AppDbContext. Parametr CustomTestQuery to po prostu funkcja, która przyjmuje w parametrze nasz kontekst bazy danych i CancellationToken, a zwraca jakiś bool.

I co najważniejsze – ten kod wystarczy. Nie trzeba tutaj stosować żadnych try..catch’y, ponieważ cała nasza funkcja i tak jest wywoływana w kontekście try..catch. Więc jeśli wystąpi jakiś exception, mechanizm healthcheck zwróci nam odpowiednią informację.

Niemniej jednak przy standardowych zastosowaniach, standardowy mechanizm sprawdzania bazy danych jest w zupełności wystarczający.

Sprawdzanie niestandardowe

Jednak nasz przykładowy serwis ForecastService, który ma imitować klienta jakiegoś zewnętrznego API, jest niestandardowym serwisem i nie znajdziemy biblioteki dla niego. Mechanizm HealthCheck pozwala jednak na napisanie własnego HealthChecka – dokładnie w taki sam sposób w jaki powstają te biblioteki wyżej pokazane.

Utworzenie klasy do sprawdzenia zdrowia

W pierwszej kolejności musimy utworzyć klasę, która implementuje interfejs IHealthCheck. Interfejs ma tylko jedną metodę, którą musimy napisać.

Teraz załóżmy, że nasz serwis, który udaje klienta API do pobierania prognozy pogody wygląda tak:

public class ForecastService(RandomHelper _randomHelper)
{
    public async Task<WeatherData> GetForecastFor(string city, DateOnly date)
    {
        await Task.Delay(500);
        return new WeatherData
        {
            City = city,
            Date = date,
            TemperatureC = _randomHelper.GetRandomTemperature()
        };
    }

    public async Task<bool> IsServiceHealthy()
    {
        await Task.Delay(500);
        return true;
    }

Czyli sprawdzenie stanu zdrowia tego serwisu będzie wymagało tylko wywołania metody IsServiceHealthy, którą nam daje nasz oszukany klient. A jak to zrobić? No oczywiście w klasie implementującej IHealthCheck:

public class ForecastServiceHealthCheck(ForecastService _forecastService) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, 
        CancellationToken cancellationToken = default)
    {
        var result = await _forecastService.IsServiceHealthy();
        if (result)
            return HealthCheckResult.Healthy();
        else
            return HealthCheckResult.Unhealthy();
    }
}

W metodzie CheckHealthAsync musimy teraz zwrócić HealthCheckResult – rezultat, który mówi, czy testowany podsystem jest zdrowy, czy nie. Domyślne stany Healthy i Unhealthy zazwyczaj wystarczą.

Oczywiście w klasie implementującej IHealthCheck możesz zrobić dowolny kod. Jeśli masz faktyczne zewnętrzne API, do którego się łączysz, możesz mieć tutaj po prostu HttpClienta, za pomocą którego wyślesz jakiś request.

I tak jak pisałem wcześniej – takich klas możesz sobie utworzyć tyle, ile potrzebujesz.

A jak ją zarejestrować? Też cholernie prosto:

//healthcheck
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>()
    .AddCheck<ForecastServiceHealthCheck>("Forecast service");

Czyli wywołujemy metodę AddCheck. W parametrze generycznym przekazujemy typ klasy, w której zaimplementowaliśmy sprawdzenie, a w parametrze Name przekazujemy jakąś nazwę dla tego sprawdzenia. W tym wypadku: "Forecast service", bo sprawdzamy działanie właśnie tego serwisu.

Pokazywanie większej ilości informacji

W tym momencie, jeśli strzelimy na końcówkę z healthcheckiem dostaniemy odpowiedź w formie czystego stringa – Healthy albo Unhealthy. Ale możemy to zmienić w dość łatwy sposób. Najprościej pobrać sobie Nugeta: AspNetCore.Healthchecks.UI.Client i podczas dodawania healthchecków do middleware dodać opcje:

app.MapHealthChecks("/_health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse //UIResponseWriter pochodzi z ww. Nugeta
});

I teraz dostaniemy dużo więcej informacji. Np. przy działającej aplikacji:

{
  "status": "Healthy",
  "totalDuration": "00:00:08.0064214",
  "entries": {
    "AppDbContext": {
      "data": {},
      "duration": "00:00:06.9029003",
      "status": "Healthy",
      "tags": []
    },
    "Forecast service": {
      "data": {},
      "duration": "00:00:00.5291383",
      "status": "Healthy",
      "tags": []
    }
  }
}

Zwróć uwagę, że otrzymujemy główny status apki i statusy poszczególnych serwisów, które sprawdzamy. AppDbContext to oczywiście sprawdzenie bazy danych. A Forecast service – to jest to, co sami pisaliśmy. Przy błędzie możemy uzyskać coś takiego:

{
  "status": "Unhealthy",
  "totalDuration": "00:00:00.5979899",
  "entries": {
    "AppDbContext": {
      "data": {},
      "duration": "00:00:00.0768822",
      "status": "Healthy",
      "tags": []
    },
    "Forecast service": {
      "data": {},
      "duration": "00:00:00.5204673",
      "status": "Unhealthy",
      "tags": []
    }
  }
}

Tutaj nie zadziałał serwis do prognoz.

Generalnie właściwość ResponseWriter przy mapowaniu tych healthchecków daje nam opcje takiego stworzenia odpowiedzi jaką chcemy. Jeśli ta domyślna z Nugeta daje za mało info albo trochę za dużo, sami możemy coś pokombinować, np.:

app.MapHealthChecks("/_health", new HealthCheckOptions
{
    ResponseWriter = async (httpContext, healthReport) =>
    {
        await httpContext.Response.WriteAsJsonAsync(healthReport);
    }
});

ResponseWriter to po prostu funkcja, która dostaje w parametrze HttpContext i HealthReport, a zwraca Task. Jej zadaniem jest wypisanie do responsa tego, co chcemy zobaczyć w odpowiedzi na ten endpoint.

Więc możemy sobie tutaj skonstruować odpowiedź jaka nam się tylko podoba. Możemy np. napisać sobie funkcje, która zwróci nam informacje o wersji albo co sobie tam wymyślimy.

Dodatkowe możliwości

Jeśli przyjrzysz się metodzie AddCheck z IHealthCheckBuilder, zobaczysz że ma ona dodatkowe parametry, które możesz przekazać. Wszystkie parametry trafią później do HealthCheckStatus – parametr w metodzie, w której tworzysz sprawdzenie – jak robiliśmy wyżej z ForecastServiceHealthCheck.

Dodatkowo możesz umieścić tam np. timeout. Mechanizm healthcheck mierzy czas wykonania każdego sprawdzenia. Jeśli przekażesz timeout i ten czas zostanie przekroczony, no to też dostaniesz odpowiednią informację.

Jeśli chodzi o listę tags, to możesz sobie wrzucić tam jakieś dodatkowe informacje, które są Ci potrzebne. O tym będzie jeszcze niżej.

Zabezpieczenie healthchecka – uwierzytelnianie

Zastanów się, czy każdy powinien mieć dostęp do Twojego healthchecka. Być może po drugiej stronie siedzi gdzieś ciemny typ, który próbuje hackować Twój system i zastanawia się jak po różnych krokach wygląda healthcheck. Jeśli dojdziesz do wniosku, że tylko niektóre osoby (maszyny) powinny mieć do tego dostęp, łatwo to ogarnąć.

W momencie, w którym mapujesz końcówkę healthchecka możesz dodać zabezpieczenia:

app.MapHealthChecks("/_health")
    .RequireHost("localhost");

Po takiej konfiguracji, będzie można dobić się do healthchecka tylko z domeny localhost. Próba dojścia z innej da po prostu zwrotkę 404 Not Found. Możesz też pokombinować mocniej. Np. wymusić konkretny port z dowolnego hosta:

app.MapHealthChecks("/_health")
    .RequireHost("*:5001");

Takich metod Require* mamy kilka, których możemy używać do różnych ograniczeń.

  • RequireCors – będzie wymagało odpowiedniej polityki CORS. O CORSach pisałem tutaj,
  • RequireAuthorization – będzie wymagało uwierzytelnionego użytkownika. Jak to zrobisz, to już jest Twoja sprawa. W tej metodzie możesz podać politykę autoryzacyjną, możesz też role, schematy. Jeśli nie podasz niczego, będzie użyty domyślny schematy uwierzytelniania,
  • RequireRateLimiting – da Ci rate limiting na tej końcówce 🙂 Po krótce, jest to mechanizm, który pozwala uderzyć w dane miejsce nie częściej niż ileś tam razy. Czyli np. możesz sobie ustawić raz na minutę.

Te ograniczenia możesz ze sobą również łączyć. Nic nie stoi na przeszkodzie, żeby mógł dobić się tylko uwierzytelniony użytkownik z konkretnego hosta i to nie częściej niż co jakiś czas:

app.MapHealthChecks("/_health")
    .RequireAuthorization()
    .RequireHost("localhost:5101")
    .RequireRateLimiting(...);

Filtrowanie healthchecków i kilka końcówek

Z jakiegoś powodu możesz chcieć uruchamiać tylko niektóre sprawdzenia. Domyślny mechanizm uruchamia wszystkie zarejestrowane HealthChecki. Ale możesz stworzyć filtrować te serwisy i pozwalać na uruchamianie tylko niektórych. Ponadto, możesz mieć więcej końcówek dla różnych sprawdzeń. Na przykład:

app.MapHealthChecks("/_health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.MapHealthChecks("/_health_db", new HealthCheckOptions
{
    Predicate = healthCheck => healthCheck.Tags.Contains("db")
});

Tutaj stworzyłem dwie końcówki:

  • _health – sprawdzi wszystkie zarejestrowane healthchecki i zwróci rezultat w JSON (tak jak pokazywałem wyżej)
  • _health_db – sprawdzi tylko te healthchecki, które zwróci Predicate – w tym wypadku te, które mają w swoich tagach słowo "db". I rezultat będzie zwrócony w standardowy sposób, czyli dostaniesz tylko informację Healthy lub Unhealthy (w tej końcówce nie posługujemy się ResponseWriterem).

A skąd ten filtr ma wiedzieć, że tag „db” oznacza bazę danych? Na szczęście to nie jest żadna magia i sam musisz zadbać o to, żeby do odpowiednich serwisów dodać odpowiednie tagi. Robisz to podczas ich rejestracji, np. tak:

//healthcheck
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(tags: new string[] { "db" })
    .AddCheck<ForecastServiceHealthCheck>("Forecast service");

Zwróć uwagę, jak w trzeciej linijce dodałem tagi do serwisu badającego bazę danych.

Dodanie endpointa do Swaggera

Skoro to czytasz, to zapewne zauważyłeś, że tak stworzony endpoint dla healthcheck nie jest dodawany do Swaggera. I przy obecnej technologii, gdzie możemy używać Postmana i requestów prosto z VisualStudio (plik *.http) nie widzę w tym większego sensu, ale się da. Wystarczy stworzyć i zarejestrować swój własny DocumentFilter.

IDocumentFilter to interfejs, który dostarcza informacji o dodatkowych operacjach. Standardowo Swagger szuka po prostu kontrolerów i akcji w nich i na tej podstawie (używając refleksji) tworzy swoją dokumentację. Oczywiście można mu dodać operacje, które nie są obsługiwane przez kontrolery. Wystarczy zaimplementować ten interfejs IDocumentFilter:

public class HealthCheckDocumentFilter : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        var healthCheckOp = CreateHealthcheckOperation("_health", true);
        var dbHealthCheckOp = CreateHealthcheckOperation("_health_db", false);

        var healthPathItem = new OpenApiPathItem();
        healthPathItem.AddOperation(OperationType.Get, healthCheckOp);

        var dbHealthCheckPathItem = new OpenApiPathItem();
        dbHealthCheckPathItem.AddOperation(OperationType.Get, dbHealthCheckOp);

        swaggerDoc.Paths.Add("/_health", healthPathItem);
        swaggerDoc.Paths.Add("/_health_db", dbHealthCheckPathItem);
    }

    private OpenApiOperation CreateHealthcheckOperation(string endpoint, bool returnsJson)
    {
        var result = new OpenApiOperation();
        result.OperationId = $"{endpoint}OperationId";

        var mediaType = returnsJson ? "application/json" : "text/plain";
        var objType = returnsJson ? "object" : "string";
        var schema = new OpenApiSchema
        {
            Type = objType
        };

        var response = new OpenApiResponse
        {
            Description = "Success"
        };

        response.Content.Add(mediaType, new OpenApiMediaType { Schema = schema });
        result.Responses.Add("200", response);

        return result;
    }
}

No i musimy go zarejestrować podczas rejestracji Swaggera:

builder.Services.AddSwaggerGen(o =>
{
    o.DocumentFilter<HealthCheckDocumentFilter>();
});

Nie będę omawiał tego kodu, bo nie ma nic wspólnego z healthcheckiem, tylko z dodawaniem operacji do Swaggera. Jest dość prosty i intuicyjny. Po prostu musimy dodać konkretne operacje (OpenApiOperation) do konkretnych endpointów (swaggerDoc.Paths.Add) i tyle. A każda operacja może składać się z różnych opisów, zwrotek itd. To wszystko co tutaj podasz, będzie potem widoczne w odpowiednich opisach na stronie Twojej dokumentacji.


To tyle na dzisiaj. Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu. Jeśli chciałbyś jakąś dodatkową wiedzę na temat healthchecków, to też daj znać. No i koniecznie podziel się tekstem z osobami, którym się przyda 🙂

Podziel się artykułem na:
Nowości w C#12

Nowości w C#12

Wstęp

Wraz z wersją ósmą .NET, o której pisałem w tym artykule, dostaliśmy też wersję 12 języka C#. Dzisiaj opiszę Ci wszystkie nowości i zmiany w tej wersji. Lecimy.

Główne konstruktory – Primary constructors

Do tej pory mogliśmy ich używać tylko w recordach. Od C#12 mamy je dostępne również w klasach i strukturach. Działają jednak troszkę inaczej. O co chodzi?

Spójrz na ten POPRAWNY w C#12 kod:

class Person(string name, int age)
{
    public string Name { get; } = name;
    public int Age { get; } = age;
}

Obok nazwy klasy znalazły się nawiasy z parametrami. To jest właśnie coś, co nazywamy primary constructor. Zwróć uwagę, że te parametry: name i age są dostępne w całym ciele klasy. Można je odczytywać, zmieniać i robić wszystko to, co robiłbyś z prywatnymi polami. No właśnie… prywatnymi. Nie możesz się do nich dobrać z zewnątrz, dlatego też powyżej widzisz utworzone właściwości, które zwracają ich wartości.

Tak samo to działa w strukturach. I to jest ta różnica między klasą/strukturą, a rekordem:

record MyRecord(int X, int Y)
{
    public bool IsNegative()
    {
        return X < 0 || Y < 0
    }
}

Jeśli używasz primary constructor w rekordach, kompilator automatycznie stworzy właściwości ( { get; init; } ) dla każdego takiego parametru. Dzięki czemu możesz się normalnie do nich odwoływać na rzecz obiektu.

Wróćmy jednak do naszej klasy:

class Person(string name, int age)
{
    public string Name { get; } = name;
    public int Age { get; } = age;
}

To, co tutaj widzisz to tak naprawdę cukier składniowy. Kompilator wygeneruje do tego kod, który będzie wyglądał mniej więcej tak:

class Person
{
    private string __unspeakable_name;
    private int __unspeakable_age;

    public string Name  => __unspeakable_name;

    public Person(string name, int age)
    {
        __unspeakable_name = name;
        __unspeakable_age = age;
    }
}

Primary constructor z innymi konstruktorami

Jeśli chciałbyś mieć dodatkowe konstruktory, to musisz w nich wywołać ten primary constructor. Robi się to za pomocą słówka this:

class Person(string name, int age)
{
    public string Name { get; } = name;

    public Person(string name, DateTime birthday)
        : this(name, DateTime.Now.Year - birthday.Year)
    {

    }
}

Dependency injection

Primary constructors wspierają również mechanizm dependency injection, co wydaje się całkiem interesującym rozwiązaniem. Prawdę mówiąc, to chyba jedyny powód, dla których chciałbym ich używać. No bo popatrz na standardowy kod:

class MyService
{
    private readonly MyOtherService _service;

    public MyService(MyOtherService service)
    {
        _service = service;
    }
}

Używając primary constructors możemy go skrócić do takiego zapisu:

class MyService(MyOtherService service)
{
}

Jest kompresja.

Operacje na parametrach

No dobra, a jak poradzić sobie z sytuacją, gdzie w konstruktorze musimy zrobić jakieś sprawdzenia, czy coś w ten deseń? Czyli po staremu:

class Person
{
    public Person(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Person must have a name");
    }
}

Po prostu możemy użyć statycznej metody:

class Person(string name)
{
    public string Name { get; } = ValidName(name) ? name : throw new ArgumentException("Person must have a name!");

    private static bool ValidName(string name) 
    {
        return !string.IsNullOrWhiteSpace(name);
    }
}

Podczas tworzenia takiego obiektu, zostanie wywołana metoda ValidName. No i jeśli podamy niepoprawną wartość, wywali się. Czyli kod poniżej zadziała tak, jak się tego spodziewamy:

var person = new Person(""); //wywali się

Niemniej jednak nie podoba mi się to. Chociaż to pewnie głównie kwestia gustu. Natomiast uważam, że to czyni kod brzydkim, brudnym, a im więcej takich rzeczy, tym ciężej będzie się go debugować.

Tworzenie kolekcji – Collection expressions

Bardzo miłe ułatwienie. Do tej pory, żeby stworzyć tablicę, trzeba było napisać:

int[] tab = new int[] { 1, 2, 3 };

Stary, fajny, klasyczny kod. Collection expressions, które dostaliśmy w C#12 daje nam dużo prostszą i szybszą metodę:

int[] tab = [1, 2, 3];

Możemy to samo zrobić z listami, spanami i właściwie wszystkimi kolekcjami wspierającymi inicjalizatory:

List<int> list = [1, 2, 3];
IEnumerable<int> e = [1, 2, 3];

A teraz spójrz jeszcze na zaznaczoną powyżej linię. Widzisz, jak stworzyłem IEnumerable?

Ale jak to? Przecież IEnumerable to interfejs!

Azaliż. I tak naprawdę powstał obiekt klasy ReadOnlyArray<int>.

W taki sposób można tworzyć też wszystkie rodzaje tablic – wielowymiarowe, poszarpane (jagged arrays) itd.

Jak dla mnie, bardzo fajne rozwiązanie. Bardzo ułatwi pracę i zdecydowanie będę z niego korzystał.

Inline arrays

UWAGA! To jest dość zaawansowany temat. Jeśli nie bawiłeś się pamięcią i nie używałeś bibliotek z innych języków (np. C++, Pascal), prawdopodobnie ten akapit niczego Ci nie da.

Inline array to odpowiednio utworzona struktura. Jest odpowiednikiem fixed buffer w bezpiecznym kodzie (safe code). Spójrz w jaki sposób możesz utworzyć taką strukturę:

[InlineArray(3)]
struct InlineBuffer
{
    public int element0;
}

Oznacza to, że tworzymy 3 elementową tablicę intów. Jedna rzecz jest istotna:

InlineBuffer buff = new InlineBuffer();

Teraz zmienna buff WSKAZUJE na pierwszy element tablicy – który jest jednocześnie polem element0. Czyli nie jest to referencja do tablicy, tylko sama tablica.

To jest przydatne, gdy walczymy bardzo o szybkość wykonywania kodu. Kompilator gwarantuje, że buff będzie po prostu ciągłą pamięcią o zadanej wielkości.

Ważne jest, że taka struktura nie może mieć żadnego layoutu i musi mieć jedno pole o typie, jakiego chcemy użyć w naszej tablicy.

Co istotne, to pole nie może być wskaźnikiem (w końcu to wskaźnik to niebezpieczny kontekst), ale może być każdym typem wartościowym i większością typów referencyjnych (w tym stringiem). A dobrać się możesz do konkretnych wartości tak samo jak w przypadku zwykłej tablicy – przez indekser. A także przez operator zakresu.

Domyślne parametry w wyrażeniach lambda

Nie ma się co rozpisywać. Po prostu w C#12 możemy używać domyślnych parametrów w wyrażeniach lambda. Wcześniej taki zapis w ogóle się nie kompilował. Obowiązują dokładnie te same reguły, co przy domyślnych parametrach metod:

var foo = (string s = "Siemma") => System.Console.WriteLine(s);

foo();  //wyświetli -> Siema
foo("Hej"); //wyświetli -> Hej

Modyfikator ref readonly

Do tej pory mieliśmy takie modyfikatory, którymi mogliśmy oznaczać parametry metody:

  • in
  • out
  • ref

W C#12 doszedł nowy: ref readonly.

W sumie, ref readonly robi dokładnie to samo, co in. Przyjrzałem się nawet kodowi generowanemu przez IL i te poniższe fragmenty C# tworzą dokładnie ten sam kod IL:

static void Main(string[] args)
{
    int val = 10;

    Show(val); //<-- pierwszy przypadek
    Show2(ref val); //<-- drugi przypadek
    Show2(in val); //<-- trzeci przypadek
}

public static void Show(in int value)
{
    System.Console.WriteLine(value);
}

public static void Show2(ref readonly int value)
{
    System.Console.WriteLine(value);
}

A kod IL też jest dość prosty, bo najpierw odkłada na stos ADRES zmiennej val, następnie wywołuje metodę, która ten adres ze stosu pobiera.

Jednak pewne, nieznaczne różnice są:

  • metoda przyjmująca parametr in nie potrzebuje, żeby kod wywołujący dawał o tym znać. Przy ref readonly bez jawnego określenia dostaniesz warning. Czyli:
//tu wszystko jest ok
Show(val);

public static void Show(in int value)
{
    System.Console.WriteLine(value);
}

//tutaj dostaniesz warning, bo powinieneś wywołać metodę z modyfikatorem ref lub in (nie ma żadnego faktycznego znaczenia dla kodu):

Show(val); //<-- dostaniesz warning
//Show(ref val); //<-- bez warninga
//Show(in val); <-- bez warninga

public static void Show(ref readonly int value)
{
    System.Console.WriteLine(value);
}

Zaznaczam – nie ma znaczenia, czy wywołasz metodę (ref readonly) z modyfikatorem ref, czy in. Kod w IL zostanie utworzony dokładnie ten sam.

  • jeśli przekażesz wartość (lub wyrażenie) do metody z modyfikatorem in, wszystko będzie ok; jeśli zrobisz to samo z metodą z modyfikatorem ref readonly – dostaniesz warning:
//tu wszystko jest ok
Show(10);

public static void Show(in int value)
{
    System.Console.WriteLine(value);
}

//tutaj dostaniesz warning

Show(10); //<-- dostaniesz warning
public static void Show(ref readonly int value)
{
    System.Console.WriteLine(value);
}

Jeśli jesteś ciekawy, co się wydarzy tutaj, to już mówię. Kompilator tak jakby stworzy za Ciebie zmienną, której przypisze wartość 10. Następnie adres tej zmiennej przekaże do metody.

Więc po co to ref readonly? Nie wiem. W specyfikacji czytam coś takiego:

W C# 7.2 wprowadzono parametry „in” jako sposób przekazywania referencji tylko do odczytu (w C++ nazywałoby się to stałą referencją – przyp. Adama). Parametry „in” dopuszczają zarówno lvalues jak i rvalues i można ich używać bez żadnej adnotacji podczas wywoływania.

Jednakże interfejsy API, które przechwytują lub zwracają referencje ze swoich parametrów chciałyby uniemożliwić rvalue a także wymusić pewne wskazanie w miejscu wywołania, że przechwytywana jest referencja.

Parametry ref readonly są idealne w takich przypadkach, ponieważ ostrzegają, jeśli zostaną użyte z rvalue lub bez żadnej adnotacji.

Także ma to na celu chyba tylko indykację, że metoda przyjmuje referencję.

Atrybut Experimental

C#12 daje nam nowy atrybut. System.Diagnostics.CodeAnalysis.ExperimentalAttribute. Możemy nim oznaczyć chyba wszystko. Od klasy, czy też enuma do pola, właściwości, czy zdarzenia.

Jeśli w kodzie użyjemy czegoś, co jest oznaczone atrybutem Experimental, kompilator wypluje ostrzeżenie, które zachowa się jak błąd. Trzeba jawnie oznaczyć to stłumić (suppress) to ostrzeżenie, żeby taki kod się zbudował.

Jeśli jednak wywołanie eksperymentalnego elementu będzie w innym eksperymentalnym elemencie, wtedy takie ostrzeżenie się nie pojawi. Czyli np.:

internal class MyClass
{
    public static void Run()
    {
        Foo();
    }

    public static void Foo()
    {

    }

    [Experimental("")]
    public static void Bar()
    {

    }
}

Powyższy kod skompiluje się bez problemu. Poniższy też

public static void Run()
{
    //Foo(); <-- wykomentowane wywołanie, czyli nie używamy metod eksperymentalnych
}

[Experimental("")]
public static void Foo()
{
    Bar();
}

[Experimental("")]
public static void Bar()
{

}

Ale poniższy da już błąd:

public static void Run()
{
    Foo(); //<-- użycie eksperymentalnej metody
}

[Experimental("")]
public static void Foo()
{
    Bar();
}

Błąd mówi:
Error CS9204 'Foo()' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

Więc, jeśli chcemy jednak używać metody eksperymentalnej, musimy to ostrzeżenie będące błędem wyłączyć:

        public static void Run()
        {
#pragma warning disable CS9204 
            Foo();
#pragma warning restore CS9204 
        }

        [Experimental("")]
        public static void Foo()
        {
            Bar();
        }

Można też zmniejszyć poziom tego ostrzeżenia – dokładnie tak samo jak innych. Możemy też to zrobić oczywiście globalnie, ale tego bardzo nie polecam.

Po co ten atrybut?

Raczej dla twórców bibliotek. Jeśli wprowadzają jakieś działanie, które w przyszłości może się mocno zmienić lub w ogóle zostać wywalone, wtedy oznaczenie tego jako Experimental jest dobrym pomysłem. A czy używanie takiego eksperymentalnego kodu jest dobrym pomysłem? Na produkcji raczej nie. W swoich wewnętrznych testach można się pobawić.

Wyłączanie ostrzeżeń o konkretnych funkcjonalnościach

Atrybut Experimental ma ciekawą właściwość. Można mu przekazać coś w rodzaju Id danej funkcjonalności (DiagnosticId), a potem tym Id posługiwać się podczas wyłączania ostrzeżenia. Np.:

        public static void Run()
        {
#pragma warning disable DoingFoo
            Foo();
#pragma warning restore DoingFoo
        }

        [Experimental("DoingFoo")]
        public static void Foo()
        {
            Bar();
        }

Co więcej, możesz przekazać więcej informacji w komunikacie błędu – a konkretnie adres strony, na której jest opisana ta funkcjonalność lub powód dlaczego klienci nie powinni tego używać:

[Experimental("DoingFoo", UrlFormat = "https://example.com/{0}")]
public static void Foo()
{
    Bar();
}

Wtedy błąd będzie wyglądał tak:

Error DoingFoo 'Foo()' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. (https://example.com/DoingFoo)

Interceptory

W chwili pisania tego artykułu (styczeń 2024) interceptory są eksperymentalne, więc póki co, nie opisuję ich. Ale będę trzymał rękę na pulsie i jeśli wejdą do użycia, na pewno o nich napiszę. Żeby tego nie przegapić, koniecznie zapisz się na newsletter 🙂


Dzięki za przeczytanie tego artykułu. Widać coraz bardziej, że C# idzie w stronę minimalizacji pisanego kodu, co jest z jednej strony fajnym rozwiązaniem. Chociaż może być cięższe do zrozumienia dla początkujących programistów. Niemniej jednak uważam, że to dobry krok naprzód. I choć w tej wersji może nie było jakiś super wielkich zmian, to jednak miło że MS słucha community.

Jeśli czegoś nie rozumiesz lub zauważyłeś w tekście jakiś błąd, daj znać w komentarzu 🙂

Podziel się artykułem na:
Co nowego w .NET8?

Co nowego w .NET8?

Wstęp

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

Bez zbędnych ceregieli – lecimy z koksem.

Spis treści

Wsparcie dla .NET8

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:

builder.Services.AddControllers()
    .AddJsonOptions(o =>
    {
        o.JsonSerializerOptions.PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate;
    });

JsonSerializationContext

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

JsonSerializerOptions.TypeInfoResolverChain.Add(MyContext.Default)

ponieważ TypeInfoResolverChain to zwykła lista.

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:

<PropertyGroup>
  <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>

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

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:

ITimer timer = TimeProvider.System.CreateTimer(obj.TimerCallback, state, TimeSpan.Zero, TimeSpan.FromSeconds(1));

Timer ma kilka parametrów:

  • 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.

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:

char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()".ToCharArray();
char[] selectedChars = Random.Shared.GetItems(chars, 10);

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:

MethodElementsCountReadsMeanRatioRatioSD
ReadDictionary1000111.173 ns1.000.00
ReadFrozen100017.047 ns0.640.11
ReadDictionary10001097.220 ns1.000.00
ReadFrozen10001061.455 ns0.640.08
ReadDictionary10001001,000.912 ns1.000.00
ReadFrozen1000100566.589 ns0.570.07
ReadDictionary10000110.008 ns1.000.00
ReadFrozen1000016.577 ns0.700.08
ReadDictionary100001093.015 ns1.000.00
ReadFrozen100001057.464 ns0.620.05
ReadDictionary10000100963.958 ns1.000.00
ReadFrozen10000100654.683 ns0.690.11
ReadDictionary100000110.472 ns1.000.00
ReadFrozen10000016.879 ns0.670.12
ReadDictionary1000001098.492 ns1.000.00
ReadFrozen1000001062.093 ns0.640.09
ReadDictionary1000001001,075.513 ns1.000.00
ReadFrozen100000100614.143 ns0.580.09
ReadDictionary1000000110.387 ns1.000.00
ReadFrozen100000016.834 ns0.660.08
ReadDictionary10000001089.935 ns1.000.00
ReadFrozen10000001061.172 ns0.690.06
ReadDictionary10000001001,013.573 ns1.000.00
ReadFrozen1000000100620.679 ns0.620.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:

MethodValueMeanRatioRatioSD
UsingSearchValues@oiud(…)BNFMA [32]8.984 ns1.000.00
UsingContains@oiud(…)BNFMA [32]7.922 ns0.890.14
UsingSpan@oiud(…)BNFMA [32]116.279 ns13.131.58
UsingSearchValuesqoiud(…)BNFM@ [32]9.134 ns1.000.00
UsingContainsqoiud(…)BNFM@ [32]90.065 ns10.121.76
UsingSpanqoiud(…)BNFM@ [32]119.263 ns13.392.25
UsingSearchValuesqoiud(…)BNFMA [32]6.140 ns1.000.00
UsingContainsqoiud(…)BNFMA [32]203.261 ns33.544.97
UsingSpanqoiud(…)BNFMA [32]114.471 ns18.972.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:

MethodMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
FormatTest494.9 ns10.67 ns28.84 ns1.000.000.0801128 B1.00
CompositeFormatTest468.4 ns10.04 ns27.66 ns0.950.070.0658104 B0.81
InterpolatedTest458.3 ns13.24 ns37.78 ns0.930.090.0658104 B0.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ę:

builder.Services.AddKeyedScoped<ISomeService, UserSomeService>("user");
builder.Services.AddKeyedScoped<ISomeService, AdminSomeService>("admin");

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.

Ten interfejs wygląda tak:

public interface IHostedLifecycleService : IHostedService
{
    Task StartingAsync(CancellationToken cancellationToken);
    Task StartedAsync(CancellationToken cancellationToken);
    Task StoppingAsync(CancellationToken cancellationToken);
    Task StoppedAsync(CancellationToken cancellationToken);
}

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

builder.Services.AddSingleton<IValidateOptions<MyAppConfig>, MyAppConfigValidator>();

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:

<PropertyGroup>
    <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>

Nowości w… WPF!

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

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:

app.MapPost("/", (IFormFile formFile) => ...)
  .DisableAntiforgery();

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.

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.

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 🙂

Więcej tutaj: https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-8.0/breaking-changes

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

Więcej tutaj: https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-8.0?view=aspnetcore-8.0


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.

Podziel się artykułem na: