التدريب العملي: نصائح لكتابة البرامج المدعومة في العالم الحقيقي

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

تحتاج أولاً إلى الاتفاق على معنى أفضل الممارسات للغة البرمجة. هنا يمكنك أن تتذكر كلمات Russ Cox ، المدير الفني لـ Go:

هندسة البرمجيات هي ما يحدث للبرمجة ، إذا قمت بإضافة عامل الوقت والمبرمجين الآخرين.

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

المحتويات



1. المبادئ الأساسية


قد أكون من أوائل مستخدمي Go بينكم ، لكن هذا ليس رأيي الشخصي. هذه المبادئ الأساسية تكمن وراء Go نفسها:

  1. البساطة
  2. سهولة القراءة
  3. الإنتاجية

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

الأداء والتزامن هما من السمات المهمة ، لكن ليسا مهمين مثل البساطة وسهولة القراءة والإنتاجية.

البساطة


"البساطة شرط أساسي للاعتمادية " - Edsger Dijkstra

لماذا نسعى جاهدين للبساطة؟ لماذا من المهم أن تكون برامج Go بسيطة؟

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

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

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

1.2. قراءة


"قابلية القراءة هي جزء لا يتجزأ من الصيانة" - مارك راينهولد ، مؤتمر JVM ، 2018

لماذا من المهم أن تكون الشفرة قابلة للقراءة؟ لماذا يجب أن نسعى جاهدين للقراءة؟

"ينبغي كتابة البرامج للأشخاص ، والآلات تنفذها فقط" - هال أبيلسون وجيرالد ساسمان ، "هيكل وتفسير برامج الكمبيوتر"

ليس فقط برامج Go ، ولكن عمومًا يتم كتابة جميع البرامج بواسطة أشخاص للأشخاص. حقيقة أن الآلات أيضا رمز عملية ثانوية.

بمجرد قراءة الكود المكتوب بواسطة الأشخاص: المئات ، إن لم يكن الآلاف من المرات.

"إن أهم مهارة للمبرمج هي القدرة على توصيل الأفكار بفعالية." - جاستون هوركر

القراءة هي المفتاح لفهم ما يفعله البرنامج. إذا لم تستطع فهم الكود ، كيف تحافظ عليه؟ إذا تعذر دعم البرنامج ، فستتم إعادة كتابته ؛ وقد تكون هذه آخر مرة تستخدم فيها شركتك Go.

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

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

1.3. الإنتاجية


"التصميم هو فن تنظيم الشفرة بحيث يعمل اليوم ، ولكن دائمًا ما يدعم التغيير." - Sandy Mets

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

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

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

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

عندما يتحدث مطورو Go عن قابلية التوسع ، فإن ذلك يعني الإنتاجية.

2. معرفات


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

"الاسم السيئ هو أحد أعراض سوء التصميم" - ديف تشيني

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

2.1. معرفات الاسم بناءً على الوضوح بدلاً من الإيجاز


"من المهم أن تكون الشفرة واضحة. ما يمكنك القيام به في سطر واحد ، يجب عليك القيام به في ثلاثة. " - Ukia Smith

Go غير مُحسّن من أجل الخطوط المفردة أو الحد الأدنى لعدد الخطوط في البرنامج. نحن لا نحسن حجم الكود المصدري على القرص ، ولا الوقت اللازم لكتابة البرنامج في المحرر.

"اسم جيد مثل مزحة جيدة. إذا كنت بحاجة إلى شرح ذلك ، فلم يعد الأمر مضحكًا. " - ديف تشيني

مفتاح أقصى الوضوح هو الأسماء التي نختارها لتحديد البرامج. ما هي الصفات الكامنة في اسم جيد؟

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

دعونا ننظر في مزيد من التفاصيل كل من هذه الخصائص.

2.2. طول الهوية


أحيانًا يتم انتقاد أسلوب Go بسبب أسماء المتغيرات القصيرة. كما قال روب بايك ، "يريد مبرمجو Go التعرف على الطول الصحيح ."

يقدم أندرو جيراند معرفات أطول للإشارة إلى الأهمية.

"كلما زادت المسافة بين إعلان الاسم واستخدام كائن ما ، كلما كان يجب أن يكون الاسم" - Andrew Gerrand

وبالتالي ، يمكن تقديم بعض التوصيات:

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

النظر في مثال.

type Person struct { Name string Age int } // AverageAge returns the average age of people. func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count } 

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

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

يمكنك اختيار s for sum و c (أو n ) count ، ولكن هذا يقلل من أهمية جميع المتغيرات في البرنامج إلى نفس المستوى. يمكنك استبدال people بـ p ، ولكن ستكون هناك مشكلة ، ما يجب استدعاء متغير التكرار for ... range . سيبدو person واحد غريبًا ، لأن متغير التكرار قصير العمر يحصل على اسم أطول من عدة قيم مشتقة منه.

نصيحة . افصل مجموعة الوظائف بخطوط فارغة ، حيث أن الخطوط الفارغة بين الفقرات تقطع تدفق النص. في AverageAge ، لدينا ثلاث عمليات متتالية. أولا ، التحقق من القسمة على الصفر ، ثم الانتهاء من إجمالي العمر وعدد الأشخاص ، والأخير - حساب متوسط ​​العمر.

2.2.1. الشيء الرئيسي هو السياق


من المهم أن نفهم أن معظم نصائح التسمية هي سياق محدد. أود أن أقول إن هذا مبدأ وليس قاعدة.

ما هو الفرق بين i index ؟ على سبيل المثال ، لا يمكنك القول بشكل لا لبس فيه أن مثل هذا الرمز

 for index := 0; index < len(s); index++ { // } 

في الأساس أكثر قابلية للقراءة من

 for i := 0; i < len(s); i++ { // } 

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

ولكن أي من هذه الوظائف أكثر قابلية للقراءة؟

 func (s *SNMP) Fetch(oid []int, index int) (int, error) 

او

 func (s *SNMP) Fetch(o []int, i int) (int, error) 

في هذا المثال ، oid هي اختصار لمعرف كائن SNMP ، والاختصار الإضافي لـ o يفرض عند قراءة الكود للتبديل من تدوين موثق إلى تدوين أقصر في الكود. وبالمثل ، فإن تقليص index إلى i يجعل من الصعب فهمه ، لأنه في رسائل SNMP ، تسمى القيمة الفرعية لكل OID بالفهرس.

نصيحة . لا تجمع بين المعلمات الرسمية الطويلة والقصيرة في إعلان واحد.

2.3. لا اسم المتغيرات حسب النوع


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

 var usersMap map[string]*User 

ما هو جيد هذا الإعلان؟ نحن نرى أن هذه خريطة وأنها لها علاقة بنوع *User : ربما يكون هذا جيدًا. لكن usersMap عبارة عن خريطة بالفعل ، ولن تسمح ميزة "الانتقال" كلغة مكتوبة بشكل ثابت بطريق الخطأ باستخدام مثل هذا الاسم الذي يتطلب وجود متغير عددي ، وبالتالي فإن لاحقة Map تكون زائدة عن الحاجة.

النظر في الموقف حيث يتم إضافة متغيرات أخرى:

 var ( companiesMap map[string]*Company productsMap map[string]*Products ) 

الآن لدينا ثلاثة متغيرات من خريطة النوع: usersMap ، companiesMap ، usersMap و productsMap ، ويتم تعيين جميع الخطوط لأنواع مختلفة. نحن نعلم أن هذه خرائط ، ونحن نعلم أيضًا أن المترجم سيرمي خطأً إذا حاولنا استخدام companiesMap حيث يتوقع الرمز map[string]*User . في هذه الحالة ، من الواضح أن لاحقة Map لا تعمل على تحسين وضوح الكود ، فهذه مجرد أحرف إضافية.

أقترح تجنب أي لاحقات تشبه نوع المتغير.

نصيحة . إذا لم يكن users يصفون الجوهر بوضوح كافٍ ، فعندئذٍ ، يمكن usersMap أيضًا usersMap .

تنطبق هذه النصيحة أيضًا على معلمات الوظيفة. على سبيل المثال:

 type Config struct { // } func WriteConfig(w io.Writer, config *Config) 

اسم *Config للمعلمة *Config زائدة. نحن نعلم بالفعل أن هذا هو *Config ، يتم كتابته على الفور بجواره.

في هذه الحالة ، ضع في اعتبارك conf أو c إذا كان عمر المتغير قصيرًا بدرجة كافية.

إذا كان هناك أكثر من واحد في منطقتنا في أكثر من *Config ، conf1 و conf2 أقل معنى من original updated ، حيث يصعب خلط الأخير.

ملاحظة لا تدع أسماء الحزم تسرق أسماء المتغيرات الجيدة.

يحتوي اسم المعرف الذي تم استيراده على اسم الحزمة. على سبيل المثال ، سيتم استدعاء نوع Context في حزمة context . هذا يجعل من المستحيل استخدام متغير أو نوع context في الحزمة الخاصة بك.

 func WriteLog(context context.Context, message string) 

هذا لن يجمع. لهذا السبب عند الإعلان عن context.Context أنواع context.Context محليًا ، على سبيل المثال ، يتم استخدام أسماء مثل ctx بشكل تقليدي.

 func WriteLog(ctx context.Context, message string) 

2.4. استخدام نمط تسمية واحدة


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

على سبيل المثال ، إذا كان الرمز يدور حول واصف قاعدة البيانات ، في كل مرة يتم عرض المعلمة ، يجب أن يكون لها نفس الاسم. بدلاً من كل أنواع المجموعات مثل d *sql.DB ، dbase *sql.DB ، DB *sql.DB database *sql.DB من الأفضل استخدام شيء واحد:

 db *sql.DB 

من الأسهل فهم الرمز. إذا رأيت db ، فأنت تعلم أنه *sql.DB ويتم الإعلان عنه محليًا أو يتم توفيره بواسطة المتصل.

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

ملاحظة تتعارض اتفاقية الاسم المختصر لـ Go Go Receipient سابقًا مع التوصيات. هذه واحدة من الحالات التي يصبح فيها الاختيار الذي تم في مرحلة مبكرة هو النمط القياسي ، مثل استخدام CamelCase بدلاً من snake_case .

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

أخيرًا ، ترتبط بعض المتغيرات المكونة من حرف واحد مع الحلقات والعد. على سبيل المثال ، عادة ما تكون i و j و k متغيرات استقلالية في الحلقات ، وعادة ما ترتبط n مع عداد أو adder التراكمي ، v هي اختصار نموذجي للقيمة في وظيفة ترميز ، وعادة ما تستخدم k لمفتاح خريطة ، وكثيرا ما تستخدم s كمختصار لمعلمات string الكتابة .

كما في المثال db أعلاه ، يتوقع المبرمجون i أكون متغيرًا حثيًا. إذا رأوا ذلك في الكود ، فمن المتوقع أن يروا حلقة قريباً.

نصيحة . إذا كان لديك العديد من الحلقات المتداخلة التي نفدت فيها متغيرات i و j و k ، فقد ترغب في تقسيم الوظيفة إلى وحدات أصغر.

2.5. استخدام نمط إعلان واحد


Go لديه ست طرق مختلفة على الأقل لإعلان متغير.

  •  var x int = 1 
  •  var x = 1 
  •  var x int; x = 1 
  •  var x = int(1) 
  •  x := 1 

أنا متأكد من أنني لم أتذكر كل شيء بعد. من المحتمل أن يعتبر مطورو التشغيل هذا خطأ ، ولكن بعد فوات الأوان لتغيير أي شيء. مع هذا الاختيار ، كيفية ضمان نمط موحد؟

أريد أن أقترح أسلوبًا لإعلان المتغيرات التي أحاول استخدامها بنفسي كلما كان ذلك ممكنًا.

  • عند التصريح عن متغير دون التهيئة ، استخدم var .

     var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing) 

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

    لشرح السبب ، دعونا نلقي نظرة على المثال السابق ، لكن هذه المرة نهيئ كل متغير بشكل خاص:

     var players int = 0 var things []Thing = nil var thing *Thing = new(Thing) json.Unmarshall(reader, thing) 

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

 var players = 0 var things []Thing = nil var thing = new(Thing) json.Unmarshall(reader, thing) 

هنا ، players تهيئة players صريح إلى 0 ، وهو أمر ضروري ، لأن القيمة الأولية players هي صفر في أي حال. لذلك ، من الأفضل أن نوضح أننا نريد استخدام قيمة فارغة:

 var players int 

ماذا عن المشغل الثاني؟ لا يمكننا تحديد النوع والكتابة

 var things = nil 

لأن nil يوجد nil نوع . بدلاً من ذلك ، لدينا خيار: أو نستخدم قيمة صفرية لشريحة ...

 var things []Thing 

... أو إنشاء شريحة مع عناصر الصفر؟

 var things = make([]Thing, 0) 

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

 things := make([]Thing, 0) 

هذا يخبر القارئ أننا قررنا تهيئة things بشكل صريح.

لذلك نأتي إلى الإعلان الثالث:

 var thing = new(Thing) 

هنا ، كل من التهيئة الصريحة للمتغير وإدخال الكلمة الرئيسية "الفريدة" new ، والتي لا يحبها بعض مبرمجي Go. باستخدام بناء جملة قصيرة الموصى بها

 thing := new(Thing) 

هذا يجعل من الواضح أن thing تهيئته بشكل صريح لنتيجة new(Thing) ، ولكن لا يزال يترك new غير عادي. يمكن حل المشكلة باستخدام حرفي:

 thing := &Thing{} 

الذي يشبه new(Thing) ، ومثل هذه الازدواجية يزعج بعض المبرمجين الذهاب. ومع ذلك ، فهذا يعني أننا نهيئة thing بشكل صريح بمؤشر إلى Thing{} وقيمة Thing بقيمة صفر.

لكن من الأفضل مراعاة حقيقة أن thing الإعلان عنه بقيمة صفرية ، واستخدام عنوان المشغل لتمرير عنوان thing في json.Unmarshall :

 var thing Thing json.Unmarshall(reader, &thing) 

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

 var min int max := 1000 

إعلان أكثر قابلية للقراءة:

 min, max := 0, 1000 

لتلخيص:

  • عند التصريح عن متغير دون التهيئة ، استخدم بناء جملة var .
  • عند التصريح عن المتغير وتهيئته بشكل صريح ، استخدم := .

نصيحة . أشر بوضوح إلى الأشياء المعقدة.

 var length uint32 = 0x80 

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

 length := uint32(0x80) 

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

2.6. اعمل مع الفريق


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

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

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

3. التعليقات


قبل أن ننتقل إلى النقاط الأكثر أهمية ، أريد أن أتناول بضع دقائق للتعليق.

"الرمز الجيد يحتوي على الكثير من التعليقات ، والرمز السيئ يحتاج إلى الكثير من التعليقات." - ديف توماس وأندرو هانت ، مبرمج براغماتي

التعليقات مهمة جدا لسهولة قراءة البرنامج. يجب أن يقوم كل تعليق بواحد - واحد فقط - من ثلاثة أشياء:

  1. اشرح ما الذي يفعله الكود؟
  2. اشرح كيف يفعل ذلك.
  3. اشرح لماذا .

النموذج الأول مثالي للتعليق على الشخصيات العامة:

 // Open     . //           . 

والثاني مثالي للتعليقات داخل إحدى الطرق:

 //     var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) } 

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

 return &v2.Cluster_CommonLbConfig{ //  HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, } 

في هذا المثال ، قد لا يكون واضحًا على الفور ما يحدث عندما يتم تعيين HealthyPanicThreshold على صفر بالمائة. الغرض من التعليق هو توضيح أن القيمة 0 تعطل عتبة الذعر.

3.1. يجب أن تصف التعليقات في المتغيرات والثوابت محتوياتها ، وليس الغرض منها


قلت في وقت سابق أن اسم المتغير أو الثابت يجب أن يصف الغرض منه. لكن التعليق على متغير أو ثابت يجب أن يصف المحتوى تمامًا وليس الغرض .

 const randomNumber = 6 //     

في هذا المثال ، يصف التعليق لماذا randomNumber على 6 ومن أين جاء. التعليق لا يصف المكان الذي سيتم استخدام randomNumber . فيما يلي بعض الأمثلة الأخرى:

 const ( StatusContinue = 100 // RFC 7231, 6.2.1 StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2 StatusProcessing = 102 // RFC 2518, 10.1 StatusOK = 200 // RFC 7231, 6.3.1 

في سياق HTTP ، يُعرف الرقم 100 باسم StatusContinue ، كما هو محدد في RFC 7231 ، القسم 6.2.1.

نصيحة . بالنسبة للمتغيرات التي لا تحتوي على قيمة أولية ، يجب أن يصف التعليق من المسؤول عن تهيئة هذا المتغير.

 // sizeCalculationDisabled ,   //     . . dowidth. var sizeCalculationDisabled bool 

هنا يخبر القارئ القارئ أن وظيفة dowidth المسؤولة عن الحفاظ على حالة sizeCalculationDisabled .

نصيحة . إخفاء في الأفق. هذه نصيحة من كيت غريغوري . أحيانًا يكون أفضل اسم لمتغير مخفيًا في التعليقات.

 //   SQL var registry = make(map[string]*sql.Driver) 

تمت إضافة تعليق بواسطة المؤلف لأن registry الاسم لا يشرح الغرض منه بشكل كافٍ - هذا سجل ، ولكن ما هو السجل؟

إذا قمت بإعادة تسمية متغير إلى sqlDrivers ، يصبح من الواضح أنه يحتوي على برامج تشغيل SQL.

 var sqlDrivers = make(map[string]*sql.Driver) 

الآن أصبح التعليق زائدا ويمكن حذفه.

3.2. قم دائمًا بتوثيق الشخصيات المتاحة للجمهور


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

فيما يلي إرشادات من دليل Google Style:

  • يجب التعليق على أي وظيفة عامة ليست واضحة وموجزة.
  • يجب التعليق على أي وظيفة في المكتبة ، بغض النظر عن الطول أو التعقيد.


 package ioutil // ReadAll   r      (EOF)   // ..    err == nil, not err == EOF. //  ReadAll     ,     //  . func ReadAll(r io.Reader) ([]byte, error) 

هناك استثناء واحد لهذه القاعدة: لا تحتاج إلى توثيق الطرق التي تنفذ الواجهة. على وجه التحديد ، لا تفعل هذا:

 // Read   io.Reader func (r *FileReader) Read(buf []byte) (int, error) 

هذا التعليق لا يعني أي شيء. إنه لا يقول ما الذي تفعله الطريقة: والأسوأ من ذلك أنه يرسل مكانًا للبحث عن الوثائق. في هذه الحالة ، أقترح حذف التعليق بالكامل.

هنا مثال من الحزمة io .

 // LimitReader  Reader,    r, //    EOF  n . //   *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // LimitedReader   R,     //   N .   Read  N  //    . // Read  EOF,  N <= 0    R  EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if lN <= 0 { return 0, EOF } if int64(len(p)) > lN { p = p[0:lN] } n, err = lRRead(p) lN -= int64(n) return } 

لاحظ أن إعلان LimitedReader يسبقه مباشرة الوظيفة التي تستخدمه ، LimitedReader.Read إعلان LimitedReader.Read إعلان LimitedReader نفسه. على الرغم من أن LimitedReader.Read نفسها غير موثقة ، يمكن فهم أن هذا هو تطبيق io.Reader .

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

3.2.1. لا تعلق على كود غير صحيح ، أعد كتابته


"لا تعلق على كود سيء - أعد كتابته " - براين كيرنيغان

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

في المكتبة القياسية ، من المعتاد ترك التعليقات في نمط TODO باسم المستخدم الذي لاحظ المشكلة.

 // TODO(dfc)  O(N^2),     . 

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

3.2.2. بدلاً من التعليق على الكود ، قم بإعادة تركيبه


"الرمز الجيد هو أفضل الوثائق. عندما تكون على وشك إضافة تعليق ، اسأل نفسك السؤال التالي: "كيفية تحسين الكود حتى لا تكون هناك حاجة لهذا التعليق؟" Refactor وترك تعليق لجعلها أكثر وضوحا. " - ستيف ماكونيل

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

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

4. هيكل الحزمة


"اكتب رمزًا متواضعًا: الوحدات التي لا تُظهر أي شيء غير ضروري للوحدات الأخرى ولا تعتمد على تنفيذ الوحدات الأخرى" - ديف توماس

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

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

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

4.1. حزمة جيدة تبدأ مع اسم جيد


تبدأ حزمة Go الجيدة باسم جودة. فكر في الأمر على أنه عرض تقديمي قصير يقتصر على كلمة واحدة فقط.

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

نصيحة . اختر اسم الحزمة من خلال وظيفتها ، وليس محتواها.

4.1.1. يجب أن تكون أسماء الحزمة الجيدة فريدة من نوعها


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

  1. .
  2. . , .

4.2. base , common util


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

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

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

"[القليل] الازدواجية أرخص بكثير من التجريد الخاطئ" - ساندي ميتس

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

نصيحة . استخدم صيغة الجمع لحزم الخدمة. على سبيل المثال ، من stringsأجل أدوات مساعدة معالجة السلسلة.

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

على سبيل المثال، net/httpلا تفعل حزم الفردية clientو serverبدلا من ذلك، هناك ملفات client.goو server.goمع أنواع البيانات المطابقة، وكذلك transport.goلنقل الكلي.

نصيحة . من المهم أن تتذكر أن اسم المعرف يتضمن اسم الحزمة.

  • وظيفة Getمن حزمة net/httpتصبح http.Getرابط من حزمة أخرى.
  • Readerيتم stringsتحويل نوع من الحزمة إلى عند استيرادها إلى حزم أخرى strings.Reader.
  • ترتبط واجهة Errorالحزمة netبوضوح بأخطاء الشبكة.

4.3. أعود بسرعة دون الغوص العميق


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

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

 func (b *Buffer) UnreadRune() error { if b.lastRead <= opInvalid { return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } 

عند الدخول إلى الوظيفة UnreadRune، يتم التحقق من الحالة b.lastReadوإذا لم تكن العملية السابقة قد تم ReadRuneإرجاع خطأ على الفور. يعمل باقي الوظيفة بناءً على ما هو b.lastReadأكبر من opInvalid.

قارن بنفس الوظيفة ، ولكن بدون عامل الحدود:

 func (b *Buffer) UnreadRune() error { if b.lastRead > opInvalid { if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } 

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

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

4.4. جعل قيمة فارغة مفيدة


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

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

خذ بعين الاعتبار نوع sync.Mutexيحتوي على حقلين عدد صحيح يمثل الحالة الداخلية من mutex. هذه الحقول لاغية في أي إعلان.sync.Mutex. يتم أخذ هذه الحقيقة في الاعتبار في الكود ، لذلك النوع مناسب للاستخدام دون تهيئة صريحة.

 type MyInt struct { mu sync.Mutex val int } func main() { var i MyInt // i.mu is usable without explicit initialisation. i.mu.Lock() i.val++ i.mu.Unlock() } 

مثال آخر على نوع ذو قيمة فارغة مفيدة bytes.Buffer. يمكنك التصريح والبدء في الكتابة إليه دون تهيئة صريحة.

 func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) } 

تعني القيمة الصفرية لهذه البنية أن lenكلاهما capمتساوي 0، و y array، المؤشر إلى الذاكرة مع محتويات صفيف شريحة النسخ الاحتياطي ، القيمة nil. هذا يعني أنك لا تحتاج إلى قطع صراحة ، يمكنك ببساطة إعلان ذلك.

 func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) } 

ملاحظة . var s []stringعلى غرار الخطوط المعلقة في الجزء العلوي ، ولكن ليست متطابقة لهم. هناك فرق بين قيمة شريحة لا شيء وقيمة شريحة ذات طول صفري. سيتم طباعة الكود التالي false.

 func main() { var s1 = []string{} var s2 []string fmt.Println(reflect.DeepEqual(s1, s2)) } 

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

 type Config struct { path string } func (c *Config) Path() string { if c == nil { return "/usr/home" } return c.path } func main() { var c1 *Config var c2 = &Config{ path: "/export", } fmt.Println(c1.Path(), c2.Path()) } 

4.5. تجنب حالة مستوى الحزمة


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

هناك طريقتان رائعتان لتحقيق اتصال ضعيف في Go:

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

في Go ، يمكننا إعلان المتغيرات في نطاق دالة أو طريقة ، وكذلك في نطاق الحزمة. عندما يكون المتغير متاحًا للعموم ، مع معرف بحرف كبير ، يكون نطاقه بالفعل عالميًا للبرنامج بأكمله: أي حزمة في أي وقت ترى نوع ومحتوى هذا المتغير.

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

كيفية تقليل الاتصال الذي ينشئه متغير عمومي:

  1. انقل المتغيرات المقابلة كحقول إلى الهياكل التي تحتاجها.
  2. استخدم واجهات لتقليل الاتصال بين السلوك وتطبيق هذا السلوك.

5. هيكل المشروع


دعنا نتحدث عن كيفية دمج الحزم في المشروع. هذا هو عادة مستودع بوابة واحد.

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

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

إذا كان لديك تطبيق (تطبيق ويب ، وحدة تحكم Kubernetes ، إلخ) ، فقد يحتوي المشروع على حزمة رئيسية واحدة أو أكثر. على سبيل المثال ، في وحدة تحكم Kubernetes الخاصة بي ، توجد حزمة واحدة cmd/contourتعمل كخادم تم نشره في كتلة Kubernetes وكعميل لتصحيح الأخطاء.

5.1. حزم أقل ولكن أكبر


في مراجعة التعليمات البرمجية ، لاحظت أحد الأخطاء النموذجية للمبرمجين الذين تحولوا إلى "الانتقال من لغات أخرى": يميلون إلى إساءة استخدام الحزم.

العودة لا يوفر نظام محكم وضوح الرؤية: اللغة ليست كافية معدلات الوصول كما هو الحال في جافا ( public، protected، privateوضمنا default). لا يوجد تناظر للصفوف الودية من C ++.

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

ملاحظة . يمكنك سماع الكلمات "المصدرة" أو "غير المصدرة" كمرادفات للقطاعين العام والخاص.

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

نصيحة . كل حزمة على حدة cmd/و internal/يكون مصدر هذا.

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

نصيحة . جاء من جافا؟

إذا كنت قادماً من عالم Java أو C # ، فتذكر القاعدة غير المعلنة: حزمة Java تعادل ملف مصدر واحد .go. حزمة Go تعادل الوحدة Maven بأكملها أو تجميع .NET.

5.1.1. ترتيب الكود حسب الملف باستخدام تعليمات الاستيراد


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

فيما يلي التوصيات التي أستخدمها:

  • ابدأ كل حزمة بملف واحد .go. أعط هذا الملف نفس اسم الدليل. على سبيل المثال ، httpيجب أن تكون الحزمة في ملف http.goفي دليل http.
  • مع نمو الحزمة ، يمكنك تقسيم الوظائف المختلفة إلى عدة ملفات. على سبيل المثال، الملف messages.goسيحتوي أنواع Requestو Responseملف client.go- نوع Client، ملف server.go- نوع الخادم.
  • , . , .
  • . , messages.go HTTP- , http.go , client.go server.go — HTTP .

. .

. Go . ( — Go). .

5.1.2. تفضل الاختبارات الداخلية على الخارجية


goتدعم الأداة الحزمة testingفي مكانين. إذا كان لديك حزمة http2، يمكنك كتابة ملف http2_test.goواستخدام إعلان الحزمة http2. وتجمع رمز http2_test.go، كما أنها جزء من حزمة http2. في خطاب العامية ، مثل هذا الاختبار يسمى الداخلية. تدعم

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

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

ولكن من الضروري وضع أمثلة لوظائف الاختبار ( Example) في ملف اختبار خارجي . هذا يضمن أنه عند عرضها في godoc ، ستتلقى الأمثلة بادئة الحزمة المناسبة ويمكن نسخها بسهولة.

. , .

, , Go go . , net/http net .

.go , , .

5.1.3. , API


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

لإنشاء مثل هذه الحزمة ، ضعها في دليل باسم internal/أو في دليلها الفرعي. عندما goيرى الفريق استيراد الحزمة مع المسار internal، فإنه يتحقق من موقع حزمة الاتصال في دليل أو دليل فرعي internal/.

على سبيل المثال ، .../a/b/c/internal/d/e/fيمكن للحزمة استيراد حزمة فقط من شجرة دليل .../a/b/c، ولكن ليس على الإطلاق .../a/b/gأو أي مستودع تخزين آخر (انظرالوثائق ).

5.2. أصغر الحزمة الرئيسية


في وظيفة main، وحزمة mainيجب أن يكون وظيفة الحد الأدنى، لأنه main.mainبمثابة المفرد: البرنامج يمكن أن يكون إلا وظيفة واحدة main، بما في ذلك الاختبارات.

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

نصيحة . func main()يجب تحليل الإشارات ، وفتح الاتصالات بقواعد البيانات ، وقطع الأشجار ، وما إلى ذلك ، ثم نقل التنفيذ إلى كائن رفيع المستوى.

6. هيكل API


نصيحة التصميم الأخيرة للمشروع الذي أعتبره الأكثر أهمية.

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

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

عندما يتعلق الأمر بواجهات برمجة التطبيقات (APIs) العامة ، فإن الأمر يستحق النظر بجدية في البنية منذ البداية ، لأن التغييرات اللاحقة ستكون مدمرة للمستخدمين.

6.1. واجهات برمجة التطبيقات للتصميم التي يصعب إساءة استخدامها حسب التصميم


"يجب أن تكون واجهات برمجة التطبيقات بسيطة للاستخدام السليم وصعبة الخطأ" - Josh Bloch

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

6.1.1. كن حذرا مع الوظائف التي تقبل معلمات متعددة من نفس النوع.


مثال جيد على نظرة بسيطة للوهلة الأولى ، ولكن من الصعب استخدام واجهة برمجة التطبيقات (API) هي عندما تتطلب معلمتين أو أكثر من نفس النوع. قارن بين التواقيع الوظيفية:

 func Max(a, b int) int func CopyFile(to, from string) error 

ما هو الفرق بين هاتين الوظيفتين؟ من الواضح أن أحدهما يعيد رقمين كحد أقصى ، والآخر ينسخ الملف. لكن هذه ليست هي النقطة.

 Max(8, 10) // 10 Max(10, 8) // 10 

الحد الأقصى هو تبديلي : ترتيب المعلمات لا يهم. بحد أقصى ثمانية وعشرة هي عشرة ، بغض النظر عما إذا تم مقارنة ثمانية وعشرة أو عشرة وثمانية.

ولكن في حالة CopyFile ، هذا ليس كذلك.

 CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup") 

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

أحد الحلول الممكنة هو تقديم نوع إضافي مسؤول عن المكالمة الصحيحة CopyFile.

 type Source string func (src Source) CopyTo(dest string) error { return CopyFile(dest, string(src)) } func main() { var from Source = "presentation.md" from.CopyTo("/tmp/backup") } 

هناك CopyFileما يسمى دائما بشكل صحيح - يمكن القول من خلال اختبار وحدة - ويمكن القيام به في القطاع الخاص، مما يقلل كذلك من احتمال سوء الاستخدام.

نصيحة . من الصعب استخدام واجهة برمجة التطبيقات (API) مع معلمات متعددة من نفس النوع بشكل صحيح.

6.2. تصميم واجهة برمجة التطبيقات لحالة الاستخدام الأساسية


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

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

6.2.1. باستخدام nil كمعلمة غير مستحسن


لقد بدأت بالقول أنه لا يجب عليك إجبار المستخدم على توفير معلمات API التي لا تهمه. هذا يعني تصميم واجهات برمجة التطبيقات لحالة الاستخدام الرئيسية (الخيار الافتراضي).

فيما يلي مثال من حزمة net / http.

 package http // ListenAndServe listens on the TCP network address addr and then calls // Serve with handler to handle requests on incoming connections. // Accepted connections are configured to enable TCP keep-alives. // // The handler is typically nil, in which case the DefaultServeMux is used. // // ListenAndServe always returns a non-nil error. func ListenAndServe(addr string, handler Handler) error { 

ListenAndServeيقبل معلمتين: عنوان TCP للاستماع على الاتصالات الواردة http.Handlerومعالجة طلب HTTP وارد. Serveيسمح المعلمة الثانية أن تكون nil. في التعليقات ، يلاحظ أنه عادة ما يمر كائن الاستدعاء بالفعلnil ، مما يشير إلى الرغبة في استخدامه http.DefaultServeMuxكمعلمة ضمنية.

الآن المتصل Serveلديه طريقتان لفعل الشيء نفسه.

 http.ListenAndServe("0.0.0.0:8080", nil) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

كلا الخيارين تفعل الشيء نفسه. ينتشر

هذا التطبيق nilمثل الفيروس. تحتوي الحزمة أيضًا httpعلى مساعد http.Serve، بحيث يمكنك تخيل بنية الوظيفة ListenAndServe:

 func ListenAndServe(addr string, handler Handler) error { l, err := net.Listen("tcp", addr) if err != nil { return err } defer l.Close() return Serve(l, handler) } 

لأن ListenAndServeيسمح للطالب لتمرير nilالمعلمة الثانية، http.Serveكما يدعم هذا السلوك. في الواقع ، يتم http.Serveتطبيقه في المنطق "إذا كان المعالج متساويًا nil، استخدم DefaultServeMux". nilيمكن أن يؤدي قبول المعلمة الواحدة إلى جعل المتصل يفكر في إمكانية تمريرها nilلكلتا المعلمتين. لكن هذاServe

 http.Serve(nil, nil) 

يؤدي إلى ذعر رهيب.

نصيحة . لا تخلط المعلمات في نفس وظيفة التوقيع nilوليس nil.

http.ListenAndServeحاول المؤلف تبسيط حياة مستخدمي واجهة برمجة التطبيقات للحالة الافتراضية ، لكن الأمان تأثر.

في الوجود ، nilلا يوجد فرق في عدد الخطوط بين الاستخدام الصريح وغير المباشر DefaultServeMux.

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", nil) 

مقارنة ب

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

هل كان يستحق الخلط للحفاظ على سطر واحد؟

  const root = http.Dir("/htdocs") mux := http.NewServeMux() mux.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux) 

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

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

6.2.2. استخدم وسيطات متغيرة الطول بدلاً من [] T


في كثير من الأحيان ، تأخذ وظيفة أو طريقة شريحة من القيم.

 func ShutdownVMs(ids []string) error 

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

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

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

 if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { // apply the non zero parameters } 

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

 // anyPostive indicates if any value is greater than zero. func anyPositive(values ...int) bool { for _, v := range values { if v > 0 { return true } } return false } 

جعل ذلك من الممكن تحديد الحالة التي سيتم فيها تنفيذ الوحدة الداخلية بوضوح:

 if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) { // apply the non zero parameters } 

ومع ذلك ، هناك مشكلة في ذلك anyPositive، يمكن لشخص ما أن يسميها عن طريق الخطأ مثل هذا:

 if anyPositive() { ... } 

في هذه الحالة ، anyPositiveسوف يعود false. هذا ليس هو الخيار الأسوأ. أسوأ إذا anyPositiveعاد trueفي غياب الحجج.

ومع ذلك ، سيكون من الأفضل أن تكون قادرًا على تغيير توقيع anyPositive لضمان تمرير وسيطة واحدة على الأقل إلى المتصل. يمكن القيام بذلك من خلال الجمع بين المعلمات للوسائط العادية والوسائط متغيرة الطول (varargs):

 // anyPostive indicates if any value is greater than zero. func anyPositive(first int, rest ...int) bool { if first > 0 { return true } for _, v := range rest { if v > 0 { return true } } return false } 

الآن anyPositiveلا يمكنك الاتصال باستخدام أقل من وسيطة واحدة.

6.3. دع الوظائف تحدد السلوك المطلوب.


لنفترض أنه تم إعطائي مهمة كتابة وظيفة تحافظ على الهيكل Documentعلى القرص.

 // Save      f. func Save(f *os.File, doc *Document) error 

يمكنني كتابة وظيفة Saveتكتب Documentإلى ملف *os.File. ولكن هناك بعض المشاكل.

التوقيع Saveيلغي إمكانية تسجيل البيانات عبر الشبكة. إذا ظهر هذا الشرط في المستقبل ، فسيتعين تغيير توقيع الوظيفة ، مما سيؤثر على جميع كائنات الاتصال.

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

ويجب أن أتأكد من fكتابته في مجلد مؤقت وحذفه لاحقًا.

*os.Fileيعرّف أيضًا العديد من الطرق غير المرتبطة Save، على سبيل المثال ، قراءة الدلائل والتحقق مما إذا كان المسار رابط رمزي. حسنا ، إذا كان التوقيعSaveوصف فقط الأجزاء ذات الصلة *os.File.

ما الذي يمكن عمله؟

 // Save      // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error 

بمساعدته io.ReadWriteCloser، يمكنك تطبيق مبدأ فصل الواجهة - وإعادة تعريفه Saveعلى واجهة تصف الخصائص العامة للملف.

بعد هذا التغيير ، io.ReadWriteCloserيمكن استبدال أي نوع يقوم بتنفيذ الواجهة بالنوع السابق *os.File.

يعمل هذا في الوقت نفسه على توسيع النطاق Saveويوضح للمتصل أنواع الطرق *os.Fileالمرتبطة بتشغيله. ولم يعد بإمكان

المؤلف Saveاستدعاء هذه الأساليب غير المرتبطة به *os.File، لأنه مخفي وراء الواجهة io.ReadWriteCloser.

ولكن يمكننا تمديد مبدأ فصل الواجهة إلى أبعد من ذلك.

أولا إذاSave يتبع مبدأ المسؤولية الفردية ، من غير المرجح أن يقرأ الملف الذي كتبه للتو للتحقق من محتوياته - يجب أن يقوم رمز آخر بذلك.

 // Save      // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error 

لذلك ، يمكنك تضييق نطاق مواصفات الواجهة Saveلمجرد الكتابة والإغلاق.

ثانياً ، آلية إغلاق مؤشر الترابط y Saveهي إرث الوقت الذي عملت فيه مع الملف. والسؤال هو ، في أي ظروف wcسيتم إغلاقه.

إذا كانت Saveالقضية Closeدون قيد أو شرط، سواء في حالة النجاح.

يمثل هذا مشكلة للمتصل لأنه قد يرغب في إضافة بيانات إلى الدفق بعد كتابة المستند.

 // Save      // Writer. func Save(w io.Writer, doc *Document) error 

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

بعد تطبيق مبدأ فصل الواجهة ، أصبحت الوظيفة في الوقت نفسه أكثر تحديدًا من حيث المتطلبات (تحتاج فقط إلى كائن حيث يمكن كتابتها) ، وأكثر عمومية من حيث الوظيفة ، حيث يمكننا الآن استخدامها Saveلحفظ البيانات أينما يتم تنفيذها io.Writer.

7. خطأ في التعامل


أعطى عدة عروض و الكثير كتب عن هذا الموضوع في بلوق، لذلك لن أكرر.

بدلاً من ذلك ، أريد تغطية مجالين آخرين متعلقين بمعالجة الأخطاء.

7.1. تخلص من الحاجة إلى معالجة الأخطاء عن طريق إزالة الأخطاء نفسها


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

ملاحظة . أنا لا أقول "حذف خطأ معالجة". أقترح تغيير الرمز بحيث لا توجد أخطاء للمعالجة.

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

7.1.1. عدد الصف


سنكتب وظيفة لحساب عدد الخطوط في الملف.

 func CountLines(r io.Reader) (int, error) { var ( br = bufio.NewReader(r) lines int err error ) for { _, err = br.ReadString('\n') lines++ if err != nil { break } } if err != io.EOF { return 0, err } return lines, nil } 

ونحن نتبع النصيحة من الأقسام السابقة ، CountLinesيقبل io.Reader، لا *os.File؛ إنها مهمة المتصل بالفعل توفير io.Readerالمحتوى الذي نريد حسابه.

نقوم بإنشاء bufio.Reader، ثم استدعاء الأسلوب في حلقة ReadString، وزيادة العداد ، حتى نصل إلى نهاية الملف ، ثم نعيد عدد الأسطر المقروءة.

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

  _, err = br.ReadString('\n') lines++ if err != nil { break } 

نزيد عدد الخطوط قبل البحث عن الأخطاء - يبدو هذا غريباً.

السبب في أننا يجب أن نكتبها بهذه الطريقة هو أنها ReadStringستُرجع خطأً إذا واجهت نهاية الملف قبل حرف السطر الجديد. يمكن أن يحدث هذا إذا لم يكن هناك سطر جديد في نهاية الملف.

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

ملاحظة . هذا المنطق لا يزال غير مثالي ، هل يمكنك العثور على خطأ؟

لكننا لم ننته من التحقق من الأخطاء. ReadStringسيعود io.EOFعندما يواجه نهاية الملف. هذا هو الموقف المتوقع ، لذلك ReadStringعليك أن تفعل بعض الشيء لقول "توقف ، لا يوجد شيء آخر للقراءة". لذلك ، قبل إعادة الخطأ إلى كائن الاستدعاء CountLine، تحتاج إلى التحقق من أن الخطأ لا يرتبط به io.EOF، ثم نقله ، وإلا فإننا نعود nilونقول أن كل شيء على ما يرام.

أعتقد أن هذا مثال جيد على أطروحة Russ Cox حول كيفية معالجة الأخطاء لإخفاء الوظيفة. لنلقِ نظرة على النسخة المحسّنة.

 func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() { lines++ } return lines, sc.Err() } 

يستخدم هذا الإصدار المحسن bufio.Scannerبدلاً من ذلك bufio.Reader.

تحت غطاء محرك السيارة bufio.Scannerيستخدم bufio.Reader، لكنه يضيف مستوى جيد من التجريد ، مما يساعد على إزالة معالجة الأخطاء.

. bufio.Scanner , .

تقوم الطريقة sc.Scan()بإرجاع قيمة trueإذا واجه الماسح الضوئي سلسلة ولم يعثر على خطأ. وبالتالي ، يتم forاستدعاء نص الحلقة فقط إذا كان هناك سطر من النص في المخزن المؤقت للماسحة الضوئية. هذا يعني أن الجديد CountLinesيعالج الحالات عندما لا يوجد سطر جديد أو عندما يكون الملف فارغًا.

ثانياً ، نظرًا sc.Scanلإرجاعها falseعند اكتشاف خطأ ، forتنتهي الدورة عندما تصل إلى نهاية الملف أو يتم اكتشاف خطأ. bufio.Scannerيتذكر النوع الخطأ الأول الذي واجهه ، وباستخدام الطريقة sc.Err()يمكننا استعادة ذلك الخطأ بمجرد الخروج من الحلقة.

أخيرًا ، sc.Err()يعتني بالمعالجة io.EOFويحولها إلى nilما إذا تم الوصول إلى نهاية الملف دون أخطاء.

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

7.1.2. الكاتب


مثالي الثاني مستوحى من منشور "الأخطاء هي القيم" .

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

 type Header struct { Key, Value string } type Status struct { Code int Reason string } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err } 

أولاً ، قم بإنشاء شريط الحالة مع fmt.Fprintfالتحقق من الخطأ. ثم لكل عنوان نكتب قيمة المفتاح والعنوان ، في كل مرة التحقق من وجود خطأ. أخيرًا ، نكمل قسم الرأس بجزء إضافي \r\n، ونفحص الخطأ ، وننسخ نص الاستجابة إلى العميل. أخيرًا ، على الرغم من أننا لسنا بحاجة إلى التحقق من الخطأ io.Copy، إلا أننا نحتاج إلى ترجمته من قيمتين للإرجاع إلى القيمة الوحيدة التي تُرجع WriteResponse.

هذا كثير من العمل الرتيب. ولكن يمكنك تخفيف مهمتك من خلال تطبيق نوع صغير من الغلاف errWriter.

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

 type errWriter struct { io.Writer err error } func (e *errWriter) Write(buf []byte) (int, error) { if e.err != nil { return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err } 

إذا قمت بتطبيق errWriterل WriteResponse، وضوح كود تحسنت بشكل ملحوظ. لم تعد بحاجة إلى التحقق من وجود أخطاء في كل عملية فردية. تنتقل رسالة الخطأ إلى نهاية الوظيفة ew.errكاختبار حقل ، مع تجنب الترجمة المزعجة لقيم io.Copy التي تم إرجاعها.

7.2. تعامل مع الخطأ مرة واحدة فقط


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

 // WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) } 

إذا اتخذت أقل من قرار ، فإنك تتجاهل الخطأ. كما نرى هنا ، يتم w.WriteAllتجاهل الخطأ من .

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

 func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err) // annotated error goes to log file return err // unannotated error returned to caller } return nil } 

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

على الأرجح ، يقوم المتصل بالشيء نفسه:

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) return err } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

وبالتالي ، يتم إنشاء مكدس من الخطوط المتكررة في السجل.

 unable to write: io.EOF could not write config: io.EOF 

لكن في الجزء العلوي من البرنامج ، تحصل على خطأ أصلي دون أي سياق.

 err := WriteConfig(f, &conf) fmt.Println(err) // io.EOF 

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

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) // oops, forgot to return } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

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

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

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

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

7.2.1. إضافة السياق إلى الأخطاء


حدث خطأ لأن المؤلف كان يحاول إضافة سياق إلى رسالة الخطأ. حاول ترك علامة للإشارة إلى مصدر الخطأ.

دعونا ننظر في طريقة أخرى للقيام بنفس الشيء من خلال fmt.Errorf.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil { return fmt.Errorf("could not write config: %v", err) } return nil } func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { return fmt.Errorf("write failed: %v", err) } return nil } 

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

في حالة حدوث خطأ I / O أثناء كتابة الملف ، Error()ستنتج الطريقة شيئًا مثل هذا:

 could not write config: write failed: input/output error 

7.2.2. خطأ في الالتفاف باستخدام github.com/pkg/errors


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

  1. تأكد من أنها ليست صفرا.
  2. اعرضه على الشاشة أو سجله.

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

 func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil { return nil, errors.Wrap(err, "read failed") } return buf, nil } func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config") } func main() { _, err := ReadConfig() if err != nil { fmt.Println(err) os.Exit(1) } } 

أصبحت الرسالة الآن خطأً لطيفًا على غرار K & D:

 could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory 

وقيمته تحتوي على رابط للسبب الأصلي.

 func main() { _, err := ReadConfig() if err != nil { fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", err) os.Exit(1) } } 

وبالتالي ، يمكنك استعادة الخطأ الأصلي وعرض تتبع المكدس:

 original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory stack trace: open /Users/dfc/.settings.xml: no such file or directory open failed main.ReadFile /Users/dfc/devel/practical-go/src/errors/readfile2.go:16 main.ReadConfig /Users/dfc/devel/practical-go/src/errors/readfile2.go:29 main.main /Users/dfc/devel/practical-go/src/errors/readfile2.go:35 runtime.main /Users/dfc/go/src/runtime/proc.go:201 runtime.goexit /Users/dfc/go/src/runtime/asm_amd64.s:1333 could not read config 

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

8. التزامن


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

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

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

يناقش هذا القسم بعض عيوب الاستخدام الساذج لوظائف التزامن Go.

8.1. القيام ببعض العمل في كل وقت.


ما هي مشكلة هذا البرنامج؟

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { } } 

يقوم البرنامج بما نهدف إليه: إنه يخدم خادم ويب بسيط. في الوقت نفسه ، فإنه يقضي وقت وحدة المعالجة المركزية في حلقة لا نهائية ، لأنه for{}في السطر الأخير mainيمنع gorutin الرئيسي ، دون إجراء أي I / O ، لا يوجد انتظار لحظر الرسائل أو إرسالها أو تلقيها ، أو نوع من الاتصال مع sheduler.

نظرًا لأن وقت تشغيل Go يتم تقديمه عادة بواسطة sheduler ، فسيتم تشغيل هذا البرنامج بلا معنى على المعالج وقد ينتهي به المطاف في قفل نشط (قفل حي).

كيفية اصلاحها؟ هنا خيار واحد.

 package main import ( "fmt" "log" "net/http" "runtime" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { runtime.Gosched() } } 

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

إذا كنت أكثر خبرة بقليل مع Go ، فيمكنك كتابة شيء مثل هذا.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() select {} } 

تم selectحظر عبارة فارغة إلى الأبد. هذا مفيد ، لأننا الآن لا ندور المعالج بالكامل لمكالمة فقط runtime.GoSched(). ومع ذلك ، فإننا نعامل فقط الأعراض ، وليس السبب.

أريد أن أبين لك حلاً آخر ، آمل أن يكون قد حدث لك بالفعل. بدلاً من الركض http.ListenAndServeفي goroutine ، وترك مشكلة goroutine الرئيسية ، ما عليك سوى تشغيل http.ListenAndServegoroutine الرئيسي.

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

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } 

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

هذا غالباً ما يلغي الكثير من تتبع الدولة ومعالجة القنوات اللازمة لنقل النتيجة مرة أخرى من goroutine إلى بادئ العملية.

نصيحة . يسيء كثير من مبرمجي Go استخدام goroutines ، خاصةً في البداية. مثل كل شيء آخر في الحياة ، فإن مفتاح النجاح هو الاعتدال.

8.2. ترك التوازي للمتصل


ما هو الفرق بين واجهات برمجة التطبيقات اثنين؟

 // ListDirectory returns the contents of dir. func ListDirectory(dir string) ([]string, error) 

 // ListDirectory returns a channel over which // directory entries will be published. When the list // of entries is exhausted, the channel will be closed. func ListDirectory(dir string) chan string 

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

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

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

يحتوي إصدار ListDirectoryالقناة على مشكلتين أخريين:

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

في كلتا الحالتين ، يكون الحل هو استخدام رد اتصال: وظيفة تسمى في سياق كل إدخال دليل أثناء تنفيذه.

 func ListDirectory(dir string, fn func(string)) 

مما لا يثير الدهشة ، وظيفة filepath.WalkDirتعمل بهذه الطريقة.

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

8.3. لا تقم أبدًا بتشغيل goroutine دون معرفة متى ستتوقف


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

يخدم هذا التطبيق البسيط حركة مرور HTTP على منفذين مختلفين: المنفذ 8080 لحركة مرور التطبيق والمنفذ 8001 للوصول إلى نقطة النهاية /debug/pprof.

 package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug http.ListenAndServe("0.0.0.0:8080", mux) // app traffic } 

على الرغم من أن البرنامج غير معقد ، إلا أنه أساس تطبيق حقيقي.

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

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() { http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { go serveDebug() serveApp() } 

معالجات الانهيار serveAppو serveDebugإلى وظائف منفصلة، قمنا تفصل بينهما من main.main. نحن أيضا اتبعت النصيحة السابقة، والتأكد من serveAppو serveDebugترك المهمة لضمان التوازي المتصل.

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

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

ومع ذلك ، serveDebugيبدأ بـ goroutine منفصل ، وفي حالة إصداره ، ينتهي goroutine ، بينما يستمر باقي البرنامج. لن يعجب Devs بحقيقة أنه لا يمكنك الحصول على إحصائيات التطبيق لأن المعالج /debugقد توقف عن العمل لفترة طويلة.

نحن بحاجة إلى التأكد من إغلاق التطبيق في حالة توقف أي goroutine يقدمه.

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil { log.Fatal(err) } } func serveDebug() { if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil { log.Fatal(err) } } func main() { go serveDebug() go serveApp() select {} } 

الآن serverAppو serveDebugأخطاء الاختيار من ListenAndServe، وإذا سبب ضروري log.Fatal. نظرًا لأن كلا المعالجين يعملان في goroutines ، فإننا نضع الروتين الرئيسي فيه select{}.

هذا النهج لديه عدد من المشاكل:

  1. إذا تم ListenAndServeإرجاعه مع وجود خطأ nil، فلن تكون هناك مكالمة log.Fatal، وستتوقف خدمة HTTP على هذا المنفذ دون إيقاف التطبيق.
  2. log.Fatalيدعو إلى os.Exitالخروج دون قيد أو شرط من البرنامج ؛ لن تعمل المكالمات المؤجلة ، ولن يتم إخطار الجهات التابعة الأخرى بالإغلاق ، وسيتوقف البرنامج ببساطة. هذا يجعل من الصعب كتابة اختبارات لهذه الوظائف.

نصيحة . استخدم فقط log.Fatalفي وظائف main.mainأو init.

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

 func serveApp() error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() error { return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { done := make(chan error, 2) go func() { done <- serveDebug() }() go func() { done <- serveApp() }() for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } } } 

يمكن الحصول على حالة عودة Goroutine من خلال القناة. إن حجم القناة يساوي عدد goroutines الذي نريد التحكم فيه ، لذلك doneلن يتم حظر الإرسال إلى القناة ، حيث سيؤدي ذلك إلى إيقاف تشغيل goroutines وإحداث تسرب.

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

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

نداء لhttp.Serverحول الانتهاء ، لذلك أنا ملفوفة هذا المنطق في وظيفة المساعد. serveيقبل المساعد العنوان http.Handlerوكذلك http.ListenAndServe، القناة stopالتي نستخدمها لتشغيل الطريقة Shutdown.

 func serve(addr string, handler http.Handler, stop <-chan struct{}) error { s := http.Server{ Addr: addr, Handler: handler, } go func() { <-stop // wait for stop signal s.Shutdown(context.Background()) }() return s.ListenAndServe() } func serveApp(stop <-chan struct{}) error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return serve("0.0.0.0:8080", mux, stop) } func serveDebug(stop <-chan struct{}) error { return serve("127.0.0.1:8001", http.DefaultServeMux, stop) } func main() { done := make(chan error, 2) stop := make(chan struct{}) go func() { done <- serveDebug(stop) }() go func() { done <- serveApp(stop) }() var stopped bool for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } if !stopped { stopped = true close(stop) } } } 

الآن لكل قيمة في القناة ، doneنغلق القناة stop، مما يجعل كل جوروتين على هذه القناة يغلق نفسه http.Server. بدوره ، هذا يؤدي إلى عودة جميع goroutines المتبقية ListenAndServe. عندما تتوقف جميع gorutins قيد التشغيل ، main.mainينتهي وينتهي العملية.

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

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


All Articles