مدیریت خطاها در Go: رویکرد Go به مدیریت ارور

فهرست مطالب

مدیریت خطاها در Go: رویکرد Go به مدیریت ارور

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

فلسفه Go در مورد مدیریت خطا، به طور قابل توجهی با زبان‌هایی مانند جاوا، پایتون یا C++ که از مکانیزم `try-catch` برای استثناها استفاده می‌کنند، متفاوت است. در Go، خطاها مقادیر عادی هستند که از توابع بازگردانده می‌شوند و انتظار می‌رود که برنامه‌نویس به طور صریح آنها را بررسی و مدیریت کند. این رویکرد، که به عنوان “خطا یک مقدار است” شناخته می‌شود، می‌تواند در ابتدا برای توسعه‌دهندگانی که با پارادایم‌های دیگر آشنا هستند، کمی غیرعادی به نظر برسد، اما با گذشت زمان، مزایای آن در تولید کدی قابل پیش‌بینی، روشن و بدون ابهام، آشکار می‌شود. این پست وبلاگ به عمق رویکرد Go به مدیریت خطا می‌پردازد، از مفاهیم بنیادی گرفته تا استراتژی‌های پیشرفته، بهترین شیوه‌ها و ابزارهای موجود.

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

فلسفه Go در مورد مدیریت خطا: “خطا یک مقدار است”

همانطور که قبلاً اشاره شد، سنگ بنای فلسفه Go در مدیریت خطا، مفهوم “خطا یک مقدار است” می‌باشد. این بدان معناست که در Go، خطاها استثنائات پنهان یا مکانیسم‌های پرش (jump mechanism) نیستند، بلکه مقادیر مشخصی هستند که توابع می‌توانند علاوه بر نتایج عادی خود، بازگردانند. این رویکرد، توسعه‌دهنده را وادار می‌کند تا به طور صریح هر خطای احتمالی را در هر نقطه از برنامه که یک تابع می‌تواند خطا برگرداند، بررسی و مدیریت کند.

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

package main

import (
	"fmt"
	"strconv"
)

// تقسیم دو عدد را انجام می دهد. اگر مخرج صفر باشد، خطا برمی گرداند.
func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("خطا: تقسیم بر صفر مجاز نیست")
	}
	return a / b, nil
}

func main() {
	// مثال موفقیت آمیز
	result, err := divide(10, 2)
	if err != nil {
		fmt.Println("خطا:", err)
		return
	}
	fmt.Println("نتیجه تقسیم (10/2):", result) // خروجی: نتیجه تقسیم (10/2): 5

	// مثال با خطا
	result, err = divide(10, 0)
	if err != nil {
		fmt.Println("خطا:", err) // خروجی: خطا: تقسیم بر صفر مجاز نیست
		return
	}
	fmt.Println("نتیجه تقسیم (10/0):", result)
}

همانطور که در مثال بالا مشاهده می‌شود، هر بار که تابع `divide` فراخوانی می‌شود، مقدار `err` بازگشتی بلافاصله با `if err != nil` بررسی می‌شود. این الگوی `if err != nil` به قدری رایج است که به امضای Go تبدیل شده است. این صراحت، به توسعه‌دهنده این امکان را می‌دهد که دقیقاً بداند چه خطاها و در کجا می‌توانند رخ دهند و چگونه باید با آنها برخورد کرد.

مزایا و معایب این رویکرد

این فلسفه دارای مزایا و معایبی است:

مزایا:

  • وضوح و صراحت: جریان کنترل برای خطاها به وضوح در کد دیده می‌شود. نیازی به حدس زدن نیست که آیا یک تابع می‌تواند استثنا پرتاب کند یا خیر؛ امضای تابع و بررسی‌های صریح آن را نشان می‌دهند.
  • کاهش شگفتی‌ها: هیچ مکانیزم پنهانی برای پرش از کد وجود ندارد. خطاها باید مدیریت شوند، در غیر این صورت برنامه به سادگی به کار خود ادامه می‌دهد (با مقدار خطا). این امر از “گرفتن ناخواسته” استثناهایی که انتظارشان را ندارید، جلوگیری می‌کند.
  • کنترل محلی: مدیریت خطا به جای تمرکز در یک بلوک `try-catch` بزرگ، در نزدیکی نقطه وقوع خطا انجام می‌شود. این امر باعث می‌شود کد خواناتر و قابل نگهداری‌تر باشد.
  • قابلیت ردیابی آسان: با توجه به اینکه خطاها مقادیر هستند، می‌توان آنها را به راحتی منتقل کرد، لاگ گرفت، یا در ساختارهای داده قرار داد.

معایب:

  • تکرار کد (Boilerplate): الگوی `if err != nil` می‌تواند منجر به کپی‌پیست شدن کد در بسیاری از نقاط شود، به خصوص در توابع بزرگ که چندین عملیات ممکن است خطا برگردانند.
  • نادیده گرفتن آسان خطاها: اگر توسعه‌دهنده به طور عمدی یا سهوی بررسی `if err != nil` را فراموش کند، خطا به سادگی نادیده گرفته می‌شود و برنامه بدون هشدار ادامه می‌یابد، که می‌تواند منجر به رفتارهای غیرمنتظره شود. (هرچند ابزارهای تحلیل استاتیک مانند `go vet` می‌توانند به شناسایی این موارد کمک کنند).
  • عدم انتقال خودکار پشته فراخوانی: برخلاف استثناها که معمولاً اطلاعات پشته فراخوانی را به همراه دارند، خطاهای Go به طور پیش‌فرض این اطلاعات را ندارند، مگر اینکه به طور صریح با مکانیسم‌هایی مانند `fmt.Errorf` با `%w` اضافه شوند.

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

رابط `error` در Go: سادگی و قدرت

در Go، `error` یک رابط (interface) توکار است که به طرز شگفت‌آوری ساده است. این رابط تنها یک متد به نام `Error()` را تعریف می‌کند که یک رشته (string) را بازمی‌گرداند. این رشته باید توضیحی خوانا از خطا ارائه دهد.

package main

import "fmt"

// تعریف رابط error
// type error interface {
//     Error() string
// }

// MyCustomError یک نوع خطای سفارشی است.
type MyCustomError struct {
	Code    int
	Message string
}

// متد Error() را برای MyCustomError پیاده سازی می کند
func (e *MyCustomError) Error() string {
	return fmt.Sprintf("خطای سفارشی [کد: %d]: %s", e.Code, e.Message)
}

// تابعی که یک MyCustomError برمی گرداند
func performOperation(input int) error {
	if input < 0 {
		return &MyCustomError{Code: 1001, Message: "ورودی نمی تواند منفی باشد"}
	}
	if input > 100 {
		return &MyCustomError{Code: 1002, Message: "ورودی خیلی بزرگ است"}
	}
	return nil // عملیات موفقیت آمیز
}

