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

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

Wstęp

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

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

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

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

Na szybko

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

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

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

Po co ponawiać żądania?

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

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

Wewnętrzny błąd serwera

Czyli kody odpowiedzi 5xx.

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

Throttling

Kod 429: Too Many Requests.

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

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

Stare, dobre 404.

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

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

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

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

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

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

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

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

Co jaki czas ponawiać request?

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

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

Weź pod uwagę kilka rzeczy:

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

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

Lepiej zrobić chociażby coś takiego:

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

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

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

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

Jitter

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

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

Exponential backoff

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

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

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

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

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

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

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

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

Przywitaj Polly

Jest taki Nuget:

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

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

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

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

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

Generalnie to mówi tyle:

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

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

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

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

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

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

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

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

Rozszerzenia do Polly

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

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

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

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

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

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

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

Ale można jeszcze prościej… 😉

Integracja .NET z Polly

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

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

Teraz, gdy rejestrujesz swojego klienta Http:

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

możesz dodać swoją politykę Polly:

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

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

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

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

public class IndexModel : PageModel
{
    private readonly HttpClient _httpClient;

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

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

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

Można jeszcze prościej

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

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

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

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

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

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

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

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


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

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

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

Badaj swoje API, czyli healthcheck

Spis treści

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

Wstęp

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

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

Czym jest Healthcheck?

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

Po co to właściwie?

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

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

Przykładowa apka

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

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

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

Konfiguracja healthcheck

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

builder.Services.AddControllers();

//healthcheck
builder.Services.AddHealthChecks();

var app = builder.Build();

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

app.UseAuthorization();

app.MapHealthChecks("/_health");

app.MapControllers();

app.Run();

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

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

Healthy

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

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

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

Jak działa mechanizm healthcheck?

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

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

Badanie standardowych serwisów

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

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

Sprawdzanie bazy danych

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

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

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

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

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

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

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

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

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

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

Sprawdzanie niestandardowe

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

Utworzenie klasy do sprawdzenia zdrowia

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

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

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

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

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

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

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

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

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

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

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

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

Pokazywanie większej ilości informacji

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

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

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

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

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

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

Tutaj nie zadziałał serwis do prognoz.

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

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

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

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

Dodatkowe możliwości

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

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

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

Zabezpieczenie healthchecka – uwierzytelnianie

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

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

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

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

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

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

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

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

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

Filtrowanie healthchecków i kilka końcówek

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

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

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

Tutaj stworzyłem dwie końcówki:

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

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

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

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

Dodanie endpointa do Swaggera

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

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

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

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

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

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

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

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

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

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

        return result;
    }
}

No i musimy go zarejestrować podczas rejestracji Swaggera:

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

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


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

Podziel się artykułem na:
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:
Konwersja SVG do plików graficznych

Konwersja SVG do plików graficznych

Wstęp

Czasem bywa tak, że potrzebujemy skonwertować plik SVG do jakiegoś innego formatu graficznego (chociażby PNG). Niestety .NET nie daje tego w standardzie.

Co to SVG – dla zainteresowanych

SVG to Scalable Vector Graphics. Po ludzku jest to wolny format grafiki WEKTOROWEJ. Wolny w tym znaczeniu, że nie leżą na nim żadne licencje. Grafika wektorowa to po prostu zapis elementów z jakich składa się rysunek. Czym to się różni od grafiki rastrowej?

Weźmy najprostszy przykład – BMP. Tutaj każdy piksel jest zapisany osobno. Każdy piksel ma swój kolor. Minusem takiego rozwiązania jest to, że takiej grafiki nie możemy powiększać (skalować). W pewnym momencie po prostu zrobi się „pikseloza”.

Oczywiście są pewne sposoby, żeby sobie z tym poradzić. Od aproksymacji po sztuczną inteligencję (która całkiem dobrze sobie z tym radzi).

Natomiast jeśli chodzi o format SVG (wektorowy), to nie zapisujemy w nim wartości poszczególnych pikseli, tylko pełne obiekty. Np. „linia” lub „okrąg”. Obiekty są zapisane w sposób wektorowy, dzięki czemu teoretycznie możemy takie grafiki skalować w nieskończoność.

Konwersja SVG do innych formatów

Tak jak już mówiłem, .NET nie ma tego w standardzie. Istnieją jednak różne biblioteki do takiej konwersji, jednak wszystkie one są płatne. No i tu wchodzi SeViGo.

SeViGo to DARMOWE narzędzie mojego autorstwa, które konwertuje podane pliki SVG do wybranych formatów graficznych.

Jak korzystać z SeViGo?

Jeśli potrzebujesz skonwertować po prostu jeden, czy dwa pliki ad hoc, wejdź na stronę https://sevigo.eu i użyj tam wbudowanego konwertera.

Jednak fajniejszą opcją jest możliwość użycia konwertera w Twoim kodzie. W jaki sposób? SeViGo udostępnia API do konwersji. Jedyne, co musisz zrobić to:

  1. Zarejestrować swojego klienta API na: https://sevigo.eu/Api

Jak widzisz, wystarczy że podasz swój adres e-mail i jakąś nazwę. Może to być nazwa Twojej firmy, chociaż dużo bardziej polecam podać tam coś, co jednoznacznie określi Twój produkt. Np. nazwę Twojego projektu.

Po rejestracji dostajesz klucz do API i możesz działać.

  1. Po rejestracji pobierz NuGeta, którego przygotowałem: dotnet add package nerdolando-svg-api-client --version 1.0.2
  2. Dodaj do konfiguracji aplikacji info o otrzymanym po rejestracji kluczu:
"SevigoApiOptions": 
{ 
    "ApiKey": "<twój klucz>"
}
  1. Na koniec zarejestruj serwisy potrzebne do obsługi SeViGo:
