領域驅動設計實踐

領域驅動設計的關注重心是領域,尤其在面對複雜的領域邏輯時,它總能夠幫助我們很好地分析領域。領域驅動設計的基礎是領域建模。Eric認爲需要和領域專家良好地合作,從交談中發現通用語言,找到領域的關鍵詞。領域建模是迭代的過程,根據逐漸深入的領域知識來精化模型。不過,領域驅動設計並不排斥其他的分析技術,例如分析模式,或者通過測試驅動開發來引導我們找到問題域的領域模型。

領域建模並非領域驅動設計所獨有。在RUP中,領域建模就是一個非常重要的環節。它是一種用例驅動的開發方法,通過獲得的用例來幫助分析和設計人員尋找對象,以及對象之間的關係。根據我個人的經驗,我喜歡採用兩種截然不同的方式來獲得模型。一種是用例驅動,一種則是測試驅動。在得到初步的領域模型中,我會嘗試利用領域驅動設計的思想爲對象分類,找到實體、值對象、聚合以及服務對象,並通過分析對象的生命週期,爲不同類型的對象建立資源庫和工廠對象。

本文將以一個讀者耳熟能詳的圖書館管理系統作爲我們要分析的領域,嘗試講解如何在項目開發中應用領域驅動設計。我將選擇用例驅動的方式來獲得我最初的領域模型。簡單起見,我先給出分析領域的用例以及用例圖。

借書:讀者攜帶要借書籍到借書處。圖書館工作人員首先掃描讀者的借書卡,獲得讀者信息,然後掃描書籍的條形碼。如果借閱多本,則掃描多本書籍。掃描時,需要判斷當前讀者的類型,獲得讀者可借書籍數。如果借閱書籍超出,則提示。如果掃描失敗,允許工作人員手工輸入編號。借閱的期限爲1個月。

還書:讀者攜帶要還書籍到還書處。圖書館工作人員掃描書籍的條形碼,進行還書操作。如果借閱的書籍超期,則提示,並計算出應收罰款的數額。如果掃描失敗,允許工作人員手工輸入編號。

我採用了摘要方式來描述用例。我喜歡這樣一種簡潔的方式,它實際上等同於XP中的用戶故事。在需求並不複雜的時候,或者在對文檔要求並不嚴格的時候,都可以採用這種方式來編寫用例。

以下是表達上述兩個用例的用例圖展現:

clip_image002

可以首先利用名詞/動詞法找到模型中的領域對象。這種方法雖然極度地簡單與低級,然後在建立領域模型之初,是非常有效的手段。通過對用例的分析,大致可以獲得如下對象:Reader,Administrator,Book,Library Card以及Scanner。也許還有我們未曾發現的領域對象,這可以通過深入領域或與客戶交談來進一步獲得。我們可以嘗試着先獲得一個最簡單的領域模型,如下所示。

clip_image004

我們發現Administrator對象是一個孤立的對象,它與其他領域對象沒有產生任何關係。至少在借書、還書用例中,我們並不需要管理這個對象,可以考慮刪除它。模型中的Scanner對象非常特殊,表面上它會對Book與LibraryCard進行操作,然而對於Scanner而言,它並不關心操作的是什麼對象,而只需要掃描條形碼,返回一個字符串。這是一種行爲的體現。在整個系統中,Scanner對象可以只擁有一個,沒有屬性和狀態,僅提供掃描功能,或者說是服務,因此可以考慮將其定義爲服務對象。

Reader與Book之間的關係非常直接,可是在引入LibraryCard之後,這個關係就顯得有些尷尬了。仔細閱讀用例,我們發現識別讀者的信息,是通過借書卡來獲取的。無論是借書還是還書,都可以通過借書卡來獲得讀者當前借閱的書。此時,讀者與書之間就不存在任何關係了,它已經進行了轉嫁。既然借書卡已經實現了對借書關係的管理,我們還有必要保留Reader對象嗎?閱讀用例,我們知道在掃描借書卡時,會獲得讀者的信息。雖然我們可以在借書卡中保留這些信息,但根據單一職責原則(SRP),將其專門封裝爲一個對象仍有必要。

目前,借書卡僅僅維護了讀者當前借閱的書籍,那麼,還需要維護借閱和返還的歷史記錄嗎?從用例的描述來看,並沒有這一功能。我們感到疑惑,因爲保留歷史記錄是大多數系統所必備的。此時,客戶的答案就顯得格外重要。“哦,是的,我們需要查看歷史記錄!”這是客戶給我們的肯定答覆。顯然,查看歷史記錄屬於另一個用例,它甚至可能屬於另外一個上下文(Context),例如關於“查詢”的上下文。然而,這一信息的來源卻來自於借閱與返回用例,我們應該將其識別出來。如果其他用例需要用到,我認爲這個對象是需要共享的。細化後的領域模型如下:

clip_image006

通過對掃描行爲的分析,我認爲Scanner提供的掃描行爲與領域無關,而是一種基礎設施,因此我將其定義爲基礎設施層的服務。模型增加了FineCalculator對象,用以完成對超期讀者的罰款金額計算。顯然,它是一個服務對象。注意,BorrowingHistory與Book是一對一的關係,因爲我們需要爲每一本書建立一條借閱歷史記錄。

現在,我們需要識別領域模型中的實體和值對象,以及可能的聚合。我們需要一個唯一的標識來區別讀者,且這一標識具有連續性,因此Reader是一個實體對象。同樣,Book對象也是一個實體對象,因爲我們需要一個唯一標識來完成對書籍的跟蹤。注意,在這個模型中的Book實體,其實例代表的是具體的某一本書,而不是指同一種書。因爲圖書館可能就同一種書購買多本,而讀者借閱的是真實的書本,而不僅僅是書的屬性。此時,Book的標識ID就顯得尤爲重要,甚至不能用書籍的ISBN來標識。

從表面上看,BorrowingHistory同樣屬於實體對象,它的每一條記錄都是唯一的,即使存在兩條歷史記錄,具有相同的讀者ID與書籍ID,我們仍將其視爲不同的記錄,因爲它們的借閱時間並不相同。不過,對於系統的調用者而言,通常不會去關注所有的借閱記錄,而是查詢某位讀者的借閱記錄,因此,我們可以將其作爲與Reader放在一起的聚合。然而,隨着對需求的深入分析,我們發現定義這樣的聚合存在問題,因爲我們可能還需要查詢某本書的借閱記錄(例如,希望知道哪本書最受歡迎,跟蹤每本書的借閱情況等)。由於Reader和Book應該分屬於不同的聚合,BorrowingHistory就存在無法劃定聚合的問題。既然如此,我們應該將其分離出來,作爲一個單獨的聚合根。

讓人感覺疑惑不解的是LibraryCard對象。一方面,它的ID來源於Reader,且存在一對一的關係,因此它可以作爲Reader聚合的一部分。根據模型圖來看,它實際上又記錄了讀者與書之間的關係。仔細分析,LibraryCard所維護的這樣一種讀者與書的關係,事實上正是BorrowingHistory的一種體現,區別僅在於一個記錄了當前的借書信息,一個還包括過去的借書信息。BorrowingHistory可以進行信息的持久化,LibraryCard則完全可以在內存中維持一個當前借閱信息的集合。因此,可以將LibraryCard定義在Reader聚合中。這樣既可以減少對象之間的關聯,又能保證對象之間的一致性。

我們還需要深入分析Reader對象和Book對象的標識ID,因爲這兩者的標識ID都是通過基礎設施的Scanner服務獲得的。Scanner並沒有能力知道二者之間的區別。而在借閱書籍時,根據需求規定的流程,必須是先掃描借書卡,獲得讀者信息,然後再掃描書。此外,當掃描出現錯誤時,系統需要支持操作人員手工輸入,因此對手工輸入的內容也需要進行ID的驗證。我們需要有專門驗證ID的對象。

我們還要考慮許多業務規則,例如是否允許讀者借書的規則,是否超期的規則,計算罰款額度的規則。如果這些規則極爲簡單,且不具有變化的可能,可以放在領域對象中。然而,一旦規則變得複雜,就會嚴重干擾相關領域對象的職責。根據職責分離的原則,我們可以提供專門的規則對象,即領域驅動設計中規格模式的應用。如果可能變化,我們甚至可以引入策略模式,對這些規則進行抽象。經過分析後得到的領域模型如下所示:

clip_image008

Reader實體對象和LibraryCard實體對象處於同一個聚合中,其中Reader爲聚合根。BorrowingSpecification和ReturningSepecification均爲值對象,並放在Reader聚合中。FineCalculator是一個服務對象,它會調用FineRule值對象獲得罰款規則,通過計算後返回Money值對象值。由於聚合的原因,原來FineCalculator與LibraryCard之間的關係已經修改爲計算Reader的罰款。

BorrowingHistory和Book均爲實體對象,而IdentityValidator則爲服務對象,負責驗證掃描碼。

接下來需要爲領域對象選擇資源庫(Repository)。在領域模型中,只有Reader、BorrowingHistory和Book三個實體爲聚合根對象,因此只需要爲這三個對象建立資源庫對象即可。

clip_image010

由於需求較爲簡單,建立的領域模型已經比較完善,我們可以着手編碼,對這些模型進行驗證。本文沒有考慮限定上下文的情況,我希望未來的文章能夠以真實的案例對此進行表述。整體而言,根據這個案例,我們已經能夠初步領略領域驅動設計的基本步驟。


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