إذا كنت قد واجهت يومًا SocketException: Address already in use أو رأيت تطبيقًا على .NET ينفد منه المقابس المتاحة فجأة بعد بضع ساعات من العمل مع البروكسي، فإن المشكلة تكمن دائمًا تقريبًا في الاستخدام غير الصحيح لـ HttpClient. في هذه المقالة، سنستعرض كيفية إعداد البروكسي عبر SocketsHttpHandler، وتوصيل SOCKS5 وتنظيم تدوير IP بحيث لا يتحول تجمع الاتصالات إلى عنق زجاجة.
لماذا "يأكل" HttpClient المقابس
الخطأ الكلاسيكي هو إنشاء مثيل جديد من HttpClient لكل طلب داخل using. عند العمل مع البروكسي، تتفاقم هذه المشكلة: كل اتصال عبر خادم البروكسي يتطلب مصافحة TCP منفصلة، وإذا كنت تقوم أيضًا بتغيير البروكسي أثناء التشغيل، فإن تجمع الاتصالات لا يعاد استخدامه، وتبقى المقابس في حالة TIME_WAIT.
على نظام Windows، تكون هذه المشكلة حرجة بشكل خاص - حيث يتوفر عدد محدود من المنافذ الديناميكية (عادةً 16384) بشكل افتراضي، وإذا كان التطبيق يقوم بقراءة البيانات عبر البروكسي بكثافة عالية من الطلبات، فقد تنفد جميع المنافذ في غضون دقائق. على نظام Linux، تكون الحالة أكثر تساهلاً بسبب إعدادات النواة، ولكن تبقى المشكلة قائمة إذا لم يتم إعادة استخدام الاتصالات.
لا يوفر HttpClientHandler القديم، المتاح منذ أيام .NET Framework، تحكمًا مرنًا في دورة حياة الاتصالات. لهذا السبب، بدءًا من .NET Core 2.1، يُوصى باستخدام SocketsHttpHandler - فهو مكتوب بالكامل بلغة managed ويتيح الوصول المباشر إلى تجمع الاتصالات، ووقت انتهاء صلاحية DNS، وإعدادات البروكسي.
SocketsHttpHandler: لماذا هو مطلوب
SocketsHttpHandler هو مستوى النقل الذي تم بناء HttpClient عليه بشكل افتراضي على Linux و macOS منذ .NET Core 2.1، ومنذ .NET 5 أيضًا على Windows. على عكس 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 مع هذا المعالج singleton أو يتم إنشاؤه عبر IHttpClientFactory - لا تعيد إنشاؤه لكل طلب، وإلا فإن الغرض من تجمع الاتصالات يضيع.
إعداد البروكسي عبر SocketsHttpHandler
بالنسبة لبروكسي HTTP/HTTPS، يكفي تعيين خاصية Proxy على مستوى المعالج. هذا يعمل مع بروكسي مراكز البيانات والبروكسي السكنية التي تقدم الوصول عبر بروتوكول 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 - غالبًا ما يتم دمج كلا النهجين: يتم توجيه حركة المرور الرئيسية عبر بروكسي مراكز البيانات كبروكسي مراكز البيانات، بينما يتم توجيه الطلبات إلى خدمات CAPTCHA أو المصادقة مباشرة.
دعم 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 الخاص بهم، الذي كان يقوم يدويًا بإجراء مصافحة 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+ واستخدام التنفيذ المدمج - فهو مغطى بالاختبارات في وقت التشغيل ولا يتطلب دعمًا ذاتيًا لبروتوكول المصافحة.
تدوير البروكسي دون إعادة إنشاء HttpClient
خطأ شائع عند تنفيذ تدوير IP هو إنشاء HttpClient جديد (وبالتالي، معالج جديد، وتجمع اتصالات جديد) في كل مرة يتم فيها تغيير البروكسي. هذا يؤدي بالتأكيد إلى تراكم المقابس غير المغلقة. النهج الصحيح هو تنفيذ 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، من الأسهل استخدام بروكسي سكنية مع تدوير على جانب المزود - حينها لا تحتاج إلى تغيير العنوان في الكود، يكفي الاحتفاظ بنقطة دخول واحدة مع تغيير تلقائي لعنوان IP الخارجي.
PooledConnectionLifetime وتدوير DNS
إذا كان مزود البروكسي يستخدم تدوير DNS (اسم مضيف واحد يتم حله إلى IPs مختلفة مع كل طلب)، فإنه من الضروري تعيين 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 القصيرة ليست مجانية - كل إعادة إنشاء اتصال تعني مصافحة TCP+TLS جديدة، مما يضيف تأخيرًا قدره 50-200 مللي ثانية. يتم تحديد التوازن بين حداثة IP والأداء تجريبيًا: لمهام مراقبة الأسعار، يكفي 60-120 ثانية، وللجمع عالي التردد للحسابات - أحيانًا تحتاج إلى 10-20 ثانية.
مقارنة أنواع البروكسي لتطبيقات .NET
| نوع البروكسي | البروتوكول | PooledConnectionLifetime الموصى به | المهام |
|---|---|---|---|
| بروكسي مراكز البيانات | HTTP/HTTPS، SOCKS5 | 5-10 دقائق | تحليل، تكامل واجهة برمجة التطبيقات، سرعة عالية |
| بروكسي سكنية | HTTP/HTTPS، SOCKS5 | 30-90 ثانية | مراقبة الأسعار، تجاوز الحجب الجغرافي |
| بروكسي محمولة | HTTP/HTTPS، SOCKS5 | 10-30 ثانية | أتمتة في الشبكات الاجتماعية، واجهات برمجة التطبيقات الحساسة لمكافحة الاحتيال |
IHttpClientFactory والعملاء المخصصين مع البروكسي
في مشاريع ASP.NET Core، يُوصى بتسجيل HttpClient عبر IHttpClientFactory - فهذا يخفف من مسؤولية المطور عن إدارة دورة حياة المعالجات، ويعيد استخدام 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 - هذه آلية منفصلة لتدوير المعالجات نفسها داخل المصنع (بشكل افتراضي دقيقتان)، والتي لا ينبغي الخلط بينها وبين PooledConnectionLifetime للاتصال الفردي. يقوم المصنع بانتظام بإنشاء معالج جديد، ولكن الاتصالات القديمة تستمر في العمل حتى تنتهي الطلبات النشطة - سيقوم جامع القمامة بتنظيفها بنفسه.
لسيناريوهات متعددة تجمعات البروكسي، من الملائم تسجيل عدة عملاء مسماة - على سبيل المثال، بشكل منفصل للولايات المتحدة وأوروبا، إذا كانت المهمة هي اختبار إعلانات 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
});
تشخيص تسرب المقابس
إذا كنت تشك في تراكم المقابس، فلا تخمن - تحقق. على نظام Linux، هناك طريقة سريعة لرؤية عدد الاتصالات المفتوحة لعملية معينة:
# عدد الاتصالات في TIME_WAIT
ss -tan state time-wait | wc -l
# جميع الاتصالات لعملية معينة (PID)
ls -la /proc/{PID}/fd | grep socket | wc -l
على نظام Windows، توفر نفس الإحصائيات الأمر 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 ينمو بشكل مستمر ولا ينخفض أبدًا - فهذا علامة مباشرة على أن المعالج يتم إعادة إنشائه بشكل متكرر جدًا، أو أن PooledConnectionIdleTimeout تم تعيينه إلى Infinite، وأن الطلبات تتوقف عن الانتهاء بشكل صحيح (على سبيل المثال، لا يتم قراءة جسم الاستجابة بالكامل، ولا يتم إرجاع الاتصال إلى التجمع).
سبب شائع آخر للتسرب هو response.Content المنسي، الذي لم يتم قراءته أو لم يتم التخلص منه. استخدم دائمًا using var response = ... أو استدعاء صريح لـ Dispose() بعد الانتهاء من العمل مع الاستجابة.
الخاتمة
يعتمد العمل الصحيح مع البروكسي في .NET على ثلاثة مبادئ: استخدم SocketsHttpHandler بدلاً من التنفيذات القديمة، احتفظ بـ HttpClient كـ singleton (أو عبر IHttpClientFactory)، ونفذ تدوير البروكسي عبر IWebProxy مخصص، وليس من خلال إعادة إنشاء العميل بالكامل. بدءًا من .NET 6، يتم دعم SOCKS5 بشكل أصلي، مما يلغي الحاجة لكتابة بروتوكول المصافحة الخاص بك.
إذا كان تطبيقك يقوم بتحليل الأسواق أو جمع البيانات عبر واجهات برمجة التطبيقات بكثافة عالية من الطلبات، فانتبه إلى بروكسي مراكز البيانات - فهي توفر أقصى سرعة مع الإعداد الصحيح لتجمع الاتصالات. لمهام حيث تكون الخصوصية والتشابه مع حركة المرور العادية مهمة، فإن البروكسي السكنية هي الأفضل، وللعمل مع واجهات برمجة التطبيقات المحمولة والمنصات الاجتماعية - البروكسي المحمولة مع أوقات حياة قصيرة للاتصالات في الكود.