Problems? To normalne, że w domenie chmury i aplikacji HTTP każdy serwis wzajemnie się odpytuje. Jak to robimy w .NET ? Poprzez klasę HTTPClient.

Jaki jest problem z HTTPClient?  To pytanie często wraca jak bumerang. W trakcie pisania aplikacji, a nawet na rozmowach kwalifikacyjnych. 

Pamiętam jak raz w pracy na ten problem kazali mi po prostu zainstalować paczkę NuGet "Flurl", która otacza HttpClienta i nie myśleć o tym, jakie on powoduje problemy.

Potem wiele miesięcy później zostałem postawiony przed pytaniem, dlaczego trzeba uważać na HTTPClienta w .NET i nie miałem na to dobrej odpowiedzi.

Myślisz sobie przecież to proste. Chce wykonać zapytanie HTTP do strony internetowej to co tworzę sobie instancje HTTPClient i wykonuje zapytanie. 

Zobaczmy co się stanie, gdy utworzę 20 instancji HTTPClient, który ma odpytać moją stronę.

Jeśli nie chcesz czytać artykuł zawsze możesz obejrzeć mój filmik na YouTube

public class Program
{
    private static async Task Main()
    {
        Console.WriteLine("Starting connections");

        for (int i = 0; i < 20; i++)
        {
            using (var client = new HttpClient())
            {
                var result = await client.GetAsync("https://cezarywalenciuk.pl");

                Console.WriteLine(result.StatusCode);
            }
        }

        Console.WriteLine("Connections done");
    }
}

Wykonaliśmy więc 20 zapytań GET do mojej strony.

 new HttpClient() 20 razy

Myślisz, że ten przykład jest głupi? Pomyśl sobie o kontrolerze w ASP.NET CORE, który z każdym wywołaniem metody tworzy instancję HTTPClient, aby pobrać jakąś informacje z innego serwisu.

Jaki problem sobie stworzyłem? 

Klasa HTTPClient implementuje IDisposable więc wyrażenie using powinna po nim wszystko posprzątać. Tak jednak nie jest. Mimo iż obiekt HTTPClient jest czyszczony to sockety sieciowe nie są natychmiastowo uwalniane.

To znaczy, że jeśli źle napiszesz kod to otrzymasz jedno otwarte połączenie za każdym razem, gdy utworzysz instancję HTTPClient. Nawet gdy połączenie nie jest już używane to zostanie ono otwarte na bardzo długo.

Możesz to sprawdzić. Ja uruchomiłem Windows Terminal z prawami administratora tobie może wystarczyć zwykły wiersz poleceń.

netstat w Windows Terminal

Wpisując "netstat" zobaczymy 20 otwartych socketów. Jak widać cały czas one są otwarte. Pomimo tego, że swoją aplikację konsolową wyłączyłem jakieś 3 minuty temu. Dlaczego tak jest?

20 portów otwartych

Co oznacz status TIME_WAIT ? Przeczytajmy dokumentację.

Time_WAIT dokumentacja

Co się dzieje z tymi połączeniami. Mimo iż ich już nie używamy, to połączenie nadal jest trzymane przy życiu tak na wszelki wypadek, gdyby miały przyjść jakieś opóźnione pakiety. Te połączenie zniknie dopiero po pewnym czasie określonym przez system operacyjny. 

W systemie Windows tą wartość można znaleźć pod rejestrem : TcpTimedWaitDelay 

Wiem więc, że sockety nie zamykają się natychmiastowo i może to potrwać przynajmniej 3-4 minuty lub więcej.

Tutaj zaczyna się problem. Liczba socketów jest ograniczona.

Tworzą więc na potęgę instancję HTTPClient jesteś w stanie zapełnić całą pulę.  Ten problem nazywa się Socket Exhaustion

Gdy to zrobisz to wtedy nie tylko twoja aplikacja przestanie działać, ale wszystkie aplikację sieciowe/strony, które są na twoim serwerze. Dlaczego? Ponieważ do każdej komunikacji sieciowe jest potrzebny socket.

Jak więc rozwiązać ten problem. Możemy otoczyć HTTPClient swoim kodem i dopilnować aby zawsze mieć tylko 1 instancję tej klasy.

