Anophel-آنوفل آشنایی با Error Handling در Go : بررسی عمیق

آشنایی با Error Handling در Go : بررسی عمیق

تاریخ انتشار:
Go
زمان مطالعه: 23 دقیقه

مدیریت خطا (Error Handling) یک جنبه حیاتی در هر زبان برنامه نویسی است و Go نیز از این قاعده مستثنی نیست. در این مقاله از سری مقالات گولنگ در آنوفل، به بررسی اشتباهات معمولی که حتی توسعه دهندگان باتجربه مرتکب می شوند اختصاص یافته است، ما بر Error Handling تمرکز خواهیم کرد.


ما در گولنگ بر خلاف سایر زبان های برنامه نویسی که دارای try-catch هستند، نداریم. رسیدگی به خطا (Error Handling) موضوعی است که اغلب در کانتکست Go، یک زبان برنامه نویسی مدرن محبوب که خود را با انحراف از الگوی معمولی try-catch متمایز می کند، مورد بحث است. علیرغم برانگیختن بحث‌ها و بحث‌های شدید، من معتقدم که رویکرد Go از دیگر زبان‌ها برتر است. در Go، خطاها صریح هستند و نیاز به توجه مداوم دارند. هر زمان که خطایی رخ می دهد، باید به سرعت به آن رسیدگی شود و معمولاً به caller بازگردانده شود. خطاها به طور طبیعی از طریق استک منتشر می شوند و بین لایه ها و بسته ها جریان می یابند.

 

اهمیت Error Handling در Go

مدیریت خطا به برنامه نویسان کمک می کند تا با اطمینان از شناسایی و رسیدگی سیستماتیک خطاها، برنامه های کاربردی قوی و قابل نگهداری ایجاد کنند. Go با اجتناب از مکانیسم‌های مبتنی بر استثنا و در عوض با استفاده از یک رابط خطای ساده و در عین حال قدرتمند، یک رویکرد منحصر به فرد برای مدیریت خطا در پیش می‌گیرد. این رویکرد توسعه دهندگان را تشویق می کند تا خطاها را به طور صریح مدیریت کنند و خطر انتشار خطاهای کنترل نشده از طریق برنامه را به حداقل می رساند.


مدیریت خطاها در Go شبیه یک هنر است. این یک اغراق یا زیاده‌روی نیست. من به زودی توضیح خواهم داد که چرا این دیدگاه را دارم. با این حال، اجازه دهید ابتدا پیامدهای نادیده گرفتن کامل خطاها را بررسی کنیم.

 

اشتباه 1: نادیده گرفتن خروجی خطا

مثال زیر را در نظر داشته باشید:

 

package tenmistakes

import "fmt"

// DoAllTheThings does all the things.
func DoAllTheThings() error {
	// Do all the things.
	return fmt.Errorf("not implemented")
}

// Yolo is a function that does all the things but ignores errors.
// This is a bad idea, but we only live once, so YOLO!
func Yolo() {
	// Do all the things but ignore errors.
	DoAllTheThings()
}

 

این ممکن است یکی از تاثیرگذارترین error ها در کل سریال باشد. یک قانون کلی که در اوایل سفر Go خود یاد می گیرید این است که "هرگز error ها را نادیده نگیرید". نادیده گرفتن خطاها می تواند دیباگ کردن را پیچیده کند، منجر به رفتارهای غیرمنتظره و باگ ها شود. این نشان دهنده عدم مسئولیت در قبال تیم شما و کاربران نهایی است. در حالی که این اصل راهنما مسیر را ارائه می دهد، ارزش آن را دارد که واقعاً در برابر چالش های دنیای واقعی مقاومت کند. بیایید چند سناریو را بررسی کنیم که می تواند اعتبار آن را تست کند.


مورد 0: اگر بخواهید interface را با متدی که بازگشت خطا ندارد ایجاد کنید، چه؟

در حالت ایده‌آل، ابتدا می‌خواهم متد را طوری تنظیم کنم که شامل بازگشت خطا باشد. با این حال، این ممکن است شامل refactoring قابل توجهی باشد. یک راه حل برای این می تواند ثبت خطا باشد. در حالت ایده‌آل، می‌توانید از یک نمونه logger متمرکز استفاده کنید، که به‌عنوان یک وابستگی یا از طریق یک thread-safe singleton قابل دسترسی است. 

 

