做一個租戶系統下的許可權服務,接管使用者的認證和授權,我們取名該服務為
oneday-auth-server
寫在前面
DDD(領域驅動設計)中涉及到幾個概念,實體,值物件,聚合,限定上下文。本篇只涉及實踐,概念講解將放在下一篇,同時上一篇為什麼我們需要領域驅動設計作為科普帖,大家可以在看完程式碼之後再回頭理解一下,同時對比一下現有專案,知其然更要知其所以然,你經常遇到了什麼問題,為什麼DDD能夠更好的解決軟體負責的問題。
需求描述
-
認證功能即登入功能,登入成功登入態的設定,登入失敗的處理方式例如IP鎖定,失敗超過次數鎖定等方式
-
授權功能即對認證通過的使用者,進行角色和許可權授予,同時開啟資源保護,未具備訪問該資源許可權的使用者將無法訪問。
本篇將詳細介紹如何在DDD的指導下實現第一點功能。
領域、子域和界限上下文
我們先明白的一點是領域這個詞語承載了太多的含義,既可以表示整個業務系統,也可以表示其中的某個核心域或者支撐子域。舉個不是很恰當的例子,假設我們原本想要在一個叫賬戶模組實現了這個功能,同時還有使用者資訊功能,這個時候,賬戶就是一個大的領域,一塊的大蛋糕,而oneday-auth
則是這塊大蛋糕的某一塊,使用者資訊又是另一塊,這被分出的一塊一塊蛋糕,我們稱之為由賬戶領域分成的子域,許可權子域和使用者資訊子域。子域下還可以再接著劃分出子域,沒有最小的子域,只有最合適的子域。
你會覺得這個微服務的拆分很像,是的,微服務的拆分是遵循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
的核心作用的是校驗,登入不登入,即登入態的設定並不是他所關心的,並不是他的業務邏輯。領域層只保證使用者和密碼是正確的,而其他一切東西都是外圍,應用層,甚至是上游服務得知校驗成功之後再來設定登入態。
我們接著看看領域層,領域服務是如何工作的。
我們先介紹兩個類,LoginUserRepositoryPort
和LoginUserConverter
。讀者可能會有一個疑惑是,怎麼可能會沒有技術細節呢,我怎樣都需要將資料儲存到資料庫中,這肯定就涉及到持久化技術,這個時候六邊形架構就應運而生了。我們的口號是“領域層不摻雜任何技術細節”,任何的外部依賴,我們都定義成一個埠類,而具體的實現交由各個層的介面卡去實現,通過依賴注入實現相應的依賴功能。如何檢驗這一點,就是要看你的領域層能不能做到拷貝不走樣,即如果你單純複製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。重點是業務邏輯,無技術細節。持久化只是一種儲存技術,不要因為用了這一個技術反而被綁架了你的思路。
總結
業務層執行非業務邏輯,領域層只執行業務邏輯,使用埠-介面卡模式隔離外部依賴,檢驗的標準是拷貝不走樣。第一步的界限上下文劃分很關鍵。一開始的劃分就決定了你是物件導向還是程式導向。不要被持久化技術綁架了我們的開發思路。我們的口號是“領域層不摻雜任何技術細節”,我們的目標是真正的物件導向開發,我們的理想是永不加班!!!'
走過路過不要錯誤,您的點贊是支援我寫作最好的動力
原始碼地址:github.com/iamlufy/one…
作者:plz叫我紅領巾
本部落格歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。