← بازگشت به وبلاگ

پروکسی در .NET HttpClient: SocketsHttpHandler، SOCKS5 و چرخش بدون نشت سوکت‌ها

نحوه تنظیم صحیح پروکسی در .NET HttpClient با SocketsHttpHandler، اتصال به SOCKS5 و سازماندهی چرخش IP بدون خستگی سوکت را بررسی می‌کنیم.

📅۲۶ مهر ۱۴۰۵

اگر شما هرگز SocketException: Address already in use را دریافت کرده‌اید یا دیده‌اید که یک برنامه در .NET به طور ناگهانی سوکت‌های در دسترس خود را پس از چند ساعت کار با پروکسی تمام می‌کند، — مشکل تقریباً همیشه در استفاده نادرست از HttpClient است. در این مقاله بررسی می‌کنیم که چگونه پروکسی را از طریق SocketsHttpHandler تنظیم کنیم، SOCKS5 را متصل کنیم و چرخش IP را به گونه‌ای سازماندهی کنیم که استخر اتصالات به گلوگاه تبدیل نشود.

چرا HttpClient سوکت‌ها را «می‌خورد»

اشتباه کلاسیک — ایجاد یک نمونه جدید از HttpClient برای هر درخواست درون using. هنگام کار با پروکسی، این مشکل تشدید می‌شود: هر اتصال از طریق سرور پروکسی نیاز به یک handshake TCP جداگانه دارد، و اگر شما پروکسی را در حین کار تغییر دهید، استخر اتصالات دوباره استفاده نمی‌شود و سوکت‌ها در حالت TIME_WAIT معلق می‌مانند.

در ویندوز این موضوع به ویژه بحرانی است — به طور پیش‌فرض تعداد محدودی از پورت‌های دینامیک در دسترس است (معمولاً 16384)، و اگر برنامه داده‌ها را از طریق پروکسی با شدت درخواست بالا تجزیه کند، تمام پورت‌ها ممکن است در عرض چند دقیقه تمام شوند. در لینوکس وضعیت کمی ملایم‌تر است به خاطر تنظیمات هسته، اما مشکل همچنان وجود دارد اگر اتصالات دوباره استفاده نشوند.

HttpClientHandler قدیمی، که هنوز از زمان .NET Framework در دسترس است، کنترل انعطاف‌پذیری بر چرخه حیات اتصالات نمی‌دهد. به همین دلیل از .NET Core 2.1 به بعد توصیه می‌شود از SocketsHttpHandler استفاده کنید — این کلاس به طور کامل با کد مدیریت نوشته شده و دسترسی مستقیم به استخر اتصالات، زمان‌های تایم‌اوت DNS و تنظیمات پروکسی را فراهم می‌کند.

SocketsHttpHandler: چرا به آن نیاز داریم

SocketsHttpHandler — این سطح حمل و نقل است که از .NET Core 2.1 به بعد HttpClient به طور پیش‌فرض در لینوکس و macOS ساخته شده است و از .NET 5 به بعد نیز در ویندوز. در مقایسه با WinHttpHandler قدیمی، این:

  • اجازه می‌دهد تا PooledConnectionLifetime — زمان حیات اتصال در استخر را تعیین کنید، که هنگام کار با پروکسی‌های چرخشی بحرانی است؛
  • دسترسی به IWebProxy را از طریق دلیگیت Proxy که می‌توان آن را به صورت برنامه‌نویسی در حین کار بازنویسی کرد، فراهم می‌کند؛
  • پشتیبانی از ConnectCallback — یک هوک سطح پایین برای جایگزینی حمل و نقل، که برای SOCKS5 لازم است؛
  • سوکت‌ها را به درستی پس از انقضای PooledConnectionIdleTimeout آزاد می‌کند و زباله‌ای در سیستم باقی نمی‌گذارد.

تنظیمات پایه به شکل زیر است:

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
    MaxConnectionsPerServer = 50,
    AutomaticDecompression = DecompressionMethods.All
};

var client = new HttpClient(handler)
{
    Timeout = TimeSpan.FromSeconds(30)
};

قانون اصلی: HttpClient با این handler باید singleton باشد یا از طریق IHttpClientFactory ایجاد شود — آن را برای هر درخواست دوباره ایجاد نکنید، در غیر این صورت تمام مفهوم استخر اتصالات از بین می‌رود.

تنظیم پروکسی از طریق SocketsHttpHandler