بیایید نگاهی دقیق به کد زیر بیندازیم.

 

package main

import (
	"fmt"
	"log"
	"time"
)

// Duck is an interface for ducks.
type Duck interface {
	// Quack makes the duck quack. It returns no error because ducks never fail to quack.
	Quack()
}

// ImposterDuck is not a real duck.
// It is an imposter. Don't trust your eyes or money to this duck.
type ImposterDuck struct {
	// disguiseExpiryDate is the moment when the disguise will break.
	disguiseExpiryDate time.Time
}

// NewImposterDuck returns a new imposter duck.
// The duck is disguised as a real duck. The disguise will break in 5 seconds.
func NewImposterDuck() *ImposterDuck {
	return &ImposterDuck{
		disguiseExpiryDate: time.Now().Add(5 * time.Second),

	}
}

// Quack makes the imposter duck quack, but only if the disguise is perfect.
func (d *ImposterDuck) Quack() {
	// We can quack while disguise lasts.
	for time.Until(d.disguiseExpiryDate) > 0 {
		log.Println("Quack!")

		// Wait 2 seconds before quacking again to avoid suspicion.
		time.Sleep(2 * time.Second)
	}

	// We can't quack without a perfect disguise.
	// Attempt to fix the disguise.
	err := d.fixDisguise()
	if err != nil {
		log.Printf("quacking without a perfect disguise won't work: %v\n", err)

		return
	}

}

// fixDisguise attempts to fix the disguise of the imposter duck.
// It returns an error if the disguise cannot be fixed.
func (d *ImposterDuck) fixDisguise() error {
	return fmt.Errorf("disguise cannot be fixed: we ran out of ducktape")
}

func main() {
	// Create a new imposter duck.
	duck := NewImposterDuck()

	// Quack!
	duck.Quack()
}

 

کد بالا خروجی زیر را داریم:

 

2024/09/16 09:30:52 Quack!
2024/09/16 09:30:54 Quack!
2024/09/16 09:30:56 Quack!
2024/09/16 09:30:58 quacking without a perfect disguise won't work: disguise cannot be fixed: we ran out of ducktape

 

در این مثال واضح است که ما نمی‌توانستیم رابط کاربری را تغییر دهیم، زیرا طبق تعریف، duck ها هرگز نمی‌توانند قفل کنند، بنابراین ما مجبور شدیم با استفاده از imposter خود به صورت مبدل و ثبت خطا در زمانی که quacking دیگر گزینه ای نبود، بداهه سازی کنیم.


برای سادگی، پکیج استاندارد "log" را انتخاب کردم. با این حال، به استفاده از جایگزین هایی مانند zap یا zerolog برای تولید فکر کنید. علاوه بر این، مراقب اسلوگ آینده، یک لاگر ساختاری داخلی و بخشی از نسخه Go 1.21 باشید.البته ما در آنوفل پکیج zap و بحث logging را کامل مورد بررسی قرار دادیم.


مورد 1: من یک تابع کمکی ایجاد می کنم که یک رشته را به time.Time تبدیل می کند، و می خواهم فقط یک مقدار برگردانده شود. مرحله بعدی چیست؟

 

package tenmistakes

import (
	"fmt"
	"time"
)

// ParseTimeToRFC3339 parses a time string to a time.Time object.
// The time string must be in RFC3339 format - "2006-01-02T15:04:05Z07:00".
func ParseTimeToRFC3339(timeString string) (time.Time, error) {
	parsedTime, err := time.Parse(time.RFC3339, timeString)
	if err != nil {
		return time.Time{}, fmt.Errorf("parsing time string to RFC3339 time: %w", err)
	}

	return parsedTime, nil
}

 

بسیار خب، تابع کمکی اولیه ما آماده شده است، اما ما علاقه ای به خروجی خطا نداریم. این سناریو منطقی است. ممکن است هنگام تجزیه مقادیر در پیکربندی برنامه در هنگام راه‌اندازی ایجاد شود. در چنین شرایطی، ممکن است بخواهید یک تابع Must* جداگانه ایجاد کنید. این باعث می شود برنامه به جای بازگشت یا ثبت خطا، panic کند. بله، درست خواندید، یک panic در برنامه. با این حال، برای مورد استفاده خاص ما، ممکن است قابل قبول باشد، زیرا، فرض کنید، اگر فایل پیکربندی حاوی رشته‌ای باشد که از فرمت RFC3339 پیروی نمی‌کند، کاملاً مطمئن هستیم که مشکلی در برنامه ما به شدت اشتباه است.

 

