utworzone przez Adam Jachocki | wrz 5, 2022 | .NetCore, Architektura, Backend, Programowanie
Wstęp
Często mówimy o tym, czym jest WebApi, jak je tworzyć, a jak nie. Ale jakoś nie mówimy o tym jak stworzyć dobrze fajnego klienta do tego WebAPI.
Mogłoby się zdawać, że wystarczy utworzyć instancję HttpClient i wywołać odpowiednią końcówkę. I czasem nawet można tak zrobić. Ale jeśli chcesz mieć naprawdę dobrego klienta do większego API niż tylko dwie końcówki, to ten artykuł pokaże Ci jak do tego podejść na konkretnym przykładzie.
Jest NuGetowa paczka – RestSharp. Jest to bardzo popularna darmowa biblioteka, która zdecydowanie ułatwia tworzenie klientów API. Jednak w tym artykule nie posłużymy się nią. Zrobimy coś sami. Potem sam zdecydujesz, czy wolisz tworzyć takie rozwiązania samodzielnie, czy z użyciem RestSharpa.
Przede wszystkim – WebAPI
Żeby klient API miał sens, musi przede wszystkim łączyć się z jakimś API. Dlatego też przygotowałem dość proste rozwiązanie, na którym będziemy pracować. Możesz je pobrać z GitHuba.
Uwaga! Nie zwracaj za bardzo uwagi na kod API – jest bardzo prosty, banalny i nie we wszystkich aspektach super poprawny. Nie zajmujemy się tutaj WebAPI, tylko klientem do API.
To Api trzyma dane w słowniku, to znaczy że po ponownym uruchomieniu, wszystkie dane znikną.
Api ma kilka końcówek, możesz sobie je zobaczyć, uruchamiając swaggera. Z grubsza to:
- POST – /api/clients/all – pobiera listę klientów (dlaczego POST – o tym niżej)
- POST – /api/clients – dodaje klienta
- GET – /api/clients/{id} – pobiera klienta o konkretnym id
- DELETE – /api/clients/{id} – usuwa klienta o konkretnym id
- POST – /api/orders/all – pobiera zamówienia (dlaczego POST – o tym niżej)
- POST – /api/orders – dodaje zamówienie
- GET – /api/orders/client/{clientId} – pobiera zamówienia dla konkretnego klienta
- GET – /api/orders/{id} – pobiera zamówienie o konkretnym id
Także mamy kilka końcówek podzielonych na dwa kontrolery.
Zaczynamy pisać klienta
OK, skoro już wiemy jak mniej więcej wygląda API, możemy utworzyć projekt, w którym napiszemy klienta. Niech to będzie zwykły projekt Class Library.
Model DTO
Najpierw musimy utworzyć modele DTO. DTO czyli Data Transfer Object – są to klasy, które przekazują dane między API, a klientem. Modele DTO mogą być jak najgłupsze się da. To po prostu worek na dane. Nic więcej.
Teraz możesz zapytać – po co tworzyć dodatkowy model, skoro mamy już dokładny model bazodanowy? Nie lepiej ten model bazodanowy z projektu WebApi przenieść do jakiegoś współdzielonego?
W tym konkretnym przypadku banalnej aplikacji – pewnie tak. Natomiast przy aplikacjach bardziej rozbudowanych przekazywanie danych za pomocą modeli bazodanowych może okazać się baaaardzo problematyczne. Sam wiele lat temu zrobiłem taki błąd. W pewnym momencie okazało się, że muszę stosować jakieś dziwne haki i czary, żeby to wszystko jakoś działało. Dlatego – stwórz osobny model DTO.
W przykładowej aplikacji są w projekcie Models
. Modele DTO wyglądają prawie tak samo jak modele bazodanowe. Specjalnie dodałem do modeli bazodanowych jedną właściwość (IsDeleted
), żeby je czymś rozróżnić.
Zwróć uwagę na dwie klasy:
GetClientsRequestDto
:
public class GetClientsRequestDto
{
public int Skip { get; set; }
public int Take { get; set; }
}
GetClientsResultDto
:
public class GetClientsResultDto
{
public IEnumerable<ClientDto> Data { get; init; }
public int Offset { get; init; }
public GetClientsResultDto(IEnumerable<ClientDto> data, int offset)
{
Data = data;
Offset = offset;
}
}
W standardowym tutorialu tworzenia WebApi zobaczyłbyś, że gdy żądasz listy klientów, API zwraca po prostu listę klientów, np: IEnumerable<ClientDto>
.
Jednak w prawdziwym świecie to może być za mało. Dlatego też stworzyłem dwie dodatkowe klasy:
GetClientsRequestDto
– obiekt tej klasy będzie wysyłany wraz z żądaniem pobrania listy klientówGetClientsResultDto
– obiekt tej klasy będzie zwracany przez API zamiast zwykłej listy klientów.
Jak widzisz, te klasy zawierają w sobie informacje ograniczające ilość pobieranych danych. Jeśli miałbyś bazę z 10000 klientów i z jakiegoś powodu chciałbyś pobrać ich listę, to zupełnie bez sensu byłoby pobieranie wszystkich 10000 rekordów. To naprawdę sporo danych. Zamiast tego możesz pobierać te dane partiami i napisać jakiś prosty mechanizm paginacji. Do tego mogą właśnie służyć te dodatkowe klasy.
Analogicznie zrobiłem dla modelu OrderDto.
Abstrakcja
Skoro już mamy modele DTO, możemy pomyśleć o abstrakcji, która umożliwi nam testowanie klienta API.
Zgodnie z regułą pojedynczej odpowiedzialności (signle responsibility) klient API nie powinien być odpowiedzialny za wszystkie operacje związane z API. Ale powinien dać taką możliwość. Jak to osiągnąć? Poprzez dodatkowe klasy operacji. I tak będziemy mieć klasę odpowiedzialną za operacje na zamówieniach i drugą odpowiedzialną za klientów. Stwórzmy teraz takie abstrakcje:
public interface IClientOperations
{
public Task<ClientDto> AddClient(ClientDto data);
public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
public Task<ClientDto> GetClientById(int id);
public Task<bool> DeleteClient(int id);
}
To jest interfejs, którego implementacja będzie odpowiedzialna za operacje na klientach. Analogicznie stworzymy drugi interfejs – do zamówień:
public interface IOrderOperations
{
public Task<OrderDto> AddOrder(OrderDto order);
public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
public Task<bool> DeleteOrder(int id);
}
To są bardzo proste interfejsy i na pierwszy rzut oka wszystko jest ok. Ale co jeśli z WebApi otrzymasz jakiś konkretny błąd? Np. podczas dodawania nowego klienta mógłbyś otrzymać błąd w stylu: „Nazwa klienta jest za długa”. W taki sposób tego nie ogarniesz. Dlatego proponuję stworzyć dwie dodatkowe klasy, które będą przechowywały rezultat wywołania końcówki API:
public class BaseResponse
{
public int StatusCode { get; init; }
public bool IsSuccess { get { return StatusCode >= 200 && StatusCode <= 299 && string.IsNullOrWhitespace(ErrorMsg); } }
public string ErrorMsg { get; init; }
public BaseResponse(int statusCode = 200, string errMsg = "")
{
StatusCode = statusCode;
ErrorMsg = errMsg;
}
}
public class DataResponse<T> : BaseResponse
{
public T Data { get; init; }
public DataResponse(T data, int statusCode = 200, string errMsg = "")
: base(statusCode, errMsg)
{
Data = data;
}
}
Klasa BaseResponse i operacja zakończona poprawnie
Klasa BaseResponse
będzie przechowywała kod odpowiedzi wraz z ewentualnym komunikatem o błędzie. Wg specyfikacji HTTP wszystkie kody od 200 do 299 włącznie oznaczają operację zakończoną poprawnie, dlatego też IsSuccess
jest tak skonstruowane.
Teraz pojawia się pytanie – co oznacza „operacja zakończona poprawnie”? W kontekście WebApi zazwyczaj chodzi tutaj o to, że dane przesłane w żądaniu były prawidłowe, na serwerze nic się nie wywaliło, nie było problemu z autoryzacją i serwer odpowiedział prawidłowo. Jednak nie znaczy to, że operacja zakończyła się tak, jak byśmy sobie tego życzyli.
To trochę dziwnie brzmi, zatem pokażę Ci pewien przykład. Załóżmy, że chcesz pobrać klienta o ID = 5. Wg specyfikacji REST Api, jeśli taki klient nie istnieje, powinieneś otrzymać zwrotkę z kodem 404. Jednak błąd 404 oznacza również, że nie znaleziono określonej strony (końcówki API). Jest to pewien znany problem. Czasami się to tak zostawia, czasem można rozróżnić w taki sposób, że z WebAPI zwracamy kod 200 – operacja się powiodła, ale dołączamy informację o błędzie w odpowiedzi np: „Nie ma klienta o takim ID”.
To nam wszystko załatwia klasa BaseResponse
.
Klasa DataResponse
Jak widzisz, DataResponse
dziedziczy po BaseResponse
. Jedyną różnicą jest to, że DataResponse
przechowuje dodatkowo dane, które mogły przyjść w odpowiedzi. Teraz, mając takie klasy, możemy zmienić zwracany typ z interfejsów IClientOperations
i IOrderOperations
. Do tej pory wyglądało to tak:
public interface IClientOperations
{
public Task<ClientDto> AddClient(ClientDto data);
public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
public Task<ClientDto> GetClientById(int id);
public Task<bool> DeleteClient(int id);
}
public interface IOrderOperations
{
public Task<OrderDto> AddOrder(OrderDto order);
public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
public Task<bool> DeleteOrder(int id);
}
A teraz będziemy mieli coś takiego:
public interface IClientOperations
{
public Task<DataResponse<ClientDto>> AddClient(ClientDto data);
public Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data);
public Task<DataResponse<ClientDto>> GetClientById(int id);
public Task<BaseResponse> DeleteClient(int id);
}
public interface IOrderOperations
{
public Task<DataResponse<OrderDto>> AddOrder(OrderDto order);
public Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
public Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data);
public Task<BaseResponse> DeleteOrder(int id);
}
Interfejs IApiClient
Skoro mamy już interfejsy dla poszczególnych operacji, możemy teraz napisać sobie interfejs do ApiClienta
. I tutaj znów – ta abstrakcja nie jest konieczna. Jednak bez niej nie będziesz w stanie testować jednostkowo kodu, który używa klienta API.
Interfejs jest banalny:
public interface IApiClient
{
IClientOperations ClientOperations { get; }
IOrderOperations OrderOperations { get; }
}
Jak widzisz, klient API będzie dawał dostęp do poszczególnych operacji. To teraz zajmijmy się implementacją poszczególnych operacji, która zasadniczo będzie prosta.
Implementacja IClientOperations
Do komunikacji z WebApi wykorzystujemy HttpClient
– dlatego też on musi znaleźć się w konstruktorze.
internal class ClientOperations : IClientOperations
{
private readonly HttpClient _httpClient;
public ClientOperations(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<DataResponse<ClientDto>> AddClient(ClientDto data)
{
var response = await _httpClient.PostAsJsonAsync("clients", data);
return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
}
public async Task<BaseResponse> DeleteClient(int id)
{
var response = await _httpClient.DeleteAsync($"clients/{id}");
return await ResponseFactory.CreateBaseResponse(response);
}
public async Task<DataResponse<ClientDto>> GetClientById(int id)
{
var response = await _httpClient.GetAsync($"clients/{id}");
return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
}
public async Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data)
{
var response = await _httpClient.PostAsJsonAsync("clients/all", data);
return await ResponseFactory.CreateDataResponse<GetClientsResultDto>(response, DefaultJsonSerializerOptions.Options);
}
}
Dalej mamy implementację poszczególnych metod. Każda z nich jest oparta dokładnie na tej samej zasadzie:
- wyślij żądanie na odpowiednią końcówkę
- stwórz DataResponse/BaseResponse na podstawie otrzymanej odpowiedzi –
HttpResponseMessage
.
Zwróć uwagę tutaj na trzy rzeczy.
- Klasa
DefaultJsonSerializerOptions
– jest to klasa, która trzyma domyślne dla aplikacji ustawienia serializacji JSON. W naszej aplikacji nie chcemy, żeby serializacja brała pod uwagę wielkość znaków. Jeśliby brała wtedy taki obiekt:
public class MyClass
{
public int Id { get; set; }
public string Name { get; set; }
}
nie zostałby powiązany z takim jsonem:
{
"id": 5,
"name": "Adam"
}
Z tego powodu, że występuje różnica w wielkości znaków. Niestety domyślne ustwienia serializatora z Microsoft biorą pod uwagę wielkość znaków. My chcemy tego uniknąć, dlatego powstała klasa, która przechowuje odpowiednie opcje. Znajduje się w projekcie Common
.
-
ResponseFactory
to pomocnicza klasa, która z odpowiedzi HttpRequestMessage
tworzy interesujące nas obiekty DataResponse
lub BaseResponse
– omówimy ją za chwilę. - Pobieranie danych za pomocą POST…
No właśnie, spójrz na metodę GetClients
. Ona pobiera dane za pomocą POST
, a nie GET
. Dlaczego tak jest? Czyżby to jaka herezja?
Przyczyną jest obecność klasy GetClientsRequestDto
:
public class GetClientsRequestDto
{
public int Skip { get; set; }
public int Take { get; set; }
}
Metoda GET nie może mieć żadnych danych w ciele żądania. Oczywiście w tym przypadku można by te dwie właściwości włączyć do query stringa, wywołując końcówkę np: api/clients/all?skip=0&take=10
. Jeśli jednak masz sporo więcej do filtrowania, do tego jakieś sortowanie i inne rzeczy… lub z jakiegoś powodu takie dane nie powinny być w query stringu, to spokojnie możesz je wrzucić do POSTa. Nikt Cię za to nie wychłosta 😉 Co więcej – to jest normalną praktyką w niektórych WebAPI
.
ResponseFactory
Jak już wspomniałem, klasa ResponseFactory
jest odpowiedzialna za utworzenie BaseResponse
/DataResponse
na podstawie przekazanego HttpResponseMessage
. Jej implementacja w naszym przykładzie wygląda tak:
internal static class ResponseFactory
{
public static async Task<BaseResponse> CreateBaseResponse(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode)
return new BaseResponse((int)response.StatusCode);
else
return new BaseResponse((int)response.StatusCode, await GetErrorMsgFromResponse(response));
}
public static async Task<DataResponse<T>> CreateDataResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
{
if (response.IsSuccessStatusCode)
{
T data = await GetDataFromResponse<T>(response, jsonOptions);
return new DataResponse<T>(data, (int)response.StatusCode);
}
else
{
return new DataResponse<T>(default(T), (int)response.StatusCode, await GetErrorMsgFromResponse(response));
}
}
private static async Task<T> GetDataFromResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
{
string content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content, jsonOptions);
}
private static async Task<string> GetErrorMsgFromResponse(HttpResponseMessage response)
{
string result = await response.Content.ReadAsStringAsync();
if (string.IsNullOrEmpty(result))
return response.ReasonPhrase;
else
return result;
}
}
Nie ma tu niczego skomplikowanego. Wszystko sprowadza się do tego, że odczytuję dane z contentu odpowiedzi i deserializuję je do odpowiedniego obiektu. To wszystko. Jedyne, co może być ciekawe to metoda GetErrorMsgFromResponse
, która ma zwrócić komunikat błędu. Zakładam, że jeśli błąd wystąpi, zostanie umieszczony po prostu jako content odpowiedzi – tak jest skonstruowane przykładowe WebAPI.
Implementacja IOrderOperations
Jest analogiczna jak IClientOperations
, dlatego też nie będę jej omawiał. Kod wygląda tak:
internal class OrderOperations : IOrderOperations
{
private readonly HttpClient _httpClient;
public OrderOperations(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<DataResponse<OrderDto>> AddOrder(OrderDto order)
{
var response = await _httpClient.PostAsJsonAsync("orders", order);
return await ResponseFactory.CreateDataResponse<OrderDto>(response, DefaultJsonSerializerOptions.Options);
}
public async Task<BaseResponse> DeleteOrder(int id)
{
var response = await _httpClient.DeleteAsync($"orders/{id}");
return await ResponseFactory.CreateBaseResponse(response);
}
public async Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data)
{
var response = await _httpClient.PostAsJsonAsync("orders/all", data);
return await ResponseFactory.CreateDataResponse<GetOrdersResultDto>(response, DefaultJsonSerializerOptions.Options);
}
public async Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data)
{
var response = await _httpClient.PostAsJsonAsync($"orders/client/{clientId}", data);
return await ResponseFactory.CreateDataResponse<GetOrdersResultDto> (response, DefaultJsonSerializerOptions.Options);
}
}
Implementacja ApiClient
OK, nadszedł wreszcie czas na napisanie implementacji głównego klienta API:
public class ApiClient : IApiClient
{
public IClientOperations ClientOperations { get; private set; }
public IOrderOperations OrderOperations { get; private set; }
private readonly HttpClient _httpClient;
public ApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
ClientOperations = new ClientOperations(_httpClient);
OrderOperations = new OrderOperations(_httpClient);
}
}
Tutaj HttpClient
przychodzi z dependency injection. Następnie są tworzone odpowiednie obiekty – ClientOperations
i OrderOperations
, do których przekazujemy tego HttpClienta
. Prawda, że proste?
HttpPipeline, czyli zupełnie nowy świat
Żeby klient API był wymuskany, można do niego dodać HttpPipeline. Pisałem o tym w tym artykule, więc nie będę się powtarzał. Zostawię Ci tylko zajawkę, że dzięki Http Pipeline, możesz zrobić zupełnie wszystko z żądaniem (zanim dotrze do celu) i odpowiedzią (zanim wróci do HttpClient). To zupełnie nowy świat możliwości. Przede wszystkim możesz automatycznie ustawiać wersję API, możesz odświeżać bearer token, możesz logować całe żądanie. Nic Cię tu nie ogranicza. Dlatego koniecznie przeczytaj ten artykuł, żeby mieć pełen obraz.
Przykładowe użycie
W repozytorium do tego artykułu jest umieszczony projekt WebApp – jest to bardzo prosta aplikacja RazorPages, które po krótce pokazuje użycie klienta.
UWAGA! Kod w aplikacji przykładowej jak i w WebApi jest podatny na różne rodzaje ataków. Dlatego nie stosuj takich „uproszczeń” w prawdziwym życiu. Różne ataki i jak się przed nimi chronić zostały opisane w tej książce.
W ramach ćwiczeń możesz spróbować zaimplementować w tym rozwiązaniu paginację, a także resztę operacji związanych z zamówieniami.
Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz przykładał większą wagę do klientów API, które tworzysz i artykuł podpowiedział Ci jak to zrobić dobrze. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu 🙂
Obrazek artykułu: Server icons created by Freepik – Flaticon
utworzone przez Adam Jachocki | sie 24, 2022 | .NetCore, Backend, Programowanie
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
utworzone przez Adam Jachocki | maj 23, 2022 | .NetCore, Backend, Frontend, Programowanie
Wstęp
Osoby niezdające sobie sprawy, jak pod kapeluszem działa HttpClient
, często używają go źle. Ja sam, zaczynając używać go kilka lat temu, robiłem to źle – traktowałem go jako typową klasę IDisposable
. Skoro utworzyłem, to muszę usunąć. W końcu ma metodę Dispose
, a więc trzeba jej użyć. Jednak HttpClient
jest nieco wyjątkowy pod tym względem i należy do niego podjeść troszkę inaczej.
Raz a dobrze!
HttpClient
powinieneś stworzyć tylko raz na cały system. No, jeśli strzelasz do różnych serwerów, możesz utworzyć kilka klientów – po jednym na serwer. Oczywiście przy założeniu, że nie komunikujesz się z kilkuset różnymi serwerami, bo to zupełnie inna sprawa 😉
Przeglądając tutoriale, zbyt często widać taki kod:
using var client = new HttpClient();
To jest ZŁY sposób utworzenia HttpClient
, a autorzy takich tutoriali nigdy (albo bardzo rzadko) o tym nie wspominają albo po prostu sami nie wiedzą.
Dlaczego nie mogę ciągle tworzyć i usuwać?
To jest akapit głównie dla ciekawskich.
Każdy request powoduje otwarcie nowego socketu. Spójrz na ten kod:
for(int i = 0; i < 10; i++)
{
using var httpClient = new HttpClient();
//puszczenie requestu
}
On utworzy 10 obiektów HttpClient
, każdy z tych obiektów otworzy własny socket. Jednak, gdy zwolnimy obiekt HttpClient
, socket nie zostanie od razu zamknięty. On będzie sobie czekał na jakieś zagubione pakiety (czas oczekiwania na zamknięcie socketu ustawia się w systemie). W końcu zostanie zwolniony, ale w efekcie możesz uzyskać coś, co nazywa się socket exhaustion (wyczerpanie socketów). Ilość socketów w systemie jest ograniczona i jeśli wciąż otwierasz nowe, to w końcu – jak to mawiał klasyk – nie będzie niczego.
Z drugiej strony, jeśli masz tylko jeden HttpClient
(singleton), to tutaj pojawia się inny problem. Odświeżania DNSów. Raz utworzony HttpClient
po prostu nie będzie widział odświeżenia DNSów.
Te problemy właściwie nie istnieją jeśli masz aplikację desktopową, którą użytkownik włącza na chwilę. Wtedy hulaj dusza, piekła nie ma. Ale nikt nie zagwarantuje Ci takiego używania Twojej apki. Poza tym, coraz więcej rzeczy przenosi się do Internetu. Wyobraź sobie teraz kontroler, który tworzy HttpClient
przy żądaniu i pobiera jakieś dane z innego API. Tutaj katastrofa jest murowana.
Poprawne tworzenie HttpClient
Zarówno użytkownicy jak i Microsoft zorientowali się w pewnym momencie, że ten HttpClient
nie jest idealny. Od jakiegoś czasu mamy dostęp do HttpClientFactory
. Jak nazwa wskazuje jest to fabryka dla HttpClienta
. I to przez nią powinniśmy sobie tego klienta tworzyć.
Ta fabryka naprawia oba opisane problemy. Robi to przez odpowiednie zarządzanie cyklem życia HttpMessageHandler
, który to bezpośrednio jest odpowiedzialny za całe zamieszanie i jest składnikiem HttpClient
.
Jest kilka możliwości utworzenia HttpClient
za pomocą fabryki i wszystkie działają z Dependency Injection. Teraz je sobie omówimy. Zakładam, że wiesz czym jest dependency injection i jak z niego korzystać w .Net.
Podstawowe użycie
Podczas rejestracji serwisów, zarejestruj HttpClient
w taki sposób:
services.AddHttpClient();
Następnie, w swoim serwisie, możesz pobrać HttpClient
w taki sposób:
class MyService
{
IHttpClientFactory factory;
public MyService(IHttpClientFactory factory)
{
this.factory = factory;
}
public void Foo()
{
var httpClient = factory.CreateClient();
}
}
Przy wywołaniu CreateClient
powstanie wprawdzie nowy HttpClient
, ale może korzystać z istniejącego HttpMessageHandler
’a, który odpowiada za wszystkie problemy. Fabryka jest na tyle mądra, że wie czy powinna stworzyć nowego handlera, czy posłużyć się już istniejącym.
Takie użycie świetnie nadaje się do refaktoru starego kodu, gdzie tworzenie HttpClient
’a zastępujemy eleganckim CreateClient
z fabryki.
Klient nazwany (named client)
Taki sposób tworzenia klienta wydaje się być dobrym pomysłem w momencie, gdy używasz różnych HttpClientów
na różnych serwerach z różną konfiguracją. Możesz wtedy rozróżnić poszczególne „klasy” HttpClient
po nazwie. Rejestracja może wyglądać tak:
services.AddHttpClient("GitHub", httpClient =>
{
httpClient.BaseAddress = new Uri("https://api.github.com/");
// The GitHub API requires two headers.
httpClient.DefaultRequestHeaders.Add(
HeaderNames.Accept, "application/vnd.github.v3+json");
httpClient.DefaultRequestHeaders.Add(
HeaderNames.UserAgent, "HttpRequestsSample");
});
services.AddHttpClient("MyWebApi", httpClient =>
{
httpClient.BaseAddress = new Uri("https://example.com/api");
httpClient.RequestHeaders.Add("x-login-data", config["ApiKey"]);
});
Zarejestrowaliśmy tutaj dwóch klientów. Jeden, który będzie używany do połączenia z GitHubem i drugi do jakiegoś własnego API, które wymaga klucza do logowania.
A jak to pobrać?
class MyService
{
IHttpClientFactory factory;
public MyService(IHttpClientFactory factory)
{
this.factory = factory;
}
public void Foo()
{
var gitHubClient = factory.CreateClient("GitHub");
}
}
Analogicznie jak przy podstawowym użyciu. W metodzie CreateClient
podajesz tylko nazwę klienta, którego chcesz utworzyć. Z każdym wywołaniem CreateClient
idzie też kod z Twoją konfiguracją.
Klient typowany (typed client)
Jeśli Twoja aplikacja jest zorientowana serwisowo, możesz wstrzyknąć klienta bezpośrednio do serwisu. Skonfigurować go możesz zarówno w serwisie jak i podczas rejestracji.
Kod powie więcej. Rejestracja:
services.AddHttpClient<MyService>(client =>
{
client.BaseAddress = new Uri("https://api.services.com");
});
Taki klient zostanie wstrzyknięty do Twojego serwisu:
class MyService
{
private readonly HttpClient _client;
public MyService(HttpClient client)
{
_client = client;
}
}
Tutaj możesz dodatkowo klienta skonfigurować. HttpClient
używany w taki sposób jest rejestrowany jako Transient.
Zabij tego HttpMessageHandler’a!
Jak już pisałem wcześniej, to właśnie HttpMessageHandler
jest odpowiedzialny za całe zamieszanie. I to fabryka decyduje o tym, kiedy utworzyć nowego handlera, a kiedy wykorzystać istniejącego.
Jednak domyślna długość życia handlera jest określona na dwie minuty. Po tym czasie handler jest usuwany.
Ale możesz mieć wpływ na czas jego życia:
services.AddHttpClient("c1", client =>
{
client.BaseAddress = new Uri("http://api.c1.pl");
client.DefaultRequestHeaders.Add("x-login", "asd");
}).SetHandlerLifetime(TimeSpan.FromMinutes(10));
Używając metody SetHandlerLifetime
podczas konfiguracji, możesz określić maksymalny czas życia handlera.
Konfiguracja HttpMessageHandler
Czasem bywa tak, że musisz nieco skonfigurować tego handlera. Możesz to zrobić, używając metody ConfigurePrimaryHttpMessageHandler
:
builder.Services.AddHttpClient("c1", client =>
{
client.BaseAddress = new Uri("http://api.c1.pl");
client.DefaultRequestHeaders.Add("x-login", "asd");
}).ConfigurePrimaryHttpMessageHandler(() =>
{
return new HttpClientHandler
{
PreAuthenticate = true,
UseProxy = false
};
};
Pobieranie dużych ilości danych
Jeśli pobierasz dane lub pliki większe niż 50 MB powinieneś sam je buforować zamiast korzystać z domyślnych mechanizmów. One mogę mocno obniżyć wydajność Twojej aplikacji. I wydawać by się mogło, że poniższy kod jest super:
byte[] fileBytes = await httpClient.GetByteArrayAsync(uri);
File.WriteAllBytes("D:\\test.avi", fileBytes);
Niestety nie jest. Przede wszystkim zajmuje taką ilość RAMu, jak wielki jest plik. RAM jest zajmowany na cały czas pobierania. Ponadto przy pliku testowym (około 1,7 GB) nie działa. Task
, w którym wykonywał się ten kod w pewnym momencie po prostu rzucił wyjątek TaskCancelledException
.
Co więcej w żaden sposób nie możesz wznowić takiego pobierania, czy też pokazać progressu. Jak więc pobierać duże pliki HttpClientem
? W taki sposób (to nie jest jedyna słuszna koncepcja, ale powinieneś iść w tę stronę):
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Test", "1.0"));
var httpRequestMessage = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = uri
};
using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead);
if (!httpResponseMessage.IsSuccessStatusCode)
return;
var fileSize = httpResponseMessage.Content.Headers.ContentLength;
using Stream sourceStream = await httpResponseMessage.Content.ReadAsStreamAsync();
using Stream destStream = File.Open("D:\\test.avi", FileMode.Create);
var buffer = new byte[8192];
ulong bytesRead = 0;
int bytesInBuffer = 0;
while((bytesInBuffer = await sourceStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
{
bytesRead += (ulong)bytesInBuffer;
Downloaded = bytesRead;
await destStream.WriteAsync(buffer);
await Dispatcher.InvokeAsync(() =>
{
NotifyPropertyChanged(nameof(Progress));
NotifyPropertyChanged(nameof(Division));
});
}
W pierwszej linijce ustawiam przykładową nazwę UserAgenta. Na niektórych serwerach przy połączeniach SSL jest to wymagane.
Następnie wołam GET na adresie pliku (uri to dokładny adres pliku, np: https://example.com/files/big.avi).
Potem już czytam w pętli poszczególne bajty. To mi umożliwia pokazanie progressu pobierania pliku, a także wznowienie tego pobierania.
Możesz poeksperymentować z wielkością bufora. Jednak z moich testów wynika, że 8192 jest ok. Z jednej strony jego wielkość ma wpływ na szybkość pobierania danych. Z drugiej strony, jeśli bufor będzie zbyt duży, to może nie zdążyć się zapełnić w jednej iteracji i nie zyskasz na prędkości.
Koniec
No, to tyle co chciałem powiedzieć o HttpClient. To są bardzo ważne rzeczy, o których trzeba pamiętać. W głowie mam jeszcze jeden artykuł, ale to będą nieco bardziej… może nie tyle zaawansowane, co wysublimowane techniki korzystania z klienta.
Dzięki za przeczytanie artykułu. Jeśli znalazłeś w nim błąd lub czegoś nie zrozumiałeś, koniecznie podziel się w komentarzu.