領域驅動設計最佳實踐--程式碼篇

請叫我紅領巾!發表於2019-05-10

做一個租戶系統下的許可權服務,接管使用者的認證和授權,我們取名該服務為oneday-auth-server

寫在前面

​ DDD(領域驅動設計)中涉及到幾個概念,實體,值物件,聚合,限定上下文。本篇只涉及實踐,概念講解將放在下一篇,同時上一篇為什麼我們需要領域驅動設計作為科普帖,大家可以在看完程式碼之後再回頭理解一下,同時對比一下現有專案,知其然更要知其所以然,你經常遇到了什麼問題,為什麼DDD能夠更好的解決軟體負責的問題。

需求描述

  1. ​ 認證功能即登入功能,登入成功登入態的設定,登入失敗的處理方式例如IP鎖定,失敗超過次數鎖定等方式

  2. ​ 授權功能即對認證通過的使用者,進行角色和許可權授予,同時開啟資源保護,未具備訪問該資源許可權的使用者將無法訪問。

    本篇將詳細介紹如何在DDD的指導下實現第一點功能。

領域、子域和界限上下文

​ 我們先明白的一點是領域這個詞語承載了太多的含義,既可以表示整個業務系統,也可以表示其中的某個核心域或者支撐子域。舉個不是很恰當的例子,假設我們原本想要在一個叫賬戶模組實現了這個功能,同時還有使用者資訊功能,這個時候,賬戶就是一個大的領域,一塊的大蛋糕,而oneday-auth則是這塊大蛋糕的某一塊,使用者資訊又是另一塊,這被分出的一塊一塊蛋糕,我們稱之為由賬戶領域分成的子域,許可權子域和使用者資訊子域。子域下還可以再接著劃分出子域,沒有最小的子域,只有最合適的子域。

​ 你會覺得這個微服務的拆分很像,是的,微服務的拆分是遵循DDD的思想,但是你再仔細思考下,你是不是隻學了一個形式而已?可以對比一下下面的了兩張圖片和你的思路是不是不謀而合。

DDD中的劃分

普通微服務的劃分

​ 本文中我將許可權子域再劃分出了認證上下文和授權上下文。對於界限上下文,我們把重點放在界限上,摘抄實現領域驅動設計的一段話:

比如,“顧客”這個術語可能有多種含義。在瀏覽產品目錄的時候,“顧客”表示一種意思;而在下單的時候,“顧客”又表示另一種意思。原因在於:當瀏覽產品目錄時,“顧客”被放在了先前購買情況、忠誠度、可買產品、折扣和物流方式這樣的上下文中。而在上下單時,“顧客”的上下文包括名字、產品寄送地址、訂單總價和一些付款術語

​ 我在oneday-auth中設計了一個類LoginUserE,用來代表登入使用者實體類,包含的資訊僅僅跟認證和授權相關,而使用者資訊子域中,肯定也有一個使用者類UserInfo,但是這裡的代表的含義是跟業務系統相關資訊,比如說性別,暱稱。我相信大多數讀者肯定經歷過一個類中承擔過多功能,試圖去建立一個全功能的類,最終導致的結果各位也可想而知,貪一時之方便帶來的是不斷拆東牆補西牆。

​ 使用者進入認證界限上下文,他在這裡只會被認為 一個待認證,而且只具備認證相關的資訊,使用者進入授權界限上下文,他在這裡只會被認為一個認證成功,等待授權或者具備許可權的使用者。認證上下文和授權上下文我們可以

​ 於是在程式碼裡,我劃分了兩個包模組:

> one.day.auth:
    >> authentication :認證即使用者登入,身份識別等功能
    >> authorization :授權上下文:給予使用者身份,角色,許可權,並判斷使用者是否具備訪問某個功能的許可權等功能

​ 看到這裡,請讀者自己思考一個問題,如果按照原來的做法,你會不會分出兩個包,你的大致做法是不是如下

> one.day.auth.service
    >> authenticationServiceImpl
    >> authorizationServiceImpl

