تركز هذه المقالة على أفضل الممارسات لكتابة رمز Go. يتكون في أسلوب العرض التقديمي ، ولكن بدون الشرائح المعتادة. سنحاول باختصار وبشكل واضح من خلال كل بند.
تحتاج أولاً إلى الاتفاق على معنى
أفضل الممارسات للغة البرمجة. هنا يمكنك أن تتذكر كلمات Russ Cox ، المدير الفني لـ Go:
هندسة البرمجيات هي ما يحدث للبرمجة ، إذا قمت بإضافة عامل الوقت والمبرمجين الآخرين.
وهكذا ، يميز روس بين مفاهيم
البرمجة وهندسة البرمجيات . في الحالة الأولى ، تكتب برنامجًا لنفسك ، وفي الثانية تقوم بإنشاء منتج سيعمل عليه المبرمجون الآخرون بمرور الوقت. المهندسين يأتون ويذهبون. فرق تنمو أو تتقلص. يتم إضافة ميزات جديدة ويتم إصلاح الخلل. هذه هي طبيعة تطوير البرمجيات.
المحتويات
1. المبادئ الأساسية
قد أكون من أوائل مستخدمي Go بينكم ، لكن هذا ليس رأيي الشخصي. هذه المبادئ الأساسية تكمن وراء Go نفسها:
- البساطة
- سهولة القراءة
- الإنتاجية
ملاحظة يرجى ملاحظة أنني لم أذكر "الأداء" أو "التزامن". هناك لغات أسرع من 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 }
في السطر العاشر ، يتم الإعلان عن متغير المدى
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 {
اسم
*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
أنا متأكد من أنني لم أتذكر كل شيء بعد. من المحتمل أن يعتبر مطورو التشغيل هذا خطأ ، ولكن بعد فوات الأوان لتغيير أي شيء. مع هذا الاختيار ، كيفية ضمان نمط موحد؟
أريد أن أقترح أسلوبًا لإعلان المتغيرات التي أحاول استخدامها بنفسي كلما كان ذلك ممكنًا.
بما أن 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. التعليقات
قبل أن ننتقل إلى النقاط الأكثر أهمية ، أريد أن أتناول بضع دقائق للتعليق.
"الرمز الجيد يحتوي على الكثير من التعليقات ، والرمز السيئ يحتاج إلى الكثير من التعليقات." - ديف توماس وأندرو هانت ، مبرمج براغماتي
التعليقات مهمة جدا لسهولة قراءة البرنامج. يجب أن يقوم كل تعليق بواحد - واحد فقط - من ثلاثة أشياء:
- اشرح ما الذي يفعله الكود؟
- اشرح كيف يفعل ذلك.
- اشرح لماذا .
النموذج الأول مثالي للتعليق على الشخصيات العامة:
والثاني مثالي للتعليقات داخل إحدى الطرق:
الاستمارة الثالثة ("لماذا") فريدة من نوعها لأنها لا تحل محل أو تحل محل الأولين. توضح هذه التعليقات العوامل الخارجية التي أدت إلى كتابة الكود في شكله الحالي. في كثير من الأحيان بدون هذا السياق ، من الصعب فهم سبب كتابة الكود بهذه الطريقة.
return &v2.Cluster_CommonLbConfig{
في هذا المثال ، قد لا يكون واضحًا على الفور ما يحدث عندما يتم تعيين HealthyPanicThreshold على صفر بالمائة. الغرض من التعليق هو توضيح أن القيمة 0 تعطل عتبة الذعر.
3.1. يجب أن تصف التعليقات في المتغيرات والثوابت محتوياتها ، وليس الغرض منها
قلت في وقت سابق أن اسم المتغير أو الثابت يجب أن يصف الغرض منه. لكن التعليق على متغير أو ثابت يجب أن يصف
المحتوى تمامًا وليس
الغرض .
const randomNumber = 6
في هذا المثال ، يصف التعليق
لماذا randomNumber
على 6 ومن أين جاء. التعليق لا يصف المكان الذي سيتم استخدام
randomNumber
. فيما يلي بعض الأمثلة الأخرى:
const ( StatusContinue = 100
في سياق HTTP ، يُعرف الرقم
100
باسم
StatusContinue
، كما هو محدد في RFC 7231 ، القسم 6.2.1.
نصيحة . بالنسبة للمتغيرات التي لا تحتوي على قيمة أولية ، يجب أن يصف التعليق من المسؤول عن تهيئة هذا المتغير.
هنا يخبر القارئ القارئ أن وظيفة dowidth
المسؤولة عن الحفاظ على حالة sizeCalculationDisabled
.
نصيحة . إخفاء في الأفق. هذه نصيحة من كيت غريغوري . أحيانًا يكون أفضل اسم لمتغير مخفيًا في التعليقات.
تمت إضافة تعليق بواسطة المؤلف لأن registry
الاسم لا يشرح الغرض منه بشكل كافٍ - هذا سجل ، ولكن ما هو السجل؟
إذا قمت بإعادة تسمية متغير إلى sqlDrivers ، يصبح من الواضح أنه يحتوي على برامج تشغيل SQL.
var sqlDrivers = make(map[string]*sql.Driver)
الآن أصبح التعليق زائدا ويمكن حذفه.
3.2. قم دائمًا بتوثيق الشخصيات المتاحة للجمهور
يتم إنشاء وثائق الحزمة الخاصة بك عن طريق godoc ، لذلك يجب عليك إضافة تعليق على كل شخصية عامة معلنة في الحزمة: متغير ، ثابت ، وظيفة ، وطريقة.
فيما يلي إرشادات من دليل Google Style:
- يجب التعليق على أي وظيفة عامة ليست واضحة وموجزة.
- يجب التعليق على أي وظيفة في المكتبة ، بغض النظر عن الطول أو التعقيد.
package ioutil
هناك استثناء واحد لهذه القاعدة: لا تحتاج إلى توثيق الطرق التي تنفذ الواجهة. على وجه التحديد ، لا تفعل هذا:
هذا التعليق لا يعني أي شيء. إنه لا يقول ما الذي تفعله الطريقة: والأسوأ من ذلك أنه يرسل مكانًا للبحث عن الوثائق. في هذه الحالة ، أقترح حذف التعليق بالكامل.
هنا مثال من الحزمة
io
.
لاحظ أن إعلان
LimitedReader
يسبقه مباشرة الوظيفة التي تستخدمه ،
LimitedReader.Read
إعلان
LimitedReader.Read
إعلان
LimitedReader
نفسه. على الرغم من أن
LimitedReader.Read
نفسها غير موثقة ، يمكن فهم أن هذا هو تطبيق
io.Reader
.
نصيحة . قبل كتابة الوظيفة ، اكتب تعليقًا يصفها. إذا وجدت صعوبة في كتابة تعليق ، فهذه علامة على أنه من الصعب فهم الرمز الذي أنت بصدد كتابته.
3.2.1. لا تعلق على كود غير صحيح ، أعد كتابته
"لا تعلق على كود سيء - أعد كتابته " - براين كيرنيغان
لا يكفي أن نوضح في التعليقات صعوبة جزء الشفرة. إذا صادفت أحد هذه التعليقات ، فيجب أن تبدأ تذكرة مع تذكير بإعادة التسكين. يمكنك التعايش مع الدين الفني طالما كان المبلغ معروفًا.
في المكتبة القياسية ، من المعتاد ترك التعليقات في نمط TODO باسم المستخدم الذي لاحظ المشكلة.
هذا ليس التزامًا بإصلاح المشكلة ، لكن المستخدم المشار إليه قد يكون أفضل شخص يطرح سؤالاً. مشاريع أخرى ترافق TODO مع التاريخ أو رقم التذكرة.
3.2.2. بدلاً من التعليق على الكود ، قم بإعادة تركيبه
"الرمز الجيد هو أفضل الوثائق. عندما تكون على وشك إضافة تعليق ، اسأل نفسك السؤال التالي: "كيفية تحسين الكود حتى لا تكون هناك حاجة لهذا التعليق؟" Refactor وترك تعليق لجعلها أكثر وضوحا. " - ستيف ماكونيل
يجب أن تؤدي المهام مهمة واحدة فقط. إذا كنت ترغب في كتابة تعليق لأن بعض الأجزاء لا تتعلق بباقي الوظيفة ، ففكر في استخراجها في وظيفة منفصلة.
الميزات الأصغر ليست أكثر وضوحًا فحسب ، ولكنها أسهل في الاختبار بشكل منفصل عن بعضها البعض. عند عزل الشفرة في وظيفة منفصلة ، يمكن أن يحل اسمها محل تعليق.
4. هيكل الحزمة
"اكتب رمزًا متواضعًا: الوحدات التي لا تُظهر أي شيء غير ضروري للوحدات الأخرى ولا تعتمد على تنفيذ الوحدات الأخرى" - ديف توماس
كل حزمة هي في الأساس برنامج Go صغير منفصل. تمامًا كما لا يهم تنفيذ وظيفة أو طريقة للمتصل ، فإن تنفيذ الوظائف والأساليب والأنواع التي تشكل واجهة برمجة التطبيقات العامة لحزمتك لا يهم.
تسعى حزمة Go الجيدة إلى الحد الأدنى من الاتصال مع الحزم الأخرى على مستوى الكود المصدري ، بحيث مع نمو المشروع ، لا يتم تغيير التغييرات في حزمة واحدة عبر قاعدة الكود. مثل هذه المواقف تمنع المبرمجين الذين يعملون على قاعدة الكود هذه.
في هذا القسم ، سنتحدث عن تصميم الحزمة ، بما في ذلك الاسم والنصائح الخاصة بطرق الكتابة ووظائفها.
4.1. حزمة جيدة تبدأ مع اسم جيد
تبدأ حزمة Go الجيدة باسم جودة. فكر في الأمر على أنه عرض تقديمي قصير يقتصر على كلمة واحدة فقط.مثل أسماء المتغيرات في القسم السابق ، فإن اسم الحزمة مهم للغاية. لا حاجة للتفكير في أنواع البيانات في هذه الحزمة ، فمن الأفضل طرح السؤال: "ما الخدمة التي توفرها هذه الحزمة؟" عادة لا تكون الإجابة "توفر هذه الحزمة النوع X" ، ولكن "هذه الحزمة تسمح لك بالاتصال عبر HTTP".نصيحة . اختر اسم الحزمة من خلال وظيفتها ، وليس محتواها.
4.1.1. يجب أن تكون أسماء الحزمة الجيدة فريدة من نوعها
كل حزمة لها اسم فريد في المشروع. لا توجد صعوبة إذا اتبعت نصيحة إعطاء أسماء لغرض الحزم. إذا اتضح أن الحزمتين لها نفس الاسم ، فعلى الأرجح:- .
- . , .
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
مثال آخر على نوع ذو قيمة فارغة مفيدة 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() {
ملاحظة . 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:- استخدم واجهات لوصف السلوك المطلوب بواسطة الوظائف أو الطرق.
- تجنب الوضع العالمي.
في Go ، يمكننا إعلان المتغيرات في نطاق دالة أو طريقة ، وكذلك في نطاق الحزمة. عندما يكون المتغير متاحًا للعموم ، مع معرف بحرف كبير ، يكون نطاقه بالفعل عالميًا للبرنامج بأكمله: أي حزمة في أي وقت ترى نوع ومحتوى هذا المتغير.توفر الحالة العالمية القابلة للتغيير علاقة وثيقة بين الأجزاء المستقلة من البرنامج ، حيث تصبح المتغيرات العالمية معلمة غير مرئية لكل وظيفة في البرنامج! يمكن انتهاك أي وظيفة تعتمد على متغير عمومي عندما يتغير نوع هذا المتغير. يمكن انتهاك أي وظيفة تعتمد على حالة متغير عمومي إذا غير جزء آخر من البرنامج هذا المتغير.كيفية تقليل الاتصال الذي ينشئه متغير عمومي:- انقل المتغيرات المقابلة كحقول إلى الهياكل التي تحتاجها.
- استخدم واجهات لتقليل الاتصال بين السلوك وتطبيق هذا السلوك.
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)
الحد الأقصى هو تبديلي : ترتيب المعلمات لا يهم. بحد أقصى ثمانية وعشرة هي عشرة ، بغض النظر عما إذا تم مقارنة ثمانية وعشرة أو عشرة وثمانية.ولكن في حالة 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
يقبل معلمتين: عنوان 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 {
نظرًا لأن المشغل كان if
يحصل على فترة طويلة جدًا ، فقد أردت سحب منطق التحقق من الصحة إلى وظيفة منفصلة. إليكم ما توصلت إليه:
جعل ذلك من الممكن تحديد الحالة التي سيتم فيها تنفيذ الوحدة الداخلية بوضوح: if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
ومع ذلك ، هناك مشكلة في ذلك anyPositive
، يمكن لشخص ما أن يسميها عن طريق الخطأ مثل هذا: if anyPositive() { ... }
في هذه الحالة ، anyPositive
سوف يعود false
. هذا ليس هو الخيار الأسوأ. أسوأ إذا anyPositive
عاد true
في غياب الحجج.ومع ذلك ، سيكون من الأفضل أن تكون قادرًا على تغيير توقيع anyPositive لضمان تمرير وسيطة واحدة على الأقل إلى المتصل. يمكن القيام بذلك من خلال الجمع بين المعلمات للوسائط العادية والوسائط متغيرة الطول (varargs):
الآن anyPositive
لا يمكنك الاتصال باستخدام أقل من وسيطة واحدة.6.3. دع الوظائف تحدد السلوك المطلوب.
لنفترض أنه تم إعطائي مهمة كتابة وظيفة تحافظ على الهيكل Document
على القرص.
يمكنني كتابة وظيفة Save
تكتب Document
إلى ملف *os.File
. ولكن هناك بعض المشاكل.التوقيع Save
يلغي إمكانية تسجيل البيانات عبر الشبكة. إذا ظهر هذا الشرط في المستقبل ، فسيتعين تغيير توقيع الوظيفة ، مما سيؤثر على جميع كائنات الاتصال.Save
أيضا غير سارة لاختبار ، لأنه يعمل مباشرة مع الملفات الموجودة على القرص. وبالتالي ، من أجل التحقق من تشغيله ، يجب أن يقرأ الاختبار محتويات الملف بعد الكتابة.ويجب أن أتأكد من f
كتابته في مجلد مؤقت وحذفه لاحقًا.*os.File
يعرّف أيضًا العديد من الطرق غير المرتبطة Save
، على سبيل المثال ، قراءة الدلائل والتحقق مما إذا كان المسار رابط رمزي. حسنا ، إذا كان التوقيعSave
وصف فقط الأجزاء ذات الصلة *os.File
.ما الذي يمكن عمله؟
بمساعدته io.ReadWriteCloser
، يمكنك تطبيق مبدأ فصل الواجهة - وإعادة تعريفه Save
على واجهة تصف الخصائص العامة للملف.بعد هذا التغيير ، io.ReadWriteCloser
يمكن استبدال أي نوع يقوم بتنفيذ الواجهة بالنوع السابق *os.File
.يعمل هذا في الوقت نفسه على توسيع النطاق Save
ويوضح للمتصل أنواع الطرق *os.File
المرتبطة بتشغيله. ولم يعد بإمكانالمؤلف Save
استدعاء هذه الأساليب غير المرتبطة به *os.File
، لأنه مخفي وراء الواجهة io.ReadWriteCloser
.ولكن يمكننا تمديد مبدأ فصل الواجهة إلى أبعد من ذلك.أولا إذاSave
يتبع مبدأ المسؤولية الفردية ، من غير المرجح أن يقرأ الملف الذي كتبه للتو للتحقق من محتوياته - يجب أن يقوم رمز آخر بذلك.
لذلك ، يمكنك تضييق نطاق مواصفات الواجهة Save
لمجرد الكتابة والإغلاق.ثانياً ، آلية إغلاق مؤشر الترابط y Save
هي إرث الوقت الذي عملت فيه مع الملف. والسؤال هو ، في أي ظروف wc
سيتم إغلاقه.إذا كانت Save
القضية Close
دون قيد أو شرط، سواء في حالة النجاح.يمثل هذا مشكلة للمتصل لأنه قد يرغب في إضافة بيانات إلى الدفق بعد كتابة المستند.
الخيار الأفضل هو إعادة تحديد "حفظ" للعمل فقط مع 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. تعامل مع الخطأ مرة واحدة فقط
أخيرًا ، أود الإشارة إلى أنه يجب معالجة الأخطاء مرة واحدة فقط. تعني المعالجة التحقق من معنى الخطأ واتخاذ قرار واحد .
إذا اتخذت أقل من قرار ، فإنك تتجاهل الخطأ. كما نرى هنا ، يتم w.WriteAll
تجاهل الخطأ من .لكن اتخاذ أكثر من قرار استجابة لخطأ واحد يعد خطأ أيضًا. أدناه هو رمز كثيرا ما جئت عبر. func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err)
في هذا المثال ، في حالة حدوث خطأ أثناء الوقت 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)
أرغب في تحليل هذا الموضوع بمزيد من التفاصيل ، لأنني لا أعتبر مشكلة إرجاع خطأ وتسجيل تفضيلاتي الشخصية في نفس الوقت. func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err)
أواجه مشكلة غالبًا ما ينسى مبرمج العودة من خطأ. كما قلنا سابقًا ، فإن أسلوب 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
يعمل النمط بشكل جيد لتسجيل رسائل الخطأ ، لكن نوع الخطأ يتخطى الطريق. جادلت أن التعامل مع الأخطاء كقيم مبهمة أمر مهم للمشاريع المزدوجة الترابط ، لذا لا ينبغي أن يكون نوع الخطأ المصدر مهمًا إذا كنا بحاجة فقط إلى العمل بقيمته:- تأكد من أنها ليست صفرا.
- اعرضه على الشاشة أو سجله.
ومع ذلك ، يحدث أن تحتاج إلى استعادة الخطأ الأصلي. لتعليق هذه الأخطاء ، يمكنك استخدام شيء مثل حزمة بلدي 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.ListenAndServe
goroutine الرئيسي.نصيحة . في حالة الخروج من الوظيفة 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
حتى تتم قراءة جميع إدخالات الدليل. اعتمادًا على حجم الدليل ، قد يستغرق الأمر الكثير من الوقت وربما الكثير من الذاكرة.النظر في المثال الثاني. يشبه إلى حد كبير برمجة 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)
على الرغم من أن البرنامج غير معقد ، إلا أنه أساس تطبيق حقيقي.يحتوي التطبيق في شكله الحالي على العديد من المشكلات التي ستظهر أثناء نموها ، لذلك دعونا ننظر فورًا إلى بعضها. 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{}
.هذا النهج لديه عدد من المشاكل:- إذا تم
ListenAndServe
إرجاعه مع وجود خطأ nil
، فلن تكون هناك مكالمة log.Fatal
، وستتوقف خدمة HTTP على هذا المنفذ دون إيقاف التطبيق.
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
الآن لكل قيمة في القناة ، done
نغلق القناة stop
، مما يجعل كل جوروتين على هذه القناة يغلق نفسه http.Server
. بدوره ، هذا يؤدي إلى عودة جميع goroutines المتبقية ListenAndServe
. عندما تتوقف جميع gorutins قيد التشغيل ، main.main
ينتهي وينتهي العملية.نصيحة . كتابة هذا المنطق لوحدك هو العمل المتكرر وخطر الأخطاء. انظر إلى شيء مثل هذه الحزمة التي ستقوم بمعظم العمل من أجلك.