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. 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:
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:
Asynchroniczne REST Api – jak i po co?

Asynchroniczne REST Api – jak i po co?

W tym artykule wyjaśnię Ci, czym jest asynchroniczne REST API, czemu i kiedy takie tworzyć. Poza tym powiem Ci jak sobie poradzić z synchronicznym REST API, żeby bez dostępu do kodu uzyskać wersję asynchroniczną. Takie czary 🙂

W międzyczasie ubrudzimy sobie trochę ręce. Przykładowe kody umieściłem na GitHubie. No to jedziemy.

Jak działa REST API?

REST Api możemy podzielić na dwie wersje – synchroniczne i asynchroniczne. Jeśli chodzi o synchroniczne, to sprawa jest dość prosta. Wysyłasz żądanie i czekasz na odpowiedź:

public async Task GenerateReport(string jsonData)
{
    var result = await _httpClient.PostAsJsonAsync("orders/report", jsonData);
}

Kod jest prosty i całe flow też. Zobaczmy jak to wygląda w żądaniach HTTP:

  • klient wysyła żądanie do serwera o wygenerowanie jakiegoś raportu, np.:
POST https://example.com/orders/report
{
  "startDate": "2023-01-01",
  "endDate": "2023-01-31"
}
  • serwer odpowiada kodem 400, jeśli przekazane dane są błędne lub 200 OK:
HTTP/1.1 200 OK
Content-Type: application/json
{
 "data": <data>
}

Tutaj oczywiście dane raportu mogą pojawić się w BODY lub też dostaniesz adres do pobrania pliku z raportem – w zależności od API.

Gorzej, gdy operacja na serwerze trwa długo. Kilkadziesiąt sekund lub kilka minut. Wtedy taka robota może zakończyć się kilkoma problemami:

  • możesz otrzymać time-out (brak odpowiedzi z serwera)
  • tak, czy inaczej używasz wątku, który czeka (na odpowiedź). Może to powodować problemy w wydajności aplikacji, szybsze jej skalowanie w górę i wzrost kosztów związany z chwilowym zwiększonym zapotrzebowaniem na zasoby (zwłaszcza jeśli ruch jest duży).
  • podatność na atak DDoS

Asynchroniczne REST Api

Jeśli operacja może trwać nieco dłużej niż kilka sekund, lepiej rozważyć jest zrobienie asynchronicznego API. Możesz je zaprojektować na kilka sposobów. Klient może odpytywać serwer co jakiś czas o status operacji lub klient może przekazać webhooka, na który serwer da znać, gdy operacja się zakończy. Prześledźmy obie możliwości:

Odpytywanie serwera

  • klient wysyła żądanie do serwera, np.:
POST https://example.com/orders/report
{
  "startDate": "2023-01-01",
  "endDate": "2023-01-31"
}
  • serwer odpowiada kodem 202 Accepted, dodając nagłówek Location, który wskazuje na endpoint, którym możesz odpytywać o status operacji
HTTP/1.1 202 Accepted
Location: orders/report/status/<id>
Retry-After: 60
  • serwer rozpoczyna operację (lub częściej – przekazuje ją dalej do wykonania)
  • co jakiś czas (Retry-After) pytasz o stan operacji, wysyłając żądanie na końcówkę otrzymaną w kroku 2
GET https://example.com/orders/report/status/<id>
  • możesz dostać odpowiedź 200 OK, wraz z opisem statusu lub 303 See Other ze wskazaniem miejsca, z którego pobierasz rezultat. Przy czym kod 303 oznacza, że operacja się zakończyła.

Przykładowa odpowiedź na operację, która jest w toku:

HTTP/1.1 200 OK
Content-Type: application/json
Retry-After: 60
{
 "status" : "InProgress"
}

Przykładowa odpowiedź na zakończoną operację:

HTTP/1.1 303 See Other
Location: orders/report/<id>
  • wysyłasz żądanie po rezultat na końcówkę z nagłówka Location
GET https://example.com/orders/report/<id>

W tym momencie żaden wątek klienta nie był zblokowany i nie czekał aż operacja się wykona. Co więcej, jeśli serwer przekazał operację do wykonania dalej, żaden wątek serwera też nie został zblokowany. Po prostu klient zlecił jakieś zadanie i co jakiś czas odpytywał, czy jest już zrobione (jak to bywa w życiu ;)).

Oczywiście serwer może odpowiedzieć na różne sposoby. W pewnym momencie może się coś wywalić i wtedy pytając o status klient powinien otrzymać informację o błędzie.

Jeśli klient przesyła niepoprawne dane w pierwszym żądaniu, serwer powinien odpowiedzieć kodem 400 Bad Request zamiast 202 Accepted – jak w przypadku synchronicznej wersji.

A niech to serwer… odpowie

Czasem nie chcesz, żeby klient pytał co jakiś czas o stan zadania i wychodzisz z założenia: „Panie, będzie to będzie”. Tak też można. Tutaj sprawa jest nieco prostsza.

  • klient wysyła żądanie wraz z adresem, na który serwer ma dać odpowiedź
POST https://example.com/orders/report
{
  "startDate": "2023-01-01",
  "endDate": "2023-01-31",
  "callbackUrl": "https://application.com/callback"
}
  • serwer odpowiada 202 Accepted (lub 400, jeśli dane w żądaniu są nieprawidłowe). Zauważ, że nie podaje tutaj już końcówki do sprawdzania stanu – nagłówka Location. Po prostu – „będzie zrobione, jak się zrobi”
HTTP/1.1 202 Accepted
  • no i jak już się zrobiło, to tym razem SERWER wysyła żądanie do klienta na wcześniej przekazany callback
POST https://application.com/callback
{
  "status" : "Completed",
  "links" : [{
    "rel" : "reports",
    "href" : "orders/reports/<id>"
  }]
}

Na koniec klient powinien zapytać się o konkretny raport, strzelając na podany endpoint. Oczywiście w zależności od API, serwer też może już w callbacku wysłać wynikowe dane.

Jak to wygląda w praktyce

Zazwyczaj, żeby móc korzystać z czyjegoś API, musisz zarejestrować swojego klienta (swoją aplikację, która będzie to API wykorzystywać). Często podczas rejestracji można podać od razu adres callback, na który serwer ma dawać znać o zakończonym zadaniu lub po prostu wysyłać do Ciebie różne komunikaty – to już zależy od konkretnego API.

Jednak często jest też możliwe wysłanie adresu callbacka w żądaniu, jak to było zrobione w tym przykładzie.

API, po skończonym zadaniu, może od razu wysłać Ci rezultat zamiast odpowiedzi o zakończonym statusie (tak jak w powyższym przykładzie).

Piszemy asynchroniczny serwer

Teraz napiszemy sobie przykładowy asynchroniczny serwer. Zauważ kilka rzeczy:

  • to jest przykład – dość prosty, acz użyteczny
  • nie ma tutaj mechanizmu autoryzacji, który powinien być w prawdziwym rozwiązaniu
  • nie ma tutaj wykonywania prawdziwej operacji, w rzeczywistym przypadku to może być robione na różne sposoby
  • nie ma tutaj żadnej abstrakcji, piszemy najprościej jak się da, jednak staram się stosować zasady czystego kodu

UWAGA! Ta wersja kodu jest wersją prostą. Bez użycia Azure (albo innej chmury). Weź pod uwagę, że to nie jest do końca asynchroniczne rozwiązanie, jednak jeśli nie znasz Azure, to ta wersja dużo bardziej ułatwi Ci zrozumienie o co w tym chodzi. Wersja wykorzystująca Azure jest opisana niżej.

Na początek stwórzmy sobie standardowe WebApi z kontrolerem do pogody, który za chwilę zmienimy. Do tego stwórzmy serwis WeatherForecastService, który na początek będzie pusty:

public class WeatherForecastService
{
    public Task GetForecast(Guid requestId, DateOnly date)
    {
        return Task.CompletedTask;
    }
}

Serwis zarejestrujemy jako Scoped:

builder.Services.AddScoped<WeatherForecastService>();

I wstrzykniemy go do naszego lekko zmienionego kontrolera:

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private WeatherForecastService _service;

    public WeatherForecastController(WeatherForecastService service)
    {
        _service = service;
    }

    [HttpGet(Name = "GetWeatherForecast/{date}")]
    public async Task<IActionResult> Get(DateOnly date)
    {
        Guid requestId = Guid.NewGuid();
        _ = _service.GetForecast(requestId, date);

        return await Task.FromResult(Accepted($"WeatherForecast/Status/{requestId}"));
    }
}

Do tej pory wszystko powinno być jasne. Użytkownik pyta o pogodę na dany dzień. Kontroler wywołuje metodę w serwisie. Dodatkowo kontroler tworzy ID dla tego żądania, które zwraca użytkownikowi. Tutaj ważne jest, że nie czekamy na to aż wykona się Task GetForecast. Nie ma tutaj wywołania z await. To znaczy, że kontroler może zakończyć swoją pracę od razu, a Task będzie działał w tle.

Na koniec kontroler zwraca odpowiednią odpowiedź – 202 Accepted. Zwróć uwagę, że kontroler nie czeka na wykonanie operacji przez serwis. Co to oznacza? W tym momencie nigdy nie dowiemy się, czy operacja się wykonała i jaki jest jej wynik. Dlatego też ten wynik trzeba gdzieś zapisać…

Baza danych

Tak, takie operacje zapisuje się w bazie danych. Stwórzmy więc prosty model bazodanowy i banalną bazę danych – opakowany słownik (weź pod uwagę, że w rzeczywistości powinna być to prawilna baza, ten kod poniżej jest tylko ze względu na prostotę):

public enum OperationStatus
{
    NotStarted,
    InProgress,
    Finished
}
public class WeatherDatabaseItem
{
    public Guid RequestId { get; set; }
    public OperationStatus Status { get; set; }
    public WeatherForecast Data { get; set; }
}

A teraz nasza baza danych:

public class Database
{
    private Dictionary<Guid, WeatherDatabaseItem> _weatherForecasts = new();

    public void UpsertForecast(Guid id, OperationStatus status, WeatherForecast forecast)
    {
        WeatherDatabaseItem item = GetOrCreate(id);
        item.Status = status;
        item.Data = forecast;

        _weatherForecasts[id] = item;
    }

    private WeatherDatabaseItem GetOrCreate(Guid id)
    {
        WeatherDatabaseItem result = null;

        if (!_weatherForecasts.TryGetValue(id, out result))
            return new WeatherDatabaseItem { RequestId = id };

        return result;
    }
}

Żeby taka „baza” miał sens, musimy klasę Database zarejestrować jako singleton:

builder.Services.AddSingleton<Database>();

Serwis

OK, mając bazę danych i kontroler, możemy teraz zrobić jakąś prawdziwą robotę – sprawdzić/wyliczyć prognozę pogody w serwisie. Wstrzyknijmy mu bazę danych i dajmy nieco ciałka:

public class WeatherForecastService
{
    private readonly Database _database;

    public WeatherForecastService(Database database)
    {
        _database = database;
    }

    public async Task GetForecast(Guid requestId, DateOnly date)
    {
        _database.UpsertForecast(requestId, OperationStatus.InProgress, null);

        await Task.Delay(30000); //symulacja długiej operacji

        var result = new WeatherForecast
        {
            Date = date,
            Summary = "Sunny",
            TemperatureC = 25
        };

        _database.UpsertForecast(requestId, OperationStatus.Finished, result);
    }
}

Zwróć uwagę na dwie rzeczy. Po pierwsze na początku (linia 12) dodajemy pusty rekord do bazy z uzyskanym ID i statusem ustawionym na InProgress. To znaczy, że obliczenia są w trakcie.

Następnie mamy jakiś Delay, który ma tylko symulować długotrwałą operację.

Na koniec dodajemy do bazy gotową prognozę pogody. W międzyczasie klienci mogą pytać się o status operacji i dostaną odpowiedź InProgress.

Zatem musimy jeszcze zrobić dwa endpointy w kontrolerze:

  • sprawdzanie statusu operacji
  • pobieranie wyników

Sprawdzanie statusu operacji

Najpierw w bazie danych dodajmy metodę do pobierania odpowiedniego rekordu:

public class Database
{
    private Dictionary<Guid, DatabaseItem> _weatherForecasts = new();

    public void UpsertForecast(Guid id, OperationStatus status, WeatherForecast forecast)
    {
        DatabaseItem item = GetOrCreate(id);
        item.Status = status;
        item.Data = forecast;

        _weatherForecasts[id] = item;
    }

    public DatabaseItem GetById(Guid id)
    {
        DatabaseItem result = null;
        _weatherForecasts.TryGetValue(id, out result);
        return result;
    }

    private DatabaseItem GetOrCreate(Guid id)
    {
        DatabaseItem result = null;

        if (!_weatherForecasts.TryGetValue(id, out result))
            return new DatabaseItem { RequestId = id };

        return result;
    }
}

Teraz w serwisie powinna znaleźć się metoda do pobierania statusu:

public class WeatherForecastService
{
    private readonly Database _database;

    public WeatherForecastService(Database database)
    {
        _database = database;
    }

    public async Task GetForecast(Guid requestId, DateOnly date)
    {
        _database.UpsertForecast(requestId, OperationStatus.InProgress, null);

        await Task.Delay(30000);

        var result = new WeatherForecast
        {
            Date = date,
            Summary = "Sunny",
            TemperatureC = 25
        };

        _database.UpsertForecast(requestId, OperationStatus.Finished, result);
        return Task.CompletedTask;
    }

    public async Task<OperationStatus> GetRequestStatus(Guid requestId)
    {
        var status = await Task.Run(() =>
        {
            var item = _database.GetById(requestId);
            if (item == null)
                return OperationStatus.NotStarted;
            else
                return item.Status;
        });

        return status;
    }
}

Możesz się czepić o to, że metoda GetRequestStatus jest oznaczona jako asynchroniczna, bo w tym przypadku ta asynchroniczność niczego nie daje. Zrobiłem tak tylko po to, żeby utrzymać konwencję pobierania danych z bazy danych jako operację asynchroniczną.

No i na koniec endpoint w kontrolerze:

[HttpGet("Status/{requestId}")]
public async Task<IActionResult> GetStatus(Guid requestId)
{
    var status = await _service.GetRequestStatus(requestId);
    if(status == Models.OperationStatus.Finished)
    {
        Response.Headers.Add("Location", $"/WeatherForecast/{requestId}");
        return StatusCode(StatusCodes.Status303SeeOther);
    } else
    {
        var data = new
        {
            status = status
        };
        return Ok(data);
    }
}

Najpierw pobieramy status z serwisu i sprawdzamy go. Jeśli operacja jest zakończona, to odsyłamy klientowi odpowiedź 303 See Other wraz z linkiem do pobrania danych.

Jeśli jednak operacja jest w trakcie, to odsyłamy klientowi rezultat Ok z odpowiednimi danymi.

UWAGA!

Standardowi klienci (przeglądarki internetowe, Postman, a nawet klasa HttpClient) mają wbudowany mechanizm „follow redirects„, co oznacza że po odebraniu odpowiedzi 301, 302 lub 303 automatycznie przeniosą Cię na adres, który będzie w nagłówku Location.