برای پروکسی HTTP/HTTPS کافی است که ویژگی Proxy را در سطح handler تنظیم کنید. این برای پروکسی‌های دیتاسنتر و پروکسی‌های مقیم که دسترسی را از طریق پروتکل HTTP CONNECT ارائه می‌دهند، کار می‌کند:

var proxy = new WebProxy("http://proxy-host:8000")
{
    Credentials = new NetworkCredential("login", "password")
};

var handler = new SocketsHttpHandler
{
    Proxy = proxy,
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromMinutes(3)
};

var client = new HttpClient(handler);

var response = await client.GetAsync("https://example.com");
Console.WriteLine(await response.Content.ReadAsStringAsync());

اگر نیاز به اتصال به پروکسی‌های مختلف برای میزبان‌های مختلف دارید (به عنوان مثال، احراز هویت به یک دامنه به طور مستقیم انجام می‌شود، در حالی که اسکرپینگ — از طریق پروکسی)، از یک دلیگیت به جای یک شیء استاتیک استفاده کنید:

var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy(),
    UseProxy = true
};

handler.Proxy = new CustomProxy(uri =>
{
    return uri.Host.Contains("target-site")
        ? new Uri("http://proxy-host:8000")
        : null;
});

برای وظایف تجزیه بازارها — به عنوان مثال، نظارت بر قیمت‌ها در Wildberries یا Ozon — معمولاً هر دو رویکرد ترکیب می‌شوند: ترافیک اصلی از طریق پروکسی دیتاسنتر به عنوان پروکسی دیتاسنتر می‌رود، در حالی که درخواست‌ها به خدمات کپچا یا احراز هویت — به طور مستقیم.

پشتیبانی از SOCKS5 در .NET

از .NET 6 به بعد، SocketsHttpHandler پشتیبانی بومی از SOCKS4/SOCKS4a/SOCKS5 را از طریق مشخص کردن طرح در URI پروکسی دریافت کرده است — هیچ کتابخانه خارجی لازم نیست:

var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
    {
        Credentials = new NetworkCredential("login", "password")
    },
    UseProxy = true
};

var client = new HttpClient(handler);
var response = await client.GetAsync("https://httpbin.org/ip");

نکته مهم: تا قبل از .NET 6 چنین امکانی وجود نداشت و توسعه‌دهندگان مجبور بودند ConnectCallback خود را بنویسند که به صورت دستی handshake SOCKS5 را قبل از برقراری اتصال TLS انجام می‌داد. اگر پروژه شما هنوز بر روی .NET Core 3.1 یا .NET 5 است، این مسیر هنوز هم معتبر است:

var handler = new SocketsHttpHandler
{
    ConnectCallback = async (context, cancellationToken) =>
    {
        var socket = new Socket(SocketType.Stream, ProtocolType.Tcp)
        {
            NoDelay = true
        };

        await socket.ConnectAsync("proxy-host", 1080, cancellationToken);
        await PerformSocks5Handshake(socket, context.DnsEndPoint, cancellationToken);

        return new NetworkStream(socket, ownsSocket: true);
    }
};

در عمل برای پروژه‌های تولیدی بهتر است به .NET 6+ بروید و از پیاده‌سازی داخلی استفاده کنید — این پیاده‌سازی تحت آزمایش‌های زمان اجرا قرار دارد و نیازی به پشتیبانی مستقل از پروتکل handshake ندارد.

چرخش پروکسی بدون ایجاد مجدد HttpClient

اشتباه رایج در پیاده‌سازی چرخش IP — ایجاد یک HttpClient جدید (و به همین ترتیب یک handler جدید و یک استخر اتصالات جدید) برای هر تغییر پروکسی است. این به طور تضمینی به انباشت سوکت‌های بسته نشده منجر می‌شود. رویکرد صحیح — پیاده‌سازی یک IWebProxy سفارشی است که در هر بار فراخوانی آدرس پروکسی متفاوتی را برمی‌گرداند:

public class RotatingProxy : IWebProxy
{
    private readonly string[] _proxies;
    private int _index = -1;

    public RotatingProxy(string[] proxies) => _proxies = proxies;

    public ICredentials Credentials { get; set; }

    public Uri GetProxy(Uri destination)
    {
        var next = Interlocked.Increment(ref _index) % _proxies.Length;
        return new Uri(_proxies[next]);
    }

    public bool IsBypassed(Uri host) => false;
}

