تقنية تطوير خوادم موثوق بها للغاية على Go

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



ما هو مستوى الموثوقية التي يحتاجها مشروعك؟


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

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

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

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

ربما يكون الحل هو TDD؟


غالبًا ما يُعتبر التطوير من خلال الاختبار ( التطوير المدفوع بالاختبار ، TDD) أفضل علاج للشفرة السيئة. TDD هي منهجية تطوير خالصة ، في تطبيق الاختبارات التي تتم كتابتها أولاً ، وعندها فقط - الرمز الذي يضاف إلى المشروع فقط عندما تتوقف الاختبارات التي تتحقق منه ، عن توليد الأخطاء. تضمن هذه العملية تغطية الرمز بنسبة 100٪ بالاختبارات وغالبًا ما تعطي الوهم بأن الرمز تم اختباره في جميع المتغيرات الممكنة لاستخدامه.

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

الاختبارات هي مفتاح الموثوقية


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

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

يجب أن تكون الاختبارات سريعة. سريع جدا.

إذا استغرق إكمال مجموعة الاختبار أكثر من بضع ثوان ، فمن المرجح أن يكون المطورون كسالى ويضيفون كودًا إلى المشروع دون اختباره. السرعة هي واحدة من أعظم نقاط القوة في Go. تعد مجموعة أدوات التطوير في هذه اللغة واحدة من الأسرع بين تلك الموجودة. يتم تجميع المشاريع وإعادة بنائها واختبارها في ثوانٍ.

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

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

تعتبر التغطية الكاملة لقاعدة الشفرة بالاختبارات مقياسًا مخادعًا ولكنه مهم. قد يبدو الهدف المتمثل في تحقيق تغطية رمز 100٪ بالاختبارات مفرطًا ، ولكن إذا فكرت في الأمر ، فقد اتضح أنه إذا لم يتم تغطية الرمز بالكامل من خلال الاختبارات ، فسيتم إرسال جزء من الرمز إلى الإنتاج دون التحقق ، والذي لم يتم تنفيذه من قبل.

لا تعني التغطية الكاملة للشفرة بالاختبارات بالضرورة أن هناك ما يكفي من الاختبارات في المشروع ، ولا تعني أن هذه اختبارات توفر جميع الخيارات تمامًا لاستخدام الرمز. بكل ثقة ، يمكننا القول فقط أنه إذا لم يتم تغطية المشروع بنسبة 100٪ في الاختبارات ، فلا يمكن للمطور التأكد من الموثوقية المطلقة للرمز ، حيث أن بعض أجزاء الرمز لم يتم اختبارها أبدًا.

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

لماذا يعتبر Go خيارًا رائعًا لمشاريع موثوقة للغاية؟


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

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

الذهاب مختصر ولكنه معبر. الكود المقروء أسهل للتحليل والتدقيق. إذا كانت الشفرة مطولة للغاية ، فقد يغرق غرضها الرئيسي في "ضوضاء" الإنشاءات المساعدة. إذا كانت الشفرة موجزة للغاية ، فقد يكون من الصعب قراءة وفهم البرامج الموجودة عليها. يحافظ جو على التوازن بين الإيجاز والتعبير. على سبيل المثال ، لا يوجد العديد من التركيبات المساعدة فيه ، كما هو الحال في لغات مثل Java أو C ++. في الوقت نفسه ، فإن إنشاءات Go ، التي تتعلق ، على سبيل المثال ، بمجالات مثل معالجة الأخطاء ، واضحة للغاية ومفصلة تمامًا ، مما يبسط عمل المبرمج ، مما يساعده على التأكد ، على سبيل المثال ، من أنه فحص كل ما هو ممكن.

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

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

التبعيات هي أيضا خطر أمني ، حيث أن مستوى الضعف في المشروع يتوافق مع مستوى الضعف في أكثر التبعيات غير الآمنة . يتم الحفاظ على المكتبة القياسية الواسعة Go من قبل مطوريها في حالة جيدة جدًا ، ويقلل وجودها من الحاجة إلى التبعيات الخارجية.

