مطبات HttpClient في .NET

استمرارًا لسلسلة من المقالات حول "المزالق" ، لا يمكنني تجاهل System.Net.HttpClient ، الذي يُستخدم غالبًا في الممارسة العملية ، ولكن به العديد من المشكلات الخطيرة التي قد لا تكون مرئية على الفور.

هناك مشكلة شائعة إلى حد ما في البرمجة هي أن المطورين يركزون فقط على وظيفة مكون معين ، بينما يتجاهلون تمامًا مكونًا مهمًا غير وظيفي للغاية ، والذي يمكن أن يؤثر على الأداء وقابلية التوسع وسهولة الاسترداد في حالة الفشل والأمن وما إلى ذلك. على سبيل المثال ، يبدو أن نفس HttpClient مكون أساسي ، ولكن هناك العديد من الأسئلة: كم يخلق اتصالات متوازية للخادم ، وكم من الوقت يعيش ، وكيف سيتصرف إذا تم تحويل اسم DNS الذي تم الوصول إليه سابقًا إلى عنوان IP مختلف ؟؟؟ دعنا نحاول الإجابة على هذه الأسئلة في المقالة.

  1. تسرب الاتصال
  2. الحد من اتصالات الخادم المتزامنة
  3. اتصالات طويلة الأمد وتخزين DNS المؤقت

المشكلة الأولى مع HttpClient هي تسرب الاتصال غير الواضح. في كثير من الأحيان ، كان علي تلبية التعليمات البرمجية حيث يتم إنشاؤها لتنفيذ كل طلب:

public async Task<string> GetSomeText(Guid textId) { using (var client = new HttpClient()) { return await client.GetStringAsync($"http://someservice.com/api/v1/some-text/{textId}"); } } 

لسوء الحظ ، يؤدي هذا النهج إلى إهدار كبير للموارد واحتمال كبير للحصول على تجاوز قائمة الاتصالات المفتوحة. لإظهار المشكلة بوضوح ، يكفي تنفيذ التعليمات البرمجية التالية:

 static void Main(string[] args) { for(int i = 0; i < 10; i++) { using (var client = new HttpClient()) { client.GetStringAsync("https://habr.com").Wait(); } } } 

وفي النهاية ، انظر إلى قائمة الاتصالات المفتوحة عبر netstat:

 PS C: \ Development \ Exercises> netstat -n |  select-string -terntern "178.248.237.68"

   TCP 192.168.1.13:43684 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43685 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43686 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43687 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43689 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003690 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003691 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003692 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003693 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003695 178.248.237.68-00-0043 TIME_WAIT

هنا ، يتم استخدام -n لتسريع الإخراج ، وإلا فإن netstat لكل IP سيبحث عن اسم المجال ، و 178.248.237.68 سيبحث عن عنوان IP habr.com في وقت كتابة هذا التقرير.

بشكل عام ، نرى أنه على الرغم من استخدام الإنشاء ، وعلى الرغم من اكتمال البرنامج بالكامل ، إلا أن الاتصالات بالخادم ظلت "معلقة". وسيتم تعليقها طالما هو موضح في مفتاح التسجيل HKEY_LOCAL_MACHINE \ SYSTEM \ CurrentControlSet \ Services \ Tcpip \ Parameters \ TcpTimedWaitDelay.

قد يطرح سؤال على الفور - كيف يتصرف .NET Core في مثل هذه الحالات؟ ما هو موجود على Windows ، وما هو على Linux - نفس الشيء تمامًا ، لأن الاحتفاظ بالاتصال يحدث على مستوى النظام ، وليس على مستوى التطبيق. حالة TIME_WAIT هي حالة خاصة للمقبس بعد أن يتم إغلاقها بواسطة التطبيق ، وهذا ضروري لمعالجة الحزم التي لا يزال بإمكانها المرور عبر الشبكة. بالنسبة إلى Linux ، يتم تحديد مدة هذه الحالة بالثواني في / proc / sys / net / ipv4 / tcp_fin_timeout ، وبالطبع يمكن تغييرها إذا لزم الأمر.

العدد الثاني من HttpClient هو الحد غير الواضح للاتصالات المتزامنة بالخادم . لنفترض أنك تستخدم .NET Framework 4.7 المألوف ، الذي تقوم من خلاله بتطوير خدمة محملة للغاية ، حيث توجد مكالمات إلى خدمات أخرى عبر HTTP. تمت معالجة المشكلة المحتملة في تسرب الاتصال ، لذلك يتم استخدام مثيل HttpClient نفسه لجميع الطلبات. ماذا يمكن أن يكون خطأ؟