package tenmistakes

import (
	"fmt"
	"time"
)

// MustParseTimeToRFC3339 parses a time string to a time.Time object.
// The time string must be in RFC3339 format - "2006-01-02T15:04:05Z07:00".
// It panics if the time string cannot be parsed.
func MustParseTimeToRFC3339(timeString string) time.Time {
	parsedTime, err := time.Parse(time.RFC3339, timeString)
	if err != nil {
		panic(fmt.Errorf("parsing time string to RFC3339 time: %w", err))
	}

	return parsedTime
}

 

برای مثال‌های بیشتر از استفاده از آن الگو در حالت wild، می‌توانید به ()regexp.MustCompile یا ()ulid.MustNew مراجعه کنید.


مورد 2: اگر من کاملاً، بطور مثبت، مجبور به نادیده گرفتن خطا باشم، چه؟

اگر قاطعانه معتقدید که از خروجی خطا استفاده نمی‌کنید و مدیریت آن فایده‌ای ندارد، لطفاً با استفاده از underscore یا همنان _ به صراحت آن را نادیده بگیرید. علاوه بر این، برای روشن شدن دلیل خود برای این انتخاب، یک کامنت اضافه کنید. به این ترتیب، چه در طول بررسی تغییرات و چه حتی در سه سال بعد، همه مطمئن خواهند شد که این اقدام عمدی بوده و دلیل آن را درک خواهند کرد.

 

package main

import (
	"io"
	"log"
	"os"
)

func main() {
	// Open the file.
	file, err := os.Open("example.txt")
	if err != nil {
		log.Fatal("Error opening file:", err)
	}

	// Close the file when we are done.
	defer func(file *os.File) {
		err = file.Close()
		if err != nil {
			log.Fatal("Error closing file:", err)
		}
	}(file)

	// Read the first 1024 bytes of the file.
	buffer := make([]byte, 1024)
	_, _ = io.ReadFull(file, buffer) // We already checked for errors when opening the file, so we can ignore the error here.

	// Continue processing the buffer without handling the error
}

 

به خاطر داشته باشید که این صرفاً یک نمونه است و توصیه من این است که به جای رد کردن آنها، به همه خطاها رسیدگی کنید. اگر در حین بررسی کد با این وضعیت مواجه شدم، مطمئناً به آن اشاره خواهم کرد. نادیده گرفتن خطا در این زمینه، اگر اصلاً وجود داشته باشد، مزایای قابل توجهی به همراه ندارد.


در مورد کانتکست صحبت می کنیم، اجازه دهید اکنون wrapping خطا را بررسی کنیم، که یکی دیگر از ویژگی های متمایز Go در هنگام گنجاندن اطلاعات اضافی در پیام های خطا است. با این حال، راه هایی برای اشتباه گرفتن آن وجود دارد.


اشتباه 2: wrapping نادرست

Wrapping در نسخه 1.13 به Go معرفی شد و فصل جدیدی را در تاریخچه مدیریت خطاهای زبان باز کرد. علیرغم این واقعیت که این تقریباً چهار سال پیش رخ داده است، هنوز ممکن است برخی عدم قطعیت ها یا سوء تفاهم های جزئی وجود داشته باشد که من قصد دارم به آنها بپردازم.


wrap یا wrap نکردن؟

برای پاسخ به این سوال، پیشنهاد می‌کنم سناریوهای بالقوه‌ای را که در آن wrapping  ضروری است، بررسی کرده و آن‌ها را تحلیل کنیم.


خطایی از صدا زدن با پکیج شخص ثالث می آید:

 

correct, err := hashcash.CheckSolution(solution)
if err != nil {
 return false, fmt.Errorf("checking solution: %w", err)
}

 

خطا از صدا زدن وابستگی می آید:

 

err = s.Store.Add(ctx, key.String(), challengeStr)
if err != nil {
 return "", fmt.Errorf("saving challenge to store: %w", err)
}

 

بدون wrapping در هر دو مورد، شناسایی ردپای کامل خطا و درک هدف پشت فراخوانی های تابع که شکست خورده اند، چالش برانگیزتر خواهد بود. کدی که ما فراخوانی می‌کنیم در پکیج های دیگری قرار دارد، زیرا برای استفاده مجدد در نظر گرفته شده است و استفاده نهایی آن را نادیده می‌گیرد. در نتیجه، خطاهای ناشی از صدا زدن های این پکیج ها به جزئیات بیشتری نیاز دارند تا به خوانندگان کمک کند تا کانتکست را درک کنند.


