在六邊形架構中,會穿過邊界的,只有 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 起,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
:用於判斷一個錯誤鏈中,是否有某個錯誤可以被「賦值」給一個特定的類型。這在我們需要取得自訂錯誤結構中的欄位時非常有用。
我們需要將應用中的錯誤分為兩大類:
領域錯誤(Domain Errors):這些是我們預期內的、與業務邏輯相關的錯誤。
比如 user not found
invalid email or password
這些錯誤通常應該被轉換為 4xx 系列的 HTTP 狀態碼(客戶端錯誤)。
系統錯誤(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
}
// ...
}
與其在每個 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 的職責是:
這時候,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)
}
}
通過這六個部分,我們建立了一個清晰且一致的錯誤處理策略:
這樣的策略不僅提升了程式碼的可讀性和可維護性,還確保了錯誤處理的一致性,讓我們的應用更加健壯和可靠。
下一篇,我們將在這個基礎上,介紹如何實作自訂錯誤類型,以進一步提升錯誤處理的靈活性和表達力。