# clean arch in Go ## the clean arch ![](https://ithelp.ithome.com.tw/upload/images/20190929/201119978c6JzESmtN.jpg) - 外圈程式的修改不應該影響內圈 - 藉由DI讓依賴的方向總是由外往內走 (外圈 import 內圈 package) - 可以根據狀況加減層數和職責 --- 例如:從Yahoo抓資料變成從Google抓資料,結果資料格式變了,修改格式以後所有用到的地方都需要跟著修正 => 不管從哪裡抓資料,都應該轉成一個統一的格式回傳,並要求不同的資料源抓取實作要擁有相同的interface。 ```go= package pubfinance // x, data struct is different for every api type YFinanceRow struct {} func GetPubFinance(stockID string) ([]YFinanceRow, error) // o, implements the same interface type FinanceRow struct {} func (*YFinanceCrawler) GetPubFinance(stockID string) ([]FinanceRow, error) func (*GFinanceCrawler) GetPubFinance(stockID string) ([]FinanceRow, error) ``` note. 常寫Java或C#的開發者習慣先定義interface,但在go裡面我們傾向在consumer side定義interface,並且 `accept interfaces, return structs` --- 對應到現有結構上: entity -> models, lib usecase -> appmodule, service adapter -> module/controller Web -> gin --- ### 可能遇到的問題:資料結構跨層 當controller想要return response的時候可能會寫: `c.AbortWithError(http.StatusBadRequest, err)` `c.JSON(resp)` 這邊用到了外圈的知識(api provided by gin),讓內圈需要關注如何用適當的方式回應結果 先前我們已經做了一些相關的工作去抽象化,把如何處理錯誤的工作交給外部,外部再實作該錯誤應該回覆哪個http status,要不要直接暴露原始錯誤訊息給client,同時也可以避免不同開發者有不同想法而實作自己的邏輯 (比如說有的開發者喜歡在找不到資源的時候回傳404,有的人則是400+專用的error response object) ```go= package app // import "gobe/app" const CodeUnknownError = 1000 ... func AlreadyExistError() error func AuthError() error func BadRequest() error func ConnectionError(err error, proto, addr string) error func DBError(err error) error func InfluxError(err error, sql string) error func IsRecordNotFoundError(err error) bool func NotFoundError() error func ParamError(err error) error func ParamErrorf(s string, args ...any) error func PermError(required string) error func RedisError(err error, args ...string) error func ResourceForbiddenError() error func RowsAffectedError(expected, actual int64) error func ServiceError(service string, err error) error func Text(code int) string func Unexpected() error func UnexpectedError(err error) error func UnexpectedErrorf(s string, args ...any) error func WithCode(err error, code int) error ``` 如果想要讓依賴關係始終由外向內,就需要利用依賴反轉的方式。 所以我們定義一個interface讓外層實作轉換方式,並傳進來。 (這個例子比較簡單,想想[http.ResponseWriter](https://pkg.go.dev/net/http#ResponseWriter)) ```go= package controller type Response struct { Name string, Password string } type ResponseWriter interface { Write(Response) Error(error) } func Serve(req Request, writer ResponseWriter) { var resp Response ... writer.Write(resp) } ``` 在外層另外定義一個JSON marshal用的struct (gorm同理) ```go= package adapter // for example password is discarded, differents from controller.Response type JSONResponse struct { Name string `json:"name"` } type ResponseHandler struct { ctx *gin.Context } func (r *ResponseHandler) Write(resp service.Response) { jsonResp := ToJSONResp(resp) r.ctx.JSON(jsonResp) } func (r *ResponseHandler) Error(err error) { r.ctx.AbortWithError(500, err) } ``` --- ### data mapping ```go= type User struct { ID uint `gorm:"primarykey" json:"id"` Email string `gorm:"not null;unique" json:"email"` Password string `gorm:"not null" json:"-"` } ``` 這樣是不是違反了規則?entity(model)竟然需要定義外圈的知識 --- ### data mapping strategy (ref. clean arch 實作篇) 最簡單的開發方式是將所有資料結構定義在一起 (no mapping)。 但隨著複雜度越來越高,我們可能需要針對不同layer定義自己的資料結構。 通常有以下幾種作法: - No Mapping - input == output == entity - One-Way Mapping - {input, output, eneity} implements EntityModelInterface 全部共用一個interface - Two-Way Mapping - input <--> entity, entity <--> output - Full Mapping - Two-Way Mapping + Request + Response 如何選擇並沒有固定的答案,要看目前的複雜度和現場情況而進行修改,可能一開始先用no mapping,後面發現已經不符合需求了再改成two-way mapping #### No Mapping go中偏好利用struct tag輔助reflect機制,所以我們在struct定義同時寫上gorm和json tag表示該struct會被gorm和json使用 ```go= package entity type User struct { ID uint `gorm:"primarykey" json:"id"` Email string `gorm:"not null;unique" json:"email"` Password string `gorm:"not null" json:"-"` Name string `gorm:"not null" json:"name"` } ``` #### One-Way Mapping 每個struct都要實作User interface ```go= package entity type User interface { ID() uint Email() string Password() string Name() string SetID(uint) SetEmail(string) SetPassword(string) SetName(string) } type user struct {} var _ User = &user{} ``` ```go= package orm type gormUser struct {} var _ entity.User = &gormUser{} ``` ```go= package adapter type jsonUser struct {} var _ entity.User = &jsonUser{} ``` #### Two-Way Mapping 實作從外圈struct轉換到內圈的function,內圈不需要實作 (會違反依賴關係) ```go= package entity type User struct { ID uint ... /* no tag any more */ } package orm type gormUser struct { ID uint `gorm:"primarykey"` ... } func (u *gormUser) ToUser() *entity.User package adapter type jsonUser struct { ID uint `json:"id"` ... } func (u *jsonUser) ToUser() *entity.User ``` #### Full Mapping orm 和 adapter 採用 Two-Way Mapping 形式,此外針對每個usecase都有專門的Request。 Response可以額外定義也可以直接回傳entity object。 ```go= package entity type User struct { ... } type ChangePasswordRequest { ID uint, password string } type ChangeUserNameRequest { ID uint, name string } type SingleUserRequest { ID uint } func ChangePassword(req ChangePasswordRequest) error func ChangeUserName(req ChangeUserNameRequest) error func DeleteUser(req SingleUserRequest) ``` --- ## 我們期望從clean arch中得到什麼啟發 - 更明確的層次和職責 - now: lib, model <- appmodule <- controller <- tonic <- gin, gorm - 更方便進行測試 - 不用每次測試都要開一個資料庫,塞一堆假資料進去 順便得到: - 簡化gin層介接的程式碼 - 多一層介面可以跑api schema auto gen --- ## Go packaging - Bad package names - **Avoid meaningless package names.** Packages named util, common, or misc provide clients with no sense of what the package contains. - **Don’t use a single package for all your APIs.** Many well-intentioned programmers put all the interfaces exposed by their program into a single package named api, types, or interfaces - **Avoid unnecessary package name collisions.** While packages in different directories may have the same name, packages that are frequently used together should have distinct names. --- ### 怎麼調整code base #### 期望的發展方向 cmd -> app app往外圈:tonic -> gin app往內圈:tonic -> component (or directly import entity for naive senario) component根據feature或根據元件打包 一個usecase如果需要其他usecase的功能 (例如billing需要抓上下線) 則從 tonic層注入實作,該usecase只定義介面 #### 回顧程式中最噁心的部分:config management - 程式碼錯綜複雜的依賴關係,使得開發者很難手寫填好程式啟動時需要的config - 有時候config的數量多達百條,且在不同subcommang還有不同flag可填 - 我們利用package引入時的init副作用使該package自動向cliflag包註冊config和初始化行為 - 接著該package可使用singleton為其他package提供服務 - 以database為例,gorm.DB實作了細節,而相依性關係只需要要求pass gorm.DB,而不需要自己import database。因為gorm.DB的物件如何初始化是**某層外圈**需要擔心的事 #### 現有的架構 - singleton: database, cache, email - 引入會導致副作用 - wrapper: lib/twsms - 本身就是實作,一旦import就是有可能被用到 - netural: app, tonic - 高度抽象化(和商業邏輯無關的infra code),會被大量引用,本身幾乎不引用別人 (類似gin, gorm的角色) - model: models/user, models/account - 純粹定義model struct - appmodule: appmodule/user, appmodule/rbac - 彙集了各種商業邏輯系統邏輯相關的lib,有可能被多個app引用 - app: app/panel, app/crond - code只被該app使用,往外圈引用tonic, database進行setup,往內圈引用appmodule呼叫service或自己實作service 我們想要處理的對象是model, appmodule, app (層次混淆不清的問題) #### 如果根據layer打包 造成 name collision ``` . └── gobe/ ├── entity/ │ ├── user.go │ ├── broker.go │ └── billing.go └── usecase/ ├── user.go ├── broker.go └── billing.go ``` --- #### 如果根據feature打包 看起來稍微好一點 ``` . └── gobe/ └── component/ ├── user/ │ ├── entity.go │ └── usecase.go └── billing/ ├── entity.go └── usecase.go ``` --- #### 根據元件打包 ``` . └── gobe/ └── component/ └── user/ ├── entity/ │ └── entity.go ├── service/ │ └── service.go ├── repository/ │ ├── db-repository.go │ └── mock-repository.go ├── input/ │ └── gorm-model.go └── output/ └── json-model.go ``` 搭配type alias使用,似乎也是可以考慮的選項 (如果component太複雜的話) ``` package user import "go-play/component/user/entity" type User = entity.User ``` 比較不容易發生cycle import,但公開包要寫比較多code做alias #### private package 如果依照元件打包,在clean arch實作篇裡面提到應該針對某些package設定為private,即不可被外界存取 (如下標示為o的部分) ``` backpul/ └── account/ └── adapter/ ├── in/ │ └── web/ │ └── o AccountController ├── out/ │ └── persistence/ │ ├── o AccountPersistenceAdapter │ └── o SpringDataAccountRepository ├── domain/ │ ├── + Account │ └── + Activity └── application/ ├── o SendMoneyService └── port/ ├── in/ │ └── + SendMoneyUseCase └── out/ ├── + LoadAccountPort └── + UpdateAccountStatePort ``` 雖然go沒有針對package的存取修飾子,但可以利用internal阻止外部存取。 如下`internal/`只有`user/`能存取,`api/`和`billing/`都無法`import internal/repository`。 這樣便可以作出更嚴謹的package結構 ``` gobe/ ├── api/ │ └── gin.go └── component/ ├── user/ │ ├── domain.go │ ├── usecase.go │ ├── response.go │ └── internal/ │ └── repository/ │ └── gorm-db-repository.go └── billing/ └── domain.go ``` ## 外圈結構的變化 針對api的部分定義更明確的職責分工 - gin: HTTP Method URI Middlewares(logger, recovery, CORS, gzip, rate limit) - tonic: - DI (context) - req+query+param+resp adapter - Middlewares(auth, rbac) --- ## gin layer ```go= func RegisterEndpoint(r *gin.Engine) { controller := user.NewController() r.POST("/user/login", controller.Login) r.GET("/user/info", controller.SelfUserInfo) r.DELETE("/user/:user_id", controller.DisableUserByID) } ``` --- ## controller layer tonic ```go= type Controller struct { Login tonic.Action SelfUserInfo tonic.Action DisableUserByID tonic.Action } func NewController() *Controller { c := &Controller{} g := tonic.Use(tonic.AuthRequired) c.Login = g.Use(c.login) c.SelfUserInfo = g.Use(c.selfUserInfo) c.DisableUserByID = g.Use(c.disableUserByID) } ``` adapter ```go= func (x *Controller) login(c *tonic.Context) { var req Request tonic.Chain(c, login). BindJSON(&req). Call(&req). RespJSON() } func login(c *tonic.Context, req *Request) (*Response, error) { ... } ``` testing ```go= func TestLogin(t *testing.T) { var req Request var expResp Response c := stubContext() resp, err := login(c, &req) assert.NoError(t, err) assert.Equal(t, expResp, resp) } ``` ## tonic.Context - 期望他扮演DI的角色,將外部的服務包裝後傳進來使用,例如DB, redis, rbac service, user service, email service - 目標將c.GinCtx隱藏,內部不需要知道gin - 測試時可以建造一個mock context,方便內部進行測試用 ## tonic.Empty, tonic.ID 內建常見的req和resp ```go= func listData(c *tonic.Context, req *tonic.Empty) ([]Data, error) tonic.Chain(c, listData).Call(nil).RespJSON() func createTicket(c *tonic.Context, req *Request) (*tonic.ID, error) tonic.Chain(c, createTicket). BindJSON(&req).StringSliceQuery("status", &req.Statuses). Call(&req).RespJSON() func cancelTicket(c *tonic.Context, req *tonic.ID) (*tonic.Empty, error) tonic.Chain(c, cancelTicket).Param("id", &req.ID).Call(&req).RespJSON() ``` ## 為甚麼不使用struct tag ```go= type Request struct { id int `json:"id"` status string `form:"status"` domain string `uri:"domain"` } ``` - 理論上可以做到,但gin的binding也不完整 (只會填json而略過form和uri) - 要自己寫大量的reflect code來解析資料,而這很容易出bug - 了解怎麼使用tag需要非常詳盡的文件和範例,而method簡單明瞭,直接看method name和code就知道用途 - 後續要新增功能也對開發者比較友善 - 比較之下method chaining比較方便 ## future work - validator struct tag for req - custom data validate functions - more XXXQuery and XXXParam functions - option to add comment or usage to endpoint - context auto wrap transaction for every endpoint execution (to avoid forget it) ### the auto gen implementation 多一層介面以後,我們得到的好處 - 藉由 `Router interface` 實作自己的路由追蹤,並紀錄需要的資訊 - 將常用的BindJSON等function進一步包裝並介入執行流程,在失敗時自動中斷 - 在紀錄資訊時順帶檢查傳入的參數和執行順序是否正確,降低部署後實際測試api才發現錯誤的機會 ```go= type Router interface { Group(string) Router GET(string, Action) POST(string, Action) PUT(string, Action) DELETE(string, Action) } ``` 在Router被呼叫時紀錄Method和URI,利用自己實作的docRouter抽換行為 docRouter的實作會將context替換成追蹤用的假context,傳給Action執行並進一步追蹤內容 下面這個function會被執行一次並紀錄他需要的資訊和回傳的結果類型 ```go= func (r docRouter) DELETE(p string, action Action) { p = path.Join(r.currentPath, p) r.inferAction("DELETE", p, action) } func (r docRouter) inferAction(method, p string, action Action) { traceEndpoint := Endpoint{ Method: method, URI: p, } // collect會蒐集他執行的middlewares清單和最後的handler資訊 traceEndpoint.Collect(action) } ``` handler ```go= func (x *Controller) login(c *tonic.Context) { var req Request tonic.Chain(c, login). // 在這邊利用 generic 紀錄 req 和 resp 的 type BindJSON(&req). // 檢查傳入的 type 是否為 pointer IntParam("id", &req.ID). // 檢查 URI 是否確實有 :id Call(&req). // call 會 invoke login,但追蹤時會跳過不執行 RespJSON() // 回傳結果以 JSON marshal } ``` 最後蒐集出來的結果 ``` type Endpoint struct { Method string URI string Action string Middlewares []string Req any Resp any Queries []HTTPInput Params []HTTPInput } type HTTPInput struct { Key string ValueType string IsOptional bool } ``` ### service layer #### bad 產生了對database的直接依賴 ```go func NewService(dbType string) *Service ``` #### good 另類的DI,gorm.DB也可看做是對DB的interface,我們只知道需要使用他,而不關心他實作對哪種DB的SQL ```go func NewService(db *gorm.DB) *Service ``` #### test approach 1 repository隔離,適用於service內部有重要的邏輯需要測試,又不得不相依於來自Repository的input ```go func NewService(Repository) *Service func DBRepository(db *gorm.DB) Repository func MockRepository() Repository func TestService(t *testing.T) { repo := MockRepository() svc := NewService(repo) count, err := svc.CountUsersVisitInRange(start, end) assert.NoError(t, err) assert.EqualValues(10000, count) } ``` #### test approach 2 直接使用db,適用於不確定該orm用法是否如預期一般的回傳結果,而service只是簡單的facade,所以我們不想多一層service object 缺點是testing需要比較多code準備db環境 (integration test) ```go func NewTxHelper(db *gorm.DB) *TxHelper func (*TxHelper) SumUserAllocatedFunds(userID int) (int64, error) func TestTxHelper(t *testing.T) { db := database.GetDB(database.Default) assert.NoError(t, db.Create([]Sub{sub1, sub2, sub3}).Error) helper := NewTxHelper(db) af, err := helper.SumUserAllocatedFunds(1) assert.NoError(t, err) assert.EqualValues(t, 100000, af) } ```