services.AddSevigo(Configuration.GetSection("SevigoApiOptions");

A samo użycie biblioteki jest banalne. Wystarczy, że dodasz MainApiClient w konstruktorze swojego serwisu. Odpowiedni obiekt zostanie wstrzyknięty przez dependency injection. Następnie wystarczy, że wywołasz metodę ConvertSvg:

public MyService(MainApiClient sevigoApiClient)
{
	_sevigoApiClient = sevigoApiClient;
}

public async Task ConvertSvg(string svgContent)
{
	var request = new ConvertFileRequest();
	request.SvgContent = svgContent;
	request.ExportType = ExportType.PNG; //lub inny typ
	
	var response = await _apiClient.ConvertSvg(request);
}

SvgContent to oczywiście zawartość Twojego pliku SVG. Pamiętaj, że SVG jest plikiem tekstowym, więc możesz go odczytać przez zwykłe File.ReadAllText i tą zawartość tutaj przekazać.

W odpowiedzi dostaniesz informacje, czy konwersja się powiodła, a także link do skonwertowanego pliku, który możesz pobrać. Tylko uwaga! Plik zostaje usunięty z serwera po 7 dniach.

UWAGA

SeViGo jest hostowane na Azure Container Apps, które (żeby ograniczyć koszty) skaluje się do zera. To oznacza, że aplikacja może nie być uruchomiona w jakimś czasie. I pierwszy strzał do niej może się nie powieść. Aby mieć pewność, że aplikacja będzie odpowiednio „wybudzona”, możesz wykonać strzał na końcówkę health:

await _apiClient.CallHealthCheck();

Czy to jest za darmo?

Tak, SeViGo na ten moment jest zupełnie za darmo. Jeśli jednak zainteresowanie będzie odpowiednio wysokie, wtedy mogą pojawić się jakieś ograniczenia. Ale spokojnie, jeśli coś miało by się zmienić, wszyscy zostaną o tym powiadomieni 🙂


To tyle, daj znać w komentarzu, czy używasz, czy Ci się podoba i co być potrzebował więcej.

Obrazek wyróżniający: Obraz autorstwa Freepik

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:

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:
Rekordy w C# – jak i kiedy używać

Rekordy w C# – jak i kiedy używać

Wstęp

Rekordy weszły do C# w wersji 9. Obok struktur i klas stały się trzecim „ogólnym” typem. Chociaż są podobne zarówno do klas jak i struktur, to jednak dostarczają pewnych mechanizmów, które mogą przyspieszyć i ułatwić pisanie.

Zanim przeczytasz ten artykuł, powinieneś znać dokładne różnice między klasami i strukturami, stosem i stertą. Na szczęście opisałem je w tym artykule 🙂

Czym właściwie jest rekord?

Rekord to typ danych, który może zachowywać się tak jak struktura (być typem wartościowym) lub klasa (być typem referencyjnym). Jednak jasno musimy to określić podczas jego deklaracji. Rekordy rządzą się też pewnymi prawami. Gdy tworzymy rekord, kompilator dodaje automatycznie kilka elementów. O tym za chwilę. Najpierw spójrz jak rekordy się deklaruje.

Jest kilka możliwości deklaracji rekordu, najprostszą z nich jest:

public record MyRecord(int Id, string Name);

I tyle. Jedna linijka. Czy to nie jest piękne? Gdyby to przetłumaczyć na deklarację klasy, otrzymalibyśmy mniej więcej coś takiego:

public class MyRecord
{
    public int Id { get; init; }
    public string Name { get; init; }

    public MyRecord(int id, string name)
    {
        Id = id;
        Name = name;
    }

    public static bool operator==(MyRecord x, MyRecord y)
    {
         //o tym później
    }

    public static bool operator!=(MyRecord x, MyRecord y)
    {
        return !(x == y);
    }

    public override bool Equals(object? obj)
    {
        //o tym później
    }

    public void Deconstruct(out int id, out string name)
    {
         id = Id;
         name = Name;
        //o tym później
    }
}

A to i tak tylko szkielet 🙂

Jak można z tego korzystać? Dokładnie tak samo jak z każdej innej klasy:

static void Main(string[] args)
{
    var rec = new MyRecord(5, "Adam");
}

Jednak, jak wspomniałem wcześniej, rekord to coś więcej niż tylko cukier składniowy na klasę, czy strukturę. Gdy deklarujemy typ rekordowy, kompilator dokłada co nieco od siebie:

Porównanie wartościowe

Rekordy są automatycznie wyposażone w operatory ==, != i metodę Equals. Te elementy porównują dwa rekordy wartościowo. Tzn., że rekordy są takie same wtedy i tylko wtedy, gdy:

  • są tego samego typu
  • WARTOŚCI wszystkich właściwości są takie same.

Zwróć uwagę na słowo „WARTOŚCI”. Jeśli porównujesz ze sobą dwa obiekty, one domyślnie są porównywane referencyjnie. Czyli dwa obiekty będą takie same, jeśli wskazują na to samo miejsce w pamięci. Przy rekordach porównywane są domyślnie WARTOŚCI wszystkich pól.

Jednak pamiętaj, że to muszą być typy wartościowe. Jeśli rekord będzie zawierał klasę, to referencje obiektów tych klas zostaną ze sobą porównane (można powiedzieć, że wartością klasy jest referencja). Lepiej to wygląda na przykładzie. Porównajmy rekordy, które mają te same dane, których wartości można porównać:

var rec = new MyRecord(5, "Adam");
var rec2 = new MyRecord(5, "Adam");

Console.WriteLine(rec == rec2); // TRUE

Dla przykładu, jeśli to byłyby klasy, zostałyby porównane referencyjnie, a nie wartościowo:

var c1 = new MyClass //KLASA
{
    Name = "Adam"
};

var c2 = new MyClass //KLASA
{
    Name = "Adam"
};

Console.WriteLine(c1 == c2); // FALSE

Jednak gdy teraz dodamy klasę (typ referencyjny) do rekordu i będziemy chcieli porównać:

public record MyRecord(MyClass data);

//

var c1 = new MyClass
{
    Name = "Adam"
};

var c2 = new MyClass
{
    Name = "Adam"
};

var rec1 = new MyRecord(c1);
var rec2 = new MyRecord(c2);
Console.WriteLine(rec1 == rec2); //FALSE

No to obiekty c1 i c2 zostaną jednak porównanie referencyjnie i te dwa rekordy będą różne.

Czyli – 2 rekordy są takie same, gdy są tego samego typu i WARTOŚCI ich pól są takie same. W przypadku klasy, można powiedzieć, że jej wartością jest ADRES, na jaki wskazuje na stercie.

Niezmienialność (Immutability)

Rekordy są domyślnie niezmienialne (jest od tego wyjątek). Jeśli tak zadeklarujesz rekord:

public record Person(string Name);

to po utworzeniu obiektu tego rekordu nie będziesz mógł już go zmienić. Czyli wszystkie dane przekazujesz w konstruktorze i dalej nie można już niczego modyfikować.

Ale to nie dotyczy rekordów „strukturowych”, o których za chwilę.

Dekonstrukcja rekordów

Deklarując rekord, kompilator automatycznie tworzy też specjalną metodę Deconstruct, która w C# ma konkretne znaczenie. Służy do… dekonstrukcji obiektu 🙂 Dekonstrukcja jest znana z Tupli. O co chodzi? Spójrz na to:

public record struct Person(int Id, string FirstName, string LastName);

//
var p1 = new Person(1, "Adam", "Jachocki");
var (id, fName, lName) = p1;

Przyjrzyj się temu kodowi, bo jeśli nie słyszałeś o dekonstrukcji, to może wydawać się błędny. Ale jest zupełnie poprawny. Teraz wszystkie wartości z obiektu trafią do poszczególnych zmiennych: id, fName i lName. Osobiście bardzo uwielbiam dekonstrukcję.

Metoda ToString()

Rekordy mają również predefiniowaną metodę ToString, która zwraca wszystkie wartości w takiej postaci:

Person { Name = Adam, Age = 38 }

Różne rodzaje rekordów

Rekordy ze względu na swoją specyfikę, mogą zachowywać się na trzy różne sposoby. Chociaż wszystkie gwarantują porównywanie wartościowe i inne te rzeczy, o których pisałem wyżej, to jednak są pewne minimalne różnice.

Rekord klasowy (record)

Deklarujesz go w ten sposób:

public record Person(string Name);

//od C#10 możesz napisać również
//public record class Person(string Name);

Wyróżnia się tym, że jest tworzony na stercie. Jest domyślnie niezmienialny. Ma tylko jeden konstruktor.

Rekord strukturowy (record struct)

Deklarujesz go w taki sposób:

public record struct Person(string Name);

Wyróżnia się tym, że jest tworzony na stosie. Domyślnie jest zmienialny – możesz nadpisywać właściwości ile tylko chcesz. Ma też dwa konstruktory – ten z parametrami i domyślny – bezparametrowy.

Rekord strukturowy tylko do odczytu (readonly record struct)

Deklarujesz go tak:

public readonly record struct Person(string Name);

Wyróżnia się tym, że jest tworzony na stosie. Domyślnie jest NIEZMIENIALNY. Jednak kompilator tworzy dla niego dwa konstruktory – domyślny (bezparametrowy) i ten z parametrami.

Jeśli używasz konstruktora bezparametrowego, jedyną opcją na przypisanie wartości poszczególnym polom, jest przypisane podczas tworzenia obiektu:

var p = new Person { Name = "Adam" };

Dodatkowe pola w rekordzie

W rekordach możesz umieszczać dodatkowe pola i metody. Robisz to w sposób standardowy:

//Deklaracja
public readonly record struct Person(string Name)
{
    public int Age { get; init; }   
}

//użycie
var p1 = new Person("Adam") { Age = 38 };

Age jest zadeklarowane z setterm inicjującym. Dlatego możesz nadać mu wartość tylko w trakcie tworzenia obiektu.

Rekordy mogą mieć też normalne metody.

Dziedziczenie

Rekordy mogą dziedziczyć po innych rekordach. Ale nie mogą dziedziczyć po klasach, a klasy nie mogą dziedziczyć po rekordach. Dotyczy to jednak tylko rekordów klasowych. Rekordy strukturowe nie dziedziczą. Rekordy klasowe mogą też implementować interfejsy:

public interface IIface
{
    void Foo();
}
public record Person(string Name);
public record Emloyee(int DeptId, string Name) : Person(Name), IIface
{
    public void Foo()
    {
        throw new NotImplementedException();
    }
}

Kopiowanie rekordów

Fajną sprawą jest kopiowanie rekordów. W bardzo łatwy sposób można stworzyć rekord na podstawie istniejącego. Służy do tego słowo with:

var p1 = new Person("Adam") { Age = 38 };

var p2 = p1 with { Name = "Janek" };

Teraz obiekt p2 będzie miał Name ustawiony na Janek, a Age na 38.

Pamiętaj jednak, że jeśli w rekordzie jest pole z klasą, to wtedy zostanie skopiowana referencja do tego obiektu, a nie jego wartości. Czyli mając taki program:

//deklaracja
public class MyClass
{
    public int Id { get; set; }
}
public readonly record struct Person(string Name, MyClass Data)
{
    public int Age { get; init; }   
}

//użycie
var c1 = new MyClass { Id = 1 };
var p1 = new Person("Adam", c1) { Age = 38};

var p2 = p1 with { Name = "Janek" };
c1.Id = 10;

pamiętaj, że klasa w strukturach wskazuje na to samo miejsce na stercie. Czyli p2.Data.Id będzie również równe 10.

Kiedy używać rekordów

Z zasady, jeśli potrzebujesz danych, które są:

  • porównywane wartościowo
  • niezmienialne

Rekordy są idealnym kandydatem dla bindingu w kontrolerach. Chociaż ja głównie ich używam, jeśli mój serwis musi zwrócić do kontrolera więcej niż jedną wartość.


To tyle, jeśli chodzi o rekordy. Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz już znasz różnice między typami rekordów i, że będziesz ich używał bardziej świadomie. Albo w ogóle zaczniesz 🙂

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

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

Z pogranicza – używamy DLL pisanej w C++ – część 1.

Wstęp

Czasem bywa tak, że trzeba użyć DLL pisanej w innym języku. Sprawa jest dość prosta, jeśli to są języki „natywne”, jak np. C, C++, czy Pascal. Jednak podczas używania natywnej biblioteki w .NET czeka na nas kilka niespodzianek.

Ze względu na ogrom informacji i możliwości, podzielę ten artykuł na kilka części. W tej części ogólny zarys działania mechanizmu, przekazywanie typów prostych i stringów.

W artykule przedstawię głównie praktykę. Teorii jest naprawdę sporo, a znając praktyczne metody radzenia sobie z tym problemem, teoria niewiele daje. Jeśli jednak naprawdę chcecie wiedzieć dokładnie dlaczego tak, a nie inaczej i teoria zawarta w tym artykule jest dla Was niewystarczająca, dajcie znać w komentarzu 🙂

Za każdym razem, gdy piszę o funkcji C++, mam na myśli funkcję pisaną w dowolnym języku niskiego poziomu (niezarządzanego) typu C, C++, Pascal.

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

Czym się różni funkcja od metody

Jeśli nigdy nie miałeś do czynienia z programowaniem w innych językach niż C#, możesz nie znać różnicy między funkcją a metodą. Można powiedzieć, że metoda to funkcja będąca składnikiem klasy. W innych językach nie wszystko musi być klasą, wobec czego funkcja może być umieszczona poza jakąkolwiek klasą.

Rozróżnienie w tym artykule jest istotne, ponieważ, aby można było używać DLL napisanej w C++, w kodzie muszą zostać wyeksportowane poszczególne funkcje (z klasami też w prawdzie się da, ale to jest już zupełnie inny mechanizm na zupełnie inny cykl).

Jak to działa z grubsza?

Mamy dwa światy – kody zarządzane (managed) i niezarządzane (unmanaged). Zarządzane to te pisane przykładowo w C#. Niezarządzane to natywne – może być to C++, Pascal itp.

Jak się pewnie domyślasz te dwa różne światy dzieli pewna przepaść. Powstał więc mechanizm marshalingu jako pomost między nimi.

Co to ten Marshaling?

Wyobraź sobie Marshaling jako proces przekształcania danych między różnymi formatami. Jeśli przykładowo wysyłasz żądanie HTTP z jakimś obiektem, to ten obiekt jest przekształcany na format JSON, a po drugiej stronie przekształcany z JSON na jakąś inną klasę (być może nawet w innym języku). Można powiedzieć, że to też pewna forma Marshalingu. Chociaż w przypadku programowania międzyplatformowego oczywiście mówimy o czymś bardziej skomplikowanym.

Ale wszystko sprowadza się właśnie do tego. Mechanizm marshalingu po prostu bierze kilka bajtów z pamięci i konwertuje je (przekształca) na odpowiedni typ danych.

Co to Platform Invoke (P/Invoke)?

To jest mechanizm (w C# wyrażany za pomocą atrybutów), który pozwala na używanie funkcji ze świata niezarządzanego w środowisku .NET. Częścią tego mechanizmu jest Marshaling.

Co się dzieje, gdy wywołujemy funkcję z C++?

Mechanizm P/Invoke wkracza do akcji i:

  1. Szuka biblioteki DLL, która zawiera wymaganą funkcję
  2. Ładuje bibliotekę do pamięci (tylko przy pierwszym wywołaniu)
  3. Znajduje adres w pamięci, w którym zaczyna się wywoływana funkcja
  4. Kładzie (push) argumenty na stosie, robiąc marshaling (marshaling tylko przy pierwszym wywołaniu)
  5. Uruchamia funkcję znajdującą się pod znalezionym adresem.
  6. Jeśli funkcja coś zwraca, wtedy P/Invoke znów dokonuje marshalingu (jeśli trzeba) i zwraca te dane do kodu zarządzanego (w uproszczeniu).

Lokalizowanie funkcji

Mangling

Każda funkcja w DLL ma swój numer lub nazwę. Niestety to wcale nie musi być nazwa jakiej się spodziewamy. Istnieje mechanizm manglingu, szczególnie istotny w C++. Polega to na tym, że do funkcji dodaje się prefix i/lub sufix, którego zadaniem jest utworzenie jednoznaczej nazwy funkcji. Załóżmy, że mamy dwie funkcje:

void foo();
void foo(int a);

Teraz mangling bierze pod uwagę parametry i typy zwracane poszczególnych funkcji i ostatecznie mogą one zostać wyeksportowane pod takimi nazwami (zupełnie przykładowo):

foo@_v1
foo@_vi2

Od czego zależy mangling? Chociażby od tego, czy funkcje eksportujesz jako funkcje C, czy C++. W przypadku eksportu funkcji jako C, mangling nie jest wykorzystywany. Dzięki czemu funkcje w DLL mają dokładnie takie nazwy, jakich się spodziewamy.

Kolejność parametrów i odpowiedzialność

Jak się domyślasz, kolejność parametrów jest cholernie ważna. Ale też może być różna. Co więcej, czasem za niszczenie parametrów na stosie odpowiedzialny jest caller, a czasem biblioteka, którą wywołujemy. To wszystko zależy od konwencji wywołania funkcji.

Mamy w programowaniu kilka konwencji:

  • stdcall – funkcja w bibliotece jest odpowiedzialna za zniszczenie parametrów na stosie. Parametry są przesyłane w odwrotnej kolejności (od prawej do lewej). Głównie świat Windows.
  • cdecl – kod wywołujący (caller) jest odpowiedzialny za zniszczenie parametrów na stosie. Parametry są przesyłane od lewej do prawej. Głównie świat Unix

Są jeszcze inne konwencje wywołań, ale nie będę o nich pisał, bo nie są istotne dla tego artykułu. Jeśli Cię to interesuje, poczytaj o: fastcall, thiscall, vectorcall, syscall.

Zatem, żeby możliwe było używanie funkcji z C++, konwencje wywołań muszą być jednakowe zarówno w bibliotece jak i w kodzie wywołującym. Jeśli będą inne – wszystko wybuchnie i posypią się meteoryty.

Zestaw znaków (Charset)

Funkcje WinApi (i niektórych frameworków) można podzielić m.in. na użycie zestawu znaków. Jedne używają czystego ANSI, a drugie Unicode (WideString). To jest istotne, ponieważ te, używające ANSI są zakończone literką A. Te drugie – W. Np.:

int MessageBoxA(const char * pMessage); //używa ANSI
int MessageBoxW(const wchar_t * pMessage); //używa Unicode

Jeśli zatem używasz funkcji, która przyjmuje w parametrze jakieś stringi, wtedy istotne jest, abyś określił zestaw znaków w mechanizmie P/Invoke. To pomaga na wyszukanie odpowiedniej wersji funkcji, a poza tym na odpowiedni marshaling stringów. Chociaż to jest trochę głębszy temat, do którego wrócimy później. Tak, mechanizm P/Invoke może szukać funkcji o nazwie FooA, zamiast Foo, jeśli określisz Charset na ANSI (analogicznie może szukać FooW, jeśli określisz Charset na Unicode).

Ok, czas pobrudzić sobie ręce…

Jedziemy z koksem

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

Budowanie solucji

Żeby móc zbudować tę solucję, musisz mieć zainstalowane narzędzia do budowania C++. Biblioteka używa toolseta v143 (VisualStudio 2022). Musisz mieć w Visual Studio Installer zaznaczone „Programowanie aplikacji klasycznych w C++”:

Uruchamianie i debugowanie

Zaznacz w Visual Studio konfigurację Debug x64, a jako projekt startowy – CsClient:

Zwróć też uwagę na plik launchSettings.json w projekcie CsClient. Powinien wyglądać tak:

{
  "profiles": {
    "CsClient": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

Dzięki temu możliwe będzie debugowanie kodu C++ z aplikacji pisanej w C#.

Zacznijmy od czegoś prostego…

Podstawy

Przede wszystkim pamiętaj, że dobrze jest stworzyć sobie klasę w C#, która będzie odwoływać się do funkcji eksportowanych z biblioteki. Tutaj rolę tej klasy przejmuje LibraryClient.

Weź też po uwagę, że w rzeczywistym świecie niektóre biblioteki mogą nie nadawać się do importu. Nie eksportują żadnych funkcji albo eksportują je z natywnymi typami danych (np. std::wstring). Lub też eksportują całe klasy (jeśli to jest mechanizm COM, to można z tym sobie poradzić, ale to temat na zupełnie inny artykuł).

Pierwsza klasa

Na początek spójrzmy na kod w C++ – plik library.h. To jest plik nagłówkowy, w którym są umieszczane deklaracje funkcji. Zobacz, że są w bloku extern "C", dzięki czemu nie ma manglingu. Każda z nich jest eksportowana w konwencji stdcall. Teraz spróbujmy użyć pierwszej:

DLL_EXPORT int __stdcall add(int a, int b);

Ta funkcja dodaje do siebie dwa inty i zwraca ich sumę.

Stwórzmy teraz klasę wrappera w C#:

internal class LibraryClient
{
    [DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
    private static extern int add(int a, int b);

    public int Add(int a, int b)
    {
        return add(a, b);
    }
}

Zobacz teraz, co się dzieje. W linijce 3 i 4 używamy mechanizmu P/Invoke. Mówimy mu: „Hej, używam metody add z biblioteki CppDll.dll z konwencją StdCall„. Mechanizm P/Invoke wie jakiej funkcji szukać, ponieważ taką nazwę przekazałeś w linijce 4. Oczywiście jest też parametr atrybutu DllImportEntryPoint, w którym możesz przekazać inną nazwę funkcji.

Tutaj nie ma żadnego problemu. Nie ma też marshalingu. Dlatego, że typ int jest tzw. typem kopiowalnym (blittable type). O tym za chwilę.

Co się dzieje:

  • P/Invoke ładuje bibliotekę CppDll.dll do pamięci
  • P/Invoke szuka adresu funkcji o nazwie add
  • P/Invoke kopiuje na jej stos parametry b, a
  • P/Invoke uruchamia funkcję
  • Po stronie C++ parametry są zdejmowane ze stosu, robione na nich obliczenia, następnie na stos jest zwracany wynik. C++ zwalnia pamięć używaną przez parametry po swojej stronie.
  • P/Invoke zdejmuje ze stosu wynik funkcji i zwraca go w odpowiedniej postaci: int.

Proste? Proste 🙂

Typy kopiowalne i niekopiowalne

Część typów danych ma wspólną reprezentację zarówno po stronie zarządzanej jak i niezarządzanej. Przykładowo typ int zarówno po stronie C++ jak i C# ma 4 bajty, a te 4 bajty są ułożone w tej samej kolejności. W związku z tym bez problemu można sobie je „kopiować”. Bo znaczą dokładnie to samo. Te typy są kopiowalne. Nie wymagają żadnej konwersji pomiędzy dwoma światami. Należą do nich:

  • System.Byte
  • System.SByte
  • System.Int16
  • System.Int32
  • System.UInt32
  • System.Int64
  • System.UInt64
  • System.IntPtr
  • System.UIntPtr
  • System.Single
  • System.Double
  • Jednowymiarowe statyczne tablice typów kopiowalnych
  • Typy wartościowe składające się z typów kopiowalnych

Teraz pytanie o typy niekopiowalne. One wymagają marshalingu. Czyli jakiejś formy konwersji.

Ważne jest, że referencje do obiektów nie są kopiowalne. Możesz mieć kopiowalną strukturę, ale jeśli masz tablicę referencji do obiektów tej struktury, to ten obiekt nie jest już kopiowalny.

Przesyłanie stringa

Trochę gorzej ze stringami. To jest typ niekopiowalny. Co więcej jest traktowany w specjalny sposób. Zanim w nie wejdziemy, muszę odpowiedzieć na pytanie – czym tak naprawdę jest string?

Czym jest string?

String to tablica znaków zakończona znakiem ASCII #0 (null) lub dwoma takimi znakami w przypadku unicode. W przypadku stringu ANSI na jeden znak przypada jeden bajt. Jednak w przypadku unicode jeden znak jest już kodowany dwoma bajtami (jeśli znak jest typowym znakiem z zakresu ASCII, wtedy drugi bajt ma wartość 0x00). A w niektórych przypadkach – nawet czterema (języki głównie azjatyckie). Spójrz na fragment pamięci z kodu w C++:

To jest najbardziej prawdziwy string. Znaki 0x00 na końcu są potrzebne do tego, żeby komputer wiedział, gdzie ten string się kończy. On nie ma informacji o długości ani o niczym innym. To jest po prostu tablica pojedynczych znaków.

A teraz spójrz na string w C#:

A co tu się dzieje? Co to za awantury?

No właśnie, string w .NET to nie jest prawdziwy string. To jest jakaś klasa, która posiada więcej informacji (chociażby długość stringa). Co więcej, wcale nie musi się kończyć dwoma nullami. Zatem widzisz, że to jest jakaś większa klasa. Coś jak std::wstring w C++ – to jest klasa. Dlatego też, jeśli w parametrze eksportowanej funkcji C++ jest std::string lub std::wstring, taka funkcja nie nadaje się do użytku w .NET (ogólnie poza kodem pisanym w C++ w odpowiedniej wersji biblioteki).

Przesyłanie stringa do C++

Na szczęście przesyłanie stringa do C++ jest banalnie proste.

Od strony C++ wystarczy go przechwycić jako wskaźnik na tablicę znaków:

DLL_EXPORT size_t getStrLen(const wchar_t* pStr);

A od strony C# po prostu wysyłamy go jako zwykły string:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern int getStrLen(string str);

Co ważne, zwróć uwagę, że przekazujemy tutaj CharSet. Jeśli tego nie zrobisz, domyślnie będzie użyty ANSI.

Zwróć uwagę, że do C++ jest w takim wypadku przesyłany dokładnie ten string (dokładnie to miejsce w pamięci na zarządzanej stercie, gdzie zaczyna się tekst).

I teraz bardzo ważne jest, żeby kod C++ używał tego stringa tylko do odczytu. Jeśli w jakiś sposób spróbuje go zmienić, najpewniej skończy się to uszkodzeniem sterty.

Jednak, jeśli po stronie C++ odbieramy ANSI string (char *), wtedy string z C# jest kopiowany do dodatkowego bufora (i konwertowany) i to to miejsce jest przekazywane do C++. Gdy wracamy z funkcji, zawartość tego bufora jest ponownie kopiowana do stringa w C#. Więc jeśli masz dużo takich wywołań, możesz mieć problem z wydajnością. Wtedy spróbuj wysyłać tablicę bajtów zamiast stringa i sprawdź, co się stanie.

Jeśli jednak masz taką opcję, to posłuż się BSTR – o którym piszę niżej.

Pobieranie string z C++

Tutaj jest trochę trudniej. Dla takiego stringa trzeba zaalokować pamięć na stercie i odpowiednio go przekonwertować. Jest kilka sposobów na pobranie stringa z C++. Wszystkie mają swoje plusy i minusy.

Pobieranie jako rezultat

Pobierz i zwolnij

Po stronie C++ musisz skopiować string do miejsca, w którym będzie rezultat.

DLL_EXPORT wchar_t* getInfo()
{
	return _wcsdup(L"Jestem wywołany z C++!"); //C++ tworzy kopię tego stringa i zwraca ją
}

W związku z tym, że C++ utworzył nowy obiekt na stercie, musi też go zwolnić w odpowiednim momencie. Dlatego też biblioteka powinna dać funkcję, która zwolni zaalokowaną pamięć, np.:

DLL_EXPORT void freeInfo(wchar_t* pData)
{
	delete pData;
}

Teraz po stronie C# musisz pobrać ten string jako wskaźnik (w końcu wchar_t * jest wskaźnikiem), potem użyć marshalingu, żeby skonwertować go na string .NETowy, na koniec musisz zwolnić pobrany wskaźnik:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern IntPtr getInfo();

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void freeInfo(IntPtr pData);

//

public string GetInfo()
{
    var ptr = getInfo();
    var result = Marshal.PtrToStringUni(ptr);
    freeInfo(ptr);
    return result;
}

Marshal.PtrToStringUni traktuje dane, na które wskazuje ptr jak string w Unicode. Czyli szuka jego końca (2 znaki null), kopiuje go na stertę zarządzaną i zwraca w postaci .NETowego stringa. Pobrany wskaźnik cały czas wskazuje na miejsce na stercie niezarządzanej, więc trzeba go zwolnić. Dlatego też wywołujemy metodę freeInfo z C++.

Pamiętaj, że za zwolnienie pamięci jest odpowiedzialna biblioteka, która tę pamięć zaalokowała. W tym przypadku C# używa innej biblioteki do zarządzania pamięcią niż C++. Dlatego też to po stronie C++ pamięć musi zostać zwolniona.

Pobieranie bez zwalniania

Jest jednak mechanizm, który zadziała nieco inaczej. Prościej dla programisty C#, jednak od strony C++ wymaga nieco więcej zachodu. To wykorzystuje technologię COM. Nie wchodząc w szczegóły – C# używa tej samej biblioteki do zwolnienia pamięci, co C++ do jej alokacji. Dlatego też w tym przypadku C# może być odpowiedzialny za zwolnienie tej pamięci i tak też się dzieje (zupełnie automatycznie). Od strony C++ wygląda to tak:

DLL_EXPORT wchar_t* __stdcall getInfoWithCom()
{
	std::wstring str = L"Cześć, jestem z COM";
	int allocSize = str.size() * 2 + 2;
	STRSAFE_LPWSTR result = (STRSAFE_LPWSTR)CoTaskMemAlloc(allocSize);
	StringCchCopy(result, allocSize, str.c_str());

	return (wchar_t*)result;
}

Najpierw liczymy, ile pamięci musimy zaalokować. Pamiętaj, że jeden znak stringa unicodowego zajmuje dwa bajty. Dlatego też ilość znaków w string str mnożymy przez dwa. Następnie trzeba dodać jeszcze dwa znaki na zakończenie stringa (to będą te dwa nulle na końcu).

Pamiętaj, że w niektórych przypadkach jeden znak może potrzebować aż 4 bajtów. Jeśli wiesz, że Twoja aplikacja będzie działała na rynku azjatyckim i nie znasz długości stringa, dla jakiego chcesz zaalokować pamięć, to lepiej mnóż przez 4.

Następnie dzieje się magia. Używamy funkcji CoTaskMemAlloc do zaalokowania odpowiedniej ilości pamięci. C# pod spodem użyje CoTaskMemFree do zwolnienia tej pamięci. To jest ta sama biblioteka.

Na koniec kopiujemy string str do zaalokowanej pamięci i zwracamy go.

A po stronie C#? Możemy to odebrać jako zwykły string:

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

//

public string GetInfoWithCom()
{
    return getInfoWithCom();
}

Plusy – to widać po stronie C#. Mniej kodu i programiści nie muszą zajmować się pamięcią. Minusy – to widać po stronie C++. Kod musi być odpowiednio napisany, żeby dało się tak zrobić.

Pobieranie z użyciem BSTR

BSTR to specjalna forma stringa, która składa się z trzech części:

  • długość stringu
  • tekst
  • znaki kończące (null)

Zwróć uwagę, że sam BSTR wskazuje na początek tekstu, a nie na 4 wcześniejsze bajty, w których jest zaszyta długość stringu. BSTR jest używany w mechanizmie COM i jest naturalny dla P/Invoke. Jego użycie jest bardzo podobne do tego, co było wyżej, jednak jest prostsze (od strony C++) i zwraca inny typ:

DLL_EXPORT BSTR __stdcall getInfoWithBstr()
{
	return SysAllocString(L"A tak działa BSTR");
}

A w C#:

[DllImport("CppDll.dll", CallingConvention=CallingConvention.StdCall, CharSet = CharSet.Unicode)]
[return:MarshalAs(UnmanagedType.BStr)]
private static extern string getInfoWithBstr();

//

public string GetInfoWithBstr()
{
    return getInfoWithBstr();
}

Tutaj jest istotne, żeby oznaczyć typ zwracany odpowiednim Marshalerem. W tym przypadku BStr. Dzięki temu to C# może zatroszczyć się o zwolnienie pamięci zarezerwowanej przez C++. Ta rezerwacja i zwalnianie też dzieje się przez mechanizm COM.

Pobieranie w parametrze

String można pobierać również przez parametr. W internetach (m.in. Stack Overflow) często widać taki kod jak poniżej. Jest on prosty, ale ma swoje problemy:

Użycie StringBuilder

Po stronie C++ mamy taki kod:

DLL_EXPORT void getInfo2(wchar_t* pData, int strLen)
{
	ZeroMemory(pData, strLen * 2);
	std::wstring result = L"Można i tak...";
	result = result.substr(0, strLen);
	std::copy(result.begin(), result.end(), pData);
}

W parametrze pData przekazujemy gotowe miejsce w pamięci do przyjęcia stringu. Jak duże? O tym mówi parametr strLen. Zarówno miejsce, jak i strLen jest przekazywane przez kod C#. Tylko trzeba pamiętać o dwóch rzeczach, o których powiem jeszcze przy okazji kodu C#.

Musisz przekazać strLen wystarczająco duży, aby pomieścił string, który będzie zwracany. No i parametr strLen musi też wziąć pod uwagę dwa dodatkowe bajty na zakończenie stringa (2 znaki null). W innym przypadku dostaniesz jakieś śmieci. A teraz, co się dzieje w kodzie?

W pierwszej kolejności zerujemy tę pamięć. Chodzi o to, żeby mieć pewność, że te dwa ostatnie znaki (zakończenie stringa) będą nullami. Tak, można by zerować tylko te dwa ostatnie bajty, ale tak jest mi prościej i łatwiej tłumaczyć 🙂

Potem kopiujemy do jakiejś zmiennej stringa – w tym przypadku, jeśli podamy ilość znaków większą niż string faktycznie zawiera, string będzie skopiowany do końca.

Na koniec kopiujemy go w przekazane w parametrze miejsce.

A jak to wygląda od strony C#?

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "getInfo2", CharSet = CharSet.Unicode)]
private static extern void getInfo(StringBuilder data, int length);

//

public string GetInfoWithStrBuilder()
{
    var sb = new StringBuilder(255);
    getInfo(sb, sb.Capacity);

    return sb.ToString();
}

No i tutaj sprawa jest prosta. Najpierw tworzymy StringBuildera odpowiednie dużego, żeby mógł przetrzymać zwracany string. Pamiętamy też o dwóch znakach kończących stringa. Następnie wywołujemy funkcję z tymi parametrami.

Jak dużo pamięci?

Teraz pojawia się pytanie – skąd mam wiedzieć, ile pamięci mam zarezerwować na ten string? Skąd mam znać jego długość?

No to jest odwieczne pytanie programistów C++. Dlatego też powstało kilka mechanizmów, które rozwiązują ten problem. Oczywiście trzeba je samemu zaprogramować.

Pierwszy sposób jest taki, że funkcja C++ getInfo zwraca wartość bool, która mówi, czy string się skończył, czy nie. Wtedy tę funkcję wywołujemy w pętli, przekazując od którego znaku ma być czytany string. Często ten mechanizm zwraca stałą ilość znaków (lub mniej, jeśli już nie ma).

Drugi sposób to taki, gdzie wywołujemy funkcję getInfo z parametrem pierwszym (miejsce w pamięci) ustawionym na null – C++ widzi, że to miejsce nie jest zaalokowane (bo jest nullem) i zwraca długość stringa, jaki wyprodukuje. Wtedy w C# ustawiamy odpowiednio duży bufor. To może wyglądać tak:

DLL_EXPORT int __stdcall getInfo3(wchar_t* pData, int strLen)
{
	std::wstring str = L"A to jest string o nieznanej długości";

	if (pData == nullptr)
		return str.size();

	ZeroMemory(pData, strLen * 2);
	str = str.substr(0, strLen);
	std::copy(str.begin(), str.end(), pData);
	return 0;
}

A po stronie C#:

public string GetInfoWithLen()
{
    int len = getInfoWithLen(null, 0);
    var sb = new StringBuilder(len + 2);
    getInfoWithLen(sb, sb.Capacity);

    return sb.ToString();
}

Tak naprawdę wystarczyłoby dodanie 1 znaku w zaznaczonej wyżej linii zamiast dwóch. Jeden znak w unicode to dwa bajty i o te dwa bajty nam chodzi. Dodałem specjalnie 2, żeby wbić do głowy te dwa bajty kończące string unicodowy.

Problemy

Minusem rozwiązania ze string builderem jest ilość alokacji na stercie. To jest stosunkowo wolna operacja, więc jeśli masz duże stringi i robisz to często, to raczej zauważysz problemy z wydajnością:

  • tworzenie StringBuildera alokuje pamięć na stercie zarządzanej
  • wywołanie funkcji:
    • alokuje bufor na stercie niezarządzanej
    • kopiuje zawartość StringBuildera do sterty niezarządzanej
    • tworzy zarządzaną tablicę i tam kopiuje zawartość natywnego stringa
  • StringBuffer.ToString() tworzy kolejną zarządzaną tablicę.

Także podczas tej operacji mamy 4 alokacje, których można by uniknąć. Dlatego tak mocno powielany sposób ze StringBuilderem uważam za zły. Są inne – lepsze.

Użycie tablicy

Zamiast StringBuildera, możesz użyć tablicy znaków – pamiętając, że string to nic innego niż tablica znaków. Nie ma tutaj za wiele do omówienie, po prostu pokażę kod:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "getInfo3", CharSet = CharSet.Unicode)]
private static extern int getInfoWithArray([Out] char[] data, int length);

//

public string GetInfoWithArray()
{
    int len = getInfoWithArray(null, 0);
    char[] buffer = ArrayPool<char>.Shared.Rent(len + 1);
    getInfoWithArray(buffer, buffer.Length);

    return new string(buffer);
}

Tutaj nie ma już tylu alokacji co przy StringBuilderze, ten kod jest zdecydowanie lepszej jakości.

Użycie BSTR

Już pisałem wcześniej o użyciu BSTR. Mając wiedzę z całego artykułu, możesz na spokojnie wywnioskować taki kod:

DLL_EXPORT void __stdcall getInfo4(BSTR& data)
{
	data = SysAllocString(L"Dzień dobry, jak się masz?");
}

A po stronie C#:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, EntryPoint = "getInfo4")]
private static extern void getInfo([MarshalAs(UnmanagedType.BStr)] ref string data);

//

public string GetInfoFromBstr()
{
    string result = "";
    getInfo(ref result);

    return result;
}

To jest najprostszy i najlepszy sposób pobierania stringa z parametru. Oczywiście nie zawsze biblioteka natywna będzie do tego przygotowana, ale jeśli możesz iść w tym kierunku, to idź. W taki sam sposób możesz przekazać stringa.


Ufff, to na tyle jeśli chodzi o pierwszą część artykułu o połączeniu C++ z C#. W drugiej części zajmiemy się tablicami.

Jeśli znalazłeś w artykule błąd lub czegoś nie zrozumiałeś, koniecznie daj znać w komentarzu. Dzięki za przeczytanie, koniecznie dodaj go do ulubionych, bo kiedyś może Ci się przydać. I do następnego! 🙂

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:
Jak zacząć z EntityFramework (core)

Jak zacząć z EntityFramework (core)

Wstęp

Witaj podróżniku. Skoro już tu jesteś, prawdopodobnie wiesz czym jest EntityFramework. Jeśli jednak nie, to przeczytaj kolejny akapit. W tym artykule postaram szybko wprowadzić Cię w jego świat, żebyś w ciągu kilku minut był w stanie zacząć z nim wspólną przygodę.

Czym jest Entity Framework? – rozwiń jeśli nie wiesz

EntityFramework Core zwany również EfCore to narzędzie typu ORMObject Relational Mapper. Prostymi słowami to mechanizm, który umie zamienić (mapować) rekordy znajdujące się w bazie danych, na obiekty modelowe. Po co to? To jest zwykły „pomagacz”. Bo oczywiście możesz napisać w starym dobrym ADO.NET kod w stylu:

//pseudokod
var resultList = new List<Employee>();
string sql = "SELECT * from employees";
var reader = _db.OpenQuery(sql);
while(reader.Read())
{
    var employee = new Employee();
    employee.Id = reader.GetGuid("id");
    employee.Name = reader.GetString("name");
    employee.Salary = reader.GetDecimal("salary");
    
    resultList.Add(employee);
    reader.Next();
}

return resultList;

ale ostatecznie większość programistów, pisząc w ADO.NET i tak kończyła z jakąś ubogą formą własnego ORMa.

Dlatego powstały właśnie takie narzędzia, żeby ułatwić pracę. Programista wie jak ma pobrać dane i wie jaki obiekt chce zwrócić. Nie musi się zajmować czarną robotą w stylu mapowania tak jak wyżej.

Jest sporo różnych ORMów na rynku. Myślę, że najpopularniejsze z nich (przynajmniej w mojej subiektywnej ocenie) to Dapper, nHibernate i właśnie EfCore. Przy czym Dapper zalicza się do grupy tzw. micro orm. Dlatego, że nie potrafi tyle, co EfCore, czy nHibernate. Wciąż musisz pisać własnego SQLa, ale za to dostajesz obiekt już elegancko zmapowany. No i Dapper jest zdecydowanie szybszy.

Kwestia nazwy

Pewnie zobaczysz nie raz obok siebie nazwy w stylu „Entity Framework”, „EfCore”, „Entity Framework Core”. Czy to jest ten sam produkt? Nie do końca. Jeśli chodzi o „Entity Framework” jest on używany w .NET Framework (czyli w tej starszej technologii przed .NET Core). Natomiast „EfCore”, czy po prostu „Entity Framework Core” to wersja dla .Net Core i nowszych. Czasem możesz widzieć te nazwy zamiennie. W tym artykule, pisząc „Ef”, „Entity Framework” zawsze będę miał na myśli „EfCore”.

Kwestia prędkości

Oczywiście, że ORMy są wolniejsze niż ręczne mapowanie. Jednak to zwolnienie w większości przypadków jest niezauważalne. Jeśli jednak faktycznie w niektórych miejscach potrzebujesz większej szybkości, możesz tylko te miejsca traktować inaczej – bez ORMa.

Instalacja narzędzi EfCore

Na początek sprawdź, czy masz już zainstalowane narzędzia do EF. Wklep taką komendę do command line’a:

dotnet ef --version

Jeśli Ci się wykrzaczyło, znaczy że nie masz. W takim przypadku zainstaluj:

dotnet tool install --global dotnet-ef

Powyższe polecenie zainstaluje globalnie narzędzia do EF, a poniższe zaktualizuje do najnowszej wersji:

dotnet tool update --global dotnet-ef

Przykładowa aplikacja

Jak zwykle, przygotowałem małą przykładową aplikację do tego artykułu. Możesz ją pobrać z GitHuba.

Aplikacja to prosty programik w stylu ToDoList. W ramach ćwiczeń polecam Ci go wykończyć.

Wymagane NuGety:

EfCore nie jest standardową biblioteką. Musisz pobrać sobie minimum dwa NuGety:

  • Microsoft.EntityFrameworkCore – cały silnik dla EfCore
  • Microsoft.EntityFrameworkCore.Design – to jest zestaw narzędzi potrzebny programiście podczas „projektowania” aplikacji. Są to narzędzia, które np. pomagają utworzyć migracje (o tym później). Nie są jednak dostępne w czasie działania aplikacji (runtime). Więc jeśli nie będziesz robił migracji, nie musisz tego instalować. Ale lepiej mieć, niż nie mieć.
  • Biblioteka do obsługi konkretnego typu bazy danych (DBMS) – inna jest dla MSSQL, inna dla Sqlite, inna dla Oracle itd. Ich nazwy określają do jakiej bazy danych się odnoszą, np: Microsoft.EntityFrameworkCore.SqlServer – do obsługi MSSQL.

Modele bazodanowe

Na początek stwórzmy sobie podstawowy model bazodanowy do aplikacji.

public enum ToDoItemStatus
{
    NotStarted,
    Running,
    Done
}

public class ToDoItem: BaseDbItem
{
    public DateTimeOffset? StartDate { get; set; }
    public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
    public DateTimeOffset? EndDate { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
}

Mamy tutaj zwykły ToDoItem, który może być w 3 stanach:

  • Nierozpoczęty (NotRunning)
  • Rozpoczęty (Running)
  • Zakończony (Done)

Zauważysz też, że mój model dziedziczy po BaseDbItem. To zwykła klasa abstrakcyjna, która zawiera Id:

public abstract class BaseDbItem
{
    public Guid Id { get; set; }
}

Oczywiście nie musisz jej tworzyć i możesz umieścić w każdym modelu pole Id. Ja jednak wolę to zrobić, bo to pomaga i może uprościć wiele rzeczy w późniejszym utrzymaniu takiego systemu. Dlatego też zachęcam Cię, żebyś stosował taką klasę bazową.

Kontekst bazy danych

To jest najważniejszy element EfCore. Technicznie to klasa, która dziedziczy po DbContext. Odzwierciedla całą zawartość bazy danych – wszystkie tabele, które chcesz mieć. Dlatego też musisz napisać taką klasę. W swojej najprostszej i NIEWYSTARCZAJĄCEJ postaci może wyglądać tak:

public class ApplicationDbContext: DbContext
{
    public DbSet<ToDoItem> ToDoItems { get; set; }
}

Właściwie to zbiór właściwości typu DbSet. DbSet odnosi się do tabeli w bazie danych. Dlatego też dla każdej tabeli będziesz miał osobne właściwości DbSet. Stwórzmy zatem drugi model – użytkownik. Dodamy też od razu go do kontekstu:

public class User: BaseDbItem
{
    public string Name { get; set; }
}

//dodajmy go też jako właściciela konkretnego ToDoItem:
public class ToDoItem: BaseDbItem
{
    public User Owner { get; set; }
    public DateTimeOffset? StartDate { get; set; }
    public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
    public DateTimeOffset? EndDate { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
}

//no i do kontekstu:
public class ApplicationDbContext: DbContext
{
    public DbSet<ToDoItem> ToDoItems { get; set; }
    public DbSet<User> Users { get; set; }
}

Połączenie z bazą danych

Mając stworzony DbContext, możemy teraz utworzyć połączenie do bazy danych. Sprowadza się to do trzech kroków:

Przechowywanie ConnectionString

ConnectionString do bazy będzie przechowywany oczywiście w ustawieniach – appsettings. Ja trzymam w głównym pliku: appsettings.json zamiast appsettings.Development.json, ponieważ jest mi prościej (za chwilę dowiesz się dlaczego).

Pamiętaj, że ten plik ląduje w repozytorium na serwerze, więc jeśli umieszczasz tam jakieś hasło, to pamiętaj, że to zła praktyka i powinieneś posłużyć się sekretami. W przeciwnym razie każdy z dostępem do repozytorium zobaczy Twoje hasło.

To teraz jak utworzyć ConnectionString do bazy lokalnej? Bardzo prosto. Najpierw otwórz sobie okienko SQL Server Object Explorer:

To okienko pozwala Ci zarządzać lokalnymi bazami. Teraz odnajdź bazę master na lokalnym serwerze i wejdź w jej właściwości (ważne, żebyś rozwinął bazę master. Jeśli jej nie rozwiniesz, nie zobaczysz connection stringa):

Z właściwości możesz odczytać connection stringa:

Skopiuj go i wklej do ustawień w pliku appsettings.json lub w secrets.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "MainDbConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=master;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
  }
}

Teraz zwróć uwagę, że w connection string jest zaszyta nazwa bazy – master. Dlatego też powinieneś ją zmienić na swoją docelową bazę. Ta baza nie musi istnieć, EfCore sam ją sobie utworzy, ale zmień tą nazwę. Nigdy nie pracuj na bazie master (traktuj ją tylko jako do odczytu):

  "ConnectionStrings": {
    "MainDbConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=todo;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
  }

Tutaj bazę nazwałem todo.

Przekazywanie ConnectionString

Musimy teraz w jakiś sposób powiedzieć EfCore’owi z jaką bazą chcemy się łączyć. Robimy to poprzez kontekst bazy danych. Wystarczy oprogramować konkretną wersję konstruktora w taki sposób:

public class ApplicationDbContext: DbContext
{
    public DbSet<ToDoItem> ToDoItems { get; set; }
    public DbSet<User> Users { get; set; }

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {

    }
}

I to w zasadzie wystarczy. Oczywiście nic Ci nie broni, żebyś w tym konstruktorze posiadał jakiś kod. Ale to w domyślnym mechanizmie wystarczy.

Pamiętaj tylko, że jeśli używasz EfCore z dependency injection, stwórz konstruktor z generyczną wersją DbContextOptions – tak jak wyżej: DbContextOptions<ApplicationDbContext> zamiast samego DbContextOptions.

Dlaczego? Jeśli masz w aplikacji jeden DbContext, to nie ma żadnej różnicy. Jednak, jeśli masz ich kilka, to użycie generycznej wersji DbContextOptions<T> zapewni, że do odpowiednich kontekstów zawsze trafią odpowiednie opcje. W przeciwnym razie dependency injection może coś pochrzanić.

Więc lepiej wyrób sobie taki nawyk.

Teraz dwa słowa wyjaśnienia. EfCore działa w taki sposób, że to kontekst bazy danych musi wiedzieć z jaką bazą się łączy. I ten connection string przekazuje się w konstruktorze. Jednak nie możesz go tam zahardkodować, bo to zła praktyka z punktu widzenia bezpieczeństwa i utrzymania takiego kodu.

Oczywiście mógłbyś posłużyć się mechanizmem konfiguracji i wstrzyknąć tutaj jakieś IOptions. Ale to już jest domyślnie. Ten domyślny sposób korzysta z tego przeciążenia konstruktora – wstrzykuje do kontekstu odpowiednie opcje.

Podczas rejestracji kontekstu, rejestrowane są też jego opcje (DbContextOptions), w którym connection string jest już obecny. Jeśli dobrze zarejestrujemy 🙂

Rejestracja kontekstu bazy danych

Kontekst rejestrujemy tak jak każdy inny serwis, dodatkowo podając connection stringa i mówiąc mu, jakiej bazy chcemy użyć.

var config = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(o =>
{
    o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
});

Uwaga! GetConnectionString to metoda pomocnicza, która pobiera sekcję z ustawień aplikacji o nazwie „ConnectionStrings”. Więc jeśli Twoja sekcja nazywa się inaczej lub w innym miejscu masz tego connection stringa, wtedy posłuż się standardowym wywołaniem w stylu config[„MojaSekcja:Connection”].

Pamiętaj, że UseSqlServer to extension method pochodzące z NuGet: Microsoft.EntityFrameworkCore.SqlServer. Jeśli zainstalujesz inny NuGet, np. do obsługi SQLite, wtedy będziesz używał metody UseSqlite. Analogicznie z innymi bazami danych.

Inne sposoby

Są też inne prawilne sposoby rejestracji takiego kontekstu. Np. za pomocą DbContextFactory, o którym za chwilę.

Czym są migracje w EfCore?

Nieodłącznym mechanizmem EfCore są migracje. EfCore jest w stanie sam utworzyć bazę danych. Co więcej, jest w stanie pilnować, żeby struktura bazy danych odpowiadała strukturze w Twoich modelach (choć pamiętaj, że świat relacyjny i obiektowy to zupełnie dwa różne miejsca). Co więcej, od jakiegoś czasu EfCore potrafi też stworzyć modele na podstawie tabel istniejących w bazie danych.

Na pewno spotkałeś się z pojęciami Code First i Database First. To właśnie opisuje sposób tworzenia bazy danych. Podejście Code First tworzy bazę danych z istniejących modeli obiektowych. Database First z kolei na odwrót – tworzy modele obiektowe na podstawie struktury bazy danych. W EfCore jeszcze do niedawna było możliwe użycie jedynie podejścia CodeFirst.

EfCore, aby to wszystko było możliwe, posługuje się migracjami. Z technicznego punktu widzenia, migracja to zwykła klasa, która posiada przepis na utworzenie konkretnej wersji bazy danych.

Gdy zmieniasz swój model i uznajesz go za pewną skończoną wersję, powinieneś zrobić migrację. Robi się to za pomocą narzędzi EfCore. Wystarczy, że w konsoli przejdziesz do folderu, w którym jest Twój projekt i wpiszesz tam takie polecenie:

dotnet ef migrations add "InitialDbCreate"

InitialDbCreate to oczywiście nazwa Twojej migracji. Ta jest standardową nazwą na pierwszą migrację. Poza tym przyjęło się, że nazwa migracji powinna mówić o tym, co migracja zawiera, np. „AddedClientName” albo „RemovedOwnerModel” itd.

Wykonanie tego polecenia stworzy folder Migrations i dwa pliki w środku. Pierwszy to migracja (klasa dziedzicząca po Migration), a drugi to snapshot aktualnej wersji – powstaje na podstawie migracji. Jeśli chcesz od nowa zbudować bazę danych, po prostu możesz usunąć cały folder i bazę danych.

Mając taką migrację, możesz teraz uaktualnić / stworzyć bazę danych poleceniem:

dotnet ef database update

To polecenie po pierwsze połączy się z bazą danych, a po drugie utworzy lub odczyta specjalną tabelę, w której są zapisane nazwy migracji, z których powstaje baza danych. Patrzy na ostatni wpis i wykonuje kolejną migrację – aktualizuje strukturę bazy danych.

Oczywiście dokładnie tak samo, jak tworzyłbyś skrypty SQL aktualizujące strukturę – tutaj też to może się nie udać. Przykładowo, jeśli istnieją rekordy i jakaś kolumna ma wartości NULL, a teraz chcesz zrobić, żeby ta kolumna nie mogła być NULLem, to się nie uda. Więc trzeba na takie rzeczy zwracać uwagę.

Jak to działa?

Zarówno tworzenie migracji jak i aktualizacja bazy uruchamia Twoją aplikację. Najpierw apka jest budowana (choć to można pominąć, dodając do polecenia argument --no-build, ale uważaj na to, bo może się okazać że migracja nie zostanie przeprowadzona), potem uruchamiana w specjalny sposób. I tutaj może pojawić się problem z odczytem connection stringa. W .NET6 narzędzia szukają connection stringa na produkcyjnej wersji środowiska (chyba że masz globalnie ustawioną zmienną środowiskową ASPNET_ENVIRONMENT z wartością Development) – dlatego wcześniej pisałem o tym, że dla ułatwienia connection string trzymany jest w głównym appsettings.json (lub secrets.json).

Pewnie można to jakoś zmienić, ale jeszcze nie rozkminiłem jak 🙂

Konfiguracja EfCore

EfCore jest na tyle „mądry”, że dużo potrafi wywnioskować na temat modeli będących w DbContext. I tak naprawdę jeśli w ogóle nie skonfigurujesz swoich modeli, to on i tak rozkimi, że pole o nazwie Id jest kluczem głównym; że pole OwnerId jest jakimś kluczem obcym. Co więcej nawet jak zobaczy właściwość wskazującą na inny obiekt, też stworzy z tego relację.

Jednak nie wszystko jest w stanie wydedukować. Poza tym mamy w naszym przypadku jeden poważny problem. Kolumna Status w tabeli ToDoItems domyślnie została utworzona jako int (EfCore tak domyślnie mapuje enumy). Czy to dobrze? Zdecydowanie nie. W tym momencie mamy możliwe 3 wartości:

public enum ToDoItemStatus
{
    NotStarted,
    Running,
    Done
}

NotStarted zostało zmapowane do 0, Running to 1, a Done to 2. Fajnie. A jak ktoś dołoży kolejną?

public enum ToDoItemStatus
{
    NotStarted,
    Running,
    Paused,
    Done
}

Wtedy w bazie wszystko się pop…rzy. Bo nagle Paused przyjmie wartość 2, a Done 3. Z bazy będzie wynikało, że żadne zadanie nie zostało ukończone. Za to te, co były ukończone będą uważane za wstrzymane. Dlatego mimo wszystko lepiej enumy trzymać jako string.

To wszystko i wiele więcej możemy uzyskać dzięki konfiguracji modeli. Konfigurację można przeprowadzić na dwa sposoby:

  • za pomocą adnotacji
  • używając fluentowej konfiguracji

Osobiście jestem zwolennikiem tej drugiej, bo dzięki temu modele bazodanowe są czyste. Poza tym adnotacje nie pozwolą na wszystko.

Przykład konfiguracji za pomocą adnotacji może wyglądać tak:

public class ToDoItem: BaseDbItem
{
    [Required]
    public Guid OwnerId { get; set; }
    [ForeignKey(nameof(OwnerId)]
    public User Owner { get; set; }
    public DateTimeOffset? StartDate { get; set; }
    public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
    public DateTimeOffset? EndDate { get; set; }
    [MaxLength(30)]
    public string Title { get; set; }
    public string Description { get; set; }
}

Tutaj mówimy do ORMa trzy rzeczy:

  • Pole OwnerId ma być wymagane (not null)
  • Owner to klucz obcy do tabeli Users, odpowiada mu właściwość OwnerId
  • Pole Title ma mieć maksymalnie 30 znaków

Tych adnotacji jest oczywiście więcej, ale moim skromnym zdaniem szkoda na nie czasu. Przejdźmy od razu do Fluent Configuration.

Konfiguracja typu Fluent

Możesz ją przeprowadzić na dwa sposoby. Czysto – tzn. oddzielne klasy odpowiadają za konfigurację konkretnych modeli lub brudno – wszystkie modele w jednej metodzie. Najpierw pokażę Ci tę „brudną” konfigurację.

Przejdźmy do naszego DbContext. Tam jest do przeciążenia taka metoda jak OnModelCreating, w której możemy te modele pokonfigurować. Ale zanim do tego dojdzie, zróbmy małą, ale cholernie istotną zmianę w modelu:

public class ToDoItem: BaseDbItem
{
    public Guid OwnerId { get; set; }
    public User Owner { get; set; }
    public DateTimeOffset? StartDate { get; set; }
    public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
    public DateTimeOffset? EndDate { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
}

Dodałem właściwość OwnerId. Dzięki temu będziemy mogli przeszukiwać tabelę ToDoItems pod kątem konkretnych użytkowników z użyciem Linq. Jednocześnie nie będziemy musieli robić joina z tabelą Users. Skonfigurujmy to teraz:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    var todoItemBuilder = modelBuilder.Entity<ToDoItem>();

    todoItemBuilder.Property(x => x.Title)
        .HasMaxLength(30);

    todoItemBuilder.Property(x => x.Status)
        .HasConversion(new EnumToStringConverter<ToDoItemStatus>());

    todoItemBuilder.Property(x => x.OwnerId)
        .IsRequired();

    todoItemBuilder.HasOne(x => x.Owner)
        .WithMany()
        .HasForeignKey(x => x.OwnerId);
}

Ta konfiguracja fluentowa również sama się świetnie opisuje 🙂 Założę się, że bez tłumaczenia od razu widzisz jakie masz możliwości i co robi powyższy kod.

Generalnie, posługując się metodą Property, możemy ustawiać ograniczenia, konwertery itd. dla konkretnych właściwości.

Zwróć uwagę szczególnie na linijki 10 i 11, gdzie konwertujemy enum do stringa.

Czy warto bawić się w konfigurację, skoro EfCore robi dużo za nas? Moim zdaniem warto. Ja nie za bardzo lubię oddawać kontrolę nad kodem, są rzeczy które wolę jasno określić. Jednak, jeśli Ty jesteś bardziej otwarty lub nie potrzebujesz niektórych ograniczeń, możesz spróbować dać EfCore wolną wolę i zobaczyć co się stanie.

Ignorowanie właściwości

EfCore domyślnie umieszcza w bazie wszystkie właściwości z modelu (w przeciwieństwie do nHibernate). Tutaj też możemy mu powiedzieć jakie właściwości ma ominąć. Np. jeśli bym nie chciał, żeby do bazy trafiał czas zakończenia zadania, mógłbym to skonfigurować tak:

todoItemBuilder.Ignore(x => x.EndDate);

Jak widzisz sam konkretny builder danego modelu ma w sobie też różne ciekawe elementy. Pozwala między innymi na zmianę nazwy tabeli.

Klucze

Możesz ustawić dodatkowy klucz na jednym lub na kilku polach. Przy czym jeśli chodzi o klucz ustawiany na kilku polach, jest to możliwe tylko za pomocą fluent configuration. Nie można tego zrobić adnotacjami:

todoItemBuilder.HasKey(x => x.Status);
todoItemBuilder.HasKey(x => new {x.OwnerId, x.Status});

Tutaj utworzyłem dwa klucze – jeden na pole Status, drugi jest kluczem na dwóch polach – OwnerId i Status. Jak widzisz, jest to banalnie proste.

Relacje w EfCore

Relacje to dość ważny aspekt w bazach relacyjnych 😀 W EfCore konfiguruje się je bardzo prosto, trzeba pamiętać tylko o kilku rzeczach i pojęciach:

  • relację konfigurujemy tylko z jednej strony. Tzn. możemy ustawić, że ToDoItem ma jednego ownera albo User ma wiele ToDoItem'ów. Nie musimy konfigurować dwóch modeli. Tzn. nie musimy w User podawać, że ma wiele ToDoItem'ów i jednocześnie w ToDoItem, że ma jednego usera. Wystarczy, że skonfigiujemy to po jednej stronie
  • encja zależna (dependent entity) – to jest ta encja, która zawiera klucz obcy. Czyli w naszym przypadku encją zależną jest ToDoItem – ponieważ to w tej klasie zdefiniowaliśmy klucz obcy
  • encja główna (principal entity) – to jest ta encja, która jest „rodzicem” encji zależnej. W naszym wypadku będzie to User. No, jeśli usuniemy Usera, jego encje zależne też powinny zostać usunięte (czyli jego wszystkie zadania).
  • właściwość nawigacyjna (navigation property) – to jest właściwość w encji zależnej i/lub głównej, która wskazuje na tę drugą stronę. Bardziej po ludzku, w modelu ToDoItems naszym navigation property jest Owner – to ta właściwość wskazuje na konkretny model encji głównej. Możemy dodać takie navigation property również do modelu User, dodając np. listę todo itemów:
public class User: BaseDbItem
{
    public string Name { get; set; }
    public List<ToDoItem> Items { get; set; }
}

Generalnie navigation property może posiadać kilka typów:

  • kolekcję zależnych encji (jak wyżej właściwość Items)
  • właściwość wskazującą na jedną powiązaną encję (np. w naszym modelu ToDoItems jest to Owner)

Czasem mówimy też o inverse property. To jest zwykłe navigation property tyle, że po drugiej stronie relacji. Jakby to powiedzieć prościej… Jeśli nasza encja User miałaby listę Itemów jak w powyższym przykładzie, wtedy ta lista Itemów byłaby takim „inverse property”. Kiedy to się przydaje i po co?

Generalnie EfCore potrafi sam wykminić wiele rzeczy na podstawie konwencji nazewnictwa, zależności itd. Ale w pewnych sytuacjach nie ogranie. Czasem możesz dostać błąd, że nie może „wydedukować” rodzaju relacji. Wtedy, żeby mu ułatwić możesz zastosować ten inverse property. I konfiguracja takiej relacji wyglądałaby tak:

todoItemBuilder.HasOne(x => x.Owner)
    .WithMany(y => y.Items)
    .HasForeignKey(x => x.OwnerId);

Jeśli nie masz inverse property, możesz po prostu skonfigurować to tak:

todoItemBuilder.HasOne(x => x.Owner)
    .WithMany()
    .HasForeignKey(x => x.OwnerId);

Konfiguracja „czysta”

Mam tu na myśli taką konfigurację, że poszczególne klasy są konfigurowane w osobnych plikach – tak jak ma to miejsce przy nHibernate. Najpierw warto utworzyć sobie konfiguracyjną klasę bazową, np:

public abstract class BaseModelConfig<TModel> : IEntityTypeConfiguration<TModel>
    where TModel : BaseDbItem
{
    public virtual void Configure(EntityTypeBuilder<TModel> builder)
    {
        builder.Property(x => x.Id)
            .IsRequired()
            .ValueGeneratedOnAdd();
    }
}

Zwróć uwagę, że nasza klasa bazowa implementuje interfejs IEntityTypeConfiguration. Tutaj właśnie przyda się też bazowy model, o czym pisałem wcześniej. Naprawdę warto go mieć.

Następnie konfiguracja pozostałych modeli polega na dziedziczeniu po klasie BaseModelConfig:

public class TodoItemConfig : BaseModelConfig<ToDoItem>
{
    public override void Configure(EntityTypeBuilder<ToDoItem> builder)
    {
        base.Configure(builder);

        builder.Property(x => x.Title)
            .HasMaxLength(30);

        builder.Property(x => x.Status)
            .HasConversion(new EnumToStringConverter<ToDoItemStatus>());

        builder.Property(x => x.OwnerId)
            .IsRequired();

        builder.HasOne(x => x.Owner)
            .WithMany()
            .HasForeignKey(x => x.OwnerId);
    }
}

Oczywiście nie musisz tworzyć abstrakcyjnej klasy bazowej. Ważne, żeby konkretne konfiguracje implementowały interfejs IEntityTypeConfiguration. Jednak taka klasa bazowa dużo ułatwia.

Na koniec musisz jeszcze powiedzieć w kontekście, gdzie ma szukać konfiguracji. DbContext będzie szukał implementacji interfejsu IEntityTypeConfiguration w Assembly, które mu podasz. W tym przypadku wykorzystujemy aktualne, główne:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
    modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); 
}