Z jednej strony to fajnie, bo masz załatwioną część pracy. Z drugiej strony jeśli chcesz to przetestować krok po kroku np. Postmanem, to musisz mu wyłączyć opcję „Follow redirect” albo globalnie, albo na poziomie konkretnego requestu:

Niestety nie da się tego wyłączyć w Swaggerze.

Minusy takiego serwera

Oczywiście takie rozwiązanie (opisany serwer) ma swoje minusy, które w zależności od sytuacji można albo zaakceptować, albo nie.

Przede wszystkim tutaj serwer, mimo wszystko, odpowiada za przeprowadzenie długotrwałej operacji. Fakt, żadne wątki nie są blokowane, komunikacja z klientami jest szybka, natomiast jeśli uruchomimy kilka takich zadań, bo dostaniemy żądania od kilku klientów, wtedy nie skorzystamy za bardzo z asynchroniczności. Może się okazać, że aplikacja szybko będzie potrzebowała nowych zasobów i albo je dostanie (wzrost opłat za hosting), albo przestanie odpowiadać i się zblokuje. Daje to też możliwą podatność na atak DDoS.

Oczywiście, jeśli z apki korzysta kilkadziesiąt klientów raz na jakiś czas, to raczej nie ma to znaczenia. Ale już przy kilkuset czy kilku tysiącach, to jest nie do pomyślenia.

Jak zatem sobie z tym poradzić w rzeczywistości?

Odpowiedzią jest chmura.

Serwer asynchroniczny z użyciem chmurki

Pokażę Ci to na przykładzie Microsoft Azure. Jeśli nie wiesz, czym jest Azure, to ten akapit i następne nie dadzą Ci za wiele i na tym możesz skończyć czytanie. Jeśli jednak coś tam wiesz albo jesteś po prostu ciekawy, to czytaj dalej 🙂 Najpierw, z poczucia obowiązku, opiszę Ci bardzo ogólnie trzy usługi, z których będziemy korzystać.

Zakładam że posiadasz subskrypcję Azure’ową i wiesz jak rejestrować podstawowe usługi.

Poniższy przykład nie ma nic wspólnego z bezpieczeństwem. Ze względu na prostotę, wszystkie klucze i hasła będą przekazywane aplikacji w jawny sposób. Nie używamy tutaj KeyVaulta, żeby nie zaciemniać obrazu.

Usługi

Zauważ, że metod na rozwiązanie tego problemu jest zapewne kilka. Ja przedstawię Ci tylko jedną z nich. Oto usługi, z jakich będziemy korzystać:

  • Storage Queue
  • Azure Functions
  • CosmosDb

Oględny opis usług

Storage Queue

StorageQueue to usługa, która daje Ci kolejkę. Możesz kłaść do niej wiadomości, odczytywać je, a także zdejmować je z kolejki.

Azure Functions

Są to funkcje, które mogą być „serverless„, tzn. nie potrzebujesz do ich utrzymywania żadnego serwera. Ma to swoje plusy i minusy. Plusem zdecydowanie są (bardzo) małe koszty.

Potraktuj AzureFunction jak zwykłą funkcję lub metodę. Możesz ją napisać na kilka sposobów i w różnych językach. My się skupimy tutaj na kompilowanej wersji C#.

Dodatkowo funkcje Azurowe mają tzw. triggery – czyli coś, co je uruchamia. Jest wiele wbudowanych triggerów i one właściwie wystarczają. Jednym z nich jest np. wywołanie HTTP, innym – którego będziemy używać – dodanie nowej wiadomości do kolejki Storage Queue.

CosmosDB

CosmosDb to baza danych typu NoSQL. Na Azure znajduje się jej wersja „serverless”, dzięki czemu w prostych zastosowaniach koszty takiej bazy są naprawdę mikroskopijne.

W tej bazie będziemy trzymać dane dotyczące naszych prognoz.

Jeśli nie chcesz tworzyć usług ręcznie, w przykładowym kodzie są pliki BICEP, które utworzą infrastrukturę (o tym jak to zrobić piszę niżej).

Jak utworzyć usługi automatem?

W przykładowych kodach znajdują się pliki BICEP z opisaną strukturą usług. Teraz musisz się upewnić, że masz zainstalowane narzędzie az bicep:

az bicep version

lub je zainstalować:

az bicep install

Następnie za pomocą az musisz zalogować się do swojej subskrypcji na Azure i stworzyć grupę zasobów (dajmy na to: rg-rest-api).

Teraz, mając odpowiednią grupę, możesz uruchomić tworzenie usług. Przejdź w konsoli do katalogu deployment w przykładowych kodach, a następnie:

az deployment group create --resource-group "rg-rest-api" --template-file .\main.bicep

Po chwili wszystkie wymagane usługi będą utworzone w twojej resource grupie.

Tworzymy bazę danych

Na początek utwórzmy bazę danych CosmosDb (For NoSQL).

Tak naprawdę potrzebujemy tylko jednego kontenera. Nazwijmy go operations, a partitionKey ustawmy na requestId. W tym kontenerze będziemy trzymać wszystkie informacje.

Tworzymy kolejkę

Teraz stwórzmy kolejkę (Blob Storage Queue). Nowe wiadomości wpadające do tej kolejki będą odpalać Azurową funkcję, która będzie robiła całą robotę. W tym celu musimy utworzyć StorageAccount.

Jak widzisz, ja utworzyłem storage account o nazwie masterbranchweatherst, a w środku kolejkę o nazwie weather-requests-queue.

Super, została teraz już tylko logika do zrobienia. A to wymaga utworzenia funkcji Azurowej.

Tworzymy funkcję Azurową

Funkcja będzie uruchamiana wcześniej utworzoną kolejką. To znaczy, że jeśli w kolejce znajdzie się jakaś wiadomość, to to zdarzenie uruchomi funkcję i przekaże do niej konkretną wiadomość.

Oczywiście, jeśli w kolejce będzie 100 wiadomości, to jest szansa, że uruchomi się 100 funkcji równolegle. To jednak zależy od kilku czynników, którymi nie będziemy się zajmować w tym artykule. To co jest ważne, to to, że jeśli funkcje Azurowe nie będą w stanie obrobić wszystkich wiadomości od razu, te wiadomości będą po prostu czekać na swoją… kolej. Jak to w kolejce 🙂 Dzięki temu system wciąż będzie wydolny i nie zobaczymy żadnego przeciążenia. Po prostu niektóre wyniki będą nieco później dostępne.

Taka funkcja w najprostszej postaci może wyglądać jak w przykładzie:

public class AnalyzeWeather
{
    private readonly ILogger<AnalyzeWeather> _logger;
    private readonly WeatherRepository _weatherRepository;
    private readonly Randomizer _randomizer;

    public AnalyzeWeather(ILogger<AnalyzeWeather> logger, 
        WeatherRepository weatherRepository, 
        Randomizer randomizer)
    {
        _logger = logger;
        _weatherRepository = weatherRepository;
        _randomizer = randomizer;
    }

    [Function(nameof(AnalyzeWeather))]
    public async Task Run([QueueTrigger("weather-requests-queue", Connection = "QueueConnectionString")] QueueMessage message)
    {
        _logger.LogInformation("Weather analyzing started");

        WeatherQueueItem msgItem = message.Body.ToObjectFromJson<WeatherQueueItem>();

        WeatherDatabaseItem dbItem = new WeatherDatabaseItem();
        dbItem.Status = OperationStatus.InProgress;
        dbItem.RequestId = msgItem.Data.RequestId;
        dbItem.Data = msgItem.Data.Data;

        await _weatherRepository.UpsertWeatherOperation(dbItem);

        //symulacja
        await Task.Delay(30000);

        dbItem.Data.Summary = "Warm";
        dbItem.Data.TemperatureC = _randomizer.GetInt(20, 29);
        dbItem.Status = OperationStatus.Finished;

        await _weatherRepository.UpsertWeatherOperation(dbItem);            
    }
}

Nie omawiam tutaj, jak działają funkcje Azurowe i czym są, zakładam że wiesz to. Ale jeśli chciałbyś przeczytać o tym artykuł daj znać w komentarzu.

Tutaj sprawa jest prosta. Do funkcji trafia wiadomość z kolejki (parametr message). Wiadomość ma właściwość Body, w której znajdzie się json, którego wcześniej wysyłamy (o tym za chwilę).

To, co robimy w linii 22, to deserializujemy tego JSONa do konkretnego obiektu. Następnie (w linii 29) zapisujemy stan naszej operacji w bazie danych – zauważ, że ze statusem InProgress.

Potem symulujemy jakąś długą analizę, na koniec aktualizujemy nasz rekord w bazie danych częściowo losowymi danymi (to tak dla picu, żeby się działo :))

Wszystkie kody zobaczysz w przykładzie, nie jest celem tego artykułu opisywanie ich, bo byłby straaasznie długi. A to zwykła obsługa funkcji azurowej i CosmosDb.

OK, skoro już mamy funkcję azurową uruchamianą przez kolejkę, to teraz trzeba coś do tej kolejki dodać. I tu jest właśnie przeniesienie pracy i rozdzielenie naszej aplikacji na mniejsze części.

Dodajemy wiadomość do kolejki.

Tutaj sprawa jest prosta. To ma działać tak:

  • klient wysyła żądanie z pytaniem o prognozę pogody
  • jego żądanie jest wrzucane do kolejki
  • zwracamy mu odpowiedź 202 Accepted wraz z linkiem do pobierania informacji o statusie jego żądania

Najpierw pokażę Ci klasę, którą napisałem do wrzucenia żądania do kolejki:

public class StorageQueueService
{
    private readonly QueueOptions _queueOptions;

    public StorageQueueService(IOptions<QueueOptions> queueOptions)
    {
        _queueOptions = queueOptions.Value;
    }
    public async Task SendWeatherRequest(WeatherDatabaseItem item)
    {
        QueueClientOptions clientOptions = new QueueClientOptions
        {
            MessageEncoding = QueueMessageEncoding.Base64
        };

        var client = new QueueClient(_queueOptions.ConnectionString, 
            _queueOptions.WeatherQueueName, clientOptions);
        
        WeatherQueueItem queueItem = new WeatherQueueItem
        {
            Data = item
        };

        var serializedData = JsonSerializer.Serialize(queueItem);
        await client.SendMessageAsync(serializedData);
    }
}

Klasa jest dość prymitywna. Tworzymy ją z opcjami QueueOptions – to jest zwykła klasa trzymająca opcje, które pozwalają na połączenie się z kolejką:

public class QueueOptions
{
    public string ConnectionString {  get; set; }
    public string WeatherQueueName { get; set; }
}

Czyli mamy tutaj connection string do kolejki (w dokładniej do AzureBlobStorage), a także nazwę kolejki, do której chcemy wrzucać te żądania. Więcej o opcjach w .NET pisałem w tym artykule.

Następnie mamy tylko jedną metodę: SendWeatherRequest, która jest odpowiedzialna właśnie za wrzucenie konkretnego żądania na kolejkę. Wrzucamy to w postaci JSON, później otrzymamy to jako wiadomość w naszej funkcji Azure.

WeatherQueueItem to zwykła klasa, którą wykorzystuję dla danych wysyłanych do kolejki. Równie dobrze mógłbym posłużyć się WeatherDatabaseItem, jednak uznałem że tak będzie bardziej prawilnie. Ostatecznie ta wiadomość w kolejce może mieć jakieś dodatkowe dane. Natomiast, jak widzisz, WeatherDatabaseItem jest składnikiem WeatherQueueItem:

public class WeatherQueueItem
{
    public WeatherDatabaseItem Data {  get; set; }
}

StorageQueueService rejestrujemy jako Scoped (przy okazji konfigurujące opcje):

builder.Services.AddScoped<StorageQueueService>();
builder.Services.Configure<QueueOptions>(builder.Configuration.GetSection("StorageQueueOptions"));

Aktualizujemy WeatherForecastService

Teraz tylko zaktualizujemy sobie WeatherForecastService, bo kontroler będzie korzystał z niego:

public class WeatherForecastService
{
    private readonly Database _database;
    private readonly StorageQueueService _queueService;

    public WeatherForecastService(Database database, 
        StorageQueueService queueService)
    {
        _database = database;
        _queueService = queueService;
    }

    public async Task SetForecastRequestToQueue(Guid requestId, DateOnly date)
    {
        WeatherForecast forecast = new WeatherForecast
        {
            Date = date
        };

        WeatherDatabaseItem dbItem = new WeatherDatabaseItem
        {
            RequestId = requestId,
            Data = forecast,
            Status = OperationStatus.NotStarted,
        };

        await _queueService.SendWeatherRequest(dbItem);
    }
    //reszta bez zmian
}

No i na koniec dodajmy nowy endpoint do kontrolera:

[HttpGet(Name = "GetWeatherForecast/{date}")]
public async Task<IActionResult> Get(DateOnly date)
{
    Guid requestId = Guid.NewGuid();
    _ = _service.GetForecast(requestId, date);

    return await Task.FromResult(Accepted($"/WeatherForecast/Status/{requestId}"));
}

[HttpPost("GetAsyncWeatherForecast/{date}")]
public async Task<IActionResult> GetByQueue(DateOnly date)
{
    Guid requestId = Guid.NewGuid();
    await _service.SetForecastRequestToQueue(requestId, date);

    return Accepted($"/AsyncWeatherForecast/Status/{requestId}");
}

Podsumowanie

Ok, mamy prawie działający serwer asynchroniczny. Prawie, ponieważ nie ma tutaj końcówki do pobierania stanu ani wyniku. To będzie po prostu pobranie danych z CosmosDb, a działanie analogiczne do wersji serwera bez chmury.

Teraz, gdy wywołasz końcówkę GetByQueue, do kolejki zostanie dodana odpowiednia wiadomość. Dodanie tej wiadomości uruchomi funkcję Azurową. Funkcja odczyta tę wiadomość i zrobi odpowiednie wpisy w CosmosDb. To tyle.

Tworzenie asynchroniczności przy API synchronicznym

Może się zdarzyć taka sytuacja, że używasz API, które jest synchroniczne, jednak poszczególne żądania działają zbyt długo jak na Twoje wymagania.

W takiej sytuacji również możesz posłużyć się mechanizmem jak wyżej. I nie ma żadnego znaczenia, że nie masz dostępu do kodów API.

Musisz po prostu stworzyć jakąś kolejkę, funkcję Azure’ową i jakąś bazę danych. Teraz zamiast wysyłać żądanie do docelowego API, po prostu umieścisz odpowiedni komunikat w kolejce. Kolejka uruchomi funkcję Azurową, która strzeli do docelowego API. Po tym jak praca się skończy, funkcja Azure’owa może albo wysłać Ci powiadomienie (callback), albo po prostu zmienić dane w bazie, żebyś wiedział, że zadanie zostało zakończone.

Diagram takiej pracy może wyglądać w taki sposób (worker to docelowe API):


