RESTinio هو خادم HTTP غير متزامن. مثال بسيط من الممارسة: إرجاع كمية كبيرة من البيانات استجابة


في الآونة الأخيرة ، صادفت العمل على تطبيق كان من المفترض أن يتحكم في سرعة اتصالاته الصادرة. على سبيل المثال ، عند الاتصال بعنوان URL واحد ، يجب أن يقتصر التطبيق على ، على سبيل المثال ، 200 كيلو بايت في الثانية. والاتصال بعنوان URL آخر - فقط 30KiB / ثانية.


النقطة الأكثر إثارة للاهتمام هنا كانت اختبار هذه القيود للغاية. كنت بحاجة إلى خادم HTTP من شأنه أن يعطي حركة المرور بسرعة معينة ، على سبيل المثال ، 512 كيلو بايت / ثانية. ثم يمكنني معرفة ما إذا كان التطبيق يقاوم بالفعل سرعة 200 كيلوبايت / ثانية أو إذا كان ينهار إلى سرعات أعلى.


ولكن أين يمكن الحصول على خادم HTTP هذا؟


نظرًا لأن لديّ علاقة مع خادم RESTinio HTTP المضمن في تطبيقات C ++ ، لم أكن أتوصل إلى أي شيء أفضل من رمي خادم اختبار HTTP بسيط على ركبتي يمكنه إرسال دفق طويل من البيانات الصادرة إلى العميل.


حول مدى بساطة الأمر وترغب في سرده في المقالة. في الوقت نفسه ، اكتشف في التعليقات ما إذا كان هذا أمرًا بسيطًا حقًا أو ما إذا كنت أخدع نفسي. من حيث المبدأ ، يمكن اعتبار هذه المقالة استمرارًا للمقال السابق حول RESTinio المسمى "RESTinio هو خادم HTTP غير متزامن. غير متزامن" . لذلك ، إذا كان شخص ما مهتمًا بقراءة تطبيق RESTinio الحقيقي ، وإن لم يكن شديد الخطورة ، فأنت مرحب بك في cat.


فكرة عامة


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


ولكن كل شيء أكثر تعقيدا قليلا


إذا كان العميل يقرأ البيانات بوتيرة أبطأ مما يرسله الخادم ، فإن مجرد إرسال N كيلوبايت كل ثانية ليس فكرة جيدة. لأن البيانات ستبدأ في التراكم في المقبس وهذا لن يؤدي إلى أي شيء جيد.


لذلك ، عند إرسال البيانات ، يُنصح بالتحكم في جاهزية المقبس للكتابة على جانب خادم HTTP. طالما أن المقبس جاهز (أي أنه لم يتم تجميع الكثير من البيانات فيه) ، يمكنك إرسال جزء جديد. ولكن إذا لم تكن جاهزة ، فأنت بحاجة إلى الانتظار حتى يصبح المقبس في حالة استعداد للتسجيل.


يبدو الأمر معقولاً ، لكن عمليات الإدخال / الإخراج مخفية في حواشي RESTinio ... كيف يمكنني معرفة ما إذا كان يمكن كتابة الجزء التالي من البيانات أم لا؟


يمكنك الخروج من هذا الموقف إذا كنت تستخدم أدوات الإعلام بعد الكتابة ، الموجودة في RESTinio. على سبيل المثال ، يمكننا كتابة هذا:


void request_handler(restinio::request_handle_t req) { req->create_response() //   . ... //   . .done([](const auto & ec) { ... //         . }); } 

سيتم استدعاء lambda الذي تم تمريره إلى طريقة done() عند انتهاء RESTinio من كتابة البيانات الصادرة. وفقًا لذلك ، إذا لم يكن المقبس جاهزًا للتسجيل لبعض الوقت ، فلن يتم استدعاء lambda على الفور ، ولكن بعد أن يصل المقبس إلى حالته الصحيحة ويقبل جميع البيانات الصادرة.


نظرًا لاستخدام مُخطِّرات ما بعد الكتابة ، سيكون منطق خادم الاختبار كما يلي:


  • إرسال الدُفعة التالية من البيانات ، وحساب الوقت الذي سنحتاج فيه إلى إرسال الدُفعة التالية في سياق الأحداث العادي ؛
  • نقوم بتعليق المخطر بعد الجزء التالي من البيانات ؛
  • عند استدعاء إشعار ما بعد الكتابة ، نتحقق مما إذا كانت الدفعة التالية قد وصلت أم لا. إذا كان الأمر كذلك ، فقم ببدء إرسال الجزء التالي على الفور. إذا لم يحدث ذلك ، ثم الديك الموقت.

نتيجة لذلك ، اتضح أنه بمجرد أن يبدأ التسجيل في التباطؤ ، فإن إرسال بيانات جديدة سوف يتوقف مؤقتًا. وتستأنف عندما يكون المقبس جاهزًا لقبول البيانات الصادرة الجديدة.


وأكثر تعقيدًا بقليل: chunked_output


يدعم RESTinio ثلاث طرق لإنشاء استجابة لطلب HTTP . أبسط طريقة ، والتي يتم استخدامها بشكل افتراضي ، ليست مناسبة في هذه الحالة ، لأن أحتاج إلى دفق لانهائي تقريبًا من البيانات الصادرة. وبطبيعة الحال ، لا يمكن set_body هذا set_body إلى مكالمة واحدة إلى طريقة set_body .


لذلك ، يستخدم خادم الاختبار الموصوف ما يسمى chunked_output . أي عند إنشاء إجابة ، أشير إلى RESTinio أن الإجابة ستتشكل في أجزاء. ثم ، أتصل بشكل دوري بأساليب append_chunk لإضافة الجزء التالي إلى الإجابة append_chunk لكتابة الأجزاء المتراكمة إلى المقبس.


ودعونا ننظر إلى الكود!


ربما يكفي أن تكون الكلمات الافتتاحية كافية وقد حان الوقت للانتقال إلى مراجعة الكود نفسه ، والذي يمكن العثور عليه في هذا المستودع . لنبدأ بوظيفة request_processor ، والتي تسمى لمعالجة كل طلب HTTP صالح. في الوقت نفسه ، دعنا نتصفح تلك الوظائف التي يتم استدعاؤها من request_processor . حسنًا ، سنرى كيف يتم تعيين request_processor بالضبط إلى طلب HTTP وارد أو آخر.


Request_processor وظيفة ومساعديها


يتم استدعاء وظيفة request_processor لمعالجة طلبات HTTP GET التي أحتاج إليها. يتم تمريرها كحجج:


  • Asio-shny io_context الذي يتم تنفيذ جميع الأعمال عليه (سيكون مطلوبًا ، على سبيل المثال ، لأجهزة ضبط الوقت) ؛
  • حجم جزء واحد من الاستجابة. أي إذا كنت بحاجة إلى تقديم دفق صادر بمعدل 512 كيلوبايت / ثانية ، فسيتم تمرير قيمة 512 كيلوبايت كالمعلمة ؛
  • عدد الأجزاء في الاستجابة. في حالة أن يكون للتيار طول محدود. على سبيل المثال ، إذا كنت ترغب في إعطاء دفق بمعدل 512 كيلوبايت / ثانية لمدة 5 دقائق ، فسيتم تمرير القيمة 300 باعتبارها هذه المعلمة (60 قطعة في الدقيقة لمدة 5 دقائق) ؛
  • حسنا ، الطلب الوارد نفسه للمعالجة.

داخل request_processor ، يتم إنشاء كائن بمعلومات حول الطلب ومعاملات المعالجة الخاصة به ، وبعد ذلك تبدأ هذه المعالجة بالذات:


 void request_processor( asio_ns::io_context & ctx, std::size_t chunk_size, std::size_t count, restinio::request_handle_t req) { auto data = std::make_shared<response_data>( ctx, chunk_size, req->create_response<output_t>(), count); data->response_ .append_header(restinio::http_field::server, "RESTinio") .append_header_date_field() .append_header( restinio::http_field::content_type, "text/plain; charset=utf-8") .flush(); send_next_portion(data); } 

يبدو نوع response_data ، الذي يحتوي على جميع المعلمات المتعلقة بالطلب ، كما يلي:


 struct response_data { asio_ns::io_context & io_ctx_; std::size_t chunk_size_; response_t response_; std::size_t counter_; response_data( asio_ns::io_context & io_ctx, std::size_t chunk_size, response_t response, std::size_t counter) : io_ctx_{io_ctx} , chunk_size_{chunk_size} , response_{std::move(response)} , counter_{counter} {} }; 

تجدر الإشارة هنا إلى أن أحد أسباب ظهور بنية response_data هو أن كائن النوع restinio::response_builder_t<restinio::chunked_output_t> (أي أن هذا النوع مخفي وراء response_t الاسم المستعار restinio::response_builder_t<restinio::chunked_output_t> ) هو نوع قابل للنقل ، لكن ليس نوعًا restinio::response_builder_t<restinio::chunked_output_t> (بواسطة القياس مع std::unique_ptr ). لذلك ، لا يمكن التقاط هذا الكائن في دالة lambda ، التي تلتف نفسها في std::function . ولكن إذا وضعت كائن الاستجابة في مثيل response_data تم إنشاؤه ديناميكيًا ، reponse_data بالفعل التقاط المؤشر الذكي لمثيل reponse_data في وظائف lambda دون مشاكل ، ثم حفظ هذا lambda إلى std::function .


وظيفة Send_next_portion


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


 void send_next_portion(response_data_shptr data) { data->response_.append_chunk(make_buffer(data->chunk_size_)); if(1u == data->counter_) { data->response_.flush(); data->response_.done(); } else { data->counter_ -= 1u; data->response_.flush(make_done_handler(data)); } } 

أي إرسال الجزء التالي. وإذا كان هذا الجزء هو الأخير ، فسنكمل معالجة الطلب. وإذا لم يكن الأخير ، فسيتم إرسال flush إلى طريقة flush ، والتي يتم إنشاؤها ، ربما ، بواسطة أكثر الوظائف تعقيدًا في هذا المثال.


وظيفة make_done_handler


وظيفة make_done_handler المسؤولة عن إنشاء lambda التي سيتم تمريرها إلى RESTinio كمخطر بعد الكتابة. يجب أن يتحقق هذا المخطر من نجاح تسجيل الجزء التالي من الاستجابة بنجاح. إذا كانت الإجابة بنعم ، فأنت بحاجة إلى معرفة ما إذا كان يجب إرسال الجزء التالي على الفور (أي ، كان هناك "الفرامل" في المقبس ولا يمكن الحفاظ على معدل الإرسال) ، أو بعد توقف مؤقت. إذا كنت بحاجة إلى توقف مؤقت ، فسيتم توفيره من خلال جهاز ضبط الوقت.


بشكل عام ، إجراءات بسيطة ، ولكن في الكود تحصل على lambda داخل lambda ، مما قد يربك الأشخاص الذين لا يعتادون على C ++ "الحديث". التي ليست بضع سنوات حتى أن يسمى الحديثة ؛)


 auto make_done_handler(response_data_shptr data) { const auto next_timepoint = steady_clock::now() + 1s; return [=](const auto & ec) { if(!ec) { const auto now = steady_clock::now(); if(now < next_timepoint) { auto timer = std::make_shared<asio_ns::steady_timer>(data->io_ctx_); timer->expires_after(next_timepoint - now); timer->async_wait([timer, data](const auto & ec) { if(!ec) send_next_portion(data); }); } else data->io_ctx_.post([data] { send_next_portion(data); }); } }; } 