Zapytania

Przede wszystkim pamiętaj, że EfCore pełni rolę repozytorium. Dlatego nie twórz repozytorium (mówię tutaj o wzorcu projektowym), używając EfCore – to jest częsty błąd w tutorialach na YouTubie i zbyt wielu artykułach. Pamiętaj – EfCore to repozytorium.

Jeśli chodzi o programowanie webowe, to najczyściej jest używać EfCore w serwisach. Kontroler odwołuje się do serwisu, serwis ma wstrzyknięty DbContext. A teraz zobaczmy, w jaki sposób można dodać rekord. Spójrz na poniższy serwis:

public class UserService
{
    private readonly ApplicationDbContext _dbContext;

    public UserService(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task AddOrUpdateUser(User data)
    {
        _dbContext.Users.Update(data);
        await _dbContext.SaveChangesAsync();
    }
}

Najpierw wstrzykujemy do konstruktora ApplicationDbContext. Następnie możemy się nim posługiwać tak jakbyśmy używali zwykłego wzorca repozytorium. A nawet lepiej, bo można używać Linq do pobierania danych.

Zwróć tylko uwagę na to, że dodawanie/modyfikacja danych w poszczególnych DbSet'ach jeszcze niczego nie zmienia. Na tym poziomie działa taki sprytny wewnętrzny mechanizm EfCore (change tracker), który oznacza odpowiednie rekordy w odpowiedni sposób (ten rekord będzie zmodyfikowany, ten trzeba dodać, a tu jest jakiś do usunięcia).

Faktyczne operacje do bazy danych pójdą dopiero po wywołaniu SaveChanges. I do tego pójdą w transakcji.

Czas życia DbContext – ważne

DbContext nie jest thread-safe. Nie powinien żyć długo. Domyślnie jest wstrzykiwany jako scope (choć to można zmienić podczas konfiguracji). Zalecam zostawić domyślne zachowanie, chyba że pojawiają się problemy, to wtedy można zmienić na transient:

builder.Services.AddDbContext<ApplicationDbContext>(o =>
{
    o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
}, ServiceLifetime.Transient);

Jeśli używasz zwykłego WebApplication albo WebApi, to takie konstrukcje ze wstrzykiwaniem DbContext są jak najbardziej poprawne. Ale problem pojawia się przy Blazor lub aplikacjach desktopowych.

Inaczej dla Blazor i aplikacji desktopowych

Dlaczego? Jak już mówiłem, DbContext jest standardowo wstrzykiwany jako scoped. Możesz też zmienić na transient. Jednak co to oznacza dla Blazor? Tam, jeśli masz serwis oznaczony jako scope, to właściwie zachowuje się to jak singleton. Dokładnie tak samo jest w aplikacjach desktopowych.

Nie oznacza to teraz, że w Blazor wszystkie serwisy korzystające pośrednio lub bezpośrednio z bazy danych, mają być wstrzykiwane jako transient. O nie. Jest lepszy sposób. Spójrzmy na fabrykę.

Fabryka DbContext

Zamiast rejestrować DbContext, możesz zarejestrować fabrykę:

builder.Services.AddDbContextFactory<ApplicationDbContext>(o =>
{
    o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
});

Przyznasz, że wielkiej różnicy nie ma. Różnica pojawia się w momencie używania tego kontekstu. Teraz wstrzykujemy fabrykę do serwisu:

public class UserService
{
    private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;