public class Program
{
    private static async Task Main()
    {
        MyServiceClient m = new MyServiceClient();

        var todo = await m.GetToDo();

        Console.WriteLine(todo.Title);
        Console.WriteLine(todo.Completed);
    }
}

Oto przykład klasy, która pobiera JSON z testowego REST API/

public class MyServiceClient
{
    private static HttpClient _httpClient = new HttpClient();

    public MyServiceClient()
    {
        _httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
        _httpClient.Timeout = new TimeSpan(0, 0, 30);
    }

    public async Task<ToDo> GetToDo()
    {
        // Wywołanie API
        var response = await _httpClient.GetAsync("/todos/1");

        // Sprawdzenie czy mamy HTTP 200 OK
        response.EnsureSuccessStatusCode();

        // Odczytanie JSON z odpowiedzi
        var content = await response.Content.ReadAsStringAsync();

        // Deserializacja TODO
        var todo = JsonConvert.DeserializeObject<ToDo>(content);

        return todo;
    }
}

Oto klas, która będzie deserializowana.

public class ToDo
{
    public int UserId { get; set; }

    public int Id { get; set; }

    public string Title { get; set; }

    public bool Completed { get; set; }
}

Czy jednak rozwiązaniem tego problemu jest po prostu traktować HTTPClient jak singleton? A może mieć pole statyczne HTTPClient?

Pomyśli teraz.

W całym cyklu naszej aplikacji nie usuwamy HTTPClienta. Dla aplikacji ASP.NET CORE instancja singletona zostałaby utworzona ponownie dopiero, gdybyśmy uruchomili cały serwer ponownie co się rzadko zdarza.

Mamy teraz jeszcze inny problem, który został znaleziony .NET CORE. Gdy korzystamy HTTPClient jako singeltona lub pola statycznego wtedy ten obiekt nie szanuje zmiany sieci DNS. 

Jeśli twoja sieć DNS się nie zmienia to nie ma problemu. W przeciwnym wypadku musisz uruchomić cały serwer ponownie dla aplikacji ASP.NET CORE, a dla aplikacji konsolowej uruchomić ją ponownie.

Myślisz, że zmyślam, ale możesz przeczytać taki cytat z oficjalnej dokumentacji Microsoft.

Issues with the original HttpClient class available in .NET
Therefore, HttpClient is intended to be instantiated once and reused throughout the life of an application. Instantiating an HttpClient class for every request will exhaust the number of sockets available under heavy loads.....

 

 

Another issue that developers run into is when using a shared instance of HttpClient in long-running processes. In a situation where the HttpClient is instantiated as a singleton or a static object, it fails to handle the DNS changes as described in this issue of the dotnet/runtime GitHub repository.


Oto link do problemu z HTTPClient na GitHubie : https://github.com/dotnet/runtime/issues/18348

Zróbmy więc podsumowanie wiedzy na temat HTTPClient :

  • Każdy HTTPClient tworzy instancje nowego socketa
  • Liczba socketów jest ograniczona i można ją wyczerpać
  • HTTPClient nie uwalnia socketa natychmiastowo pomimo użycia IDisposable
  • HTTPClient jako singleton może stworzyć jeszcze inny problem, gdy twój DNS się zmienia.

Teraz sobie myślisz to, jakie jest rozwiązanie. Z jednej strony muszę mieć jak najmniej HTTPClient-ów, a z drugiej muszę go tworzy raz na jakiś czas aby nie mieć problemów z DNS.

Na pomoc przychodzi HTTPClientFactory. Oto jak jak jej użyć w ASP.NET CORE.

a-04.PNG

W klasie startup deklarujemy użycie HTTPClient i jego specyficzną konfiguracje pod kluczem "ToDoClient".

public class Startup
{

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddHttpClient("ToDoClient", client =>
        {
            client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
            client.Timeout = new TimeSpan(0, 0, 30);
            client.DefaultRequestHeaders.Clear();
        });

        services.AddControllers();
    }

Tak wygląda lepsza wersja mojego kodu, który korzysta teraz z IHttpClientFactory 

