iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Software Development

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

六角架構的魅力之美:開發的便利

  • 分享至 

  • xImage
  •  

前面的文章中,我們介紹了六角架構(Hexagonal Architecture)的基本概念及其優點。
這次,我們將深入探討六角架構在開發時所帶來的便利性,並以新增一個用戶登入 API 為例。

這個篇章程式碼會比較多,請耐心閱讀。

實作 Use Case

第一步: 定義輸入與輸出介面

type Input struct {
	email    string
	password string
}

func NewInput(email, password string) Input {
	return Input{
		email:    email,
		password: password,
	}
}

type Output struct {
	AccessToken string
}

眼尖的可能會已經發現到, Login 的 request 跟 response 的 struct 是一樣的,
但是我們還是將其分成兩個,這樣之後不論哪一個要調整,都不會互相影響。

第二步: 定義 Use Case

type UseCase struct {
}

func (u *UseCase) Execute(ctx context.Context, input Input) (*Output, error) {
  return nil, fmt.Errorf("not implemented")
}

這裡的 function 簽名也是跟 register 的相同

第三步: 實作 Execute 方法

先列出整個 use case 的邏輯步驟:

  1. 根據 email 從資料庫取得 user 資料
  2. 比對找出 user 的密碼跟輸入的密碼是否相同
  3. 產生 access token
  4. 回傳 access token
func (u *UseCase) Execute(ctx context.Context, input Input) (*Output, error) {
	// 1. 根據 email 從資料庫取得 user 資料
	// 2. 比對找出使用者的密碼跟輸入的密碼是否相同
	// 3. 產生 access Token
	// 4. 回傳 access Token
	return nil, fmt.Errorf("not implemented")
}

當列完步驟後,我們就可以明確知道,我們需要哪些外部依賴(dependency)來完成這個 use case。

  1. repository:用來存取資料庫,取得 user 資料
  2. passwordService:用來驗證密碼是否正確
  3. tokenService:用來產生 access token

接下來就是定義這些依賴的介面,並且完成 Dependency Injection:

type repository interface {
	FindUserByEmail(ctx context.Context, email string) (*domain.DBUserModel, error)
}

type password interface {
	Compare(hashed, password string) error
}

type token interface {
	GenerateAccessToken(userID int) (string, error)
}

type UseCase struct {
	repository repository // <--- 新增這些依賴
	password   password   // <--- 新增這些依賴
	token      token      // <--- 新增這些依賴
}

func NewUseCase(repository repository, password password, token token) *UseCase {
	return &UseCase{repository: repository, password: password, token: token}
}

在完成依賴注入後,我們就可以開始實作 Execute 方法:

func (u *UseCase) Execute(ctx context.Context, input Input) (*Output, error) {
	// 1. 根據 email 從資料庫取得 user 資料
	user, err := u.repository.FindUserByEmail(ctx, input.email)
	if err != nil {
		return nil, fmt.Errorf("failed to find user by email: %w", err)
	}

	if user == nil {
		return nil, fmt.Errorf("user not found")
	}
	// 2. 比對找出使用者的密碼跟輸入的密碼是否相同
	if err := u.password.Compare(user.PasswordHash(), input.password); err != nil {
		return nil, fmt.Errorf("invalid password: %w", err)
	}

	// 3. 產生 access Token
	accessToken, err := u.token.GenerateAccessToken(user.ID())
	if err != nil {
		return nil, fmt.Errorf("failed to generate access token: %w", err)
	}

	// 4. 回傳 access Token
	return &Output{
		AccessToken: accessToken,
	}, nil
}

這個當下,我們只關注了 use case 的邏輯,完全不思考外部依賴的實作。
這是在明確分層的情境下的最大好處,讓我們可以專注在業務邏輯的實作上。

註:
在實作 Execute 方法時,有時候我們無法先列出所有的步驟,
這時候可以在實作邏輯時,再補上需要的依賴。
就會是一種,寫到一半發現需要什麼外部處理的依賴,就補上去的方式。

注入 Use Case 依賴

type UserUseCase struct {
	Register *register.UseCase
	Login    *login.UseCase // <--- 新增 Login UseCase
}

func NewUserUseCase(app *Application) *UserUseCase {
	return &UserUseCase{
		Register: register.NewUseCase(app.Database, app.Service.Password, app.Service.Token),
		Login:    login.NewUseCase(app.Database, app.Service.Password, app.Service.Token), // <--- 注入 Login UseCase 依賴
	}
}

實作 Controller

func (u *UserController) Login(usecase *login.UseCase) gin.HandlerFunc {
	return func(c *gin.Context) {
		var req request.Login
		// 1. 解析與驗證請求
		if err := c.ShouldBindJSON(&req); err != nil {
			// ShouldBindJSON 會自動根據 struct tag 進行驗證
			// 如果驗證失敗,會返回錯誤
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		// 2. 準備調用 UseCase 所需的資料
		input := login.NewInput(req.Email, req.Password)

		// 3. 調用 UseCase 的 Execute 方法
		output, err := usecase.Execute(c, input)

		// 4. 處理 UseCase 返回的結果
		if err != nil {
			// 專門處理已知的業務邏輯錯誤
			if errors.Is(err, errors.New("invalid email or password")) {
				c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
				return
			}

			if errors.Is(err, errors.New("user not found")) {
				c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
				return
			}

			// 對於其他未知的內部錯誤,回傳 500
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}

		// 5. 成功,回傳 200 狀態碼及 token
		c.JSON(http.StatusOK, gin.H{"data": output.AccessToken})
	}
}

這裡可以看到 controller 的邏輯非常簡單明瞭。
主要分成五個步驟:

  1. 解析與驗證請求
  2. 準備調用 UseCase 所需的資料
  3. 調用 UseCase 的 Execute 方法
  4. 處理 UseCase 返回的結果
  5. 成功,回傳 200 狀態碼及 token

register 的 controller function 是極其相似的。
之後有機會的話,我們會在談到這段的程式碼重構。

指定 Route to Controller

func registerAPIUserRoutes(r gin.IRouter, app *application.Application) {
	...
	r.POST("login", uc.Login(app.UseCase.User.Login)) // <--- 指定 login route
}

實作 Use Case 的 依賴

token 跟 password service 的實作,已經在先前的文章實作了,這裡就不再重複。
我們只剩下 repository 的依賴 需要實作:

func (d *Database) FindUserByEmail(ctx context.Context, email string) (*domain.DBUserModel, error) {
	var user model.User
	if err := d.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}
		return nil, fmt.Errorf("failed to find user by email: %w", err)
	}
	return domain.NewDBUserModel(user.ID, user.Email, user.Password), nil
}

這裡我們並非使用 GORM 的 model,而是轉換成 domain 的 DBUserModel。
這樣可以確保我們的 use case 不會直接依賴 GORM 的實作細節。

總結

透過這個新增用戶登入 API 的範例,我們可以看到六角架構在開發過程中所帶來的便利性。
我們可以專注在業務邏輯的實作上,而不需要擔心外部依賴的細節。
這種分層的設計讓我們的程式碼更具可讀性、可維護性,並且更容易進行測試。
在未來的開發中,無論是新增功能還是修改現有功能,都能夠更加輕鬆自如。

以上程式碼的完整內容可以到 Github 觀看


上一篇
Controller 層:API 的守門員
下一篇
Makefile 完全指南:自動化你的 Go 專案開發流程
系列文
Go Clean Architecture API 開發全攻略19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言