معالجة الأخطاء
في Go، هناك ثلاثة مستويات من الاستثناءات:
error: خطأ في التدفق الطبيعي، يحتاج إلى معالجة، تجاهله مباشرة دون معالجة لن يؤدي إلى تعطل البرنامجpanic: مشكلة خطيرة جدًا، يجب أن يخرج البرنامج فورًا بعد معالجة المشكلةfatal: مشكلة قاتلة جدًا، يجب أن يخرج البرنامج فورًا
بدقة، لغة Go لا تحتوي على استثناءات، بل تعبر عنها من خلال الأخطاء. وبالمثل، لا توجد في Go جملة try-catch-finally، يأمل مؤسسو Go أن تكون الأخطاء قابلة للتحكم، ولا يرغبون في أن تتطلب كل مهمة تداخل مجموعة من try-catch، لذا في معظم الحالات تُعاد كقيمة إرجاع للدالة. مثل المثال التالي:
func main() {
// فتح ملف
if file, err := os.Open("README.txt"); err != nil {
fmt.Println(err)
return
}
fmt.Println(file.Name())
}الهدف من هذا الكود واضح جدًا، فتح ملف باسم README.txt، إذا فشل الفتح ستعيد الدالة خطأً، وتُخرج رسالة الخطأ، إذا كان الخطأ nil فهذا يعني نجاح الفتح، وتُخرج اسم الملف.
يبدو أنها أبسط من try-catch، لكن إذا كان هناك عدد كبير جدًا من استدعاءات الدوال، فستملأ الشيفرة عبارات if err != nil، مثل المثال التالي، وهو عرض توضيحي لحساب قيمة التجزئة للملف، في هذه الشيفرة الصغيرة ظهرت if err != nil ثلاث مرات.
func main() {
sum, err := checksum("main.go")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(sum)
}
func checksum(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
_, err = io.Copy(hash, file)
if err != nil {
return "", err
}
var hexSum [64]byte
sum := hash.Sum(nil)
hex.Encode(hexSum[:], sum)
return string(hexSum[:]), nil
}لهذا السبب، أكثر ما ينتقد في Go هو معالجة الأخطاء، ففي الشيفرة المصدرية لـ Go تشكل if err != nil نسبة كبيرة. Rust أيضًا يعيد قيم خطأ، لكن لا أحد ينتقدها في هذا الصدد، لأنها حلت هذه المشكلة من خلال السكر النحوي، وبالمقارنة مع Rust، السكر النحوي في Go ليس كثيرًا، بل يمكن القول إنه معدوم تقريبًا.
لكن يجب أن ننظر للأمور بشكل موضوعي، كل شيء له إيجابيات وسلبيات، لمعالجة الأخطاء في Go عدة مزايا:
- عبء ذهني صغير: إذا كان هناك خطأ نعالجه، إذا لم نعالجه نعيده
- قابلية القراءة: لأن طريقة المعالجة بسيطة جدًا، في معظم الحالات من السهل قراءة الكود
- سهولة التصحيح: كل خطأ ينتج من خلال القيمة المرجعة لاستدعاء دالة، يمكن العودة طبقة تلو الأخرى، نادرًا ما يظهر خطأ فجأة دون معرفة مصدره
لكن هناك عيوب كثيرة أيضًا:
- لا توجد معلومات تتبع في الخطأ (تحتاج لحل من حزم خارجية أو تغليف يدوي)
- قبح، تكرار في الكود (حسب تفضيل الشخصي)
- الأخطاء المخصصة تُعلن عبر
var، وهي متغير وليست ثابت (فعلاً لا ينبغي أن يكون كذلك) - مشكلة تغطية المتغيرات
لم تتوقف الاقتراحات والنقاشات في المجتمع حول معالجة الأخطاء في Go منذ نشأتها، وهناك نكتة تقول: إذا كنت تستطيع تقبل معالجة الأخطاء في Go، فأنت Gopher مؤهل.
TIP
هناك مقالتان من فريق Go حول معالجة الأخطاء، يمكنك الاطلاع عليهما إذا كنت مهتمًا
error
الخطأ هو نوع من أخطاء التدفق الطبيعي، ظهوره مقبول، وفي معظم الحالات يجب معالجته، وبالطبع يمكن تجاهله، ومستوى خطورة error لا يكفي لإيقاف تشغيل البرنامج بالكامل. error نفسها واجهة معرفة مسبقًا، وهذه الواجهة تحتوي على طريقة واحدة فقط Error()، والتي تُعيد سلسلة نصية لإخراج رسالة الخطأ.
type error interface {
Error() string
}مرّ error بتغييرات كبيرة تاريخيًا، في النسخة 1.13 قدم فريق Go الأخطاء المتسلسلة، ووفر آلية فحص أخطاء أكثر اكتمالًا، وسيتم تقديمها جميعًا.
الإنشاء
هناك عدة طرق لإنشاء error، الأولى استخدام الدالة New من حزمة errors.
err := errors.New("هذا خطأ")الثانية استخدام الدالة Errorf من حزمة fmt، للحصول على خطأ مع معاملات منسقة.
err := fmt.Errorf("هذا خطأ مع معامل منسق رقم %d", 1)فيما يلي مثال كامل:
func sumPositive(i, j int) (int, error) {
if i <= 0 || j <= 0 {
return -1, errors.New("يجب أن يكونا أعدادًا صحيحة موجبة")
}
return i + j, nil
}في معظم الحالات، لتحسين الصيانة، لا يتم إنشاء error بشكل مؤقت، بل تُستخدم الأخطاء الشائعة كمتغيرات عامة، مثل الكود المأخوذ من ملف os\errors.go:
var (
ErrInvalid = fs.ErrInvalid // "invalid argument"
ErrPermission = fs.ErrPermission // "permission denied"
ErrExist = fs.ErrExist // "file already exists"
ErrNotExist = fs.ErrNotExist // "file does not exist"
ErrClosed = fs.ErrClosed // "file already closed"
ErrNoDeadline = errNoDeadline() // "file type does not support deadline"
ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)يمكن ملاحظة أنها جميعها مُعرّفة كمتغيرات عبر var
أخطاء مخصصة
من خلال تطبيق الطريقة Error()، يمكن بسهولة تعريف error مخصص، مثل errorString في حزمة errors وهي تطبيق بسيط جدًا.
func New(text string) error {
return &errorString{text}
}
// بنية errorString
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}لأن تطبيق errorString بسيط جدًا، وقدرته على التعبير محدودة، فإن العديد من المكتبات مفتوحة المصدر والمكتبات الرسمية تختار تعريف error مخصص، لتلبية متطلبات الأخطاء المختلفة.
التمرير
في بعض الحالات، تستدعي الدالة دالة أخرى تعيد خطأً، لكن المتصل نفسه ليس مسؤولًا عن معالجة الخطأ، لذا يعيد الخطأ كقيمة إرجاع أيضًا، ويرميه للمتصل في المستوى الأعلى، هذه العملية تسمى التمرير. خلال عملية التمرير قد يتم تغليف الخطأ طبقة تلو طبقة، وعندما يريد المتصل في المستوى الأعلى الحكم على نوع الخطأ لاتخاذ إجراءات مختلفة، قد لا يتمكن من تمييز نوع الخطأ أو الحكم بشكل خاطئ، والأخطاء المتسلسلة ظهرت لحل هذه الحالة.
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}wrappError تطبق أيضًا واجهة error، ولديها طريقة إضافية Unwrap، لتعيد المرجع للخطأ الأصلي بداخلها، وتحت التغليف المتعدد تتشكل قائمة أخطاء مرتبطة، وبالبحث في القائمة للأعلى يمكن بسهولة العثور على الخطأ الأصلي. بما أن هذه البنية غير مكشوفة للخارج، فلا يمكن إنشاؤها إلا باستخدام الدالة fmt.Errorf، مثل:
err := errors.New("هذا خطأ أصلي")
wrapErr := fmt.Errorf("خطأ، %w", err)عند الاستخدام، يجب استخدام فعل التنسيق %w، والمعامل يجب أن يكون خطأً صالحًا واحدًا فقط.
المعالجة
الخطوة الأخيرة في معالجة الأخطاء هي كيفية التعامل معها والتحقق منها، حزمة errors توفر عدة دوال ملائمة لمعالجة الأخطاء.
func Unwrap(err error) errorالدالة errors.Unwrap() تُستخدم لفك تغليف سلسلة أخطاء، وتنفيذها الداخلي بسيط أيضًا:
func Unwrap(err error) error {
u, ok := err.(interface { // تأكيد النوع، هل يطبق هذه الطريقة
Unwrap() error
})
if !ok { // لم يطبق يعني أنه error أساسي
return nil
}
return u.Unwrap() // وإلا استدعاء Unwrap
}بعد فك التغليف ستعيد الخطأ الملفوف في سلسلة الأخطاء الحالية، والخطأ الملفوف قد يظل سلسلة أخطاء، إذا أردت إيجاد قيمة أو نوع معين في سلسلة الأخطاء، يمكن البحث بشكل متكرر، لكن المكتبة القياسية وفرت بالفعل دوالًا مشابهة.
func Is(err, target error) boolدالة errors.Is تتحقق مما إذا كانت سلسلة الأخطاء تحتوي على خطأ محدد، المثال التالي:
var originalErr = errors.New("هذا خطأ")
func wrap1() error { // تغليف الخطأ الأصلي
return fmt.Errorf("خطأ ملفوف %w", wrap2())
}
func wrap2() error { // الخطأ الأصلي
return originalErr
}
func main() {
err := wrap1()
if errors.Is(err, originalErr) { // إذا استُخدم if err == originalErr سيكون false
fmt.Println("original")
}
}لذا عند الحكم على الأخطاء، لا ينبغي استخدام عامل ==، بل استخدام errors.Is().
func As(err error, target any) boolدالة errors.As() تبحث في سلسلة الأخطاء عن أول خطأ من نوع مطابق، وتسند القيمة للـ err المُمرر. في بعض الحالات تحتاج لتحويل الخطأ من نوع error إلى نوع التنفيذ المحدد، للحصول على تفاصيل خطأ أكثر، واستخدام تأكيد النوع على سلسلة أخطاء غير صالح، لأن الخطأ الأصلي ملفوف داخل بنية، ولهذا السبب نحتاج الدالة As. المثال التالي:
type TimeError struct { // error مخصص
Msg string
Time time.Time // تسجيل وقت حدوث الخطأ
}
func (m TimeError) Error() string {
return m.Msg
}
func NewMyError(msg string) error {
return &TimeError{
Msg: msg,
Time: time.Now(),
}
}
func wrap1() error { // تغليف الخطأ الأصلي
return fmt.Errorf("خطأ ملفوف %w", wrap2())
}
func wrap2() error { // الخطأ الأصلي
return NewMyError("خطأ أصلي")
}
func main() {
var myerr *TimeError
err := wrap1()
// التحقق مما إذا كان في سلسلة الأخطاء خطأ من نوع *TimeError
if errors.As(err, &myerr) { // إخراج وقت TimeError
fmt.Println("original", myerr.Time)
}
}target يجب أن يكون مؤشرًا لـ error، بما أننا عند إنشاء البنية نعيد مؤشر بنية، فـ error في الواقع من النوع *TimeError، إذن target يجب أن يكون من النوع **TimeError.
لكن حزمة errors الرسمية غير كافية في الواقع، لأنها لا تحتوي على معلومات تتبع، ولا يمكن تحديد الموقع، لذا يُنصح عادةً باستخدام حزمة محسّنة رسمية أخرى:
github.com/pkg/errorsمثال:
import (
"fmt"
"github.com/pkg/errors"
)
func Do() error {
return errors.New("خطأ")
}
func main() {
if err := Do(); err != nil {
fmt.Printf("%+v", err)
}
}الإخراج:
some unexpected error happened
main.Do
D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650من خلال الإخراج المنسق، يمكن رؤية معلومات التتبع، بشكل افتراضي لا يُخرج التتبع. هذه الحزمة تعتبر نسخة محسّنة من حزمة errors القياسية، كلاهما من نفس الكتاب الرسميين، لكن لا يُعرف لماذا لم تُدمج في المكتبة القياسية.
panic
panic تُرجمت كذعر، وتعني مشكلة خطيرة جدًا في البرنامج، يحتاج البرنامج لإيقاف فوري لمعالجة المشكلة، وإلا يتوقف البرنامج فورًا عن العمل ويُخرج معلومات التتبع. panic هي شكل الاستثناء في وقت التشغيل في Go، وتظهر عادةً في العمليات الخطرة، وهدفها الأساسي هو الحد من الخسائر لتجنب عواقب أكثر خطورة. لكن panic قبل الخروج يقوم بعمل الترتيبات اللازمة للبرنامج، كما يمكن استعادة panic لضمان استمرار تشغيل البرنامج.
المثال التالي لكتابة قيمة إلى map قيمته nil، سيؤكد حدوث panic:
func main() {
var dic map[string]int
dic["a"] = 'a'
}panic: assignment to entry in nil mapTIP
عندما يكون هناك عدة كوروتينات في البرنامج، إذا حدث panic في أي كوروتين ولم يتم التقاطه، سيتعطل البرنامج بأكمله
الإنشاء
إنشاء panic صريحًا بسيط جدًا، استخدم الدالة المدمجة panic، توقيع الدالة كالتالي:
func panic(v any)الدالة panic تأخذ معاملاً واحدًا من النوع any اسمه v، عند إخراج معلومات تتبع الخطأ، سيُخرج v أيضًا. مثال الاستخدام:
func main() {
initDataBase("", 0)
}
func initDataBase(host string, port int) {
if len(host) == 0 || port == 0 {
panic("معاملات اتصال قاعدة بيانات غير صالحة")
}
// ... منطق آخر
}عند فشل تهيئة اتصال قاعدة البيانات، لا ينبغي تشغيل البرنامج، لأنه بدون قاعدة بيانات لا معنى لتشغيل البرنامج، لذا يجب هنا إطلاق panic:
panic: معاملات اتصال قاعدة بيانات غير صالحةالترتيبات
قبل خروج البرنامج بسبب panic، سيقوم ببعض أعمال الترتيب، مثل تنفيذ جمل defer.
func main() {
defer fmt.Println("A")
defer fmt.Println("B")
fmt.Println("C")
panic("panic")
defer fmt.Println("D")
}الإخراج:
C
B
A
panic: panicوجمل defer في الدوال العليا ستُنفذ أيضًا، المثال التالي:
func main() {
defer fmt.Println("A")
defer fmt.Println("B")
fmt.Println("C")
dangerOp()
defer fmt.Println("D")
}
func dangerOp() {
defer fmt.Println(1)
defer fmt.Println(2)
panic("panic")
defer fmt.Println(3)
}الإخراج:
C
2
1
B
A
panic: panicيمكن أيضًا تداخل panic داخل defer، المثال التالي أكثر تعقيدًا:
func main() {
defer fmt.Println("A")
defer func() {
func() {
panic("panicA")
defer fmt.Println("E")
}()
}()
fmt.Println("C")
dangerOp()
defer fmt.Println("D")
}
func dangerOp() {
defer fmt.Println(1)
defer fmt.Println(2)
panic("panicB")
defer fmt.Println(3)
}ترتيب تنفيذ panic المتداخل داخل defer يظل كما هو، عند حدوث panic لن يُنفذ المنطق اللاحق.
C
2
1
A
panic: panicB
panic: panicAباختصار، عند حدوث panic، تخرج الدالة الحالية فورًا، وتنفذ أعمال الترتيب في الدالة الحالية مثل defer، ثم تُمرر للأعلى طبقة تلو الأخرى، والدوال العليا تنفذ أيضًا أعمال الترتيب، حتى يتوقف البرنامج عن العمل.
عند حدوث panic في كوروتين فرعي، لن تُطلق أعمال الترتيب في الكوروتين الحالي، وإذا لم يُسترجع panic حتى خروج الكوروتين الفرعي، سيتوقف البرنامج مباشرة عن العمل.
var waitGroup sync.WaitGroup
func main() {
demo()
}
func demo() {
waitGroup.Add(1)
defer func() {
fmt.Println("A")
}()
fmt.Println("C")
go dangerOp()
waitGroup.Wait() // الكوروتين الأب ينتظر محجوبًا حتى ينتهي الكوروتين الفرعي
defer fmt.Println("D")
}
func dangerOp() {
defer fmt.Println(1)
defer fmt.Println(2)
panic("panicB")
defer fmt.Println(3)
waitGroup.Done()
}الإخراج:
C
2
1
panic: panicBيمكن ملاحظة أن جمل defer في demo() لم تُنفذ أي منها، وخرج البرنامج مباشرة. يجب الانتباه إلى أنه إذا لم يكن هناك waitGroup لحجب الكوروتين الأب، فإن سرعة تنفيذ demo() قد تكون أسرع من سرعة تنفيذ الكوروتين الفرعي، ونتيجة الإخراج ستكون مضللة جدًا، لنعدّل الكود قليلًا:
func main() {
demo()
}
func demo() {
defer func() {
// أعمال ترتيب الكوروتين الأب تستغرق 20ms
time.Sleep(time.Millisecond * 20)
fmt.Println("A")
}()
fmt.Println("C")
go dangerOp()
defer fmt.Println("D")
}
func dangerOp() {
// الكوروتين الفرعي ينفذ بعض المنطق، يستغرق 1ms
time.Sleep(time.Millisecond)
defer fmt.Println(1)
defer fmt.Println(2)
panic("panicB")
defer fmt.Println(3)
}الإخراج:
C
D
2
1
panic: panicBفي هذا المثال، عند حدوث panic في الكوروتين الفرعي، كان الكوروتين الأب قد أنهى تنفيذ الدالة بالفعل، ودخل في أعمال الترتيب، وأثناء تنفيذ آخر defer، صادف حدوث panic في الكوروتين الفرعي، لذا خرج البرنامج مباشرة.
الاستعادة
عند حدوث panic، يمكن استخدام الدالة المدمجة recover() لمعالجته في الوقت المناسب وضمان استمرار تشغيل البرنامج، يجب تشغيلها داخل جملة defer، مثال الاستخدام:
func main() {
dangerOp()
fmt.Println("خرج البرنامج بشكل طبيعي")
}
func dangerOp() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("تم استرجاع panic")
}
}()
panic("حدث panic")
}المتصل لا يعرف أصلًا أن حدث panic داخل الدالة dangerOp()، البرنامج يُنفذ المنطق المتبقي ثم يخرج بشكل طبيعي، لذا الإخراج:
حدث panic
تم استرجاع panic
خرج البرنامج بشكل طبيعيلكن في الواقع استخدام recover() يحتوي على العديد من الفخاخ الخفية. مثل استخدام recover في إغلاق داخل defer.
func main() {
dangerOp()
fmt.Println("خرج البرنامج بشكل طبيعي")
}
func dangerOp() {
defer func() {
func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("تم استرجاع panic")
}
}()
}()
panic("حدث panic")
}الدالة المغلقة يمكن اعتبارها استدعاء دالة، panic يُمرر للأعلى وليس للأسفل، وبطبيعة الحال الدالة المغلقة لا يمكنها استرجاع panic، لذا الإخراج:
panic: حدث panicبالإضافة إلى ذلك، هناك حالة شديدة جدًا، وهي أن يكون معامل panic() هو nil.
func main() {
dangerOp()
fmt.Println("خرج البرنامج بشكل طبيعي")
}
func dangerOp() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("تم استرجاع panic")
}
}()
panic(nil)
}في هذه الحالة panic سيُسترجع بالفعل، لكن لن يُخرج أي معلومات خطأ.
الإخراج:
خرج البرنامج بشكل طبيعيبشكل عام، للدالة recover عدة نقاط يجب الانتباه لها:
- يجب استخدامها داخل
defer - استخدامها عدة مرات لن يكون هناك إلا واحد قادر على استرجاع
panic recoverفي إغلاق لن يسترجع أيpanicمن الدالة الخارجية- يُمنع استخدام
nilكمعامل لـpanic
fatal
fatal هو مشكلة شديدة الخطورة، عند حدوث fatal، يحتاج البرنامج للتوقف فورًا عن العمل، ولن يُنفذ أي أعمال ترتيب، عادةً ما يكون ذلك باستدعاء الدالة Exit من حزمة os للخروج من البرنامج، كما يلي:
func main() {
dangerOp("")
}
func dangerOp(str string) {
if len(str) == 0 {
fmt.Println("fatal")
os.Exit(1)
}
fmt.Println("منطق طبيعي")
}الإخراج:
fatalمشكلات مستوى fatal نادرًا ما يتم إطلاقها صراحةً، في معظم الحالات تكون مُطلقة بشكل سلبي.