بنابراین، آیا باید همیشه از wrapping استفاده کنیم؟ نه لزوما. یکی از سناریوهای رایج که در آن بسته بندی ممکن است مناسب نباشد، زمانی است که یک متد یا تابع exported را به چندین روش صادر نشده decompose می کنید تا به اصل تک مسئولیتی پایبند باشید.

 

در اینجا یک مثال کد است:

 

package tenmistakes

import (
	"errors"
)

// Calculate performs a series of calculations.
func Calculate(a, b int) (int, error) {
	result, err := calculateStepOne(a, b)
	if err != nil {
		return 0, err // This error is not wrapped.
	}

	// Perform more calculations using result...

	return result, nil
}

// calculateStepOne performs the first step of a series of calculations.
func calculateStepOne(a, b int) (int, error) {
	if a < 0 || b < 0 {
		return 0, errors.New("negative values are not supported")
	}

	// Perform calculation...

	return a + b, nil
}

// Other unexported calculation functions...

 

معرفی یک لایه دیگر از wrapping در تابع Calculate نه تنها منجر به یک پیام خطای مفصل می شود، بلکه جزئیات پیاده سازی داخلی را نیز آشکار می کند. در این مثال نیازی نیست که کالر به جزئیات نحوه تقسیم محاسبات خود به مراحل جداگانه توجه کند. آنچه واقعاً مهم است این است که خطایی که ما ارائه می دهیم متمایز و قابل عمل باشد. به همین دلیل منطقی است که یک خطای بدون wrapping را مستقیماً به کالر برگردانید. دقت کنید ریفکتور کد ما نباید بر رفتار آن تأثیر بگذارد.


هنر wrapping 

ما ثابت کرده‌ایم که خطاهای wrapping عموماً سودمند هستند. اکنون، بیایید به تکنیک های موثر برای زیبا کردن آن بپردازیم.

اینجاست که هنر وارد می‌شود. ما می‌آموزیم که چگونه با استفاده از خطاهایمان متن موثرتری بسازیم، توالی دقیق وقایعی را که منجر به این موضوع شده است، روایت کنیم و آن را به troubleshooter ارائه کنیم.


برای درک بهترین شیوه ها، اتخاذ دیدگاه خوانندگان حیاتی است. آیا آنها می توانند پیام خروجی را به راحتی درک کنند؟ چگونه می توانیم بین حفظ لحن طبیعی و پرهیز از لفظ بیش از حد متعادل ایجاد کنیم؟


بیایید به کد اولیه نگاه کنیم. این ممکن است به کمی زمان برای خواندن نیاز داشته باشد.

 

package main

import (
	"fmt"
	"log"
)

/*
 * The following code is a simplified example of a service that deletes users.
 * That could be a user management service, for example, as a part of a larger application or a microservice.
 * Normally the service would have more methods, but for the sake of simplicity, we only have one method here.
 * It would also not be a good idea to put all the abstractions and implementations in the same file,
 * but we do it here to keep the example within one file.
 */

// UserStorage is an interface to the storage layer for users.
type UserStorage interface {
	DeleteUser(userID uint64) error
}

// Storage is a storage layer for users.
type Storage struct {
	// database client would be here
}

// DeleteUser deletes a user from the storage, given the user ID.
func (s *Storage) DeleteUser(userID uint64) error {
	// Here we mock the database by returning an error for some user IDs.
	switch userID {
	case 1: // Side note: please don't use magic numbers in your code.
		// This is a happy path, no error.
		return nil
	case 42:
		// This is a database error.
		return fmt.Errorf("internal database error")
	default:
		// By default, we return an error that the user was not found.
		return fmt.Errorf("user with ID %d not found", userID)
	}
}

// UserService manages users and contains business logic for users.
type UserService interface {
	DeleteUser(userID uint64) error
}

// Service is a user management service.
type Service struct {
	storage UserStorage
}

