روش صحیح کار با HttpClient در ASP.NET Core 2x
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: هشت دقیقه

پیشتر مطلب «روش استفاده‌ی صحیح از HttpClient در برنامه‌های دات نت» را مطالعه کرده بودید. پس از ارائه‌ی NET Core 2.1.، این مجموعه به همراه یک IHttpClientFactory نیز ارائه می‌شود که در اینجا قصد داریم این مورد و همچنین سایر موارد مشابه را بررسی کنیم.


صورت مساله

قصد داریم اطلاعاتی را با فرمت JSON، از یک API خارجی، توسط HttpClient دریافت و سپس آن‌را به یک DTO فرضی، به نام GitHubRepositoryDto نگاشت کنیم.


راه حل 1

در این روش از وهله سازی مستقیم HttpClient به همراه استفاده‌ی از یک عبارت using کمک گرفته شده‌است. همچنین چون عملیات async است، نتیجه‌ی آن‌را به کمک خاصیت Result دریافت کرده‌ایم که پس از آن، کل اطلاعات دریافتی را به صورت یک رشته، در اختیار خواهیم داشت:
public class GitHubClient
{
    public IReadOnlyCollection<GitHubRepositoryDto> GetRepositories()
    {
        using (var httpClient = new HttpClient{BaseAddress = new Uri(GitHubConstants.ApiBaseUrl)})
        {
            var result = httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).Result;
            return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
        }
    }
}
مشکلات این راه حل:
- استفاده از خاصیت Result، هیچگاه ایده‌ی خوبی نبوده است و یک عملیات async را تبدیل به عملیاتی Blocking می‌کند که حتی می‌تواند سبب بروز dead-lock نیز شود.
- HttpClient نباید Dispose شود. علت آن‌را در مطلب «روش استفاده‌ی صحیح از HttpClient در برنامه‌های دات نت» مفصل بررسی کرده‌ایم.
- دریافت کل response یک API به صورت یک رشته‌ی بزرگ، یک Large object heap را به‌وجود می‌آورد که باز هم ایده‌ی خوبی نیست.


راه حل 2

اگر خاصیت Result راه حل 1 را حذف کنیم، به راه حل 2 خواهیم رسید:
public class GitHubClient : IGitHubClient
{
    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        using (var httpClient = new HttpClient { BaseAddress = new Uri(GitHubConstants.ApiBaseUrl) })
        {
            var result = await httpClient.GetStringAsync(GitHubConstants.RepositoriesPath);
            return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
        }
    }
}
مزایا:
- اینبار از دسترسی asynchronous واقعی استفاده شده‌است.

معایب:
- ایجاد و تخریب یک HttpClient جدید به ازای هر فراخوانی.
- دریافت و ذخیره سازی کل response به صورت یک رشته.


راه حل 3

در این نگارش، HttpClient از طریق وهله سازی در سازنده‌ی کلاس دریافت شده و به این ترتیب امکان استفاده‌ی مجدد را پیدا می‌کند:
public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;
    public GitHubClient()
    {
        _httpClient = new HttpClient { BaseAddress = new Uri(GitHubConstants.ApiBaseUrl) };
    }
    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var result = await _httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).ConfigureAwait(false);
        return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
    }
}
طول عمر GitHubClient نیز Singleton معرفی می‌شود.
services.AddSingleton<GitHubClient>();
مزایا:
- دسترسی asynchronous واقعی به API مدنظر.
- استفاده‌ی مجدد از HttpClient

معایب:
- دریافت و ذخیره سازی کل response به صورت یک رشته.
- چون طول عمر GitHubClient از نوع Singleton است و برای همیشه از یک وهله‌ی سراسری استفاده می‌کند، از تغییرات DNS آگاه نخواهد شد.


راه حل 4