func main() {
	err := performOperation(-5)
	if err != nil {
		fmt.Println(err) // خروجی: خطای سفارشی [کد: 1001]: ورودی نمی تواند منفی باشد
		// می توانیم نوع خطا را نیز بررسی کنیم
		if customErr, ok := err.(*MyCustomError); ok {
			fmt.Printf("کد خطا: %d\n", customErr.Code) // خروجی: کد خطا: 1001
		}
	}

	err = performOperation(200)
	if err != nil {
		fmt.Println(err) // خروجی: خطای سفارشی [کد: 1002]: ورودی خیلی بزرگ است
	}

	err = performOperation(50)
	if err == nil {
		fmt.Println("عملیات موفقیت آمیز.") // خروجی: عملیات موفقیت آمیز.
	}
}

این سادگی به توسعه‌دهندگان اجازه می‌دهد تا انواع خطاهای سفارشی خود را ایجاد کنند که می‌توانند اطلاعات اضافی را فراتر از یک رشته ساده حمل کنند. تا زمانی که یک نوع ساختار دارای متد `Error() string` باشد، می‌تواند به عنوان یک `error` در Go رفتار کند. این انعطاف‌پذیری برای مدل‌سازی خطاهای خاص دامنه برنامه بسیار قدرتمند است.

معمولاً برای ایجاد خطاهای ساده و موقتی، از تابع `errors.New` یا `fmt.Errorf` استفاده می‌شود. `errors.New` یک خطای ساده ایجاد می‌کند که فقط حاوی یک رشته است. `fmt.Errorf` همان کار را انجام می‌دهد اما با قابلیت قالب‌بندی رشته، شبیه `fmt.Sprintf`.

package main

import (
	"errors"
	"fmt"
)

func processData(data int) error {
	if data < 0 {
		return errors.New("داده نمی تواند منفی باشد")
	}
	if data == 0 {
		return fmt.Errorf("داده صفر است: ورودی نامعتبر")
	}
	// ... پردازش داده ...
	return nil
}

func main() {
	if err := processData(-1); err != nil {
		fmt.Println(err) // خروجی: داده نمی تواند منفی باشد
	}

	if err := processData(0); err != nil {
		fmt.Println(err) // خروجی: داده صفر است: ورودی نامعتبر
	}

	if err := processData(10); err == nil {
		fmt.Println("داده با موفقیت پردازش شد.") // خروجی: داده با موفقیت پردازش شد.
	}
}

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

مدیریت خطاهای رایج با `if err != nil`

همانطور که قبلاً گفته شد، الگوی `if err != nil` قلب مدیریت خطا در Go است. این الگو به قدری اساسی و رایج است که هر توسعه‌دهنده Go باید آن را به طور غریزی درک و پیاده‌سازی کند. این الگو تضمین می‌کند که هر خطای احتمالی در جریان برنامه شناسایی و به طور مناسب مدیریت شود.

package main

import (
	"fmt"
	"io"
	"os"
)

// تابع برای خواندن محتوای یک فایل
func readFileContent(filename string) ([]byte, error) {
	file, err := os.Open(filename)
	if err != nil {
		// خطا در باز کردن فایل، آن را برمی گردانیم
		return nil, fmt.Errorf("باز کردن فایل %s ناموفق بود: %w", filename, err)
	}
	// از defer برای بستن فایل استفاده می کنیم تا از نشت منابع جلوگیری شود
	defer func() {
		if closeErr := file.Close(); closeErr != nil {
			fmt.Printf("خطا در بستن فایل %s: %v\n", filename, closeErr)
			// در اینجا می توانید خطا را لاگ کنید یا به طریق دیگری مدیریت کنید
			// توجه: در توابع واقعی، معمولاً خطای close را با خطای اصلی ترکیب نمی کنیم
			// مگر اینکه این یک خطای بحرانی باشد که نشان دهنده وضعیت نامعتبری است.
		}
	}()

	// خواندن تمام محتوای فایل
	content, err := io.ReadAll(file)
	if err != nil {
		// خطا در خواندن فایل، آن را برمی گردانیم
		return nil, fmt.Errorf("خواندن محتوای فایل %s ناموفق بود: %w", filename, err)
	}

	return content, nil
}

func main() {
	// سناریو 1: فایل وجود ندارد
	_, err := readFileContent("nonexistent.txt")
	if err != nil {
		fmt.Printf("سناریو 1 - خطا: %v\n", err)
		// مثال خروجی: سناریو 1 - خطا: باز کردن فایل nonexistent.txt ناموفق بود: open nonexistent.txt: no such file or directory
	}

	// سناریو 2: فایل موجود است (فرض کنید file.txt از قبل ایجاد شده است)
	// ایجاد یک فایل موقت برای تست
	tempFile, createErr := os.Create("test_file.txt")
	if createErr != nil {
		fmt.Printf("خطا در ایجاد فایل موقت: %v\n", createErr)
		return
	}
	_, writeErr := tempFile.WriteString("این یک محتوای آزمایشی است.")
	if writeErr != nil {
		fmt.Printf("خطا در نوشتن در فایل موقت: %v\n", writeErr)
		tempFile.Close() // مطمئن شویم بسته می شود
		os.Remove("test_file.txt") // و حذف می شود
		return
	}
	tempFile.Close() // فایل را ببندید تا تغییرات ذخیره شوند
	defer os.Remove("test_file.txt") // اطمینان از حذف فایل پس از اتمام

	content, err := readFileContent("test_file.txt")
	if err != nil {
		fmt.Printf("سناریو 2 - خطا: %v\n", err)
	} else {
		fmt.Printf("سناریو 2 - محتوای فایل: %s\n", string(content))
		// مثال خروجی: سناریو 2 - محتوای فایل: این یک محتوای آزمایشی است.
	}
}

در مثال بالا، تابع `readFileContent` دو بار `if err != nil` را برای بررسی خطاها استفاده می‌کند: یک بار برای `os.Open` و یک بار برای `io.ReadAll`. این رویکرد به طور صریح هر مرحله از عملیات را که ممکن است با شکست مواجه شود، بررسی می‌کند و در صورت لزوم، خطای مناسب را به فراخواننده بازمی‌گرداند. استفاده از `defer` در اینجا برای اطمینان از بسته شدن فایل، حتی در صورت بروز خطا، نیز یک الگوی مهم است.

یکی از انتقاداتی که به این الگو وارد می‌شود، "Boilerplate" بودن آن است، به این معنی که ممکن است نیاز باشد این خطوط کد بارها و بارها تکرار شوند. با این حال، در Go، این تکرار به عنوان یک ویژگی در نظر گرفته می‌شود که کد را خواناتر و جریان خطا را شفاف‌تر می‌کند. این وضوح به توسعه‌دهنده کمک می‌کند تا دقیقاً بداند چه چیزی در حال رخ دادن است و چه چیزی باید مدیریت شود.

مدیریت خطاهای زنجیره‌ای و زمینه (Context)

