為什么我們要了解ddd?
作為一個開發者,我們肯定接手過其他的人的項目。我想你一定有個這樣的經歷:
面對冗雜的系統,模塊彼此關聯,沒有人能描述清楚每個細節,沒有文檔,即使有文檔也和系統對不上。當新需求需要修改一個功能時,往往光回顧該功能涉及的流程就需要很長時間,更別提修改帶來的不可預知的影響面。于是 RD 就加開關,小心翼翼地切流量上線,一有問題趕緊關閉開關。
面對此般場景,你要么跑路,要么重構。重構是克服演進式設計中大雜燴問題的主力,通過在單獨的類及方法級別上做一系列小步重構來完成,我們可以很容易重構出一個獨立的類來放某些通用的邏輯,但是,你會發現你很難給它一個業務上的含義,只能給予一個技術維度描繪的含義。你正在一邊重構一邊給后人挖坑。
作為一個架構師,在軟件開發中如何降低系統復雜度是一個永恒的挑戰,雖然通過一系列的設計模式或范例來降低一些常見的復雜度。但是問題在于,這些理念是通過技術手段解決技術問題,但并沒有從根本上解決業務的問題。
如果你也有這方面的苦惱,那么ddd 的思想也許能為你帶來啟發。
DDD 和傳統數據驅動的區別
DDD 全程是 Domain-Driven Design,中文叫領域驅動設計,是一套應對復雜軟件系統分析和設計的面向對象建模方法論。
什么是數據驅動
傳統的數據驅動開發模式,View、Service、dao這種三層分層模式,開發者會很自然的寫出過程式代碼,這種開發方式中的對象只是數據載體,而沒有行為,是一種貧血對象模型。以數據為中心,以數據庫ER圖為設計驅動,分層架構在這種開發模式下可以認為是數據處理和實現的過程。
什么是領域驅動
以前的系統分析和設計是分開的,導致需求和成品非常容易出現偏差,兩者相對獨立,還會導致溝通困難,DDD 則打破了這種隔閡,提出了領域模型概念,統一了分析和設計編程,使得軟件能夠更靈活快速跟隨需求變化。
DDD 的宏觀理念其實并不難懂,但是如同 REST 一樣,DDD 也只是一個設計思想,缺少一套完整的規范,導致DDD新手落地困難。
由于 DDD 不是一套框架,而是一種架構思想,所以在代碼層面缺乏了足夠的約束,導致 DDD 在實際應用中上手門檻很高,甚至可以說絕大部分人都對 DDD 的理解有所偏差。舉個例子(貧血域模型)在實際應用當中層出不窮,而一些仍然火熱的 ORM 工具比如 Hibernate,Entity Framework 實際上助長了貧血模型的擴散。同樣的,傳統的基于數據庫技術以及 MVC 的四層應用架構(UI、Business、Data Access、Database),在一定程度上和 DDD 的一些概念混淆,導致絕大部分人在實際應用當中僅僅用到了 DDD 的建模的思想,而其對于整個架構體系的思想無法落地。
從單機的時代,服務化的架構還局限于單機 +LB 用 MVC 提供 Rest 接口供外部調用,到今天,在一個所有的東西都能被稱之為“服務”的時代(XAAS),人們在踩過諸多拆分服務的坑(拆分過細導致服務爆炸、拆分不合理導致頻分重構等)之后,開始死鎖原因了。DDD 的思想讓我們能冷靜下來,去思考到底哪些東西可以被服務化拆分,哪些邏輯需要聚合,才能帶來最小的維護成本,而不是簡單的去追求開發效率。
有了 DDD 的指導,加之微服務的事件,才是完美的架構。
DDD 與微服務的關系
系統的復雜度越來越來高是必然趨勢,原因可能來自自身業務的演進,也有可能是技術的創新,然而一個人和團隊對復雜性的認知是有極限的,就像一個服務器的性能極限一樣,解決的辦法只有分而治之,將大問題拆解為小問題,最終突破這種極限。微服務在這方面都給出來了理論指導和最佳實踐,諸如注冊中心、熔斷、限流等解決方案,但微服務并沒有對“應對復雜業務場景”這個問題給出合理的解決方案,這是因為微服務的側重點是治理,而不是分。
我們都知道,架構一個系統的時候,應該從以下幾方面考慮:
- 功能維度
- 質量維度(包括性能和可用性)
- 工程維度
微服務在第二個做得很好,但第一個維度和第三個維度做的不夠。這就給 DDD 了一個“可乘之機”,DDD 給出了微服務在功能劃分上沒有給出的很好指導這個缺陷。所以說它們在面對復雜問題和構建系統時是一種互補的關系。
DDD 與微服務如何協作
知道了 DDD 與微服務還不夠,我們還需要知道他們是怎么協作的。
一個系統(或者一個公司)的業務范圍和在這個范圍里進行的活動,被稱之為領域,領域是現實生活中面對的問題域,和軟件系統無關,領域可以劃分為子域,比如電商領域可以劃分為商品子域、訂單子域、發票子域、庫存子域 等,在不同子域里,不同概念會有不同的含義,所以我們在建模的時候必須要有一個明確的邊界,這個邊界在 DDD 中被稱之為限界上下文,它是系統架構內部的一個邊界,《整潔之道》這本書里提到:
系統架構是由系統內部的架構邊界,以及邊界之間的依賴關系所定義的,與系統中組件之間的調用方式無關。
所謂的服務本身只是一種比函數調用方式成本稍高的,分割應用程序行為的一種形式,與系統架構無關。
所以復雜系統劃分的第一要素就是劃分系統內部架構邊界,也就是劃分上下文,以及明確之間的關系,這對應之前說的第一維度(功能維度),這就是 DDD 的用武之處。其次,我們才考慮基于非功能的維度如何劃分,這才是微服務發揮優勢的地方。
假如我們把服務劃分成 ABC 三個上下文:
我們可以在一個進程內部署單體應用,也可以通過遠程調用來完成功能調用,這就是目前的微服務方式,更多的時候我們是兩種方式的混合,比如 A 和 B 在一個部署單元內,C 單獨部署,這是因為 C 非常重要,或并發量比較大,或需求變更比較頻繁,這時候 C 獨立部署有幾個好處:
- C 獨立部署資源:資源更合理的傾斜,獨立擴容縮容。
- 彈力服務:重試、熔斷、降級等,已達到故障隔離。
- 技術棧獨立:C 可以使用其他語言編寫,更合適個性化團隊技術棧。
- 團隊獨立:可以由不同團隊負責。
架構是可以演進的,所以拆分需要考慮架構的階段,早期更注重業務邏輯邊界,后期需要考慮更多方面,比如數據量、復雜性等,但即使有這個方針,也常會見仁見智,沒有人能一下子將邊界定義正確,其實這里根本就沒有明確的對錯。
即使邊界定義的不太合適,通過聚合根可以保障我們能夠演進出更合適的上下文,在上下文內部通過實體和值對象來對領域概念進行建模,一組實體和值對象歸屬于一個聚合根。
按照 DDD 的約束要求:
- 第一,聚合根來保證內部實體規則的正確性和數據一致性;
- 第二,外部對象只能通過 id 來引用聚合根,不能引用聚合根內部的實體;
- 第三,聚合根之間不能共享一個數據庫事務,他們之間的數據一致性需要通過最終一致性來保證。
有了聚合根,再基于這些約束,未來可以根據需要,把聚合根升級為上下文,甚至拆分成微服務,都是比較容易的。
其實DDD的核心訴求就是將業務架構映射到系統架構上,在響應業務變化調整業務架構時,也隨之變化系統架構。而微服務追求業務層面的復用,設計出來的系統架構和業務一致;在技術架構上則系統模塊之間充分解耦,可以自由地選擇合適的技術架構,去中心化地治理技術和數據。
可以參見下圖來更好地理解雙方之間的協作關系:
DDD 的相關術語與基本概念
我們來認識一下 DDD 的一些概念吧,每個概念找了一個 Spring 模式開發的映射概念,方便理解,但要僅僅作為理解用,不要過于依賴。另外,這里可能需要結合后面的代碼反復結合理解,才能融匯貫通到實際工作中。
領域
映射概念:切分的服務。
領域就是范圍。范圍的重點是邊界。領域的核心思想是將問題逐級細分來減低業務和系統的復雜度,這也是 DDD 討論的核心。
子域
映射概念:子服務。
領域可以進一步劃分成子領域,即子域。這是處理高度復雜領域的設計思想,它試圖分離技術實現的復雜性。這個拆分的里面在很多架構里都有,比如 C4。
核心域
映射概念:核心服務。
在領域劃分過程中,會不斷劃分子域,子域按重要程度會被劃分成三類:核心域、通用域、支撐域。
通用域
映射概念:中間件服務或第三方服務。
支撐域
映射概念:企業公共服務。
統一語言
映射概念:統一概念。
定義上下文的含義。它的價值是可以解決交流障礙,不管你是 RD、PM、QA 等什么角色,讓每個團隊使用統一的語言(概念)來交流,甚至可讀性更好的代碼。
通用語言包含屬于和用例場景,并且能直接反應在代碼中。
可以在事件風暴(開會)中來統一語言,甚至是中英文的映射、業務與代碼模型的映射等。可以使用一個表格來記錄。
限界上下文
映射概念:服務職責劃分的邊界。
定義上下文的邊界。領域模型存在邊界之內。對于同一個概念,不同上下文會有不同的理解,比如商品,在銷售階段叫商品,在運輸階段就叫貨品。
理論上,限界上下文的邊界就是微服務的邊界,因此,理解限界上下文在設計中非常重要。
聚合
映射概念:包。
聚合概念類似于你理解的包的概念,每個包里包含一類實體或者行為,它有助于分散系統復雜性,也是一種高層次的抽象,可以簡化對領域模型的理解。
拆分的實體不能都放在一個服務里,這就涉及到了拆分,那么有拆分就有聚合。聚合是為了保證領域內對象之間的一致性問題。
在定義聚合的時候,應該遵守不變形約束法則:
聚合邊界內必須具有哪些信息,如果沒有這些信息就不能稱為一個有效的聚合;
聚合內的某些對象的狀態必須滿足某個業務規則:
一個聚合只有一個聚合根,聚合根是可以獨立存在的,聚合中其他實體或值對象依賴與聚合根。
只有聚合根才能被外部訪問到,聚合根維護聚合的內部一致性。
聚合根
映射概念:包。
一個上下文內可能包含多個聚合,每個聚合都有一個根實體,叫做聚合根,一個聚合只有一個聚合根。
實體
映射概念:Domain 或 entity。
《領域驅動設計模式、原理與實踐》一書中講到,實體是具有身份和連貫性的領域概念,可以看出,實體其實也是一種特殊的領域,這里我們需要注意兩點:唯一標示(身份)、連續性。兩者缺一不可。
你可以想象,文章可以是實體,作者也可以是,因為它們有 id 作為唯一標示。
值對象
映射概念:Domain 或 entity。
為了更好地展示領域模型之間的關系,制定的一個對象,本質上也是一種實體,但相對實體而言,它沒有狀態和身份標識,它存在的目的就是為了表示一個值,通常使用值對象來傳達數量的形式來表示。
比如 money,讓它具有 id 顯然是不合理的,你也不可能通過 id 查詢一個 money。
定義值對象要依照具體場景的區分來看,你甚至可以把 Article 中的 Author 當成一個值對象,但一定要清楚,Author 獨立存在的時候是實體,或者要拿 Author 做復雜的業務邏輯,那么 Author 也會升級為聚合根。
四種領域模型
失血模型、貧血模型、充血模型、脹血模型
四種模型示例
- 失血模型
Domain Object 只有屬性的 getter/setter 方法的純數據類,所有的業務邏輯完全由 business object 來完成。
- public class Article implements Serializable {
- private Integer id;
- private String title;
- private Integer classId;
- private Integer authorId;
- private String authorName;
- private String content;
- private Date pubDate;
- //getter/setter/toString
- }
- public interface ArticleDao {
- public Article getArticleById(Integer id);
- public Article findAll();
- public void updateArticle(Article article);
- }
- 貧血模型
簡單來說,就是 Domain Object 包含了不依賴于持久化的領域邏輯,而那些依賴持久化的領域邏輯被分離到 Service 層。
- public class Article implements Serializable {
- private Integer id;
- private String title;
- private Integer classId;
- private Integer authorId;
- private String authorName;
- private String content;
- private Date pubDate;
- //getter/setter/toString
- //判斷是否是熱門分類(假設等于57或102的類別的文章就是熱門分類的文章)
- public boolean isHotClass(Article article){
- return Stream.of(57,102)
- .anyMatch(classId -> classId.equals(article.getClassId()));
- }
- //更新分類,但未持久化,這里不能依賴Dao去操作實體化
- public Article changeClass(Article article, ArticleClass ac){
- return article.setClassId(ac.getId());
- }
- }
- @Repository("articleDao")
- public class ArticleDaoImpl implements ArticleDao{
- @Resource
- private ArticleDao articleDao;
- public void changeClass(Article article, ArticleClass ac){
- article.changeClass(article, ac);
- articleDao.update(article)
- }
- }
注意這個模式不在 Domain 層里依賴 DAO。持久化的工作還需要在 DAO 或者 Service 中進行。
這樣做的優缺點
優點:各層單向依賴,結構清晰。
缺點:
Domain Object 的部分比較緊密依賴的持久化 Domain Logic 被分離到 Service 層,顯得不夠 OO
Service 層過于厚重
- 充血模型
充血模型和第二種模型差不多,區別在于業務邏輯劃分,將絕大多數業務邏輯放到 Domain 中,Service 是很薄的一層,封裝少量業務邏輯,并且不和 DAO 打交道:
Service (事務封裝) —> Domain Object <—> DAO
- public class Article implements Serializable {
- @Resource
- private static ArticleDao articleDao;
- private Integer id;
- private String title;
- private Integer classId;
- private Integer authorId;
- private String authorName;
- private String content;
- private Date pubDate;
- //getter/setter/toString
- //使用articleDao進行持久化交互
- public List<Article> findAll(){
- return articleDao.findAll();
- }
- //判斷是否是熱門分類(假設等于57或102的類別的文章就是熱門分類的文章)
- public boolean isHotClass(Article article){
- return Stream.of(57,102)
- .anyMatch(classId -> classId.equals(article.getClassId()));
- }
- //更新分類,但未持久化,這里不能依賴Dao去操作實體化
- public Article changeClass(Article article, ArticleClass ac){
- return article.setClassId(ac.getId());
- }
- }
所有業務邏輯都在 Domain 中,事務管理也在 Item 中實現。這樣做的優缺點如下。
優點:
更加符合 OO 的原則;
Service 層很薄,只充當 Facade 的角色,不和 DAO 打交道。
缺點:
DAO 和 Domain Object 形成了雙向依賴,復雜的雙向依賴會導致很多潛在的問題。
如何劃分 Service 層邏輯和 Domain 層邏輯是非常含混的,在實際項目中,由于設計和開發人員的水平差異,可能 導致整個結構的混亂無序。
- 脹血模型
基于充血模型的第三個缺點,有同學提出,干脆取消 Service 層,只剩下 Domain Object 和 DAO 兩層,在 Domain Object 的 Domain Logic 上面封裝事務。
Domain Object (事務封裝,業務邏輯) <—> DAO
似乎 Ruby on rails 就是這種模型,它甚至把 Domain Object 和 DAO 都合并了。
這樣做的優缺點:
簡化了分層
也算符合 OO
該模型缺點:
很多不是 Domain Logic 的 Service 邏輯也被強行放入 Domain Object ,引起了 Domain Object 模型的不穩定;
Domain Object 暴露給 Web 層過多的信息,可能引起意想不到的副作用。
DDD落地的一些思考
最近把一個新項目使用了DDD思想進行落地,項目代碼結構如下:
整體感覺還是不錯的,不用文檔,基本也能看清楚業務的脈絡。使用Domain Primitive(DP)和CQRS 設計接口,接口語義更加清楚,接口參數的清晰度,業務代碼邏輯的清晰度顯著提高,而且方便后期讀寫分離,使用Event Sourcing模式,領域對象的狀態完全是由事件驅動的,可以最大限度的實現系統的松耦合。
唯一的缺點就是代碼量的膨脹,但是這也是不可避免的。對于大團隊來講利大于弊,減少溝通成本,小團隊按需嘗試。
原文鏈接:https://mp.weixin.qq.com/s/Je8YsJuismegaUJW9blXpA