Skip to content

Raaaaaaaay86/go-project-structure

Repository files navigation

Golang乾淨架構實踐

Go和其他語言相比,在專案架構上並無限制,但是在實際開發中,我們還是需要一個規範,讓專案的架構更加清晰,便於維護和擴展。此範例會以 乾淨架構(Clean Architecture)為基礎,並且帶入領域驅動開發(Domain Driven Design)的概念來組織專案。

乾淨架構能帶來以下好處:

  • 易於閱讀的代碼
  • 提高可測試性
  • 低代碼耦合度
  • 團隊協作友好,不互相干擾

範例中使用了以下資料庫來表現資料存取層的彈性:

其中也使用OpenTelemetryJaeger增加可觀測性。

環境設置

  • 安裝Go
  • 安裝Docker,使用Docker Compose搭建環境(Postgres, MongoDB...)
  • 安裝golang-migrate,用來執行資料庫遷移
  • 安裝FFmpeg,此專案會使用到FFmpeg來處理影片上傳轉檔案

啟動專案

執行終端指令

make run

或是

bash ./scripts/setup.sh
docker-compose up -d
migrate -path migration/postgres -database "postgres://root:123456@localhost:5432/$(DB_SCHEMA)?sslmode=disable" -verbose up
go run main.go

乾淨架構主要概念

image image

乾淨架構主要分為三層,依序由核心到外部為Domain Layer、Application Layer、Adapter Layer。每層之間皆以介面(Interface) 做依賴注入(Dependency Injection), 來達成解耦。

  • Domain Layer:
    是專案的核心,主要負責專案的業務邏輯,並且不依賴任何外部服務,例如資料庫、HTTP等等。通常以Entity、Aggregate組成。

  • Application Layer:
    是Domain Layer的橋樑,負責將Domain Layer的業務邏輯透過Interface暴露外部。通常以UseCase、Repository(Interface)組成。

  • Adapter Layer:
    負責將外部的請求轉換成Application Layer的輸入(e.g. HTTP, gRPC等請求),或將Application Layer的輸出轉換成外部的輸出(e.g. 存取資料庫, 請求外部服務)。

資料夾結構

.
├── .
├── ├── adapter # 對應Adapter Layer
├── │   ├── port_i # 外部輸入(ingress)
├── │   │   └── http
├── │   │       ├── route
├── │   │       └── router.go
├── │   └── port_out # 內部輸出(egress)
├── │       └── repository
├── ├── config
├── │   └── app_config.yaml
├── ├── docker-compose.yml
├── ├── docs
├── ├── internal
├── │   ├── context # 以不同限界上下文(Bounded Context)做package拆分,商業邏輯會存在於各package
├── │   │   │── auth (驗證領域)
│   │   │   └── media (媒體領域)
├── │   │       ├── comment (sub-domain) # 每個限界上下文包含不同sub-domain
├── │   │       └── video (sub-domain)
├── │   ├── entity # 對應Entity
├── │   ├── aggregate # 對應Aggregate
├── │   ├── vo # 對應 Value Object
├── │   ├── repository # 對應Application Layer的Repository,只存放介面(Interface)
├── │   └── exception
├── ├── go.mod
├── ├── go.sum
├── ├── main.go # 進入點
├── ├── migration # 資料庫版控,可以分開獨立於其他Git Repository
├── ├── mocks # 存檔Mockery產生的mock檔案
├── ├── pkg # 調用外部套件會放在/pkg下
├── ├── Makefile
└── └── scripts

開發準則

Entity

宣告一個Entity並不需要完全按照Database的Schema來設計,而是以業務邏輯為主,並且不依賴任何外部服務。以下為一個User Entity的範例。

type User struct {
	Id        uint                 `json:"id"`
	Roles     []Role               `json:"roles" gorm:"many2many:user_roles"`
	Username  string               `json:"username"`
	Password  vo.EncryptedPassword `json:"password"`
	Email     vo.Email             `json:"email"`
	CreatedAt time.Time            `json:"created_at,omitempty"`
	UpdatedAt time.Time            `json:"updated_at,omitempty"`
	DeletedAt sql.NullTime         `json:"deleted_at,omitempty"`
}

// 務必創建一個Constructor function.
func NewUser(username string, password vo.EncryptedPassword, email vo.Email, roles ...Role) *User {
	return &User{
		Roles:    roles,
		Username: username,
		Password: password,
		Email:    email,
	}
}

Application Layer 應依賴於 Repository Interface

Application Layer的UseCase應該依賴於Repository Interface,而不是Adapter Layer中Repository的實作。如此才能達到解耦的效果,並且可以 輕易替換Repository實作(e.g. MySQL替換為Postgres)。且可以在沒有Database的情況下,先開發出Application Layer的商業邏輯。 再做Unit Test時針對Repository Interface做Mock即可,不需要真的連接到資料庫。

type VideoCommentRepository interface {
    Create(ctx context.Context, comment *entity.VideoComment) error
    FindByVideoId(ctx context.Context, videoId uint) ([]*entity.VideoComment, error)
    FindById(ctx context.Context, id primitive.ObjectID) (*entity.VideoComment, error)
    DeleteById(ctx context.Context, id primitive.ObjectID, deleterId uint) (int, error)
    ForceDeleteById(ctx context.Context, id primitive.ObjectID) (int, error)
}

讀寫分離模式(CQRS)拆分業務邏輯

推薦閱讀:CQRS 命令查詢職責分離模式 Ajay Kumar 著 錢亞宏 譯

將寫入與讀取的行為拆分為獨立的UseCase,藉此維持函式呼叫的冪等性,避免意外的副作用,同時提升寫入和讀取的效率。