هنگامی که خطاهای متعددی در یک زنجیره از فراخوانی توابع رخ می‌دهند، مهم است که اطلاعات زمینه‌ای (contextual information) را به خطا اضافه کنیم تا ردیابی و اشکال‌زدایی آن آسان‌تر شود. `fmt.Errorf` با فرمت `%w` (که از Go 1.13 معرفی شد) به ما امکان می‌دهد یک خطا را در یک خطای جدید بپیچیم و زنجیره‌ای از خطاها ایجاد کنیم. این کار برای حفظ پشته اصلی خطا مفید است.

package main

import (
	"fmt"
	"os"
)

// تابع فرعی که ممکن است خطا برگرداند
func performSubOperation(path string) error {
	_, err := os.Stat(path) // سعی می کنیم اطلاعات فایل را بگیریم
	if err != nil {
		// خطا را با اضافه کردن زمینه به آن می پیچیم.
		// %w برای "wrapping" error استفاده می شود.
		return fmt.Errorf("خطا در انجام عملیات فرعی بر روی %s: %w", path, err)
	}
	return nil
}

// تابع اصلی که تابع فرعی را فراخوانی می کند
func performMainOperation(filename string) error {
	err := performSubOperation(filename)
	if err != nil {
		// خطای پیچیده شده را مجدداً می پیچیم یا مستقیماً برمی گردانیم
		return fmt.Errorf("خطا در انجام عملیات اصلی برای فایل %s: %w", filename, err)
	}
	return nil
}

func main() {
	if err := performMainOperation("nonexistent_file.txt"); err != nil {
		fmt.Printf("خطای کلی: %v\n", err)

		// برای دسترسی به خطاهای پیچیده شده، از errors.Is و errors.As استفاده می کنیم.
		// errors.Is: بررسی می کند که آیا یک خطا در زنجیره خطا با یک خطای خاص مطابقت دارد.
		if os.IsNotExist(err) {
			fmt.Println("فایل وجود ندارد (شناسایی با os.IsNotExist)")
		}

		// errors.As: سعی می کند یک خطا را به یک نوع خاص تبدیل کند.
		// اگر در زنجیره خطا یک خطای از نوع *os.PathError وجود داشته باشد، آن را استخراج می کند.
		var pathError *os.PathError
		if errors.As(err, &pathError) {
			fmt.Printf("نوع خطا: *os.PathError, مسیر: %s, Op: %s, منبع خطا: %v\n", pathError.Path, pathError.Op, pathError.Err)
		}
	}
}

این مثال نشان می‌دهد که چگونه می‌توان یک خطا را با استفاده از `%w` در `fmt.Errorf` پیچید و سپس با `errors.Is` و `errors.As` آن را بررسی کرد. این قابلیت برای دیباگینگ و اتوماسیون (مانند تصمیم‌گیری بر اساس نوع خطای اصلی) بسیار قدرتمند است.

خطاهای Sentinel و خطاهای سفارشی

در Go، دو نوع اصلی از خطاها که توسعه‌دهندگان به طور گسترده‌ای از آن‌ها استفاده می‌کنند، خطاهای Sentinel (نگهبان) و خطاهای سفارشی (custom errors) هستند.

خطاهای Sentinel

خطاهای Sentinel (مانند `io.EOF` یا `os.ErrNotExist`) خطاهایی هستند که به صورت متغیرهای عمومی تعریف می‌شوند و برای نشان دادن شرایط خاصی که می‌توانند به طور برنامه‌نویسی بررسی شوند، استفاده می‌شوند. این خطاها معمولاً در سطح بسته تعریف می‌شوند و با استفاده از برابری مستقیم (==) یا با `errors.Is` (که از Go 1.13 به بعد توصیه می‌شود) مقایسه می‌شوند.

package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"strings"
)

// یک خطای Sentinel سفارشی تعریف می کنیم
var ErrInvalidInput = errors.New("ورودی نامعتبر است")

func processLine(line string) error {
	if strings.TrimSpace(line) == "" {
		return ErrInvalidInput // برگرداندن خطای Sentinel ما
	}
	if len(line) > 50 {
		return fmt.Errorf("خط خیلی طولانی است: %d کاراکتر", len(line))
	}
	fmt.Printf("پردازش خط: %s\n", line)
	return nil
}

func readAndProcess(reader io.Reader) error {
	scanner := bufio.NewScanner(reader)
	for scanner.Scan() {
		line := scanner.Text()
		err := processLine(line)
		if err != nil {
			// بررسی خطای Sentinel
			if errors.Is(err, ErrInvalidInput) {
				fmt.Printf("خطا در خط: '%s' - %v. نادیده گرفته شد.\n", line, err)
				continue // ادامه به خط بعدی
			}
			// بررسی خطای io.EOF که توسط scanner.Scan() برگردانده می شود اگر در پایان فایل باشیم
			// هرچند scanner.Scan() به طور معمول io.EOF را مستقیماً برنمی گرداند،
			// اگر خطا به دلیلی رخ دهد، از scanner.Err() استفاده می کنیم.
			if err == io.EOF { // با errors.Is نیز می توان بررسی کرد
				fmt.Println("پایان فایل رسید.")
				return nil
			}
			// اگر خطای دیگری باشد، آن را بازگردانیم
			return fmt.Errorf("خطا در پردازش خط '%s': %w", line, err)
		}
	}
	if err := scanner.Err(); err != nil {
		return fmt.Errorf("خطا در اسکنر: %w", err)
	}
	return nil
}

func main() {
	input := `
خط اول
خط دوم
	
خط سوم که خیلی طولانی است و از ۵۰ کاراکتر بیشتر است و باید خطا برگرداند و توسط ما مدیریت شود
پایان
`
	err := readAndProcess(strings.NewReader(input))
	if err != nil {
		fmt.Printf("خطای کلی در خواندن و پردازش: %v\n", err)
	}
}

استفاده از `errors.Is` (که جایگزین `err == ErrX` برای خطاهای پیچیده شده می‌شود) برای مقایسه خطاهای Sentinel بسیار مهم است، زیرا به شما امکان می‌دهد خطاهایی را که در یک زنجیره پیچیده شده‌اند، شناسایی کنید. `errors.Is(err, target)` اگر `err` یا هر یک از خطاهای زیرین آن در زنجیره خطا با `target` برابر باشد، `true` را برمی‌گرداند.

خطاهای سفارشی (Custom Error Types)

خطاهای سفارشی (که در بخش "رابط `error` در Go" به آن‌ها اشاره شد) زمانی مفید هستند که شما نیاز دارید اطلاعات بیشتری از یک رشته ساده خطا را حمل کنید یا زمانی که می‌خواهید بتوانید به طور برنامه‌نویسی نوع خاصی از خطا را شناسایی کرده و بر اساس آن اقدام کنید. این خطاها معمولاً به صورت یک `struct` تعریف می‌شوند که متد `Error() string` را پیاده‌سازی می‌کند.

package main

