做一個租戶系統下的許可權服務,接管使用者的認證和授權,我們取名該服務為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,因此,我們需要領域服務去組裝這一層。
我們來講一下登陸是如何實現的,首先我們定義LoginCmdLogin的入參,

//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叫我紅領巾

     出處:領域驅動最佳實踐--用程式碼來告訴你來如何進行領域驅動設計

    本部落格歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利