Clean Architecture Go Implementation

tags: Domain-Driven Design Clean Architecture Go
category: 軟體開發
description: Clean Architecture Go Implementation
created_at: 2024/11/28 11:00:00

cover image


前言

繼前面的文章 Domain-Driven Design 領域驅動設計 初學者指南 提到,其中一種實作 DDD 的方式是基於 Clean Architecture 這個架構,所以這篇文章就來淺談 Clean Architecture 這個架構(主要以實作為主),然後以 Go 來實現,不過也可以理解之後套用到其他的程式語言,畢竟程式語言只是一種工具,一種實現的方式(?),然後這篇也不會扯到 DDD,就單純以 Clean Architecture 為主。也許你可以稱它為 DDD-Lite (?),雖然這邊實作連任務型介面都沒有,主要為了讓只熟悉一般 MVCCRUD 介面的人容易上手。


預備知識

學習 Clean Architecture 之前,建議先了解以下知識: (最少)

  • 物件導向相關的概念 (Object-Oriented) ((尤其是依賴反轉(DIP)
  • Go 語言基礎 (畢竟是用 Go 來實作)
  • 測試相關的概念 (因為後面會有測試,當然你也可以跳過)

預防針

老樣子先打個預防針,至少我先前在學習的過程中,在搜尋 Clean Architecture 相關實作來參考時,也是有各式各樣不同的實作方式,但唯一不變的就是依賴關係,所以如果實作起來和你看到的不太一樣也是正常的,但是最重要的是你要了解這個架構的核心概念,這樣你才能夠更好的應用在你的專案上。


目錄


Clean Architecture


在實作之前,總是應該先說說我們要做的是什麼東西,Clean Architecture 是由 Robert C. Martin 所提出的一種軟體架構,這個架構的目的是為了讓軟體更容易維護、擴展、測試,並且讓軟體的各個部分更容易被替換,這個架構的核心概念是將軟體分為不同的層級,並且這些層級之間有明確的依賴關係,這樣可以讓軟體的各個部分更容易被替換,而不會影響到其他部分。 <-- 這段是 Copilot 生出來的。

簡單來說,就是希望你的程式不要跟框架結婚(綁太緊),,再換句話說,也就是你的程式碼可以和框架解耦,如果今天忽然要換個框架也不是問題(雖然機會可能不多(?),但是是可以做到的),或者是說可能比較常見的可能是更換儲存的媒介(例如用哪一個資料庫),然後你的程式碼也會變得能夠被測試,更容易維護。

附上一張經典的圖: The Clean Architecture

先來看看左邊那個同心圓,這個圖顯示了程式碼的依賴性,依賴都是向內的,從最外層開始往內分別為:

  • 框架和工具(infrastructure layer)
  • 介面適配器(interface adapter layer)
  • 應用邏輯(application layer)
  • 商業邏輯(entity layer)

然而在右下角有一個小範例,待會下面會實作一遍,如果相對於圖比較看得懂 Code 的人待會會比較理解。

這個範例是 Controller 去組出 UseCase 需要的資料,然後丟去 UseCase 拿到結果之後再透過 Presenter 去組出最後要回傳的東西,這樣的好處是 Controller 只需要負責組出 UseCase 需要的資料,UseCase 只需要負責處理、協調邏輯,Presenter 只需要負責組出最後要回傳的東西,這樣的好處是每一個部分都可以獨立測試,而且也可以獨立替換。

更具體一點的好處是:

  • 假設要替換新的框架,實際上你也只要替換 Controller 這一層就好,充當一個 Adapter 的角色,其他的部分都不用動。
  • 假設要替換新的輸出結果(例如資料格式不同),也只要替換 Presenter 這一層就好,其他的部分都不用動。
  • 細節推遲到最後決定,例如使用什麼資料庫、框架,這樣可以讓你的程式碼更容易被測試,因為你可以用 Mock 來測試,而不用真的去連接資料庫。

這些好處在後面實作都會看到。


跟以往相比(以 MVC 為例)


以前單純 MVC 的話,你可能會有 ControllerServiceRepository 等,你總是會使用 Controller 去呼叫 Service,然後不管你的 Repository 是透過 Controller 直接呼叫,還是透過 Service 去呼叫,這樣的好處是你可以很快的開發出一個功能,但現在問題就來了,怎麼測試?

也許會說 e2e 就好,但 e2e 的成本比單元測試高很多。

然後又碰到了下一個問題,假設我資料庫想換,如果當初沒有特別設計或是框架沒有提供的話,你可能會需要一個一個去改,這樣的成本也是很高的。

然後再來最殘酷的,如果我整個框架要替換呢,全部重寫

Clean Architecture 可以解決這些問題,讓你的程式碼更容易被測試、更容易維護、更容易擴展。

最後附上一張圖,表示 MVC 的複雜依賴關係: MVC

這邊還沒有呈現出 View 的部分,只是光核心邏輯處理的部分,然後 Model 的部分假定包含在 Repository 裡面,更不用說有些可能散落在各地的複雜度。


實作項目


說到範例,又又又是那個你我熟悉的 Todo List,這次我們要做的是一個 Todo ListAPI

這個 API 會有以下功能:

  • 列出所有 Todo
  • 新增 Todo
  • 刪除 Todo
  • 更新 Todo

嗯...是很基本的 CRUD 功能,但是這樣就好,因為我們要著重在 Clean Architecture 上。

為什麼沒有 查詢 功能呢? 因為可以自己當小練習


使用的套件/框架


如同前面所說,這是細節,所以我們先跳過不管。

但..語言總是應該先決定,否則你要寫什麼,這邊選擇使用 Go 來實作。


實作


1. 建立專案


初始化 go mod:

go mod init ca-app

接著下一步呢? 直接裝框架,然後開始寫 ControllerUseCasePresenterRepository 這些東西嗎? 不,我們先從最底層開始,也就是 Entity

但在開始之前,為了方便知道我們每一個檔案分別對應到哪一層,所以我們先定義一下目錄結構:

.
├── domain
│   ├── entity
│   ├── repository
├── usecase
├── interface
│   ├── presenter
│   ├── ...
├── infrastructure
│   ├── repository
│   └── ...
└── main.go

先暫時這樣放,在這個範例上我們就可以很清楚的知道每一個檔案是屬於哪一層,實際上你可以自己決定要怎麼放比較好。

然後其他檔案當需要用到時再來新增,避免一開始就寫太多,

然後為什麼會有兩個 repository 的目錄呢?

這是因為 domain 這邊是放 entity 層(最內層)的 Repository Interface,而 infrastructure 這個目錄是放 Repository 的實作。

基本上可以想成說 domain 這邊只是 Interface,而 infrastructure 這邊才是真正的實作,這邊用到了 依賴反轉 的概念,這樣的好處是你可以很容易的替換 Repository 的實作,而不用動到 Domain 層,也不會違反 Clean Architecture 的原則。


2. 實作 Domain


Entity


Entity 如果以宏觀的角度來看,以之前 MVC 的例子來說,可能會是 Model,所以我們先來定義 TodoEntity

檔案路徑: domain/entity/todo.go

package entity

type Todo struct {
    ID        int
    Title     string
    Completed bool
}

非常的簡單,只有 IDTitleCompleted 這三個欄位。

接著我們需要有個東西去存取這些 Entity,這個東西就是 Repository


Repository


檔案路徑: domain/repository/todo_repository.go

package repository

import "ca-app/domain/entity"

type TodoRepository interface {
    GetAll() ([]*entity.Todo, error)
    GetByID(id int) (*entity.Todo, error)
    Create(todo *entity.Todo) error
    Update(id int, todo *entity.Todo) error
    Delete(id int) error
}

這樣我們就定義了 EntityRepository,接著總是要有一個 UseCase 來協調這些東西來組出我們的應用邏輯。


3. 實作 Application


UseCase


這邊可能需要以下幾個 UseCase:

  • ListTodoUsecase
  • CreateTodoUsecase
  • UpdateTodoUsecase
  • DeleteTodoUsecase

所以可以分別定義這幾個 UseCase

先從最基本的 ListTodoUsecase 開始,這邊為了簡化,所以我將 DTO 之類的東西都直接放在 UseCase 裡面,實際上你可以自己決定資料夾怎麼拆。

檔案路徑: domain/usecase/todo/list_todo.go

package todousecase

import "ca-app/domain/repository"

type Todo struct {
    ID        int
    Title     string
    Completed bool
}

type ListTodoOutput []*Todo

type ListTodoUsecase interface {
    Execute() (*ListTodoOutput, error)
}

type listTodoUsecase struct {
    repo repository.TodoRepository
}

func NewListTodoUsecase(repo repository.TodoRepository) ListTodoUsecase {
    return &listTodoUsecase{
        repo: repo,
    }
}

func (u *listTodoUsecase) Execute() (*ListTodoOutput, error) {
    todos, err := u.repo.GetAll()
    if err != nil {
        return nil, err
    }

    outputs := make([]*Todo, len(todos))
    for i, todo := range todos {
        outputs[i] = &Todo{
            ID:        todo.ID,
            Title:     todo.Title,
            Completed: todo.Completed,
        }
    }

    return (*ListTodoOutput)(&outputs), nil
}

錯誤處理的部分,如果是其他的語言,可能會用 try-catch 之類的,但是 Go 是用 error 來處理錯誤,所以這邊就直接回傳 error (雖然你也可以包裝成另外的 error 然後再拋出去,這在後面會看到,但這邊就為了簡化,先這樣)。

接著是 CreateTodoUsecase 的部分,為了節省篇幅,我只留下 Execute 的部分:

func (u *createTodoUsecase) Execute(input *CreateTodoInput) (*CreateTodoOutput, error) {
    todo := &entity.Todo{
        Title: input.Title,
    }

    if err := u.repo.Create(todo); err != nil {
        return nil, err
    }

    return &CreateTodoOutput{
        ID:        todo.ID,
        Title:     todo.Title,
        Completed: todo.Completed,
    }, nil
}

基本上大同小異,接收一個 input ,然後建立一個 Todo,然後呼叫 RepositoryCreate 方法,最後回傳 output

接著是 UpdateTodoUsecase 的部分:

func (u *updateTodoUsecase) Execute(input *UpdateTodoInput) (*UpdateTodoOutput, error) {
    todo, err := u.repo.GetByID(input.ID)
    if err != nil {
        return nil, err
    }

    if todo == nil {
        return nil, usecaseerror.NewTodoNotFoundError()
    }

    if input.Title != nil {
        todo.Title = *input.Title
    }

    if input.Completed != nil {
        todo.Completed = *input.Completed
    }

    if err := u.repo.Update(input.ID, todo); err != nil {
        return nil, err
    }

    return &UpdateTodoOutput{
        ID:        todo.ID,
        Title:     todo.Title,
        Completed: todo.Completed,
    }, nil
}

可以看到有一段 usecaseerror.NewTodoNotFoundError(),這是一個自定義的錯誤,他長得像下面這樣:

package usecaseerror

import "errors"

type TodoNotFoundError error

func NewTodoNotFoundError() TodoNotFoundError {
    return errors.New("todo not found")
}

func IsTodoNotFoundError(err error) bool {
    _, ok := err.(TodoNotFoundError)
    return ok
}

包含了建立錯誤和判斷是不是相應的錯誤。

接著最後是 DeleteTodoUsecase 的部分,相信應該也猜到了,基本上大同小異:

func (u *deleteTodoUsecase) Execute(input *DeleteTodoInput) (*DeleteTodoOutput, error) {
    todo, err := u.repo.GetByID(input.ID)
    if err != nil {
        return nil, err
    }

    if todo == nil {
        return nil, usecaseerror.NewTodoNotFoundError()
    }

    if err := u.repo.Delete(input.ID); err != nil {
        return nil, err
    }

    return &DeleteTodoOutput{
        ID: input.ID,
    }, nil
}

最後我們可以建立一個 UseCaseFacade,這樣可以讓我們更容易的使用 UseCase

package usecase

import todousecase "ca-app/usecase/todo"

type TodoUsecase struct {
    CreateTodo todousecase.CreateTodoUsecase
    UpdateTodo todousecase.UpdateTodoUsecase
    DeleteTodo todousecase.DeleteTodoUsecase
    ListTodo   todousecase.ListTodoUsecase
}

func NewTodoUsecase(
    createTodo todousecase.CreateTodoUsecase,
    updateTodo todousecase.UpdateTodoUsecase,
    deleteTodo todousecase.DeleteTodoUsecase,
    listTodo todousecase.ListTodoUsecase,
) *TodoUsecase {
    return &TodoUsecase{
        CreateTodo: createTodo,
        UpdateTodo: updateTodo,
        DeleteTodo: deleteTodo,
        ListTodo:   listTodo,
    }
}

這一項並不是必要的,但是可以讓你更容易的使用 UseCase

到這邊,我們還沒有跟任何工具、框架結合,但是我們已經完成了所有的核心邏輯,接著就是要把這些東西組合起來,也就是你的這些核心邏輯想被誰用,例如 APICLI 之類的。


4. 實作 Interface


Presenter


Interface 層,我們會有 ControllerPresenter 之類的東西,這邊我們先來實作 Presenter,先決定我們要回傳的資料長什麼樣子,JSONXML 之類的,這邊我們先用 JSON

假設我們要使用 JSON 來回傳,我們可以先定義一個 DTO,這個 DTO 會是 JSON 的樣子。 (假設我叫他 Model 來充當 DTO)

package model

type Todo struct {
    ID        int    `json:"id"`
    Title     string `json:"title"`
    Completed bool   `json:"completed"`
}

: 這裡命名可以看你自己的喜好,例如 model.Todomodel.TodoJSONmodeljson.Todo 之類的。 ((如果有需要其他格式共存的話,或是如果你直接寫 tag 進同個 model 也是可以,不過這樣可能會有太多的職責在同一個 struct 身上,可能要權衡一下。

接著我們一樣從 ListTodo 開始:

檔案路徑: interface/presenter/todo/list_todo_json.go

package todopresenter

import (
    "ca-app/interface/presenter/todo/model"
    todousecase "ca-app/usecase/todo"
)

type ListTodoJsonOutput struct {
    Success bool          `json:"success"`
    Data    []*model.Todo `json:"data"`
    Message string        `json:"message"`
}

type ListTodoJSONPresenter interface {
    Output(output *todousecase.ListTodoOutput) *ListTodoJsonOutput
    Error(err error) *ListTodoJsonOutput
}

type listTodoJSONPresenter struct{}

func NewListTodoJSONPresenter() ListTodoJSONPresenter {
    return &listTodoJSONPresenter{}
}

func (p *listTodoJSONPresenter) Output(output *todousecase.ListTodoOutput) *ListTodoJsonOutput {
    todos := make([]*model.Todo, len(*output))
    for i, todo := range *output {
        todos[i] = &model.Todo{
            ID:        todo.ID,
            Title:     todo.Title,
            Completed: todo.Completed,
        }
    }

    return &ListTodoJsonOutput{
        Success: true,
        Data:    todos,
        Message: "",
    }
}

func (p *listTodoJSONPresenter) Error(err error) *ListTodoJsonOutput {
    return &ListTodoJsonOutput{
        Success: false,
        Data:    nil,
        Message: err.Error(),
    }
}

基本上就是 Output 負責處理正常的情況,Error 負責處理錯誤的情況,然後都是接收 UseCaseOutput,然後轉換成 PresenterOutput(或者說是 ViewModel)。

然後為了節省篇幅,其他幾項也是大同小異,這邊就不再贅述,最後也可以做一個 Facade 來組合這些 Presenter


Controller


接著來到了 Controller 的部分,這邊我們可以用 gin 來實作,所以我接收的參數會變成 gin.Context,當然你也可以用其他框架。

檔案路徑: interface/gin-adapter/controller/todo_controller.go

首先我們一個一個來看,先從定義開始,一樣使用依賴注入的方式來決定要用哪一個 UseCasePresenter,這樣未來如果要抽換 UseCasePresenter 也很容易。

type TodoController struct {
    usecase   *usecase.TodoUsecase
    presenter *presenter.TodoJSONPresenter
}

func NewTodoController(usecase *usecase.TodoUsecase, presenter *presenter.TodoJSONPresenter) *TodoController {
    return &TodoController{
        usecase:   usecase,
        presenter: presenter,
    }
}

接著一樣從 List 開始:

func (ctl *TodoController) List(c *gin.Context) {
    output, err := ctl.usecase.ListTodo.Execute()
    if err != nil {
        c.JSON(http.StatusInternalServerError, ctl.presenter.List.Error(err))
        return
    }

    c.JSON(http.StatusOK, ctl.presenter.List.Output(output))
}

基本上就是接收 gin.Context(組出需要的 input,但這裡還沒有),然後呼叫 UseCaseExecute,然後根據 OutputError 來回傳結果,呼應到一開始那張圖的右下角,所以再附上一次: The Clean Architecture

然後這邊需要注意的是那個 Error 處理的部分,這邊為了簡化,所以直接把 error 丟出去,但這樣可能會有一些安全性的問題,所以可能需要重新包裝一下 error 再丟出去,然後你可以在這個地方放個 log 之類的記錄一下。

接著來看 Create 的部分,這邊就有簡單的組裝 input 的部分:

func (ctl *TodoController) Create(c *gin.Context) {
    input := &request.CreateTodoRequest{}
    if err := c.ShouldBindJSON(input); err != nil {
        c.JSON(http.StatusBadRequest, ctl.presenter.Create.Error(err))
        return
    }

    payload := &todousecase.CreateTodoInput{
        Title: input.Title,
    }

    output, err := ctl.usecase.CreateTodo.Execute(payload)
    if err != nil {
        c.JSON(http.StatusInternalServerError, ctl.presenter.Create.Error(err))
        return
    }

    c.JSON(http.StatusCreated, ctl.presenter.Create.Output(output))
}

這邊 request 的部分是用來接收 JSONstruct,他長得像下面這樣:

package request

type CreateTodoRequest struct {
    Title string `json:"title"`
}

如果失敗,就回傳 400,如果成功,就可以組裝 input 並呼叫 UseCaseExecute,然後根據 OutputError 來回傳結果。

再來是 Update 的部分,這邊就是多了一個 ID 的部分(一樣可以自訂錯誤訊息,但這邊略過):

func (ctl *TodoController) Update(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusBadRequest, ctl.presenter.Update.Error(err))
        return
    }

    input := &request.UpdateTodoRequest{}
    if err := c.ShouldBindJSON(input); err != nil {
        c.JSON(http.StatusBadRequest, ctl.presenter.Update.Error(err))
        return
    }

    payload := &todousecase.UpdateTodoInput{
        ID:        id,
        Title:     input.Title,
        Completed: input.Completed,
    }

    output, err := ctl.usecase.UpdateTodo.Execute(payload)
    if err != nil {
        if usecaseerror.IsTodoNotFoundError(err) {
            c.JSON(http.StatusNotFound, ctl.presenter.Update.Error(err))
            return
        }

        c.JSON(http.StatusInternalServerError, ctl.presenter.Update.Error(err))
        return
    }

    c.JSON(http.StatusOK, ctl.presenter.Update.Output(output))
}

然後還有在 usecase 當中也有拋出自訂的錯誤(IsTodoNotFoundError),這邊就可以用對應的檢查來看是不是特定的錯誤來做對應的處理。

最後刪除的部分基本上更單純,所以為了節省篇幅所以略過。


Router


最後需要一個 Router 來把這些 Controller 接起來,這邊就用 ginRouter 來實作:

檔案路徑: interface/gin-adapter/router/router.go

package router

import (
    "ca-app/interface/gin-adapter/controller"

    "github.com/gin-gonic/gin"
)

func SetupRouter(controller *controller.TodoController) *gin.Engine {
    router := gin.Default()
    router.GET("/todos", controller.List)
    router.POST("/todos", controller.Create)
    router.PUT("/todos/:id", controller.Update)
    router.DELETE("/todos/:id", controller.Delete)
    return router
}

這裡一樣使用 依賴注入 的方式來決定要用哪一個 Controller


5. 實作 Infrastructure

Infrastructure 層,我們會實作 Repository 的實作,還有其他和外部資源的連接(但在這邊目前只有 Repository 的實作)。


Repository


為了簡化,這邊我們先用 In-Memory 的方式來實作,也就是存在記憶體中,之後再換成 Database,所以到目前為止,你也還沒接觸到任何 Database

檔案路徑: infrastructure/repository/todo_repository_in_memory.go

package repository

import (
    "ca-app/domain/entity"
    "ca-app/domain/repository"
    "sync"
)

type todoRepositoryInMemoryImpl struct {
    todos []*entity.Todo
    lock  *sync.Mutex
}

func NewTodoRepositoryInMemory(todos []*entity.Todo) repository.TodoRepository {
    lock := &sync.Mutex{}
    return &todoRepositoryInMemoryImpl{
        todos,
        lock,
    }
}

func (r *todoRepositoryInMemoryImpl) GetAll() ([]*entity.Todo, error) {
    return r.todos, nil
}

func (r *todoRepositoryInMemoryImpl) GetByID(id int) (*entity.Todo, error) {
    for _, todo := range r.todos {
        if todo.ID == id {
            return todo, nil
        }
    }
    return nil, nil
}

func (r *todoRepositoryInMemoryImpl) Create(todo *entity.Todo) error {
    r.lock.Lock()
    defer r.lock.Unlock()

    r.todos = append(r.todos, todo)
    return nil
}

func (r *todoRepositoryInMemoryImpl) Update(id int, todo *entity.Todo) error {
    for i, t := range r.todos {
        if t.ID == id {
            r.todos[i] = todo
        }
    }

    return nil
}

func (r *todoRepositoryInMemoryImpl) Delete(id int) error {
    r.lock.Lock()
    defer r.lock.Unlock()

    for i, todo := range r.todos {
        if todo.ID == id {
            r.todos = append(r.todos[:i], r.todos[i+1:]...)
            return nil
        }
    }
    return nil
}

用很簡單的方式實作了一個 In-MemoryRepository,至於那個後綴 Impl 可加可不加,這邊只是表達這是一個實作,雖然這樣的命名可能不是很好,就好像 Interface 有些人會在開頭加 I 一樣,這是一個爭議(?)的議題。


6. 實作 Main 函數


最後就是把這些東西組合起來,這邊就是 Main 函數的部分。

package main

import (
    "ca-app/domain/entity"
    "ca-app/infrastructure/repository"
    "ca-app/interface/gin-adapter/controller"
    "ca-app/interface/gin-adapter/router"
    "ca-app/interface/presenter"
    todopresenter "ca-app/interface/presenter/todo"
    "ca-app/usecase"
    todousecase "ca-app/usecase/todo"
)

func main() {
    repository := repository.NewTodoRepositoryInMemory([]*entity.Todo{})

    createTodoUsecase := todousecase.NewCreateTodoUsecase(repository)
    updateTodoUsecase := todousecase.NewUpdateTodoUsecase(repository)
    deleteTodoUsecase := todousecase.NewDeleteTodoUsecase(repository)
    listTodoUsecase := todousecase.NewListTodoUsecase(repository)

    createTodoJSONPresenter := todopresenter.NewCreateTodoJSONPresenter()
    updateTodoJSONPresenter := todopresenter.NewUpdateTodoJSONPresenter()
    deleteTodoJSONPresenter := todopresenter.NewDeleteTodoJSONPresenter()
    listTodoJSONPresenter := todopresenter.NewListTodoJSONPresenter()

    todoUsecase := usecase.NewTodoUsecase(createTodoUsecase, updateTodoUsecase, deleteTodoUsecase, listTodoUsecase)
    todoJSONPresenter := presenter.NewTodoJSONPresenter(createTodoJSONPresenter, updateTodoJSONPresenter, deleteTodoJSONPresenter, listTodoJSONPresenter)

    todoController := controller.NewTodoController(todoUsecase, todoJSONPresenter)

    router := router.SetupRouter(todoController)
    router.Run(":8080")
}

因為前面都是使用 依賴注入(DI) 的架構去做,所以在這個 Main 當中會需要建立很多東西,這樣的好處是你可以很容易的替換任何東西。

但實際上你可以使用 DI 框架來幫你做這些事情,這樣你的 Main 就會變得更簡潔,未來也會更方便去做替換。

到這邊基本上就完成了,接著後面是測試的部分,但這並不是必需的,只是有的話會更好(對長期來說)。


7. 測試


Repository 測試


可以先簡單寫個測試來測試看看。

檔案路徑: infrastructure/repository/todo_repository_in_memory_test.go

這邊的 package 使用帶有後綴 _testpackage,充當黑箱的單元測試。

package repository_test

import (
    "ca-app/infrastructure/repository"
    "fmt"
    "sync"
    "testing"

    "ca-app/domain/entity"
    repo "ca-app/domain/repository"
)

func NewTodoRepositoryInMemory(todos []*entity.Todo) repo.TodoRepository {
    return repository.NewTodoRepositoryInMemory(todos)
}

func testCreateTodoRepositoryInMemory() func(t *testing.T) {
    repo := NewTodoRepositoryInMemory([]*entity.Todo{})
    return func(t *testing.T) {
        todo := &entity.Todo{
            ID:        1,
            Title:     "Test",
            Completed: false,
        }
        err := repo.Create(todo)
        if err != nil {
            t.Errorf("Error: %v", err)
        }
    }
}

func testGetAllTodoRepositoryInMemory() func(t *testing.T) {
    todos := []*entity.Todo{
        {
            ID:        1,
            Title:     "Test",
            Completed: false,
        },
        {
            ID:        2,
            Title:     "Test2",
            Completed: true,
        },
    }

    repo := NewTodoRepositoryInMemory(todos)

    return func(t *testing.T) {
        result, err := repo.GetAll()
        if err != nil {
            t.Errorf("Error: %v", err)
        }

        if len(result) != 2 {
            t.Errorf("Expected: 2, Got: %d", len(result))
        }

        if result[0].ID != 1 {
            t.Errorf("Expected: 1, Got: %d", result[0].ID)
        }

        if result[1].ID != 2 {
            t.Errorf("Expected: 2, Got: %d", result[1].ID)
        }
    }
}

func testGetByIDTodoRepositoryInMemory() func(t *testing.T) {
    todos := []*entity.Todo{
        {
            ID:        1,
            Title:     "Test",
            Completed: false,
        },
        {
            ID:        2,
            Title:     "Test2",
            Completed: true,
        },
    }

    repo := NewTodoRepositoryInMemory(todos)

    return func(t *testing.T) {
        result, err := repo.GetByID(1)
        if err != nil {
            t.Errorf("Error: %v", err)
        }

        if result.ID != 1 {
            t.Errorf("Expected: 1, Got: %d", result.ID)
        }

        if result.Title != "Test" {
            t.Errorf("Expected: Test, Got: %s", result.Title)
        }

        if result.Completed != false {
            t.Errorf("Expected: false, Got: %t", result.Completed)
        }
    }
}

func testUpdateTodoRepositoryInMemory() func(t *testing.T) {
    todos := []*entity.Todo{
        {
            ID:        1,
            Title:     "Test",
            Completed: false,
        },
        {
            ID:        2,
            Title:     "Test2",
            Completed: true,
        },
    }

    repo := NewTodoRepositoryInMemory(todos)

    return func(t *testing.T) {
        todo := &entity.Todo{
            ID:        1,
            Title:     "Test Updated",
            Completed: true,
        }

        err := repo.Update(1, todo)
        if err != nil {
            t.Errorf("Error: %v", err)
        }

        result, _ := repo.GetByID(1)

        if result.Title != "Test Updated" {
            t.Errorf("Expected: Test Updated, Got: %s", result.Title)
        }

        if result.Completed != true {
            t.Errorf("Expected: true, Got: %t", result.Completed)
        }
    }
}

func testDeleteTodoRepositoryInMemory() func(t *testing.T) {
    todos := []*entity.Todo{
        {
            ID:        1,
            Title:     "Test",
            Completed: false,
        },
        {
            ID:        2,
            Title:     "Test2",
            Completed: true,
        },
    }

    repo := NewTodoRepositoryInMemory(todos)

    return func(t *testing.T) {
        err := repo.Delete(1)
        if err != nil {
            t.Errorf("Error: %v", err)
        }

        result, _ := repo.GetAll()

        if len(result) != 1 {
            t.Errorf("Expected: 1, Got: %d", len(result))
        }

        if result[0].ID != 2 {
            t.Errorf("Expected: 2, Got: %d", result[0].ID)
        }
    }
}

func TestTodoRepositoryInMemory(t *testing.T) {
    t.Run("Create", testCreateTodoRepositoryInMemory())
    t.Run("GetAll", testGetAllTodoRepositoryInMemory())
    t.Run("GetByID", testGetByIDTodoRepositoryInMemory())
    t.Run("Update", testUpdateTodoRepositoryInMemory())
    t.Run("Delete", testDeleteTodoRepositoryInMemory())
}

基本上就測試了 CreateGetAllGetByIDUpdateDelete 這幾個功能,這樣我們就可以確保 Repository 是正常運作的。


Presenter 測試


假設以 List 為例,我們可以寫個測試來測試看看。

檔案路徑: interface/presenter/todo/list_todo_json_test.go

package todopresenter_test

import (
    todopresenter "ca-app/interface/presenter/todo"
    "ca-app/interface/presenter/todo/model"
    todousecase "ca-app/usecase/todo"
    usecaseerror "ca-app/usecase/todo/error"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestListTodoJSONPresenter(t *testing.T) {
    t.Run("Output", func(t *testing.T) {
        output := &todousecase.ListTodoOutput{
            {
                ID:        1,
                Title:     "title 1",
                Completed: false,
            },
            {
                ID:        2,
                Title:     "title 2",
                Completed: true,
            },
        }
        expected := &todopresenter.ListTodoJsonOutput{
            Success: true,
            Data: []*model.Todo{
                {
                    ID:        1,
                    Title:     "title 1",
                    Completed: false,
                },
                {
                    ID:        2,
                    Title:     "title 2",
                    Completed: true,
                },
            },
            Message: "",
        }
        presenter := todopresenter.NewListTodoJSONPresenter()
        actual := presenter.Output(output)

        assert.Equal(t, expected, actual)
    })

    t.Run("Error", func(t *testing.T) {
        err := errors.New("custom error")
        expected := &todopresenter.ListTodoJsonOutput{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        }
        presenter := todopresenter.NewListTodoJSONPresenter()
        actual := presenter.Error(err)

        assert.Equal(t, expected, actual)
    })
}

這邊可以看到說我們測試了 OutputError 兩個部分,這樣我們就可以確保 Presenter 是正常運作的。 (基本上就是確定他有正常轉換你的資料)


Controller 測試


這邊以 Create 為例子,我們可以寫個測試來測試看看。

不過這邊先聲明一下,為了簡化,所以只是簡單 Mock 一個需要的東西來用,實際上你可能會需要一個 Mock 的工具來幫你做這些事情,例如 go-mockery 之類的。

檔案路徑: interface/gin-adapter/controller/todo_controller_test.go

首先先定義 Mock 相關的物件(記得實務上請用 Mock 的工具來幫你做這些事情):

type MockCreateTodoUsecase struct{}
type MockCreateTodoPresenter struct{}

func (m *MockCreateTodoUsecase) Execute(input *todousecase.CreateTodoInput) (*todousecase.CreateTodoOutput, error) {
    return nil, nil
}

func (m *MockCreateTodoPresenter) Output(output *todousecase.CreateTodoOutput) *todopresenter.CreateTodoJsonOutput {
    return nil
}

func (m *MockCreateTodoPresenter) Error(err error) *todopresenter.CreateTodoJsonOutput {
    return nil
}

我什麼處理都不做,只是想呈現說在測試你都可以單獨測,不會像以往一樣全部綁在一起沒辦法測(可能只能 e2e):

接著我們需要去組出 Controller,會像下面這樣:

func setupController() *controller.TodoController {
    // Setup Mock
    mockTodoUsecase := &MockCreateTodoUsecase{}
    mockTodoPresenter := &MockCreateTodoPresenter{}

    // Setup Facade Usecase
    todoUsecase := &usecase.TodoUsecase{
        CreateTodo: mockTodoUsecase,
    }

    // Setup Facade Presenter
    todoPresenter := &presenter.TodoJSONPresenter{
        Create: mockTodoPresenter,
    }

    return controller.NewTodoController(todoUsecase, todoPresenter)
}

這邊如果是完整的測試的話,你的 Facade 當中的所有 UsecasePresenter 都會是 Mock 的,只是這邊為了節省篇幅,所以只 Mock 了一個會用到的。

接著因為我們的 Controller 是接收 gin.Context,所以我們也需要拿到測試用的 gin.Context,這邊可以寫一個 setupGin 來幫助我們:

func setupGin() (*httptest.ResponseRecorder, *gin.Context) {
    gin.SetMode(gin.TestMode)
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    return w, c
}

這樣的話你就可以拿到 gin.Context 來調整你的 Request(例如你可能需要調 HeaderBody 之類的),接著 w 就是 ResponseRecorder,你可以拿到 Response 來做檢查(如果你想的話)。

接著就是寫測試了,這邊以 Create 為例:

func TestTodoController_Create(t *testing.T) {
    t.Run("Bad Request", func(t *testing.T) {
        controller := setupController()
        w, c := setupGin()

        controller.Create(c)

        if w.Code != 400 {
            t.Errorf("Expected status code 400, but got %d", w.Code)
        }
    })

    t.Run("Success", func(t *testing.T) {
        controller := setupController()
        w, c := setupGin()

        todo := request.CreateTodoRequest{
            Title: "title",
        }
        jsonTodo, _ := json.Marshal(todo)
        c.Request = httptest.NewRequest("POST", "/todos", bytes.NewBuffer(jsonTodo))

        controller.Create(c)

        if w.Code != 201 {
            t.Errorf("Expected status code 201, but got %d", w.Code)
        }
    })
}

這裡為了簡化,所以只寫了兩個測試,一個是 Bad Request,一個是 Success,而且不去介入太多細節(只檢查 Status Code),只是簡單的測試,實際上你可能會需要更多的測試來確保你的 Controller 是正常運作的。

但是測試也不應該太過具體,避免測試過於脆弱,程式碼一改就壞,這樣反而得不償失。

例如你可能只需要測試相應被 MockUsecasePresenter 有沒有被正確的呼叫,而不是去檢查具體的 Response


其他的測試


連相對複雜的 Controller 都能寫測試了,其他更靠近內部的 UsecaseEntity 之類的就更容易測試了,基本上做法不會差太多,所以這邊就不再贅述。

但是有個可以稍微提一下的是,這邊使用的是 gin ,如果你連 Router 都想測,也不是不行,不過如果你的 router 也是以 DI 架構建立的話,你還是得像上面一樣手動建立 Controller 來測。

檔案路徑: interface/gin-adapter/router/router_test.go

func TestCreateTodo(t *testing.T) {
    t.Run("Bad Request", func(t *testing.T) {
        controller := setupController()
        router := router.SetupRouter(controller)
        w := httptest.NewRecorder()
        r := httptest.NewRequest("POST", "/todos", nil)

        router.ServeHTTP(w, r)

        if w.Code != http.StatusBadRequest {
            t.Errorf("Expected status %d but got %d", http.StatusBadRequest, w.Code)
        }
    })

    t.Run("Success", func(t *testing.T) {
        controller := setupController()
        router := router.SetupRouter(controller)
        w := httptest.NewRecorder()
        todo := request.CreateTodoRequest{
            Title: "title",
        }
        jsonTodo, _ := json.Marshal(todo)
        r := httptest.NewRequest("POST", "/todos", bytes.NewBuffer(jsonTodo))

        router.ServeHTTP(w, r)

        if w.Code != http.StatusCreated {
            t.Errorf("Expected status %d but got %d", http.StatusCreated, w.Code)
        }
    })
}

基本上是使用 gin 所提供的 ServeHTTP 來做 HTTP 的測試,這樣你就可以測試你的 Router 了。

這邊可以參考: https://gin-gonic.com/docs/testing/

然後這邊和前面 Controller 都用到了 httptest 去建立 Request,不過有一個很微小的差異可以提一下,在 Controller 當中的 Request 如果你的 path 打錯沒關係,因為他注重的是 body,但是在 Router 的測試當中,如果你的 path 打錯的話,他就會直接 404,所以這邊要注意一下。

雖然你可能應該盡量當作不知道他的實作,但是這邊還是提一下。


8. 替換實作


前面使用 In-Memory 的方式來實作 Repository,但是實際上你可能會需要用 Database 來實作,這邊就以 mysql 來實作,當然你也可以替換成其他資料庫。

而這邊打算使用 sqlx 當作例子,當然你也可以使用 gorm 之類的。

檔案路徑: infrastructure/repository/todo_repository_mysql.go

package repository

import (
    "ca-app/domain/entity"
    "ca-app/domain/repository"
    "sync"

    "github.com/jmoiron/sqlx"
)

type TodoRepositoryMySQLImpl struct {
    lock *sync.Mutex
    db   *sqlx.DB
}

func NewTodoRepositoryMySQL(db *sqlx.DB) repository.TodoRepository {
    return &TodoRepositoryMySQLImpl{
        lock: &sync.Mutex{},
        db:   db,
    }
}

func (r *TodoRepositoryMySQLImpl) GetAll() ([]*entity.Todo, error) {
    todos := []*entity.Todo{}
    if err := r.db.Select(&todos, "SELECT * FROM todos"); err != nil {
        return nil, err
    }
    return todos, nil
}

func (r *TodoRepositoryMySQLImpl) GetByID(id int) (*entity.Todo, error) {
    todo := &entity.Todo{}
    if err := r.db.Get(todo, "SELECT * FROM todos WHERE id = ?", id); err != nil {
        return nil, err
    }
    return todo, nil
}

func (r *TodoRepositoryMySQLImpl) Create(todo *entity.Todo) error {
    r.lock.Lock()
    defer r.lock.Unlock()

    result, err := r.db.Exec("INSERT INTO todos (title, completed) VALUES (?, ?)", todo.Title, todo.Completed)
    if err != nil {
        return err
    }

    lastId, err := result.LastInsertId()
    if err != nil {
        return err
    }

    todo.ID = int(lastId)
    return nil
}

func (r *TodoRepositoryMySQLImpl) Update(id int, todo *entity.Todo) error {
    _, err := r.db.Exec("UPDATE todos SET title = ?, completed = ? WHERE id = ?", todo.Title, todo.Completed, id)
    return err
}

func (r *TodoRepositoryMySQLImpl) Delete(id int) error {
    r.lock.Lock()
    defer r.lock.Unlock()

    _, err := r.db.Exec("DELETE FROM todos WHERE id = ?", id)
    return err
}

基本上就是實作當初定義好的 Repository,這樣你就可以很容易的替換掉原本的 In-Memory 的實作。

然後這邊需要一個 DB 的連接,所以你可以在 infrastructrue 建立一個檔案來建立 DB 的連接:

檔案路徑: infrastructure/database/mysql_sqlx.go

package database

import (
    "fmt"
    "net/url"

    "github.com/jmoiron/sqlx"
    _ "gorm.io/driver/mysql"
)

func NewMySQLDB() *sqlx.DB {
    loc := "Asia/Taipei"
    loc = url.QueryEscape(loc)

    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=%s",
        "root",
        "",
        "localhost",
        3306,
        "ca-app-db", // 這邊是你的 DB 名稱
        loc,
    )

    db := sqlx.MustConnect("mysql", dsn)
    return db
}

這裡為了方便,直接將 DB 的資訊寫死在程式碼中,實際上你可能會需要用 env 之類的來設定。

然後你可以在 Main 中替換掉原本的 In-Memory 的實作:

func main() {
    // repository := repository.NewTodoRepositoryInMemory([]*entity.Todo{})
    db := database.NewMySQLDB()
    repository := repository.NewTodoRepositoryMySQL(db)

    // ...
}

然後重新執行你的程式,沒意外的話一切正常運作,完美。

其他部分的抽換基本上也是大同小異,這邊就不再贅述,篇幅已經太長了


9. 下一步


下一步你可能還會需要:

  • 使用 DI 框架來幫你處理 DI 的部分
  • DDD 的方式來設計你的應用,脫離貧血模型
  • 使用 CQRS 來處理你的 CommandQuery,或是至少以 任務型介面 的方式來設計你的程式,而不是以 CRUD 的方式
  • Test-First(TDD) 的方式來開發你的程式

這些都是可能可以進一步學習的東西,這篇文章只是一個開始,之後有空應該會在補文章。


結論


這一篇只是一個簡單的範例,也沒有特別對 entity 加入一些商業邏輯,實際上你可能會遇到更複雜的情況,但這篇只是希望讓還在寫 MVC 的人能夠快速地上手(?) Clean Architecture,至少能夠知道他的基本概念。

這樣以後就可以不用先決定要用的細節,而是可以先 focus 在你要做的事情上,然後再根據需求來決定要用什麼技術。

最後總覺得好像忘了什麼,但是這篇文章已經夠長了,所以就先這樣吧(?) ((因為最近有點忙,這篇文章隔了好多天分好多次寫




最後更新時間: 2024年11月28日.