iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Software Development

Go Clean Architecture API 開發全攻略系列 第 20

[Day 20] 優雅地處理錯誤(一):設計統一的 API 錯誤回報機制

  • 分享至 

  • xImage
  •  

在六邊形架構中,會穿過邊界的,只有 domain 物件 和 interface adapter 之間的資料結構,像是 Input、Output、DBUserModel 等等。

還有一種例外情況,就是錯誤(error)。錯誤也是一種資料結構,會穿過邊界。

這一篇,我們就來說說,什麼是錯誤,還有在六邊形架構中,該如何優雅地處理錯誤。

本文將建立一套從資料庫層到 HTTP 回應的完整錯誤處理流程。

第一部分:錯誤是什麼

在 Go 語言中,錯誤是一個實作了 error 介面的物件:

type error interface {
  Error() string
}

Go 語言中,錯誤是一個值(value),而不是例外(exception)。

當一個函式執行失敗時,會回傳一個錯誤值,呼叫端可以根據這個錯誤值來決定下一步要怎麼做。

在 Go 語言的慣例裡,如果一個 function 會回傳錯誤,通常會把錯誤放在回傳值的最後一個位置:

// error 會放在最後一個位置
func DoSomething() (Result, error)

第二部分:基礎 - Go 1.13+ 的錯誤包裝

自 Go 1.13 起,errors 套件和 fmt.Errorf 提供了強大的錯誤包裝(Wrapping)功能,這是我們策略的基石。

  • fmt.Errorf%w:使用 %w 動詞,可以將一個底層錯誤「包裝」起來,同時附加新的上下文資訊。被包裝的錯誤鏈不會丟失。

    // 在 repository 層
    dbErr := r.db.WithContext(ctx).Create(dbModel).Error
    if dbErr != nil {
        return fmt.Errorf("failed to create user in db: %w", dbErr)
    }
    
  • errors.Is:用於判斷一個錯誤鏈中,是否「是」某個特定的目標錯誤。它會沿著錯誤鏈逐一解包(Unwrap)進行比較。

    // 在 usecase 層
    if errors.Is(err, errors.New("user not found")) {
        // 處理使用者未找到的邏輯
    }
    
  • errors.As:用於判斷一個錯誤鏈中,是否有某個錯誤可以被「賦值」給一個特定的類型。這在我們需要取得自訂錯誤結構中的欄位時非常有用。

第三部分:錯誤的分類

我們需要將應用中的錯誤分為兩大類:

  1. 領域錯誤(Domain Errors):這些是我們預期內的、與業務邏輯相關的錯誤。
    比如 user not found invalid email or password 這些錯誤通常應該被轉換為 4xx 系列的 HTTP 狀態碼(客戶端錯誤)。

  2. 系統錯誤(System Errors):這些是非預期的、基礎設施層面的錯誤。
    例如:資料庫連線中斷、Redis 無法連線、磁碟空間已滿、程式發生 panic
    這些錯誤的細節絕對不能洩漏給客戶端。
    它們應該被詳細地記錄下來,並統一以 500 Internal Server Error 的形式回傳給客戶端。

第四部分:在各層之間傳遞與轉換錯誤