import (
	"errors"
	"fmt"
)

// DBError یک نوع خطای سفارشی برای خطاهای پایگاه داده است.
type DBError struct {
	Operation string
	Query     string
	Err       error // خطای اصلی که از درایور DB برگشته است
}

// پیاده سازی متد Error() برای DBError
func (e *DBError) Error() string {
	return fmt.Sprintf("خطای پایگاه داده در '%s' برای کوئری '%s': %v", e.Operation, e.Query, e.Err)
}

// Unwrap متد Unwrap را برای پشتیبانی از errors.Is و errors.As پیاده سازی می کند.
func (e *DBError) Unwrap() error {
	return e.Err
}

// تابع شبیه سازی شده برای اجرای کوئری پایگاه داده
func executeQuery(query string) error {
	// شبیه سازی یک خطای پایگاه داده
	if query == "SELECT * FROM users WHERE id = 'invalid'" {
		// این می تواند یک خطای واقعی از یک درایور پایگاه داده باشد
		underlyingErr := errors.New("خطای نحوی SQL یا ورودی نامعتبر")
		return &DBError{
			Operation: "اجرا",
			Query:     query,
			Err:       underlyingErr,
		}
	}
	return nil
}

func main() {
	query := "SELECT * FROM users WHERE id = 'invalid'"
	err := executeQuery(query)

	if err != nil {
		fmt.Printf("خطای دریافت شده: %v\n", err)

		// استفاده از errors.As برای بررسی اینکه آیا خطای دریافتی از نوع DBError است و استخراج آن.
		var dbErr *DBError
		if errors.As(err, &dbErr) {
			fmt.Printf("نوع خطا: DBError\n")
			fmt.Printf("عملیات: %s\n", dbErr.Operation)
			fmt.Printf("کوئری: %s\n", dbErr.Query)
			fmt.Printf("خطای اصلی: %v\n", dbErr.Err)

			// می توانیم خطای اصلی را نیز بررسی کنیم، به عنوان مثال، آیا خطای SQL است؟
			if dbErr.Err.Error() == "خطای نحوی SQL یا ورودی نامعتبر" {
				fmt.Println("این یک خطای SQL است!")
			}
		}

		// با وجود اینکه DBError پیچیده شده، errors.Is می تواند خطای زیرین را نیز بررسی کند
		if errors.Is(err, errors.New("خطای نحوی SQL یا ورودی نامعتبر")) {
			fmt.Println("خطای اصلی 'خطای نحوی SQL یا ورودی نامعتبر' در زنجیره خطا یافت شد.")
		}
	} else {
		fmt.Println("کوئری با موفقیت اجرا شد.")
	}
}

استفاده از `errors.As` زمانی حیاتی است که می‌خواهید به اطلاعات خاص موجود در یک نوع خطای سفارشی دسترسی پیدا کنید، به خصوص زمانی که آن خطای سفارشی ممکن است در یک زنجیره خطا پیچیده شده باشد. `errors.As(err, &target)` اگر `err` یا هر یک از خطاهای زیرین آن در زنجیره خطا با نوع `target` مطابقت داشته باشد، `true` را برمی‌گرداند و `target` را به آن خطا اختصاص می‌دهد.

تفاوت اصلی بین خطاهای Sentinel و خطاهای سفارشی در روش مقایسه و اطلاعاتی است که حمل می‌کنند. خطاهای Sentinel برای برابری (با `errors.Is`) بررسی می‌شوند و معمولاً اطلاعات اضافی را حمل نمی‌کنند. خطاهای سفارشی برای نوع (با `errors.As`) بررسی می‌شوند و می‌توانند داده‌های ساختاریافته‌ای را حمل کنند که برای مدیریت یا اشکال‌زدایی خطا مفید هستند.

پوشش‌دهی خطا (Error Wrapping) در Go 1.13+

یکی از مهمترین پیشرفت‌ها در مدیریت خطا در Go با معرفی Go 1.13 و مفهوم "پوشش‌دهی خطا" (Error Wrapping) بود. قبل از Go 1.13، ردیابی علت اصلی یک خطا در یک زنجیره از فراخوانی توابع دشوار بود. توسعه‌دهندگان مجبور بودند با تجزیه و تحلیل رشته‌های خطا یا ساختارهای خطای سفارشی پیچیده، این اطلاعات را به دست آورند.

پوشش‌دهی خطا به توسعه‌دهندگان اجازه می‌دهد تا یک خطا را در یک خطای جدید "بپیچند" و در عین حال، به خطای اصلی (inner or wrapped error) نیز ارجاع دهند. این قابلیت با متدهای `Unwrap()`، توابع `errors.Is()` و `errors.As()` پشتیبانی می‌شود.

Unwrap()

اگر یک نوع خطا متد `Unwrap() error` را پیاده‌سازی کند، آنگاه `errors.Unwrap()` می‌تواند خطای داخلی را از آن استخراج کند. این به ما اجازه می‌دهد تا یک "زنجیره" از خطاها را تشکیل دهیم و به عقب ردیابی کنیم تا علت اصلی خطا را بیابیم.

package main

import (
	"errors"
	"fmt"
)

// MyWrappedError یک نوع خطای سفارشی است که متد Unwrap را پیاده سازی می کند.
type MyWrappedError struct {
	Msg   string
	Cause error
}

func (e *MyWrappedError) Error() string {
	return fmt.Sprintf("%s: %v", e.Msg, e.Cause)
}

// Unwrap متد Unwrap را پیاده سازی می کند تا به خطای اصلی دسترسی داشته باشیم.
func (e *MyWrappedError) Unwrap() error {
	return e.Cause
}

func operationA() error {
	// این خطای "اصلی" است
	return errors.New("مشکل در دسترسی به سرویس A")
}

func operationB() error {
	errA := operationA()
	if errA != nil {
		// خطای A را در یک خطای جدید می پیچیم
		return &MyWrappedError{
			Msg:   "خطا در عملیات B",
			Cause: errA,
		}
	}
	return nil
}

func operationC() error {
	errB := operationB()
	if errB != nil {
		// خطای B را با استفاده از fmt.Errorf با %w می پیچیم (این نیز Unwrap را پشتیبانی می کند)
		return fmt.Errorf("خطا در عملیات C: %w", errB)
	}
	return nil
}

