Miroslav Holec
Premium

HttpClient a HttpClientFactory internals

Miroslav Holec   25. března 2022

Tento článek je již velmi zastaralý. Článek nemusí popisovat aktuální stav technologie, ideální řešení a můj současný pohled na dané téma.

Komunikaci s různými HTTP API vývojářům umožňuje třída HttpClient. Její použití je specifické a při vytváření spojení s různými HTTP API je nutné trochu více přemýšlet. Na mém posledním školení jsme s vývojáři řešili detaily kolem vytváření TCP spojení. Rozhodl jsem se oblast více prozkoumat a všechny zjištěné podrobnosti vypisuji v tomto článku.

Zajímáte-li se o konzumaci REST API, doporučuji se podívat na můj webinář Konzumace REST API v .NET Core, který je k dispozici v archivu.

HttpClient

Když odmyslíme různé abstrakce typu Refit, třída HttpClient je v současné době nejsnazší způsob, jak komunikovat se vzdálenými API. První možnost, jak třídu vytvořit je ruční instancování:

using HttpClient httpClient = new();

Třída implementuje rozhraní IDisposable, takže je nutné explicitně zavolat Dispose nebo použít konstrukci using. Při tomto způsobu vytváření instance pomocí bezparametrického konstruktoru dojde k implicitnímu vytvoření HttpMessageHandleru (konkrétně HttpClientHandler), o čemž se můžeme přesvědčit dekompilací:

public HttpClient() : this(new HttpClientHandler())
{
}

Toto je nejméně efektivní způsob vytáření HttpClient třídy. Samotný HttpMessageHandler instruuje operační systém k vytvoření nového TCP připojení, což je nákladná operace. V případě odeslání více požadavků v rámci jednoho using bloku zjistíme následující časy:

Call 1: 00:00:00.3614902
Call 2: 00:00:00.0487879
Call 3: 00:00:00.0489801
Call 4: 00:00:00.0487872
Call 5: 00:00:00.0415290

I když není Stopwatch nijak mimořádně přesný, první volání trvalo zjevně delší dobu kvůli režii spojené s vytvářením HttpClient třídy, následně HttpMessageHandleru a samotného TCP spojení.

V praxi je situace ještě horší, protože vývojář si volání na API zabalí do samostatné service a tudíž by 5x volal určitou metodu v service. Mohlo by to vypadat následně:

public async Task<string> Get()
{
    using HttpClient httpClient = new();
    httpClient.BaseAddress = new Uri("https://restdemo.miroslavholec.cz");
    var data = await httpClient.GetStringAsync("/api/exchange-rates?c=1");
    return data;
}

V tomto případě už celá režie s vytvářením HttpHandleru proběhne pokaždé, když se zavolá metoda Get. Jestliže se tak stane například 5x během jednoho HTTP požadavku na aplikaci, časy jsou následující:

Call: 00:00:00.2922342
Call: 00:00:00.1519746
Call: 00:00:00.2196927
Call: 00:00:00.1868177
Call: 00:00:00.1680475

Všechna čísla jsou podobně vysoká. Co více, na úrovni OS vznikne 5 samostatných TCP spojení, protože každý vytvořený HttpMessageHandler si požádá o nové. Zavoláním Dispose se spojení přepne do stavu TIME_WAIT, ve kterém setrvá po určitý čas a poté umře. Jsou to neefektivně využité systémové prostředky. S rostoucím počtem volání na aplikaci a následně HTTP volání uvnitř počítače zůstává ve stavu TIME_WAIT stále více spojení, která už se nepoužijí.

image-20220323100102260

Statická instance HttpClient třídy

Vývojáře může napadnout, že zbytečné vytváření bezstavových instancí je drahé a tak si vytvoří třídu, která se registruje jako Singleton a HttpClient tak zůstane vytvořený jen jeden. Obdobně by bylo možné vytvořit statickou instanci HttpClient třídy. Spojí se tedy elegance s efektivitou a čísla se zlepší:

Call: 00:00:00.4013110
Call: 00:00:00.0616844
Call: 00:00:00.0493732
Call: 00:00:00.0485985
Call: 00:00:00.0507817

Při prvním volání se musí vytvořit nové TCP spojení, které je o něco pomalejší. Všechna následující už vychází lépe. Další výhoda je, že při opakovaných HTTP requestech už máme vytvořenou instanci HttpClienta se vší agendou na pozadí. Časy volání HTTP služby jsou pěkné i nadále:

Call: 00:00:00.0915214
Call: 00:00:00.0489666
Call: 00:00:00.0552310
Call: 00:00:00.0477762
Call: 00:00:00.0482465

Připojení vznikne jen jedno a zůstane ve stavu ESTABLISHED. V případě běžného provozu se tedy zbavíme veškeré režie. Pokud by se delší dobu nic nedělo, spojení se na úrovni OS časem zlikviduje a HttpMessageHandler dostane v případě potřeby nové.

image-20220323101159135

