Software Engineer - Data Enthusiast
Let's Go: Error Handling
چند وقت اخیر در بله، در پروژههایی کار میکنم که با Golang توسعه داده شدهاند. من قبل از این هیچ تجربهای از Go نداشتم و احساس نیاز میکنم تا دانشم را دربارهاش عمیقتر کنم و از این حالت Stackoverflowای که الان در آن هستم خارج شوم. برای اینکه به این مسئله نظم بدهم و بتوانم تمرکز بیشتری داشته باشم، تصمیم گرفتم که چند پست و مطلب در حین این پروسه و دربارهی نکتههایی که میخوانم و یاد میگیرم بنویسم. این متن هم به همین دلیل نوشته شده است.
بعضی از کدها و مثالها را، که شاید بخواهید دقیقتر ببینید، در پلیگروند به اشتراک گذاشتهام. هرچند ممکن است آنجا ران نشوند (مثلاً io و http نیاز دارند)، ولی خب میتوانید خودتان کپی و اجرایشان کنید.
فهرست منابع و مقالههای اصلی هم در انتهای مطلب هست. اگر علاقهمند بودید، حتماً بخوانیدشان.
توی این مطلب دربارهی ارورها در گولنگ صحبت میکنیم که کلاً چه هستند و چطور میشود بهتر مدیریتشان (هندل) کرد تا کد تمیزتر و بدون تکراری داشته باشیم.
چند فرق عمده بین گولنگ با زبانهای معروف دیگر وجود دارد که باعث میشود تا هرکس اول باهاش آشنا میشود کمی تعجب کند، خوب نتواند باهاش ارتباط بگیرد و با خودش فکر کند: «چرا؟» (مثل نداشتن جنریک)
ولی وقتی به این تفاوتها عادت کنی، متوجه میشوی که بیدلیل هم نیستند و خوبیهایی هم دارند. (بهجز نبود جنریک) یکی از این تفاوتها نحوهی کار با Errorها و مدیریت آنهاست.
توی Go بهجای اینکه خطاها مثل اکثر زبانهای دیگر throw بشوند، تا شاید یک جایی (implicitly) catch بشوند، مثل یک متغیر ساده هستند که توابع میتوانند آنها را برگردانند و صدازنندهی تابع باید این مقدار را هم مثل باقی مقادیر تابع بررسی کند. همین مسئله باعث میشود تا توی کدی که با Go نوشته میشود پر از تکهکدهایی مثل این باشد
اوایل، این مسئله من را خیلی اذیت میکرد. احساس میکردم بیوقفه دارم یک کد boilerplate را تکرار میکنم. مثلاً یک تابع ممکن است اینطوری بشود:
_, err = fd.Write(p1[c:d])
if err != nil {
return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
return err
}
_, err = fd.Write(p2[a:b])
if err != nil {
return err
}
که خب مسلماً خیلی خوب نیست. به همین دلیل، یکی از آپشنهایی که برای اضافه شدن به ورژنهای بعدی Go دربارهاش بحث میشود همین مسئله است که چطور میتوانیم این مقدار boilerplate را کمتر کنیم. مثل این issue و این یکی issue که پیشنهادهایی برای تغییر syntax مدیریت ارور یا بهاصطلاح sugar coat کردنش هستند.
اما خیلیها هم هستند که به نظرشان این نوع مدیریت خطا و سینتکس اصلاً بد نیست و خیلی هم خوب است، مثل ایشان و دوستانش. چرا؟ دلایل مختلفی دارد. اصلیترین و اولین دلیلش شاید این است: همهچیز واضحست! تا امضای یک تابع را ببینی، درجا متوجه میشوی که این تابع ممکن است مقدار درست را برنگرداند و به مشکل بخورد. پس حتماً اول ارور را بررسی میکنی. اگر کد تابع صدازنندهاش را هم ببینی، خیلی سریع متوجه میشوی که اگر یک جای روندش خطایی پیش بیاید چطوری مدیریتش میکند.
دلیل دیگری که میتوان به آن اشاره کرد این است که یکی از فلسفههای اصلی گو این است که برای هر کاری فقط یک روش وجود داشته باشد تا کدها شبیه هم بشوند (چه کسی بود صدا زد جاوااسکریپت؟). مثلاً برای همین است که یک مدل حلقهی for در go داریم. با sugar coat کردن این سینتکس، به این هدف پشت شده است.
به طور کلی نیز، این مقاله عمیقتر و دقیقتر مشکلهایی را که exception در زبانهایی مثل c++ و جاوا دارد گفته و توضیح داده است که چرا به نظرش روش گولنگ بهتر است. اگر علاقهمندید، حتماً بخوانید. خلاصهاش میشود اینکه در c++ که کلاً ما هیچوقت بهسادگی نمیتوانستیم بفهمیم آیا یک تابع ممکن است خطا بدهد یا نه. طراحان جاوا این را هندل کردند و throw کردن ارور را به امضای تابع اضافه کردند، ولی باز هم مشکلاتی وجود دارد. مثلاً هر طرف را نگاه کنی، یک Exception دارد throw میشود. یعنی در عمل دیگر exception (بهمعنای استثنا در انگلیسی) نیست و به روتین برنامهنویسی تبدیل میشود. حالا کدام exception خیلی مهم و حیاتی و خطرناک است و کدام یکی صرفاً برای چک کردن یک حالت خاص است، خدا داند.
این در کنار استثناهایی مثل RunTimeException که مثل دیگر خطاها نیازی نیست در امضای تابع تعریف بشود، یعنی شاید جاوا وضعیت را بهتر کرد، ولی باز هم مشکلاتی دارد
توی این پست، اول کمی با هم میبینیم که کلاً ارورها چی هستند و چطوریاند. بعد کمی از best practiceهایی را که بهتر است موقع ساخت آنها یا مواجهه باهاشان رعایت کنیم بررسی میکنیم. در انتها هم، سعی میکنیم دربارهی مشکل verbosity و repetitive بودنشان راهحل ارائه بدهیم.
ارور در Go
در گولنگ، ارور در واقع یک interface بسیار ساده است.
پرکاربردترین پیادهسازی از این اینترفیس هم احتمالاً مربوط به پکیج errors است که با استفاده از errors.New یک ارور جدید برای ما درست میکند. این کلاس یک پیادهسازی به نام errorString از این اینترفیس دارد.
روش دیگری که میشود ارور ساخت استفاده از fmt.Errorf است که مثل بقیهی fmt.folanFها استرینگ فرمتینگ هم میکند و با متن دادهشده بهعنوان ورودی برایمان یک ارور میسازد.
ساختن یک نوع ارور خاص هم به همین سادگی است. در واقع، به هر نوع تایپی میشود تابع Error را اضافه کرد و بهعنوان ارور برگرداندش. اینطوری تابع صدازننده هم میتواند با Type Assertion نوع ارور را بررسی کند و باتوجهبه آن نوع تصمیم بگیرد. بیایید با یک مثال ببینیم چطوری میشود.
اول میآییم یک استراکت جدید تعریف میکنیم و برایش تابع Error را (که مال اینترفیس error بود) بهعنوان متد پیادهسازی میکنیم. دقت کنید که این ارور ما فیلدهای دیگری هم دارد. یعنی هر دیتای اضافیای هم که بخواهیم میتوانیم توی ارورمان ذخیره کنیم.
type NegativeSqrtError struct {
msg string
value float64
}
func (e *NegativeSqrtError) Error() string {
return e.msg
}
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, &NegativeSqrtError{msg: "math: sqrt negative error", value: f}
}
return 0, nil
}
در نهایت تابعی که دارد از یک کدی که این ارور را برمیگرداند استفاده میکند میتواند هم این نوع از ارور را بهطور بهخصوص هندل کند و هم از اطلاعات اضافهای که توی تایپ ارورمان گذاشتهایم (اینجا value و msg) استفاده کند (این مثال را میتوانید توی پلیگروند ببینید).
res, err := Sqrt(-1)
if err != nil {
if negativeError, ok := err.(*NegativeSqrtError); ok {
fmt.Printf("Oh no! error is of type NegativeSqrtError for value: %g - msg: %v",
NegativeError.value, negativeError)
} else {
fmt.Printf("Oh no! unknown error - %v", err)
}
} else {
fmt.Printf("result: %g", res)
}
خب حالا بهطورکلی دیدیم ارور تو گولنگ چطوری است. برویم ببینیم چه پیشنهادها و best practice برای کار با ارورها وجود دارند.
اصلا نترس
نقل قول و سخن بزرگان مهمی در گولنگ وجود دارد که به همین نکته اشاره میکند:
اصلا نترس
گولنگ مفهوم و تابعی دارد به نام panic. وقتی تابع پنیک صدا زده بشود، مثل throw عمل میکند و روند اجرای عادی برنامه متوقف میشود و اگر جایی این پنیک recover نشود، برنامه کلاً متوقف میشود و یک پیغام خطای طولانی و ترسناک به همراه استکتریس برمیگرداند.
از روشهایی که گاهی برای مدیریت خطا در پروژهها دیده میشود همین پنیک کردن و بعد ریکاور کردنش است (اینکه چطور این اتفاق پیادهسازی میشود از حوصلهی این متن خارج است. اینجا میتوانید راجع به آن بیشتر بخوانید).
این را همین اول آوردم که دیگر هی توی دلتان نگویید این چرتوپرتها چیست بابا. از پنیک و ریکاور میشود مثل try و catch استفاده کرد. اما سخت در اشتباهید! پنیک کردن توی گولنگ قرار نیست روش مدیریت خطا باشد (هرچند شاید عملاً ممکن باشد)، به چند دلیل:
این را همین اول آوردم که دیگر هی توی دلتان نگویید این چرتوپرتها چیست بابا. از پنیک و ریکاور میشود مثل try و catch استفاده کرد. اما سخت در اشتباهید! پنیک کردن توی گولنگ قرار نیست روش مدیریت خطا باشد (هرچند شاید عملاً ممکن باشد)، به چند دلیل:
- اصلاً panic اسمش رویش است. مال وقتی است که برنامه پنیک کرده و نمیداند چیکار کند. چارهای ندارد دیگر! اروری را که ایجاد شده کلاً نمیشود هندل کرد. نه اینکه یک تابع low level و api پنیک کند.
- فرض کنید یک http api دارید و صدها گوروتین دارند جواب کاربرهایتان را میدهند، یکیشان به خطا میخورد، آیا باید پنیک کند و کل برنامه را با همهی گو روتینهایش به فنا بدهد؟
- لابد توی جواب قبلی گفتید که خب من که panic خالی نمیکنم. recover هم میکنم. ولی خب توجه ندارید دیگر! خیلی از کدها اصلاً ابزار هستند برای پروژههای گندهتر، تضمینی نیست که کسی که ازشان استفاده میکند بداند پنیک میکنند و باید حواسش به ریکاور کردن هم باشد.
در نهایت، پنیک و ریکاور شاید بعضی جاها جواب بدهد و مشکلات پیشگفته را نداشته باشد، ولی یک code smell است. کسان دیگری که بعداً بخواهند روی پروژهتان کار کنند ممکن است فکر کنند همهی برنامهنویسها استانداردهای درست کد زدن توی گولنگ را بلد هستند و حواسشان به پنیکهایی که شما نوشتهاید نباشد و ناسزا نثارتان کنند.
آمدنت بهر چه بود
مشکل دیگری که ارورها توی گولنگ دارند این است که هیچ stack traceای را بهخودیخود برنمیگردانند و این مسئله، اگر پیغام خطای خوبی تولید نشود، ممکن است دیباگ کد را سخت کند، ولی خب بهسادگی و با استفاده از یک پکیج معروف و کوچک، حل میشود.
حالا شاید بگویید چرا توی خود گولنگ این قابلیت نیست؟ که خب لازم ندیدهاند. توی خود کتابخانههای گولنگ، استانداردی برای تولید پیغام خطا رعایت میشود که از همان پیغام میشود فهمید ارور مال چه تابعی با چه ورودیهایی است (این هم از همین best practiceهاست که بقیه هم رعایتش میکنند). مثلاً تابع rename را در پکیج os گولنگ ببینید. توی پیغام خطا هم اسم تابع هست، هم پارامترهای ورودی تابع و هم ارور مد نظر:
func rename(oldname, newname string) error {
e := windows.Rename(fixLongPath(oldname), fixLongPath(newname))
if e != nil {
return &LinkError{"rename", oldname, newname, e}
}
return nil
}
در نهایت هم بهعنوان برنامهنویس استفادهکنندهی نهایی از این کد، این پیغام خطا از یک استکتریس طولانی خواناتر و قابلفهمتر است و مشکل را سریعتر به شما اطلاع میدهد، اما موقعی که میخواهیم کد خودمان را دیباگ کنیم استکتریس اهمیت پیدا میکند.
اینجاست که پکیج github.com/pkg/errors کمکمان میکند. اگر با این پکیج ارور را بسازیم، این ارور استک را توی خودش ذخیره میکند و میشود بعداً چاپش کرد. باز هم مثال:
import "github.com/pkg/errors"
// then
err := errors.Errorf("New error with stack-trace")
// or
err := errors.New("New error with stack-trace")
// print stack trace top two levels
st := err.StackTrace()
fmt.Printf("%+v", st[0:2])
گفتن نکتهی دیگری هم که خالی از لطف نیست این است که میتوانید یک ارور را داخل یک ارور دیگر wrap کنید. قبل از ورژن ۱.۱۳ خود گولنگ این قابلیت را نداشت و با تابع errors.Wrap و errors.Wrapf باید این کار را میکردید، ولی بعد از اون با %w توی fmt.Errorf هم میتوانید همین کار را بکنید.
err1 := fmt.Errorf("new err 1")
err2 := fmt.Errorf("err2 wrapped %w", err1)
fmt.Printf("%v\n", err2)
// outpus "err2 wrapped new err 1"
بعد از wrap کردن هم با تابع Cause میتوانید ریشهی اصلی و اولین اروری را که بقیهی ارورهای بعد از آن wrapاش کردهاند ببینید. به همین دلیل است که بهتر است برای مقایسهی دو تا ارور از تابع errors.Is بهجای == استفاده کنید، چون این wrap شدن و ارورهای سطوح مختلف را هم با ارور مدنظر شما مقایسه میکند. البته wrap کردن با fmt.Errorf استکتریس را نگه نخواهد داشت. ولی اگر با پکیج github.com/pkg/errors این کار را بکنید استکتریس راه هم خواهید داشت.
یا هندل کن یا نکن
یک کار نه چندان خوشایندی که در ابتدا هنگام رو به رو شدن با ارور میکردم این بود که همیشه در تابعهای سطح پایینترم نیز ارور را لاگ میکردم:
if err != nil {
log.Errorf("err at function: %v:",err)
return err
}
حالا تو تابع صدا زننده این تابع هم، وقتی به ارور میخوردم دوباره همینکار رو میکردم. صدا زنندهی اون هم. صدا زنندهی صدا زنندهی اون هم و الخ.
بعد در تابع صدا زنندهی این تابع سطح پایین نیز وقتی به ارور برمیخوردم دوباره همین کار را میکردم. در صدا زنندهی این تابع هم همچنین، و صدا زنندهی صدا زنندهی این تابع، و ... .
با این کار اتفاقی که میافتاد این بود که وقتی در لاگها به دنبال یک خطا میگشتم، برای یک ارور تعداد زیادی خط تقریبا یکسان و مشابه مربوط به آن وجود داشت که هم حجم فایل لاگ را الکی چند برابر میکند و هم پیدا کردن مکان وقوع خطا را سخت میکند. مسلما روش درستی برای مدیریت و لاگ کردن خطا نیست. پس چه کار میشود کرد؟
من برای خودم، با کمک آقای Cheney، یک اصل گذاشتم: یا با ارور یک کاری بکن. یا هیچ کاری نکن و فقط برش گردان. یک کاری کردن هم میتواند هر کاری باشد. لاگ کردن، مدیریت کردن، پنیک کردن، ثبت کردن در دیتابیس یا هر کار دیگری. برای این که بفهمیم منشا اصلی ارور نیز کجا بوده است میتوانیم همانطور که قبلا اشاره کردیم، در اولین برخورد با خطا، توسط کتابخانهی github.com/pkg/errors و تابع Wrap یا Wrapf آن، ارور را به همراه استکتریس آن ثبت کنیم و در نهایت هنگامی که قرار است تابع را مدیریت و یا لاگ کنیم میتوانیم توسط تابع StackTrace() منشا اصلی آن را پیدا کنیم:
if err, ok := err.(stackTracer); ok {
for _, f := range err.StackTrace() {
fmt.Printf("%+s:%d\n", f, f)
}
}
البته با استفاده از %+v در استرینگ فرمترها نیز میتوان استکتریس را چاپ کرد (به کاراکتر + دقت کنید. بدون این کاراکتر تنها متن پیغام چاپ خواهد شد):
if err != nil {
fmt.Printf("error details and stacktrace: %+v", err)
}
با این توضیحات به طور کلی در مواجه با یک ارور، با توجه به این که کجا این ارور را دیدهایم، باید یکی از سه کار زیر را بکنیم:
- اگر ارور از کتابخانهای غیر از سورسکد اصلی خود ماست، آن را wrap میکنیم و تا به همراه استکتریسش returnاش کنیم
- اگر ارور نیز توسط کد خود ما تولید میشود، در هنگام ساختش نیز استکتریس را به همراه آن ذخیره میکنیم.
- اگر در تابعهای سطح بالا که اینترفیس نهایی کد هستند و دیگر قرار نیست خطایی را به تابع سطح بالاتری برگردانند و باید مدیریتش کنند به یک ارور برخوردیم، همانطور که واضح است، مدیریتش میکنیم. باز مدیریت کردن میتواند شامل عملیاتهای مختلفی باشد یا تنها شامل لاگ کردن خطا باشد.
- هر جایی بین این دو وضعیت، ارور را فقط return میکنیم. حتی نیازی به لاگ کردن نیز نیست. چون مطمئنیم نهایتا در جایی به همراه استکتریس آن لاگ خواهد شد.
خشکتر و خشکتر
اولین چیزی که برایم توی گولنگ عجیب و کمی اذیتکننده بود این حجم عظیم if err != nil توی کد بود و که هرچند خط یک بار باید یک تکه کد تقریباً مشابه را تکرار کرد. هرچند الان بهش عادت کردهام و به نظرم اگر نقطهی قوت نباشد، اصلاً نقطهضعف نیست و به نظرم نکتهی مثبتی است که ارورها همیشه اینقدر جلوی چشم هستند، ولی اگر جایی بخواهیم ارور را لاگ هم بکنیم یا پراسس اضافهتری رویش انجام بدهیم، واقعاً سخت و اذیتکننده میشود. تازه کدمان هم از خشکی (DRY - Don't Repeat Yourself) درمیآید.
برای اینکه ببینیم برای این کار چه کارهایی میشود کرد، بگذارید با دو تا مثال برویم جلو. مثالی را که اول متن زدیم در نظر بگیرید:
_, err = fd.Write(p0[a:b])
if err != nil {
return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
return err
}
(شاید بگویید کی این اتفاق میافتد و کد اینطوری خواهیم داشت که خب جواب این است که در ۹۹ درصد مواقع حق با شماست. ولی وقتی بخواهید با ioها بهصورت low level کار کنید، احتمالاً بهش برمیخورید.)
کاری که میشود اینجور مواقع کرد این است که یک wrapper برای تابعمان بنویسیم که قبل صدا زدن خودش چک کند که اگر در مراحل قبلی صدا زدن ارور خوردیم، کاری نکند. برای همین تابع write میشود مثل مثال زیر:
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
}
اینجا دارد چه اتفاقی میافتد؟ errWriter ما یک متغیر از نوع error را توی خودش نگه میدارد. این متغیر کمک میکند تا برنامهی ما یادش بماند آیا تا الان به ارور خوردهام یا نه. اگر به ارور نخوردهام که کارم را ادامه بدهم و تابعم را بهصورت عادی صدا بزنم، ولی اگر به ارور خوردم، دیگر کاری نکنم و جای صدا زدن تابع ارورم را برگردانم:
ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
if ew.err != nil {
// handle the error
}
یک سرویس HTTP API مثال خوب دیگری از این است که چطور با wrapperها میتوانیم ارور هندلینگ را بهتر و خشکتر کنیم. وقتی ما یک API مینویسیم، میخواهیم هر وقت به اروری خوردیم که نشد کاریاش کنیم، یک ارور مشخص به کاربر برگردانیم. مثلاً اگر رکوردی پیدا نشد، ۴۰۴ برگردانیم. اگر دسترسی نداشت، ۴۰۳ برگردانیم یا در حالت کلی هم میخواهیم خطای 5xx برگردانیم. کنار این، میخواهیم خطا را لاگ هم بکنیم. مثلاً این کد را در نظر بگیرید( کد تو پلیگروند):
func main() {
http.HandleFunc("/", HelloWorld)
http.ListenAndServe(":8080", nil)
}
func HelloWorld(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r.URL.Path[1:])
if err != nil {
if errors.Is(err, ErrNotFound) {
// log something for example
http.Error(w, err.Error(), 404)
return
}
}
_, err = fmt.Fprintf(w, "Hello, %s!", user.Name)
if err != nil {
if errors.Is(err, ErrNotFound) {
// log something else
http.Error(w, err.Error(), 500)
return
}
}
}
حالا فکر کنید همین یک مسیر و route نیست و دهها درخواست مختلف هست که هرکدام هم تابعهای مختلفی را صدا میزنند و امکان چندین مدل ارور در آنها وجود دارد. مسلماً این روش درستی نیست.
کار سادهای که میتوانیم بکنیم این است که ارور را توی این توابع هندلرمون، مدیریت نکنیم و فقط برگردانیمش. اینطوری:
func HelloWorld(w http.ResponseWriter, r *http.Request) error {
user, err := getUser(r.URL.Path[1:])
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "Hello, %s!", user.Name)
if err != nil {
return err
}
}
و بعد یک wrapper مینویسیم که این تابع را بهعنوان ورودی بگیرد و یک تابع که بشود بهعنوان ورودی به http.HandleFunc داد برگرداند. اون وسط هم ارور را هندل کند. چطوری؟ اینطوری (کد تو پلیگروند):
type HandlerFuncWithError func(http.ResponseWriter, *http.Request) error
type HandlerFunc func(http.ResponseWriter, *http.Request)
func ErrWrapper(cb HandlerFuncWithError) HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := cb(w, r); err != nil {
// log the error
if errors.Is(err, ErrNotFound) {
http.Error(w, err.Error(), 404)
return
}
http.Error(w, err.Error(), 500)
return
}
}
}
حالا دیگر هر تابع هندلری که مینویسید فقط باید error را برگرداند و هندلرها هم اینطوری به پکیج http داده بشوند:
http.HandleFunc("/", ErrWrapper(HelloWorld))
زیباست. نه؟
در نهایت، بسته به موقعیت و ساختار کد، راهکارهای مختلفی برای مدیریت ارورها میتوان در نظر گرفت. هر کاری که حس میکنید توی کدتان بهتر و خشکتر و تمیزتر است بکنید، فقط نترسید و ارورها را هم دور نریزید. :)
منابع:
مطلبی دیگر از این انتشارات
کرونا باش تا کامروا شوی!
مطلبی دیگر از این انتشارات
هنر برگزاری جلسه مفید و مختصر
مطلبی دیگر از این انتشارات
محصول یا پروژه؟! مسئله این است