func main() {
	err := operationC()
	if err != nil {
		fmt.Printf("خطای دریافتی: %v\n", err)

		// استفاده از errors.Unwrap برای رفتن به عقب در زنجیره خطا
		unwrapped := errors.Unwrap(err)
		fmt.Printf("خطای اول unwrapped: %v\n", unwrapped) // خطای MyWrappedError از operationB

		unwrapped = errors.Unwrap(unwrapped)
		fmt.Printf("خطای دوم unwrapped: %v\n", unwrapped) // خطای اصلی از operationA

		// errors.Is: بررسی می کند که آیا خطای اصلی (یا هر خطای پیچیده شده) با یک خطای خاص برابر است.
		if errors.Is(err, errors.New("مشکل در دسترسی به سرویس A")) {
			fmt.Println("خطای 'مشکل در دسترسی به سرویس A' در زنجیره یافت شد.")
		}

		// errors.As: بررسی می کند که آیا خطای اصلی (یا هر خطای پیچیده شده) از یک نوع خاص است.
		var myWrappedErr *MyWrappedError
		if errors.As(err, &myWrappedErr) {
			fmt.Printf("یک MyWrappedError در زنجیره یافت شد: %v\n", myWrappedErr)
			fmt.Printf("پیام MyWrappedError: %s\n", myWrappedErr.Msg)
		}
	}
}

errors.Is()

همانطور که در مثال‌های قبلی نیز مشاهده شد، `errors.Is(err, target)` بررسی می‌کند که آیا `err` (یا هر خطای پیچیده شده در زنجیره آن) با `target` برابر است. این برای مقایسه با خطاهای Sentinel بسیار مفید است، زیرا دیگر نیازی به بررسی تک‌تک خطاهای پیچیده شده به صورت دستی نیست.

errors.As()

`errors.As(err, &target)` بررسی می‌کند که آیا `err` (یا هر خطای پیچیده شده در زنجیره آن) به یک نوع خاص قابل تبدیل است. اگر بله، مقدار خطا را به `target` اختصاص می‌دهد و `true` را برمی‌گرداند. این برای دسترسی به اطلاعات ساختاریافته در خطاهای سفارشی که ممکن است در یک زنجیره از خطاها پنهان شده باشند، ضروری است.

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

`panic` و `recover`: چه زمانی و چگونه؟

در Go، `panic` و `recover` مکانیسم‌هایی هستند که اغلب با مدیریت خطا اشتباه گرفته می‌شوند، اما هدف متفاوتی دارند. `panic` برای شرایط استثنایی و غیرقابل بازیابی در زمان اجرا استفاده می‌شود، در حالی که خطاها (errors) برای شرایط مورد انتظار و قابل بازیابی (مانند فایل پیدا نشد، ورودی نامعتبر) استفاده می‌شوند.

`panic`

وقتی یک تابع `panic` می‌کند، اجرای عادی برنامه متوقف می‌شود و تابع بلافاصله شروع به بازگشت می‌کند. هر تابع `defer` (که در ادامه به آن می‌پردازیم) در پشته فراخوانی اجرا می‌شود تا زمانی که به یک تابع `recover` برخورد کند یا برنامه متوقف شود (با پیام `panic`).

چه زمانی `panic` کنیم؟
`panic` باید برای نشان دادن خطاهایی استفاده شود که نشان دهنده یک اشکال برنامه‌نویسی غیرقابل بازیابی هستند، مانند:

  • دسترسی به ایندکس خارج از محدوده آرایه یا برش.
  • تلاش برای dereference یک اشاره‌گر `nil` (Nil pointer dereference).
  • آرگومان‌های نامعتبر برای یک کتابخانه (در صورتی که برنامه نویس مرتکب اشتباه شده باشد).
  • شروع یک سرویس در حالی که پیکربندی حیاتی آن وجود ندارد.

به طور خلاصه، `panic` برای خطاهایی است که "نمی‌دانید چگونه ادامه دهید".

package main

import "fmt"

func divide(a, b int) int {
	if b == 0 {
		panic("تقسیم بر صفر: مخرج نمی تواند صفر باشد") // panic می کند
	}
	return a / b
}

func main() {
	fmt.Println("شروع برنامه")
	// این خط باعث panic می شود
	// result := divide(10, 0)
	// fmt.Println("نتیجه:", result)

	fmt.Println("پایان برنامه")
}

اگر مثال بالا را اجرا کنید (با فراخوانی `divide(10,0)`)، برنامه با یک پیام `panic` و یک Traceback کامل متوقف می‌شود.

`recover`

`recover` تابعی است که تنها زمانی مفید است که در یک تابع `defer` فراخوانی شود. هنگامی که `recover` فراخوانی می‌شود، می‌تواند مقدار panic را متوقف کند و جریان کنترل عادی را از سر بگیرد. اگر `recover` در یک تابع `defer` فراخوانی نشود، یا اگر هیچ `panic` در حال انجام نباشد، `recover` مقدار `nil` را برمی‌گرداند و هیچ اثری ندارد.

چه زمانی `recover` کنیم؟
`recover` به ندرت در کدهای برنامه‌های کاربردی رایج Go استفاده می‌شود. موارد استفاده اصلی آن عبارتند از:

  • بازیابی از یک خطای زمان اجرا در سطح یک سرویس: به عنوان مثال، در یک سرور HTTP، ممکن است بخواهید یک درخواست را از یک `panic` در یک handler خاص بازیابی کنید تا سرور به طور کامل از کار نیفتد.
  • در کتابخانه‌ها: یک کتابخانه ممکن است از `panic` برای نشان دادن یک مشکل داخلی استفاده کند، اما انتظار دارد که فراخواننده از `recover` برای تبدیل آن `panic` به یک `error` استفاده کند تا رفتار متعارف Go را حفظ کند.
package main

import (
	"fmt"
	"log"
)

// تابع divide که اکنون از panic/recover استفاده می کند
func divideWithRecovery(a, b int) (result int, err error) {
	// defer برای بازیابی از panic
	defer func() {
		if r := recover(); r != nil {
			// r مقدار panic را حمل می کند
			log.Printf("یک panic اتفاق افتاد: %v", r)
			err = fmt.Errorf("خطای داخلی سرور: %v", r) // تبدیل panic به error
		}
	}()

	if b == 0 {
		panic("تقسیم بر صفر: مخرج نمی تواند صفر باشد")
	}
	result = a / b
	return result, nil
}

func main() {
	fmt.Println("شروع برنامه اصلی")

	// مثال موفقیت آمیز
	res, err := divideWithRecovery(10, 2)
	if err != nil {
		fmt.Println("خطا:", err)
	} else {
		fmt.Println("نتیجه تقسیم (10/2):", res) // خروجی: نتیجه تقسیم (10/2): 5
	}

	fmt.Println("---")

	// مثال با panic و بازیابی
	res, err = divideWithRecovery(10, 0)
	if err != nil {
		fmt.Println("خطا:", err) // خروجی: خطا: خطای داخلی سرور: تقسیم بر صفر: مخرج نمی تواند صفر باشد
	} else {
		fmt.Println("نتیجه تقسیم (10/0):", res)
	}

	fmt.Println("---")

	// برنامه به کار خود ادامه می دهد
	fmt.Println("برنامه اصلی ادامه یافت.")
}

در این مثال، `divideWithRecovery` در صورت تقسیم بر صفر `panic` می‌کند، اما بلوک `defer` با `recover()` آن را می‌گیرد و `panic` را به یک `error` استاندارد Go تبدیل می‌کند. این اجازه می‌دهد تا برنامه اصلی به کار خود ادامه دهد.