​ 如果你看到這裡突然有了一種思維的自我鬥爭,甚至有一種恍然大悟的感覺,那麼恭喜你,你已經開始培養了DDD的思維。

​ 小結:程式碼目錄的不同,就從一開始決定了你的開發思維。傳統的MVC分層註定無法真正有效的劃分領域,從而實現物件導向開發

程式碼實踐

程式碼分層

為什麼我們需要領域驅動設計提到了兩個架構,四層架構和六邊形架構(又稱埠-介面卡)。其中六邊形架構是從四層架構進一步發來而來的,是邏輯意義上的,程式碼的物理分層是做不到所謂六邊形的。我們暫時拋開這一切,只關注我們想要的目的。

領域物件要做到只關心業務邏輯,不能出現絲毫技術細節,即不直接依賴任何外部,通過介面去依賴

​ 應用層:非業務相關處理;領域層:業務相關處理;基礎設施:持久化,快取等技術細節實現。程式碼目錄分層如下:

> one.day.auth.authentication
> > app 應用層
> > client 二方包,這裡方便起見放在了同一個Maven專案中
> > domain 領域層
> > > entity 實體包,具備行為,不具備資料狀態
> > > port 埠定義,外部依賴統一定義為埠
> > > service 領域服務
> > infrastructure 基礎設施層
> > > adapt 介面卡,實現領域層定義的埠介面
> > > converter DTO,DO,Entity互相轉換的工具類
> > > dataobject 表對映包 不具備行為,具備資料狀態
> > > repository 倉儲
> > > tunnel 通道

功能實現

​ 我們來看看登入這一個功能具體是如何實現的。

@Component
public class AuthenticationApp {
   /**
     * 領域層,登入領域服務
     */
    @Autowired
    private LoginService loginService;
    /**
     * 登入
     *
     * @param loginCmd
     */
    public void login(LoginCmd loginCmd) {
        //呼叫領域層進行登入校驗
        String userId = loginService.login(loginCmd);
        //session中存放userId已證明登入
        //由於領域層主要負責登入,或者校驗密碼,登入成功之後的登入態設定不關心,交由應用層負責
        ProjectUtil.setSession("userId", userId);
    }
    public void addLoginUser(AddLoginUserCmd addLoginUserCmd) {
        loginService.addLoginUser(addLoginUserCmd);
    }
}

​ 我們可以看到,應用層AuthenticationApp先呼叫了領域層的領域服務LoginService,當該方法沒有丟擲異常則證明使用者校驗成功,但是注意的是LoginService核心作用的是校驗,登入不登入,即登入態的設定並不是他所關心的,並不是他的業務邏輯。領域層只保證使用者和密碼是正確的,而其他一切東西都是外圍,應用層,甚至是上游服務得知校驗成功之後再來設定登入態。

六邊形架構

​ 我們接著看看領域層,領域服務是如何工作的。

​ 我們先介紹兩個類,LoginUserRepositoryPortLoginUserConverter。讀者可能會有一個疑惑是,怎麼可能會沒有技術細節呢,我怎樣都需要將資料儲存到資料庫中,這肯定就涉及到持久化技術,這個時候六邊形架構就應運而生了。我們的口號是“領域層不摻雜任何技術細節”,任何的外部依賴,我們都定義成一個埠類,而具體的實現交由各個層的介面卡去實現,通過依賴注入實現相應的依賴功能。如何檢驗這一點,就是要看你的領域層能不能做到拷貝不走樣,即如果你單純複製domain目錄到其他的專案中,是否能夠正常編譯。

LoginUserConverter存在的意義是什麼,DTO,Entity,DataObject之間總會互相轉換,將這一部分程式碼統一放到Converter類中。我相信讀者的不少專案,各種轉換都是很隨意的,開心就好:)