این کلاس به همان SocketsHttpHandler متصل می‌شود:

var handler = new SocketsHttpHandler
{
    Proxy = new RotatingProxy(new[]
    {
        "http://proxy1:8000",
        "http://proxy2:8000",
        "http://proxy3:8000"
    }),
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromSeconds(60)
};

var client = new HttpClient(handler);

این طرح استخر سوکت‌های داخلی را دوباره استفاده می‌کند، اما در عین حال هر درخواست واقعاً از طریق یک IP جدید می‌رود. اگر شما استخرهای موازی اتصالات را برای هر پروکسی جمع‌آوری می‌کنید (برای سرعت)، یک PooledConnectionLifetime معقول تعیین کنید — معمولاً 30-90 ثانیه، تا اتصالات سریع‌تر از IP در سمت ارائه‌دهنده پروکسی منقضی نشوند. برای وظایفی مانند تجزیه Avito یا جمع‌آوری داده‌ها از Instagram API بهتر است از پروکسی‌های مقیم با چرخش در سمت ارائه‌دهنده استفاده کنید — در این صورت در کد نیازی به تغییر آدرس نیست، کافی است یک «نقطه ورودی» با جایگزینی خودکار IP خروجی داشته باشید.

PooledConnectionLifetime و چرخش DNS

اگر ارائه‌دهنده پروکسی از چرخش DNS استفاده کند (یک نام میزبان در هر بار درخواست به IP‌های مختلف حل می‌شود)، تعیین یک PooledConnectionLifetime محدود بسیار مهم است. به طور پیش‌فرض این مقدار Infinite است و .NET اتصال به IP قدیمی را به مدت نامحدود نگه می‌دارد و به بروزرسانی‌های DNS توجهی نمی‌کند.

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromSeconds(30), // اتصال را دوباره ایجاد می‌کنیم
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(15),
    MaxConnectionsPerServer = 20
};

این موضوع به ویژه برای پروکسی‌های موبایل مهم است، جایی که تغییر IP توسط ارائه‌دهنده ممکن است هر 1-10 دقیقه در چارچوب چرخش اپراتور تلفن همراه اتفاق بیفتد. اگر اتصال را بیشتر نگه دارید، درخواست‌ها همچنان از طریق IP قبلاً آزاد شده می‌روند و گاهی اوقات — حتی خطای تایم‌اوت دریافت می‌کنند. برای چنین سناریوهایی پروکسی‌های موبایل بسیار مناسب هستند — آنها بالاترین اعتماد را از طرف سیستم‌های ضد تقلب ارائه می‌دهند، اما نیاز به تنظیم دقیق TTL اتصالات در سطح کلاینت دارند.

مقدار کوتاه PooledConnectionLifetime رایگان نیست — هر بار که اتصال دوباره ایجاد می‌شود به معنای یک handshake جدید TCP+TLS است، که 50-200 میلی‌ثانیه تأخیر اضافه می‌کند. تعادل بین تازگی IP و عملکرد به صورت تجربی تعیین می‌شود: برای وظایف نظارت بر قیمت‌ها کافی است 60-120 ثانیه، برای کشف حساب‌های با فرکانس بالا — گاهی اوقات به 10-20 ثانیه نیاز است.

مقایسه انواع پروکسی برای برنامه‌های .NET

نوع پروکسی پروتکل PooledConnectionLifetime پیشنهادی وظایف
پروکسی دیتاسنتر HTTP/HTTPS, SOCKS5 5-10 دقیقه تجزیه، ادغام API، سرعت بالا
پروکسی‌های مقیم HTTP/HTTPS, SOCKS5 30-90 ثانیه نظارت بر قیمت‌ها، دور زدن مسدودسازی‌های جغرافیایی
پروکسی‌های موبایل HTTP/HTTPS, SOCKS5 10-30 ثانیه اتوماسیون در شبکه‌های اجتماعی، API‌های حساس به ضد تقلب

IHttpClientFactory و کلاینت‌های تایپ شده با پروکسی

در پروژه‌های ASP.NET Core توصیه می‌شود HttpClient را از طریق IHttpClientFactory ثبت کنید — این مسئولیت مدیریت چرخه حیات handler‌ها را از دوش توسعه‌دهنده برمی‌دارد و به طور خودکار SocketsHttpHandler داخلی را دوباره استفاده می‌کند.

