領域驅動最佳實踐–用程式碼來告訴你來如何進行領域驅動設計
- 2019 年 10 月 3 日
- 筆記
做一個租戶系統下的許可權服務,接管用戶的認證和授權,我們取名該服務為
go-easy-login
本文實質是領域驅動設計之實戰許可權系統微服務的進一步總結和改進,學習領域驅動設計本身是循序漸進的過程,培養的是領域的概念和面向對象編程思想,而過去以及現在,包括未來,多數人只是披著面向對象的皮,干著面向過程,面向資料庫的糙活,詳情請看為什麼我們需要領域驅動設計,如果你接觸過領域驅動設計,但是苦於不知道如何動手,概念雖懂但不知如何實踐,本篇將能為你打開實踐領域驅動設計的大門,如果你未曾了解過領域驅動設計,這篇同樣也是入門領域驅動設計的最好文章之一,帶你感受領域驅動的非凡魅力。
項目結構
程式碼先行,先展示一下程式碼的目錄結構以及相應的文件,大家可以先YY對應的作用,然後帶著疑問去閱讀。
login base encrypt.go token.go repoImpl.go domain service loginService.go loginService_test.go loginUser.go loginUser_test.go mocks EncryptHelper.go LoginUserRepo.go
如何脫離技術細節
領域驅動設計更加強調業務邏輯以及相應對建立起的領域(模型),不應該出現任何
技術細節,即資料庫,快取等。面向對象是對於外在客觀事物的一個模擬和反映,一
個User類應該具備eat
,drink
,play
,happy
等能力,一個User不可能具備連接資料庫的能力,出現依賴任何技術細節是違反面向對象編程的。那麼問題來了,道理我都懂,如何去做到,如果我們在一個項目中,什麼技術都不用到的話,是不是就達到我們的目的了?(讀者疑問:WTF,怎麼可能?)
新建一個項目,什麼第三方包都不依賴,根據我們想做的功能,做一個租戶系統下的許可權服務,接管用戶的認證和授權,我們新建一個LoginUserE
來代表登陸用戶,DoVerify
執行認證過程,同時我們希望具備帳密登陸的時候,由調用系統決定加密方式,這就意味著LoginUserE
這個領域需要可以根據EncryptWay
來獲取EncryptHelper
,我們先新建一個loginUser.go
package domain type LoginUserE struct { Username string IsLock bool UniqueCode string Mobile string canLoginFunc func() bool EncryptWay } func (user *LoginUserE) CanLogin() bool { var can bool if user.canLoginFunc != nil { can = user.canLoginFunc() } else { can = !user.IsLock } return can } func (user *LoginUserE) DoVerify(sourceCode string, encryptedCode string) (bool, error) { if !user.CanLogin() { return false, errors.New("can not login") } match := user.EncryptHelper().Match(sourceCode, encryptedCode) return match, nil }
這裡的問題在於EncryptHelper()
這個方法,我們知道加密方法,就拿MD5來說,必須需要依賴到其他包,而loginUser.go
我們是不希望依賴到任何第三方包的,這似乎進入了一種矛盾。Alistair Cockburn 提出的六邊形架構,在於domain處於核心內部,其他的依賴通過介面進行交流,再換句話說就是domain層定義介面,基礎設施層(技術層)實現介面,我們定義EncryptHelper
介面
type EncryptHelper interface { Encrypt(password string) string Decrypt(password string) string Match(source, encryptedString string) bool }
然後在base基礎設施層新建encrypt.go
實現該類
type MD5Way struct{} func (md5 MD5Way) Match(source, encryptedString string) bool { return md5.Encrypt(source) == encryptedString } func (MD5Way) Encrypt(password string) string { data := []byte(password) md5Bytes := md5.Sum(data) return string(md5Bytes[:]) } func (MD5Way) Decrypt(password string) string { panic("not support") }
問題還沒能夠解決,base層的具體實現類,如何讓domain層中不直接依賴的同時,又能使用呢?最好的方法實際上是依賴注入,但是引入依賴注入又陷入另一種悖論–不依賴任何技術細節,依賴注入也可以歸納為技術的一種,下文再繼續探討這點,且看我如何不用依賴注入實現。在loginUser.go
我們新建一個全局變數var EncryptMap = make(map[EncryptWay]EncryptHelper)
var EncryptMap = make(map[EncryptWay]EncryptHelper) func (encryptWay EncryptWay) EncryptHelper() EncryptHelper { if helper, ok := EncryptMap[encryptWay]; ok { return helper } else { panic("can not find helper") } } func AddEncryptHelper(encryptWay EncryptWay, helper EncryptHelper) { EncryptMap[encryptWay] = helper }
核心層之外的類,通過AddEncryptHelper
註冊相應的EncryptHelper
,這種設計初看尚可,但是一旦項目中具備更多個領域,再採用這種方法則會導致程式碼的維護成本的提高,依賴注入實則是屏蔽構建具體實現類的過程,要不要在domain層引入,因人而異,因項目而異。若有更好的方法,歡迎在評論區中提出。
領域服務
若你是初涉或者從未涉及過領域驅動設計,你的思維會比較固定,如為什麼我們需要領域驅動設計,長期以來你習慣以數據表為核心進行分析設計,想著某個功能我們應該如何建表。我敢打包票,在上面講解的功能過程中,你一開始就在思考這個表怎麼設計的,我從表中取哪個欄位再怎樣怎樣,這是絕對的被資料庫,被技術綁架了的思維但是,問題也在於,我們最終總是需要解決資料庫這個問題,這個問題也就是在領域服務中解決。
領域服務解決的另一個問題是組裝邏輯,舉個例子,LoginUserE.DoVerify
雖然不依賴任何第三方包或者同級的其他類,但是他的入參被我們依賴隔離,這個入參可能就依賴其他domain,因此,我們需要領域服務去組裝這一層。
我們來講一下登陸是如何實現的,首先我們定義LoginCmd
為Login
的入參,
//implemention will show right behind this func (service *LoginService) Login(loginCmd common.LoginCmd) (string, error) type LoginCmd struct { Username string TenantId string EffectiveSeconds int Mobile string SourceCode string LoginWay string EncryptWay string }
這裡就到了設計資料庫的地方,我們需要去查找判斷這個用戶是否存在,那麼問題來了,我們不能直接依賴資料庫技術,但是我們又需要,這可咋整?類似的當然是定義介面
type LoginUserRepo interface { GetOne(username, tenantId string) *domain.LoginUserDO }
但是這裡又回到了上文討論依賴注入的地方了,這裡我為了簡單起見,仍然沒有用到依賴注入,但是我個人是建議使用的
var loginService *LoginService type LoginService struct { LoginUserRepo } func NewLoginService(repo LoginUserRepo,) *LoginService { //do not argue to use double check lock,it's a example and does not hurt anyway if loginService == nil { return &LoginService{ LoginUserRepo: repo, } } else { return loginService } }
這樣我們就在初始化LoginService
的時候將repoImpl傳送進行,達到了依賴隔離的目的。領域服務不需要知道任何倉儲手段,甚者無需知道底層用的是什麼資料庫,我只關心取和拿,我只要結果,定義介面的實質目的也在於此。
回到GetOne(username, tenantId string) *domain.LoginUserDO
這個方法,這裡還暴露了一個點在於,DataObject類是定義在domain層中,而不是在service,更不是在base中,我以前糾結的一點是,既然domain層不依賴資料庫技術,是不是也應該不關心DataObject,DataObject是不是放在base層下更加合適?
現在之所以把DataObject放在domain層,原因在於
1.domain核心層不直接依賴其他層,如果DataObject放在base層勢必違背這點; 2.domain層作為介面定義者,有權根據他自身的需求定義他想要的存儲內容,其他層只需要服從並且實現。
同時,我們不希望程式碼中充斥著大量的convert,從cmd轉到DO,從DO轉到E,所以我們提煉出了dto.go
這個文件,用於存放concert程式碼。最終的程式碼形式如下。
func (service *LoginService) Login(loginCmd common.LoginCmd) (string, error) { userDO := service.GetOne(loginCmd.Username, loginCmd.TenantId) userE := common.ToLoginUserE(*userDO) userE.EncryptWay = domain.EncryptWay(loginCmd.EncryptWay) //login way contains PASSWORD and SMS ,encryptCode()is to get which one to be verify ,so userE will not to care about which way is exactly by logining encryptCode := service.encryptCode(loginCmd.LoginWay, userDO) if _, err := userE.DoVerify(loginCmd.SourceCode, encryptCode); err != nil { return "", err } //todo add login event and callback return service.token(userE.UniqueCode, loginCmd.EffectiveSeconds), nil } func (service *LoginService) encryptCode(way string, userDO *domain.LoginUserDO) string { switch way { case "PASSWORD": return userDO.Password case "SMS": return service.FindSmsCode(userDO.Mobile) default: panic("unknown login way") } }
新的風暴又出現了,service.token(userE.UniqueCode, loginCmd.EffectiveSeconds)
這段邏輯是什麼意思,上文中也沒有出現,待我慢慢需講解。正常登陸下我們校驗成功之後需要授予token,但是token的生成技術細節,用JWT還是什麼其他的,domain不應該關心,所以我們給loginService 加一個類型為函數的field
type LoginService struct { LoginUserRepo token func(uniqueCode string, effectiveSeconds int) string } func NewLoginService(repo LoginUserRepo, token func(uniqueCode string, effectiveSeconds int) string) *LoginService { if loginService == nil { return &LoginService{ LoginUserRepo: repo, token: token, } } else { return loginService } }
最終效果
拷貝不走樣,屏蔽技術細節,強調業務邏輯,最終目的是實現業務邏輯可重用,組織為一個可重用的自封閉的業務模型。最終我們很好的構建了這樣的一個模型。
這個業務模型無論置身於任何技術框架,任何Web框架,還是其他的場景,都不會受到破壞,無論選擇任何資料庫技術,也不會影響到這個模型。外在技術的細節這裡就不跟著大家一起實現了,本篇文章重在構建模型,技術的選擇就由自己去做決定,這也絲毫影響不了模型。
測試驅動使領域驅動更加完美
全篇下來的奧義在於隔離依賴,這些都是經驗積累,有沒有行之有效的規範得以遵守,答案是我也不知道,但是如果你遵守測試驅動的行為的話,這會迫使你去思考,什麼該依賴,什麼不該依賴,因為所有的第三方依賴,都需要用Mock去代替,這就是為什麼目錄中存在mocks這個文件。測試寫得好,煩惱多不了。
總結
回頭看這是我寫的第三篇涉及領域驅動設計的文章,目的在於能夠讓更多的人更加容易理解並且實踐領域驅動設計,寫出優秀的程式碼,得出接任者的稱讚,提高程式碼品質。
路漫漫其修遠兮,看官點個讚唄!
作者:plz叫我紅領巾
出處:領域驅動最佳實踐–用程式碼來告訴你來如何進行領域驅動設計
源碼地址:https://github.com/iamlufy/go-easy-login 程式碼會逐步完善,可以看看git提交記錄~
本部落格歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。