一個錯誤的生命週期,是從底層向高層,不斷被包裝和傳遞的過程。

  • Repository 層:這是錯誤轉換的第一個關口。它負責將資料庫特有的錯誤轉換為一般錯誤,這樣 Repository 層的使用者就不需要關心底層的實作細節。

    // internal/database/mysql/user.go
    func (r *userRepository) GetByEmail(...) (*domain.User, error) {
        // ...
        err := r.db.Where(...).First(...).Error
        if err != nil {
            if errors.Is(err, gorm.ErrRecordNotFound) {
                return nil, errors.New("user not found")
            }
            return nil, fmt.Errorf("db error on get user: %w", err) // 包裝系統錯誤
        }
        return &user, nil
    }
    
  • Service 層:類似於 Repository 層,Service 層負責將外部服務特有的錯誤轉換為一般錯誤。

    // internal/service/password/password.go
    func (s *passwordService) Compare(hashedPassword, plainPassword string) error {
        err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
        if err != nil {
            if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
                return errors.New("invalid password")
            }
            return fmt.Errorf("bcrypt compare error: %w", err) // 包裝系統錯誤
        }
        return nil
    }
    
  • Usecase 層:Usecase 接收來自 Repository or Services 的錯誤。它可以直接向上傳遞,或者根據業務邏輯,包裝上更多的業務上下文。

    func (u *UseCase) Execute(ctx context.Context, input Input) (*Output, error) {
        user, err := u.userRepo.FindUserByEmail(ctx, input.Email)
        if err != nil {
            return nil, fmt.Errorf("failed to find user by email: %w", err)
        }
        if user == nil {
            return nil, errors.New("user not found")
        }
        // ...
    }
    
  • Controller 層:Controller 接收來自 Usecase 的錯誤,並根據錯誤的類型,決定回傳給客戶端的 HTTP 狀態碼跟訊息。

    func (u *UserController) Login(usecase *login.UseCase) gin.HandlerFunc {
        // ...
        output, err := usecase.Execute(c, input)
        if err != nil {
            // 判斷各種 錯誤類型,並回傳適當的 HTTP 狀態碼
            if errors.Is(err, domain.ErrUserNotFound) {
                c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
            }
            ...
            return
        }
        // ...
    }
    

第五部分:Middleware 處理錯誤

與其在每個 Controller 方法中都寫一段 if errors.Is or errors.As 來判斷錯誤類型並回傳 JSON,不如建立一個 Middleware 來集中處理。

func ErrorHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()

        if len(c.Errors) == 0 {
            return
        }

        // 取得最後一個錯誤
        err := c.Errors.Last()

        // 判斷錯誤類型,並回傳適當的 HTTP 狀態碼
        if errors.Is(err, domain.ErrUserNotFound) {
            c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
            return
        }

        ...

        c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
    }
}

這個 Middleware 的職責是:

  1. 捕捉錯誤:在每個請求結束後,檢查 Gin 的上下文中是否有錯誤。
  2. 分類錯誤:根據錯誤的類型,決定回傳給客戶端的 HTTP 狀態碼。
  3. 統一回應格式:確保所有錯誤回應都遵循統一的格式,提升 API 的一致性。

這時候,controller 層就可以簡化,無需處理錯誤,只需要將錯誤附加到 Gin 的上下文中:

func (u *UserController) Login(usecase *login.UseCase) gin.HandlerFunc {
    return func(c *gin.Context) {
        // ...
        output, err := usecase.Execute(c, input)
        if err != nil {
            c.Error(err) // 將錯誤附加到 Gin 的上下文中
            return
        }
        // ...
    }
}

然後在 Gin 的路由設定中,加入這個 Middleware:

// 示意,會根據使用的範圍不同,而在不同的位置加入 Middleware
r := gin.Default()
r.Use(middleware.ErrorHandler(logger))

第六部分: 日誌記錄

在 Middleware 中,除了回傳錯誤給客戶端外,我們還應該將錯誤詳細地記錄下來,以便後續的排查和分析。

func ErrorHandler(logger logger.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        ...
        // 取得最後一個錯誤
        err := c.Errors.Last()
        // 記錄錯誤
        logger.Error(c, "request error", err)
    }
}

總結

通過這六個部分,我們建立了一個清晰且一致的錯誤處理策略:

  1. 利用 Go 1.13+ 的錯誤包裝功能,實現錯誤的傳遞與轉換。
  2. 將錯誤分為領域錯誤和系統錯誤,並在各層之間進行適當的轉換。
  3. 在 Controller 層中,將錯誤附加到 Gin 的上下文中,而不是直接處理。
  4. 使用 Middleware 集中處理錯誤,根據錯誤類型回傳適當的 HTTP 狀態碼。
  5. 詳細記錄錯誤,以便後續的排查和分析。

這樣的策略不僅提升了程式碼的可讀性和可維護性,還確保了錯誤處理的一致性,讓我們的應用更加健壯和可靠。
下一篇,我們將在這個基礎上,介紹如何實作自訂錯誤類型,以進一步提升錯誤處理的靈活性和表達力。


上一篇
[Day 19] Domain Model 設計:定義專案最核心的商業規則
下一篇
[Day 21] 優雅地處理錯誤(二):定義自己的錯誤類型
系列文
Go Clean Architecture API 開發全攻略22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言