builder.Services.AddHttpClient("ProxyClient")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
        {
            Credentials = new NetworkCredential("login", "password")
        },
        UseProxy = true,
        PooledConnectionLifetime = TimeSpan.FromMinutes(2)
    })
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));

به SetHandlerLifetime توجه کنید — این یک مکانیزم جداگانه برای چرخش خود handler‌ها درون کارخانه است (به طور پیش‌فرض 2 دقیقه)، که نباید با PooledConnectionLifetime یک اتصال اشتباه گرفته شود. کارخانه به طور دوره‌ای یک handler جدید ایجاد می‌کند، اما اتصالات قدیمی همچنان زمان خود را سپری می‌کنند تا زمانی که درخواست‌های فعال به پایان برسند — جمع‌آوری‌کننده زباله خود آنها را پاک می‌کند.

برای سناریوهایی با چندین استخر پروکسی، راحت است که چندین کلاینت نام‌گذاری شده ثبت کنید — به عنوان مثال، جداگانه برای ایالات متحده و جداگانه برای اروپا، اگر وظیفه — آزمایش تبلیغات Google Ads یا Facebook Ads از مناطق مختلف باشد:

builder.Services.AddHttpClient("US-Proxy")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://us-proxy-host:8000"),
        UseProxy = true
    });

builder.Services.AddHttpClient("EU-Proxy")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://eu-proxy-host:8000"),
        UseProxy = true
    });

تشخیص نشت سوکت‌ها

اگر مشکوک به انباشت سوکت‌ها هستید، حدس نزنید — بررسی کنید. در لینوکس، یک روش سریع برای مشاهده تعداد اتصالات باز در یک فرآیند:

# تعداد اتصالات در TIME_WAIT
ss -tan state time-wait | wc -l

# تمام اتصالات یک فرآیند خاص (PID)
ls -la /proc/{PID}/fd | grep socket | wc -l

در ویندوز، آمار مشابهی با دستور netstat -ano با فیلتر کردن بر اساس PID فرآیند ارائه می‌شود. برای تشخیص عمیق‌تر در سطح .NET، از ابزار داخلی dotnet-counters استفاده کنید که متریک‌های System.Net.Http را در زمان واقعی نشان می‌دهد:

dotnet-counters monitor -p {PID} --counters System.Net.Http

# شمارنده‌های کلیدی:
# current-connections
# connections-duration
# requests-failed-rate

اگر current-connections به طور مداوم افزایش یابد و هرگز کاهش نیابد — این نشانه مستقیم این است که handler بیش از حد معمول دوباره ایجاد می‌شود، یا PooledConnectionIdleTimeout به Infinite تنظیم شده است، و درخواست‌ها به درستی به پایان نمی‌رسند (به عنوان مثال، بدنه پاسخ به طور کامل خوانده نمی‌شود و اتصال به استخر برنمی‌گردد).

یکی دیگر از دلایل رایج نشت‌ها — response.Content فراموش شده است که خوانده نشده یا حذف نشده است. همیشه از using var response = ... یا فراخوانی صریح Dispose() پس از پایان کار با پاسخ استفاده کنید.

نتیجه‌گیری

کار صحیح با پروکسی در .NET بر سه اصل استوار است: از SocketsHttpHandler به جای پیاده‌سازی‌های قدیمی استفاده کنید، HttpClient را به عنوان singleton نگه دارید (یا از طریق IHttpClientFactory)، و چرخش پروکسی را از طریق یک IWebProxy سفارشی پیاده‌سازی کنید، نه از طریق ایجاد مجدد کل کلاینت. از .NET 6 به بعد SOCKS5 به طور بومی پشتیبانی می‌شود، که نیاز به نوشتن پروتکل handshake سفارشی را از بین می‌برد.

اگر برنامه شما بازارها را تجزیه می‌کند یا داده‌ها را از طریق API با شدت درخواست بالا جمع‌آوری می‌کند، به پروکسی‌های دیتاسنتر توجه کنید — آنها حداکثر سرعت را با تنظیم صحیح استخر اتصالات ارائه می‌دهند. برای وظایفی که در آن‌ها ناشناسی و شباهت به ترافیک کاربر عادی مهم است، بهتر است از پروکسی‌های مقیم استفاده کنید، و برای کار با API‌های موبایل و پلتفرم‌های اجتماعی — از پروکسی‌های موبایل با زمان حیات کوتاه اتصالات در کد استفاده کنید.