// DeleteUser deletes a user and all their data, given the user ID.
// It also sends a notification to the user about the deletion.
func (s *Service) DeleteUser(userID uint64) error {
	// delete user from the storage
	err := s.storage.DeleteUser(userID)
	if err != nil {
		return fmt.Errorf("failed to delete user from storage: %w", err)
	}

	// Other business logic here, e.g.:
	// send a notification to the user

	return nil
}

// Handler is a handler for HTTP requests.
type Handler struct {
	service UserService
}

// DeleteUser handles a request to delete a user.
func (h *Handler) DeleteUser(userID uint64) error {
	// delete user
	err := h.service.DeleteUser(userID)
	if err != nil {
		return fmt.Errorf("failed to delete user: %w", err)
	}

	// Other logic here, e.g.:
	// return a response

	return nil
}

func main() {
	// Initialize the storage.
	storage := &Storage{}

	// Initialize the service.
	service := &Service{storage: storage}

	// Initialize the handler.
	handler := &Handler{
		service: service,
	}

	userID := uint64(2)

	// Delete a user.
	err := handler.DeleteUser(userID)
	if err != nil {
		log.Printf("Failed to handle DELETE request on `/users/%d`: %v", userID, err)
	} else {
		log.Println("User deleted successfully")
	}
}

 

وقتی این کد را با userID := uint64(2) اجرا می کنیم، حالت پیش فرض را در لایه ذخیره سازی در خط 36 راه اندازی می کنیم که نتیجه آن خروجی زیر است:

 

2024/09/16 09:39:31 Failed to handle DELETE request on
 `/users/2`: failed to delete user: failed to delete
  user from storage: user with ID 2 not found

 

پیام خطا را نگاه کنید این خیلی خوشایند به نظر نمی رسد. خروجی خطا تکراری، متلاطم و القا کننده حمله panic است، کلمه failed بسیار زیاد است. آیا می توانیم DevX را بهبود ببخشیم؟ بیایید دوباره امتحان کنیم - تغییرات در خطوط 58، 77 و 103 هستند.

 


package main

import (
	"fmt"
	"log"
)

/*
 * The following code is a simplified example of a service that deletes users.
 * That could be a user management service, for example, as a part of a larger application or a microservice.
 * Normally the service would have more methods, but for the sake of simplicity, we only have one method here.
 * It would also not be a good idea to put all the abstractions and implementations in the same file,
 * but we do it here to keep the example within one file.
 */

// UserStorage is an interface to the storage layer for users.
type UserStorage interface {
	DeleteUser(userID uint64) error
}

// Storage is a storage layer for users.
type Storage struct {
	// database client would be here
}

// DeleteUser deletes a user from the storage, given the user ID.
func (s *Storage) DeleteUser(userID uint64) error {
	// Here we mock the database by returning an error for some user IDs.
	switch userID {
	case 1: // Side note: please don't use magic numbers in your code.
		// This is a happy path, no error.
		return nil
	case 42:
		// This is a database error.
		return fmt.Errorf("internal database error")
	default:
		// By default, we return an error that the user was not found.
		return fmt.Errorf("user with ID %d not found", userID)
	}
}

// UserService manages users and contains business logic for users.
type UserService interface {
	DeleteUser(userID uint64) error
}

// Service is a user management service.
type Service struct {
	storage UserStorage
}

// DeleteUser deletes a user and all their data, given the user ID.
// It also sends a notification to the user about the deletion.
func (s *Service) DeleteUser(userID uint64) error {
	// delete user from the storage
	err := s.storage.DeleteUser(userID)
	if err != nil {
		return fmt.Errorf("error deleting user from storage: %w", err)
	}

	// Other business logic here, e.g.:
	// send a notification to the user

	return nil
}

// Handler is a handler for HTTP requests.
type Handler struct {
	service UserService
}

// DeleteUser handles a request to delete a user.
func (h *Handler) DeleteUser(userID uint64) error {
	// delete user
	err := h.service.DeleteUser(userID)
	if err != nil {
		return fmt.Errorf("error deleting user: %w", err)
	}

	// Other logic here, e.g.:
	// return a response

	return nil
}

func main() {
	// Initialize the storage.
	storage := &Storage{}

	// Initialize the service.
	service := &Service{storage: storage}

	// Initialize the handler.
	handler := &Handler{
		service: service,
	}

	userID := uint64(2)

	// Delete a user.
	err := handler.DeleteUser(userID)
	if err != nil {
		log.Printf("Error handling DELETE request on `/users/%d`: %v", userID, err)
	} else {
		log.Println("User deleted successfully")
	}
}

 