في رأيي ، تنبع الصعوبة الرئيسية في هذا الكود من خصوصيات إنشاء وفصيل من الموقتات في آسيو. في رأيي ، اتضح بشكل مطول للغاية. ولكن هناك حقا ، وهذا هو. لكنك لست بحاجة إلى جذب أي مكتبات إضافية.


توصيل جهاز توجيه صريح


تتكون request_processor و send_next_portion و make_done_handler الموضحة أعلاه send_next_portion من الإصدار الأول من خادم الاختبار الخاص بي ، والذي تمت كتابته حرفيًا في 15 أو 20 دقيقة.


ولكن بعد يومين من استخدام خادم الاختبار هذا ، تبين أن هناك عيبًا خطيرًا فيه: لقد عاد دائمًا دفق الاستجابة بنفس السرعة. جمعت بسرعة 512 كيلوبايت / ثانية - يعطي كل 512KiB / ثانية. إعادة تجميع بسرعة 20 كيلوبايت / ثانية - ستعطى للجميع 20 كيلوبايت / ثانية وأي شيء آخر. ما كان غير مريح ، ل أصبح من الضروري أن تكون قادرًا على تلقي إجابات من "سمك" مختلف.


ثم ظهرت الفكرة: ماذا لو تم طلب سرعة الإرجاع مباشرة في عنوان URL؟ على سبيل المثال ، قاموا بتقديم طلب إلى localhost:8080/ وتلقوا استجابة بسرعة محددة مسبقًا. وإذا تقدمت بطلب إلى localhost:8080/128K ، فقد بدأوا في تلقي استجابة بسرعة 128 كيلوبايت / ثانية.