To tyle jeśli chodzi o asynchroniczne API. Nie przeczę, że temat może być zawiły zwłaszcza dla osób nie znających chmury lub juniorów. Jeśli czegoś nie zrozumiałeś lub znalazłeś w artykule błąd, koniecznie daj znać w komentarzu.

Podziel się artykułem na:
Jak zrobić własny mechanizm uwierzytelniania – na przykładzie API key i BasicAuth

Jak zrobić własny mechanizm uwierzytelniania – na przykładzie API key i BasicAuth

Wstęp

Hej, mimo że .NET daje Ci kilka gotowych mechanizmów (schematów) uwierzytelniania, to jednak czasem trzeba napisać coś swojego. Takimi przykładami mogą być Basic Authentication albo chociażby Api Key Authentication. Api Key będziesz używał wtedy, kiedy masz swoje API dostępne dla innych programistów, jednak chcesz uwierzytelnić w jakiś sposób każdego klienta, który z Twojego API korzysta.

W tym artykule pokażę Ci jak skonstruować swój własny mechanizm uwierzytelniania. Co więcej – pokażę jak wybrać dynamicznie odpowiedni schemat w zależności od przekazanego żądania. No to jedziemy.

Do artykułu przygotowałem przykładowe kody, które możesz pobrać z GitHub.

Czym jest uwierzytelnienie

Co nieco pisałem już na ten temat w artykule o uwierzytelnianiu i o tym, czym jest ClaimsPrincipal.

Generalnie proces uwierzytelnienia polega na tym, żeby sprawdzić dane identyfikacyjne, które przychodzą od użytkownika (np. w żądaniu HTTP) i wystawić na ich podstawie ClaimsPrincipal.

Najprostszym przykładem będzie właśnie klucz API. Załóżmy, że gdy klient korzysta z Twojego API, powinien w żądaniu wysłać nagłówek X-API-KEY. Jeśli go nie ma, taka osoba jest anonimowa (nie jest uwierzytelniona). Jeśli jest, to sprawdzasz, czy ten klucz jest gdzieś u Ciebie zarejestrowany. Jeśli tak, to na tej podstawie możesz stworzyć odpowiedni obiekt ClaimsPrincipal. Na tym właśnie polega cały proces – uwierzytelnij klienta, czyli zwróć informację na temat KIM ON JEST.

Później ten ClaimsPrincipal jest używany przez mechanizm autoryzacji, który sprawdza, co dany użytkownik może zrobić. No i ten ClaimsPrincipal jest dostępny w kontrolerach w HttpContext.User.

Czym tak naprawdę jest API Key?

Jeśli wystawiasz dla świata jakieś API, to to API może być publiczne (dostęp dla każdego), niepubliczne (dostęp tylko dla zarejestrowanych klientów) lub mieszane, przy czym zarejestrowani klienci mogą więcej.

Jeśli ktoś rejestruje u Ciebie klienta do Twojego API, powinieneś wydać mu tzw. API Key – klucz jednoznacznie identyfikujący takiego klienta. To może być w najprostszej postaci GUID. Po prawdzie klient też powinien dostać od Ciebie API Secret – czyli coś w rodzaju hasła.

Gdy klient chce wykonać jakąś operację na API, powinien się uwierzytelnić, wysyłając w żądaniu co najmniej Api Key. W taki sposób możesz logować operacje tego klienta lub w ogóle nie dopuścić go do używania API. Klient może się też uwierzytelnić za pomocą różnych mechanizmów jak OpenId Connect, ale ten artykuł nie jest o tym.

Dzisiaj pokazuję jak stworzyć taki mechanizm uwierzytelniania w .NET.

Jak działa mechanizm uwierzytelniania w .NET?

Tworząc swój własny mechanizm uwierzytelniania, tak naprawdę tworzysz własny „schemat”. Schemat to nic innego jak nazwa (np. „ApiKey”) połączona z Twoją klasą do uwierzytelniania (handler).

Wszystko sprowadza się ostatecznie do trzech kroków:

  • stwórz swój handler do uwierzytelniania (klasa dziedzicząca po AuthenticationHandler)
  • stwórz w nim obiekt ClaimsPrincipal
  • zarejestruj swój schemat

AuthenticationHandler

Całą obsługę uwierzytelniania robimy w klasie, która dziedziczy po AuthenticationHandler (bądź implementuje interfejs IAuthenticationHandler, co jest nieco trudniejsze). To na początek może wyglądać nieco skomplikowanie, ale jest proste.

Opcje

Klasa abstrakcyjna AuthenticationHandler jest klasą generyczną. Przyjmuje w parametrze typ, w którym trzymamy opcje naszego schematu uwierzytelnienia. Przy czym te opcje muszą dziedziczyć po klasie AuthenticationSchemeOptions i mogą być zupełnie puste, np.:

public class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions
{

}

W tych opcjach możemy mieć wszystko, co nam się podoba. Przykładem może być uwierzytelnianie za pomocą Bearer Token, gdzie w opcjach masz czas życia takiego tokena, wystawcę itd. Żeby zademonstrować całość, zrobimy sobie ograniczenie do długości klucza API. Nie ma to w prawdzie żadnego zastosowania praktycznego. Po prostu pokazuję, jak wykorzystać te opcje:

public class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions
{
    public int ApiKeyLength { get; set; }
    public bool CheckApiKeyLength { get; set; }
}

Handler

Teraz musimy napisać klasę, która będzie całym sercem uwierzytelniania – ta, która dziedziczy po AuthenticationHandler:

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        throw new NotImplementedException();
    }
}

Jak widzisz, wystarczy przesłonić metodę HandleAuthenticateAsync lub jej synchroniczną odpowiedniczkę.

Metoda musi zwrócić AuthenticationResult. Ten AuthenticationResult może przyjąć 3 stany:

  • sukces,
  • niepowodzenie,
  • brak wyniku.

Sukces

Jeśli rezultat kończy się sukcesem, musimy do niego przekazać „bilet” – ticket. Jest to taki mały obiekt, który trzyma informacje o schemacie uwierzytelnienia, ClaimsPrincipal i może zawierać jakieś dodatkowe dane (AuthenticationProperties). W swojej minimalnej postaci wystarczy mu nazwa schematu i ClaimsPrincipal.

Oczywiście „sukces” oznacza, że nasz mechanizm poprawnie uwierzytelnił danego klienta / użytkownika.

Niepowodzenie

Jeśli rezultat zakończy się niepowodzeniem (Fail) oznacza to, że nie dość, że użytkownik nie został uwierzytelniony przez nasz mechanizm, to jeszcze wszystkie inne ewentualne handlery już go nie mogą próbować uwierzytelnić.

Brak wyniku

Jeśli jednak rezultat zakończy się brakiem wyniku (NoResult) oznacza to, że użytkownik nie jest uwierzytelniony TYM SCHEMATEM, jednak inne ewentualne handlery mogą próbować go dalej uwierzytelniać.

Kiedy to stosujemy? Załóżmy, że mamy dwa schematy – ApiKey i Login + Hasło. Każdy handler jest uruchamiany po kolei przez Framework (chyba, że któryś handler zwróci sukces lub niepowodzenie – wtedy kolejne nie są już uruchamiane).

I teraz jeśli handler do ApiKey nie znajdzie klucza tam, gdzie powinien on być (np. w nagłówku żądania), może chcieć przekazać proces uwierzytelnienia kolejnym handlerom. Gdzieś tam wystartuje taki, który spodziewa się loginu i hasła.

Cały proces można by przedstawić w postaci prostego algorytmu:

UWAGA! W rzeczywistym świecie ta odpowiedź ma sens tylko, gdy w mechanizmie AUTORYZACJI wybrano kilka schematów uwierzytelnienia dla jakiejś końcówki. A, że to jak najbardziej jest możliwe, trzeba stosować tę wartość.

Podczas zwykłej operacji uwierzytelnienia (bez autoryzacji) zawsze w grę wchodzi tylko jeden schemat.

Konstruktor

Klasa AuthenticationHandler wymaga pewnych obiektów przekazanych w konstruktorze. Dlatego też minimalny konstruktor musi je przyjąć. Na szczęście wszystko ogarnia Dependency Injection. Teraz całość wygląda tak:

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    public ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        throw new NotImplementedException();
    }
}

Jak widzisz, jedną z tych wymaganych rzeczy jest IOptionsMonitor. Jeśli nie wiesz, czym to jest, pisałem o tym w artykule o opcjach.

Piszemy handlera

Napiszmy sobie teraz jakąś oszukaną klasę, która zwróci dane użytkownika, dla którego jest zarejestrowany dany ApiKey. Ta klasa pełni rolę „bazy danych”. Równie dobrze możesz tutaj użyć EfCore, czy czegokolwiek sobie życzysz:

public class ApiKeyClientProvider
{
    private Dictionary<string, ApiKeyClient> _clients = new Dictionary<string, ApiKeyClient>();
    public ApiKeyClientProvider()
    {
        AddClients();
    }

    public ApiKeyClient GetClient(string key)
    {
        ApiKeyClient result; ;

        if (_clients.TryGetValue(key, out result))
            return result;
        else
            return null;
    }

    private void AddClients()
    {
        var client = new ApiKeyClient()
        {
            ApiKey = "klucz-1",
            Email = "client1@example.com",
            Id = 1,
            Name = "Klient 1"
        };

        _clients[client.ApiKey] = client;

        var client2 = new ApiKeyClient()
        {
            ApiKey = "klucz-2",
            Email = "client2@example.com",
            Id = 2,
            Name = "Klient 2"
        };

        _clients[client2.ApiKey] = client2;
    }
}

W kolejnym kroku możemy zaimplementować ostatecznie nasz schemat uwierzytelniania:

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private readonly ApiKeyClientProvider _clientProvider;
    public ApiKeyAuthenticationHandler(
        ApiKeyClientProvider clientProvider, //wstrzykujemy naszą oszukaną bazę danych
        IOptionsMonitor<ApiKeyAuthenticationOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
        _clientProvider = clientProvider;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var apiKey = GetApiKey();
        if (string.IsNullOrWhiteSpace(apiKey))
            return AuthenticateResult.Fail("No API key provided");

        var client = _clientProvider.GetClient(apiKey);
        if (client == null)
            return AuthenticateResult.Fail("Invalid API key");

        var principal = CreatePrincipal(client);

        AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
        return AuthenticateResult.Success(ticket);
    }

    private string GetApiKey()
    {
        StringValues keyValue;
        if (!Context.Request.Headers.TryGetValue("X-API-KEY", out keyValue))
            return null;

        if (!keyValue.Any())
            return null;

        return keyValue.ElementAt(0);
    }

    private ClaimsPrincipal CreatePrincipal(ApiKeyClient client)
    {
        ClaimsIdentity identity = new ClaimsIdentity("ApiKey");
        identity.AddClaim(new Claim(ClaimTypes.Email, client.Email));
        identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, client.Id.ToString()));
        identity.AddClaim(new Claim(ClaimTypes.Name, client.Name));

        return new ClaimsPrincipal(identity);
    }
}

Przejdźmy ją fragmentami.

Na samym dole jest metoda CreatePrincipal. Ona tworzy obiekt ClaimsPrincipal na podstawie przekazanego rekordu klienta z naszej bazy.

Tworzenie ClaimsPrincipal polega w sumie na utworzeniu odpowiednich ClaimsIdentity wraz z Claimsami. ApiKey, które widzisz podczas tworzenia ClaimsIdentity to po prostu nazwa naszego schematu. Dzięki temu wiesz – aha, ten ClaimsIdentity powstał ze schematu ApiKey.

Jeśli nie wiesz, czym jest ten ClaimsPrincipal i Claimsy, przeczytaj ten artykuł.

Ok, dalej mamy metodę GetApiKey. Ona po prostu pobiera wartość odpowiedniego nagłówka żądania. Jak widzisz, klasa AuthenticationHandler daje nam bezpośredni dostęp do kontekstu HTTP przez właściwość Context.

No i najważniejsza metoda – HandleAuthenticateAsync. Przyjrzyjmy się jej jeszcze raz:

protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
    var apiKey = GetApiKey();
    if (string.IsNullOrWhiteSpace(apiKey))
        return AuthenticateResult.NoResult;

    var client = _clientProvider.GetClient(apiKey);
    if (client == null)
        return AuthenticateResult.Fail("Invalid API key");

    var principal = CreatePrincipal(client);

    AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
    return AuthenticateResult.Success(ticket);
}

Na początku pobieramy klucz API z nagłówka żądania. Jeśli jest pusty, to znaczy że nie można uwierzytelnić takiego klienta TYM SCHEMATEM. Klient po prostu nie dodał klucza do żądania. Zwracamy błąd uwierzytelnienia. Być może inny schemat będzie w stanie go zidentyfikować.

Jeśli jednak ten klucz jest, pobieramy użytkownika przypisanego do niego z naszej bazy. I znowu – jeśli taki użytkownik nie istnieje, to znaczy że klucz API nie jest prawidłowy.

Na koniec jeśli użytkownik istnieje, tworzymy na jego podstawie ClaimsPrincipal. Na koniec wydajemy mu „bilecik” z jego danymi i zwracamy sukces uwierzytelnienia.

Używamy opcji

Jak widzisz, nie dorobiliśmy jeszcze sprawdzenia, czy nasz klucz API ma odpowiednią długość. Ale wszystko mamy wstrzyknięte w konstruktorze. IOptionsMonitor daje nam te opcje. Wykorzystajmy więc go. Jeśli nie wiesz, czym jest IOptionsMonitor i jak z niego korzystać, przeczytaj ten artykuł.

protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
    var apiKey = GetApiKey();
    if (string.IsNullOrWhiteSpace(apiKey))
        return AuthenticateResult.Fail("No API key provided");

    if (Options.CheckApiKeyLength)
    {
        if (apiKey.Length != Options.ApiKeyLength)
            return AuthenticateResult.Fail("Invalid API key");
    }

    var client = _clientProvider.GetClient(apiKey);
    if (client == null)
        return AuthenticateResult.Fail("Invalid API key");

    var principal = CreatePrincipal(client);

    AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
    return AuthenticateResult.Success(ticket);
}

Jak widzisz, dostęp do opcji uwierzytelniania masz przez właściwość Options z klasy bazowej. Teraz tylko musimy zarejestrować nasz schemat.

Rejestracja

Pamiętaj o rejestracji naszej „bazy danych”:

builder.Services.AddScoped<ApiKeyClientProvider>();

No i sam schemat:

builder.Services.AddAuthentication("ApiKey")
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", o =>
    {
        o.ApiKeyLength = 7;
        o.CheckApiKeyLength = true;
    });

Wszystko rozbija się o rejestrację AddAuthentication. W parametrze podajemy domyślny schemat uwierzytelniania. Następnie dodajemy nasz schemat przez metodę AddScheme. Jeśli nie używasz opcji, to w drugim parametrze możesz dać po prostu null. Drugi parametr to delegat, który ustawia nasze opcje. Oczywiście w prawdziwym programie te wartości byłyby pobierane z konfiguracji.

Pamiętaj też o middleware. Musisz dodać przed UseAuthorization():

app.UseAuthentication();
app.UseAuthorization();

Challenge