و خروجی نیز تغییر می کند (اینجا جای تعجب نیست):

 

2024/09/16 09:45:45 Error handling DELETE request on
 `/users/2`: error deleting user: error deleting user
  from storage: user with ID 2 not found

 

این کمی بهتر به نظر می رسد، اما ما کلمه "error" را در همه جا دریافت کردیم. چطور آن را به طور کلی حذف کنیم؟

 

بیاید دوباره کد هایمان را ریفکتور کنیم:

 

package main

import (
	"fmt"
	"log"
)

/*
 * The following code is a simplified example of a service that deletes users.
 * That could be a user management service, for example, as a part of a larger application or a microservice.
 * Normally the service would have more methods, but for the sake of simplicity, we only have one method here.
 * It would also not be a good idea to put all the abstractions and implementations in the same file,
 * but we do it here to keep the example within one file.
 */

// UserStorage is an interface to the storage layer for users.
type UserStorage interface {
	DeleteUser(userID uint64) error
}

// Storage is a storage layer for users.
type Storage struct {
	// database client would be here
}

// DeleteUser deletes a user from the storage, given the user ID.
func (s *Storage) DeleteUser(userID uint64) error {
	// Here we mock the database by returning an error for some user IDs.
	switch userID {
	case 1: // Side note: please don't use magic numbers in your code.
		// This is a happy path, no error.
		return nil
	case 42:
		// This is a database error.
		return fmt.Errorf("internal database error")
	default:
		// By default, we return an error that the user was not found.
		return fmt.Errorf("user with ID %d not found", userID)
	}
}

// UserService manages users and contains business logic for users.
type UserService interface {
	DeleteUser(userID uint64) error
}

// Service is a user management service.
type Service struct {
	storage UserStorage
}

// DeleteUser deletes a user and all their data, given the user ID.
// It also sends a notification to the user about the deletion.
func (s *Service) DeleteUser(userID uint64) error {
	// delete user from the storage
	err := s.storage.DeleteUser(userID)
	if err != nil {
		return fmt.Errorf("deleting user from storage: %w", err)
	}

	// Other business logic here, e.g.:
	// send a notification to the user

	return nil
}

// Handler is a handler for HTTP requests.
type Handler struct {
	service UserService
}

// DeleteUser handles a request to delete a user.
func (h *Handler) DeleteUser(userID uint64) error {
	// delete user
	err := h.service.DeleteUser(userID)
	if err != nil {
		return fmt.Errorf("deleting user: %w", err)
	}

	// Other logic here, e.g.:
	// return a response

	return nil
}

func main() {
	// Initialize the storage.
	storage := &Storage{}

	// Initialize the service.
	service := &Service{storage: storage}

	// Initialize the handler.
	handler := &Handler{
		service: service,
	}

	userID := uint64(2)
	
	// Delete a user.
	err := handler.DeleteUser(userID)
	if err != nil {
		log.Printf("Handling DELETE request on `/users/%d` endpoint: %v", userID, err)
	} else {
		log.Println("User deleted successfully")
	}
}

 

خروجی به صورت زیر خواهد بود:

 

2024/09/16 09:45:43 Handling DELETE request on `/users/2` endpoint: deleting user: deleting user from storage: user with ID 2 not found

 

خیلی بهتر شد! ما اکشن هایی داشتیم که زنجیر شده بودند. ما اکشن ها را با هم در یک روایت موجز پیوند داده ایم. بدون حرف غیر ضروری، با این حال مشخص است که چه اتفاقی افتاده است.


اشتباه 3: رسیدگی به یک خطا دو بار

این اشتباه ظریف تر از سایر اشتباهات موجود در لیست امروز است.


شما به‌تازگی از مسیر گسترده‌ای برای wrapping و بازگرداندن خطاها عبور کرده‌اید، و ممکن است مشتاق باشید که آن را به صورت گلوبال اعمال کنید. با این حال، اگر با سناریویی مواجه شدید که در آن وسوسه می‌شوید که هم خطا را وارد کنید و هم خطا را برگردانید، لطفاً مکث کنید و فکر کنید. از خودتان بپرسید: آیا رویکرد بهتری وجود دارد؟ فایده برگرداندن خطایی که به تازگی ثبت کرده ام چیست؟ آیا لاگ خود را به درستی قرار داده ام؟