public class MyBetterServiceClient
{
    private readonly IHttpClientFactory _httpClientFactory;

    // Constructor
    public MyBetterServiceClient(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task<ToDo> GetToDo()
    {
        var httpClient = _httpClientFactory.CreateClient("ToDoClient");

        // Wywołanie API
        var response = await httpClient.GetAsync("/todos/1");

        // Sprawdzenie czy mamy HTTP 200 OK
        response.EnsureSuccessStatusCode();

        // Odczytanie JSON z odpowiedzi
        var content = await response.Content.ReadAsStringAsync();

        // Deserializacja TODO
        var todo = JsonConvert.DeserializeObject<ToDo>(content);

        return todo;
    }
}

Użyjmy tej klasy w kontrolerze, aby pokazać, że działa :

public class HomeController : Controller
{
    private IHttpClientFactory _fac;

    public HomeController(IHttpClientFactory fac)
    {
        _fac = fac;
    }

    public async Task<string> IndexAsync()
    {
        MyBetterServiceClient myBetterServiceClient
            = new MyBetterServiceClient(_fac);

        var todo = await myBetterServiceClient.GetToDo();

        return todo.Title + " " + todo.Completed;
    }

}

Jak widać wszystko działa tak samo tylko zmieniliśmy kod tworzący HTTPClient.

Aplikacja ASP.NET CORE działa

Teraz pytanie, jak IHttpClientFactory rozwiązuje dwa problemy z HTTPClient. Analizują sockety zapytaniem netstat można stwierdzić, że nie mamy nowych już socketów z statusem TIME_WAIT. Pierwszy problem z zapełnianiem puli socketów mamy więc już z głowy.

Chociaż mnie przeraża fakt, że nadal sockety istnieją z przykładu konsolowego, od którego rozpoczęliśmy ten wpis. Przypadkiem nie miał być one czyszczone po 4 minutach :)

A co z DNS? Nie martw się on też się tym problemem zajął. Oto lista zalet IHttpClientFactory 

  • Centralizacja konfiguracji HTTPClient
  • Zarządza on cyklem życia HttpMessageHandler 
  • HttpMessageHandler rozwiązuje problem z socketami
  • Cykliczność i regularność HttpMessageHandler sprawia, że nie będziesz miał problem, gdy DNS się zmieni
  • Możesz do niego dodać gotową i powtarzający się autoryzację, jeśli jest to potrzebne

Możesz użyć IHttpClientFactory na różne sposoby. W tym przypadku zobaczyłeś jak stworzyć HTTPClient w stylu "Named Client". Czyli takim, że każda usługa HTTP ma swoją klasę, ale nie ma w sobie specyficznej konfiguracji.

Oto przykład stylu Typed Client 

public class TodoTypedClient
{
    private readonly HttpClient _client;

    // Constructor
    public TodoTypedClient(HttpClient client)
    {
        _client = client;
        _client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
        _client.Timeout = new TimeSpan(0, 0, 30);
        _client.DefaultRequestHeaders.Clear();
    }

    public async Task<ToDo> GetToDo()
    {
        // Wywołanie API
        var response = await _client.GetAsync("/todos/1");

        // Sprawdzenie czy mamy HTTP 200 OK
        response.EnsureSuccessStatusCode();

        // Odczytanie JSON z odpowiedzi
        var content = await response.Content.ReadAsStringAsync();

        // Deserializacja TODO
        var todo = JsonConvert.DeserializeObject<ToDo>(content);

        return todo;
    }
}

Oto kod jego instalacji :

services.AddHttpClient<TodoTypedClient>();

Oto jak go użyć w kontrolerze.

public HomeController(IHttpClientFactory fac, TodoTypedClient cl)
{
    _fac = fac;
    _cl = cl;
}

private TodoTypedClient _cl;

public async Task<string> Index2Async()
{
    var todo = await _cl.GetToDo();

    return todo.Title + " " + todo.Completed;
}

Mam nadzieje, że teraz gdy ktoś Ciebie spyta, jaki jest problem z HTTPClient to będziesz wiedział co powiedzieć takiej osobie.