Challenge (authentication challenge) to mechanizm, który jest uruchamiany przez .NET, gdy użytkownika nie można uwierzytelnić. Efektem tego może być przejście na stronę logowania albo po prostu dodanie jakiejś informacji w odpowiedzi na żądanie. Domyślny Challenge zwraca po prostu błąd 401.

Aby zrobić coś swojego, wystarczy przeciążyć metodę HandleChallengeAsync w naszej klasie. Można to zrobić tak:

protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
    Response.Headers.WWWAuthenticate = new StringValues("X-API-KEY");
    return Task.CompletedTask;
}

Podczas wywoływania HandleChallengeAsync przez .Net możemy korzystać z Response – czyli możemy modyfikować sobie odpowiedź do klienta. Standardowym podejściem w takim przypadku jest umieszczenie nagłówka www-authenticate z nazwą schematu lub jakimiś wskazówkami, jak uwierzytelniać się w naszym systemie.

To jest opcjonalne, Domyślny mechanizm, jak mówiłem, zwraca po prostu błąd 401.

Jeśli spróbujesz teraz pobrać dane przez Postmana, oczywiście nie zobaczysz ich, ale zostanie zwrócony Ci właśnie ten nagłówek. Zwróć też uwagę na to, że zwrócony kod operacji (200) oznacza operację zakończoną sukcesem:

ForwardChallenge

Jeśli przyjrzysz się klasie bazowej do opcji uwierzytelniania, zobaczysz taką właściwość jak ForwardChallenge. Możesz tutaj przypisać nazwę schematu, który będzie użyty do Challengowania. Jeśli więc podczas konfiguracji naszego schematu, przypisałbyś takie opcje:

builder.Services.AddAuthentication("ApiKey")
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", o =>
    {
        o.ApiKeyLength = 7;
        o.CheckApiKeyLength = true;
        o.ForwardChallenge = "Bearer";
    });

To wtedy, jeśli Twój schemat nie uwierzytelni użytkownika, Challenge zostanie przekazany do schematu o nazwie Bearer. Oczywiście, jeśli taki schemat nie został zarejestrowany, program się wysypie.

Forbid

To jest metoda, która wykona się, gdy dostęp do zasobu nie został udzielony dla Twojego schematu uwierzytelniania. Inaczej mówiąc, załóżmy że masz dwa schematy uwierzytelniania:

  • Użytkownik podaje login i hasło
  • Klient API podaje klucz API

Teraz, niektóre końcówki mogą wymagać konkretnego schematu uwierzytelniania. Załóżmy, że mamy jakieś końcówki administracyjne, na które nie można się dobić za pomocą uwierzytelniania przez klucz API. One wymagają uwierzytelnienia za pomocą loginu i hasła. Można to w kontrolerze zablokować przekazując po prostu nazwę schematu, który oczekujemy, np:

[Authorize(AuthenticationSchemes = "LoginAndPass")]

I teraz załóżmy taką sytuację. Jakiś klient API został uwierzytelniony przez nasze ApiKeyAuthorizationHandler. Natomiast końcówka wymaga uwierzytelnienia przez jakiś schemat LoginAndPass. W tym momencie zostanie wywołana metoda Forbid w naszym handlerze (ponieważ to nasz handler go uwierzytelnił). Działa to analogicznie do metody Challenge. Domyślnie zwracany jest błąd 403.

Oczywiście tutaj też możemy przekazać Forbid do innego schematu, używając – analogicznie jak przy Challenge – ForwardForbid w opcjach uwierzytelniania.

Inne opcje

Jeśli chodzi o uwierzytelnianie klientów API, istnieje inna opcja, w której właściwie nie musisz pisać tego kodu. Jest to usługa Azure’owa o nazwie Azure API Management, która załatwia to wszystko za Ciebie. Możesz też ustawić limity czasowe/ilościowe dla konkretnych klientów. Czego dusza zapragnie. Usługa daje Ci duuużo więcej (wraz z portalem dla Twoich klientów). Nie jest jednak darmowa.

Basic Authentication

Basic Authentication to standardowy mechanizm uwierzytelniania. Polega on na obecności odpowiedniej wartości w nagłówku Authentication.

A ta wartość to po prostu: Base64(<login>:<hasło>).

Czyli dajesz login i hasło przedzielone dwukropkiem, a następnie konwertujesz to na Base64. Taką wartość umieszcza się w nagłówku Authentication. Jak zapewne się domyślasz, nie jest to zbyt dobra metoda, jednak jest używana. W związku z tym, że przekazywane jest jawnie login i hasło, konieczne jest użycie SSL przy tej formie.

Napiszemy sobie teraz prosty mechanizm uwierzytelniania używający właśnie Basic Authentication. To będzie zrobione analogicznie do tego, co robiliśmy wyżej. Więc możesz po prostu przejrzeć sobie kod:

public class BasicAuthenticationOptions: AuthenticationSchemeOptions
{
}
public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationOptions>
{
    private readonly UserProvider _userProvider;
    private record UserCredentials(string login, string password);
    
    public BasicAuthenticationHandler(
        UserProvider userProvider,
        IOptionsMonitor<BasicAuthenticationOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
        _userProvider = userProvider;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var creds = RetrieveCredentials();
        if (creds == null)
            return AuthenticateResult.Fail("No credentials");

        var userData = _userProvider.GetUser(creds.login, creds.password);
        if (userData == null)
            return AuthenticateResult.Fail("No such user");

        if (userData.Password != creds.password)
            return AuthenticateResult.Fail("Invalid password");

        var principal = CreatePrincipal(userData);
        var ticket = new AuthenticationTicket(principal, "Basic");

        return AuthenticateResult.Success(ticket);
    }

  private UserCredentials RetrieveCredentials()
  {
      if (Context.Request.Headers.Authorization.Count == 0)
          return null;

      var basedValue = Context.Request.Headers.Authorization[0];
      if (basedValue.StartsWith("Basic "))
          basedValue = basedValue.Remove(0, "Basic ".Length);
      else
          return null;

      var byteData = Convert.FromBase64String(basedValue);
      var credsData = Encoding.UTF8.GetString(byteData);

      var credValues = credsData.Split(':');
      if (credValues == null || credValues.Length != 2)
          return null;

      return new UserCredentials(credValues[0], credValues[1]);
  }

    private ClaimsPrincipal CreatePrincipal(UserData user)
    {
        ClaimsIdentity identity = new ClaimsIdentity("Basic");
        identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
        identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
        identity.AddClaim(new Claim(ClaimTypes.Name, user.UserName));

        return new ClaimsPrincipal(identity);
    }
}

Jedyne, czego tu nie widać, to klasa UserProvider, która wygląda bardzo podobnie jak ApiKeyClientProvider. Możesz zobaczyć całość na GitHub. Wszystko działa tutaj analogicznie.

Dodałem tę metodę, żeby pokazać Ci teraz, w jaki sposób możesz dynamicznie wybrać sobie schemat uwierzytelniania.

Dynamiczny wybór schematu uwierzytelniania

Żeby móc dynamicznie wybrać schemat, musimy dodatkowo dodać politykę. To nie wymaga dużo wysiłku, spójrz na ten kod:

builder.Services.AddAuthentication("ApiKeyOrBasic")
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", o =>
    {
        o.ApiKeyLength = 7;
        o.CheckApiKeyLength = true;
    })
    .AddScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>("Basic", null)
    .AddPolicyScheme("ApiKeyOrBasic", null, o =>
    {
        o.ForwardDefaultSelector = context =>
        {
            if (context.Request.Headers.ContainsKey("X-API-KEY"))
                return "ApiKey";
            else
                return "Basic";
        };
    });

Gdy rejestrujemy mechanizmy uwierzytelniania przez AddAuthentication, zobacz że jako domyślny schemat podajemy nazwę ApiKeyOrBasic – czyli nazwę naszej polityki do wyboru schematu.

Teraz, wykonując AddPolicyScheme, rejestrujemy właśnie taką politykę.

W rezultacie, wywołany zostanie domyślny schemat uwierzytelniania – czyli nasza polityka, która po prostu sprawdzi, czy w żądaniu znajduje się odpowiedni nagłówek. Następnie zwraca nazwę schematu, którym to żądanie powinno być uwierzytelnione. Nazwa trafia do ForwardDefaultSelector.

.NET w kolejnym kroku uruchomi właśnie ten schemat.

Czym jest domyślna nazwa schematu?

W .NET możesz m.in. przy kontrolerach wymagać uwierzytelnienia użytkownika konkretnym schematem. Czyli przykładowo: „Jeśli użytkownik chce wykonać tę operację, MUSI być zalogowany schematem login i hasło„.

Jeśli tego nie podasz jawnie, wtedy do gry wejdzie domyślny schemat uwierzytelniania. Dlatego ważne jest, żeby zawsze go podać.

Dobre praktyki

Kod, który pokazałem nie zawiera dobrych praktyk. Ale dzięki temu jest bardziej czytelny.

W prawdziwym kodzie upewnij się, że stosujesz te dobre praktyki, czyli:

  • Nazwy nagłówków – jeśli wprowadzasz jakieś własne nazwy nagłówków, upewnij się, że NIE zaczynają się od X-. Jest to przestarzała forma, która jest już odradzana przez konsorcjum. Zamiast tego powinieneś w jakiś jednoznaczny sposób nazwać swój nagłówek, np.: MOJ-PROGRAM-API-KEY.
  • Nazwy schematów w gołych stringach – no coś takiego w prawdziwym kodzie woła o pomstę do nieba. Powinieneś stworzyć jakieś stałe w stylu:
class ApiKeyAuthenticationDefaults
{
    public const string SchemeName = "ApiKey";
}

i posługiwać się tymi stałymi.

  • Nazwy nagłówków w gołych stringach – tutaj tak samo. Wszystko powinno iść przez stałe.

Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd, koniecznie daj znać w komentarzu. No i udostępnij go osobom, którym się przyda 🙂

Obrazek wyróżniający: Obraz autorstwa macrovector na Freepik

Podziel się artykułem na:
Walidacja opcji

Walidacja opcji

Wstęp

Kiedy tworzysz opcje do swojego programu, warto dodatkowo je walidować. Pewnie, że nie wszystkie. Jednak daje to większe poczucie spokoju, zwłaszcza kiedy aplikacja chodzi na różnych środowiskach (chociażby produkcyjne i deweloperskie). Jeśli ktoś przez pomyłkę źle skonfiguruje system, to nie będzie on działał poprawnie. Co więcej, przez długi czas możesz się o tym nie dowiedzieć. Niepoprawne opcje mogą przez dłuższy czas nie dawać o sobie znaku. Walidacja ich od razu może rozwalić program. Od razu będzie wiadomo, że coś jest nie tak.

W tym artykule pokażę Ci jak możesz walidować swoje opcje zarówno za pomocą adnotacji, jak i fluent validation.

Jeśli nie wiesz jak konfigurować opcje w .NET, koniecznie przeczytaj ten artykuł: Konfiguracja i opcje programu w .NET.

Przykładowy projekt

Do tego artykułu przygotowałem przykładowy projekt, który możesz pobrać z GitHuba: https://github.com/AdamJachocki/OptionsValidation/tree/master

Podstawy walidacji

.NET umożliwia Ci walidację opcji na kilka różnych sposobów. Możesz sprawdzać typowymi adnotacjami (DataAnnotations) w klasie modelu opcji. Pisałem już o tym w artykule o walidacji.

Załóżmy więc, że mamy taki prosty model opcji:

public class SimpleOptions
{
    [EmailAddress]
    public string SenderEmail { get; set; }
    [Required]
    public string SmtpAddress { get; set; }
}

Jak widać, walidujemy tutaj za pomocą adnotacji. Pole SenderEmail musi być adresem e-mail, natomiast pole SmtpAddress jest wymagane.

Teraz, żeby uruchomić walidację, trzeba nieco inaczej skonfigurować te opcje niż w sposób domyślny opisany w tym artykule. Teraz zamiast metody Configure, użyjemy AddOptions, które zwraca obiekt klasy OptionBuilder, który z kolei umożliwia walidacje:

services.AddOptions<SimpleOptions>()
	.Bind(Configuration.GetSection("SimpleOptions"))
	.ValidateDataAnnotations();

Zauważ, że używając OptionBuildera, trzeba użyć metody Bind do powiązania tych opcji i na koniec ValidateDataAnnotations, co uruchomi walidację tych opcji, używając adnotacji. Tylko adnotacji. Pamiętaj o tym.

Teraz, jeśli jakieś opcje nie będą spełniały założeń, podczas ich wstrzykiwania pójdzie wyjątek. Np. spójrz na taki appsettings.json:

 {
   "SimpleOptions": {
    "SenderEmail": "admin@example.com"
  }
}

Jak widzisz, nie ma tutaj w ogóle pola SmtpAddress, które jest wymagane w naszym modelu. Teraz, jeśli chcielibyśmy takie opcje odczytać np.:

[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
    private readonly SimpleOptions _simpleOptions;
    public TestController(IOptions<SimpleOptions> simpleOptions)
    {
        _simpleOptions = simpleOptions.Value;
    }
}

to w linijce 8 dostaniemy wyjątek. Takich opcji nie można pobrać, bo nie spełniają warunków.

Problemem jest to, że program musi dojść do tego miejsca, żeby opcje zostały zwalidowane. Na szczęście w .NET6 można sprawdzić konkretne opcje już podczas uruchamiania aplikacji, co jest naprawdę mega użyteczne. Piszę o tym później.

Oczywiście sam możesz pisać własne atrybuty walidacyjne, o czym pisałem tutaj. Wystarczy napisać klasę dziedziczącą po ValidationAttribute.

To prosta walidacja. Nie można za jej pomocą zrobić bardziej wyrafinowanych sprawdzeń. A jeśli można to jest to uciążliwe. Dlatego dla takich scenariuszy przychodzi kolejna możliwość…

Własny walidator

Wystarczy stworzyć własny walidator – klasę, która implementuje interfejs IValidateOptions. Nic nie stoi na przeszkodzie, żeby Twój model ten interfejs implementował, jednak z punktu widzenia czystości kodu, to nie jest dobre rozwiązanie. Pamiętaj o tym.

Stworzę zatem osobną klasę, która będzie walidować taki model:

public class ApiOptions
{
    public string ClientId { get; set; }
    public string ClientSecret { get; set; }
    public string ClientUri { get; set; }
}

Te przykładowe opcje pozwalają się łączyć z hipotetycznym API. Założenie jest takie, że albo podajemy ClientId i ClientSecret (który musi być odpowiedniej długości), albo podajemy ClientUri. Napiszmy teraz walidator do tego. Zacznijmy od pustego:

public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
    public ValidateOptionsResult Validate(string? name, ApiOptions options)
    {
        
    }
}

Jak widzisz, interfejs IValidateOptions posiada tylko jedną metodę do implementacji. W parametrze name dostaniesz nazwę tych opcji, jeśli używasz named options. Natomiast w parametrze options otrzymasz cały model odczytany z konfiguracji. I teraz możesz go sprawdzić np. w taki najprostszy sposób:

public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
    public ValidateOptionsResult Validate(string? name, ApiOptions options)
    {
        bool isIdAndSecret = IsIdAndSecret(options);
        bool isUri = IsUri(options);

        if (isIdAndSecret && isUri)
            return ValidateOptionsResult.Fail("Nie możesz jednocześnie podać ClientUri i sekretów");

        if (!isIdAndSecret && !isUri)
            return ValidateOptionsResult.Fail("Musisz podać jakieś dane do połączenia z API");

        if (isIdAndSecret && options.ClientSecret.Length < 5)
            return ValidateOptionsResult.Fail("Client secret jest za krótki");

        return ValidateOptionsResult.Success;
    }

    private bool IsIdAndSecret(ApiOptions options)
    {
        return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
    }

    private bool IsUri(ApiOptions options)
    {
        return !string.IsNullOrWhiteSpace(options.ClientUri);
    }
}

Po prostu wykonujemy kilka prostych sprawdzeń i albo zwracamy ValidateOptionsResult.Success, albo Fail. W przypadku, jeśli walidacja się nie powiedzie, zachowanie będzie identyczne jak przy walidacji adnotacjami. Program się wywali na próbie pobrania opcji.

Teraz tylko trzeba to zarejestrować w nieco inny sposób. Możemy posłużyć się zarówno OptionsBuilderem jak i konfiguracją, jednak trzeba dodatkowo zarejestrować takiego walidatora:

builder.Services.Configure<ApiOptions>(builder.Configuration.GetSection("ApiOptions"));
builder.Services.AddSingleton<IValidateOptions<ApiOptions>, ApiOptionsValidator>();

Nowość w .NET8

W .NET8 wprowadzono budowniczego ValidateOptionsResultBuilder, którym możesz sobie zbudować cały rezultat jeśli chcesz. Dzięki temu możesz zwrócić kilka błędów. Powyższy kod mógłby wyglądać tak:

public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
    public ValidateOptionsResult Validate(string? name, ApiOptions options)
    {
        bool isIdAndSecret = IsIdAndSecret(options);
        bool isUri = IsUri(options);

        ValidateOptionsResultBuilder builder = new();

        if (isIdAndSecret && isUri)
            builder.AddError("Nie możesz jednocześnie podać ClientUri i sekretów");

        if (!isIdAndSecret && !isUri)
            builder.AddError("Musisz podać jakieś dane do połączenia z API");

        if (isIdAndSecret && options.ClientSecret.Length < 5)
            builder.AddResult(ValidateOptionsResult.Fail("Client secret jest za krótki"));

        return builder.Build();
    }

    private bool IsIdAndSecret(ApiOptions options)
    {
        return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
    }

    private bool IsUri(ApiOptions options)
    {
        return !string.IsNullOrWhiteSpace(options.ClientUri);
    }
}

Zaznaczyłem znaczące linie. Jak widzisz, możesz do buildera dodawać zarówno errory jak i całe obiekty ValidateOptionsResult.

Dzięki temu możesz pokazać wszystkie problemy związane z opcjami, a nie tylko jeden.

Walidacja w OptionsBuilderze

Ten sposób walidacji zostawiam raczej jako ciekawostkę. W małych systemach pewnie się sprawdzi, natomiast w większych lepiej go unikać.

Można napisać kod walidacyjny podczas rejestrowania opcji:

builder.Services.AddOptions<ApiOptions>()
    .Bind(builder.Configuration.GetSection("ApiOptions"))
    .Validate(options =>
    {
        if (string.IsNullOrWhiteSpace(options.ClientUri)
            && string.IsNullOrWhiteSpace(options.ClientId)
            && string.IsNullOrWhiteSpace(options.ClientSecret))
            return false;

        return true;
    });

To tylko fragment wcześniejszej walidacji. Musiałbym napisać resztę przypadków, ale to nie ma sensu (bo to tylko przykład). Tutaj metoda Validate przyjmuje delegat – funkcję, która zwraca bool, a w parametrze ma model opcji.

Dlaczego to nie ma sensu? To chyba widać. W przypadku większej ilości opcji lub bardziej wyrafinowanych walidacji w kodzie po prostu zrobi się burdel i całość stanie się mało czytelna.

Tak jak mówiłem wcześniej – w małych, szybkich projektach to się może sprawdzić. Natomiast w większych raczej nie.

Walidacja przy starcie systemu

Domyślny mechanizm będzie walidował opcje dopiero w momencie próby ich pobrania: var myOptions = options.Value. Natomiast możesz sobie życzyć, żeby opcje były sprawdzane podczas uruchamiania programu. Plusem tego jest to, że od razu dowiesz się, że coś jest nie tak, bo apka wywali się podczas uruchamiania. Minus? Aplikacja będzie potrzebować nieco więcej czasu, żeby się uruchomić, ponieważ będzie sprawdzać opcje, które wskażesz. Myślę jednak, że warto to zrobić, bo od razu dostajesz wiedzę, że coś jest źle skonfigurowane.

Wystarczy, że wywołasz metodę ValidateOnStart z OptionsBuilder:

builder.Services.AddOptions<SimpleOptions>()
    .Bind(builder.Configuration.GetSection("SimpleOptions"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Pamiętaj, że metoda ValidateOnStart doszła dopiero w .NET6. Dlatego jeśli masz projekt we wcześniejszej wersji przemyśl migrację do .NET6.

Walidacja typu FLUENT

Na koniec pokażę jak zaprząc do walidacji opcji znaną i lubianą bibliotekę open source – FluentValidation. Od razu zaznaczam, że ten artykuł nie jest kursem ani nawet nie muska działania tej biblioteki. Jeśli wiesz, co ona robi, ten akapit może Ci się przydać. W innym przypadku spróbuj się z nią najpierw zapoznać.

FluentValidation umożliwia walidacje w sposób „fluent” modeli. Jednak standardowo nie obsługuje opcji. Można to w dość prosty sposób zmienić.

Spójrzmy na przykładowy model opcji:

public class FluentApiOptions
{
    public string ClientId { get; set; }
    public string ClientSecret { get; set; }
    public string ClientUri { get; set; }
    public string ApiUrl { get; set; }
}

Założenia będą takie jak w poprzednim przykładzie, tzn. podajemy albo clientId i secret, albo clientUri. Jedno z dwóch. Dodatkowo zawsze musi być ApiUrl.

Walidator Fluenta

Napiszmy do tego standardowy walidator fluentowy:

public class FluentApiOptionsValidator: AbstractValidator<FluentApiOptions>
{
    public FluentApiOptionsValidator()
    {
        RuleFor(x => x.ApiUrl)
            .NotEmpty();

        RuleFor(x => x.ClientUri)
            .NotEmpty()
            .When(x => string.IsNullOrWhiteSpace(x.ClientId) && string.IsNullOrWhiteSpace(x.ClientSecret))
            .WithMessage("Jeśli nie podajesz clientId i sekretu, musisz podać ClientUri")
            .MinimumLength(5)
            .WithMessage("ClientUri jest za krótkie");

        RuleFor(x => x.ClientId)
            .NotEmpty()
            .When(x => string.IsNullOrWhiteSpace(x.ClientUri))
            .WithMessage("Musisz podać ClientId i sekret, jeśli nie podajesz ClientUri");

        RuleFor(x => x.ClientSecret)
            .NotEmpty()
            .When(x => !string.IsNullOrWhiteSpace(x.ClientId))
            .WithMessage("Brak client secret");
    }
}

I dopiero teraz zacznie się zabawa.

Mając już walidator do konkretnego modelu, musimy teraz stworzyć swój własny walidator opcji – ten, implementujący interfejs IValidateOptions. Dlaczego?

Integracja z opcjami

Jak już mówiłem, FluentValidation nie jest domyślnie zintegrowany z mechanizmem opcji w .NET. A szkoda, bo mógłby być. Zatem sami musimy sobie taką integrację zapewnić. I tutaj przychodzi z pomocą IValidateOptions. Utworzymy generyczny walidator, żeby można go było używać z każdym typem opcji. To w najprostszej postaci może wyglądać tak:

public class GenericFluentValidator<TOptions> : IValidateOptions<TOptions>
    where TOptions : class
{
    private readonly IServiceProvider _serviceProvider;
    private readonly string _name;

    public GenericFluentValidator(string name, IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        _name = name;
    }

    public ValidateOptionsResult Validate(string? name, TOptions options)
    {
        if (_name != null && _name != name)
            return ValidateOptionsResult.Skip;

        using var scope = _serviceProvider.CreateScope();
        var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();

        ValidationResult res = validator.Validate(options);
        if (res.IsValid)
            return ValidateOptionsResult.Success;

        var errorArray = res.Errors.Select(e => e.ErrorMessage).ToArray();
        var msg = string.Join(Environment.NewLine, errorArray);

        return ValidateOptionsResult.Fail(msg);              
    }
}

To na pierwszy rzut oka może okazać się zawiłe, ale jest naprawdę bardzo proste.

Pomińmy na razie konstruktor – w jakiś sposób dostaniemy IServiceProvider i nazwę (jeśli używamy named options). Przejdźmy od razu do metody Validate.

Najpierw sprawdzamy, czy używamy named options i czy to odpowiedni do tego walidator. Jeśli nie, to olewamy.

Następnie tworzymy sobie scope’a, żeby pobrać z niego serwis implementujący IValidator<TOptions>. A teraz pytanie – co to takiego? Interfejs IValidator<T> pochodzi z FluentValidation. Wszystkie walidatory ten interfejs implementują. A więc po prostu szukamy walidatora dla konkretnego typu.

Gdy już mamy go, to w linijce 21 uruchamiamy walidację. Jeśli się udała, zwracamy sukces. Jeśli nie, zwracamy listę błędów z tego walidatora w postaci jednego stringa.

Rejestracja

Teraz, jak już wiesz, trzeba zarejestrować ten GenericFluentValidator:

builder.Services.AddSingleton<IValidateOptions<FluentApiOptions>>(sp =>
{
    return new GenericFluentValidator<FluentApiOptions>("", sp);
});

Po prostu dodajemy go tak jak w poprzednich przykładach, tyle że z wykorzystaniem fabryki – dzięki czemu możemy przekazać IServiceProvidera. Parametrem name na razie się nie przejmuj, zajmiemy się nim później.

Na koniec wystarczy zarejestrować konkretny walidator modelu:

builder.Services.AddSingleton<IValidator<FluentApiOptions>, FluentApiOptionsValidator>();

No i wszystko śmiga. Ale przyznasz, że żeby ogarnąć jeden model w taki sposób, trzeba się sporo napisać. Właściwie upierdliwe są te rejestracje. Ale jest na to metoda…

Upraszczamy rejestracje

Posłużymy się extensioniem, żeby ułatwić sobie pracę. Całą rejestrację przeniesiemy do extensiona. Przy okazji załatwi nam to problem named options:

public static class OptionsBuilderExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions, TValidator>(this OptionsBuilder<TOptions> builder)
        where TOptions : class
        where TValidator: class, IValidator<TOptions> 
    {
        builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
        {
            return new GenericFluentValidator<TOptions>(builder.Name, sp);
        });

        builder.Services.AddSingleton<IValidator<TOptions>, TValidator>();

        return builder;
    }
}

Dzięki takiemu rozwiązaniu, model opcji możemy zarejestrować w taki sposób:

builder.Services.AddOptions<FluentApiOptions>()
    .BindConfiguration("FluentApiOptions")
    .ValidateFluentValidation<FluentApiOptions, FluentApiOptionsValidator>()
    .ValidateOnStart();

Jeśli uważasz, że to wciąż kupa roboty, można to upraszczać dalej.

FluentValidation ma oficjalne rozszerzenie do DependencyInjection. Jeśli używasz FV, to pewnie tego rozszerzenia też:

dotnet add package FluentValidation.DependencyInjectionExtensions

W tym pakiecie znajdują się metody, które automatycznie rejestrują wszystkie walidatory ze wskazanego assembly. Więc teraz wystarczy uprościć nasze rozszerzenie i wywalić z niego rejestrację walidatora:

public static class OptionsBuilderExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(this OptionsBuilder<TOptions> builder)
        where TOptions : class
    {
        builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
        {
            return new GenericFluentValidator<TOptions>(builder.Name, sp);
        });

        return builder;
    }
}

A na koniec zarejestrować opcje:

builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
builder.Services.AddOptions<FluentApiOptions>()
    .BindConfiguration("FluentApiOptions")
    .ValidateFluentValidation()
    .ValidateOnStart();

Teraz jest łatwo, miło i prosto.

I tutaj rodzi się pytanie, czy walidowanie opcji za pomocą FluentValidation ma sens i czy nie jest to przerost formy nad treścią. Jak zwykle – to zależy. Jeśli szybciej/lepiej pracuje Ci się z FluentValidation i widzisz zysk w takim sprawdzaniu, zamiast pisać własny kod walidujący, to na pewno ma to sens, a czas włożony w konfigurację tego ustrojstwa szybko się zwróci. Zwłaszcza, że jest już sporo gotowych walidatorów na „dzień dobry”. A jak widzisz, konfiguracja nie jest aż taka straszna.

Nowości w .NET8

Walidacja opcji bez użycia refleksji – zgodność z AOT

.NET8 przynosi pewną, małą nowość. Kod, który używa refleksji (na przykład ten standardowy sposób walidacji powyżej), nie jest zgodny z AOT. Dlatego też nie moglibyśmy używać walidacji opcji w kompilacji AOT.

Możemy napisać częściowego walidatora, którego kod zostanie wygenerowany automagicznie i ten kod nie będzie już używał refleksji.

Brzmi jak kupa roboty? Może i tak, ale spójrz na to:

public class MyAppConfig
{
    [EmailAddress]
    [Required]
    public string SmtpAdress {  get; set; }
    [Range(1, 10)]
    public int TraceLevel { get; set; }
}

To jest model, który będziemy walidować. A walidator będzie wyglądał tak:

[OptionsValidator]
public partial class MyAppConfigValidator: IValidateOptions<MyAppConfig>
{

}

I to jest dokładnie tyle. Dobrze widzisz. Tutaj są istotne dwie rzeczy:

  • klasa musi być oznaczona jako partial
  • klasa musi posiadać atrybut [OptionsValidator]

W innym wypadku po prostu się nawet nie skompiluje.

W ostatnim kroku 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 – zgodność z AOT

Jeśli chodzi o rejestracje opcji, tj. Configure(TOptions), Bind i Get, to standardowo była do tego wykorzystywana refleksja. W .NET8 w aplikacjach internetowych domyślnie konfiguracja jest realizowana przez generator kodu. Czyli jest to zgodne z AOT i nie wymaga żadnych zmian.

Jeśli jednak chcesz być zgodny z AOT i nie tworzysz aplikacji webowej, musisz na takie działanie jawnie wyrazić zgodę. Wystarczy dodać ustawienie w projekcie:

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

A czy ty walidujesz swoje opcje? Daj znać w komentarzu 🙂


Dziękuję Ci za przeczytanie tego artykułu. Wierzę, że walidacja opcji stała się dla Ciebie jasna i będziesz jej używać. Jeśli znalazłeś w artykule jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