سرعة تطوير عالية. السمة الرئيسية لبيئات مثل Node.js هي دورة التطوير القصيرة للغاية. تستغرق كتابة التعليمات البرمجية وقتًا أقل ، ونتيجة لذلك ، يصبح المبرمج أكثر إنتاجية.

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

تجربة عملية


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

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

▍ تقسيم مشروع معقد إلى أجزاء ملائمة للإدارة


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

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

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

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

إذن ما هي الخدمات التي نحتاجها؟ قد يكون خادمنا بسيطًا جدًا بحيث لا يمكن تقسيمه إلى أجزاء ، ولكن ، لأغراض تعليمية ، فإننا ، مع ذلك ، سنقسمه. نحن بحاجة إلى الاستجابة لطلبات HTTP العميل التي تهدف إلى التحقق من الأرصدة وتنفيذ المعاملات. يمكن أن تعمل إحدى الخدمات مع واجهة HTTP للعملاء. PublicApi نسميها PublicApi . ستمتلك خدمة أخرى معلومات حول حالة النظام - الميزانية العمومية. StateStorage نطلق عليه StateStorage . ستدمج الخدمة الثالثة بين الاثنين الموصوفين أعلاه وتطبق منطق "العقود" التي تهدف إلى تغيير الأرصدة. ستكون مهمة الخدمة الثالثة تنفيذ العقود. VirtualMachine نسميها VirtualMachine .


بنية خادم التطبيق

ضع كود هذه الخدمات في مجلدات المشروع /services/publicapi ، /services/virtualmachine و /services/statestorage .

▍ تعريف واضح لمسؤوليات الخدمة


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

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

أحد الخيارات لوصف الواجهات هو استخدام protobuf . هذا بروتوكول بسيط للغة ولغة مستقلة لوصف الرسائل ونقاط نهاية الخدمة.

لنبدأ StateStorage لخدمة StateStorage . سنقدم حالة التطبيق في شكل هيكل عرض القيمة الرئيسية. هذا هو الرمز الخاص بملف statestorage.proto :

 syntax = "proto3"; package statestorage; service StateStorage { rpc WriteKey (WriteKeyInput) returns (WriteKeyOutput); rpc ReadKey (ReadKeyInput) returns (ReadKeyOutput); } message WriteKeyInput { string key = 1; int32 value = 2; } message WriteKeyOutput { } message ReadKeyInput { string key = 1; } message ReadKeyOutput { int32 value = 1; } 

على الرغم من أن العملاء يستخدمون HTTP من خلال خدمة PublicApi ، إلا أنه لا يتداخل مع الواجهة الواضحة الموضحة بنفس الوسائل المذكورة أعلاه (ملف publicapi.proto ):

 syntax = "proto3"; package publicapi; import "protocol/transactions.proto"; service PublicApi { rpc Transfer (TransferInput) returns (TransferOutput); rpc GetBalance (GetBalanceInput) returns (GetBalanceOutput); } message TransferInput { protocol.Transaction transaction = 1; } message TransferOutput { string success = 1; int32 result = 2; } message GetBalanceInput { protocol.Address from = 1; } message GetBalanceOutput { string success = 1; int32 result = 2; } 

نحتاج الآن إلى وصف هياكل بيانات transactions.proto (ملف transactions.proto ):

 syntax = "proto3"; package protocol; message Address { string username = 1; } message Transaction { Address from = 1; Address to = 2; int32 amount = 3; } 

في المشروع ، يتم وضع الأوصاف الأولية للخدمات في مجلد /types/services ، وأوصاف هياكل البيانات للأغراض العامة موجودة في /types/protocol مجلد /types/protocol .

بمجرد أن تصبح أوصاف الواجهة جاهزة ، يمكن تجميعها في كود Go.

مزايا هذا النهج هي أن الرمز الذي لا يتطابق مع وصف الواجهة ببساطة لا يظهر في نتائج الترجمة. سيتطلب منا استخدام طرق بديلة كتابة اختبارات خاصة للتحقق من أن الرمز يطابق أوصاف الواجهة.

