滕雲:DDD 實現之路

編者按:這篇文章最早撰寫於 2014 年,作者也是《實現領域驅動設計》的譯者。幾年過去了,DDD 在坊間依然方興未艾,然而它的複雜性所引發的誤解也層出不窮。對於一些基本概念的澄清甚至溯源,會幫助我們回到起點,對它展開新的認識。

我想,多數有經驗的程序開發者都應該聽說過 DDD,並且嘗試過將其應用在自己的項目中。不知你是否遇到過這樣的場景:你創建了一個資源庫(Repository),但一段時間之後發現這個資源庫和傳統的 DAO 越來越像了,你開始反思自己的實現方式是正確的嗎?或者,你創建了一個聚合,然後發現這個聚合是如此的龐大,它爲什麼引用瞭如此多的對象,難道又是我做錯了嗎?

其實你並不孤單,我相信多數同仁都曾遇到過相似的問題。前不久,我一個同事給我展示了他在 2007 年買的那本已經被他韋編三絕過的《領域驅動設計》,他告訴我,讀過好幾遍後,他依然不知道如何將 DDD 付諸實踐。Eric 那本書固然是好,無可否認,但是我們程序員總希望看到一些實際的例子能夠切實將 DDD 落地以指導我們的日常開發。

於是,在 Eric 的書出版將近 10 年之後,我們有了 Vaughn Vernon 的《實現領域驅動設計》,作爲該書的譯者,我有幸通讀了本書,受益匪淺,得到的結論是:好的軟件就應該是 DDD 的。

就像在微電子領域有知識產權核(Intellectual Property)一樣,DDD 將一個軟件系統的核心業務功能集中在一個核心域裏面,其中包含了實體、值對象、領域服務、資源庫和聚合等概念。在此基礎上,DDD 提出了一套完整的支撐這樣的核心領域的基礎設施。此時,DDD 已經不再是 “面向對象進階” 那麼簡單了,而是演變成了一個系統工程。

所謂領域,即是一個組織的業務開展方式,業務價值便體現在其中。長久以來,我們程序員都是很好的技術型思考者,我們總是擅長從技術的角度來解決項目問題。但是,一個軟件系統是否真正可用是通過它所提供的業務價值體現出來的。因此,與其每天鑽在那些永遠也學不完的技術中,何不將我們的關注點向軟件系統所提供的業務價值方向思考思考,這也正是 DDD 所試圖解決的問題。

在 DDD 中,代碼就是設計本身,你不再需要那些繁文縟節的並且永遠也無法得到實時更新的設計文檔。編碼者與領域專家再也不需要翻譯才能理解對方所表達的意思。DDD 有戰略設計和戰術設計之分。戰略設計主要從高層 “俯視” 我們的軟件系統,幫助我們精準地劃分領域以及處理各個領域之間的關係;而戰術設計則從技術實現的層面教會我們如何具體地實施 DDD。

DDD 之戰略設計

需要指出的是,DDD 絕非一套單純的技術工具集,但是我所看到的很多程序員卻的確是這麼認爲的,並且也是懷揣着這樣的想法來使用 DDD 的。過於拘泥於技術上的實現將導致 DDD-Lite。簡單來講,DDD-Lite 將導致劣質的領域對象,因爲我們忽略了 DDD 戰略建模所帶來的好處。DDD 的戰略設計主要包括領域 / 子域、通用語言、限界上下文和架構風格等概念。

領域和子域(Domain/Subdomain)

既然是領域驅動設計,那麼我們主要的關注點理所當然應該放在如何設計領域模型上,以及對領域模型的劃分。

領域並不是多麼高深的概念,比如,一個保險公司的領域中包含了保險單、理賠和再保險等概念;一個電商網站的領域包含了產品名錄、訂單、發票、庫存和物流的概念。這裏,我主要講講對領域的劃分,即將一個大的領域劃分成若干個子域。

限界上下文(Bounded Context)

在一個領域 / 子域中,我們會創建一個概念上的領域邊界,在這個邊界中,任何領域對象都只表示特定於該邊界內部的確切含義。這樣邊界便稱爲限界上下文。限界上下文和領域具有一對一的關係。

從物理上講,一個限界上下文最終可以是一個 DLL(.NET) 文件或者 JAR(Java) 文件,甚至可以是一個命名空間(比如 Java 的 package)中的所有對象。但是,技術本身並不應該用來界分限界上下文。將一個限界上下文中的所有概念,包括名詞、動詞和形容詞全部集中在一起,我們便爲該限界上下文創建了一套通用語言。通用語言是一個團隊所有成員交流時所使用的語言,業務分析人員、編碼人員和測試人員都應該直接通過通用語言進行交流。

對於上文中提到的各個子域之間的集成問題,其實也是限界上下文之間的集成問題。在集成時,我們主要關心的是領域模型和集成手段之間的關係。比如需要與一個 REST 資源集成,你需要提供基礎設施(比如 Spring 中的 RestTemplate),但是這些設施並不是你核心領域模型的一部分,你應該怎麼辦呢?答案是防腐層,該層負責與外部服務提供方打交道,還負責將外部概念翻譯成自己的核心領域能夠理解的概念。當然,防腐層只是限界上下文之間衆多集成方式的一種,另外還有共享內核、開放主機服務等,具體細節請參考《實現領域驅動設計》原書。限界上下文之間的集成關係也可以理解爲是領域概念在不同上下文之間的映射關係,因此,限界上下文之間的集成也稱爲上下文映射圖。

架構風格(Architecture)

DDD 並不要求採用特定的架構風格,因爲它是對架構中立的。你可以採用傳統的三層式架構,也可以採用 REST 架構和事件驅動架構等。但是在《實現領域驅動設計》中,作者比較推崇事件驅動架構和六邊形(Hexagonal)架構。

當下,面向接口編程和依賴注入原則已經在顛覆着傳統的分層架構,如果再進一步,我們便得到了六邊形架構,也稱爲端口和適配器(Ports and Adapters)。在六邊形架構中,已經不存在分層的概念,所有組件都是平等的。這主要得益於軟件抽象的好處,即各個組件的之間的交互完全通過接口完成,而不是具體的實現細節。正如 Robert C. Martin 所說:

抽象不應該依賴於細節,細節應該依賴於抽象。

採用六邊形架構的系統中存在着很多端口和適配器的組合。端口表示的是一個軟件系統的輸入和輸出,而適配器則是對每一個端口的訪問方式。比如,在一個 Web 應用程序中,HTTP 協議可以作爲一個端口,它向用戶提供 HTML 頁面並且接受用戶的表單提交;而 Servlet(對於 Java 而言)或者 Spring 中的 Controller 則是相對應於 HTTP 協議的適配器。再比如,要對數據進行持久化,此時的數據庫系統則可看成是一個端口,而訪問數據庫的 Driver 則是相應於數據庫的適配器。如果要爲系統增加新的訪問方式,你只需要爲該訪問方式添加一個相應的端口和適配器即可。

那麼,我們的領域模型又如何與端口和適配器進行交互呢?

上文已經提到,軟件系統的真正價值在於提供業務功能,我們會將所有的業務功能分解爲若干個業務用例,每一次業務用例都表示對軟件系統的一次原子操作。所以首先,軟件系統中應該存在這樣的組件,他們的作用即以業務用例爲單位向外界暴露該系統的業務功能。在 DDD 中,這樣的組件稱爲應用層(Application Layer)。

在有了應用層之後,軟件系統和外界的交互便變成了適配器和應用層之間的交互,如上圖所示。

從圖中可以看出,領域模型位於應用程序的核心部分,外界與領域模型的交互都通過應用層完成,應用層是領域模型的直接客戶。然而,應用層中不應該包含有業務邏輯,否則就造成了領域邏輯的泄漏,而應該是很薄的一層,主要起到協調的作用,它所做的只是將業務操作代理給我們的領域模型。同時,如果我們的業務操作有事務需求,那麼對於事務的管理應該放在應用層上,因爲事務也是以業務用例爲單位的。

應用層雖然很薄,但卻非常重要,因爲軟件系統的領域邏輯都是通過它暴露出去的,此時的應用層扮演了系統門面(Facade)的角色。

DDD 之戰術設計

戰略設計爲我們提供一種高層視野來審視我們的軟件系統,而戰術設計則將戰略設計進行具體化和細節化,它主要關注的是技術層面的實施,也是對我們程序員來得最實在的地方。

行爲飽滿的領域對象

我們希望領域對象能夠準確地表達出業務意圖,但是多數時候,我們所看到的卻是充滿 getter 和 setter 的領域對象,此時的領域對象已經不是領域對象了,而是 Martin Fowler 所稱之爲的貧血對象。

放到 Java 世界中,多年以來,Java Bean 規範都引誘着程序員們以 “自然而然又合乎情理” 的方式創建着無數的貧血對象,而一些框架也規定對象必須提供 getter 和 setter 方法,比如 Hibernate 的早期版本。那麼,貧血對象到底有什麼壞處呢?來看一個例子:要修改一個客戶(Customer)的郵箱地址,在使用 setter 方法時爲:

1public class Customer {
2  private String email;
3
4  public void setEmail(String email) {
5    this.email = email;
6  }
7}
8
9

雖然以上代碼可以完成 “修改郵箱地址” 的功能,但是當你讀到這段代碼時,你能否推測出系統中就一定存在着一個 “修改郵箱地址” 的業務用例呢?

你可能會說,可以在另一個 Service 類裏面創建一個 changeCustomerEmail() 方法,再在該方法中調用 Customer 的 setEmailAddress() 方法,這樣業務意圖不就明瞭了嗎?問題在於,修改郵箱地址這樣的職責本來就應該放在 Customer 上,而不應該由 Service 和 Customer 共同完成。遵循諸如信息封裝這樣的基本面向對象原則是在實施 DDD 時最基本的素養。

要創建行爲飽滿的領域對象並不難,我們需要轉變一下思維,將領域對象當做是服務的提供方,而不是數據容器,多思考一個領域對象能夠提供哪些行爲,而不是數據。

近幾年又重新流行起來的函數式編程也能夠幫助我們編寫更加具有業務表達力的業務代碼,比如 C# 和 Java 8 都提供了 Lambda 功能,同時還包括多數動態語言(比如 Ruby 和 Groovy 等)。再進一步,我們完全可以通過領域特定語言(DSL)的方式實現領域模型。

筆者曾經設想過這麼一個軟件系統:它的核心功能完全由一套 DSL 暴露給外界,所有業務操作都通過這套 DSL 進行,這個領域的業務規則可以通過一套規則引擎進行配置,於是這套 DSL 可以像上文提到的知識產權核一樣拿到市面上進行銷售。此時,我們的核心域被嚴嚴實實地封裝在這套 DSL 之內,不容許外界的任何污染。

實體 vs 值對象(Entity vs Value Object)

在一個軟件系統中,實體表示那些具有生命週期並且會在其生命週期中發生改變的東西;而值對象則表示起描述性作用的並且可以相互替換的概念。同一個概念,在一個軟件系統中被建模成了實體,但是在另一個系統中則有可能是值對象。例如貨幣,在通常交易中,我們都將它建模成了一個值對象,因爲我們花了 20 元買了一本書,我們只是關心貨幣的數量而已,而不是關心具體使用了哪一張 20 元的鈔票,也即兩張 20 元的鈔票是可以互換的。但是,如果現在中國人民銀行需要建立一個系統來管理所有發行的貨幣,並且希望對每一張貨幣進行跟蹤,那麼此時的貨幣便變成了一個實體,並且具有唯一標識(Identity)。在這個系統中,即便兩張鈔票都是 20 元,他們依然表示兩個不同的實體。

具體到實現層面,值對象是沒有唯一標識的,他的 equals() 方法(比如在 Java 語言中)可以用它所包含的描述性屬性字段來實現。但是,對於實體而言,equals() 方法便只能通過唯一標識來實現了,因爲即便兩個實體所擁有的狀態是一樣的,他們依然是不同的實體,就像兩個人的名字都叫張三,但是他們卻是兩個不同的人的個體。

我們發現,多數領域概念都可以建模成值對象,而非實體。值對象就像軟件系統中的過客一樣,具有 “創建後不管” 的特徵,因此,我們不需要像關心實體那樣去關心諸如生命週期和持久化等問題。

聚合(Aggregate)

聚合可能是 DDD 中最難理解的概念 ,之所以稱之爲聚合,是因爲聚合中所包含的對象之間具有密不可分的聯繫,他們是內聚在一起的。比如一輛汽車(Car)包含了引擎(Engine)、車輪(Wheel)和油箱(Tank)等組件,缺一不可。一個聚合中可以包含多個實體和值對象,因此聚合也被稱爲根實體。聚合是持久化的基本單位,它和資源庫(請參考下文)具有一一對應的關係。

既然聚合可以容納其他領域對象,那麼聚合應該設計得多大呢?這也是設計聚合的難點之一。比如在一個博客(Blog)系統中,一個用戶(User)可以創建多個 Blog,而一個 Blog 又可以包含多篇博文(Post)。在建模時,我們通常的做法是在 User 對象中包含一個 Blog 的集合,然後在每個 Blog 中又包含了一個 Post 的集合。你真的需要這麼做嗎?如果你需要修改 User 的基本信息,在加載 User 時,所有的 Blog 和 Post 也需要加載,這將造成很大的性能損耗。誠然,我們可以通過延遲加載的方式解決問題,但是延遲加載只是技術上的實現方式而已。導致上述問題的深層原因其實在我們的設計上,我們發現,User 更多的是和認證授權相關的概念,而與 Blog 關係並不大,因此完全沒有必要在 User 中維護 Blog 的集合。在將 User 和 Blog 分離之後,Blog 也和 User 一樣成爲了一個聚合,它擁有自己的資源庫。問題又來了:既然 User 和 Blog 分離了,那麼如果需要在 Blog 中引用 User 又該怎麼辦呢?在一個聚合中直接引用另外一個聚合並不是 DDD 所鼓勵的,但是我們可以通過 ID 的方式引用另外的聚合,比如在 Blog 中可以維護一個 userId 的實例變量。

User 作爲 Blog 的創建者,可以成爲 Blog 的工廠。放到 DDD 中,創建 Blog 的功能也只能由 User 完成。

綜上,對於 “創建 Blog” 的用例,我們可以通過以下方法完成:

 1public class BlogApplicatioinService {
 2
 3  @Transactional
 4  public void createBlog(String blogName, String userId) {
 5    User user = userRepository.userById(userId);
 6    Blog blog = user.createBlog(blogName);
 7    blogRepository.save(blog);
 8  }
 9}
10

在上例中,業務用例通過 BlogApplicationService 應用服務完成,在用例方法 createBlog() 中,首先通過 User 的資源庫得到一個 User,然後調用 User 中的工廠方法 createBlog() 方法創建一個 Blog,最後通過 BlogRepository 對 Blog 進行持久化。整個過程構成了一次事務,因此 createBlog() 方法標記有 @Transactional 作爲事務邊界。

使用聚合的首要原則爲在一次事務中,最多隻能更改一個聚合的狀態。如果一次業務操作涉及到了對多個聚合狀態的更改,那麼應該採用發佈領域事件(參考下文)的方式通知相應的聚合。此時的數據一致性便從事務一致性變成了最終一致性(Eventual Consistency)。

領域服務(Domain Service)

你是否遇到過這樣的問題:想建模一個領域概念,把它放在實體上不合適,把它放在值對象上也不合適,然後你冥思苦想着自己的建模方式是不是出了問題。恭喜你,祝賀你,你的建模手法完全沒有問題,只是你還沒有接觸到領域服務(Domain Service)這個概念,因爲領域服務本來就是來處理這種場景的。比如,要對密碼進行加密,我們便可以創建一個 PasswordEncryptService 來專門負責此事。

值得一提的是,領域服務和上文中提到的應用服務是不同的,領域服務是領域模型的一部分,而應用服務不是。應用服務是領域服務的客戶,它將領域模型變成對外界可用的軟件系統。領域服務不能濫用,因爲如果我們將太多的領域邏輯放在領域服務上,實體和值對象上將變成貧血對象。

資源庫(Repository)

資源庫用於保存和獲取聚合對象,在這一點上,資源庫與 DAO 多少有些相似之處。但是,資源庫和 DAO 是存在顯著區別的。DAO 只是對數據庫的一層很薄的封裝,而資源庫則更加具有領域特徵。另外,所有的實體都可以有相應的 DAO,但並不是所有的實體都有資源庫,只有聚合纔有相應的資源庫。

資源庫分爲兩種,一種是基於集合的,一種是基於持久化的。顧名思義,基於集合的資源庫具有編程語言中集合的特徵。舉個例子,Java 中的 List,我們從一個 List 中取出一個元素,在對該元素進行修改之後,我們並不用顯式地將該元素重新保存到 List 裏面。因此,面向集合的資源庫並不存在 save() 方法。比如,對於上文中的 User,其資源庫可以設計爲:

1public interface CollectionOrientedUserRepository {
2  public void add(User user);
3  public User userById(String userId);
4  public List allUsers();     public void remove(User user); 
5}
6
7

對於面向持久化的資源庫來說,在對聚合進行修改之後,我們需要顯式地調用 sava() 方法將其更新到資源庫中。依然是 User,此時的資源庫如下:

1public interface PersistenceOrientedUserRepository {
2  public void save(User user); 
3  public User userById(String userId); 
4  public List<User> allUsers();    
5  public void remove(User user); 
6}
7

在以上兩種方式所實現的資源庫中,雖然只是將 add() 方法改成了 save() 方法,但是在使用的時候卻是不一樣的。在使用面向集合資源庫時,add() 方法只是用來將新的聚合加入資源庫;而在面向持久化的資源庫中,save() 方法不僅用於添加新的聚合,還用於顯式地更新既有聚合。

領域事件(Domain Event)

在 Eric 的《領域驅動設計》中並沒有提到領域事件,領域事件是最近幾年才加入 DDD 生態系統的。

在 DDD 中,領域事件便可以用於處理上述問題,此時最終一致性取代了事務一致性,通過領域事件的方式達到各個組件之間的數據一致性。

需要注意的是,既然是領域事件,他們便應該從領域模型中發佈。領域事件的最終接收者可以是本限界上下文中的組件,也可以是另一個限界上下文。

領域事件的額外好處在於它可以記錄發生在軟件系統中所有的重要修改,這樣可以很好地支持程序調試和商業智能化。另外,在 CQRS 架構的軟件系統中,領域事件還用於寫模型和讀模型之間的數據同步。再進一步發展,事件驅動架構可以演變成事件源(Event Sourcing),即對聚合的獲取並不是通過加載數據庫中的瞬時狀態,而是通過重放發生在聚合生命週期中的所有領域事件完成。

總結

DDD 存在戰略設計和戰術設計之分,過度地強調 DDD 的技術性將使我們錯過由戰略設計帶來的好處。因此,在實現 DDD 時,我們應該將戰略設計也放在一個重要的位置加以對待。戰略設計幫助我們從一個宏觀的角度觀察和審視軟件系統,其中的限界上下文和上下文映射圖幫助我們正確地界分各個子域(系統)。DDD 的戰術設計則更加側重於技術實現,它向我們提供了一整套技術工具集,包括實體、值對象、領域服務和資源庫等。雖然 DDD 的概念已經提出近 10 年了,但是在如何實現 DDD 上,我們依然有很長的路要走。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ODNdLoPRzrC6vCX-ggcxFA