قانون طلایی: از `panic` برای خطاهای مورد انتظار و قابل بازیابی استفاده نکنید. برای این موارد از مقادیر `error` استفاده کنید. `panic` برای شرایطی است که برنامه نمی‌تواند به طور منطقی ادامه یابد.

استراتژی‌های پیشرفته و بهترین شیوه‌ها

پس از درک مفاهیم بنیادی، بیایید به استراتژی‌های پیشرفته‌تر و بهترین شیوه‌ها برای مدیریت خطای قوی در Go بپردازیم.

۱. خطاها را در سطح مناسب بازگردانید (Error Propagation)

یک اصل کلیدی در Go این است که خطاها را تا زمانی که نیاز به مدیریت خاصی دارند، به بالا (به فراخواننده) منتقل کنید. "مدیریت خاص" می‌تواند شامل لاگ کردن، تبدیل به خطای دیگر، یا ارائه بازخورد به کاربر باشد. اگر تابعی خطایی دریافت می‌کند و نمی‌داند چگونه آن را مدیریت کند، معمولاً باید آن خطا را به فراخواننده خود برگرداند.

package main

import (
	"errors"
	"fmt"
)

// تابع سطح پایین
func lowLevelOperation(value int) error {
	if value < 0 {
		return errors.New("خطا: مقدار منفی در عملیات سطح پایین")
	}
	fmt.Println("عملیات سطح پایین با موفقیت انجام شد.")
	return nil
}

// تابع سطح میانی
func midLevelOperation(data int) error {
	err := lowLevelOperation(data)
	if err != nil {
		// خطای سطح پایین را می پیچیم و context اضافه می کنیم
		return fmt.Errorf("خطا در عملیات سطح میانی هنگام پردازش %d: %w", data, err)
	}
	fmt.Println("عملیات سطح میانی با موفقیت انجام شد.")
	return nil
}

// تابع سطح بالا (جایی که خطا مدیریت نهایی می شود)
func highLevelOperation(input int) error {
	err := midLevelOperation(input)
	if err != nil {
		// در اینجا تصمیم می گیریم که چگونه با خطا برخورد کنیم.
		// می توانیم لاگ کنیم، به کاربر اطلاع دهیم، یا به طور کلی شکست بخوریم.
		fmt.Printf("عملیات سطح بالا با خطا مواجه شد: %v\n", err)

		// می توانیم نوع خطای اصلی را بررسی کنیم
		if errors.Is(err, errors.New("خطا: مقدار منفی در عملیات سطح پایین")) {
			fmt.Println("نوع خطا: مقدار ورودی نامعتبر بود.")
			// اینجا می توانیم یک خطای "کاربرپسند" برگردانیم
			return fmt.Errorf("خطا در ورودی کاربر: %w", err)
		}
		return err // خطای اصلی را برگردانید اگر مدیریت خاصی ندارید
	}
	fmt.Println("عملیات سطح بالا با موفقیت انجام شد.")
	return nil
}

func main() {
	// سناریو 1: موفقیت
	fmt.Println("--- سناریو 1: موفقیت ---")
	if err := highLevelOperation(10); err != nil {
		fmt.Println("خطای نهایی:", err)
	}

	fmt.Println("\n--- سناریو 2: خطا ---")
	// سناریو 2: خطا (مقدار منفی)
	if err := highLevelOperation(-5); err != nil {
		fmt.Println("خطای نهایی:", err)
	}
}

در این مثال، خطا از `lowLevelOperation` به `midLevelOperation` و سپس به `highLevelOperation` منتقل می‌شود. در هر سطح، اطلاعات بیشتری به خطا اضافه می‌شود (با `fmt.Errorf` و `%w`) تا ردیابی آن در زمان اشکال‌زدایی آسان‌تر شود.

۲. لاگ کردن خطاها

لاگ کردن خطاها برای نظارت بر وضعیت برنامه و اشکال‌زدایی مسائل حیاتی است. در Go، از بسته `log` استاندارد یا کتابخانه‌های لاگینگ ساختاریافته (مانند `logrus`, `zap`, `zerolog`) برای لاگ کردن خطاها استفاده می‌شود.

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

package main

import (
	"fmt"
	"log"
	"os"
)

// این تابع فقط خطا را برمی گرداند، لاگ نمی کند
func performDBQuery(query string) error {
	if query == "" {
		return fmt.Errorf("کوئری پایگاه داده نمی تواند خالی باشد")
	}
	// شبیه سازی خطای DB
	if query == "INVALID" {
		return fmt.Errorf("خطای اتصال به پایگاه داده")
	}
	fmt.Println("کوئری با موفقیت اجرا شد:", query)
	return nil
}

// این تابع خطا را لاگ می کند و به فراخواننده اطلاع می دهد که عملیات شکست خورده است
func processUserRequest(userID string, query string) (bool, error) {
	err := performDBQuery(query)
	if err != nil {
		// این نقطه "مسئولیت" است - ما اینجا خطا را مدیریت نهایی می کنیم
		log.Printf("خطا در پردازش درخواست برای کاربر %s: %v", userID, err)
		return false, fmt.Errorf("پردازش درخواست کاربر %s ناموفق بود", userID)
	}
	return true, nil
}

func main() {
	// پیکربندی لاگر برای خروجی به stderr
	log.SetOutput(os.Stderr)
	log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)

	fmt.Println("شروع پردازش درخواست ها...")

	// سناریو 1: موفقیت
	success, err := processUserRequest("user123", "SELECT * FROM users")
	if err != nil {
		fmt.Println("خطا در برنامه اصلی:", err)
	} else {
		fmt.Println("درخواست user123 موفقیت آمیز بود:", success)
	}

	fmt.Println("---")

	// سناریو 2: خطا
	success, err = processUserRequest("user456", "INVALID")
	if err != nil {
		fmt.Println("خطا در برنامه اصلی:", err)
	} else {
		fmt.Println("درخواست user456 موفقیت آمیز بود:", success)
	}

	fmt.Println("\nپایان برنامه.")
}

در مثال بالا، `performDBQuery` صرفاً خطا را برمی‌گرداند. `processUserRequest` مسئولیت لاگ کردن خطا را بر عهده دارد زیرا این تابع تصمیم می‌گیرد که دیگر نیازی نیست خطا به بالاتر منتقل شود و می‌تواند به کاربر بازخورد مناسب دهد.

۳. استفاده از `defer` برای پاکسازی منابع

دستور `defer` در Go تضمین می‌کند که یک فراخوانی تابع در پایان تابع حاوی آن اجرا می‌شود، صرف نظر از اینکه تابع به طور عادی برگردد یا با `panic` مواجه شود. این برای پاکسازی منابع (مانند بستن فایل‌ها، اتصال‌های شبکه، یا قفل‌ها) بسیار مفید است.

package main

import (
	"fmt"
	"os"
)