ثم ذهب التفكير إلى أبعد من ذلك: في عنوان URL يمكنك أيضًا تحديد عدد الأجزاء الفردية في الاستجابة. أي طلب localhost:8080/128K/3000 ستنتج تيار 3000 أجزاء بسرعة 128KiB / ثانية.


لا مشكلة RESTinio لديه القدرة على استخدام جهاز توجيه الاستعلام الذي تم إجراؤه تحت تأثير ExpressJS . نتيجة لذلك ، كانت هناك وظيفة لوصف معالجات طلبات HTTP الواردة:


 auto make_router(asio_ns::io_context & ctx) { auto router = std::make_unique<router_t>(); router->http_get("/", [&ctx](auto req, auto) { request_processor(ctx, 100u*1024u, 10000u, std::move(req)); return restinio::request_accepted(); }); router->http_get( R"(/:value(\d+):multiplier([MmKkBb]?))", [&ctx](auto req, auto params) { const auto chunk_size = extract_chunk_size(params); if(0u != chunk_size) { request_processor(ctx, chunk_size, 10000u, std::move(req)); return restinio::request_accepted(); } else return restinio::request_rejected(); }); router->http_get( R"(/:value(\d+):multiplier([MmKkBb]?)/:count(\d+))", [&ctx](auto req, auto params) { const auto chunk_size = extract_chunk_size(params); const auto count = restinio::cast_to<std::size_t>(params["count"]); if(0u != chunk_size && 0u != count) { request_processor(ctx, chunk_size, count, std::move(req)); return restinio::request_accepted(); } else return restinio::request_rejected(); }); return router; } 

هنا يتم تشكيل معالجات طلب HTTP GET لثلاثة أنواع من عناوين URL:


  • النموذج http://localhost/ ؛
  • النموذج http://localhost/<speed>[<U>]/ ؛
  • النموذج http://localhost/<speed>[<U>]/<count>/

حيث speed هي رقم يحدد السرعة ، و U هو مضاعف اختياري يشير إلى أي الوحدات يتم ضبط السرعة. يعني 128 أو 128b سرعة 128 بايت في الثانية. و 128 كيلو هو 128 كيلو بايت في الثانية.


كل عنوان URL له وظيفة lambda الخاصة به ، والتي تتفهم المعلمات المستلمة ، إذا كان كل شيء على ما يرام ، فإنه يستدعي وظيفة request_processor الموضحة أعلاه.