    public UserService(IDbContextFactory<ApplicationDbContext> dbContextFactory)
    {
        _dbContextFactory = dbContextFactory;
    }

    public async Task AddOrUpdateUser(User data)
    {
        using var dbContext = _dbContextFactory.CreateDbContext();
        
        dbContext.Users.Update(data);
        await dbContext.SaveChangesAsync();
    }
}

Tutaj do dostępu do bazy danych wykorzystujemy fabrykę. Po prostu ona nam tworzy ten kontekst. Pamiętaj, że musisz tworzyć to przez using. DbContext implementuje interfejs IDisposable i jest cholernie ważne, żeby po sobie posprzątał.

Nie ma nic złego w takim tworzeniu kontekstu. Co więcej – to naturalny sposób w przypadku aplikacji desktopowych no i we wspomnianym Blazor.

Pamiętaj, że jeśli chodzi o WebApplication lub WebApi – gdzie każdy request tworzy i usuwa scope, to możesz posłużyć się tą wersją ze wstrzykiwaniem samego kontekstu.

Zapytania SELECT

Oczywiście jest to tak samo proste jak pobieranie danych ze zwykłej listy. Zwróć tylko uwagę na to, czym jest IQueryable. Spójrz na poniższy kod:

var data = dbContext.Users.Where(x => x.Name.Length > 3);

Tutaj pobieramy użytkowników, których imię ma więcej niż 3 znaki. Czy to zapytanie się wykona?

Nie wykona się. Dlatego, że metoda Where zwraca obiekt IQueryable. A to taki twór, który możesz uznać za zapytanie SQL. Możesz mu zatem dodać inne warunki, np:

var data = dbContext.Users.Where(x => x.Name.Length > 3);
data = data.Where(x => x.Name.Contains("A"));

A kiedy IQueryable zostanie wysłane do bazy danych? We wszystkich tych metodach, które zwracają coś innego niż IQueryable (a konkretnie nasz model lub jego listę), np:

var list = await data.ToListAsync();
var first = await data.FirstAsync();
var fod = await data.FirstOrDefaultAsync();

Itd. Generalnie zawsze spójrz na to, co zwraca konkretna metoda. Tam, gdzie masz IQueryable, to nie ma jeszcze żadnej pracy na bazie danych.

Join

A teraz spójrz na takie zapytanie:

using var ctx = _dbContextFactory.CreateDbContext();

var data = await ctx.ToDoItems.ToListAsync();

Co zostanie pobrane?

Same zadania. Bez ich właścicieli. To super wiadomość. EfCore domyślnie zwróci tylko te dane, o które pytamy. Nie jest to wprawdzie takie „lazy loading” jak w nHibernate (choć to też da się ustawić), jednak do bazy idą tylko najbardziej potrzebne zapytania.

Oczywiście możemy do tego zrobić joina, żeby uzyskać zadania wraz z właścicielami. Ale musimy to jasno powiedzieć. W taki sposób:

using var ctx = _dbContextFactory.CreateDbContext();

var data = await ctx.ToDoItems
    .Include(x => x.Owner)
    .ToListAsync();

Metoda Include powie EfCore’owi, że chcemy pobrać również właściciela zadania. Istnieje również metoda ThenInclude, która pobiera dane z kolejnej encji (w naszym przypadku byłby to User).

Uważaj na podwójny INSERT

Spójrz na tan kod:

public async Task AddOrUpdateTask(ToDoItem item)
{
    User owner;
    using var ctx = _dbContextFactory.CreateDbContext();
    owner = await ctx.Users.FirstAsync();

    item.Owner = owner;
    ctx.ToDoItems.Add(item);
    await ctx.SaveChangesAsync();
}

Tak naprawdę nie jest ważne skąd pochodzi owner, ważne jest że ten owner znajduje się już w bazie danych. Taki kod wybucha błędem o niepoprawnym kluczu. EfCore myśli, że skoro dodajemy nowy rekord (ToDoItem), to dodajemy go z całym inwentarzem, czyli chcemy też dodać jego ownera (technicznie – INSERT owner i INSERT todoItem). Ale User o takim id już istnieje w bazie danych.

Czemu tak się stało?

EfCore ma taki sprytny mechanizm, który nazywa się ChangeTracker. On po prostu sprawdza, co się zmieniło w encji podczas życia kontekstu bazy danych. Akurat ta encja (TodoItem) jest zupełnie nowa. Więc w ChangeTrackerze, który żyje razem z kontekstem bazy danych ma oznaczenie „New”. Wszystko co ma oznaczenie „New” musi zostać dodane do bazy przez INSERT. EfCore uznaje również, że wszystkie encje zależne też są nowe.

Ale to nie koniec niespodzianki. Jeśli stworzymy jeden DbContext i pobierzemy z niego TodoItem, następnie kontekst zostanie ubity, to po utworzeniu drugiego kontekstu ten TodoItem, który został pobrany też będzie widoczny jako nowy – w tym nowym DbContext. To jest pułapka, na którą często sam się łapałem i na którą zwracam Tobie szczególną uwagę.

Więc jak sobie z czymś takim poradzić? Po prostu:

public async Task AddOrUpdateTask(ToDoItem item)
{
    User owner;
    using var ctx = _dbContextFactory.CreateDbContext();
    owner = await ctx.Users.FirstAsync();

    item.OwnerId = owner.Id;
    ctx.ToDoItems.Add(item);
    await ctx.SaveChangesAsync();
}

Czyli zamiast obiektu, przypisuję Id. Czasem nawet będziesz musiał zrobić takie cudo:

item.Owner = null;
ctx.ToDoItems.Update(item);
await ctx.SaveChangesAsync();

Musisz znullować ownera jako obiekt, ale zostawiasz jego Id. To oczywiście dotyczy tej samej sytuacji, czyli jedna encja pochodzi z jednego kontekstu, a druga z drugiego. Może się tak zdarzyć chociażby przy WebApi. Dlatego też pomocne może okazać się przesyłanie między końcówkami modelu DTO, zamiast bazodanowego (i mocno Cię do tego zachęcam, żebyś nie przesyłał modeli bazodanowych).

Różne bazy danych, różne możliwości

Możliwości EfCore zależą trochę od rodzaju bazy danych, na jakiej pracujesz. Niektóre operacje nie będą dozwolone np. na Sqlite. Zazwyczaj pracujemy na jednym typie bazy. Ale nie zawsze. Czasem zdarza się, że potrzebujesz dwóch rodzajów. Dlatego ważne jest, żeby przetestować wszystkie funkcje na wszystkich DBMS, które wykorzystujesz.


Dziękuję Ci za przeczytanie tego artykułu. To oczywiście nie wyczerpuje tematu. To są podstawy podstaw jeśli chodzi o EfCore. Na tym blogu mam jeszcze kilka tekstów na ten temat, jeśli Cię zainteresowało, to polecam:

Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu. No i oczywiście udostępnij artykuł osobom, które powinny wiedzieć jak podejść do EfCore.

Podziel się artykułem na:
Zwracanie błędów z API, czyli ProblemDetails

Zwracanie błędów z API, czyli ProblemDetails

Wstęp

Często zdarza się, że tworzymy własne modele, którymi przesyłamy błędy z API do klienta API. Osobiście często miałem z tym rozkminy, jak to zrobić uniwersalnie. Na szczęście w 2016 roku w życie wszedł standard RFC7807, który opisuje ProblemDetails – uniwersalny sposób przesyłania błędów.

Standard ProblemDetails

Po co powstał ten standard? Przede wszystkim REST ma być uniwersalny. Twórcy dostrzegli problem typu „każdy wuj na swój strój” – każdy robił własne modele i standardy przesyłania informacji o błędach. Chodziło też o usprawnienie automatyzacji.

Przykładowo, człowiek siedzący za monitorem często z kontekstu może wydedukować, czy błąd 404 jest związany z brakiem strony, czy może z brakiem szukanego rekordu w bazie danych. Automaty niekoniecznie.

Czym jest ProblemDetails?

Z punktu widzenia dokumentacji to jest ustandaryzowany model, który wygląda tak:

{
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "status": 400,
    "instance": "/account/12345/msgs/abc",
 }

Teraz omówię każde z tych pól:

Type

To pole jest wymagane i powinno posiadać adres do strony HTML na temat danego błędu. Informacje mają być w formie, w której człowiek może je przeczytać. Jeśli nie możesz dać żadnego adresu, daj domyślną wartość „about:blank”.

Czy to znaczy, że musisz stworzyć strony HTML z opisem swoich błędów? Nie. Wystarczy, że wpiszesz tam adres standardowy opisujący dany błąd, np.: https://www.rfc-editor.org/rfc/rfc7231#section-6.5.4. Generalnie możesz odnieść się do poszczególnych sekcji ze strony: https://www.rfc-editor.org/rfc/rfc7231, na której są opisane już te błędy.

Klient API musi traktować to pole jako główne pole opisu błędu. Tzn. powinien wiedzieć, jak zareagować na przykładową wartość: https://www.rfc-editor.org/rfc/rfc7231#section-6.5.4.

Title

To ma być jakieś podsumowanie problemu czytelne dla człowieka. I tutaj uwaga – zawartość tego pola nie może się zmieniać. Tzn. że kilka „wywołań” tego samego problemu musi dać ten sam tytuł. Czyli nie zmieniaj tego w zależności od kontekstu.

Wyjątkiem od tej reguły może być lokalizowanie treści. Czyli inną możesz dać dla Niemca (po niemiecku), inną dla Polaka (po polsku).

Detail

Tutaj powinien znaleźć się opis konkretnej sytuacji czytelny dla człowieka. O ile pole title nie powinno się zmieniać w zależności od danych, to pole detail jak najbardziej może – tak jak widzisz w przykładzie. Pole jest opcjonalne.

Klient API nie powinien w żaden sposób parsować informacji zawartych w tym polu. Są inne możliwości, które pomagają w automatyzacji, o których piszę później (rozszerzenia).

Status

To pole jest opcjonalne. Jego wartością powinien być kod oryginalnego błędu. Powiesz – „ale po co, skoro kod błędu jest w odpowiedzi na żądanie?”. No niby jest. Ale niekoniecznie musi być oryginalny. Jeśli np. ruch przechodzi przez jakieś proxy albo w jakiś inny sposób jest modyfikowany, wtedy kod w odpowiedzi może być inny niż w oryginalnym wystąpieniu błędu. Dlatego w polu status zawsze podawaj oryginalny kod błędu.

Instance

Tutaj powinno znaleźć się miejsce, które spowodowało błąd. To też powinien być adres URI.

Rozszerzenia

Standard pozwala rozszerzać ProblemDetails. Tzn. możesz dodać do modelu inne właściwości. Teraz możesz powiedzieć: „I tu się kończy uniwersalność, dziękuję, do widzenia.”. I w pewnym sensie będziesz miał rację, ale tylko w pewnym sensie. Zauważ, że cały czas podstawowy model jest taki sam.

Przykład modelu z rozszerzeniem

{
   "type": "https://example.net/validation-error",
   "title": "Your request parameters didn't validate.",
   "status": 400,
   "invalid_params": [ {
                         "name": "age",
                         "reason": "must be a positive integer"
                       },
                       {
                         "name": "color",
                         "reason": "must be 'green', 'red' or 'blue'"}
                     ]
   }

Jak widzisz w przykładzie powyżej, doszło pole „invalid_params„. Takie rozszerzenia mogą być użyteczne dla klientów API lub po prostu posiadać dodatkowe informacje. Pamiętaj, że pole detail nie powinno być w żaden sposób parsowane. Więc, wracając do pierwszego przykładu, moglibyśmy dać dwa rozszerzenia, którymi klient API może się w jakiś sposób posłużyć:

{
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "status": 400,
    "instance": "/account/12345/msgs/abc",
    "balance": 30,
    "price": 50
 }

Generalnie rozszerzenia umożliwiają przekazanie większej ilości informacji w sposób, w jaki chcesz.

Czy muszę używać ProblemDetails?

Nie musisz. Jeśli masz już swój mechanizm, który działa, to go zostaw. ProblemDetails nie ma zamieniać istniejących mechanizmów. Ma dawać uniwersalność i brak konieczności tworzenia własnych rozwiązań.

Jeśli jednak zwracasz jakiś konkretny model, zawsze masz opcję żeby zrobić z niego swój ProblemDetails. Po prostu potraktuj swój model jako rozszerzenie. Wystarczy, że dodasz pola wymagane i będzie git.

Ale powtarzam – jeśli masz już swój działający mechanizm, niczego nie musisz robić. Nie musisz tego implementować na siłę. Jednak w nowych projektach – doradzam używanie ProblemDetails.

ProblemDetails w .NET

Na szczęście .NET ma już ogarnięte ProblemDetails i w zasadzie nie musisz tworzyć własnego mechanizmu. Różnice występują między .NET6 i .NET7. Nie piszę o wcześniejszych wersjach jako że już nie są wspierane. Jeśli utrzymujesz jakieś apki poniżej .NET6, powinieneś zmigrować do nowszej wersji.

Zwracanie problemu

W .NET standardowo możesz zwracać Problem w kontrolerze:

[HttpGet]
public IActionResult ReturnProblem()
{
    return Problem();
}

Oczywiście możesz przekazać mu wszystkie pola z modelu standardowego (jako parametry metody Problem). Jeśli tego nie zrobisz, .NET sam je uzupełni domyślnymi wartościami. Z powyższego przykładu uzyskasz taką zwrotkę:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-4139aae6364cb68d2235c576689bb359-88964c6328a1d834-00"
}

