أهم 10 أخطاء شائعة واجهتها في مشاريع Go

هذا المنشور هو الجزء العلوي من أكثر الأخطاء شيوعًا التي واجهتها في مشاريع Go. النظام لا يهم.

صورة

قيمة غير معروفة من التعداد


دعنا نلقي نظرة على مثال بسيط:

type Status uint32 const ( StatusOpen Status = iota StatusClosed StatusUnknown ) 

هنا نقوم بإنشاء عداد باستخدام iota ، مما يؤدي إلى هذه الحالة:

 StatusOpen = 0 StatusClosed = 1 StatusUnknown = 2 

الآن دعنا نتخيل أن هذا النوع من الحالات هو جزء من طلب JSON الذي سيتم تعبئته / تفريغه. يمكننا تصميم الهيكل التالي:

 type Request struct { ID int `json:"Id"` Timestamp int `json:"Timestamp"` Status Status `json:"Status"` } 

ثم نحصل على نتيجة الاستعلام هذه:

 { "Id": 1234, "Timestamp": 1563362390, "Status": 0 } 

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

 { "Id": 1235, "Timestamp": 1563362390 } 

في هذه الحالة ، سيتم تهيئة حقل الحالة لهيكل الطلب إلى صفر (بالنسبة لـ uint32 يكون 0). لذلك ، نحصل مرة أخرى على StatusOpen بدلاً من StatusUnknown.

في هذه الحالة ، من الأفضل تعيين القيمة المجهولة للعداد أولاً - أي 0:

 type Status uint32 const ( StatusUnknown Status = iota StatusOpen StatusClosed ) 

إذا لم تكن الحالة جزءًا من طلب JSON ، فسيتم تهيئتها في StatusUnknown ، كما نتوقع.

المقارنة


القياس الصحيح هو أمر صعب للغاية. هناك العديد من العوامل التي يمكن أن تؤثر على النتيجة.

يتم خداع خطأ شائع واحد بواسطة تحسينات برنامج التحويل البرمجي. دعونا نرى مثالًا محددًا من مكتبة teivah / bitvector :

 func clear(n uint64, i, j uint8) uint64 { return (math.MaxUint64<<j | ((1 << i) - 1)) & n } 

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

 func BenchmarkWrong(b *testing.B) { for i := 0; i < bN; i++ { clear(1221892080809121, 10, 63) } } 

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

قد يكون أحد الحلول تعيين النتيجة إلى متغير عمومي ، مثل هذا:

 var result uint64 func BenchmarkCorrect(b *testing.B) { var r uint64 for i := 0; i < bN; i++ { r = clear(1221892080809121, 10, 63) } result = r } 

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

مؤشرات! المؤشرات في كل مكان!


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

وبالتالي ، فإن تمرير مؤشر يكون دائما أسرع ، أليس كذلك؟

إذا كنت تعتقد ذلك ، ألق نظرة على هذا المثال . هذا هو معيار لبنية بيانات 0.3 كيلو بايت نرسلها ونستقبلها أولاً بالمؤشر ، ثم حسب القيمة. 0.3 كيلوبايت قليلاً - حول بنى البيانات المعتادة التي نعمل معها كل يوم تشغلها كثيرًا.

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

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

يمكن وضع متغير على الكومة أو المكدس:
  • يحتوي المكدس على المتغيرات الحالية لهذا البرنامج. بمجرد أن ترجع الدالة ، تظهر المتغيرات من المكدس.
  • يحتوي الكومة على متغيرات شائعة (متغيرات عمومية ، إلخ).

دعونا نلقي نظرة على مثال بسيط حيث نرجع قيمة:

 func getFooValue() foo { var result foo // Do something return result } 

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

 func getFooPointer() *foo { var result foo // Do something return &result } 

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

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

نص آخر لتمرير المؤشرات:

 func main() { p := &foo{} f(p) } 

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

على سبيل المثال ، بهذه الطريقة يتم الحصول على شريحة في طريقة القراءة لـ io.Reader. إرجاع شريحة (وهو مؤشر) يضعها في كومة.

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

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

أيضًا ، إذا كنا نعاني من مشكلات في الأداء ، فقد يكون أحد التحسينات الممكنة التحقق مما إذا كانت المؤشرات تساعد في مواقف معينة؟ ما إذا كان المترجم إخراج متغير إلى كومة الذاكرة المؤقتة يمكن العثور عليها باستخدام الأمر التالي:
 go build -gcflags "-m -m" 
.
ولكن ، مرة أخرى ، بالنسبة لمعظم مهامنا اليومية ، فإن استخدام القيم هو الأفضل.

إحباط / التبديل أو لـ / تحديد


ماذا يحدث في المثال التالي إذا كانت قيمة f صحيحة؟

 for { switch f() { case true: break case false: // Do something } } 

نحن ندعو كسر. هذا الفاصل هو الوحيد الذي يكسر المفتاح وليس الحلقة.

نفس المشكلة هنا:

 for { select { case <-ch: // Do something case <-ctx.Done(): break } } 

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

أحد الحلول الممكنة للمقاطعة لـ / switch أو لـ / select هو استخدام تسمية:

 loop: for { select { case <-ch: // Do something case <-ctx.Done(): break loop } } 

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


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

توفر المكتبة القياسية الحالية (قبل Go 1.13) وظائف فقط لبناء الأخطاء. لذلك ، سيكون من المثير للاهتمام إلقاء نظرة على حزمة pkg / error .

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

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

دعونا نلقي نظرة على مثال مع استدعاء REST يؤدي إلى خطأ في قاعدة البيانات:

 unable to server HTTP POST request for customer 1234 |_ unable to insert customer contract abcd |_ unable to commit transaction 

إذا استخدمنا pkg / error ، فيمكننا القيام بما يلي:

 func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} } return Status{ok: true} } func insert(contract Contract) error { err := dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } func dbQuery(contract Contract) error { // Do something then fail return errors.New("unable to commit transaction") } 

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

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

 func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { switch errors.Cause(err).(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } } return Status{ok: true} } func insert(contract Contract) error { err := db.dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } 

يتم ذلك باستخدام أخطاء. السبب ، والذي يتم تضمينه أيضًا في pkg / أخطاء :

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

 switch err.(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } 

في هذا المثال ، إذا تم التفاف db.DBError ، فلن يتم إجراء مكالمة ثانية أبدًا.

تهيئة شريحة


في بعض الأحيان نعلم ما سيكون الطول النهائي للشريحة. على سبيل المثال ، لنفترض أننا نريد تحويل شريحة Foo إلى شريحة Bar ، مما يعني أن هاتين الشريحتين سيكون لهما نفس الطول.

غالبًا ما صادف شرائح تمت تهيئتها بهذه الطريقة:

 var bars []Bar bars := make([]Bar, 0) 

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

الآن دعنا نتخيل أننا نحتاج إلى تكرار هذه العملية لزيادة الحجم عدة مرات ، لأن [] فو لدينا يحتوي على الآلاف من العناصر. سيظل تعقيد خوارزمية الإدراج هو O (1) ، ولكن هذا سيؤثر في الممارسة على الأداء.

لذلك ، إذا علمنا بالطول النهائي ، فيمكننا إما:

  • قم بتهيئتها بطول محدد مسبقًا:

 func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars } 

  • أو قم بتهيئته بطول 0 وسعة محددة مسبقًا:

 func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars } 

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

إدارة السياق


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

دعنا نحاول معرفة ذلك. السياق يمكن أن يحمل:
  • الموعد النهائي - يعني إما المدة (على سبيل المثال ، 250 مللي ثانية) أو وقت التاريخ (على سبيل المثال ، 2019-01-08 01:00:00) ، والتي نعتقد أنه إذا تم الوصول إليها ، فيجب إلغاء الإجراء الحالي (طلب إدخال / إخراج) ) ، في انتظار إدخال القناة ، وما إلى ذلك).
  • إلغاء الإشارة (أساسا <-chan struct {}). هنا السلوك مشابه. بمجرد أن نتلقى إشارة ، يجب أن نوقف العمل الحالي. على سبيل المثال ، لنفترض أننا حصلنا على طلبين. واحد لإدراج البيانات ، والآخر لإلغاء الطلب الأول (لأنه لم يعد ذا صلة ، على سبيل المثال). يمكن تحقيق ذلك باستخدام السياق الملغى في المكالمة الأولى ، والذي سيتم إلغاؤه بمجرد استلامنا للطلب الثاني.
  • قائمة المفتاح / القيمة (كلاهما يعتمد على نوع الواجهة {}).

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

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

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

لقد لاحظت أن هذا السياق قد تم نقله مباشرة ، على سبيل المثال ، عندما تم استدعاء نقطة نهاية gRPC. هذا ليس على الإطلاق ما نحتاجه.

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

لتحقيق ذلك ، يمكننا ببساطة إنشاء سياق مركب. إذا كان الأصل هو اسم سياق التطبيق (تم إنشاؤه بواسطة urfave / cli ) ، فيمكننا ببساطة القيام بذلك:

 ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond) response, err := grpcClient.Send(ctx, request) 

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

عدم استخدام الخيار -race


يعد اختبار تطبيق Go بدون خيار -race خطأً أواجهه باستمرار.

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

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

استخدام اسم الملف كمدخلات


خطأ شائع آخر هو تمرير اسم الملف إلى دالة.

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

 func count(filename string) (int, error) { file, err := os.Open(filename) if err != nil { return 0, errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() scanner := bufio.NewScanner(file) count := 0 for scanner.Scan() { if scanner.Text() == "" { count++ } } return count, nil } 

يتم تعيين اسم الملف كمدخل ، لذلك نحن فتحه ثم تنفيذ منطقنا ، أليس كذلك؟

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

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

Go يأتي مع اثنين من التجريدات الكبيرة: io.Reader و io.Writer. بدلاً من تمرير اسم الملف ، يمكننا ببساطة تمرير io.Reader ، والذي سوف يستخلص مصدر البيانات.
هل هذا ملف؟ هيئة HTTP؟ بايت العازلة؟ لا يهم ، لأننا سنواصل استخدام طريقة القراءة نفسها.

في حالتنا ، يمكننا حتى إدخال الإدخال المؤقت لقراءته سطرا سطرا. للقيام بذلك ، يمكنك استخدام bufio.Reader وطريقة ReadLine الخاصة به:

 func count(reader *bufio.Reader) (int, error) { count := 0 for { line, _, err := reader.ReadLine() if err != nil { switch err { default: return 0, errors.Wrapf(err, "unable to read") case io.EOF: return count, nil } } if len(line) == 0 { count++ } } } 

الآن تم تفويض مسؤولية فتح الملف إلى عميل count:

 file, err := os.Open(filename) if err != nil { return errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() count, err := count(bufio.NewReader(file)) 

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

 count, err := count(bufio.NewReader(strings.NewReader("input"))) 

Goroutines والمتغيرات دورة


آخر خطأ شائع التقيته كان عند استخدام goroutines مع متغيرات الحلقة.

ماذا سيكون استنتاج المثال التالي؟

 ints := []int{1, 2, 3} for _, i := range ints { go func() { fmt.Printf("%v\n", i) }() } 

1 2 3 عشوائيا؟ كلا.

في هذا المثال ، يستخدم كل goroutine نفس مثيل المتغير ، لذلك سينتج 3 3 (على الأرجح).

هناك حلان لهذه المشكلة. الأول هو تمرير قيمة المتغير i إلى الإغلاق (الوظيفة الداخلية):

 ints := []int{1, 2, 3} for _, i := range ints { go func(i int) { fmt.Printf("%v\n", i) }(i) } 

والثاني هو إنشاء متغير آخر داخل حلقة for:

 ints := []int{1, 2, 3} for _, i := range ints { i := i go func() { fmt.Printf("%v\n", i) }() } 

تعيين i: = قد أبدو غريبًا بعض الشيء ، ولكن هذا التصميم صالح تمامًا. أن تكون في حلقة يعني أن تكون في نطاق مختلف. لذلك ، i: = i تنشئ مثيلًا آخر للمتغير i. بالطبع ، يمكننا أن نسميها باسم مختلف لسهولة القراءة.

إذا كنت تعرف الأخطاء الشائعة الأخرى ، فلا تتردد في الكتابة عنها في التعليقات.

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


All Articles