func writeDataToFile(filename string, data string) (err error) {
	file, err := os.Create(filename)
	if err != nil {
		return fmt.Errorf("ایجاد فایل %s ناموفق بود: %w", filename, err)
	}
	// defer تضمین می کند که فایل بسته خواهد شد، حتی اگر نوشتن با خطا مواجه شود یا تابع panic کند.
	defer func() {
		if closeErr := file.Close(); closeErr != nil {
			// در اینجا، ما باید تصمیم بگیریم که آیا خطای بستن مهمتر از خطای اصلی است یا خیر.
			// معمولاً، اگر خطای اصلی وجود نداشته باشد و فقط خطای بستن باشد، آن را بازمی گردانیم.
			// اگر خطای اصلی (err) وجود داشته باشد، خطای بستن را لاگ می کنیم و خطای اصلی را باز می گردانیم.
			if err == nil { // اگر تا این نقطه خطایی نداشته ایم
				err = fmt.Errorf("خطا در بستن فایل %s: %w", filename, closeErr)
			} else {
				// اگر قبلا خطا داشته ایم، خطای بستن را لاگ می کنیم اما خطای اصلی را حفظ می کنیم.
				fmt.Printf("خطا در بستن فایل %s بعد از خطای اصلی: %v\n", filename, closeErr)
			}
		}
	}()

	_, err = file.WriteString(data)
	if err != nil {
		return fmt.Errorf("نوشتن در فایل %s ناموفق بود: %w", filename, err)
	}

	return nil
}

func main() {
	// سناریو 1: موفقیت
	fmt.Println("--- سناریو 1: موفقیت ---")
	if err := writeDataToFile("output1.txt", "این یک تست موفقیت آمیز است."); err != nil {
		fmt.Println("خطا:", err)
	} else {
		fmt.Println("نوشتن موفقیت آمیز بود.")
		os.Remove("output1.txt") // پاکسازی
	}

	// سناریو 2: شبیه سازی خطای نوشتن (فرض کنید دیسک پر است یا مجوز نداریم)
	// در Go واقعی، شبیه سازی یک خطای نوشتن در فایل بدون تغییر سطح سیستم عامل دشوار است.
	// این فقط یک مثال تئوری است که defer چگونه در صورت بروز خطای میانی کار می کند.
	fmt.Println("\n--- سناریو 2: شبیه سازی خطا ---")
	// برای تست واقعی این سناریو، می توانید یک فایل را به صورت read-only باز کنید و سعی کنید در آن بنویسید.
	// به خاطر سادگی، این بخش فقط نشان می دهد که اگر WriteString خطا بدهد، defer باز هم اجرا می شود.
	// فرض کنیم writeDataToFile("invalid_path/output2.txt", "...") یک خطای نوشتن بدهد.
	if err := writeDataToFile("/proc/foo.txt", "این نباید نوشته شود."); err != nil {
		fmt.Println("خطا در برنامه اصلی (انتظار می رفت):", err)
	}
}

نکته مهم در `defer` برای بستن منابع این است که اگر خود `Close()` نیز خطا برگرداند، چگونه با آن برخورد کنیم. قرارداد رایج این است که اگر خطای دیگری قبلاً رخ نداده باشد، خطای `Close()` را بازگردانید. در غیر این صورت، آن را لاگ کنید اما خطای اصلی را حفظ کنید تا از پنهان شدن خطای مهم‌تر جلوگیری شود.

۴. خطاهای Nil و استفاده از Interface ها

در Go، رابط‌ها (interfaces) می‌توانند `nil` باشند. با این حال، یک رابط تنها زمانی `nil` است که هم مقدار و هم نوع آن `nil` باشند. اگر یک رابط `nil` را از یک نوع concret (مانند `*MyCustomError`) در خود نگه دارد، آن رابط `nil` نیست، حتی اگر مقدار `nil` باشد.

package main

import (
	"fmt"
)

type MyError struct {
	Message string
}

func (e *MyError) Error() string {
	return e.Message
}

func returnsNilMyError() *MyError {
	return nil
}

func returnsErrorInterface() error {
	// اینجا یک MyError با مقدار nil را به یک رابط error تبدیل می کنیم.
	// این باعث می شود که رابط error نیل نباشد، حتی اگر مقدار داخلی آن nil باشد.
	return returnsNilMyError()
}

func main() {
	err := returnsErrorInterface()
	if err != nil {
		fmt.Println("خطا nil نیست!") // این خط چاپ می شود
		fmt.Printf("مقدار خطا: %v (نوع: %T)\n", err, err) // خروجی: مقدار خطا:  (نوع: *main.MyError)
	} else {
		fmt.Println("خطا nil است!")
	}

	// در مقابل، این واقعاً nil است
	var anotherErr error
	if anotherErr == nil {
		fmt.Println("anotherErr واقعاً nil است.")
	}
}

این یک خطای رایج برای تازه کاران Go است. همیشه مطمئن شوید که وقتی می‌خواهید یک خطای `nil` برگردانید، واقعاً یک `nil` از نوع `error` برمی‌گردانید، نه یک نوع سفارشی که مقدار آن `nil` است.

۵. تست کردن مسیرهای خطا

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

  • خطاهای شبکه
  • خطاهای دیسک/I/O
  • ورودی‌های نامعتبر
  • خطاهای سرویس‌های خارجی
  • خطاهایی که از لایه‌های پایین‌تر به بالا منتقل می‌شوند.

برای تست خطاها، می‌توانید از Mocking یا Interface ها برای تزریق وابستگی‌های تست شده یا از ساختارهای `Test` برای شبیه‌سازی شرایط خطا استفاده کنید.

package main

import (
	"errors"
	"fmt"
)

// DataProcessor اینترفیسی برای پردازش داده ها
type DataProcessor interface {
	Process(data string) (string, error)
}

// RealProcessor یک پیاده سازی واقعی از DataProcessor
type RealProcessor struct{}

func (rp *RealProcessor) Process(data string) (string, error) {
	if data == "" {
		return "", errors.New("داده نمی تواند خالی باشد")
	}
	if data == "fail" {
		return "", fmt.Errorf("خطا در پردازش 'fail'")
	}
	return "processed_" + data, nil
}

// MockProcessor یک پیاده سازی Mock برای DataProcessor (برای تست)
type MockProcessor struct {
	Result string
	Err    error
}

func (mp *MockProcessor) Process(data string) (string, error) {
	return mp.Result, mp.Err
}

// سرویس اصلی که از DataProcessor استفاده می کند
type MyService struct {
	Processor DataProcessor
}

func (ms *MyService) HandleRequest(input string) (string, error) {
	output, err := ms.Processor.Process(input)
	if err != nil {
		return "", fmt.Errorf("خطا در MyService هنگام پردازش درخواست '%s': %w", input, err)
	}
	return output, nil
}

