Go和其他語言相比,在專案架構上並無限制,但是在實際開發中,我們還是需要一個規範,讓專案的架構更加清晰,便於維護和擴展。此範例會以 乾淨架構(Clean Architecture)為基礎,並且帶入領域驅動開發(Domain Driven Design)的概念來組織專案。
乾淨架構能帶來以下好處:
- 易於閱讀的代碼
- 提高可測試性
- 低代碼耦合度
- 團隊協作友好,不互相干擾
範例中使用了以下資料庫來表現資料存取層的彈性:
其中也使用OpenTelemetry和Jaeger增加可觀測性。
- 安裝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乾淨架構主要分為三層,依序由核心到外部為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並不需要完全按照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的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 命令查詢職責分離模式 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與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))
}
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來產生代碼提升開發速度,也可避免在開發框架下的代碼風格不一致。
var _ $INTERFACE$ = (*$STRUCT$)(nil)
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
}
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
}
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)
}
}