يمكن رؤية المشكلة بسهولة عن طريق تشغيل الكود التالي:

 static void Main(string[] args) { var client = new HttpClient(); var tasks = new List<Task>(); for (var i = 0; i < 10; i++) { tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com")); } Task.WaitAll(tasks.ToArray()); } private static async Task SendRequest(HttpClient client, string url) { var response = await client.GetAsync(url); Console.WriteLine($"Received response {response.StatusCode} from {url}"); } 

يسمح لك المورد المحدد في الرابط بتأجيل استجابة الخادم للوقت المحدد ، في هذه الحالة - 5 ثوانٍ.

كما أنه من السهل ملاحظة ذلك بعد تنفيذ الرمز أعلاه - كل 5 ثوانٍ يصل ردان فقط ، على الرغم من إنشاء 10 طلبات متزامنة. ويرجع ذلك إلى حقيقة أن التفاعل مع HTTP في إطار .NET عادي ، من بين أمور أخرى ، يمر عبر فئة خاصة System.Net.ServicePointManager تتحكم في جوانب مختلفة من اتصالات HTTP. تحتوي هذه الفئة على خاصية DefaultConnectionLimit التي تشير إلى عدد الاتصالات المتزامنة التي يمكن إنشاؤها لكل مجال. ومن الناحية التاريخية ، القيمة الافتراضية للعقار هي 2.

إذا قمت بإضافة مثال الرمز أعلاه في البداية

 ServicePointManager.DefaultConnectionLimit = 5; 

بعد ذلك ، سيتم تسريع تنفيذ المثال بشكل ملحوظ ، حيث سيتم تنفيذ الطلبات على دفعات من 5.

وقبل الانتقال إلى كيفية عمل ذلك في .NET Core ، يجب قول المزيد عن ServicePointManager. تشير الخاصية التي تمت مناقشتها أعلاه إلى العدد الافتراضي للاتصالات التي سيتم استخدامها للاتصالات اللاحقة بأي مجال. ولكن بالإضافة إلى ذلك ، من الممكن التحكم في المعلمات لكل اسم مجال على حدة ويتم ذلك من خلال فئة ServicePoint:

 var delayServicePoint = ServicePointManager.FindServicePoint(new Uri("http://slowwly.robertomurray.co.uk")); delayServicePoint.ConnectionLimit = 3; var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); habrServicePoint.ConnectionLimit = 5; 

بعد تنفيذ هذا الرمز ، فإن أي تفاعل مع هابر من خلال نفس مثيل HttpClient سيستخدم 5 اتصالات متزامنة ، و 3 اتصالات مع الموقع "ببطء".

هناك فارق بسيط آخر مثير للاهتمام هنا - الحد الأقصى لعدد الاتصالات للعناوين المحلية (localhost) هو int.MaxValue بشكل افتراضي. ما عليك سوى إلقاء نظرة على نتائج تنفيذ هذا الرمز دون تعيين DefaultConnectionLimit أولاً:

 var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); Console.WriteLine(habrServicePoint.ConnectionLimit); var localServicePoint = ServicePointManager.FindServicePoint(new Uri("http://localhost")); Console.WriteLine(localServicePoint.ConnectionLimit); 

