DDD與數據事務腳本
- 2022 年 2 月 27 日
- 筆記
DDD與數據事務腳本
扯淡
相信點進來看這篇文章的同學,大部分是因為標題裡面的「DDD」所吸引!DDD並不是一個新技術,如果你百度一下它的歷史就會知道,實際上它誕生於2004年, 到現在已經18年,完全是個「古董」,軟體開發技術日新月異,DDD確顯得很獨特,一直不溫不火,也未淘汰。有些人為了使用DDD「苦思冥想」、有些人對它保持敬畏,覺得是一種高端的技術,當然也有人覺得這玩意垃圾根本沒用。廢話不多說. 下面我嘗試使用一個最基本的業務場景來討論下ddd和事務腳本。
假如我們的現在需要做這麼一個系統,名字叫做「消息發送系統」。 系統裡面存在很多用戶,而我們需要做的,就是給指定的用戶發送消息
- 用戶能在收件箱中看到收到的消息,消息有已讀,未讀狀態,消息內容不能為空。
- 通過發送用戶id、消息內容,就可以發送到用戶。注意這裡並沒有發件人的概念。
數據事務腳本
需求很簡單,現在我們開始設計這個功能。相信你只需要學習過一些基本的編程技術,你第一直覺一定會整理出下面這個數據結構
message{
id , //消息id
userId ,//接受用戶id
content ,//消息內容
state ,//消息狀態已讀,未讀
...
}
當設計出這個數據結構的時候,我們心裏面其實認為這個系統的「核心」就完成了, 這TM是真簡單啊! 為什麼會是這樣?我們都知道,電腦最核心的組件是cpu,而cpu就是用來計算處理用戶輸入的數據,並將結果回饋給用戶。而軟體運行在電腦上,所能提供的功能實際上也是這樣處理數據。當然了,電腦還有磁碟,磁碟可以存儲用戶輸入cpu的數據,也能存儲cpu處理完後的數據!按這個邏輯,一個軟體(系統)實際上就是在將特定業務場景下的數據輸入電腦,電腦處理完後存儲這些數據。軟體只需要準確的記錄下計算過程中產生的所有數據,並正確的存儲算據即可實現對應的功能。
軟體的不同功能,只是以合適的方式創建、修改、展示數據。上面的message結構,準確的找出了我們「消息發送系統」裡面產生的數據。 發送消息,只不過就是新增了這樣一個消息數據, 而用戶看消息,只是找到所有message中userid為自己的id的消息,顯示出來就可以了。這個「消息發送系統」非常簡單,其實大的系統也是一樣,大系統由很多這樣小的系統組成,本質確是一樣的。
我們已經找出這個系統會產生的數據結構,當然還需要保存起來,假設將它存入資料庫裡面(如果你願意,你也可以存到文件里,或者乾脆只放到記憶體里),那麼我們在資料庫裡面就新建一張message的表,欄位就是id,userId,content,state。假設現在有個頁面,您可以在這個頁面中輸入id,userid,content,state,點擊提交按鈕,軟體取到這些值,就寫入資料庫。偽程式碼大概是這樣:
var id = input('id');
var userId = input('userId');
var content = input('content');
if(userId == ""){
err "userid不能為空"
}
if(content == ''){
err "內容不能為空";
}
//發送消息
state = input('state');
database.exeSql("insert into message values(id,userid,content,state)");
//查看消息
messages = database.exeSql("select * from message where userid=userid");
這個時候「大神」告訴你,這個太low了,邏輯和操作資料庫全部寫一起了, 一點都不面向對象,一個類都沒有,你應該封裝一個message類。我們現在有orm框架,能直接將對象存到資料庫,這個時候你經過不斷的學習,你寫出了下面的程式碼:
id = input('id');
userId = input('userId');
content = input('content');
if(userId == ""){
err "userid不能為空"
}
if(content == ''){
err "內容不能為空";
}
state = input('state');
//發送消息
message = new message(id,userid,content,state);
orm.save(message);
//查看消息
messages= orm.getMessageByUserId(userid);
簡單到爆,一切都這麼自然,很完美這樣你的消息系統就完成了。 當然了, 「大神」還會告訴你,或者你自己意識到下面這些「硬核知識點」
- 將上面message這種跟資料庫表對應的對象,稱之為PO(Persistent Object)
- 將存儲message到資料庫,或者從資料庫中通過相應的條件查找、更新、刪除、message對象的功能,封裝到一起,叫做dao對象
- 前端頁面里不同的功能點,最好別直接調用DAO操作存儲數據,因為還有一些業務邏輯程式碼需要編寫,為了封裝性,需要將不同的功能點封裝到不同的類裡面,我們叫做sevice類(可能很多同學直接一個數據表對應一個po,對應一個dao,對應一個service),比方說messageServcie
class messageServcie{
message[] getMessageBuUserId();//通過用戶id,獲取它收到的消息
readMessageById(messageId);//將message設置為已讀
}
寫了這麼多,相信大家也看出來了,這就是所謂《數據事務腳本》開發方式的來源。 這種開發方式很直接也很簡單。如果你問我有什麼問題? 我覺得沒什麼問題,挺好的。我們以數據為中心,軟體只是在處理數據,存儲數據而已。
接下來我們說DDD
由於我們所使用的程式語言java、C#等,都是面向對象編程的,也很認可面向對象編程的好處!而數據事務腳本開發方式,核心是數據,雖然裡面也有po對象,但是po對象卻沒有任何行為,這讓很多人覺得有點尷尬! 而ddd確說我們的領域對象是有相應的行為的,這也是很多人喜歡ddd的理由。
我們知道對象包括屬性和它的行為。而軟體,從面向對象的角度,就是在業務範圍內設計不同的對象,然後通過對象與對象之間的行為調用,改變對象的狀態,從而實現不同的功能,想想我們的現實世界,確實是由一個一個的物體組成的。 汽車是不同的零部件組成,人由不同的器官組成,這也是為什麼說面向對象更加容易理解,更加流行的原因。 下面還是以消息發送系統為例。
通過閱讀上面「消息發送系統的」需求, 應該很容易發現消息, 用戶這些名詞。 暫且不管對不對, 先將這些寫成類
class message{
id , //消息id
userId ,//接受用戶id
content ,//消息內容
state//消息狀態已讀,未讀
message(id,userId,content){
if(userId == ""){
err "這消息不行"
}
if(content == ""){
err "這消息不行"
}
state = "未讀";
this.id=id;
....
}
///修改消息狀態
setState(state){
if(this.state == "已讀" && state=="未讀"){
err "已讀的消息,不能設置成未讀"
}
this.state = state;
}
}
class user{
id,
userid,
messages[],//收件箱中的消息
addMessage(message){
message.userid= this.userid;
this.messages.add(message);
},
//將消息設置為已讀
setMessageisReaded(message){
message.setState("已讀");
}
}
class userService{
//發送消息
sendMessage(id,userid,content,state){
var message = new message(id,userid,content,state);
var messageBox = findUserMessageBox(userid);//找到收件箱對象
messageBox.add(message);
}
//獲取用戶收件箱中的消息
getMessageByUserId(userId){
var user = findUserByUserId(userid);
return user.messages;
}
//將消息設置為已讀
setMessageisReaded(userId,message){
var user = findUserByUserId(userid);
user.setMessageisReaded(message);
}
}
大家看了上面的程式碼覺得怎麼樣? 是不是感覺也很自然, 對象有自己的屬性和對象。 這個時候相信有些看到這裡的同學, 已經有疑問了「你這是搞著玩吧,數據都不存到資料庫的?」。上面說過從面向對象編程的角度,軟體就是對象與對象的交互,交互完後對象狀態會改變, 比如下說上面的發送消息程式碼,我們可以理解為:新創建了一個message對象, 通過userid找到用戶,然後將message放到用戶收到的消息列表中。假如我們的記憶體是無限的,而且不會丟失,我們還需要存數據嗎? 現實當然記憶體不無限,也會丟失,那麼是不是把這個message對象和user對象通過某種方式保存到磁碟,需要的時候取出來就可以了? 存儲方式很多比如你直接json序列化寫到文件,寫到MongoDB,或者存到關係型資料庫。 但是這裡與數據事務腳本概念已經不一樣,數據事務腳本存的是軟體運行產生的結構數據, 而這裡存的是對象,這一點一定要理解。 所謂的repository就是用來存取對象,dao卻是用來存結構化數據,概念有很大的不同!!!
如果你看過一些ddd的文章,你一定知道ddd裡面有很多名詞:統一語言、事件風暴、限界上下文、領域、子域、支撐域、聚合、聚合根、實體、值對象。 初學者一看,尼瑪嚇死人這麼多的東西! 然後就是一臉懵逼。 實際上面這些詞是可以分類的,有些是告訴你怎麼和客戶溝通,了解需求,有些是告訴你怎麼劃分不同子系統,有些是告訴你怎麼找到設計對象,有些實際上只是給有特徵的對象起個名, 有些是給對象與對象的關係起個名。
對於一般開發著來說,找到對象、設計對象才是我們最關心的,因為對象是開發者寫程式碼的基礎!至於跟客戶溝通、了解需求、劃分不同的子系統、對象關係命名這種東西暫且放放! ddd之所以不普及,不是因為需求沒溝通好,子系統沒劃分好,而是因為不知道怎麼設計對象,就不知道應該怎麼寫程式碼。 對象是一個抽象的東西。有些系統里的對象,是有實物對應的,比如說購物車,我們就很清楚知道有什麼屬性和行為。而有些沒實物對應,就不知道怎麼找到對象,也不知道對象應該具有什麼行為。一個簡單的功能,每個開發者都有不同的理解與抽象,出現不同的設計方案,而且大家都認為自己的合理的。 園子里有很多高手寫的ddd文章,如果只是解釋介紹上面的名詞,一般討論不起來,只有點贊、叫好的份。因為上面這些名詞解釋可以說是死的, 大家能看懂就行, 但是如果出現一些ddd設計案例, 就會出現一些不同的設計方案來討論。而最終當然也是不了了之! 上面我寫的消息發送系統程式碼,初學者一看覺得寫的挺好,但是ddd高手一定有不認同的地方!
對象的行為如何劃分
對於ddd 對象設計來說,最糾結的是行為應該放到哪個對象? 舉個爛大街的例子「圖書管理系統」中,讀者借書的邏輯,應該設計到哪個對象。一種常見的設計是這樣:
///讀者
class reader{
id,
///名字,地址,餘額,借書記錄
其他屬性...
///借書
borrow(book){
//判斷能不能借這本書的一系列邏輯...
book.setState('已借');
}
}
//書(這裡沒有定義書數量,假設書都是一本一本)
class Book{
id,
title,
state,//已借出,未借出
setState(state){
if(this.state == "已借" && state == "已借"){
err "不能再借了";
}
}
}
class readerService{
borrow(readerId,bookId){
book = getBookById(bookId);
reader = getReaderById(readerId);
reader.borrow(book);
}
}
從程式碼上能看出借書邏輯寫到了reader(讀者)對象上,「讀者借書」所以借書應該就寫到讀者上,很符合現實和直覺。但是這裡我們需要再仔細思考下。 現實世界中對於讀者去圖書館借書,實際上是這樣的,讀者向圖書館申請借書,圖書館查看圖書是否可以借給這個讀者(可能這本書已經借出,也有可能這個用戶不能借書…),如果能,就將書借給這個讀者,然後記錄一下,借書業務就完成了。如果你覺得這個描述是對的,那麼借書的邏輯還應該放到reader這個對象上嗎?顯然不是,借書的主要邏輯應該圖書館,或者是圖書管理員(取決於業務複雜程度),讀者只是一個驅動者!上程式碼。
///讀者
class reader{
id
///名字,地址,餘額,借書記錄
其他屬性...
}
//書
class Book{
id,
title,
state,//已借出,未借出
setState(state){
if(this.state == "已借" && state == "已借"){
err "不能再借了";
}
}
}
class BookLibrary{
borrow(readerId,bookId){
reader = getReaderById(readerId);//找到這個讀者
book = getBookById(bookId);//找到這本數
//檢查這個讀者能不能借這本書...
book.setState("已借");//將書設置為已借
}
}
看上去差不都,實際上也差不多!呵呵… 但是借書這個邏輯不再放到了讀者對象上,而是放到了BookLibrary上面。
再舉些例子:
- 「銀行帳號轉賬」, 轉賬邏輯顯然也不是帳號(account)上面的,帳號的行為的主要是這2個:充值(錢變多)和消費(錢變少)。 至於轉賬應該是「銀行」的功能!!!
- 「論壇用戶發帖」, 發帖邏輯也不應該是用戶對象上,而應該是論壇本身!
- 「用戶購物」,購物邏輯也不應該是用戶對象上的,這是購物軟體本身通過操作帳號錢包對象,物品,等等一系列對象達到購物這個目的。
…
這種例子很多,其實這裡最主要是。 不能將業務邏輯簡單的放到驅動者身上,而是要深入分析一個功能點具體是怎麼實現的,是有哪些對象一起交互才完成的功能。 如果全部放到驅動者身上,最終會導致好像所有的功能都應該寫到”用戶”這個對象上,因為是用戶在使用驅動軟體。用戶通過操作軟體介面上的功能點,驅動軟體運行。 其實往往出現在軟體介面上面的功能點,都應該寫到這個軟體本身對象(BookLibrary)這個對象上。軟體本身再操作不同的領域對象,實現功能!!! 這也就是ddd中所謂application services的來源,將不同類型的功能通過services概念在封裝到一起,形成不同的***Service,取名叫做領域服務對象。那有人可能又會說,這樣下去是不是所有的業務都到了service裡面,其實不是,其他對象有自己的業務邏輯,service需要操作不同的領域對象,實現不同的業務, 比方說:銀行轉賬中,賬戶充值消費就是帳號這個行為的,這裡面就應該對帳號是不是已經被凍結,餘額是否足夠消費,進行業務處理。
其實還有一種簡單方法來設計對象,面向對象編程一開始就告訴了我們:對象=屬性+行為,通過行為修改屬性來到達改變對象狀態實現不同的功能。也就是說行為跟對象屬性是有關聯性的,這也是面向對象中所謂的”內聚”。
- 如果一個行為跟這個對象上的屬性沒有任何關聯,這個行為放到這個對象上就是不合適的,
- 如果一個行為需要好幾個對象,而把功能放到這好幾個對象中其中一個對象上也是不合理的。可以通過service的方式實現,還有一種方式就是將這個行為獨立抽象出一個對象(這取決與業務需要),而這個對象擁有其他需要的對象,比分說銀行轉賬
class transfer{
//執行轉賬
execute(fromAccout,toAccount,amount){
if(this.check(fromAccout,toAccount)){
fromAccout.sub(amount);
fromAccout.add(amount);
}
err '不能轉';
}
//檢查是否能轉
check(fromAccout,toAccount){
//一系列的檢查
}
}
對於借書這個場景來說,一個借書流程需要,讀者對象,書對象,以及一些借書規則,這些資訊,這裡面任何一個對象都不具備所有資訊,比如說,讀者對象並不知道讀書對象現在是什麼狀態,也不知道借書有哪些規則。 所以將借書邏輯放到讀者對象上是不合適的。
相信對於找出對象的屬性一般沒問題,所以可以通過先找出對象的屬性,然後再通過屬性找出它的行為!!!
restfull資源與對象的不匹配
ddd之所以這段時間變得火,主要是因為微服務。 大家都說使用ddd能給微服務帶來多少好處(可能最大的好處是能分出不同的子系統(微服務)), 我這裡說一個它的壞處。
按照ddd的思路,最終出來的是一個一個的對象。 而rest出來的api是一個一個的資源。ddd每個對象是有不同行為的,而rest中卻規定,對於資源的操作應該是統一的(post,delete,get,put)。 對象中不同的行為,應該怎麼在rest中表達? 從這個角度來說,ddd與restfull天然需要做一次適配,而ddd與遠程對象調用更加合拍(ejb,wcf等)
而如果是傳統的數據事務腳本,數據很容易對應到rest中的資源,對於數據的操作無非就是(增,刪,查,改),也容易對應到(post,delele,get,put)。
總結
寫了這麼多,從數據事務腳本的由來,到ddd,再寫到怎麼設計對象。總的來說就下面幾點!
- 資料庫事務腳本:service–>dao這種方式也挺好,並不是不行!!甚至跟rest配合起來更加「直接」。這也是為什麼現在大部分還是數據事務腳本方式的原因(程式碼中有repository,有domian可不一定是ddd哦)
- 在ddd對象設計中不能將業務邏輯簡單放到驅動者身上,應該仔細思考,到底發生了什麼,不能為了充血而充血! 可以通過先找對象屬性,然後再找關聯的業務行為。 但是前面也說了,每個人可能抽象出不同的對象,如果對象符合行為和屬性內聚這個特性,建議一定要有自信別亂想(這也是很多人糾結的地方)…
- ddd與restfull並不是完美匹配的。
當然這只是拋磚引玉,實際上ddd是一套完整的軟體開發流程,並不只是設計對象而已, 在實際開發中也還有很多的問題需要思考,比如存儲對象,應該放到service中嗎? 發送郵件,發送簡訊這些功能點,寫到領域對象,還是service裡面,類似的問題很多!! 這裡不討論,這需要大家的開發經驗實踐,或者去查看一下大神的最佳實踐。 就這樣了,希望對大家有幫助,謝謝閱讀!