Yandex.Taxi تلتزم بنية microservice. مع الزيادة في عدد الخدمات المصغرة ، لاحظنا أن المطورين يقضون وقتًا طويلاً في حل المشكلات والمشاكل النموذجية ، في حين أن الحلول لا تعمل دائمًا على النحو الأمثل.
قررنا إنشاء إطار عمل خاص بنا ، باستخدام C ++ 17 و coroutines. هذه هي الطريقة التي يظهر بها الآن رمز الخدمة الجزئية:
Response View::Handle(Request&& request, const Dependencies& dependencies) { auto cluster = dependencies.pg->GetCluster(); auto trx = cluster->Begin(storages::postgres::ClusterHostType::kMaster); const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1"; auto row = psql::Execute(trx, statement, request.id)[0]; if (!row["ok"].As<bool>()) { LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb(); return Response400(); } psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar); trx.Commit(); return Response200{row["baz"].As<std::string>()}; }
وهذا هو السبب في أنه فعال وسريع للغاية - سنقول تحت الخفض.
Userver - غير متزامن
لا يتألف فريقنا من مطوري C ++ المتمرسين فحسب: فهناك متدربون ومطورون مبتدئون ، وحتى أشخاص ليسوا معتادين بشكل خاص على الكتابة في C ++. لذلك ، يعتمد تصميم الخادم على سهولة الاستخدام. ومع ذلك ، مع أحجام البيانات الخاصة بنا وتحميلها ، لا يمكننا أيضًا هدر موارد الحديد بطريقة غير فعالة.
تتميز Microservices بتوقع الإدخال / الإخراج: غالبًا ما يتم تشكيل استجابة microservice من عدة استجابات من الخدمات وقواعد البيانات الأخرى. يتم حل مشكلة انتظار I / O الفعال من خلال طرق غير متزامنة وعمليات الاسترجاعات: مع العمليات غير المتزامنة ، ليست هناك حاجة لإنتاج مؤشرات ترابط التنفيذ ، وبالتالي ، لا توجد حمولة كبيرة لتبديل التدفقات ... هذا مجرد رمز يصعب للغاية كتابته وصيانته:
void View::Handle(Request&& request, const Dependencies& dependencies, Response response) { auto cluster = dependencies.pg->GetCluster(); cluster->Begin(storages::postgres::ClusterHostType::kMaster, [request = std::move(request), response](auto& trx) { const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1"; psql::Execute(trx, statement, request.id, [request = std::move(request), response, trx = std::move(trx)](auto& res) { auto row = res[0]; if (!row["ok"].As<bool>()) { if (LogDebug()) { GetSomeInfoFromDb([id = request.id](auto info) { LOG_DEBUG() << id << " is not OK of " << info; }); } *response = Response400{}; } psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar, [row = std::move(row), trx = std::move(trx), response]() { trx.Commit([row = std::move(row), response]() { *response = Response200{row["baz"].As<std::string>()}; }); }); }); }); }
وها هي الكوتينات المحمومة تنقذ. يعتقد مستخدم الإطار أنه يكتب الكود المتزامن المعتاد:
auto row = psql::Execute(trx, queries::kGetRules, request.id)[0];
ومع ذلك ، يحدث ما يلي تقريبًا تحت الغطاء:
- يتم إنشاء حزم TCP وإرسالها مع طلب إلى قاعدة البيانات ؛
- تنفيذ coroutine ، حيث تعمل وظيفة View :: Handle حاليًا ، مع وقف التنفيذ ؛
- نقول لـ kernel في نظام التشغيل: "ضع coroutine المعلق في قائمة المهام الجاهزة للتنفيذ بمجرد أن تأتي حزم TCP كافية من قاعدة البيانات" ؛
- دون انتظار الخطوة السابقة ، نأخذ ونطلق coroutine آخر جاهز للتنفيذ من قائمة الانتظار.
بمعنى آخر ، تعمل الدالة من المثال الأول بشكل غير متزامن وهي قريبة من هذا الرمز باستخدام C ++ 20 Coroutines:
Response View::Handle(Request&& request, const Dependencies& dependencies) { auto cluster = dependencies.pg->GetCluster(); auto trx = co_await cluster->Begin(storages::postgres::ClusterHostType::kMaster); const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1"; auto row = co_await psql::Execute(trx, statement, request.id)[0]; if (!row["ok"].As<bool>()) { LOG_DEBUG() << request.id << " is not OK of " << co_await GetSomeInfoFromDb(); co_return Response400{"NOT_OK", "Please provide different ID"}; } co_await psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar); co_await trx.Commit(); co_return Response200{row["baz"].As<std::string>()}; }
هذا فقط لا يحتاج المستخدم إلى التفكير في co_await و co_return ، كل شيء يعمل "من تلقاء نفسه".
في إطار عملنا ، يكون التبديل بين coroutines أسرع من استدعاء std :: this_thread :: yield (). تكاليف microservice بأكملها عدد قليل جداً من مؤشرات الترابط.
في الوقت الحالي ، يحتوي userver على برامج تشغيل غير متزامنة:
* لمآخذ نظام التشغيل ؛
* http و https (العميل والخادم) ؛
* بوستجرس
* MongoDB ؛
* Redis ؛
* العمل مع الملفات ؛
* توقيت.
* بدايات لمزامنة وإطلاق coroutines جديدة.
يجب أن تكون الطريقة غير المتزامنة أعلاه لحل المهام المرتبطة بالإدخال / الإخراج مألوفة لدى مطوري البرامج. ولكن على عكس Go ، لا نتحمل تكاليف الذاكرة ووحدة المعالجة المركزية من جامع البيانات المهملة. يمكن للمطورين استخدام لغة أكثر ثراءً ، مع العديد من الحاويات والمكتبات عالية الأداء ، دون معاناة من عدم الاتساق أو RAII أو القوالب.
Userver - المكونات
بطبيعة الحال ، فإن الإطار الكامل ليس فقط coroutines. تتنوع مهام المطورين في Taxi بشكل كبير ، ويتطلب كل منهم مجموعة من الأدوات التي يجب حلها. لذلك ، لدى userver كل ما تحتاجه:
* للتسجيل ؛
* التخزين المؤقت.
* العمل مع صيغ البيانات المختلفة.
* العمل مع التكوينات وتحديث التكوينات دون إعادة تشغيل الخدمة ؛
* الأقفال الموزعة ؛
اختبار
* إذن والمصادقة.
* إنشاء وإرسال المقاييس.
* كتابة REST معالجات.
+ إنشاء رمز ودعم التبعية (المحرز في جزء منفصل من الإطار).
Userver - جيل الشفرة
دعنا نعود إلى السطر الأول من مثالنا ونرى ما هو مخفي وراء الاستجابة والطلب:
Response Handle(Request&& request, const Dependencies& dependencies);
باستخدام userver ، يمكنك كتابة أي خدمات microservice ، ولكن هناك حاجة لخدمات micros لدينا إلى أن تكون واجهات برمجة التطبيقات الخاصة بها موثقة (موصوفة من خلال مخططات swagger).
على سبيل المثال ، بالنسبة للمقبض من المثال ، قد يبدو مخطط swagger بالشكل التالي:
paths: /some/sample/{bar}: post: description: | Habr. summary: | , - . parameters: - in: query name: id type: string required: true - in: header name: foo type: string enum: - foo1 - foo2 required: true - in: path name: bar type: string required: true responses: '200': description: OK schema: type: object additionalProperties: false required: - baz properties: baz: type: string '400': $ref: '#/responses/ResponseCommonError'
حسنًا ، نظرًا لأن المطور لديه بالفعل مخطط يحتوي على وصف للطلبات والاستجابات ، فلماذا لا تنشئ هذه الطلبات والإجابات بناءً عليها؟ في الوقت نفسه ، يمكن أيضًا الإشارة إلى الارتباطات إلى ملفات protobuf / flatbuffer / ... في المخطط - سيؤدي إنشاء الشفرة من الطلب نفسه إلى الحصول على كل شيء ، والتحقق من صحة بيانات الإدخال وفقًا للمخطط وفك تشفيرها في حقول بنية الاستجابة. يحتاج المستخدم فقط لكتابة وظيفة في طريقة المقبض ، دون أن يشتت انتباهه عن طريق أداة تحليل تحليل الطلب وتسلسل الاستجابة.
في الوقت نفسه ، يعمل إنشاء الشفرة لعملاء الخدمة. يمكنك الإشارة إلى أن خدمتك تحتاج إلى عميل يعمل وفقًا لهذا المخطط ، واستعد لفئة للاستخدام لإنشاء طلبات غير متزامنة:
Request req; req.id = id; req.foo = foo; req.bar = bar; dependencies.sample_client.SomeSampleBarPost(req);
تحتوي هذه الطريقة على ميزة إضافية: دائمًا الوثائق الحديثة. إذا حاول أحد المطورين فجأة استخدام معلمات غير موجودة في الوثائق ، فسيحصل على خطأ في الترجمة.
خادم - تسجيل
نحن نحب كتابة السجلات. إذا قمت بتسجيل أهم المعلومات فقط ، فسيتم تشغيل عدة تيرابايت من السجلات في الساعة. لذلك ، ليس من المستغرب أن يكون لقطع الأشجار خدعنا الخاصة:
* إنه غير متزامن (بالطبع :-)) ؛
* يمكننا تسجيل تجاوز std البطيء :: locale و std :: ostream؛
* يمكننا التبديل مستوى التسجيل على الطاير (دون إعادة تشغيل الخدمة) ؛
* نحن لا ننفذ رمز المستخدم إذا لزم الأمر فقط للتسجيل.
على سبيل المثال ، أثناء التشغيل العادي للجهاز microservice ، سيتم ضبط مستوى التسجيل على INFO والتعبير بأكمله
LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb();
لن يتم حسابها. بما في ذلك استدعاء دالة كثيفة الاستخدام للموارد لن يحدث GetSomeInfoFromDb ().
إذا بدأت الخدمة فجأة في "خداع" ، يمكن للمطور دائمًا إخبار خدمة العمل: "تسجيل الدخول في وضع DEBUG". وفي هذه الحالة ، ستبدأ الإدخالات "ليست بخير" في الظهور في السجلات ، وسيتم تنفيذ وظيفة GetSomeInfoFromDb ().
بدلا من المجاميع
في مقال واحد ، من المستحيل أن نتحدث في وقت واحد عن جميع الميزات والحيل. لذلك ، بدأنا بمقدمة قصيرة. اكتب التعليقات حول الأشياء التي قد تكون مهتمًا بتعلمها وقراءتها.
الآن نحن نفكر فيما إذا كان سيتم نشر الإطار في مصدر مفتوح. إذا قررنا ذلك بنعم ، فإن إعداد الإطار لفتح المصدر سوف يتطلب الكثير من الجهد.