clean arch in Go
the clean arch
Image Not Showing
Possible Reasons
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
Learn More →
- 外圈程式的修改不應該影響內圈
- 藉由DI讓依賴的方向總是由外往內走 (外圈 import 內圈 package)
- 可以根據狀況加減層數和職責
例如:從Yahoo抓資料變成從Google抓資料,結果資料格式變了,修改格式以後所有用到的地方都需要跟著修正 => 不管從哪裡抓資料,都應該轉成一個統一的格式回傳,並要求不同的資料源抓取實作要擁有相同的interface。
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)
如果想要讓依賴關係始終由外向內,就需要利用依賴反轉的方式。
所以我們定義一個interface讓外層實作轉換方式,並傳進來。
(這個例子比較簡單,想想http.ResponseWriter)
在外層另外定義一個JSON marshal用的struct (gorm同理)
data mapping
這樣是不是違反了規則?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使用
One-Way Mapping
每個struct都要實作User interface
Two-Way Mapping
實作從外圈struct轉換到內圈的function,內圈不需要實作 (會違反依賴關係)
Full Mapping
orm 和 adapter 採用 Two-Way Mapping 形式,此外針對每個usecase都有專門的Request。
Response可以額外定義也可以直接回傳entity object。
我們期望從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
- netural: app, tonic
- 高度抽象化(和商業邏輯無關的infra code),會被大量引用,本身幾乎不引用別人 (類似gin, gorm的角色)
- model: models/user, models/account
- 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
如果根據feature打包
看起來稍微好一點
根據元件打包
搭配type alias使用,似乎也是可以考慮的選項 (如果component太複雜的話)
比較不容易發生cycle import,但公開包要寫比較多code做alias
private package
如果依照元件打包,在clean arch實作篇裡面提到應該針對某些package設定為private,即不可被外界存取 (如下標示為o的部分)
雖然go沒有針對package的存取修飾子,但可以利用internal阻止外部存取。
如下internal/
只有user/
能存取,api/
和billing/
都無法import internal/repository
。
這樣便可以作出更嚴謹的package結構
外圈結構的變化
針對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
controller layer
tonic
adapter
testing
tonic.Context
- 期望他扮演DI的角色,將外部的服務包裝後傳進來使用,例如DB, redis, rbac service, user service, email service
- 目標將c.GinCtx隱藏,內部不需要知道gin
- 測試時可以建造一個mock context,方便內部進行測試用
內建常見的req和resp
為甚麼不使用struct tag
- 理論上可以做到,但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才發現錯誤的機會
在Router被呼叫時紀錄Method和URI,利用自己實作的docRouter抽換行為
docRouter的實作會將context替換成追蹤用的假context,傳給Action執行並進一步追蹤內容
下面這個function會被執行一次並紀錄他需要的資訊和回傳的結果類型
handler
最後蒐集出來的結果
service layer
bad
產生了對database的直接依賴
good
另類的DI,gorm.DB也可看做是對DB的interface,我們只知道需要使用他,而不關心他實作對哪種DB的SQL
test approach 1
repository隔離,適用於service內部有重要的邏輯需要測試,又不得不相依於來自Repository的input
test approach 2
直接使用db,適用於不確定該orm用法是否如預期一般的回傳結果,而service只是簡單的facade,所以我們不想多一層service object
缺點是testing需要比較多code準備db環境 (integration test)