تا اینجا همانطور که ملاحظه کردید، به سادگی می‌توان HttpClient را به نحو نادرستی مورد استفاده قرار داد. ایجاد مجدد آن به علت عدم رها شدن بلافاصله‌ی سوکت‌های لایه‌ی زرین آن توسط سیستم عامل، مشکل حادی را به نام sockets exhaustion پدید می‌آورد. به همین جهت، این کلاس باید یکبار نمونه سازی شده و در طول عمر برنامه از همین تک وهله‌ی آن استفاده شود. یک روش اینکار تعریف آن به صورت اشیاء singleton و یا static است. مشکلی که این روش به همراه دارد، عدم باخبر شدن آن از تغییرات DNS است. برای رفع این مسایل، از NET Core 2.1. به بعد، خود مایکروسافت با ارائه‌ی یک IHttpClientFactory، روش استانداری را برای مدیریت وهله‌های HttpClient ارائه کرده‌است:
public class GitHubClient : IGitHubClient
{
    private readonly IHttpClientFactory _httpClientFactory;
    public GitHubClient(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
    }
    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var httpClient = _httpClientFactory.CreateClient("GitHub");
        var result = await httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).ConfigureAwait(false);
        return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
    }
}
با این روش ثبت
services.AddHttpClient("GitHub", x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
services.AddSingleton<GitHubClient>();
در این روش، IHttpClientFactory به سازنده‌ی کلاس تزریق می‌شوند و از آن برای دسترسی به یک HttpClient جدید، هربار که این متد فراخوانی خواهد شد، استفاده می‌کنیم. بله ... در این حالت نیز یک HttpClient هربار ایجاد خواهد شد؛ اما چون از IHttpClientFactory استفاده می‌کنیم، مشکلی به شمار نمی‌رود. از این جهت که مطابق مستندات آن، هر HttpClient‌ای که به این نحو تولید می‌شود، یک HttpMessageHandler را در پشت صحنه مورد استفاده قرار می‌دهد که عملیات pooling و استفاده‌ی مجدد از آن‌ها، صورت می‌گیرد. یعنی IHttpClientFactory از HttpClient خود، به نحو بهینه‌ای استفاده‌ی مجدد می‌کند و در این حالت سیستم با مشکل کمبود منابع مواجه نخواهد شد و همچنین سربار ایجاد HttpClient‌های جدید نیز به حداقل می‌رسند.

مزیت‌ها:
- استفاده‌ی از یک IHttpClientFactory توکار

معایب:
- استفاده‌ی یک از کلاینت نامدار، بجای یک کلاینت مشخص شده‌ی بر اساس نوع آن.
- دریافت و ذخیره سازی کل response به صورت یک رشته.

روش ثبت services.AddHttpClient را که در اینجا ملاحظه می‌کنید، یک روش ثبت نامدار است و بر اساس نام رشته‌ای GitHub کار می‌کند. همین نام در متد GetRepositories به صورت httpClientFactory.CreateClient("GitHub") برای دسترسی به یک HttpClient جدید استفاده شده‌است.


راه حل 5

در اینجا از یک کلاینت نوع‌دار، بجای یک کلاینت نامدار، استفاده شده‌است:
public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;
    public GitHubClient(HttpClient httpClient)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }
    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var result = await _httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).ConfigureAwait(false);
        return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
    }
}
با این روش ثبت:
services.AddHttpClient<GitHubClient>(x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
برای ثبت یک کلاینت نوع‌دار، از متد AddHttpClient به همراه ذکر نوع کلاس کلاینت، استفاده می‌شود.

مزایا:
- استفاده از IHttpClientFactory
- استفاده از یک کلاینت نوع‌دار، بجای یک نمونه‌ی نامدار

معایب:
- اینبار تمام استفاده کنندگان از IGitHubClient ما باید دارای طول عمر transient باشند (خصوصیت کلاینت‌های نوع‌دار است)؛ برخلاف راه حل‌های پیشین که می‌توانستند singleton تعریف شوند (یا امکان فراخوانی IGitHubClient از سرویس‌های singleton نیز وجود داشت).
- دریافت و ذخیره سازی کل response به صورت یک رشته.


راه حل 6

اگر در جائی نیاز به استفاده و تزریق یک کلاینت نوع‌دار، در یک سرویس با طول عمر singleton را داشتید، روش آن به صورت زیر است:
public class GitHubClientFactory
{
    private readonly IServiceProvider _serviceProvider;
    public GitHubClientFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public GitHubClient Create()
    {
        return _serviceProvider.GetRequiredService<GitHubClient>();
    }
}
با این روش ثبت:
services.AddHttpClient<GitHubClient>(x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
services.AddSingleton<GitHubClientFactory>();
در این روش، یک GitHubClientFactory را داریم که یک GitHubClient را باز می‌گرداند. نکته‌ی اصلی آن، کار با ServiceProvider، جهت دسترسی به GitHubClient است. مابقی آن یعنی تعریف GitHubClient، مانند روش 5 است.

مزایا:
- استفاده از IHttpClientFactory
- استفاده از یک کلاینت نوع‌دار
- استفاده کننده‌ی از GitHubClientFactory، می‌توانند طول عمر singleton نیز داشته باشد

معایب:
- دریافت و ذخیره سازی کل response به صورت یک رشته.


راه حل 7

از اینجا به بعد، هدف ما بهینه سازی عملیات است و رفع مشکل کار با یک رشته‌ی بزرگ. برای این منظور بجای متد GetStringAsync، از متد SendAsync که امکان streaming را فراهم می‌کند، استفاده خواهیم کرد. به این ترتیب، بجای ارسال یک رشته‌ی بزرگ به متد Deserialize، امکان دسترسی به استریم response را توسط آن میسر کرده‌ایم.
public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;
    private readonly JsonSerializer _jsonSerializer;
    public GitHubClient(HttpClient httpClient, JsonSerializer jsonSerializer)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer));
    }

    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var request = CreateRequest();
        var result = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead);
        using (var responseStream = await result.Content.ReadAsStreamAsync())
        {
            using (var streamReader = new StreamReader(responseStream))
            using (var jsonTextReader = new JsonTextReader(streamReader))
            {
                return _jsonSerializer.Deserialize<List<GitHubRepositoryDto>>(jsonTextReader);
            }
        }
    }

    private static HttpRequestMessage CreateRequest()
    {
        return new HttpRequestMessage(HttpMethod.Get, GitHubConstants.RepositoriesPath);
    }
}
با این روش ثبت:
services.AddHttpClient<GitHubClient>(x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
services.AddSingleton<GitHubClientFactory>();
services.AddSingleton<JsonSerializer>();
مزایا:
- کار با IHttpClientFactory
- استفاده از یک کلاینت نوع‌دار
- کار با استریم response

معایب:
- استفاده از ResponseContentRead


راه حل 8

در این روش بجای سطر ذیل در راه حل 7
var result = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead);
از این سطر استفاده خواهیم کرد:
var result = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
در حالت ResponseContentRead که حالت پیش‌فرض نیز هست، تمام هدرها و کل محتوای بازگشتی از سمت سرور باید خوانده شوند تا در اختیار مصرف کننده قرار گیرند، اما در حالت ResponseHeadersRead، فقط برای دریافت هدرها صبر خواهد شد و مابقی آن سریعا به صورت یک استریم در اختیار مصرف کننده قرار می‌گیرد.


