DDD與資料事務指令碼

~Js發表於2022-02-27

DDD與資料事務指令碼

扯淡

相信點進來看這篇文章的同學,大部分是因為標題裡面的“DDD”所吸引!DDD並不是一個新技術,如果你百度一下它的歷史就會知道,實際上它誕生於2004年, 到現在已經18年,完全是個“古董”,軟體開發技術日新月異,DDD確顯得很獨特,一直不溫不火,也未淘汰。有些人為了使用DDD“苦思冥想”、有些人對它保持敬畏,覺得是一種高階的技術,當然也有人覺得這玩意垃圾根本沒用。廢話不多說. 下面我嘗試使用一個最基本的業務場景來討論下ddd和事務指令碼。

假如我們的現在需要做這麼一個系統,名字叫做“訊息傳送系統”。 系統裡面存在很多使用者,而我們需要做的,就是給指定的使用者傳送訊息

  1. 使用者能在收件箱中看到收到的訊息,訊息有已讀,未讀狀態,訊息內容不能為空。
  2. 通過傳送使用者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);

簡單到爆,一切都這麼自然,很完美這樣你的訊息系統就完成了。 當然了, “大神”還會告訴你,或者你自己意識到下面這些“硬核知識點”

  1. 將上面message這種跟資料庫表對應的物件,稱之為PO(Persistent Object)
  2. 將儲存message到資料庫,或者從資料庫中通過相應的條件查詢、更新、刪除、message物件的功能,封裝到一起,叫做dao物件
  3. 前端頁面裡不同的功能點,最好別直接呼叫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上面。

再舉些例子:

  1. “銀行賬號轉賬”, 轉賬邏輯顯然也不是賬號(account)上面的,賬號的行為的主要是這2個:充值(錢變多)和消費(錢變少)。 至於轉賬應該是“銀行”的功能!!!
  2. “論壇使用者發帖”, 發帖邏輯也不應該是使用者物件上,而應該是論壇本身!
  3. “使用者購物”,購物邏輯也不應該是使用者物件上的,這是購物軟體本身通過操作賬號錢包物件,物品,等等一系列物件達到購物這個目的。
    ...

這種例子很多,其實這裡最主要是。 不能將業務邏輯簡單的放到驅動者身上,而是要深入分析一個功能點具體是怎麼實現的,是有哪些物件一起互動才完成的功能。 如果全部放到驅動者身上,最終會導致好像所有的功能都應該寫到"使用者"這個物件上,因為是使用者在使用驅動軟體。使用者通過操作軟體介面上的功能點,驅動軟體執行。 其實往往出現在軟體介面上面的功能點,都應該寫到這個軟體本身物件(BookLibrary)這個物件上。軟體本身再操作不同的領域物件,實現功能!!! 這也就是ddd中所謂application services的來源,將不同型別的功能通過services概念在封裝到一起,形成不同的***Service,取名叫做領域服務物件。那有人可能又會說,這樣下去是不是所有的業務都到了service裡面,其實不是,其他物件有自己的業務邏輯,service需要操作不同的領域物件,實現不同的業務, 比方說:銀行轉賬中,賬戶充值消費就是賬號這個行為的,這裡面就應該對賬號是不是已經被凍結,餘額是否足夠消費,進行業務處理。

其實還有一種簡單方法來設計物件,物件導向程式設計一開始就告訴了我們:物件=屬性+行為,通過行為修改屬性來到達改變物件狀態實現不同的功能。也就是說行為跟物件屬性是有關聯性的,這也是物件導向中所謂的"內聚"。

  1. 如果一個行為跟這個物件上的屬性沒有任何關聯,這個行為放到這個物件上就是不合適的,
  2. 如果一個行為需要好幾個物件,而把功能放到這好幾個物件中其中一個物件上也是不合理的。可以通過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,再寫到怎麼設計物件。總的來說就下面幾點!

  1. 資料庫事務指令碼:service-->dao這種方式也挺好,並不是不行!!甚至跟rest配合起來更加“直接”。這也是為什麼現在大部分還是資料事務指令碼方式的原因(程式碼中有repository,有domian可不一定是ddd哦)
  2. 在ddd物件設計中不能將業務邏輯簡單放到驅動者身上,應該仔細思考,到底發生了什麼,不能為了充血而充血! 可以通過先找物件屬性,然後再找關聯的業務行為。 但是前面也說了,每個人可能抽象出不同的物件,如果物件符合行為和屬性內聚這個特性,建議一定要有自信別亂想(這也是很多人糾結的地方)...
  3. ddd與restfull並不是完美匹配的。

當然這只是拋磚引玉,實際上ddd是一套完整的軟體開發流程,並不只是設計物件而已, 在實際開發中也還有很多的問題需要思考,比如儲存物件,應該放到service中嗎? 傳送郵件,傳送簡訊這些功能點,寫到領域物件,還是service裡面,類似的問題很多!! 這裡不討論,這需要大家的開發經驗實踐,或者去檢視一下大神的最佳實踐。 就這樣了,希望對大家有幫助,謝謝閱讀!

相關文章