Http Pipeline – czyli middleware HTTP
Wstęp
Jak zapewne wiesz, sercem .Net jest middleware pipeline. To sprawia, że możemy sobie napisać dowolny komponent i wpiąć go w łańcuch przetwarzania żądania.
Jednak HttpClient też posiada swój „rurociąg”. Możesz napisać małe komponenty, które w odpowiedni sposób będą procesować żądanie. Dzięki temu możemy osiągnąć naprawdę bardzo fajne efekty, np. zautomatyzować wersjonowanie żądań albo odnawianie bearer tokena. W tym artykule pokażę Ci oba takie przykłady.
Czym jest HttpMessageHandler?
HttpMessageHandler
zajmuje się najbardziej podstawową obsługą komunikatów. Każdy HttpClient
zawiera HttpMessageHandler
(domyślnie HttpClientHandler
).
Czyli wyobraź sobie, jakby HttpClient
był panem, który każe wysłać wiadomość, a MessageHandler
był takim gołębiem pocztowym, który dalej się tym już zajmuje. To jest jednak klasa abstrakcyjna, po której dziedziczy kilka innych, m.in. DelegatingHandler
, jak też wspomniany HttpClientHandler
– gołąb pocztowy.
Czym jest DelegatingHandler?
I tu dochodzimy do sedna. DelegatingHandler
to klasa, którą możesz wpiąć w łańcuch handlerów. Co więcej, każdy DelegatingHandler
ma pod spodem HttpClientHandlera
, który służy do faktycznego, fizycznego przekazania wiadomości.
To brzmi trochę jak czeskie kino, więc wejdźmy w przykład. Stwórzmy handler, który zapisze w logach wiadomość, że odbywa się żądanie.
Pierwszy handler
class LoggerHandler: DelegatingHandler
{
private ILogger<LoggerHandler> _logger;
public LoggerHandler(ILogger<LoggerHandler> logger)
{
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
_logger.LogInformation($"Żądanie na adres: {request.RequestUri.OriginalString}");
var response = await base.SendAsync(request, cancellationToken);
_logger.LogInformation($"Żądanie zakończone, odpowiedź: {response.StatusCode}");
return response;
}
}
Jak widać na załączonym obrazku, trzeba zrobić 3 rzeczy:
- napisać klasę dziedziczącą po
DelegatingHandler
- przeciążyć metodę
Send/SendAsync
- wywołać
Send/SendAsync
z klasy bazowej.
Dopiero wywołanie SendAsync
z klasy bazowej pchnie cały request do Internetów. Czyli, jeśli byś chciał, mógłbyś napisać takiego handlera, który niczego nie przepuści i zwróci jakiś ResponseMessage
z dowolnym kodem.
Mając takiego handlera, musimy go wpiąć do pipeline’a http. Można to zrobić na dwa sposoby.
Rejestracja Handlera
Generalnie rejestrujemy go podczas rejestrowania serwisów. Konkretnie – podczas rejestrowania HttpClienta
. O prawidłowym użyciu HttpClienta
i tworzeniu go przez fabrykę, pisałem w tym artykule.
services.AddScoped<LoggerHandler>();
services.AddHttpClient("c1", client =>
{
client.BaseAddress = new Uri("https://www.example.com");
})
.AddHttpMessageHandler<LoggerHandler>();
Najpierw rejestrujemy naszego handlera w DependencyInjection. Potem rejestrujemy HttpClient
i dodajemy do niego naszego handlera przez metodę AddHttpMessageHandler
. Pamiętaj tylko, żeby doinstalować z NuGeta paczkę Microsoft.Extensions.Http
.
Tutaj możesz zarejestrować cały łańcuch takich handlerów. Oczywiście kolejność jest istotna. Handlery będą się wykonywały w kolejności ich rejestracji.
Jest jeszcze druga metoda. Jeśli z jakiegoś powodu tworzysz HttpClient
ręcznie, możesz też utworzyć instancje swoich handlerów i umieścić jednego w drugim – jak w ruskiej babie, np:
services.AddScoped<LoggerHandler>();
services.AddScoped(sp =>
{
var loggerHandler = sp.GetRequiredService<LoggerHandler>();
var otherHandler = new OtherHandler();
loggerHandler.InnerHandler = otherHandler;
otherHandler.InnerHandler = new HttpClientHandler();
var client = new HttpClient(loggerHandler);
return client;
});
Spójrz, co ja tutaj robię. Na początku rejestruję LoggerHandler
w dependency injection. Nie muszę tego oczywiście robić, ale mogę 🙂
Potem rejestruję fabrykę dla HttpClienta
– tzn. metodę fabryczną (nie myl z HttpClientFactory
) – ta metoda utworzy HttpClient
, gdy będzie potrzebny.
I teraz w tej metodzie najpierw tworzę (za pomocą Dependency Injection) swój handler – LoggerHandler
, potem tworzę jakiś inny handler o nazwie OtherHandler
i teraz robię całą magię.
W środku LoggerHandlera
umieszczam OtherHandlera
. A w środku OtherHandlera
umieszczam HttpClientHandler
– bo jak pisałem wyżej – na samym dole zawsze jest HttpClientHandler
(gołąb pocztowy), który zajmuje się już fizycznie przekazaniem message’a.
Czyli mamy LoggerHandlera
, który ma w sobie OtherHandlera
, który ma w sobie ostatnie ogniwo łańcucha – HttpClientHandlera
.
Na koniec tworzę HttpClient
, przekazując mu LoggerHandlera
.
Oczywiście zgodnie z tym artykułem przestrzegam Cię przed takim tworzeniem HttpClienta
. Zawsze na początku idź w stronę HttpClientFactory
.
Do roboty – wersjonowanie API
W tym akapicie pokażę Ci bardzo prosty handler, który dodaje informację o wersji żądanego API.
O wersjonowaniu API pisałem już kiedyś tutaj. Napisałem w tym artykule: „Jeśli tworzę API, a do tego klienta, który jest oparty o HttpClient
, bardzo lubię dawać informację o wersji w nagłówku. Wtedy w kliencie mam wszystko w jednym miejscu i nie muszę się martwić o poprawne budowanie ścieżki„
W momencie, gdy zaczynamy zabawę z handlerami, ten argument o wyższości przekazywania wersji w nagłówku jest już trochę inwalidą. No bo przecież handler nam wszystko załatwi.
Załóżmy, że chcemy wywołać taką końcówkę: https://example.com/api/v1/clients
Normalnie w każdym wywołaniu requesta z HttpClienta
musielibyśmy dbać o tę wersję:
httpClient.GetAsync("api/v1/clients");
ale, używając odpowiedniego DelegatingHandlera
ten problem nam odpada i końcówkę możemy wywołać tak:
httpClient.GetAsync("api/clients");
Nasz delegating handler może wyglądać tak:
class ApiVersionHandler: DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var uriWithVersion = CreateUriWithVersion(request.RequestUri, "v1");
request.RequestUri = uriWithVersion;
return await base.SendAsync(request, cancellationToken);
}
private Uri CreateUriWithVersion(Uri originalUri, string version)
{
UriBuilder builder = new UriBuilder(originalUri);
var segments = builder.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
int apiSegmentIndex = segments.FindIndex(s => string.Compare("api", s, true) == 0);
if (apiSegmentIndex < 0)
return originalUri;
segments.Insert(apiSegmentIndex + 1, version);
builder.Path = String.Join('/', segments);
return builder.Uri;
}
}
Cała magia dzieje się w metodzie CreateUriWithVersion
.
Na początku posługuję się magiczną klasą UriBuilder
. Biorę część, która nazywa się Path
(czyli np: /api/clients
zamiast całego https://example.com/api/clients
) i rozwalam ją na poszczególne elementy.
Teraz na tej liście szukam fragmentu ścieżki „api”. Jeśli nie znajdę, to znaczy że to nie jest ścieżka do wersjonowania i zwracam oryginalne Uri
. Jeśli jednak znajdę, to dodaję info o wersji do tej listy. Na koniec wszystko łączę z powrotem do jednego stringa, uzyskując: /api/v1/clients.
Jak widzisz w metodzie SendAsync
– swobodnie mogę sobie działać na wychodzącej wiadomości i nawet zmienić jej endpoint, na który strzela – co też czynię w tym momencie.
Na koniec wywołuję bazowe SendAsync
, które wychodzi już na dobrą drogę z wersją: https://example.com/api/v1/clients
.
Odświeżanie bearer tokena
To jest zdecydowanie bardziej użyteczny przypadek. Jeśli nie wiesz co to Bearer Token
i jak działa uwierzytelnianie w API, koniecznie przeczytaj ten artykuł zanim pójdziesz dalej.
Generalnie, jeśli wyślesz jakieś żądanie z ustawionym Bearer Tokenem
i dostaniesz odpowiedź 401 to znaczy, że token jest niepoprawny – jeśli wcześniej działał, to wygasł. W tym momencie trzeba go odświeżyć. Możemy w tym celu posłużyć się Delegating Handlerem
. I wierz mi – zanim poznałem ten mechanizm, robiłem to na inne sposoby, ale DelegatingHandler
jest najlepszym, najprostszym i najczystszym rozwiązaniem. A robię to w taki sposób:
public class AuthDelegatingHandler: DelegatingHandler
{
private readonly ILogger<AuthDelegatingHandler> _logger;
private readonly IAuthTokenProvider _tokenProvider;
public AuthDelegatingHandler(ILogger<AuthDelegatingHandler> logger, IAuthTokenProvider tokenProvider)
{
_logger = logger;
_tokenProvider = tokenProvider;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await AssignAuthHeader(request);
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!await TryRefreshingTokens(request))
return response;
else
{
await AssignAuthHeader(request);
return await base.SendAsync(request, cancellationToken);
}
}
else
return response;
}
private async Task AssignAuthHeader(HttpRequestMessage request)
{
TokenInfo tokens = await _tokenProvider.ReadToken();
if (tokens == null)
{
request.Headers.Authorization = null;
return;
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
}
private async Task<bool> TryRefreshingTokens(HttpRequestMessage request)
{
_logger.LogInformation("Unauthorized, trying reauthorization");
var rtResponse = await CallRefreshEndpoint(request);
if (!rtResponse.IsSuccessStatusCode)
return false;
else
{
await ExchangeTokens(rtResponse);
return true;
}
}
private async Task<HttpResponseMessage> CallRefreshEndpoint(HttpRequestMessage request)
{
using var refreshRequest = new HttpRequestMessage();
try
{
var currentTokens = await _tokenProvider.ReadToken();
refreshRequest.Method = HttpMethod.Post;
refreshRequest.Content = JsonContent.Create(currentTokens);
refreshRequest.RequestUri = GetTokenRefreshEndpoint(request);
return await base.SendAsync(refreshRequest, CancellationToken.None);
}catch(Exception ex)
{
_logger.LogError(ex, "");
throw;
}
}
private Uri GetTokenRefreshEndpoint(HttpRequestMessage request)
{
var baseAddress = request.RequestUri.GetLeftPart(UriPartial.Authority);
var baseUri = new Uri(baseAddress);
var endpointPart = "api/token/refresh";
return new Uri(baseUri, endpointPart);
}
private async Task ExchangeTokens(HttpResponseMessage msg)
{
JsonSerializerOptions o = new();
o.FromTradesmanDefaults();
TokenResultDto data = await msg.Content.ReadFromJsonAsync<TokenResultDto>(o);
TokenInfo ti = new TokenInfo
{
AccessToken = data.AccessToken,
RefreshToken = data.RefreshToken
};
await _tokenProvider.WriteToken(ti);
}
}
A teraz wyjaśnię Ci ten kod krok po kroku.
Przede wszystkim wstrzykuję do tej klasy interfejs IAuthTokenProvider
. To jest mój własny interfejs. Zadaniem klasy, która go implementuje jest zapisanie i odczytanie informacji o tokenach – bearer token i refresh token. Miejsce i sposób zapisu zależy już ściśle od konkretnego projektu. W aplikacji desktopowej lub mobilnej może to być po prostu pamięć. W aplikacji internetowej (np. Blazor) może to być local storage. Unikałbym zapisywania takich informacji w ciastkach, ponieważ ciastka są wysyłane przez sieć z każdym żądaniem. LocalStorage to miejsce do przetrzymywania danych na lokalnym komputerze. Dane tam trzymane nigdzie nie wychodzą. Dlatego to jest dobre miejsce jeśli chodzi o aplikacje internetowe.
W każdym razie spójrz teraz na metodę SendAsync
:
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await AssignAuthHeader(request);
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!await TryRefreshingTokens(request))
return response;
else
{
await AssignAuthHeader(request);
return await base.SendAsync(request, cancellationToken);
}
}
else
return response;
}
Dodanie nagłówka autoryzacyjnego
W pierwszej linijce dodaję nagłówek autoryzacyjny do żądania. A robi się to w ten sposób:
private async Task AssignAuthHeader(HttpRequestMessage request)
{
TokenInfo tokens = await _tokenProvider.ReadToken();
if (tokens == null)
{
request.Headers.Authorization = null;
return;
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
}
Czyli po prostu odczytuję informacje o tokenach. Jeśli ich nie ma, to znaczy, że albo nastąpiło wylogowanie, albo tokeny jeszcze nie zostały uzyskane. W takim przypadku upewniam się, że nagłówek autoryzacyjny nie istnieje.
Jeśli jednak tokeny istnieją, to wystarczy utworzyć nagłówek autoryzacyjny.
W kolejnym kroku po prostu przesyłam żądanie z nagłówkiem autoryzacyjnym:
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await AssignAuthHeader(request);
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!await TryRefreshingTokens(request))
return response;
else
{
await AssignAuthHeader(request);
return await base.SendAsync(request, cancellationToken);
}
}
else
return response;
}
Odświeżenie tokena
I teraz tak, w drugiej linijce przesyłam żądanie dalej. I sprawdzam odpowiedź. I jeśli odpowiedź jest inna niż 401, zwracam tą odpowiedź i fajrant. Jeśli jednak otrzymałem zwrotkę 401 – Unauthorized, to tak jak pisałem wcześniej – prawdopodobnie bearer token wygasł i trzeba go odświeżyć. A robię to w taki sposób:
private async Task<bool> TryRefreshingTokens(HttpRequestMessage request)
{
_logger.LogInformation("Unauthorized, trying reauthorization");
var rtResponse = await CallRefreshEndpoint(request);
if (!rtResponse.IsSuccessStatusCode)
return false;
else
{
await ExchangeTokens(rtResponse);
return true;
}
}
private async Task<HttpResponseMessage> CallRefreshEndpoint(HttpRequestMessage request)
{
using var refreshRequest = new HttpRequestMessage();
try
{
var currentTokens = await _tokenProvider.ReadToken();
refreshRequest.Method = HttpMethod.Post;
refreshRequest.Content = JsonContent.Create(currentTokens);
refreshRequest.RequestUri = GetTokenRefreshEndpoint(request);
return await base.SendAsync(refreshRequest, CancellationToken.None);
}catch(Exception ex)
{
_logger.LogError(ex, "");
throw;
}
}
Spójrz na metodę CallRefreshEndpoint
, bo ona jest tutaj sercem.
Na początku tworzę zupełnie nowy RequestMessage
. Ustawiam go tak, żeby strzelił na końcówkę do odświeżania tokenów i wysyłam. Tak więc, jak widzisz mogę przesłać zupełnie inny message niż ten, który dostałem.
W każdym razie dostaję zwrotkę, która mogła się zakończyć wydaniem nowych tokenów. Jeśli tak, to podmieniam je na stare. I spójrz teraz ponownie na metodę SendAsync
:
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await AssignAuthHeader(request);
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!await TryRefreshingTokens(request))
return response;
else
{
await AssignAuthHeader(request);
return await base.SendAsync(request, cancellationToken);
}
}
else
return response;
}
Jeśli nie udało się odświeżyć tokenów (Refresh Token też mógł wygasnąć), wtedy nie robę już niczego więcej, po prostu zwracam odpowiedź do aplikacji. Teraz aplikacja może zarządzić, co zrobić z takim klopsem. Może np. pokazać stronę do logowania.
Jeśli jednak udało się odświeżyć tokeny, to ponownie przypisuję nagłówek autoryzacyjny (z nowym bearer tokenem) i jeszcze raz przesyłam oryginalną wiadomość. W tym momencie autoryzacja powinna się już powieść.
Handlery są łańcuchem
Pamiętaj, że DelegatingHandlers
działają jak łańcuch. Czyli pierwszy uruchamia drugi, drugi trzeci itd. Jeśli wywołujesz metodę base.SendAsync
, to do roboty rusza kolejny handler w łańcuchu. Aż w końcu dochodzi do HttpClientHandler
, który fizycznie przesyła komunikat do serwera.
Czyli nie obawiaj się rekurencji. Jeśli wywołujesz base.SendAsync
, to nie dostaniesz tego żądania w tym handlerze, tylko w kolejnym.
Dzięki za przeczytanie artykułu. Mam nadzieję, że w głowie pojawiły Ci się pomysły, jak można wykorzystać delegating handlery na różne sposoby. Jeśli czegoś nie zrozumiałeś lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu 🙂
Obrazek dla artykułu: Matryoshka plik wektorowy utworzone przez macrovector – pl.freepik.com