از کجا وارد شوید؟
اگر پروژه شما به اندازه کافی ساختار یافته است و بخش های آن جدا شده اند، مانند معماری شش ضلعی یا سایر سطوح، منطق کسب و کار خود را در یک مکان متمرکز کرده اید، لایه دامنه یا سرویس. این لایه به طور منحصر به فردی مجهز شده است تا معنای استک تماس های شخص ثالث یا وابستگی را در چارچوب منطق شما درک کند. با متمرکز کردن ثبت خطا در لایه سرویس، می توانید نویز را در گزارش های سیستم به حداقل برسانید و ثبات را حفظ کنید. علاوه بر این، تمرکز منطق تجاری در یک لایه به شما این امکان را می دهد که تعیین کنید چه مقدار اطلاعات باید به کالر ارائه دهید، مانند یک لایه انتقال که لایه سرویس را فراخوانی می کند.


دقت کنید که در این زمینه، من فقط در مورد ثبت مرکزی جریان خطای اولیه در استک، یعنی در سطح "ERROR" بحث می کنم. این بدان معنا نیست که گزارش‌های موجود در سطوح دیگر نمی‌توانند در جای دیگری در برنامه شما استفاده شوند.


اجازه دهید چند مورد را مطالعه کنیم تا مشکل double error handling را نشان دهیم.


مورد 0: تلاش برای ورود به سیستم و بازگشت همان خطا

 

func (s *Service) DeleteUser(userID uint64) error {
	// delete user from the storage
	err := s.storage.DeleteUser(userID)
	if err != nil {
		log.Error("Deleting user from storage: ", err)
		
		return fmt.Errorf("deleting user from storage: %w", err)
	}
        
        return nil
}

 

ما در لایه سرویس هستیم و پس از صدا زدن با وابستگی ذخیره‌سازی، خطایی دریافت کردیم که از چیزی بی‌ضرر مانند «not found» تا چیزی به شدت «database is down.» را شامل می‌شود. ما با ثبت این خطا و سپس بازگرداندن همان خطا، wrapping آن با کانتکست تکمیلی ادامه می دهیم.


تفکر پشت این راه حل چیست؟ با نگاه کردن به کد، فرض من این است که ما این خطا را در جای دیگری ثبت نخواهیم کرد، زیرا باعث ایجاد نویز گزارش می شود. بنابراین، انتظار ما از کالر در اینجا چیست؟ با خطای wrapped چه کاری باید انجام دهد؟ اگر این لایه انتقال یا هر رابط دیگر برای منطق تجاری ما (به عنوان مثال CLI) یا حتی سرویس دیگری باشد، احتمالاً به تمام جزئیات خطای پیچیده نیازی ندارند، زیرا ممکن است این جزئیات از جانب آنها قابل اجرا نباشد.


به طور خلاصه، این رویکرد بهینه نیست. یکی از مراحل فرآیند رسیدگی به خطا باید مورد بازنگری قرار گیرد.


مورد 1: فقط خطا را برمی گرداند

 

func (s *Service) DeleteUser(userID uint64) error {
	// computing something
	valuableData := "valuable_data"
 
	// delete user from the storage
	err := s.storage.DeleteUser(userID)
	if err != nil {  
		return fmt.Errorf("deleting user with `%s` from storage: %w", valuableData, err)
	}

	return nil
}

 

ما به لایه سرویس بازگشته‌ایم، اما این بار فقط خطای ناشی از ذخیره‌سازی را جمع‌بندی می‌کنیم و آن را با «داده‌های ارزشمند» تقویت می‌کنیم.


در یک نگاه، این مشکل به نظر می رسد، با این تفاوت که ما مسئولیت ثبت خطا را به نهاد دیگری محول می کنیم، مانند مکانیزم ثبت مرکزی در جایی بالاتر از استک (احتمالاً یک میدلور). این همچنین به این معنی است که شخصی باید تجزیه خطا را انجام دهد تا آن را به روشی ساختاریافته ثبت کند، با توجه به اینکه ما انتظار نداریم کالر داده های ارزشمند را مجدداً محاسبه کند.


به طور خلاصه، رویارویی با خطا تنها یک بار جنبه مثبتی دارد، اما از نظر فرصت از دست رفته برای ثبت و غنی‌سازی پیام گزارش با داده‌های موجود محلی، یک جنبه منفی نیز وجود دارد.


مورد 2: رسیدگی مشروط

 

