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

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

Wstęp

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

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

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

Przykładowy projekt

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

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

Struktury w pamięci

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

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

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

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

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

Atrybut StructLayoutAttribute

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

Czym jest padding?

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

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

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

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

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

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

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

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

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

A co się stanie teraz?

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

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

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

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

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

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

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

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

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

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

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

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

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

Jak używać StructLayout?

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

  • LayoutKind
  • CharSet
  • Pack
  • Size

LayoutKind

Możemy tutaj ustawić 3 wartości:

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

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

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

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

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

Pack

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

Size

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

Padding po stronie C++

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

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

Pobieranie danych z C++ – struktury kopiowalne

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

Po stronie C++ mamy taką strukturę:

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

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

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

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

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

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

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

A po stronie C#:

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

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

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

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

I po stronie C#:

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

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

Ten sam padding

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

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

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

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

I po stronie C#:

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

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

Struktury niekopiowalne

Struktury ze stringami

Zacznijmy od struktury ze stringami.

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

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

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

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

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

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

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

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

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

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

	return result;
}

Po stronie C#:

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

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

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

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


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

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

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

Code Europe 2024 nadchodzi!


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


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

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

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

Prelegenci

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

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

i inni.

Czemu warto?

/akapit organizatora/

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

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

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

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

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

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

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

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

UWAGA! Zniżka!

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

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

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

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

Wstęp

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

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

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

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

Na szybko

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

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

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

Po co ponawiać żądania?

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

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

Wewnętrzny błąd serwera

Czyli kody odpowiedzi 5xx.

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

Throttling

Kod 429: Too Many Requests.

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

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

Stare, dobre 404.

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

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

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

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

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

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

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

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

Co jaki czas ponawiać request?

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

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

Weź pod uwagę kilka rzeczy:

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

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

Lepiej zrobić chociażby coś takiego:

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

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

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

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

Jitter

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

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

Exponential backoff

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

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

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

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

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

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

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

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

Przywitaj Polly

Jest taki Nuget:

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

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

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

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

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

Generalnie to mówi tyle:

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

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

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

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

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

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

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

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

Rozszerzenia do Polly

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

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

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

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

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

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

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

Ale można jeszcze prościej… 😉

Integracja .NET z Polly

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

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

Teraz, gdy rejestrujesz swojego klienta Http:

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

możesz dodać swoją politykę Polly:

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

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

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

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

public class IndexModel : PageModel
{
    private readonly HttpClient _httpClient;

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

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

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

Można jeszcze prościej

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

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

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

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

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

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

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

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


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

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

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

Badaj swoje API, czyli healthcheck

Spis treści

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

Wstęp

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

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

Czym jest Healthcheck?

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

Po co to właściwie?

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

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

Przykładowa apka

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

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

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

Konfiguracja healthcheck

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

builder.Services.AddControllers();

//healthcheck
builder.Services.AddHealthChecks();

var app = builder.Build();

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

app.UseAuthorization();

app.MapHealthChecks("/_health");

app.MapControllers();

app.Run();

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

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

Healthy

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

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

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

Jak działa mechanizm healthcheck?

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

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

Badanie standardowych serwisów

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

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

Sprawdzanie bazy danych

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

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

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

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

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

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

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

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

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

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

Sprawdzanie niestandardowe

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

Utworzenie klasy do spr