@Service
public class LoginServiceImpl implements LoginService {
    private final LoginUserRepositoryPort loginUserRepositoryPort;
    private final LoginUserConverter loginUserConverter;
    @Autowired
    public LoginServiceImpl(LoginUserRepositoryPort loginUserRepositoryPort, LoginUserConverter loginUserConverter) {
        this.loginUserRepositoryPort = loginUserRepositoryPort;
        this.loginUserConverter = loginUserConverter;
    }
    @Override
    public String login(LoginCmd loginCmd) {
        Optional<LoginUserE> optionalLoginUserE = loginUserRepositoryPort.findByUsername(loginCmd.getUsername());
        optionalLoginUserE.orElseThrow(() -> new BaseException(GlobalEnum.NON_EXIST));
        LoginUserE loginUserE = optionalLoginUserE.get();
        loginUserE.login(loginCmd.getPassword());
        //todo 登入成功,非同步通知觀察者
        return loginUserE.getUserId();
    }
    @Override
    public void addLoginUser(AddLoginUserCmd addLoginUserCmd) {
        LoginUserE loginUserE = loginUserConverter.convert2Entity(addLoginUserCmd);
        loginUserE.prepareToAdd();
        loginUserRepositoryPort.add(loginUserE);
    }
}

​ 領域服務LoginServiceImpl的第一件事是通過依賴注入獲取的LoginUserRepositoryPort去查詢獲取登入使用者LoginUserE,如果存在則呼叫login方法。我們看看LoginUserE究竟是什麼玩意。

@Data
public class LoginUserE extends Unique {
    public static final String COMMON_SALT = "commonSalt";
    /**
     * 登入使用者名稱
     */
    private String username;
    /**
     * 登入密碼
     */
    private String password;
    /**
     * 鹽
     */
    private String salt;
    /**
     * 加密演算法
     */
    private EncryptionAlgorithmV encryptionAlgorithmV;
    /**
     * 業務唯一ID
     */
    private String userId;
    private TenantIdV tenantIdV;
    /**
     * 比較密碼
     *
     * @param sendPwd 傳入的密碼
     * @return true/false
     */
    public boolean login(String sendPwd) {
        //檢查available
        //錯誤次數限制
        //鎖號 ip
        return StringUtils.equals(password, encryptionAlgorithmV.getPasswordEncoder().encoder(sendPwd, salt));
    }
    /**
     * 密碼加密
     */
    public void encryptPassword() {
        this.setSalt(RandomStringUtils.randomNumeric(8));
        this.setPassword(encryptionAlgorithmV.getPasswordEncoder().encoder(password, salt));
    }
}

​ 程式碼邏輯其實很簡單,留著幾個擴充套件功能沒有實現,一個是針對登入失敗的各種場景操作,第二個是,對不同的租戶下的使用者系統實現不同的加密器。功能從上帝Service類轉移到具備真正意義的實體類上,具備真正的行為,符合類的單一職責標準。

​ 到這裡登入功能講解就算是結束,但其中我留有一個功能未開發,即登入成功,非同步通知觀察者,DDD中同時倡導事件驅動開發和最終一致性。這其實也是跟類的單一職責原則有關。在整個登入功能中,校驗是第一步,校驗成功緊接著是進行授權,兩者是上下游關係,核心業務邏輯不應該寫在一塊,這在傳統MVC專案中兩者是絕對的耦合在一起。而採用事件驅動可以將兩者分離,無論是非同步或者同步,簡單起見的話可以直接使用guava的EventBus。

​ 持久化層的設計和特點本篇暫不涉及,不可一步而就,事實上如果你還關心這一點的話則證明你還未能理解DDD。重點是業務邏輯,無技術細節。持久化只是一種儲存技術,不要因為用了這一個技術反而被綁架了你的思路。

總結

​ 業務層執行非業務邏輯,領域層只執行業務邏輯,使用埠-介面卡模式隔離外部依賴,檢驗的標準是拷貝不走樣。第一步的界限上下文劃分很關鍵。一開始的劃分就決定了你是物件導向還是程式導向。不要被持久化技術綁架了我們的開發思路。我們的口號是“領域層不摻雜任何技術細節”,我們的目標是真正的物件導向開發,我們的理想是永不加班!!!

​ 原始碼地址:https://github.com/iamlufy/oneday-auth

​ 作者:plz叫我紅領巾   

​ 出處:https://juejin.im/post/5cd3d1a8f265da034c7042c6

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

相關文章