前面的文章中,我們介紹了六角架構(Hexagonal Architecture)的基本概念及其優點。
這次,我們將深入探討六角架構在開發時所帶來的便利性,並以新增一個用戶登入 API 為例。
這個篇章程式碼會比較多,請耐心閱讀。
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 是一樣的,
但是我們還是將其分成兩個,這樣之後不論哪一個要調整,都不會互相影響。
type UseCase struct {
}
func (u *UseCase) Execute(ctx context.Context, input Input) (*Output, error) {
return nil, fmt.Errorf("not implemented")
}
這裡的 function 簽名也是跟 register
的相同
先列出整個 use case 的邏輯步驟:
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。
接下來就是定義這些依賴的介面,並且完成 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 方法時,有時候我們無法先列出所有的步驟,
這時候可以在實作邏輯時,再補上需要的依賴。
就會是一種,寫到一半發現需要什麼外部處理的依賴,就補上去的方式。
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 依賴
}
}
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 的邏輯非常簡單明瞭。
主要分成五個步驟:
跟 register
的 controller function 是極其相似的。
之後有機會的話,我們會在談到這段的程式碼重構。
func registerAPIUserRoutes(r gin.IRouter, app *application.Application) {
...
r.POST("login", uc.Login(app.UseCase.User.Login)) // <--- 指定 login route
}
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 觀看