文章出處

強烈推薦

  原文地址: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上,我們依然有很長的路要走。


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()