做一個租戶系統下的許可權服務,接管使用者的認證和授權,我們取名該服務為
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。重點是業務邏輯,無技術細節。持久化只是一種儲存技術,不要因為用了這一個技術反而被綁架了你的思路。
總結
業務層執行非業務邏輯,領域層只執行業務邏輯,使用埠-介面卡模式隔離外部依賴,檢驗的標準是拷貝不走樣。第一步的界限上下文劃分很關鍵。一開始的劃分就決定了你是物件導向還是程式導向。不要被持久化技術綁架了我們的開發思路。我們的口號是“領域層不摻雜任何技術細節”,我們的目標是真正的物件導向開發,我們的理想是永不加班!!!
原始碼地址:https://github.com/iamlufy/oneday-auth
作者:plz叫我紅領巾
出處:https://juejin.im/post/5cd3d1a8f265da034c7042c6
本部落格歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。