No i podziel się tym artykułem z kimś, komu uważasz że się przyda 🙂

Podziel się artykułem na:
Controller MVC i/lub API w osobnej bibliotece Class Library

Controller MVC i/lub API w osobnej bibliotece Class Library

Wstęp

Dodawanie kontrolera do osobnej biblioteki może być użyteczne w przypadku, gdy na przykład tworzysz plugin lub system, który korzysta z pluginów. Lub z jakiegoś jeszcze innego powodu chcesz wydzielić część kontrolerów do innego projektu. W .NET robi się to bardzo prosto.

Krok po kroku

Zakładam, że masz już istniejącą solucję z kontrolerami API, czy też MVC.

  1. Dodaj kolejny projekt Class Library do solucji, jeśli jeszcze go nie masz.
  2. Doinstaluj do niego paczkę NuGet: Microsoft.AspNetCore.App
  3. Podczas rejestracji serwisów dodaj:
services.AddMvc().AddApplicationPart(assembly);

Zmienna assembly to oczywiście Twoje assembly z ClassLibrary, w którym masz kontrolery. Możesz to pobrać na kilka sposobów. Jeśli taką rejestrację przeprowadzasz z jakiejś extension method w swojej ClassLibrary, np:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection(this IServiceCollection services)
    {
         services.AddMvc().AddApplicationPart(Assembly.GetExecutingAssembly());
    }
}

Jeśli jednak rejestrację przeprowadzasz z jakiegoś powodu z głównej aplikacji, to najprościej pobrać Assembly po konkretnej klasie.

Załóżmy, że Twój kontroler mieści się w takiej klasie:

namespace API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AccountController : ControllerBase
    {
        //
    }
}

Wtedy wystarczy pobrać assembly z tej właśnie klasy:

var assembly = typeof(API.Controllers.AccountController).Assembly;
services.AddMvc().AddApplicationPart(assembly);

To wszystko. Ta prosta „sztuczka” może sprawić, że Twój projekt stanie się bardziej czytelny i bardziej modularny.

Dzięki za przeczytanie artykułu. Jeśli znalazłeś jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu. Jeśli uważasz, że ta „sztuczka” jest super przydatna i ma też inne zastosowania, to też się podziel 🙂

Podziel się artykułem na:
Piszemy klienta do WebAPI

Piszemy klienta do WebAPI

Wstęp

Często mówimy o tym, czym jest WebApi, jak je tworzyć, a jak nie. Ale jakoś nie mówimy o tym jak stworzyć dobrze fajnego klienta do tego WebAPI.

Mogłoby się zdawać, że wystarczy utworzyć instancję HttpClient i wywołać odpowiednią końcówkę. I czasem nawet można tak zrobić. Ale jeśli chcesz mieć naprawdę dobrego klienta do większego API niż tylko dwie końcówki, to ten artykuł pokaże Ci jak do tego podejść na konkretnym przykładzie.

Jest NuGetowa paczka – RestSharp. Jest to bardzo popularna darmowa biblioteka, która zdecydowanie ułatwia tworzenie klientów API. Jednak w tym artykule nie posłużymy się nią. Zrobimy coś sami. Potem sam zdecydujesz, czy wolisz tworzyć takie rozwiązania samodzielnie, czy z użyciem RestSharpa.

Przede wszystkim – WebAPI

Żeby klient API miał sens, musi przede wszystkim łączyć się z jakimś API. Dlatego też przygotowałem dość proste rozwiązanie, na którym będziemy pracować. Możesz je pobrać z GitHuba.

Uwaga! Nie zwracaj za bardzo uwagi na kod API – jest bardzo prosty, banalny i nie we wszystkich aspektach super poprawny. Nie zajmujemy się tutaj WebAPI, tylko klientem do API.

To Api trzyma dane w słowniku, to znaczy że po ponownym uruchomieniu, wszystkie dane znikną.

Api ma kilka końcówek, możesz sobie je zobaczyć, uruchamiając swaggera. Z grubsza to:

  • POST – /api/clients/all – pobiera listę klientów (dlaczego POST – o tym niżej)
  • POST – /api/clients – dodaje klienta
  • GET – /api/clients/{id} – pobiera klienta o konkretnym id
  • DELETE – /api/clients/{id} – usuwa klienta o konkretnym id
  • POST – /api/orders/all – pobiera zamówienia (dlaczego POST – o tym niżej)
  • POST – /api/orders – dodaje zamówienie
  • GET – /api/orders/client/{clientId} – pobiera zamówienia dla konkretnego klienta
  • GET – /api/orders/{id} – pobiera zamówienie o konkretnym id

Także mamy kilka końcówek podzielonych na dwa kontrolery.

Zaczynamy pisać klienta

OK, skoro już wiemy jak mniej więcej wygląda API, możemy utworzyć projekt, w którym napiszemy klienta. Niech to będzie zwykły projekt Class Library.

Model DTO

Najpierw musimy utworzyć modele DTO. DTO czyli Data Transfer Object – są to klasy, które przekazują dane między API, a klientem. Modele DTO mogą być jak najgłupsze się da. To po prostu worek na dane. Nic więcej.

Teraz możesz zapytać – po co tworzyć dodatkowy model, skoro mamy już dokładny model bazodanowy? Nie lepiej ten model bazodanowy z projektu WebApi przenieść do jakiegoś współdzielonego?

W tym konkretnym przypadku banalnej aplikacji – pewnie tak. Natomiast przy aplikacjach bardziej rozbudowanych przekazywanie danych za pomocą modeli bazodanowych może okazać się baaaardzo problematyczne. Sam wiele lat temu zrobiłem taki błąd. W pewnym momencie okazało się, że muszę stosować jakieś dziwne haki i czary, żeby to wszystko jakoś działało. Dlatego – stwórz osobny model DTO.

W przykładowej aplikacji są w projekcie Models. Modele DTO wyglądają prawie tak samo jak modele bazodanowe. Specjalnie dodałem do modeli bazodanowych jedną właściwość (IsDeleted), żeby je czymś rozróżnić.

Zwróć uwagę na dwie klasy:

GetClientsRequestDto:

public class GetClientsRequestDto
{
    public int Skip { get; set; }
    public int Take { get; set; }
}

GetClientsResultDto:

public class GetClientsResultDto
{
    public IEnumerable<ClientDto> Data { get; init; }
    public int  Offset { get; init; }

    public GetClientsResultDto(IEnumerable<ClientDto> data, int offset)
    {
        Data = data;
        Offset = offset;
    }
}

W standardowym tutorialu tworzenia WebApi zobaczyłbyś, że gdy żądasz listy klientów, API zwraca po prostu listę klientów, np: IEnumerable<ClientDto>.

Jednak w prawdziwym świecie to może być za mało. Dlatego też stworzyłem dwie dodatkowe klasy:

  • GetClientsRequestDto – obiekt tej klasy będzie wysyłany wraz z żądaniem pobrania listy klientów
  • GetClientsResultDto – obiekt tej klasy będzie zwracany przez API zamiast zwykłej listy klientów.

Jak widzisz, te klasy zawierają w sobie informacje ograniczające ilość pobieranych danych. Jeśli miałbyś bazę z 10000 klientów i z jakiegoś powodu chciałbyś pobrać ich listę, to zupełnie bez sensu byłoby pobieranie wszystkich 10000 rekordów. To naprawdę sporo danych. Zamiast tego możesz pobierać te dane partiami i napisać jakiś prosty mechanizm paginacji. Do tego mogą właśnie służyć te dodatkowe klasy.

Analogicznie zrobiłem dla modelu OrderDto.

Abstrakcja

Skoro już mamy modele DTO, możemy pomyśleć o abstrakcji, która umożliwi nam testowanie klienta API.

Zgodnie z regułą pojedynczej odpowiedzialności (signle responsibility) klient API nie powinien być odpowiedzialny za wszystkie operacje związane z API. Ale powinien dać taką możliwość. Jak to osiągnąć? Poprzez dodatkowe klasy operacji. I tak będziemy mieć klasę odpowiedzialną za operacje na zamówieniach i drugą odpowiedzialną za klientów. Stwórzmy teraz takie abstrakcje:

public interface IClientOperations
{
    public Task<ClientDto> AddClient(ClientDto data);
    public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
    public Task<ClientDto> GetClientById(int id);
    public Task<bool> DeleteClient(int id);
}

To jest interfejs, którego implementacja będzie odpowiedzialna za operacje na klientach. Analogicznie stworzymy drugi interfejs – do zamówień:

public interface IOrderOperations
{
    public Task<OrderDto> AddOrder(OrderDto order);
    public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
    public Task<bool> DeleteOrder(int id);
}

To są bardzo proste interfejsy i na pierwszy rzut oka wszystko jest ok. Ale co jeśli z WebApi otrzymasz jakiś konkretny błąd? Np. podczas dodawania nowego klienta mógłbyś otrzymać błąd w stylu: „Nazwa klienta jest za długa”. W taki sposób tego nie ogarniesz. Dlatego proponuję stworzyć dwie dodatkowe klasy, które będą przechowywały rezultat wywołania końcówki API:

public class BaseResponse
{
    public int StatusCode { get; init; }
    public bool IsSuccess { get { return StatusCode >= 200 && StatusCode <= 299 && string.IsNullOrWhitespace(ErrorMsg); } }
    public string ErrorMsg { get; init; }

    public BaseResponse(int statusCode = 200, string errMsg = "")
    {
        StatusCode = statusCode;
        ErrorMsg = errMsg;
    }
}

public class DataResponse<T> : BaseResponse
{
    public T Data { get; init; }

    public DataResponse(T data, int statusCode = 200, string errMsg = "")
        : base(statusCode, errMsg)
    {
        Data = data;
    }
}

Klasa BaseResponse i operacja zakończona poprawnie

Klasa BaseResponse będzie przechowywała kod odpowiedzi wraz z ewentualnym komunikatem o błędzie. Wg specyfikacji HTTP wszystkie kody od 200 do 299 włącznie oznaczają operację zakończoną poprawnie, dlatego też IsSuccess jest tak skonstruowane.

Teraz pojawia się pytanie – co oznacza „operacja zakończona poprawnie”? W kontekście WebApi zazwyczaj chodzi tutaj o to, że dane przesłane w żądaniu były prawidłowe, na serwerze nic się nie wywaliło, nie było problemu z autoryzacją i serwer odpowiedział prawidłowo. Jednak nie znaczy to, że operacja zakończyła się tak, jak byśmy sobie tego życzyli.

To trochę dziwnie brzmi, zatem pokażę Ci pewien przykład. Załóżmy, że chcesz pobrać klienta o ID = 5. Wg specyfikacji REST Api, jeśli taki klient nie istnieje, powinieneś otrzymać zwrotkę z kodem 404. Jednak błąd 404 oznacza również, że nie znaleziono określonej strony (końcówki API). Jest to pewien znany problem. Czasami się to tak zostawia, czasem można rozróżnić w taki sposób, że z WebAPI zwracamy kod 200 – operacja się powiodła, ale dołączamy informację o błędzie w odpowiedzi np: „Nie ma klienta o takim ID”.

To nam wszystko załatwia klasa BaseResponse.

Klasa DataResponse

Jak widzisz, DataResponse dziedziczy po BaseResponse. Jedyną różnicą jest to, że DataResponse przechowuje dodatkowo dane, które mogły przyjść w odpowiedzi. Teraz, mając takie klasy, możemy zmienić zwracany typ z interfejsów IClientOperations i IOrderOperations. Do tej pory wyglądało to tak:

public interface IClientOperations
{
    public Task<ClientDto> AddClient(ClientDto data);
    public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
    public Task<ClientDto> GetClientById(int id);
    public Task<bool> DeleteClient(int id);
}

public interface IOrderOperations
{
    public Task<OrderDto> AddOrder(OrderDto order);
    public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
    public Task<bool> DeleteOrder(int id);
}

A teraz będziemy mieli coś takiego:

public interface IClientOperations
{
    public Task<DataResponse<ClientDto>> AddClient(ClientDto data);
    public Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data);
    public Task<DataResponse<ClientDto>> GetClientById(int id);
    public Task<BaseResponse> DeleteClient(int id);
}

public interface IOrderOperations
{
    public Task<DataResponse<OrderDto>> AddOrder(OrderDto order);
    public Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data);
    public Task<BaseResponse> DeleteOrder(int id);
}

Interfejs IApiClient

Skoro mamy już interfejsy dla poszczególnych operacji, możemy teraz napisać sobie interfejs do ApiClienta. I tutaj znów – ta abstrakcja nie jest konieczna. Jednak bez niej nie będziesz w stanie testować jednostkowo kodu, który używa klienta API.

Interfejs jest banalny:

public interface IApiClient
{
    IClientOperations ClientOperations { get; }
    IOrderOperations OrderOperations { get; }
}

Jak widzisz, klient API będzie dawał dostęp do poszczególnych operacji. To teraz zajmijmy się implementacją poszczególnych operacji, która zasadniczo będzie prosta.

Implementacja IClientOperations

Do komunikacji z WebApi wykorzystujemy HttpClient – dlatego też on musi znaleźć się w konstruktorze.

internal class ClientOperations : IClientOperations
{
    private readonly HttpClient _httpClient;

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

    public async Task<DataResponse<ClientDto>> AddClient(ClientDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("clients", data);
        return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<BaseResponse> DeleteClient(int id)
    {
        var response = await _httpClient.DeleteAsync($"clients/{id}");
        return await ResponseFactory.CreateBaseResponse(response);
    }

    public async Task<DataResponse<ClientDto>> GetClientById(int id)
    {
        var response = await _httpClient.GetAsync($"clients/{id}");
        return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("clients/all", data);
        return await ResponseFactory.CreateDataResponse<GetClientsResultDto>(response, DefaultJsonSerializerOptions.Options);
    }
}

Dalej mamy implementację poszczególnych metod. Każda z nich jest oparta dokładnie na tej samej zasadzie:

  • wyślij żądanie na odpowiednią końcówkę
  • stwórz DataResponse/BaseResponse na podstawie otrzymanej odpowiedzi – HttpResponseMessage.

Zwróć uwagę tutaj na trzy rzeczy.

  1. Klasa DefaultJsonSerializerOptions – jest to klasa, która trzyma domyślne dla aplikacji ustawienia serializacji JSON. W naszej aplikacji nie chcemy, żeby serializacja brała pod uwagę wielkość znaków. Jeśliby brała wtedy taki obiekt:
public class MyClass
{
    public int Id { get; set; }
    public string Name { get; set; }
}

nie zostałby powiązany z takim jsonem:

{
  "id": 5,
  "name": "Adam"
}

Z tego powodu, że występuje różnica w wielkości znaków. Niestety domyślne ustwienia serializatora z Microsoft biorą pod uwagę wielkość znaków. My chcemy tego uniknąć, dlatego powstała klasa, która przechowuje odpowiednie opcje. Znajduje się w projekcie Common.