Může ale nastat opačný extrém. HttpMessageHandler dané spojení bude využívat dlouhé minuty, možná hodiny nebo i dny. V takovém případě riskujeme, že na úrovni DNS dojde mezitím ke změně, která nebude nijak reflektována. Výše uvedená IP adresa už nebude dostupná a aplikace se rozsype. Schůdné řešení by bylo vytvářet HttpMessageHandler jen po určitou "bezpečnou" dobu a předávat ho do HttpClienta dle potřeby. To otevírá nové možnosti, protože můžeme mít více tříd s více HttpClient třídami a ty mohou sdílet jeden HttpMessageHandler. Chceme vlastně vytvářet ručně HttpClient a efektivně do něj předávat HttpMessageHandler. Dobrá zpráva je, že přesně toto řeší HttpClientFactory.

HttpClientFactory

Celé kouzlo spočívá v tom, že registrujeme do IoC kontejneru vlastní service s instrukcemi, jak má být vytvářen HttpClient a HttpMessageHandler. Základní registrace vypadá takto:

services.AddHttpClient<IApiService, ApiService>(x =>
{
     x.BaseAddress = new Uri("https://remote.api.cz");
})

Samotná třída ApiService má v konstruktoru nyní třídu HttpClient, kterou vytváří na pozadí HttpClientFactory.

private readonly HttpClient _httpClient;
public ApiService(HttpClient httpClient)
{
     _httpClient = httpClient;
}

Třída se implicitně registruje jako Transient.

builder.Services.AddTransient(s => AddTransientHelper<TClient, TImplementation>(s, builder));

Při prvním vytváření musí HttpClientFactory vytvořit i HttpMessageHandler. Ten si bude dále držet v poolu handlerů a vždy když bude potřeba znovu vytvořit nový HttpClient, pokusí se pro něj najít vhodný HttpMessageHandler. V běžném provozu tedy na počátku dojde k vytvoření TCP spojení, které se bude opakovaně používat. Samotná registrace nám dále nabízí mnoho možností. Podle situace lze například zvolit životnost handleru:

services.AddHttpClient<IApiService, ApiService>(x =>
{
     x.BaseAddress = new Uri("https://remote.api.cz");
})
.SetHandlerLifetime(TimeSpan.FromMinutes(30));

Pokud ve výše uvedém případě uplyne 30 minut, další instance HttpClientů dostanou již novou instanci HttpMessageHandleru a nové TCP spojení, čímž budeme reflektovat případné změny na DNS. Původní TCP spojení časem OS zlikviduje.

HttpClientFactory má mnoho dalších výhod. Jednotlivé API klienty lze různé dekorovat o vlastní handlery, které mohou řídit logování, autentizaci, nebo lze například zapojit retry policy pro scénáře, kdy vzdálená služba nefunguje optimálně.

Paralelní souběh požadavků

S roustoucím počtem požadavků na webovou aplikaci může dojít k paralelnímu souběhu HTTP požadavků na vzdálenou službu. Stejně tak můžeme chtít vytvářet paralelní volání v rámci různých worker služeb. HttpMessageHandler pak dostane další potřebná TCP spojení, aby dokázal paralelní souběh efektivně vyřešit. Typicky bychom na heapu našli více instancí samotné ApiService. třídy:

image-20220323104931250

Především můžeme ale vidět více TCP spojení ve stavu ESTABLISHED.

image-20220323104300136

Dobrá zpráva je, že všechna tato dodatečná TCP spojení mohou být dále opakovaně použitá. Když se tedy zvýší provoz na webové aplikaci, souběhem HTTP požadavků vzniknou nová TCP spojení, která se s ohledem na životnost HttpMessageHandleru opakovaně používají. Se snižováním provozu se pak nevyužitá TCP spojení zlikvidují. Vývojář má snadný život, protože se všechno děje bez jeho přičinění a zcela správně.

Jen pro zajímavost: SocketsHttpHandler bychom mohli také instruovat, aby proti serveru vytvořil jen určitý maximální počet spojení.

services.AddHttpClient<IApiService, ApiService>(x =>
{
     x.BaseAddress = new Uri("https://remote.api.cz");
})
.ConfigurePrimaryHttpMessageHandler(x => new SocketsHttpHandler()
{
     MaxConnectionsPerServer = 1,
})

Ve výše uvedeném případě by měl HttpMessageHandler přiděleno vždy maximálně jedno TCP spojení a v případě paralelního souběhu požadavků by vznikla jakási blokující fronta, ve které by jednotlivé požadavky čekaly na odbavení po sdíleném TCP spojení. Pokud by se vzdálená služba jakkoliv loudala s odpovědí, webovou aplikaci by to výrazně zpomalilo. V praxi nemá smysl takto drasticky počet spojení na server omezovat.

Závěr

Stačí používat HttpClientFactory a vhodně si nastavit Handler Lifetime. U cloudových služeb nastavit raději nižší lifetime s ohledem na změny na DNS. V případě on premise řešení, kdy máme stroje pod vlastní správou a víme, že na DNS se nic neděje lze zvolit lifetime delší.