天天看點

醍醐灌頂:領域驅動設計實作之路

強烈推薦

  原文位址:http://www.infoq.com/cn/articles/implementation-road-of-domain-driven-design

  作者:滕雲,《實作領域驅動設計》譯者

  我以前認為學習領域驅動設計,隻需要讀 Eric Evans 的《領域驅動設計——軟體核心複雜性應對之道》這本經典著作就好了,但是閱讀了這篇文章之後,看來我要讀下《實踐領域驅動設計》這本書了(已經購買)。

  很多時候理論學習是一方面,理論實踐是另一方面,我們不一定去模仿别人的實踐,但是我們可以參考别人所走過的路,學習别人所總結的經驗,這些都是寶貴的财富。為什麼醍醐灌頂?因為如果你實踐過領域驅動設計,你會發現與作者有一些共鳴之處,比如下文中的行為飽滿的領域對象、實體與值對象界定、倉儲與聚合根等,如果沒有實踐過領域驅動設計,了解作者的這些内容可能隻是表面的了解,不知其是以然,但是如果實踐過,你會體會的更加深刻,可能作者無意表達的一個觀點,會為你現在所一直困惑的地方帶來一絲珍貴的靈感,這也就是分享的真谛。

  以下是内容部分,為了友善閱讀,排版稍微做了些調整。

前言

  2004年,當Eric Evans的那本《領域驅動設計——軟體核心複雜性應對之道》(後文簡稱《領域驅動設計》)出版時,我還在念高中,接觸到領域驅動設計(DDD)已經是8年後的事情了。那時,我正打算在軟體開發之路上更進一步,經同僚介紹,我開始接觸DDD。

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

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

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

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

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

  在DDD中,代碼就是設計本身,你不再需要那些繁文缛節的并且永遠也無法得到實時更新的設計文檔。編碼者與領域專家再也不需要翻譯才能了解對方所表達的意思。

  DDD有戰略設計和戰術設計之分。戰略設計主要從高層“俯視”我們的軟體系統,幫助我們精準地劃分領域以及處理各個領域之間的關系;而戰術設計則從技術實作的層面教會我們如何具體地實施DDD。

DDD之戰略設計

  需要指出的是,DDD絕非一套單純的技術工具集,但是我所看到的很多程式員卻的确是這麼認為的,并且也是懷揣着這樣的想法來使用DDD的。過于拘泥于技術上的實作将導緻DDD-Lite。簡單來講,DDD-Lite将導緻劣質的領域對象,因為我們忽略了DDD戰略模組化所帶來的好處。

  DDD的戰略設計主要包括領域/子域、通用語言、限界上下文和架構風格等概念。

領域和子域(Domain/Subdomain)

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

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

  在日常開發中,我們通常會将一個大型的軟體系統拆分成若幹個子系統。這種劃分有可能是基于架構方面的考慮,也有可能是基于基礎設施的。但是在DDD中,我們對系統的劃分是基于領域的,也即是基于業務的。

  于是,問題也來了:首先,哪些概念應該模組化在哪些子系統裡面?我們可能會發現一個領域概念模組化在子系統A中是可以的,而模組化在子系統B中似乎也合乎情理。第二個問題是,各個子系統之間的應該如何內建?有人可能會說,這不簡單得就像用戶端調用服務端那麼簡單嗎?問題在于,兩個系統之間的內建涉及到基礎設施和不同領域概念在兩個系統之間的翻譯,稍不注意,這些概念就會對我們精心建立好的領域模型造成污染。

  如何解決?答案是:限界上下文和上下文映射圖。

限界上下文(Bounded Context)

  在一個領域/子域中,我們會建立一個概念上的領域邊界,在這個邊界中,任何領域對象都隻表示特定于該邊界内部的确切含義。這樣邊界便稱為限界上下文。限界上下文和領域具有一對一的關系。

  舉個例子,同樣是一本書,在出版階段和出售階段所表達的概念是不同的,出版階段我們主要關注的是出版日期,字數,出版社和印刷廠等概念,而在出售階段我們則主要關心價格,物流和發票等概念。我們應該怎麼辦呢,将所有這些概念放在單個Book對象中嗎?這不是DDD的做法,DDD有限界上下文将這兩個不同的概念區分開來。

  從實體上講,一個限界上下文最終可以是一個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)。

  在有了應用層之後,軟體系統和外界的互動便變成了擴充卡和應用層之間的互動,如圖-1所示。

              圖-1 六邊形架構

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

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

DDD之戰術設計

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

行為飽滿的領域對象

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

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

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

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

  你可能會說,可以在另一個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”的用例,我們可以通過以下方法完成:

1 public 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 }      

  在上例中,業務用例通過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,其資源庫可以設計為:

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

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

1   public 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   }      

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

領域事件(Domain Event)

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

  在傳統的軟體系統中,對資料一緻性的處理都是通過事務完成的,其中包括本地事務和全局事務。但是,DDD的一個重要原則便是一次事務隻能更新一個聚合執行個體。然而,的确存在需要修改多個聚合的業務用例,那麼此時我們應該怎麼辦呢?

  另外,在最近流行起來的微服務(Micro Service)的架構中,整個系統被分成了很多個輕量的程式子產品,他們之間的資料一緻性并不容易通過事務一緻性完成,此時我們又該怎麼辦呢?

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

  領域事件的命名遵循英語中的“名詞+動詞過去分詞”格式,即表示的是先前發生過的一件事情。比如,購買者送出商品訂單之後釋出OrderSubmitted事件,使用者更改郵箱位址之後釋出EmailAddressChanged事件。

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

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

總結

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

作者:田園裡的蟋蟀

微信公衆号:你好架構

出處:http://www.cnblogs.com/xishuai/

公衆号會不定時的分享有關架構的方方面面,包含并不局限于:Microservices(微服務)、Service Mesh(服務網格)、DDD/TDD、Spring Cloud、Dubbo、Service Fabric、Linkerd、Envoy、Istio、Conduit、Kubernetes、Docker、MacOS/Linux、Java、.NET Core/ASP.NET Core、Redis、RabbitMQ、MongoDB、GitLab、CI/CD(持續內建/持續部署)、DevOps等等。

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接。

分享到:

QQ空間

新浪微網誌

騰訊微網誌

微信

更多

繼續閱讀