يمكن العثور على التعريفات الكاملة وملفات Go التي تم إنشاؤها وإرشادات التجميع هنا . هذا ممكن بفضل Square Engineering وتطوير goprotowrap .

يرجى ملاحظة أنه في مشروعنا لم يتم تنفيذ RPC لطبقة النقل ويبدو تبادل البيانات بين الخدمات مثل مكالمات المكتبة العادية. عندما نكون مستعدين لتوزيع الخدمات على خوادم مختلفة ، يمكننا إضافة طبقة نقل مثل gRPC إلى النظام.

▍ أنواع الاختبارات المستخدمة في المشروع


نظرًا لأن الاختبارات هي مفتاح الشفرة الموثوقة للغاية ، أقترح أن نتحدث أولاً عن الاختبارات التي سنكتبها لمشروعنا.

اختبارات الوحدة


اختبارات الوحدة هي جوهر هرم الاختبار . سنقوم باختبار كل وحدة على حدة. ما هي الوحدة؟ في Go ، يمكننا إدراك الوحدات النمطية كملفات منفصلة في حزمة. على سبيل المثال ، إذا كان لدينا الملف /services/publicapi/handlers.go ، /services/publicapi/handlers.go اختبار الوحدة عليه في نفس الحزمة على /services/publicapi/handlers_test.go .

من الأفضل وضع اختبارات الوحدة في نفس الحزمة مثل رمز الاختبار ، مما يسمح للاختبارات بالوصول إلى المتغيرات والوظائف غير المصدرة.

اختبارات الخدمة


يُعرف النوع التالي من الاختبار بأسماء مختلفة. هذه هي ما يسمى باختبارات الخدمة أو التكامل أو المكونات. جوهرهم هو اتخاذ عدة وحدات واختبار عملهم المشترك. هذه الاختبارات أعلى بمستوى واحد من اختبارات الوحدة في هرم الاختبار. في حالتنا ، سنستخدم اختبارات التكامل لاختبار الخدمة بأكملها. تحدد هذه الاختبارات مواصفات الخدمة. على سبيل المثال ، سيتم وضع اختبارات خدمة StateStorage في StateStorage /services/statestorage/spec .

من الأفضل وضع هذه الاختبارات في حزمة تختلف عن تلك التي يوجد فيها الرمز المختبر بحيث لا يتم الوصول إلى إمكانات هذا الرمز إلا من خلال واجهات تم تصديرها.

اختبارات شاملة


تقع هذه الاختبارات في الجزء العلوي من هرم الاختبار ، مع مساعدتها في التحقق من النظام بأكمله وجميع خدماته. تصف هذه الاختبارات مواصفات e2e من طرف إلى طرف للنظام ، لذلك /e2e/spec مجلد /e2e/spec .

يجب وضع الاختبارات الشاملة ، وكذلك اختبارات الخدمة ، في حزمة مختلفة عن تلك التي توجد فيها الشفرة المختبرة بحيث لا يمكن تشغيل النظام إلا من خلال واجهات تم تصديرها.

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

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

development تطوير الاختبار


تطوير الاختبار الشامل


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

هناك العديد من الأطر العظيمة التي تقدم ما نحتاج إليه. من بينهم GoConvey و Ginkgo .

أنا شخصياً أحب استخدام مزيج من Ginkgo و Gomega (أسماء رهيبة ، ولكن ماذا أفعل) التي تستخدم تراكيب نحوية مثل Describe() و It() .

كيف ستبدو اختباراتنا؟ على سبيل المثال ، فيما يلي اختبار لآلية فحص رصيد المستخدم (ملف sanity.go ):

 package spec import ... var _ = Describe("Sanity", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should show balances with GET /api/balance", func() { resp, err := http.Get("http://localhost:8080/api/balance?from=user1") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("0")) }) }) 

نظرًا لأن الخادم يمكن الوصول إليه من العالم الخارجي عبر HTTP ، فسنعمل مع واجهة برمجة تطبيقات الويب الخاصة به باستخدام http.Get . ماذا عن اختبار المعاملات؟ إليك رمز الاختبار المقابل:

 It("should transfer funds with POST /api/transfer", func() { resp, err := http.Get("http://localhost:8080/api/transfer?from=user1&to=user2&amount=17") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("-17")) resp, err = http.Post("http://localhost:8080/api/balance?from=user2", "text/plain", nil) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("17")) }) 

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