  1. ResponseFactory to pomocnicza klasa, która z odpowiedzi HttpRequestMessage tworzy interesujące nas obiekty DataResponse lub BaseResponse – omówimy ją za chwilę.
  2. Pobieranie danych za pomocą POST…

No właśnie, spójrz na metodę GetClients. Ona pobiera dane za pomocą POST, a nie GET. Dlaczego tak jest? Czyżby to jaka herezja?

Przyczyną jest obecność klasy GetClientsRequestDto:

public class GetClientsRequestDto
{
    public int Skip { get; set; }
    public int Take { get; set; }
}

Metoda GET nie może mieć żadnych danych w ciele żądania. Oczywiście w tym przypadku można by te dwie właściwości włączyć do query stringa, wywołując końcówkę np: api/clients/all?skip=0&take=10. Jeśli jednak masz sporo więcej do filtrowania, do tego jakieś sortowanie i inne rzeczy… lub z jakiegoś powodu takie dane nie powinny być w query stringu, to spokojnie możesz je wrzucić do POSTa. Nikt Cię za to nie wychłosta 😉 Co więcej – to jest normalną praktyką w niektórych WebAPI.

ResponseFactory

Jak już wspomniałem, klasa ResponseFactory jest odpowiedzialna za utworzenie BaseResponse/DataResponse na podstawie przekazanego HttpResponseMessage. Jej implementacja w naszym przykładzie wygląda tak:

internal static class ResponseFactory
{
    public static async Task<BaseResponse> CreateBaseResponse(HttpResponseMessage response)
    {
        if (response.IsSuccessStatusCode)
            return new BaseResponse((int)response.StatusCode);
        else
            return new BaseResponse((int)response.StatusCode, await GetErrorMsgFromResponse(response));
    }

    public static async Task<DataResponse<T>> CreateDataResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
    {
        if (response.IsSuccessStatusCode)
        {
            T data = await GetDataFromResponse<T>(response, jsonOptions);
            return new DataResponse<T>(data, (int)response.StatusCode);
        }
        else
        {
            return new DataResponse<T>(default(T), (int)response.StatusCode, await GetErrorMsgFromResponse(response));
        }
    }

    private static async Task<T> GetDataFromResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
    {
        string content = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<T>(content, jsonOptions);
    }

    private static async Task<string> GetErrorMsgFromResponse(HttpResponseMessage response)
    {
        string result = await response.Content.ReadAsStringAsync();
        if (string.IsNullOrEmpty(result))
            return response.ReasonPhrase;
        else
            return result;
    }
}

Nie ma tu niczego skomplikowanego. Wszystko sprowadza się do tego, że odczytuję dane z contentu odpowiedzi i deserializuję je do odpowiedniego obiektu. To wszystko. Jedyne, co może być ciekawe to metoda GetErrorMsgFromResponse, która ma zwrócić komunikat błędu. Zakładam, że jeśli błąd wystąpi, zostanie umieszczony po prostu jako content odpowiedzi – tak jest skonstruowane przykładowe WebAPI.

Implementacja IOrderOperations

Jest analogiczna jak IClientOperations, dlatego też nie będę jej omawiał. Kod wygląda tak:

internal class OrderOperations : IOrderOperations
{
    private readonly HttpClient _httpClient;

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

    public async Task<DataResponse<OrderDto>> AddOrder(OrderDto order)
    {
        var response = await _httpClient.PostAsJsonAsync("orders", order);
        return await ResponseFactory.CreateDataResponse<OrderDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<BaseResponse> DeleteOrder(int id)
    {
        var response = await _httpClient.DeleteAsync($"orders/{id}");
        return await ResponseFactory.CreateBaseResponse(response);
    }

    public async Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("orders/all", data);
        return await ResponseFactory.CreateDataResponse<GetOrdersResultDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data)
    {
        var response = await _httpClient.PostAsJsonAsync($"orders/client/{clientId}", data);
        return await ResponseFactory.CreateDataResponse<GetOrdersResultDto> (response, DefaultJsonSerializerOptions.Options);
    }
}

Implementacja ApiClient

OK, nadszedł wreszcie czas na napisanie implementacji głównego klienta API:

public class ApiClient : IApiClient
{
    public IClientOperations ClientOperations { get; private set; }
    public IOrderOperations OrderOperations { get; private set; }

    private readonly HttpClient _httpClient;

    public ApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
        ClientOperations = new ClientOperations(_httpClient);
        OrderOperations = new OrderOperations(_httpClient);
    }
}

Tutaj HttpClient przychodzi z dependency injection. Następnie są tworzone odpowiednie obiekty – ClientOperations i OrderOperations, do których przekazujemy tego HttpClienta. Prawda, że proste?

HttpPipeline, czyli zupełnie nowy świat

Żeby klient API był wymuskany, można do niego dodać HttpPipeline. Pisałem o tym w tym artykule, więc nie będę się powtarzał. Zostawię Ci tylko zajawkę, że dzięki Http Pipeline, możesz zrobić zupełnie wszystko z żądaniem (zanim dotrze do celu) i odpowiedzią (zanim wróci do HttpClient). To zupełnie nowy świat możliwości. Przede wszystkim możesz automatycznie ustawiać wersję API, możesz odświeżać bearer token, możesz logować całe żądanie. Nic Cię tu nie ogranicza. Dlatego koniecznie przeczytaj ten artykuł, żeby mieć pełen obraz.

Przykładowe użycie

W repozytorium do tego artykułu jest umieszczony projekt WebApp – jest to bardzo prosta aplikacja RazorPages, które po krótce pokazuje użycie klienta.

UWAGA! Kod w aplikacji przykładowej jak i w WebApi jest podatny na różne rodzaje ataków. Dlatego nie stosuj takich „uproszczeń” w prawdziwym życiu. Różne ataki i jak się przed nimi chronić zostały opisane w tej książce.

W ramach ćwiczeń możesz spróbować zaimplementować w tym rozwiązaniu paginację, a także resztę operacji związanych z zamówieniami.


Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz przykładał większą wagę do klientów API, które tworzysz i artykuł podpowiedział Ci jak to zrobić dobrze. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu 🙂

Obrazek artykułu: Server icons created by Freepik – Flaticon

Podziel się artykułem na:
Feature Flags – co to i po co?

Feature Flags – co to i po co?

Wstęp

Czasem bywa tak, że musimy wyłączyć pewne funkcje w aplikacji. Zazwyczaj dlatego, że nie są jeszcze gotowe/przetestowane w 100% i trudno by było opublikować aplikację bez nich.

W dzisiejszym artykule opowiem, jak podejść do tematu.

Czym są Feature flags?

To specjalny rodzaj flagi mówiącej o tym, czy dana funkcja może być używana. Najprostszą taką flagą będzie dyrektywa kompilatora, np:

#if IMPORT_DB
            DbImporter importer = new DbImporter();
            importer.Import();
#endif

Jeśli zdefiniowaliśmy flagę IMPORT_DB, wtedy ten kod się wykona. Tak samo jak wszystkie inne, które będą opatrzone dyrektywami #if..#endif. O ile dość łatwo to ogarnąć np. w C++, to w C# jest już ciężej z tego powodu, że każdy projekt ma swój własny zestaw definów. I wtedy trzeba pamiętać, żeby do każdego projektu dołączyć plik z tymi feature’ami.

Innym problemem może być (ale nie musi) to, że bez ponownej kompilacji nie odblokujemy funkcji programu. Czasem jest to pożądanie, czasem nie.

Dodatkowo, jakby nie patrzeć, takie dyrektywy zaciemniają w pewien sposób kod.

.NetCore ma jednak sprytny mechanizm do zarządzania flagami funkcji. Nazywa się to FeatureManager i za chwilę Ci go przedstawię.

Instalacja FeatureManager

Najpierw musisz pobrać sobie NuGet: Microsoft.FeatureManagement.AspNetCore

Teraz wystarczy już tylko zarejestrować serwisy z tej biblioteki. Robimy to oczywiście podczas rejestrowania wszystkich innych serwisów.

builder.Services.AddFeatureManagement();

Jeśli się przyjrzysz, to zobaczysz, że AddFeatureManagement ma dwie wersje. W drugiej możesz przekazać całą sekcję w konfiguracji, w której wyłączasz lub włączasz poszczególne funkcje (domyślnie są odczytywane z sekcji FeatureManagement).

Domyślne działanie jest takie, że FeatureManager odczytuje sobie poszczególne funkcje z appSettings.json z sekcji „FeatureManagement„. Oczywiście odczytuje to dokładnie tak samo jak wszystkie inne opcje programu. Czyli najpierw appSettings.json, appSettings.{Environment}.json, zmienne środowiskowe itd. Jeśli nie znasz tematu dokładnie, koniecznie przeczytaj ten artykuł (konfiguracja i opcje programu).

Tworzenie flag

Zrobimy sobie przykładowy projekt, który pokazuje działanie flag – symulator telewizora. Tym razem będzie to projekt MVC, żeby móc pokazać więcej rzeczy. Przykład możesz pobrać sobie z GitHuba.

Najpierw zatroszczmy się o flagi. Do appSettings dodaj taką sekcję:

"FeatureManagement": {
  "PowerControl": "true",
  "ChannelControl": "false",
  "VolumeControl": "false"
}

Zwróć uwagę, że sekcja nazywa się FeatureManagement. Tak jak już mówiłem, to z niej domyślnie są odczytywane wartości flag.

Zdefiniowaliśmy tutaj trzy flagi:

  • PowerControl – użytkownik może włączyć i wyłączyć telewizor
  • ChannelControl – użytkownik może przełączać kanały. Jak widzisz, w tym momencie flaga jest wyłączona, czyli pozbawiamy użytkownika tej opcji
  • VolumeControl – użytkownik może zmieniać głośność. Teraz też go pozbawiamy tej opcji.

Oczywiście będziemy musieli się posługiwać nazwami tych flag później w kodzie. Dlatego też powinniśmy je wyekstrahować albo do jakiś stałych, albo do jakiegoś enuma. Ja wybrałem stałe. Utwórz osobny plik do tego:

public static class FeatureFlags
{
    public const string PowerControl = "PowerControl";
    public const string ChannelControl = "ChannelControl";
    public const string VolumeControl = "VolumeControl";
}

Kontrola funkcji

Oczywiście nie można napisać mechanizmu, który automagicznie wyłączy lub włączy poszczególne funkcje. To musimy zrobić samemu. Możemy to zrobić na dwa sposoby. Spójrz na ten kod w widoku:

@using Microsoft.FeatureManagement
@inject IFeatureManager FeatureManager

<h1>SUPER TV!</h1>
<hr />

<h2>Pilot</h2>
<hr />
@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2">TV ON</button>
        <button class="btn btn-secondary col-3">TV OFF</button>
    </div>
}

Na początku wstrzykujemy IFeatureManager. Następnie sprawdzamy, czy konkretna flaga została włączona, używając metody IsEnabledAsync. W jej argumencie przekazujemy nazwę flagi.

Jeśli flaga jest włączona, pokazujemy dla niej funkcjonalność. Analogicznie teraz możemy zrobić dla pozostałych flag:

@using Microsoft.FeatureManagement
@inject IFeatureManager FeatureManager

<h1>SUPER TV!</h1>
<hr />

<h2>Pilot</h2>
<hr />
@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2">TV ON</button>
        <button class="btn btn-secondary col-3">TV OFF</button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.ChannelControl))
{
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2"><strong>-</strong></button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.VolumeControl))
{
    <div class="row mb-2">
        <button class="btn btn-outline-info col-2 me-1"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">VOLUME</span>
        <button class="btn btn-outline-info col-2"><strong>-</strong></button>
    </div>
}

Interfejs IFeatureManager możesz wstrzyknąć do dowolnej klasy i używać go też na backendzie.

Teraz dodajmy jakieś działanie do tych przycisków. Żeby to zrobić, umieścimy je wszystkie w formularzu, a każdy guzik będzie odnosił na inną końcówkę. Całość będzie wyglądała mniej więcej tak (fragmenty usunąłem dla lepszej czytelności):

<form method="post">
    @if (await FeatureManager.IsEnabledAsync(FeatureFlag.PowerControl.ToString()))
    {
        <div class="row mb-2">
            <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
            <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
        </div>
    }
//i dalej sprawdzenie innych flag
</form>

Zabezpieczanie back-endu

Jeśli na froncie nie ma konkretnej funkcji, nie znaczy że nie można jej wywołać na backendzie. Spójrz na ten kod:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    [HttpPost]
    public IActionResult TvOn()
    {
        return View("Index");
    }

    [HttpPost]
    public IActionResult VolumeUp()
    {
        return View("Index");
    }

Jeśli teraz w jakiś sposób wywołamy końcówkę VolumeUp, no to stanie się coś złego. Więc z tej strony też powinniśmy się przed tym zabezpieczyć. FeatureManager daje nam bardzo fajny atrybut do tego FeatureGate:

[HttpPost]
[FeatureGate(FeatureFlags.VolumeControl)]
public IActionResult VolumeUp()
{
    return View("Index");
}

Jeśli teraz spróbujemy wywołać tę końcówkę, dostaniemy błąd 404 – strony nie znaleziono.

Za takie działanie jest odpowiedzialna domyślna implementacja interfejsu IDisabledFeaturesHandler. Oczywiście możesz sobie ją zmienić tak jak chcesz.

UWAGA! FeatureGate nie działa a RazorPages.

TagHelper

Jeśli nie podoba Ci się ta ifologia w widoku i widzisz tutaj szansę na użycie TagHelpers, dobra wiadomość jest taka, że Microsoft zrobił już to za Ciebie.

Spójrz jeszcze raz na kod widoku:

@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
        <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.ChannelControl))
{
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.VolumeControl))
{
    <div class="row mb-2">
        <button class="btn btn-outline-info col-2 me-1" asp-action="VolumeUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">VOLUME</span>
        <button class="btn btn-outline-info col-2" asp-action="VolumeDown"><strong>-</strong></button>
    </div>
}

Tego brzydala można zamienić na TagHelpery. I to jest drugi sposób ogarnięcia featerów na froncie.

Najpierw do _ViewImports.cshtml dodaj:

@addTagHelper *, Microsoft.FeatureManagement.AspNetCore

Teraz już możesz używać tag helpera feature:

<form method="post">
    <feature name="@FeatureFlags.PowerControl">
        <div class="row mb-2">
            <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
            <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
        </div>
    </feature>

    <feature name="@FeatureFlags.ChannelControl">
        <div class="row mb-2">
            <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
            <span class="col-2 my-auto text-center me-1">CHANNEL</span>
            <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
        </div>
    </feature>

    <feature name="@FeatureFlags.VolumeControl">
        <div class="row mb-2">
            <button class="btn btn-outline-info col-2 me-1" asp-action="VolumeUp"><strong>+</strong></button>
            <span class="col-2 my-auto text-center me-1">VOLUME</span>
            <button class="btn btn-outline-info col-2" asp-action="VolumeDown"><strong>-</strong></button>
        </div>
    </feature>
</form>

Przyznasz, że to wygląda zdecydowanie lepiej.

Co więcej, tag helpery dają Ci więcej możliwości niż tylko takie proste działanie. Możesz pokazać fragment, który będzie się pojawiał jeśli flaga będzie wyłączona, np:

<feature name="@FeatureFlags.ChannelControl">
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
    </div>
