مواصفات معالجة أخطاء Golang

أنواع الأخطاء

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

  • هل يحتاج المُستدعي إلى مطابقة الخطأ لمعالجته؟ إذا كان الأمر كذلك، يجب علينا دعم errors.Is أو errors.As عن طريق إعلان متغيرات الأخطاء على المستوى العلوي أو أنواع مخصصة.
  • هل رسالة الخطأ هي سلسلة نصية ثابتة أم سلسلة ديناميكية تتطلب معلومات سياقية؟ بالنسبة للسلاسل الثابتة، يمكننا استخدام errors.New، ولكن بالنسبة للأخيرة، يجب علينا استخدام fmt.Errorf أو نوع أخطاء مخصص.
  • هل نقوم بتمرير أخطاء جديدة تعود بها وظائف التابعة؟ إذا كان الأمر كذلك، يُرجى الرجوع إلى قسم تجلي الأخطاء.
مطابقة الخطأ؟ رسالة الخطأ التوجيه
لا ثابتة errors.New
لا ديناميكية fmt.Errorf
نعم ثابتة متغير var على المستوى العلوي مع errors.New
نعم ديناميكية نوع error مخصص

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

لا مطابقة الخطأ

// package foo

func Open() error {
  return errors.New("could not open")
}

// package bar

if err := foo.Open(); err != nil {
  // لا يمكن معالجة الخطأ.
  panic("خطأ غير معروف")
}

مطابقة الخطأ

// package foo

var ErrCouldNotOpen = errors.New("could not open")

func Open() error {
  return ErrCouldNotOpen
}

// package bar

if err := foo.Open(); err != nil {
  if errors.Is(err, foo.ErrCouldNotOpen) {
    // معالجة الخطأ
  } else {
    panic("خطأ غير معروف")
  }
}

بالنسبة للأخطاء ذات السلاسل الديناميكية، استخدم fmt.Errorf إذا لم يكن المستدعي بحاجة لمطابقتها. إذا كان المستدعي فعلاً بحاجة لمطابقتها، فاستخدم نوع error مخصص.

لا مطابقة الخطأ

// package foo

func Open(file string) error {
  return fmt.Errorf("الملف %q غير موجود", file)
}

// package bar

if err := foo.Open("testfile.txt"); err != nil {
  // لا يمكن معالجة الخطأ
  panic("خطأ غير معروف")
}

مطابقة الخطأ

// package foo

type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("الملف %q غير موجود", e.File)
}

func Open(file string) error {
  return &NotFoundError{File: file}
}


// package bar

if err := foo.Open("testfile.txt"); err != nil {
  var notFound *NotFoundError
  if errors.As(err, &notFound) {
    // معالجة الخطأ
  } else {
    panic("خطأ غير معروف")
  }
}

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

تغليف الأخطاء

عند حدوث خطأ أثناء استدعاء طريقة أخرى، عادة ما تكون هناك ثلاث طرق للتعامل معها:

  • إعادة الخطأ الأصلي كما هو.
  • استخدام fmt.Errorf مع %w لإضافة سياق للخطأ ثم إعادته.
  • استخدام fmt.Errorf مع %v لإضافة سياق للخطأ ثم إعادته.

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

وإلا، يجب إضافة سياق لرسالة الخطأ قدر الإمكان حتى لا تحدث أخطاء غامضة مثل "connection refused". بدلاً من ذلك، ستتلقى أخطاء أكثر فائدة، مثل "calling service foo: connection refused".

استخدم fmt.Errorf لإضافة سياق إلى أخطاءك واختر بين أفعال %w أو %v استنادًا إلى ما إذا كان يجب على المستدعي أن يتمكن من مطابقة واستخراج السبب الجذري.

  • استخدم %w إذا كان يجب على المستدعي الوصول إلى الخطأ الأساسي. هذا هو الافتراضي الجيد لمعظم أخطاء التغليف، ولكن كن على علم بأن المستدعي قد يبدأ في الاعتماد على هذا السلوك. لذا، لأخطاء التغليف المعروفة المتغيرة أو الأنواع، سجِّلها واختبرها كجزء من عقد الوظيفة.
  • استخدم %v لإخفاء الخطأ الأساسي. لن يتمكن المستدعي من مطابقته، ولكن يمكنك التبديل إلى %w في المستقبل إذا لزم الأمر.

عند إضافة سياق إلى الخطأ المُرجَع، تجنب استخدام عبارات مثل "failed to" للحفاظ على السياق بشكل صريح. عندما يتخلل الخطأ من خلال الكود، سيكون مكدسًا طبقة بعد طبقة:

غير موصى به:

s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "failed to create new store: %w", err)
}

// فشل في x: فشل في y: فشل في إنشاء متجر جديد: الخطأ

موصى به:

s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "متجر جديد: %w", err)
}
// x: y: متجر جديد: الخطأ

ومع ذلك، عندما يتم إرسال الخطأ إلى نظام آخر، يجب أن يكون من الواضح أن الرسالة هي خطأ (على سبيل المثال، علامة "err" أو بادئة "Failed" في السجلات).

تسميات غير صحيحة

بالنسبة لقيم الأخطاء المُخزَّنة كمتغيرات عالمية، استخدم البادئة Err أو err استنادًا إلى ما إذا كانت مُصدرة. يرجى الرجوع إلى الإرشادات. بالنسبة للثوابت والمتغيرات غير المصدرة في الأعلى، استخدم شرطة سفلية (_) كبادئة.

var (
  // قم بتصدير الخطأين التاليين بحيث يمكن لمستخدمي هذه الحزمة مطابقتهم مع errors.Is.
  ErrBrokenLink = errors.New("الرابط مكسور")
  ErrCouldNotOpen = errors.New("تعذّر فتح")

  // لا يتم تصدير هذا الخطأ لأننا لا نرغب في أن يكون جزءًا من واجهة برمجيةنا العامة. قد نستخدمه لاحقًا داخل الحزمة باستخدام errors.
  errNotFound = errors.New("لم يتم العثور")
)

بالنسبة لأنواع الأخطاء المخصصة، استخدم اللاحقة Error.

// بالمثل، يتم تصدير هذا الخطأ ليمكن لمستخدمي هذه الحزمة مطابقته مع errors.As.
type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("الملف %q غير موجود", e.File)
}

// لا يتم تصدير هذا الخطأ لأننا لا نرغب في أن يكون جزءًا من واجهة برمجية عامة. يمكننا استخدامه لاحقًا داخل الحزمة باستخدام errors.As.
type resolveError struct {
  Path string
}

func (e *resolveError) Error() string {
  return fmt.Sprintf("حل %q", e.Path)
}

التعامل مع الأخطاء

عندما يتلقى المتصل خطأً من المستدعى، يمكنه التعامل مع الخطأ بطرق مختلفة استنادًا إلى فهم الخطأ.

من ضمن هذه الطرق ولكن لا تقتصر عليها:

  • مطابقة الخطأ بـ errors.Is أو errors.As إذا اتفق المستدعى على تعريف خطأ محدد، والتعامل مع التفرعات بطرق مختلفة
  • تسجيل الخطأ والتحول بشكل مهذب إذا كان الخطأ قابلاً للاسترداد
  • إرجاع خطأ محدد جيد التعريف إذا كان يمثل حالة فشل محددة للمجال
  • إرجاع الخطأ، سواءً كانت ملفوفة أو كما هي

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

على سبيل المثال، ننظر إلى السيناريوات التالية:

سيئ: تسجيل الخطأ وإعادته

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

u, err := getUser(id)
if err != nil {
  // سيئ: راجع الوصف
  log.Printf("تعذر الحصول على المستخدم %q: %v", id, err)
  return err
}

جيد: لف الخطأ وإعادته

ستتعامل الأخطاء في الأجزاء العليا من الكود مع هذا الخطأ. باستخدام %w يضمن أن بإمكانهم مطابقة الخطأ بـ errors.Is أو errors.As إذا كان ذلك ذو صلة.

u, err := getUser(id)
if err != nil {
  return fmt.Errorf("الحصول على المستخدم %q: %w", id, err)
}

جيد: تسجيل الخطأ والتحول بشكل مهذب

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

if err := emitMetrics(); err != nil {
  // يجب ألا يتسبب فشل كتابة البيانات الخاصة بالمقاييس في توقف التطبيق.
  log.Printf("تعذر بث البيانات الخاصة بالمقاييس: %v", err)
}

جيد: مطابقة الخطأ والتحول بشكل مهذب بشكل مناسب

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

tz, err := getUserTimeZone(id)
if err != nil {
  if errors.Is(err, ErrUserNotFound) {
    // المستخدم غير موجود. استخدم التوقيت العالمي المنسق.
    tz = time.UTC
  } else {
    return fmt.Errorf("الحصول على المستخدم %q: %w", id, err)
  }
}

التعامل مع فشل التأكيد

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

غير مستحسن:

t := i.(string)

مستحسن:

t, ok := i.(string)
if !ok {
  // قم بالتعامل مع الخطأ بشكل مهذب
}

تجنب استخدام الإنهيار

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

غير مُستحسن:

func run(args []string) {
  if len(args) == 0 {
    panic("يجب تقديم argument")
  }
  // ...
}

func main() {
  run(os.Args[1:])
}

مُستحسن:

func run(args []string) error {
  if len(args) == 0 {
    return errors.New("يجب تقديم argument")
  }
  // ...
  return nil
}

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

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

var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))

حتى في الكود الاختباري، من المفضل استخدام t.Fatal أو t.FailNow بدلاً من الإنهيار لضمان تسجيل الفشل.

غير مُستحسن:

// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  panic("فشل في إعداد الاختبار")
}

مُستحسن:

// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  t.Fatal("فشل في إعداد الاختبار")
}