Zauważ, że:

  • type odnosi do opisu błędu 500 (bo taki jest domyślnie zwracany przy problemie)
  • title – to jakaś ogólna domyślna wartość
  • status – jak pisałem wyżej, zwraca oryginalny kod błędu
  • traceId – to jest rozszerzenie .NET, które ułatwia szukanie błędów w logach

Piszemy rozszerzenie

Tutaj wszystko sprowadza się do zwrócenia obiektu ProblemDetails zamiast wywołania metody Problem(). Niestety metoda Problem() nie umożliwia dodawania rozszerzeń. Ale nic się nie bój – nie musisz wszystkiego tworzyć sam. Jest dostępna fabryka, którą wykorzystamy. Musisz ją wstrzyknąć do kontrolera, a potem użyć jej, żeby stworzyć instancję ProblemDetails:

public class ProblemController : ControllerBase
{
    private readonly ProblemDetailsFactory _problemDetailsFactory;
    public ProblemController(ProblemDetailsFactory problemDetailsFactory)
    {
        _problemDetailsFactory = problemDetailsFactory;
    }

    [HttpGet]
    public IActionResult ReturnProblem()
    {
        var result = _problemDetailsFactory.CreateProblemDetails(HttpContext);

        var extObject = new
        {
            errorType = "LoginProblem",
            systemCode = 10
        };
        result.Extensions["additionalData"] = extObject;

        return Unauthorized(result);
    }
}

