iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Software Development

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

[Day 27] 重複的程式碼,是一種原罪

  • 分享至 

  • xImage
  •  

在軟體開發的世界裡,有一條不成文的規則:「不要重複自己」(Don't Repeat Yourself, DRY)。
這條規則強調,重複的程式碼不僅增加了維護的難度,還可能導致錯誤和不一致性。
當我們在不同的地方寫下相同的邏輯時,一旦需要修改,我們就必須記得在所有相關的地方進行更新,這無疑增加了出錯的風險。

專案寫到這裡,程式碼中已經出現了多次重複的邏輯。

我們就以 userController 中的 registerloginchangePassword 這三個 function 為例。

這三個 function 中,都使用相同的流程,類似的程式碼

  1. 解析與驗證請求
  2. 準備調用 UseCase 所需的資料
  3. 調用 UseCase 的 Execute 方法
  4. 處理 UseCase 返回的結果
  5. 回傳 HTTP 回應

這些重複的程式碼不僅增加了維護的難度,還可能導致錯誤和不一致性。
例如,如果我們需要修改錯誤處理的邏輯,我們必須在所有相關的地方進行更新,這無疑增加了出錯的風險。
並且這些重複類似的程式碼,會讓我們在閱讀程式碼時,無法專注在真正重要的邏輯上。

思考過程 (一)

這些重複類似的程式碼,是否有方式可以抽離出來,讓我們在閱讀程式碼時,可以專注在真正重要的邏輯上?

假設我們有一個 function handleRequest,它負責處理輸入的請求跟處理回應的邏輯。

那它需要哪些邏輯,根據這些邏輯,它又需要哪些參數?

  1. 如何解析 Request
    轉換的方式 (JSON, Form, Query)
    轉換後的 Struct

  2. 如何調用 UseCase
    UseCase 的 input

  3. 如何處理 UseCase 的結果
    UseCase 的 output and error

  4. 如何回傳 Response
    成功的狀態碼
    失敗的狀態碼
    成功的回應 Struct
    失敗的回應 Struct

思考過程 (二)

根據上面的分析,最無法被定義的部分是,如何呼叫 UseCase。
因為每個 UseCase 的 input 跟 output 都不一樣。

output 的部分,可以使用 generics 來解決。
input 的部分,就無法這樣做,因為各自要處理的邏輯不一樣。
所以只能把 input 的部分,留給每個 handler 自己處理。

這時我們就會有第一個版本的 handleRequest

func handleRequest[Request, Response any](
  parse func(c *gin.Context) (Request, error),
  handle func(c *gin.Context, req Request) (Response, error)
) gin.HandlerFunc {
  return func(c *gin.Context) {
    // 1. 解析與驗證請求
    req, err := parse(c)
    if err != nil {
      c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
      return
    }

    // 2. 調用 handle function
    res, gpErr := handle(c, req)
    if gpErr != nil {
      c.JSON(gpErr.StatusCode, gin.H{"error": gpErr.Message})
      return
    }

    // 3. 回傳 HTTP 回應
    c.JSON(http.StatusOK, res)
  }
}

呼叫起來會像是這樣

func (uc *UserController) Register() gin.HandlerFunc {
  return handleRequest[RegisterRequest, RegisterResponse](
    func(c *gin.Context) (RegisterRequest, error) {
      var req RegisterRequest
      if err := c.ShouldBindJSON(&req); err != nil {
        return RegisterRequest{}, err
      }
      return req, nil
    },
    func(c *gin.Context, req RegisterRequest) (RegisterResponse, error) {
      input := &user.RegisterInput{
        Email:    req.Email,
        Password: req.Password,
      }
      output, gpErr := uc.registerUseCase.Execute(c.Request.Context(), input)
      if gpErr != nil {
        return RegisterResponse{}, gpErr
      }
      res := RegisterResponse{
        ID:    output.ID,
        Email: output.Email,
      }
      return res, nil
    },
  )
}

到了這一個版本,我們會發 parse function 的邏輯,還是會重複
因為 Parse function 的邏輯,是有限的轉換方式 (JSON, Form, Query)。

所以可以拆成三個 function

  1. parseJSON
  2. parseForm
  3. parseQuery
func parseJSON[T any](c *gin.Context) (T, error) {
  var req T
  if err := c.ShouldBindJSON(&req); err != nil {
    return req, err
  }
  return req, nil
}

func parseForm[T any](c *gin.Context) (T, error) {
  var req T
  if err := c.ShouldBind(&req); err != nil {
    return req, err
  }
  return req, nil
}

func parseQuery[T any](c *gin.Context) (T, error) {
  var req T
  if err := c.ShouldBindQuery(&req); err != nil {
    return req, err
  }
  return req, nil
}

這樣呼叫起來就會更簡潔

func (uc *UserController) Register() gin.HandlerFunc {
  return handleRequest[RegisterRequest, RegisterResponse](
    parseJSON[RegisterRequest],
    func(c *gin.Context, req RegisterRequest) (RegisterResponse, error) {
      ...
    },
  )
}

思考過程 (三)

在這個版本繼續往前走,我們會發現,呼叫 useCase 的參數,會有以下各種可能。
比如

  1. 有些 useCase 不需要任何參數
  2. 有些 useCase 需要額外的參數 (例如從 context 取得的 userID)
  3. 有些 useCase 需要從 request 中取得的參數 不止一種 (例如 path param and JSON 同時存在)

這些情況,都無法用同一個 handleRequest + Generic function 來處理。
就會需要建立多個 handleRequest function 來處理這些不同的情況。

因為 function 多個,所以我們要有個命名規則,讓我們可以快速找到我們要的 function。

{parseType}{Response}Handle

  1. parseType: 代表解析 request 的方式 (JSON, Form, Query)
  2. Response: 如果有代表 http 有回傳值,也代表 useCase 有 output
    沒有代表 http 沒有回傳值,也代表 useCase 沒有 output
  3. Handle: 固定字尾,代表這是一個 handler function

思考過程 (四)

在這個版本繼續往前走,我們會發現,處理回應的邏輯,也會有以下各種可能。
比如

  1. 有些 useCase 沒有 output
  2. 有些 useCase 有 output

所以我們就可以把處理回應的邏輯 用兩個 function 來處理

  1. handleResponse()
  2. handleError()

又因為處理 UseCase 回應的邏輯,也會有以下各種可能。
比如

  1. 有些 useCase 的 output 是陣列、字串、布林值
  2. 有些 useCase 的 output 是物件
  3. 有些 useCase 沒有 output
  4. useCase 有 error

所以我們就可以把處理 HTTP 回應的邏輯 用幾個 function 來處理

  1. apiSuccess =>
    no data response
  2. apiSuccessWithData =>
    response is one of array, string, bool
  3. apiSuccessWithObject
    response is object
  4. apiFailWithGPError
    response base on domain.GPError

實作

func jsonResponseHandler[Request, Response any](f func(*gin.Context, Request) (*Response, *domain.GPError)) gin.HandlerFunc {
  return func(ctx *gin.Context) {
    // req 是 Request 型別
    // ok 是 bool 型別,代表程式是否要繼續往下執行
    // 也會有 bindQuery, bindURI ... 等等,但這一個 function 名稱是 jsonResponseHandler
    // 所以是用 bindJSON 來解析 request
    req, ok := bindJSON[Request](ctx)
    if !ok {
      return
    }

    // 呼叫 UseCase
    res, gpErr := f(ctx, req)

    // 處理回應
	handleResponse(ctx, res, gpErr)
  }
}

func jsonHandler[Request any](f func(*gin.Context, Request) *domain.GPError) gin.HandlerFunc {
  return func(ctx *gin.Context) {
    req, ok := bindJSON[Request](ctx)
    if !ok {
      return
    }

    gpErr := f(ctx, req)
    handleError(ctx, gpErr)
  }
}

這時候,register, login, changePassword function 就會變成這樣

func (u *UserController) Register(useCase *register.UseCase) gin.HandlerFunc {
	return jsonResponseHandler(func(ctx *gin.Context, req request.Register) (*register.Output, *domain.GPError) {
		input := register.NewInput(req.Email, req.Password)
		return useCase.Execute(ctx, input)
	})
}

func (u *UserController) Login(useCase *login.UseCase) gin.HandlerFunc {
	return jsonResponseHandler(func(ctx *gin.Context, req request.Login) (*login.Output, *domain.GPError) {
		input := login.NewInput(req.Email, req.Password)
		return useCase.Execute(ctx, input)
	})
}

func (u *UserController) ChangePassword(useCase *changePassword.UseCase) gin.HandlerFunc {
	return jsonHandler(func(ctx *gin.Context, req request.ChangePassword) *domain.GPError {
    userID := ctx.GetInt("userID")
		if userID == 0 {
      return domain.NewGPError(domain.ErrCodeUnauthorized)
    }
		input := changePassword.NewInput(userID, req.Password, req.NewPassword)
		return useCase.Execute(ctx, input)
})

思考流程 (五)

在這個版本繼續往前走,我們會發現,開始有重複的內容在 呼叫 useCase 的地方。
例如:

    ...
    userID := ctx.GetInt("userID")
		if userID == 0 {
      return domain.NewGPError(domain.ErrCodeUnauthorized)
    }
    ...

這一段大量出現在需要從 context 取得 userID 的地方。
所以我們也可以把這個功能,抽離出來,放進 Handle 的 function 裡面。

一併需要調整 Handle 的命名規則

{parseType}{Param}{Response}Handle

  1. parseType: 代表解析 request 的方式 (JSON, Form, Query)
  2. Param: 額外添加的參數 (例如 Context 取得 userID)可以有多個
  3. Response: 如果有代表 http 有回傳值,也代表 useCase 有 output
    沒有代表 http 沒有回傳值,也代表 useCase 沒有 output
  4. Handle: 固定字尾,代表這是一個 handler function

所以我們就有了 jsonUserIDHandler

func jsonUserIDHandler[Request any](f func(*gin.Context, int, Request) *domain.GPError) gin.HandlerFunc {
	return func(ctx *gin.Context) {
		userID := ctx.GetInt("userID")
		if userID == 0 {
			apiFailWithErrorCode(ctx, domain.ErrCodeUnauthorized, errors.New("unauthorized"))
			return
		}

		req, ok := bindJSON[Request](ctx)
		if !ok {
			return
		}

		err := f(ctx, userID, req)
		handleError(ctx, err)
	}
}

然後 changePassword function 就會變成這樣

func (u *UserController) ChangePassword(useCase *changePassword.UseCase) gin.HandlerFunc {
	return jsonUserIDHandler(func(ctx *gin.Context, userID int, req request.ChangePassword) *domain.GPError {
		input := changePassword.NewInput(userID, req.Password, req.NewPassword)
		return useCase.Execute(ctx, input)
	})
}

總結

我們講說 「不要重複自己」(Don't Repeat Yourself, DRY)。
但我們也要注意到,過度的抽象化,會讓程式碼變得難以理解和維護。
所以我們要在 DRY 和 KISS (Keep It Simple, Stupid) 之間找到一個平衡點。
這樣的設計,是否已經達到我們想要的平衡點?
這是一個值得我們深思的問題。

永遠沒有最好的架構,只有最適合當下需求的架構。

詳細的程式碼,請參考 Github 這一個 commit。


上一篇
[Day 26] 快取服務(二):最佳實踐與注意事項
系列文
Go Clean Architecture API 開發全攻略27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言