在軟體開發的世界裡,有一條不成文的規則:「不要重複自己」(Don't Repeat Yourself, DRY)。
這條規則強調,重複的程式碼不僅增加了維護的難度,還可能導致錯誤和不一致性。
當我們在不同的地方寫下相同的邏輯時,一旦需要修改,我們就必須記得在所有相關的地方進行更新,這無疑增加了出錯的風險。
專案寫到這裡,程式碼中已經出現了多次重複的邏輯。
我們就以 userController 中的 register
和 login
和 changePassword
這三個 function 為例。
這三個 function 中,都使用相同的流程,類似的程式碼
這些重複的程式碼不僅增加了維護的難度,還可能導致錯誤和不一致性。
例如,如果我們需要修改錯誤處理的邏輯,我們必須在所有相關的地方進行更新,這無疑增加了出錯的風險。
並且這些重複類似的程式碼,會讓我們在閱讀程式碼時,無法專注在真正重要的邏輯上。
這些重複類似的程式碼,是否有方式可以抽離出來,讓我們在閱讀程式碼時,可以專注在真正重要的邏輯上?
假設我們有一個 function handleRequest
,它負責處理輸入的請求跟處理回應的邏輯。
那它需要哪些邏輯,根據這些邏輯,它又需要哪些參數?
如何解析 Request
轉換的方式 (JSON, Form, Query)
轉換後的 Struct
如何調用 UseCase
UseCase 的 input
如何處理 UseCase 的結果
UseCase 的 output and error
如何回傳 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
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 的參數,會有以下各種可能。
比如
這些情況,都無法用同一個 handleRequest
+ Generic function 來處理。
就會需要建立多個 handleRequest
function 來處理這些不同的情況。
因為 function 多個,所以我們要有個命名規則,讓我們可以快速找到我們要的 function。
{parseType}{Response}Handle
在這個版本繼續往前走,我們會發現,處理回應的邏輯,也會有以下各種可能。
比如
所以我們就可以把處理回應的邏輯 用兩個 function 來處理
又因為處理 UseCase 回應的邏輯,也會有以下各種可能。
比如
所以我們就可以把處理 HTTP 回應的邏輯 用幾個 function 來處理
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
所以我們就有了 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。