一個有寫入行為的UseCase會以此方式宣告,xxx則為該UseCase的行為名稱(e.g. Login, CreateProduct...)

package subdomain

var _ IxxxUseCase = (*xxxUseCase)(nil) // 在編譯期間檢查 xxxUseCase 是否實作 IxxxUseCase 介面

type xxxCommand struct {
    ParameterA uint
    RandomEntity  entity.RandomEntity
}

type xxxResponse struct {
	
}

type IxxxUseCase interface {
    Execute(ctx context.Context, cmd xxxCommand) (*xxxResponse, error)
}

type xxxUseCase struct {
    RandomEntityRepository repository.RandomEntityRepository // 請宣告為介面!
}

func NewxxxUseCase(randomEntityRepository repository.RandomEntityRepository) *xxxUseCase {
    return &xxxUseCase{
        RandomEntityRepository: randomEntityRepository,
    }
}

func (uc xxxUseCase) Execute(ctx context.Context, cmd xxxCommand) (*xxxResponse, error) {
    err := uc.RandomEntityRepository.Create(cmd.RandomEntity)
    if err != nil {
        return nil, err
    }
	
    return &xxxResponse{}, nil
}

若為讀取行為的UseCase則將xxxCommand改為xxxQuery,表達執行一個讀取操作。

DTO (Data Transfer Object) 該放在哪?

若DTO與UseCase的Command欄位完全相同,則可不必轉換,直接將Command當作DTO使用,直接將Application Layer與Adapter Layer耦合,避免代碼冗余。 若需要建立DTO(以HTTP Controller為例)則建議建立在adapter/port_in/http/dto下,供Adapter Layer使用。由於DTO是供給外部服務做格式轉換來 與核心服務溝通,所以internal核心服務並不會知道DTO的存在。

func (c CommentController) Create(ctx *gin.Context) {
    newCtx, span := tracing.HttpSpanFactory(c.TracerProvider, ctx, pkg)
    defer span.End()

    token, exists := ctx.Get("token")
    if !exists {
        ctx.JSON(http.StatusUnauthorized, res.Fail(exception.ErrUnauthorized.Error(), nil))
        tracing.RecordHttpError(span, http.StatusUnauthorized, exception.ErrUnauthorized)
        return
    }

    cmd := comment.CreateCommentCommand{
        AuthorId: token.(*jwt.CustomClaim).Uid,
    }
    err := ctx.ShouldBindJSON(&cmd)
    if err != nil {
        ctx.JSON(http.StatusBadRequest, res.Fail(err.Error(), "invalid request body."))
        tracing.RecordHttpError(span, http.StatusUnauthorized, err)
        return
    }

    response, err := c.CreateUseCase.Execute(newCtx, cmd)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, res.Fail(err.Error(), "unable to create comment."))
        tracing.RecordHttpError(span, http.StatusInternalServerError, err)
        return
    }

    ctx.JSON(http.StatusCreated, res.Success(response))
}

可觀測性

image OpenTelemetry是由context.Context的傳播機制來做鏈路追蹤,因此在每層架構中應將context.Context傳遞下去,使後續開發以及運維單位能夠輕易的追蹤到 每個請求的狀態。
無論當前是否有使用可觀測性工具,建議都在每層函式呼叫的第一個參數加上context.Context,並且在每層函式呼叫時將context.Context傳遞下去。 避免未來需要加入可觀測性工具時,需要大量的修改代碼。

範例 1. Use Case

type IxxxUseCase interface {
    Execute(ctx context.Context, cmd xxxCommand) (*xxxResponse, error)
}

範例 2. Repository

type UserRepository interface {
    Create(ctx context.Context, user *entity.User) error
    FindByUsername(ctx context.Context, username string) (*entity.User, error)
    FindById(ctx context.Context, id uint) (*entity.User, error)
}

組件之間應依賴於介面

組件之間應盡量依賴於介面,而不是實作。如此才能避免耦合。

Live Template (for Goland IDE)

建議個人或團隊開發使用Live Template來產生代碼提升開發速度,也可避免在開發框架下的代碼風格不一致。

Interface Implementation Compile-Time Check

var _ $INTERFACE$ = (*$STRUCT$)(nil)

CQRS Command Use Case

var _ I$Name$UseCase = (*$NAME$UseCase)(nil)

type $Name$Command struct {

}

type $Name$Response struct {

}

type I$Name$UseCase interface {
Execute(cmd $Name$Command) (*$Name$Response, error)
}

type $Name$UseCase struct {

}

func New$Name$UseCase() *$Name$UseCase {
return &$Name$UseCase{}
}

func (uc $Name$UseCase) Execute(ctx context.Context, cmd $Name$Command) (*$Name$Response, error) {
return &$Name$Response{}, nil
}

CQRS Query Use Case

var _ I$Name$UseCase = (*$NAME$UseCase)(nil)

type $Name$Query struct {

}

type $Name$Response struct {

}

type I$Name$UseCase interface {
Execute(cmd $Name$Query) (*$Name$Response, error)
}

type $Name$UseCase struct {

}

func New$Name$UseCase() *$Name$UseCase {
return &$Name$UseCase{}
}

func (uc $Name$UseCase) Execute(ctx context.Context, cmd $Name$Query) (*$Name$Response, error) {
return &$Name$Response{}, nil
}

Table-Driven Unit Test

func Test$FUNC_NAME$(t *testing.T) {
    type $NAME$TestCase struct {
        TestDescription string
    }

    testCases := []$NAME$TestCase{
    }

    for i, tc := range testCases {
        t.Logf("Test case [%d]: %s", i, tc.TestDescription)
    }
}

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

No packages published