مزایا:
- کار با IHttpClientFactory
- استفاده از یک کلاینت نوع‌دار
- کار با استریم response
- استفاده از ResponseHeadersRead

معایب:
- شاید بتوان از کتابخانه‌ی دیگری برای json deserialization استفاده کرد؟
  • #
    ‫۵ سال و ۵ ماه قبل، یکشنبه ۲۵ فروردین ۱۳۹۸، ساعت ۱۵:۳۲
    چند سوال: 
    1- مزیت تعریف کردن JsonSerializer بصورت singleton چیه؟
    2- وقتی ما response رو بصورت استریم میخونیم بازهم کل string دریافتی یکجا به متد deserialize پاس داده میشود درسته؟
    • #
      ‫۵ سال و ۵ ماه قبل، یکشنبه ۲۵ فروردین ۱۳۹۸، ساعت ۱۶:۰۳
      - کاهش تعداد بار وهله سازی آن.
      - خیر. JsonTextReader بر روی یک استریم (همان استریم response که الان به صورت استریمی از کاراکترها در دسترس است) کار می‌کند.
  • #
    ‫۴ سال و ۱۱ ماه قبل، دوشنبه ۲۲ مهر ۱۳۹۸، ساعت ۲۱:۱۳
    باتشکر،امکانش هست سورس نهایی این مطلب رو نیز قرار بدید
  • #
    ‫۴ سال و ۳ ماه قبل، پنجشنبه ۲۵ اردیبهشت ۱۳۹۹، ساعت ۱۶:۴۰
    سلام، ممنون از توضیح جامع و کامل درباره HttpClient. برای من سوالی پیش آمد،همانطور که مستحضر هستید وظیفه ارسال و دریافت Message توسط HTTPMassageHandler است و دلیل اینکه گفته میشه HttpClient نباید Dispose بشه بخاطر همین کلاسه، چون با سوکت سر کار داره، حال با اینکه مایکروسافت HttpClientFactory رو از نسخه 2.1 ارائه داده و HttpClientFactory هم در زمان ساخت HttpClient بصورت توکار از Pool برای دریافت  HTTPMessageHandler استفاده می‌کنه، و این HttpMessageHandler هم به مدت 2 دقیقه در Pool نگهداری میشه، اگر من بخوام از Typed Client استفاده کنم، باز هم لازمه اون رو بصورت Singleton تعریف کنم یا نه.
    • #
      ‫۴ سال و ۳ ماه قبل، پنجشنبه ۲۵ اردیبهشت ۱۳۹۹، ساعت ۱۶:۵۴
      راه حل 5 را کامل مطالعه کنید.
  • #
    ‫۳ سال و ۵ ماه قبل، پنجشنبه ۱۹ فروردین ۱۴۰۰، ساعت ۰۳:۰۱
    اگر دیتاها به صورت فشرده شده ("gzip, deflate") دریافت شوند, باید تنظیمات مربوط به Decompress کردن دیتاها را به HttpClient اضافه کنیم.
    services.AddHttpClient("GitHub", a =>
     {
         a.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Encoding", "gzip, deflate");
     }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
     {
         AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
     });
    همچنین اگر بخواهیم از پروکسی برای ارسال ریکوست‌ها استفاده کنیم, میتوان تنظیمات زیر را به HttpClient اضافه کنیم.
    services.AddHttpClient("Github", a =>
     {
         a.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Encoding", "gzip, deflate");
     }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
     {
         Proxy = new WebProxy("192.168.20.21", 3128),
         UseProxy = true
     });

  • #
    ‫۳ سال و ۳ ماه قبل، سه‌شنبه ۲۸ اردیبهشت ۱۴۰۰، ساعت ۱۶:۰۷
    چرا در راه حل پنجم از HttpClient به جای IHttpClientFactory  استفاده شده؟
    • #
      ‫۳ سال و ۳ ماه قبل، سه‌شنبه ۲۸ اردیبهشت ۱۴۰۰، ساعت ۱۷:۱۳
      متوجه شدم؛ به خاطر وهله سازی نوع کلاس بود.
  • #
    ‫۳ سال و ۳ ماه قبل، چهارشنبه ۲۹ اردیبهشت ۱۴۰۰، ساعت ۱۷:۲۲
    از راه حل ۴ به بعد ما از AddHttpClient برای مشخص کردن BaseAddress استفاده می‌کنیم
    اما اگر حین اجرا BaseAddress تغییر کند چطور باید آدرس جدید را جایگزین قبلی کنیم
    راه حلی که به ذهن خودم میرسه اینه که از HttpClient.CreateClient بدون نام یا آدرس مبدا استفاده کنیم و حین صدا زدن توابعی مانند GetAsync از کل آدرس استفاده کنیم
    اما آیا این روش مشکل کارایی یا مورد دیگری ایجاد نمی‌کند؟
    • #
      ‫۳ سال و ۳ ماه قبل، چهارشنبه ۲۹ اردیبهشت ۱۴۰۰، ساعت ۱۸:۰۰
      در متدهایی مانند GetAsync، فقط زمانیکه آدرسی را به صورت نسبی وارد کنید، از BaseAddress برای تکمیل آن استفاده می‌شود. اگر آدرسی را مطلق وارد کنید، از BaseAddress استفاده نخواهد شد.
  • #
    ‫۳ سال قبل، چهارشنبه ۱۷ شهریور ۱۴۰۰، ساعت ۲۰:۱۲
    با سلام - اگه قرار باشه مثلا n تا api داشته باشی با دامین‌های متفاوت و بخوایم فراخوانی کنیم اونوقت روش ثبت سرویسش که در اینجا مطرح شده
    services.AddHttpClient("GitHub", x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
    به چه صورت خواهد بود؟
    آیا باید به ازای هر دامین این سطر قید شود؟
    services.AddHttpClient("GitHub", x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
    services.AddHttpClient("Facebook", x => { x.BaseAddress = new Uri(""); });
    services.AddHttpClient("whatsapp", x => { x.BaseAddress = new Uri(""); });
    • #
      ‫۳ سال قبل، چهارشنبه ۱۷ شهریور ۱۴۰۰، ساعت ۲۰:۵۴
      هر طور راحتی. خواستی اینکار رو بکن، به همراه تنظیمات سفارشی خاص هر کدوم، نخواستی یکبار نظرات رو در مورد آدرس نسبی و غیر نسبی بخون. توضیح دادم.
  • #
    ‫۲ سال و ۸ ماه قبل، چهارشنبه ۱۷ آذر ۱۴۰۰، ساعت ۲۲:۳۹
    راه حل‌های شماره 9 و 10 را میتوانید در این لینک مشاهده کنید.