Wszystko powinno być dość jasne. Tylko zwróć uwagę na jedną rzecz. Właściwość Extensions z klasy ProblemDetails to jest słownik. Zwykły Dictionary<string, object>. Co nam to daje? Kluczem musi być string (to będzie nazwa pola), ale za to wartością może być już cokolwiek. Może być string, liczba jakaś, a nawet cały obiekt – to, co zrobiliśmy tutaj.

Utworzyłem jakiś anonimowy obiekt i dodałem go jako rozszerzenie. W efekcie ten obiekt zostanie zserializowany (domyślnie do JSON).

Oczywiście na koniec musimy zwrócić problem posługując się jedną z metod w stylu BadRequest, NotFound, Unauthorized itd. Ja przykładowo wybrałem Unauthorized, ale pewnie w rzeczywistym projekcie, najchętniej użyjesz BadRequest.

W rezultacie otrzymamy coś takiego:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-c721ebc1a2be6484c53a9cdd54307e5c-b3ebd2cf05a27d0c-00",
  "additionalData": {
    "errorType": "LoginProblem",
    "systemCode": 10
  }
}

Zwróć uwagę na jedną rzecz. W response będziesz miał kod 401 (bo zwróciliśmy Unauthorized), natomiast w modelu ProblemDetails masz 500 – bo z takim kodem jest tworzony domyślnie ProblemDetails. Oczywiście wypadałoby to ujednolicić, przekazując do fabryki odpowiedni kod błędu:

var result = _problemDetailsFactory.CreateProblemDetails(HttpContext, statusCode: (int)HttpStatusCode.Unauthorized);

Zobacz też, że nigdzie nie musisz podawać właściwości type. I super! .NET sam ma wszystko ładnie zmapowane do strony https://tools.ietf.org/html/rfc7231, na której są już opisane wszystkie błędy.

Właściwość type jest uzupełniana na podstawie przekazanego statusCode. Oczywiście, jeśli potrzebujesz, możesz dać swoje type w parametrze metody Problem() lub fabryki.

Obsługa błędów

Cały mechanizm umożliwia też elegancką obsługę błędów. Jeśli gdziekolwiek zostanie rzucony nieprzechwycony wyjątek, standardowe działanie jest takie, że zwracany jest błąd 500. Natomiast mechanizm ProblemDetails w .NET tworzy odpowiedź z użyciem tego standardu. Niestety… dopiero w .NET7. Tu jest ta różnica. W .NET7 jest to dostępne na „dzień dobry”, natomiast w .NET6 trzeba sobie samemu to dopisać. Chociaż istnieje NuGet Hellang.Middleware.ProblemDetails którego możesz użyć we wcześniejszych wersjach. Ja go nie używałem, więc tylko daję Ci znać, że jest.

Wyjątki i ProblemDetails w .NET6