func main() {
	fmt.Println("--- تست با RealProcessor (موفقیت) ---")
	realService := &MyService{Processor: &RealProcessor{}}
	res, err := realService.HandleRequest("some_data")
	if err != nil {
		fmt.Println("خطا:", err)
	} else {
		fmt.Println("نتیجه:", res)
	}

	fmt.Println("\n--- تست با MockProcessor (سناریو خطا) ---")
	mockService := &MyService{
		Processor: &MockProcessor{
			Result: "",
			Err:    errors.New("خطای ساختگی از MockProcessor"),
		},
	}
	res, err = mockService.HandleRequest("mock_data")
	if err != nil {
		fmt.Println("خطا:", err)
		if errors.Is(err, errors.New("خطای ساختگی از MockProcessor")) {
			fmt.Println("خطای اصلی از MockProcessor شناسایی شد.")
		}
	} else {
		fmt.Println("نتیجه:", res)
	}
}

۶. پرهیز از نادیده گرفتن خطاها

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

package main

import "fmt"

func riskyOperation() error {
	// عملیاتی که ممکن است خطا برگرداند
	return fmt.Errorf("خطای شبیه سازی شده در عملیات پرخطر")
}

func main() {
	// اشتباه: نادیده گرفتن خطا
	// riskyOperation() // خطا نادیده گرفته شد!

	// صحیح: بررسی و مدیریت خطا
	if err := riskyOperation(); err != nil {
		fmt.Println("خطا در عملیات پرخطر:", err)
	} else {
		fmt.Println("عملیات پرخطر موفقیت آمیز بود.")
	}

	// حتی اگر مطمئن هستید که خطا رخ نمی‌دهد، بهتر است آن را لاگ کنید.
	// البته، `go vet` به طور پیش فرض این موارد را شناسایی می کند.
}

ابزارهای تحلیل استاتیک مانند `go vet` می‌توانند به شناسایی فراخوانی‌هایی که مقادیر خطا را برمی‌گردانند اما آنها را بررسی نمی‌کنند، کمک کنند. این ابزارها باید بخشی جدایی‌ناپذیر از گردش کار توسعه Go شما باشند.

۷. استفاده از Error Group برای عملیات موازی

هنگامی که چندین عملیات را به صورت موازی اجرا می‌کنید (به عنوان مثال، با استفاده از Goroutine ها)، مدیریت خطا می‌تواند پیچیده شود. بسته `golang.org/x/sync/errgroup` ابزاری عالی برای مدیریت خطاها و context در عملیات همزمان است.

package main

import (
	"context"
	"errors"
	"fmt"
	"golang.org/x/sync/errgroup"
	"time"
)

func fetchUser(userID string) (string, error) {
	if userID == "error_user" {
		time.Sleep(50 * time.Millisecond) // شبیه سازی تاخیر و خطا
		return "", fmt.Errorf("خطا در واکشی کاربر %s", userID)
	}
	time.Sleep(100 * time.Millisecond)
	return fmt.Sprintf("کاربر %s", userID), nil
}

func fetchOrder(orderID string) (string, error) {
	if orderID == "error_order" {
		time.Sleep(30 * time.Millisecond) // شبیه سازی تاخیر و خطا
		return "", fmt.Errorf("خطا در واکشی سفارش %s", orderID)
	}
	time.Sleep(80 * time.Millisecond)
	return fmt.Sprintf("سفارش %s", orderID), nil
}

func main() {
	ctx := context.Background()
	group, gCtx := errgroup.WithContext(ctx) // یک context جدید با cancelation بر اساس خطای اول

	var userResult string
	group.Go(func() error {
		res, err := fetchUser("user123")
		if err != nil {
			return err
		}
		userResult = res
		return nil
	})

	var orderResult string
	group.Go(func() error {
		res, err := fetchOrder("order456")
		if err != nil {
			return err
		}
		orderResult = res
		return nil
	})

	// عملیات با خطا
	group.Go(func() error {
		_, err := fetchUser("error_user") // این یکی خطا برمی گرداند
		if err != nil {
			return err
		}
		return nil
	})

	// منتظر می مانیم تا تمام goroutine ها کامل شوند یا اولین خطا رخ دهد
	if err := group.Wait(); err != nil {
		fmt.Printf("عملیات موازی با خطا مواجه شد: %v\n", err)
		// errors.Is/As را می توان در اینجا نیز استفاده کرد
		if errors.Is(err, fmt.Errorf("خطا در واکشی کاربر error_user")) {
			fmt.Println("خطای خاص 'error_user' شناسایی شد.")
		}
	} else {
		fmt.Println("تمام عملیات با موفقیت انجام شد.")
		fmt.Printf("نتایج: %s, %s\n", userResult, orderResult)
	}
}

`errgroup.WithContext` یک Context جدید ایجاد می‌کند که وقتی اولین goroutine یک خطا را بازمی‌گرداند، لغو می‌شود. این اجازه می‌دهد تا goroutine های در حال اجرا به طور آگاهانه کار خود را متوقف کنند و از هدر رفتن منابع جلوگیری شود. `group.Wait()` تا زمانی که تمام goroutine ها کامل شوند یا اولین خطا رخ دهد، بلاک می‌کند. اگر خطایی رخ دهد، `Wait()` آن خطا را برمی‌گرداند.

نتیجه‌گیری

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

با استفاده از رابط `error` ساده اما انعطاف‌پذیر، قابلیت‌های پوشش‌دهی خطا در Go 1.13+ (`fmt.Errorf` با `%w`, `errors.Is`, `errors.As`)، و درک تفاوت بین `error` و `panic`/`recover`، توسعه‌دهندگان می‌توانند سیستم‌های مدیریت خطای قوی و قابل نگهداری ایجاد کنند. بهترین شیوه‌هایی مانند لاگ کردن خطاها در محل مسئولیت، استفاده از `defer` برای پاکسازی منابع، و تست دقیق مسیرهای خطا، به تقویت بیشتر کیفیت کد کمک می‌کنند.

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

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

“تسلط به برنامه‌نویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT”

قیمت اصلی 2.290.000 ریال بود.قیمت فعلی 1.590.000 ریال است.

"تسلط به برنامه‌نویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT"

"با شرکت در این دوره جامع و کاربردی، به راحتی مهارت‌های برنامه‌نویسی پایتون را از سطح مبتدی تا پیشرفته با کمک هوش مصنوعی ChatGPT بیاموزید. این دوره، با بیش از 6 ساعت محتوای آموزشی، شما را قادر می‌سازد تا به سرعت الگوریتم‌های پیچیده را درک کرده و اپلیکیشن‌های هوشمند ایجاد کنید. مناسب برای تمامی سطوح با زیرنویس فارسی حرفه‌ای و امکان دانلود و تماشای آنلاین."

ویژگی‌های کلیدی:

بدون نیاز به تجربه قبلی برنامه‌نویسی

زیرنویس فارسی با ترجمه حرفه‌ای

۳۰ ٪ تخفیف ویژه برای دانشجویان و دانش آموزان