هنا هو رمز الاختبار الكامل

تطوير اختبار الخدمة


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

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

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

/services/statestorage/mock.go رمز كعب الروتين في الملف /services/statestorage/mock.go . من الأفضل وضع كائنات كعب الروتين في نفس المكان مثل الكيانات التي تحاكيها من أجل منحهم الوصول إلى المتغيرات والوظائف غير المصدرة. كعب الروتين في هذه المرحلة هو تنفيذ تخطيطي للخدمة ، ولكن ، مع تطور الخدمة ، قد نحتاج إلى تطوير تنفيذ الروتين. هنا هو رمز كائن كعب الروتين (ملف mock.go ):

 package statestorage import ... type MockService struct { mock.Mock } func (s *MockService) Start() { s.Called() } func (s *MockService) Stop() { s.Called() } func (s *MockService) IsStarted() bool { return s.Called().Bool(0) } func (s *MockService) WriteKey(input *statestorage.WriteKeyInput) (*statestorage.WriteKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.WriteKeyOutput), ret.Error(1) } func (s *MockService) ReadKey(input *statestorage.ReadKeyInput) (*statestorage.ReadKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.ReadKeyOutput), ret.Error(1) } 

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

دعونا نعود إلى تطوير اختبار الخدمة لـ VirtualMachine . ما السيناريو الذي يجب أن أتحقق منه هنا؟ من الأفضل التركيز على واجهة الخدمة واختبارات التصميم لكل نقطة نهاية. نقوم بتنفيذ اختبار لنقطة نهاية CallContract() مع وسيطة تمثل طريقة "GetBalance" . إليك الرمز المقابل (ملف contracts.go ):

 package spec import ... var _ = Describe("Contracts", func() { var ( service uut.Service stateStorage *_statestorage.MockService ) BeforeEach(func() { service = uut.NewService() stateStorage = &_statestorage.MockService{} service.Start(stateStorage) }) AfterEach(func() { service.Stop() }) It("should support 'GetBalance' contract method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) addr := protocol.Address{Username: "user1"} out, err := service.CallContract(&virtualmachine.CallContractInput{Method: "GetBalance", Arg: &addr}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(100)) Expect(stateStorage).To(ExecuteAsPlanned()) }) }) 

يرجى ملاحظة أن الخدمة التي VirtualMachine ، VirtualMachine ، تحصل على مؤشر إلى تبعيتها ، StateStorage ، في طريقة Start() خلال آلية حقن تبعية بسيطة. هذا هو المكان الذي نمر فيه مثيل كائن كعب الروتين. أيضًا ، انتبه إلى السطر stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key… ، حيث نخبر كائن كعب الروتين كيف يجب أن يتصرف عند الوصول إليه. عند ReadKey طريقة ReadKey ، يجب أن تُرجع قيمة 100. بعد ذلك ، في السطر Expect(stateStorage).To(ExecuteAsPlanned()) ، نتحقق من أن هذا الأمر يسمى مرة واحدة بالضبط.

اختبارات مماثلة تصبح مواصفات للخدمة. يمكن العثور على المجموعة الكاملة من الاختبارات لخدمة VirtualMachine هنا . يمكن العثور على أجنحة اختبار لخدمات أخرى من مشروعنا هنا وهنا .

تطوير اختبار الوحدة


ربما يكون تنفيذ عقد طريقة "GetBalance" بسيطًا جدًا ، لذلك "GetBalance" نتحدث عن تنفيذ طريقة "Transfer" أكثر تعقيدًا قليلاً. يحتاج عقد تحويل الأموال من حساب إلى آخر ممثلة بهذه الطريقة إلى قراءة البيانات عن أرصدة المرسل والمتلقي للأموال ، لحساب الأرصدة الجديدة وتسجيل ما حدث في حالة الطلب. اختبار الخدمة لكل هذا يشبه إلى حد كبير الاختبار الذي نفذناه للتو (ملف transactions.go go):

 It("should support 'Transfer' transaction method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) t := protocol.Transaction{From: &protocol.Address{Username: "user1"}, To: &protocol.Address{Username: "user2"}, Amount: 10} out, err := service.ProcessTransaction(&virtualmachine.ProcessTransactionInput{Method: "Transfer", Arg: &t}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(90)) Expect(stateStorage).To(ExecuteAsPlanned()) }) 

في عملية العمل على المشروع ، نصل أخيرًا إلى إنشاء آلياته الداخلية وإنشاء وحدة نمطية موجودة في processor.go الملفات. processor.go ، والتي تحتوي على تنفيذ العقد. هنا هو الإصدار الأصلي (ملف processor.go ):

 package virtualmachine import ... func (s *service) processTransfer(fromUsername string, toUsername string, amount int32) (int32, error) { fromBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: fromUsername}) if err != nil { return 0, err } toBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: toUsername}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: fromUsername, Value: fromBalance.Value - amount}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: toUsername, Value: toBalance.Value + amount}) if err != nil { return 0, err } return fromBalance.Value - amount, nil } 

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