وظيفة المساعد extract_chunk_size كما يلي:


 std::size_t extract_chunk_size(const restinio::router::route_params_t & params) { const auto multiplier = [](const auto sv) noexcept -> std::size_t { if(sv.empty() || "B" == sv || "b" == sv) return 1u; else if("K" == sv || "k" == sv) return 1024u; else return 1024u*1024u; }; return restinio::cast_to<std::size_t>(params["value"]) * multiplier(params["multiplier"]); } 

هنا ، يتم استخدام C ++ lambda لمحاكاة الدالات المحلية من لغات البرمجة الأخرى.


الوظيفة الرئيسية


يبقى أن نرى كيف يعمل كل هذا في الوظيفة الرئيسية:


 using router_t = restinio::router::express_router_t<>; ... int main() { struct traits_t : public restinio::default_single_thread_traits_t { using logger_t = restinio::single_threaded_ostream_logger_t; using request_handler_t = router_t; }; asio_ns::io_context io_ctx; restinio::run( io_ctx, restinio::on_this_thread<traits_t>() .port(8080) .address("localhost") .write_http_response_timelimit(60s) .request_handler(make_router(io_ctx))); return 0; } 

ما يجري هنا:


  1. نظرًا لأنني لست بحاجة إلى جهاز توجيه عادي عادي للطلبات (لا يمكنه فعل أي شيء على الإطلاق ويضع كل العمل على أكتاف المبرمج) ، أقوم بتحديد خصائص جديدة لخادم HTTP الخاص بي. للقيام بذلك ، أغتنم الخصائص القياسية لخادم HTTP ذو ترابط واحد (اكتب restinio::default_single_thread_traits_t ) restinio::default_single_thread_traits_t إلى أنه سيتم استخدام مثيل restinio::default_single_thread_traits_t التوجيه السريع مثل معالج الطلب. في الوقت نفسه ، للتحكم في ما يحدث في الداخل ، أشير إلى أن خادم HTTP يستخدم أداة تسجيل حقيقية (بشكل افتراضي ، null_logger_t استخدام null_logger_t والذي لا يسجل أي شيء على الإطلاق).
  2. نظرًا لأنني أحتاج إلى تشغيل أجهزة ضبط الوقت داخل أجهزة إرسال ما بعد الكتابة ، فأنا بحاجة إلى نسخة نصية io_context يمكنني العمل معها. لذلك ، أنا خلقته بنفسي. هذا يتيح لي الفرصة لتمرير ارتباط إلى io_context الخاص بي في وظيفة make_router .
  3. يبقى فقط بدء تشغيل خادم HTTP في إصدار واحد مترابطة على io_context الذي قمت بإنشائه مسبقًا. سوف ترجع وظيفة restinio::run التحكم فقط عندما ينتهي خادم HTTP من عمله.

استنتاج


لم تُظهر المقالة الرمز الكامل لخادم الاختبار الخاص بي ، بل نقاطه الرئيسية فقط. الرمز الكامل أكبر قليلاً بسبب typedefs الإضافية والوظائف الإضافية ، أكثر أصالة إلى حد ما. يمكنك أن ترى ذلك هنا . في وقت كتابة هذا التقرير ، كان هناك 185 سطرًا ، بما في ذلك الأسطر والتعليقات الفارغة. حسنًا ، تتم كتابة هذه الخطوط البالغ عددها 185 سطرًا بطريقتين لا تزيد مدتهما عن ساعة.


أحببت هذه النتيجة وكانت المهمة مثيرة للاهتمام. من الناحية العملية ، تم الحصول على الأداة المساعدة التي احتاجها بسرعة. وفيما يتعلق بمزيد من التطوير لـ RESTinio ، ظهرت بعض الأفكار.


بشكل عام ، إذا لم يحاول شخص آخر تجربة RESTinio ، فأنا أدعوك للمحاولة. المشروع نفسه يعيش على جيثب . يمكنك طرح سؤال أو التعبير عن اقتراحاتك في مجموعة Google أو هنا في التعليقات.

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


All Articles