func (s *Service) DeleteUser(userID uint64) error {
	// delete user from the storage
	err := s.storage.DeleteUser(userID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			log.Warn("Deleting user from storage: user not found: ", err)

			return nil
		}
  
		return fmt.Errorf("deleting user from storage: %w", err)
 	}

	return nil
}

 

ما دوباره در همان نقطه هستیم، اما این بار، منطقی را معرفی می کنیم تا بررسی کنیم که آیا خطا pgx است.ErrNoRows. شاید این اصلاً خطا نیست؟ از این گذشته، تلاش برای حذف کاربری که وجود ندارد ممکن است نشان دهد که کاربر قبلاً با تماس قبلی حذف شده است. یک حرکت هوشمند، ما ثبت‌نام را در جایی قرار می‌دهیم که به بهترین شکل می‌توانیم بگوییم که حذف یک کاربر ناموجود یک خطا نیست. با این حال، ما به تمام خطاهای دیگر اجازه می دهیم تا از طریق آنها عبور کنند و اساساً تاریخچه مورد صفر را تکرار می کنیم.


به طور خلاصه، این رویکرد معقول به نظر می رسد، اما بی عیب و نقص نیست.


مورد 3: رسیدگی مشروط با sentinel errors

 

// NB: this should be in a separate `errors.go` file of this package
var (
	// ErrUserNotFound is returned when a user is not found.
	ErrUserNotFound = errors.New("user not found")
	// ErrInternal is returned when an internal error occurs.
	ErrInternal = errors.New("internal error")
)

func (s *Service) DeleteUser(userID uint64) error {
	// delete user from the storage
	err := s.storage.DeleteUser(userID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			log.Warn("Deleting user from storage: user not found: ", err)
			return ErrUserNotFound
  		}
  
  		log.Error("Deleting user from storage: ", err)
  		return ErrInternal
 	}
 	return nil
}

 

در این حالت دو شرط و چند sentinel errors داریم. این خطاها صادر می‌شوند و هر کسی خارج از این بسته را قادر می‌سازد تا از ()errors.Is برای اعتبارسنجی و طراحی استراتژی‌های خود (مانند نگاشت خطاها به کدهای وضعیت و پیام‌ها در لایه انتقال) استفاده کند.


با ارائه یک API قابل اعتماد با خطاهای از پیش تعریف شده و ثبت متمرکز، مطمئن می شویم که توسعه دهندگانی که در تلاش برای حل مشکلات هستند، می توانند خطای واقعی پنهان شده در پشت sentinel errors را بدون افشای جزئیات داخلی برای عموم ببینند.


با این وجود، در یک برنامه یکپارچه، این رویکرد ممکن است منجر به نگاشت خطای اضافی در هر کنترل کننده شود، یا نیاز به تعریف مرکزی و استفاده مجدد از خطاها در کل سیستم داشته باشد، که ممکن است ایده آل هم نباشد. علاوه بر این، به دلیل منطق پیچیده رسیدگی به خطا، خطر حجیم شدن کد ما وجود دارد.


به طور خلاصه، اگرچه این رویکرد کامل نیست، اما نسبت به حالت اولیه ما یک پیشرفت قابل توجه است. این به همان اندازه نزدیک است که من دو بار به حل مشکل رسیدگی به خطاها نزدیک شده ام. با این حال، اگر ایده های بیشتری دارید، لطفاً آنها را در نظرات به اشتراک بگذارید.

 

نتیجه

هرگز خطاها را نادیده نگیرید؛ همیشه آنها را مورد خطاب قرار دهید در موارد بسیار نادر، آنها را به صراحت نادیده بگیرید و کامنت بگذارید. خطاها را با ()fmt.Errorf بپیچید تا کانتکست و قابلیت ردیابی را فراهم کنید. در پیغام خطا از حروف استفاده کنید. از wrapping بیش از حد خودداری کنید. خطاهای ورود به سیستم در لایه سرویس. از ورود و بازگرداندن خطای یکسان اجتناب کنید.


با آگاهی از رویکرد منحصر به فرد Go، پیچیدگی های wrapping خطا، و لایه سرویس، اکنون مجهز شده اید تا با ظرافت با خطاها مقابله کنید. به یاد داشته باشید، در دنیای Go، هر خطایی فرصتی برای درخشش است.

 

 

#go #golang #گو #گولنگ #error_handling