نظرًا لأننا سنقوم باستدعاء الوظيفة عدة مرات باستخدام بيانات إدخال مختلفة ومحاكاة المعلمات للوصول إلى جميع فروع الشفرة ، من أجل جعل هذه العملية أكثر كفاءة ، يمكننا اللجوء إلى الاختبارات المستندة إلى الجدول. يميل Go إلى تجنب أطر اختبار الوحدة الغريبة. يمكننا رفض الجنكة ، ولكن ربما يجب أن نترك غوميغا . ونتيجة لذلك ، ستكون الفحوصات التي تتم هنا مماثلة لتلك التي أجريناها في الاختبارات السابقة. إليك رمز الاختبار (file processor_test.go ):

 package virtualmachine import ... var transferTable = []struct{ to string //  ,    read1Err error //       read2Err error //       write1Err error //       write2Err error //       output int32 //   errs bool //        }{ {"user2", errors.New("a"), nil, nil, nil, 0, true}, {"user2", nil, errors.New("a"), nil, nil, 0, true}, {"user2", nil, nil, errors.New("a"), nil, 0, true}, {"user2", nil, nil, nil, errors.New("a"), 0, true}, {"user2", nil, nil, nil, nil, 90, false}, } func TestTransfer(t *testing.T) { Ω := NewGomegaWithT(t) for _, tt := range transferTable { s := NewService() ss := &_statestorage.MockService{} s.Start(ss) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, tt.read1Err) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, tt.read2Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, tt.write1Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, tt.write2Err) output, err := s.(*service).processTransfer("user1", tt.to, 10) if tt.errs { Ω.Expect(err).To(HaveOccurred()) } else { Ω.Expect(err).ToNot(HaveOccurred()) Ω.Expect(output).To(BeEquivalentTo(tt.output)) } } } 

«Ω» — , — ( Gomega ). .

, TDD, , , . processTransfer() .

VirtualMachine . .

100% . , . .

, ? . , , , .

▍ -


. ? HTTP- Go (goroutine). , — , . , , , .

- . , , , , . - /e2e/stress . - ( stress.go ):

 package stress import ... const NUM_TRANSACTIONS = 20000 const NUM_USERS = 100 const TRANSACTIONS_PER_BATCH = 200 const BATCHES_PER_SEC = 40 var _ = Describe("Transaction Stress Test", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should handle lots and lots of transactions", func() { //  HTTP-     transport := http.Transport{ IdleConnTimeout: time.Second*20, MaxIdleConns: TRANSACTIONS_PER_BATCH*10, MaxIdleConnsPerHost: TRANSACTIONS_PER_BATCH*10, } client := &http.Client{Transport: &transport} //      ledger := map[string]int32{} for i := 0; i < NUM_USERS; i++ { ledger[fmt.Sprintf("user%d", i+1)] = 0 } //     HTTP   rand.Seed(42) done := make(chan error, TRANSACTIONS_PER_BATCH) for i := 0; i < NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH; i++ { log.Printf("Sending %d transactions... (batch %d out of %d)", TRANSACTIONS_PER_BATCH, i+1, NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH) time.Sleep(time.Second / BATCHES_PER_SEC) for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { from := randomizeUser() to := randomizeUser() amount := randomizeAmount() ledger[from] -= amount ledger[to] += amount go sendTransaction(client, from, to, amount, &done) } for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { err := <- done Expect(err).ToNot(HaveOccurred()) } } //   for i := 0; i < NUM_USERS; i++ { user := fmt.Sprintf("user%d", i+1) resp, err := client.Get(fmt.Sprintf("http://localhost:8080/api/balance?from=%s", user)) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal(fmt.Sprintf("%d", ledger[user]))) } }) }) func randomizeUser() string { return fmt.Sprintf("user%d", rand.Intn(NUM_USERS)+1) } func randomizeAmount() int32 { return rand.Int31n(1000)+1 } func sendTransaction(client *http.Client, from string, to string, amount int32, done *chan error) { url := fmt.Sprintf("http://localhost:8080/api/transfer?from=%s&to=%s&amount=%d", from, to, amount) resp, err := client.Post(url, "text/plain", nil) if err == nil { ioutil.ReadAll(resp.Body) resp.Body.Close() } *done <- err } 

, - . ( rand.Seed(42) ) , . . , , — , .

- HTTP , TCP- ( , , ). , , 200 IdleConnection TCP- . , 100.

… :

 fatal error: concurrent map writes goroutine 539 [running]: runtime.throw(0x147bf60, 0x15) /usr/local/go/src/runtime/panic.go:616 +0x81 fp=0xc4207159d8 sp=0xc4207159b8 pc=0x102ca01 runtime.mapassign_faststr(0x13f5140, 0xc4201ca0c0, 0xc4203a8097, 0x6, 0x1012001) /usr/local/go/src/runtime/hashmap_fast.go:703 +0x3e9 fp=0xc420715a48 sp=0xc4207159d8 pc=0x100d879 services/statestorage.(*service).WriteKey(0xc42000c060, 0xc4209e6800, 0xc4206491a0, 0x0, 0x0) services/statestorage/methods.go:15 +0x10c fp=0xc420715a88 sp=0xc420715a48 pc=0x138339c services/virtualmachine.(*service).processTransfer(0xc4201ca090, 0xc4203a8097, 0x6, 0xc4203a80a1, 0x6, 0x2a4, 0xc420715b30, 0x1012928, 0x40) services/virtualmachine/processor.go:19 +0x16e fp=0xc420715ad0 sp=0xc420715a88 pc=0x13840ee services/virtualmachine.(*service).ProcessTransaction(0xc4201ca090, 0xc4209e67c0, 0x30, 0x1433660, 0x12a1d01) Ginkgo ran 1 suite in 1.288879763s Test Suite Failed 

? StateStorage ( map ), . , , . , map sync.map . .

processTransfer() . , — . , , , , . , processTransfer() . .

, . , , .

 e2e/stress/transactions.go:44 Expected <string>: -7498 to equal <string>: -7551 e2e/stress/transactions.go:82 ------------------------------ Ginkgo ran 1 suite in 5.251593179s Test Suite Failed 

, . , , ( , ). , , .

— . TDD . ? , 100%?! , — . processTransfer() , , .

. , , . .

الملخص


, , , -, , , ? ? — .

, -. , «» processTransfer() . , , . , — . , - . , , .

. , . , StateStorage WriteKey , , , , WriteKeys , , .

, : . « ». -, , , , , . — . , , — .

, — GitHub. . , , , , , , .

أعزائي القراء! ?

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


All Articles