الآن دعنا ننتقل إلى .NET Core. على الرغم من أن ServicePointManager لا يزال موجودًا في مساحة الاسم System.Net ، إلا أنه لا يؤثر على سلوك HttpClient في .NET Core. بدلاً من ذلك ، يمكن التحكم في معلمات اتصال HTTP باستخدام HttpClientHandler (أو SocketsHttpHandler ، والذي سنتحدث عنه لاحقًا):

 static void Main(string[] args) { var handler = new HttpClientHandler(); handler.MaxConnectionsPerServer = 2; var client = new HttpClient(handler); var tasks = new List<Task>(); for (int i = 0; i < 10; i++) { tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com")); } Task.WaitAll(tasks.ToArray()); Console.ReadLine(); } private static async Task SendRequest(HttpClient client, string url) { var response = await client.GetAsync(url); Console.WriteLine($"Received response {response.StatusCode} from {url}"); } 

سيتصرف المثال أعلاه تمامًا مثل المثال الأولي لـ .NET Framework العادي - لإنشاء اتصالين فقط في كل مرة. ولكن إذا قمت بإزالة السطر مع مجموعة خصائص MaxConnectionsPerServer ، فسيكون عدد الاتصالات المتزامنة أعلى بكثير ، نظرًا لأن قيمة هذه الخاصية بشكل افتراضي في .NET Core هي int.MaxValue.

والآن دعونا نلقي نظرة على المشكلة الثالثة غير الواضحة في الإعدادات الافتراضية ، والتي لا يمكن أن تكون أقل أهمية من الإثنين السابقين - الاتصالات طويلة الأمد و DNS التخزين المؤقت . عند إنشاء اتصال بخادم بعيد ، يتم أولاً حل اسم المجال إلى عنوان IP المقابل ، ثم يتم وضع العنوان المستلم في ذاكرة التخزين المؤقت لبعض الوقت لتسريع الاتصالات اللاحقة. بالإضافة إلى ذلك ، لتوفير الموارد ، غالبًا ما لا يتم إغلاق الاتصال بعد كل طلب ، ولكن يبقى مفتوحًا لفترة طويلة.

تخيل أن النظام الذي نقوم بتطويره يجب أن يعمل بشكل طبيعي دون فرض إعادة تشغيل إذا تغير الخادم الذي يتفاعل معه إلى عنوان IP مختلف. على سبيل المثال ، إذا قمت بالتبديل إلى مركز بيانات آخر بسبب فشل في المركز الحالي. حتى إذا انقطع الاتصال الدائم بسبب فشل في مركز البيانات الأول (والذي يمكن أن يحدث أيضًا بسرعة) ، فلن تسمح ذاكرة التخزين المؤقت لـ DNS لنظامنا بالاستجابة السريعة لمثل هذا التغيير. وينطبق الشيء نفسه على المكالمات إلى العنوان حيث تتم موازنة التحميل من خلال نظام DNS المستدير.

في حالة وجود إطار عمل .NET "عادي" ، يمكن التحكم في هذا السلوك من خلال ServicePointManager و ServicePoint (تأخذ جميع المعلمات المدرجة أدناه قيمًا بالمللي ثانية):

  • ServicePointManager.DnsRefreshTimeout - تشير إلى المدة التي سيتم فيها تخزين عنوان IP المستلم لكل اسم مجال مؤقتًا ، والقيمة الافتراضية هي دقيقتان (120000).
  • ServicePoint.ConnectionLeaseTimeout - يشير إلى المدة التي يمكن خلالها إبقاء الاتصال مفتوحًا. بشكل افتراضي ، لا يوجد حد زمني للاتصالات ؛ يمكن إجراء أي اتصال لفترة طويلة بشكل تعسفي ، لأن هذه المعلمة هي -1. يؤدي تعيينها إلى 0 إلى إغلاق كل اتصال فورًا بعد اكتمال الطلب.
  • ServicePoint.MaxIdleTime - يشير بعد وقت عدم النشاط الذي سيتم فيه إغلاق الاتصال. التقاعس يعني عدم نقل البيانات من خلال الاتصال. قيمة هذه المعلمة بشكل افتراضي 100 ثانية (100000).

الآن ، لتحسين فهم هذه المعلمات ، سوف نجمعها جميعًا في سيناريو واحد. افترض أنه لم يتم تغيير DnsRefreshTimeout و MaxIdleTime وهما 120 و 100 ثانية على التوالي. باستخدام هذا ، تم تعيين ConnectionLeaseTimeout إلى 60 ثانية. يقوم التطبيق بإنشاء اتصال واحد فقط ، يرسل من خلاله الطلبات كل 10 ثوانٍ.

باستخدام هذه الإعدادات ، سيتم إغلاق الاتصال كل 60 ثانية (ConnectionLeaseTimeout) ، على الرغم من أنه ينقل البيانات بشكل دوري. سيتم الإغلاق وإعادة الإنشاء بطريقة لا تتداخل مع التنفيذ الصحيح للطلبات - إذا انتهى الوقت ، وفي الوقت الحالي لا يزال الطلب قيد التنفيذ ، سيتم إغلاق الاتصال بعد اكتمال الطلب. في كل مرة يتم إعادة إنشاء الاتصال ، سيتم أخذ عنوان IP المطابق من ذاكرة التخزين المؤقت أولاً ، وفقط إذا انتهت الدقة (120 ثانية) ، سيرسل النظام طلبًا إلى خادم DNS.

لن تلعب المعلمة MaxIdleTime دورًا في هذا السيناريو ، نظرًا لأن الاتصال لم يكن خاملاً لأكثر من 10 ثوانٍ.

تعتمد النسبة المثلى لهذه المعلمات بشكل كبير على الوضع المحدد والمتطلبات غير الوظيفية:

  • إذا كنت لا تنوي التبديل الشفاف لعناوين IP خلف اسم النطاق الذي يصل إليه تطبيقك ، وفي الوقت نفسه تحتاج إلى تقليل تكلفة اتصالات الشبكة ، فإن الإعدادات الافتراضية تبدو كخيار جيد.
  • إذا كانت هناك حاجة للتبديل بين عناوين IP في حالة حدوث فشل ، فيمكنك تعيين DnsRefreshTimeout على 0 ، و ConnectionLeaseTimeout على القيمة غير السلبية التي تناسبك. أيهما يعتمد بشكل خاص على مدى السرعة التي تحتاجها للتبديل إلى عنوان IP آخر. من الواضح أنك تريد أن تحصل على أسرع استجابة ممكنة للفشل ، ولكن هنا تحتاج إلى العثور على القيمة المثلى ، والتي ، من ناحية ، توفر وقت تبديل مقبولًا ، من ناحية أخرى ، لا تقلل من إنتاجية ووقت استجابة النظام من خلال عمليات إعادة الاتصال المتكررة.
  • إذا كنت بحاجة إلى أسرع رد فعل ممكن لتغيير عنوان IP ، على سبيل المثال ، كما هو الحال في حالة الموازنة عبر DNS round-robin ، يمكنك محاولة تعيين DnsRefreshTimeout و ConnectionLeaseTimeout إلى 0 ، ولكن هذا سيكون مضيعة للغاية: لكل طلب ، سيتم استقصاء خادم DNS أولاً ، ثم بعد ذلك سيتم إعادة تأسيس الاتصال بالعقدة الهدف.
  • قد تكون هناك حالات يكون فيها تعيين ConnectionLeaseTimeout على 0 باستخدام DnsRefreshTimeout غير صفري مفيدًا ، ولكن لا يمكنني التوصل إلى نص برمجي مناسب على الفور. منطقيا ، هذا يعني أنه لكل طلب ، سيتم إنشاء الاتصالات من جديد ، ولكن سيتم أخذ عناوين IP من ذاكرة التخزين المؤقت كلما أمكن ذلك.

فيما يلي مثال على التعليمات البرمجية التي يمكن استخدامها لمراقبة سلوك المعلمات الموضحة أعلاه:
 var client = new HttpClient(); ServicePointManager.DnsRefreshTimeout = 120000; var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); habrServicePoint.MaxIdleTime = 100000; habrServicePoint.ConnectionLeaseTimeout = 60000; while (true) { client.GetAsync("https://habr.com").Wait(); Thread.Sleep(10000); } 

أثناء تشغيل برنامج الاختبار ، يمكنك تشغيل netstat من خلال PowerShell في حلقة لمراقبة الاتصالات التي ينشئها.

يجب أن يقال على الفور كيفية إدارة المعلمات الموصوفة في .NET Core. لن تعمل الإعدادات من ServicePointManager ، كما في حالة ConnectionLimit. يحتوي Core على نوع خاص من معالج HTTP يقوم بتنفيذ اثنين من المعلمات الثلاثة الموضحة أعلاه - SocketsHttpHandler:

 var handler = new SocketsHttpHandler(); handler.PooledConnectionLifetime = TimeSpan.FromSeconds(60); // ConnectionLeaseTimeout handler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(100); // MaxIdleTime var client = new HttpClient(handler); 

لا توجد معلمة تتحكم في وقت التخزين المؤقت لسجلات DNS في .NET Core. تظهر حالات الاختبار أن التخزين المؤقت لا يعمل - عند إنشاء اتصال DNS جديد ، يتم تنفيذ الحل مرة أخرى ، لذلك للتشغيل العادي في ظل الظروف التي يمكن فيها اسم النطاق المطلوب التبديل بين عناوين IP المختلفة ، فقط قم بتعيين PooledConnectionLifetime على القيمة المطلوبة.

بالإضافة إلى كل شيء ، يجب أن يقال أن جميع هذه المشاكل لا يمكن أن يلاحظها مطورو Microsoft ، وبالتالي ، بدءًا من .NET Core 2.1 ، ظهر مصنع عملاء HTTP يسمح بحل بعضها - https://docs.microsoft.com/en- us / dotnet / standard / microservices-architecture / تنفيذ - مرونة - تطبيقات / استخدام - httpclientfactory - إلى - تنفيذ - مرونة - طلبات - http . علاوة على ذلك ، بالإضافة إلى إدارة عمر الاتصالات ، يوفر المكون الجديد فرصًا لإنشاء عملاء مطبوعين ، بالإضافة إلى بعض الأشياء المفيدة الأخرى. في هذه المقالة والروابط منه هناك معلومات وأمثلة كافية حول استخدام HttpClientFactory ، لذلك ، لن أفكر في التفاصيل المرتبطة به في هذه المقالة.

Source: https://habr.com/ru/post/ar424873/


All Articles