</feature>
<feature name="@FeatureFlags.ChannelControl" negate="true">
    <p>Zmiana kanałów będzie możliwa w przyszłości</p>
</feature>

Możesz też chcieć, żeby fragment kodu był widoczny tylko jeśli wszystkie lub kilka flag są włączone. Bardzo proszę:

<feature name="ChannelControl, VolumeControl" requirement="Any">
    <p>ChannelControl lub ValumeControl jest aktywne</p>
</feature>

<feature name="ChannelControl, VolumeControl" requirement="All">
    <p>ChannelControl i ValumeControl są aktywne</p>
</feature>

Wystarczy dodać nazwy tych flag do atrybutu name i posłużyć się atrybutem requirement. On może mieć dwie wartości – Any – jedna z flag musi być włączona; All – wszystkie flagi muszą być włączone.

Filtry i middleware

Jeśli używasz jakiegoś filtru (IAsyncActionFilter), który ma działać tylko gdy funkcja jest dostępna, możesz to zrobić w konfiguracji.

Dodaj ten filtr w nieco inny sposób niż standardowy:

builder.Services.AddControllersWithViews(o =>
{
    o.Filters.AddForFeature<VolumeFilter>(FeatureFlags.VolumeControl);
});

Zwróć uwagę, że nie rejestruję tutaj filtru z użyciem metody Add, tylko AddForFeature. W parametrze generycznym podaję typ filtru, a w środku nazwę flagi, z którą ten filtr ma być powiązany. W takim wypadku filtr zostanie odpalony tylko wtedy, jeśli flaga VolumeControl jest włączona.

Analogicznie można postąpić z middleware. Jeśli masz middleware, który ma być zależny od flagi, wystarczy że dodasz go w taki sposób zamiast standardowego:

app.UseMiddlewareForFeature<ChannelMiddleware>(FeatureFlags.ChannelControl);

To tyle jeśli chodzi o podstawy mechanizmu FeatureManager. To świetnie można połączyć z ustawieniami aplikacji na Azure – wtedy domyślnie stan flag odświeża się co 30 sekund. Ale to jest temat na inny artykuł, który powstanie.

Teraz dziękuję Ci za przeczytanie tego tekstu. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu.

Obrazek wyróżniający: Technologia zdjęcie utworzone przez pvproductions – pl.freepik.com

Podziel się artykułem na:
Autoryzacja oparta na zasobach

Autoryzacja oparta na zasobach

Wstęp – opisanie problemu

Często zdarza się, że użytkownik musi mieć pewne uprawnienia, żeby móc pracować z pewnymi zasobami. Żeby to nie brzmiało aż tak enigmatycznie, posłużę się przykładem.

Załóżmy, że jest aplikacja do fakturowania (np. Fakturownia, której sam używam :)). Załóżmy też, że aplikacja ma kilka ról – administrator, użytkownik i super admin.

Administrator – może tworzyć organizacje, dodawać do niej użytkowników i zarządzać wszystkimi rekordami w swojej organizacji. Użytkownik może tylko przeglądać i dodawać nowe faktury. A superadmin jest „ponad to” i może zupełnie wszystko. Super adminem byłby w tym przypadku właściciel takiego serwisu.

I teraz tak. Administrator zakłada konto „Piękny Lolo”. Dodaje rekordy związane z wydatkami (dla swojej organizacji), usuwa i git. Dodaje też użytkowników pod swoje konto.

Ale nagle rejestruje się nowy administrator (z innej organizacji) – zakłada konto: „Wąsaty Jan”. I teraz jakby wyglądała sytuacja, gdybyś posługiwał się autoryzacją opartą o role? Zarówno Wąsaty Jan, jak i Piękny Lolo mają uprawnienia administratora. Więc teoretycznie Wąsaty Jan może pracować na rekordach Pięknego i vice versa. Nie chcemy tego.

Trzeba zatem ograniczyć ich działanie tylko do ich „organizacji”. W innym przypadku mamy podatność bezpieczeństwa (jest to jedna z podatności, o których piszę w swojej książce – „Zabezpieczanie aplikacji internetowych”).

Tutaj z pomocą przychodzi tzw. autoryzacja oparta na zasobach (resource based authorization).

Przykładowy projekt

Dla ułatwienia posłużymy się prostszym problemem – zrobimy standardową aplikację do zadań. Nie zaciemni to obrazu, a postępowanie jest dokładnie takie samo.

Przygotowałem już gotowe rozwiązanie, które możesz pobrać z GitHuba: https://github.com/AdamJachocki/ResourceBasedAuth

Najpierw przyjrzyjmy się mu.

To jest zwykły projekt RazorPages z ustawionym uwierzytelnianiem (Authentication Type) na Individual Accounts. Dla ułatwienia wszystko zawarłem w jednym projekcie. Pamiętaj, że w prawdziwym życiu powinieneś rozdzielić ten projekt na kilka innych.

UWAGA! To jest bardzo prosta aplikacja bez żadnych walidacji. Pokazuje właściwie najprostszy uporządkowany kod, żeby bez sensu nie zaciemniać obrazu.

Potrafi utworzyć zadanie (TodoItem), zmodyfikować i usunąć je.

Zanim uruchomisz projekt, musisz utworzyć bazę danych. W katalogu z projektem uruchom polecenie:

dotnet ef database update

Namespacey projektu

Abstractions

Zawiera interfejs ITodoItemService, który jest wstrzykiwany do RazorPages. On obsługuje wszystkie operacje na bazie danych. Są dwa serwisy, które implementują ten interfejs: SecureTodoItemService – który pokazuje operowanie na zasobach w sposób bezpieczny, a także InsecureTodoItemService – ten pokazuje działania bez żadnych zabezpieczeń.

Domyślnie działającym jest InsecureTodoItemService. Możesz to zmienić w pliku Program.cs.

Areas

To domyślna obsługa .Net Identity – zakładanie kont, logowanie itp.

Data

Głównym jej elementem jest model bazodanowy TodoItem. Poza tym zawiera migracje EfCore, a także DbContext.

Pages

Zawiera strony i komponenty – zgodnie z nomenklaturą RazorPages

Services

Zawiera potrzebne serwisy.

Działanie niezabezpieczone

Spójrz na serwis InsecureTodoItemService. Jak widzisz nie ma on żadnych zabezpieczeń ani sprawdzeń. Przykładowa metoda usuwająca zadanie wygląda tak:

public async Task RemoveItem(int id)
{
    var model = new TodoItem { Id = id };
    _db.TodoItems.Remove(model);
    await _db.SaveChangesAsync();
}

To znaczy, że właściwie każdy, kto ma konto może usunąć dowolne itemy. Wystarczy poznać ID. Nie jest to, coś co byśmy chcieli uzyskać.

Więc zajmijmy się tym.

Zabezpieczamy program

Zabezpieczenie w tym przypadku polega na sprawdzeniu, czy użytkownik, który wykonuje operację ma prawo do wykonania tej operacji na danym zasobie. Czyli w przypadku tej aplikacji – czy jest właścicielem danego zasobu.

Oczywiście można to zrobić na kilka sposobów, jednak pokażę Ci tutaj standardowy mechanizm .NET, który to zadanie ułatwia.

Krok 1 – dodawanie wymagań

Pierwszy krok jest zarówno najprostszy, jak i najcięższy do zrozumienia. Musimy dodać wymaganie (requirement). To wymaganie musi zostać spełnione, żeby użytkownik mógł przeprowadzić operację.

To wymaganie może wyglądać tak:

public class TodoItemOwnerOrSuperAdminRequirement: IAuthorizationRequirement
{

}

Zapytasz się teraz – dlaczego ta klasa jest pusta? Jaki jest jej sens? To wytłumaczyć najtrudniej. Generalnie interfejs IAuthorizationRequirement nie ma w sobie żadnych metod, właściwości… zupełnie niczego. Jest pusty. Służy głównie tylko do opisania wymagania. Samego zaznaczenia odpowiedniej klasy. Oczywiście nikt Ci nie zabroni dodać do tej klasy jakiejś logiki. Możesz też ją wstrzykiwać do swoich serwisów.

Krok 2 – dodawanie AuthorizationHandler

Drugim krokiem jest dodanie handlera, który sprawdzi, czy użytkownik może wykonać daną operację. Prosty przykład w naszej aplikacji:

public class TodoItemAuthHandler : AuthorizationHandler<TodoItemOwnerOrSuperAdminRequirement, TodoItem>
{
    private readonly LoggedUserProvider _loggedUserProvider;

    public TodoItemAuthHandler(LoggedUserProvider loggedUserProvider)
    {
        _loggedUserProvider = loggedUserProvider;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, 
        TodoItemOwnerOrSuperAdminRequirement requirement, 
        TodoItem resource)
    {
        var loggedUser = await _loggedUserProvider.GetLoggedUser();
        if(resource.OwnerId == loggedUser.Id)
            context.Succeed(requirement);
        else
            context.Fail();
    }
}

Twoja klasa musi dziedziczyć po AuthorizationHandler. AuthorizationHandler jest abstrakcyjną klasą generyczną. W parametrach generycznych przyjmuje typ wymagania, a także typ resource’a. Jest jeszcze druga jej postać, która przyjmuje tylko typ wymagania.

Musisz przesłonić tylko jedną metodę – HandleRequirementAsync. W parametrze AuthorizationHandlerContext dostajesz m.in. zalogowanego użytkownika (ClaimsPrincipal). Ja się posługuję swoim serwisem LoggedUserProvider ze względu na prostotę (w przeciwnym razie musiałbym jakoś odczytywać i zapisywać claimsy). W parametrze dostajesz również obiekt, o który pytasz.

I jeśli spojrzysz teraz do ciała tej metody, zobaczysz że sprawdzam, czy zalogowany użytkownik jest właścicielem danego zasobu. Normalnie sprawdzałbym, czy zalogowany użytkownik jest superadminem lub właścicielem zasobu. Ze względu na prostotę, pominęliśmy tutaj aspekt ról i superadmina.

I teraz, jeśli użytkownik jest superadminem lub właścicielem zasobu, przekazuję do kontekstu sukces. W przeciwnym razie blokuję.

Krok 3 – użycie AuthorizationHandler

W pierwszej kolejności musimy zarejestrować naszą klasę AuthorizationHandler, żeby móc jej używać. Rejestrujemy to oczywiście podczas rejestracji serwisów:

services.AddScoped<IAuthorizationHandler, TodoItemAuthHandler>(); //może być jako singleton, jeśli Twój serwis nie wykorzystuje innych scoped serwisów

A potem już tylko mały zastrzyk do serwisu (plik SecuredTodoItemService.cs). Wstrzykujemy interfejs IAuthorizationService:

private readonly IAuthorizationService _authService;

public SecuredTodoItemService(IAuthorizationService authService)
{
    _authService = authService;
}

I spójrz na przykładowe użycie:

public async Task ModifyItem(TodoItem item)
{
    var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, item,
        new TodoItemOwnerOrSuperAdminRequirement());
    if(authResult.Succeeded)
    {
        _db.TodoItems.Update(item);
        await _db.SaveChangesAsync();
    }
}

_httpContextAccessor to oczywiście wstrzyknięty IHttpContextAccessor, bo metoda AuthorizeAsync niestety wymaga od nas przekazania zalogowanego użytkownika (on się później znajdzie w kontekście HandleRequirementAsync z klasy dziedziczącej po AuthorizationHandler).

Generalnie klasa implementująca IAuthorizationService została zarejestrowana automatycznie podczas rejestrowania autoryzacji (AddAuthorization()). Gdy wywołujesz AuthorizeAsync, ona sprawdza typ zasobu i wymaganie, o które pytasz. Na tej podstawie wywołuje metodę HandleRequirementAsync z odpowiedniej klasy dziedziczącej po AuthorizationHandler. A ich możesz mieć wiele. Dla różnych zasobów i różnych wymagań.

Jaki z tego wniosek? Wystarczy, że napiszesz jedną klasę pod konkretny typ zasobu, który chcesz chronić.

Dodatkowe uproszczenie

Oczywiście to można jeszcze bardziej ukryć/uprościć, tworząc przykładową klasę ResourceGuard, np:

public class ResourceGuard
{
    private readonly IAuthorizationService _authService;
    private readonly IHttpContextAccessor _httpCtx;

    public ResourceGuard(IAuthorizationService authService, IHttpContextAccessor httpCtx)
    {
        _authService = authService;
        _httpCtx = httpCtx;
    }

    public async Task<AuthorizationResult> LoggedIsAuthorized<T>(object resource)
        where T: IAuthorizationRequirement, new()
    {
        var requirement = new T();
        var user = _httpCtx.HttpContext.User;

        //tu możesz sprawdzić, czy user jest super adminem albo pójść dalej:

        return await _authService.AuthorizeAsync(user, resource, requirement);
    }
}

Wykorzystanie takiej klasy byłoby już dużo łatwiejsze:

public async Task DeleteItem(TodoItem item)
{
    var authResult = await _guard.LoggedIsAuthorized<TodoItemOwnerOrSuperAdminRequirement>(item);
    if (!authResult.Succeeded)
        return;
    else
    {
        //todo: usuń
    }
}

Gdzie wstrzykujesz już tylko ResourceGuard'a.

Moim zdaniem, jeśli masz dużo zasobów do chronienia, pomysł z ResourceGuardem jest lepszy, ale to oczywiście wszystko zależy od konkretnego problemu.

Pobieranie danych

A jak sprawdzić autoryzację przy pobieraniu danych? Tutaj trzeba odwrócić kolejność. Do tej pory najpierw sprawdzaliśmy autoryzację, a potem robiliśmy operacje na danych.

W przypadku pobierania musisz najpierw pobrać żądane dane, a dopiero potem sprawdzić autoryzację, np.:

public async Task<TodoItem> GetItemById(int id)
{
    var result = await _db.TodoItems.SingleOrDefaultAsync(x => x.Id == id);
    if (result == null)
        return null;

    var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, result,
        new TodoItemOwnerOrSuperAdminRequirement());
    if (authResult.Succeeded)
        return result;
    else
        return null;
}

Jeśli musisz pobrać całą listę, to być może będziesz musiał sprawdzić każdy rekord z osobna. I potem w zależności od analizy biznesowej – albo zwracasz tylko te rekordy, do których użytkownik może mieć dostęp, albo gdy nie ma dostępu przynajmniej do jednego – nie zwracasz niczego.

UWAGA!

Zwróć uwagę, że w przykładowym projekcie, jeśli użytkownik nie ma uprawnień do wykonania operacji to albo jej nie wykonuję, albo zwracam null. W rzeczywistym projekcie dobrze jednak jest w jakiś sposób poinformować kontroler, żeby odpowiedział błędem 403 - Forbidden.


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

Daj znać w komentarzu jaką formę artykułów wolisz – taką z gotowym projektem na GitHub, który omawiam – tak jak tutaj, czy klasyczną, w której tworzymy projekt od nowa z pominięciem GitHuba.

Obrazek z artykułu: Tkanina plik wektorowy utworzone przez storyset – pl.freepik.com

Podziel się artykułem na: