Re:從零開始的領域驅動設計

https://www.cnkirito.moe/Re-DDD/

前言

領域驅動的火爆程度不用我贅述,但是即便其如此得耳熟能詳,但大多數人對其的認識,還只是停留在知道它的縮寫是DDD,知道它是一種軟件思想,或者知道它和微服務有千絲萬縷的關係。Eric Evans對DDD的詮釋是那麼地惜字如金,而我所認識的領域驅動設計的專家又都是行業中的資深前輩,他們擅長於對軟件設計進行高屋建瓴的論述,如果沒有豐富的互聯網從業經驗,是不能從他們的分享中獲取太多的營養的,可以用曲高和寡來形容。1000個互聯網從業者,100個懂微服務,10個人懂領域驅動設計。

可能有很多和我一樣的讀者,在得知DDD如此火爆之後,嘗試去讀了開山之作《領域驅動設計——軟件核心複雜性應對之道》,翻看了幾張之後,晦澀的語句,不明所以的專業術語,加上翻譯導致的語句流暢性,可以說觀看體驗並不是很好,特別是對於開發經驗不是很多的讀者。我總結了一下,爲何這本書難以理解:

  1. 沒有閱讀軟件設計叢書的習慣,更多人偏向於閱讀偏應用層面的書籍,“talk is cheap,show me the code”往往更符合大多數人的習慣。
    2.沒有太多的開發經驗支撐。沒有踩過坑,就不會意識到設計的重要性,無法產生共情。
    3.年代有些久遠,這本書寫於2004年,書中很多軟件設計的反例,在當時是非常流行的,但是在現在已經基本絕跡了。大師之所以爲大師,是因爲其能跨越時代的限制,預見未來的問題,這也是爲什麼DDD在十幾年前就被提出,卻在微服務逐漸流行的現階段才被大家重視。

誠然如標題所示,本文是領域驅動設計的一個入門文章,或者更多的是一個個人理解的筆記,筆者也正在學習DDD的路上,可能會有很多的疏漏。如有理解有偏頗的地方,還望各位指摘。

認識領域驅動設計的意義

領域驅動設計並不會絕對地提高項目的開發效率。

領域驅動設計複雜性比較領域驅動設計複雜性比較遵循領域驅動設計的規範使得項目初期的開發甚至不如不使用它來的快,原因有很多,程序員的素質,代碼的規範,限界上下文的劃分…甚至需求修改後導致需要重新建模。但是遵循領域驅動設計的規範,在項目越來越複雜之後,可以不至於讓項目僵死。這也是爲什麼很多系統不斷迭代着,最終就黃了。書名的副標題“軟件核心複雜性應對之道”正是闡釋了這一點

模式: smart ui是個反模式

可能很多讀者還不知道smart ui是什麼,但是在這本書寫作期間,這種設計風格是非常流行的。在與一位領域驅動設計方面的資深專家的交談中,他如下感慨到軟件發展的歷史:

2003年時,正是delphi,vb一類的smart ui程序大行其道,java在那個年代,還在使用jsp來完成大量的業務邏輯操作,4000行的jsp是常見的事;2005年spring hibernate替換了EJB,社區一片歡呼,所有人開始擁護action,service,dao這樣的貧血模型(充血模型,貧血模型會在下文論述);2007年,Rails興起,有人發現了Rails的activeRecord是漲血模型,引起了一片混戰;直到現在的2017年,微服務成爲主流系統架構。

在現在這個年代,不懂個MVC分層,都不好意思說自己是搞java的,也不會有人在jsp裏面寫業務代碼了(可以說模板技術freemarker,thymeleaf已經取代jsp了),但是在那個年代,還沒有現在這麼普遍地強調分層架構的重要性。

這個章節其實並不重要,因爲mvc一類的分層架構已經是大多數java初學者的“起點”了,大多數DDD的文章都不會贅述這一點,我這裏列出來是爲了讓大家知曉這篇文章的時代侷限性,在後續章節的理解中,也需要抱有這樣的邏輯:這本書寫於2004年。

模式: Entity與Value Object

我在不瞭解DDD時,就對這兩個術語早有耳聞。entity又被稱爲reference object,我們通常所說的java bean在領域中通常可以分爲這兩類,(可別把value object和常用於前臺展示的view object,vo混爲一談)
entity的要義在於生命週期和標識,value object的要義在於無標識,通常情況下,entity在通俗意義上可以理解爲數據庫的實體,(不過不嚴謹),value object則一般作爲一個單獨的類,構成entity的一個屬性。

舉兩個例子來加深對entity和value object的理解。

例1:以電商微服務系統中的商品模塊,訂單模塊爲例。將整個電商系統劃分出商品和訂單兩個限界上下文(Bound Context)應該是沒有爭議的。如果是傳統的單體應用,我們可以如何設計這兩個模塊的實體類呢?
會不會是這樣?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Product{
	String id;//主鍵
	String skuId;//唯一識別號
	String productName;
	Bigdecimal price;
	Category category;//分類
	List<Specification> specifications;//規格	
	...	
}

class Order{
	String id;//主鍵
	String orderNo;//訂單號
	List<OrderItem> orderItems;//訂單明細
	BigDecimal orderAmount;//總金額
	...
}

class OrderItem{
	String id;
	Product product;//關聯商品
	BigDecimal snapshotPrice;//下單時的價格
}

 

看似好像沒問題,考慮到了訂單要保存下單時候的價格(當然,這是常識)但這麼設計卻存在諸多的問題。在分佈式系統中,商品和訂單這兩個模塊必然不在同一個模塊,也就意味着不在同一個網段中。上述的類設計中直接將Product的列表存儲到了Order中,也就是一對多的外鍵關聯。這會導致,每次訪問訂單的商品列表,都需要發起n次遠程調用。

反思我們的設計,其實我們發現,訂單BC的Product和商品BC的Product其實並不是同一個entity,在商品模塊中,我們更關注商品的規格,種類,實時價格,這最直接地反映了我們想要買什麼的慾望。而當生成訂單後,我們只關心這個商品買的時候價格是多少,不會關心這個商品之後的價格變動,還有他的名稱,僅僅是方便我們在訂單的商品列表中定位這個商品。

如何改造就變得明瞭了

1
2
3
4
5
6
7
8
class OrderItem{
	String id;
	String productId;//只記錄一個id用於必要的時候發起command操作
	String skuId;
	String productName;
	...
	BigDecimal snapshotPrice;//下單時的價格
}

 

是的,我們做了一定的冗餘,這使得即使商品模塊的商品,名稱發生了微調,也不會被訂單模塊知曉。這麼做也有它的業務含義,用戶會聲稱:我買的時候他的確就叫這個名字。記錄productId和skuId的用意不是爲了查詢操作,而是方便申請售後一類的命令操作(command)。

在這個例子中,Order 和 Product都是entity,而OrderItem則是value object(想想之前的定義,OrderItem作爲一個類,的確是描述了Order這個entity的一個屬性集合)。關於標識,我的理解是有兩層含義,第一個是作爲數據本身存儲於數據庫,主鍵id是一個標識,第二是作爲領域對象本身,orderNo是一個標識,對於人而言,身份證是一個標識。而OrderItem中的productId,id不能稱之爲標識,因爲整個OrderItem對象是依託於Order存在的,Order不存在,則OrderItem沒有意義。

例子2: 汽車和輪胎的關係是entity和value object嗎?
這個例子其實是一個陷阱題,因爲他沒有交代限界上下文(BC),場景不足以判斷。對於用戶領域而言,的確可以成立,汽車報廢之後,很少有人會關心輪胎。輪胎和發動機,雨刮器,座椅地位一樣,只是構成汽車的一些部件,和用戶最緊密相關的,只有汽車這個entity,輪胎只是描述這個汽車的屬性(value object);場景切換到汽修廠,無論是汽車,還是輪胎,都是汽修廠密切關心的,每個輪胎都有自己的編號,一輛車報廢了,可以安置到其他車上,這裏,他們都是entity。

這個例子是在說明這麼一個道理,同樣的事物,在不同的領域中,會有不同的地位。

通過value object優化數據庫通過value object優化數據庫

在單體應用中,可能會有人指出,這直接違背了數據庫範式,但是領域驅動設計的思想正如他的名字那樣,不是基於數據庫的,而是基於領域的。微服務使得數據庫發生了隔離,這樣的設計思想可以更好的指導我們優化數據庫。

模式: Repository

哲學家分析自然規律得出規範,框架編寫者根據規範制定框架。有些框架,可能大家一直在用,但是卻不懂其中蘊含的哲學。

——來自於筆者的口胡

記得在剛剛接觸mvc模式,常常用DAO層表示持久化層,在JPA+springdata中,抽象出了各式各樣的xxxRepository,與DDD的Repository模式同名並不是巧合,jpa所表現出的正是一個充血模型(如果你遵循正確的使用方式的話),可以說是領域驅動設計的一個最佳實踐。

開宗明義,在Martin Fowler理論中,有四種領域模型:

  1. 失血模型
  2. 貧血模型
  3. 充血模型
  4. 脹血模型
    詳細的概念區別不贅述了,可以參見專門講解4種模型的博客。他們在數據庫開發中分別有不同的實現,用一個修改用戶名的例子來分析。
    1
    2
    3
    4
    5
    
    class User{
    	String id;
    	String name;
    	Integer age;
    }
    

失血模型:
跳過,可以理解爲所有的操作都是直接操作數據庫,在smart ui中可能會出現這樣的情況。

貧血模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class UserDao {
	@Autowired
	JdbcTemplate jdbcTemplate;

	public void updateName(String name,String id){
		jdbcTemplate.excute("update user u set u.name = ? where id=?",name,id);
	}
}

class UserService{
	
	@Autowired
	UserDao userDao;

	void updateName(String name,String id){
		userDao.updateName(name,id);
	} 
}

 

貧血模型中,dao是一類sql的集合,在項目中的表現就是寫了一堆sql腳本,與之對應的service層,則是作爲Transaction Script的入口。觀察仔細的話,會發現整個過程中user對象都沒出現過。

充血模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface UserRepository extends JpaRepository<User,String>{
	//springdata-jpa自動擴展出save findOne findAll方法
}

class UserService{
	@Autowoird
	UserRepository userRepository;

	void updateName(String name,String id){
		User user = userRepository.findOne(id);
		user.setName(name);
		userRepository.save(user);
	}
}

 

充血模型中,整個修改操作是“隱性”的,對內存中user對象的修改直接影響到了數據庫最終的結果,不需要關心數據庫操作,只需要關注領域對象user本身。Repository模式就是在於此,屏蔽了數據庫的實現。與貧血模型中user對象恰恰相反,整個流程沒有出現sql語句。

漲血模型:
沒有具體的實現,可以這麼理解:

1
2
3
4
5
void updateName(String name,String id){
	User user = new User(id);
	user.setName(name);
	user.save();
}

 

我們在Repository模式中重點關注充血模型。爲什麼前面說:如果你遵循正確的使用方式的話,springdata纔是對DDD的最佳實踐呢?因爲有的使用者會寫出下面的代碼:

1
2
3
4
5
6
7
interface UserRepository extends JpaRepository<User,String>{
	
	@Query("update user set name=? where id=?")
	@Modifying(clearAutomatically = true)
	@Transactional
	void updateName(String name,String id);
}

 

歷史的車輪在滾滾倒退。本節只關注模型本身,不討論使用中的一些併發問題,再來聊聊其他的一些最佳實踐。

1
2
3
4
5
6
7
interface UserRepository extends JpaRepository<User,String>{

	User findById();//√  然後已經存在findOne了,只是爲了做個對比
	User findBy身份證號();//可以接受
	User findBy名稱();//×
	List<權限> find權限ByUserId();//×
}

 

理論上,一個Repository需要且僅需要包含三類方法loadBy標識,findAll,save(一般findAll()就包含了分頁,排序等多個方法,算作一類方法)。標識的含義和前文中entity的標識是同一個含義,在我個人的理解中,身份證可以作爲一個用戶的標識(這取決於你的設計,同樣的邏輯還有訂單中有業務含義的訂單編號,保單中的投保單號等等),在數據庫中,id也可以作爲標識。findBy名稱爲什麼不值得推崇,因爲name並不是User的標識,名字可能會重複,只有在特定的現場場景中,名字才能具體對應到人。那應該如何完成“根據姓名查找可能的用戶”這一需求呢?最方便的改造是使用Criteria,Predicate來完成視圖的查詢,哪怕只有一個非標識條件。在更完善的CQRS架構中,視圖的查詢則應該交由專門的View層去做,可以是數據庫,可以是ES。findByUserId不值得推崇則是因爲他違背了聚合根模式(下文會介紹),User的Repository只應該返回User對象。

軟件設計初期,你是不是還在猶豫:是應該先設計數據庫呢,還是應該設計實體呢?在Domain-Driven的指導下,你應當放棄Data-Driven。

模式 聚合和聚合根

難住我的還有英文單詞,初識這個概念時,忍不住發問:Aggregate是個啥。文中使用聚合的概念,來描述對象之間的關聯,採用合適的聚合策略,可以避免一個很長,很深的對象引用路徑。對劃分模塊也有很大的指導意義。

在微服務中我們常說劃分服務模塊,在領域驅動設計中,我們常說劃分限界上下文。在面向對象的世界裏,用抽象來封裝模型中的引用,聚合就是指一組相關對象的集合,我們把它作爲數據修改的單元。每個聚合都有一個聚合根(root)和一個邊界(boundary)。邊界定義了聚合內部有什麼,而根則是一個特定的entity,兩個聚合之間,只允許維護根引用,只能通過根引用去向深入引用其他引用變量。

例子還是沿用電商系統中的訂單和商品模塊。在聚合模式中,訂單不能夠直接關聯到商品的規格信息,如果一定要查詢,則應該通過訂單關聯到的商品,由商品去訪問商品規格。在這個例子中,訂單和商品分別是兩個邊界,而訂單模塊中的訂單entity和商品模塊中的商品entity就是分別是各自模塊的root。遵循這個原則,可以使我們模塊關係不那麼的盤根錯節,這也是衆多領域驅動文章中不斷強調的劃分限界上下文是第一要義。

模式 包結構

微服務有諸多的模塊,而每個模塊並不一定是那麼的單一職責,比模塊更細的分層,便是包的分層。我在閱讀中,隱隱覺得這其中蘊含着一層哲學,但是幾乎沒有文章嘗試解讀它。領域驅動設計將其單獨作爲了一個模式進行了論述,篇幅不小。重點就是論述了一個思想:包結構應當具有高內聚性。

這次以一個真實的案例來介紹一下對高內聚的包結構的理解,項目使用maven多module搭建。我曾經開發過一個短信郵件平臺模塊,它在整個微服務系統中有兩個職責,一:負責爲其他模塊提供短信郵件發送的遠程調用接口,二:有一個後臺頁面,可以讓管理員自定義發送短信,並且可以瀏覽全部的一,二兩種類型發送的短信郵件記錄。

在設計包結構之前,先是設計微服務模塊。
| module名 | 說明 | package類型 | 頂級包名 |
| ——- | ————— | ————– | ———————— |
| api | api接口定義,用於暴露服務 | jar | sinosoftgz.message.api |
| app | api實現者,真正的服務提供者 | executable jar | sinosoftgz.message.app |
| admin | 管理端應用 | executable jar | sinosoftgz.message.admin |
| model | 實體 | jar | sinosoftgz.message.model |
api層定義了一系列的接口和接口依賴的一些java bean,model層也就是我們的領域層。這兩個模塊都會打成jar包,外部服務依賴api,api則由app模塊使用rpc框架實現遠程調用。admin和app連接同一個數據源,可以查詢出短信郵件記錄,admin需要自定義發送短信也是通過rpc調用。簡單介紹完了這個項目後,重點來分析下需求,來看看如何構建包結構。
mvc分層天然將controller,service,model,config層分割開,這符合DDD所推崇的分層架構模式(這個模式在原文中有描述,但我覺得和現在耳熟能詳的分層結構沒有太大的出入,所以沒有放到本文中介紹),而我們的業務需求也將短信和郵件這兩個領域拆分開了。那麼,到底是mvc應該包含業務包結構呢?還是說業務包結構包含mvc呢?

mvc高於業務分層

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//不夠好的分層
sinosoftgz.message.admin
	config
		CommonConfig.java
	service
		CommonService.java
		mail
			MailTemplateService.java
			MailMessageService.java
		sms
			SmsTemplateService.java
			SmsMessageService.java
	web
		IndexController.java
		mail
			MailTemplateController.java
			MailMessageController.java
		sms
			SmsTemplateController.java
			SmsMessageController.java
	MessageAdminApp.java

 

業務分層包含mvc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//高內聚的分層
sinosoftgz.message.admin
	config
		CommonConfig.java
	service
		CommonService.java
	web
		IndexController.java
	mail
	    config
	        MailConfig.java
		service
			MailTemplateService.java
			MailMessageService.java
		web
			MailTemplateController.java
			MailMessageController.java
	sms
	    config
	        Smsconfig.java
		service
			SmsTemplateService.java
			SmsMessageService.java
		web
			SmsTemplateController.java
			SmsMessageController.java
	MessageAdminApp.java

 

業務並不是特別複雜,但應該可以發現第二種(業務分層包含mvc)的包結構,纔是一種高內聚的包結構。第一種分層會讓人有一種將各個業務模塊(如mail和sms)的service和controller隔離開了的感覺,當模塊更多,每個模塊的內容更多,這個“隔得很遠”的不適感會逐漸侵蝕你的開發速度。一種更加低內聚的反例是不用包分層,僅僅依賴前綴區分,由於在項目開發中真的發現同事寫出了這樣的代碼,我覺得還是有必要拿出來說一說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//反例
sinosoftgz.message.admin
	config
		CommonConfig.java
		MailConfig.java
		Smsconfig.java
	service
		CommonService.java
		MailTemplateService.java
		MailMessageService.java
		SmsTemplateService.java
		SmsMessageService.java
	web
		IndexController.java
		MailTemplateController.java
		MailMessageController.java
		SmsTemplateController.java
		SmsMessageController.java     
	MessageAdminApp.java

 

這樣的設計會導致web包越來越龐大,逐漸變得臃腫,是什麼使項目僵化,項目經理爲何一看到代碼就頭疼,規範的高內聚的包結構,遵循業務>mvc的原則,可以知道我們的項目龐大卻有條理。

其他模式

《領域驅動設計》這本書介紹了衆多的模式,上面只是介紹了一部分重要的模式,後續我會結合各個模式,儘量採用最佳實踐+淺析設計的方式來解讀。

微服務之於領域驅動設計的一點思考

技術架構誠然重要,但不可忽視領域拆解和業務架構,《領域驅動設計》中的諸多失敗,成功案例的總結,是支撐其理論知識的基礎,最終匯聚成衆多的模式。在火爆的微服務架構潮流下,我也逐漸意識到微服務不僅僅是技術的堆砌,更是一種設計,一門藝術。我的本科論文本想就微服務架構進行論述,奈何功底不夠,最後只能改寫成一篇分佈式網站設計相關的文章,雖然是一個失敗的過程,但讓我加深了對微服務的認識。如今結合領域驅動設計,更加讓我確定,技術方案始終有代替方案,決定微服務的不是框架的選擇,不僅僅是restful或者rpc的接口設計風格的抉擇,而更應該關注拆解,領域,限界上下文,聚合根等等一系列事物,這便是我所理解的領域驅動設計對微服務架構的指導意義。

參考文章

多研究些架構,少談些框架—-曹祖鵬

DDD領域驅動設計基本理論知識總結 - netfocus

歡迎關注我的微信公衆號:「Kirito的技術分享」,關於文章的任何疑問都會得到回覆,帶來更多 Java 相關的技術分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章