W .NET6 musisz do tego napisać własny mechanizm (lub posłużyć się wspomnianym Nugetem). Możesz np. napisać middleware, który Ci to ogarnie. Taki middleware w najprostszej postaci mógłby wyglądać tak:

public class ProblemDetailsMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            await HandleProblemDetails(context, ex);
        }
    }

    private async Task HandleProblemDetails(HttpContext context, Exception ex)
    {
        var factory = context.RequestServices.GetRequiredService<ProblemDetailsFactory>();
        var problem = factory.CreateProblemDetails(context,
            title: "Unhandled exception",
            detail: ex.GetType().ToString());

        problem.Extensions["message"] = ex.Message;
        if (IsDevelopement(context))
            problem.Extensions["stackTrace"] = ex.StackTrace;

        context.Response.StatusCode = problem.Status.Value;
        await context.Response.WriteAsJsonAsync(problem, null, "application/problem+json");
    }

    private bool IsDevelopement(HttpContext context)
    {
        var hostEnv = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
        return hostEnv.IsDevelopment();
    }
}

Jak widzisz, po prostu zapisuję problem do odpowiedzi. Sprawdzam też środowisko. Jeśli jest deweloperskie, to nie waham się pokazać stack trace 🙂 W innym przypadku – ze względów bezpieczeństwa lepiej tego nie robić. W takim przypadku musisz też uważać na to, jakie komunikaty dajesz, gdy rzucasz jakiś wyjątek – nie mogą być drogowskazem dla hackerów. Pisałem o tej „podatności” w książce „Zabezpieczanie aplikacji internetowych w .NET – podstawy„.

To jest najprostszy możliwy middleware. Dobrze byłoby również zalogować taki błąd.

Oczywiście musisz pamiętać o dodaniu tego middleware do pipeline:

builder.Services.AddScoped<ProblemDetailsMiddleware>();

var app = builder.Build();
app.UseMiddleware<ProblemDetailsMiddleware>();

Pamiętaj, że middleware’y obsługujące wyjątki powinny być uruchamiane jak najwcześniej w pipeline.

Wyjątki i ProblemDetails w .NET7

Jak już pisałem, w .NET7 to masz „out-of-the-box”. Wystarczy zrobić dwie rzeczy. Podczas rejestracji serwisów dodaj serwisy od ProblemDetails:

builder.Services.AddProblemDetails();

A przy konfiguracji middleware pipeline włóż obsługę wyjątków:

app.UseExceptionHandler();

A jeśli byś chciał dodać jakieś swoje rozszerzenie do ProblemDetails przy niezłapanym wyjątku, wystarczy dodać serwisy z odpowiednim przeciążeniem, np:

builder.Services.AddProblemDetails(c =>
{
    c.CustomizeProblemDetails = (ctx) =>
    {
        ctx.ProblemDetails.Extensions.Add("user_logged", ctx.HttpContext.User?.Identity?.IsAuthenticated);
    };
});

W tym przykładzie, do każdego ProblemDetails zostanie dołożone moje rozszerzenie; pole „user_logged„, mówiące czy użytkownik jest zalogowany, czy nie. W obiekcie ctx (ProblemDetailsContext) mamy do dyspozycji m.in. HttpContext z żądania. Więc jeśli potrzebujesz jakiś dodatkowych informacji, to proszę bardzo.

Bezpieczeństwo

Pamiętaj, że komunikat błędu może tworzyć jakiś drogowskaz dla hackerów. A tym bardziej cały stack trace. Na szczęście mechanizm ProblemDetails w .NET7 jest bezpieczny. Dopóki nie zrobisz niczego bardzo głupiego, to będzie dobrze. Stack Trace zostanie dołączone do modelu ProblemDetails tylko na środowisku Developerskim. Natomiast na każdym innym nie zobaczysz go. I to dobrze.

Pamiętaj jednak, żeby samemu nie podawać zbyt dużo informacji w ProblemDetails, bo to może pomóc hackerom. Zresztą pisałem o tym w książce Zabezpieczenia aplikacji internetowych w .NET – podstawy


Dzięki za przeczytanie artykułu. To tyle jeśli chodzi o podstawy użycia ProblemDetails. Jeśli znalazłeś w artykule jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

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

Podziel się artykułem na: