Get a quote

معالجة الأخطاء في Go للأنظمة الإنتاجية: التغليف والسياق والتكامل مع Sentry

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

معالجة الأخطاء في Go تبدو بسيطة في البداية. لديك errors.New وfmt.Errorf وهذا كل شيء تقريباً. لكن عندما تدير نظام SaaS في بيئة الإنتاج يخدم عملاء في لبنان والسعودية والإمارات، ويمر بمئات الطلبات في الثانية، فإن رسالة مثل "something went wrong" تصبح عديمة الفائدة تماماً.

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

المشكلة مع الأخطاء البسيطة في الإنتاج

لنأخذ هذا المثال الشائع:

var ErrNotFound = errors.New("not found")

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

fmt.Errorf مع %w أفضل لأنه يحافظ على سلسلة الأخطاء لاستخدامها مع errors.Is وerrors.As:

return fmt.Errorf("getUser: tenant %s: %w", tenantID, err)

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

تغليف الأخطاء بالسياق في كل طبقة

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

func (r *UserRepository) GetByID(ctx context.Context, tenantID, userID string) (*User, error) {
    var user User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, email FROM users WHERE id = $1 AND tenant_id = $2`,
        userID, tenantID,
    ).Scan(&user.ID, &user.Email)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("UserRepository.GetByID: tenant=%s user=%s: %w",
                tenantID, userID, ErrNotFound)
        }
        return nil, fmt.Errorf("UserRepository.GetByID: tenant=%s user=%s: %w",
            tenantID, userID, err)
    }
    return &user, nil
}

عندما يصل الخطأ إلى معالج HTTP، يكون النص:

UserService.GetUser: UserRepository.GetByID: tenant=acme user=usr_123: not found

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

نوع خطأ مخصص للأنظمة الإنتاجية

الأنماط المُغلفة بداية جيدة. النوع المخصص هو الخطوة الفعلية التالية:

type AppError struct {
    Code       string
    Message    string
    Internal   string
    HTTPStatus int
    Err        error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %s: %v", e.Code, e.Internal, e.Err)
    }
    return fmt.Sprintf("%s: %s", e.Code, e.Internal)
}

func (e *AppError) Unwrap() error {
    return e.Err
}

حقل Code مهم بشكل خاص للتطبيقات المبنية فوق API. يمكن للواجهة الأمامية أن تتحقق من قيمة "billing.insufficient_funds" وتعرض النافذة المناسبة، بدلاً من محاولة تحليل نص رسالة قد يتغير.

التكامل مع Sentry دون إغراقه بالضوضاء

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

النمط الصحيح: وسيط HTTP يلتقط الأخطاء بشكل انتقائي:

func captureIfNeeded(ctx context.Context, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        if appErr.HTTPStatus >= 400 && appErr.HTTPStatus < 500 {
            return
        }
    }
    hub := sentry.GetHubFromContext(ctx)
    if hub != nil {
        hub.CaptureException(err)
    }
}

هذا يصفي أخطاء 404 و401 و403 و422. Sentry يتلقى فقط أخطاء 5xx والحالات غير المتوقعة. في منطقة الشرق الأوسط وشمال أفريقيا حيث يكون اتصال الإنترنت متقطعاً أحياناً وسلوك إعادة المحاولة شائعاً على العملاء المحمولين، هذا التصفية بالغ الأهمية لمنع فيضان Sentry بأخطاء المهلة العابرة التي ليست أخطاء حقيقية في الكود.

أضف دائماً معرف المستأجر ومعرف الطلب كعلامات في كل حدث:

hub.Scope().SetTag("tenant_id", claims.TenantID)
hub.Scope().SetTag("request_id", r.Header.Get("X-Request-ID"))

الأخطاء الشائعة على نطاق واسع

ابتلاع الأخطاء في goroutines: أخطر نمط موجود. goroutine تفشل بصمت وتترك النظام في حالة غير متسقة:

// خطأ: الخطأ يُسقط بصمت
go func() {
    if err := sendNotification(ctx, userID); err != nil {
        // لا شيء
    }
}()

// صحيح: سجل الخطأ والتقطه
go func() {
    if err := sendNotification(ctx, userID); err != nil {
        log.Error("sendNotification failed", "user_id", userID, "error", err)
        captureIfNeeded(ctx, err)
    }
}()

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

استخدام panic للحالات غير الحرجة: panic مخصصة للأخطاء البرمجية غير القابلة للاسترداد. ليست لـ "المستخدم غير موجود" أو "بوابة الدفع أعادت 402". panic في goroutine ستتسبب في تعطل العملية بأكملها.

الدروس المستفادة من الإنتاج

  • غلف كل خطأ باسم العملية والمعرفات ذات الصلة قبل إعادته.
  • أنشئ نوع AppError مخصصاً مبكراً: رمز HTTP ورسالة المستخدم والتفاصيل الداخلية والرمز القابل للقراءة آلياً في بنية واحدة.
  • صفّ Sentry: أخطاء 4xx هي سلوك المستخدم وليست أخطاء برمجية. أرسل فقط 5xx والحالات غير المتوقعة.
  • سجل مرة واحدة عند الحد الخارجي، وليس في كل طبقة.
  • لا تبتلع الأخطاء أبداً في goroutines.
  • لا تستخدم panic لمنطق العمل.

هل تبني خدمة Go وتريد بناء طبقة الأخطاء والمراقبة بشكل صحيح منذ البداية؟ تواصل مع فريق Voxire الهندسي: https://voxire.com/get-a-quote/

العودة إلى المدونة
Chat on WhatsApp