|
領域驅動設計的關注重心是領域,尤其在面對復雜的領域邏輯時,它總能夠幫助我們很好地分析領域。領域驅動設計的基礎是領域建模。Eric認為需要和領域專家良好地合作,從交談中發現通用語言,找到領域的關鍵詞。領域建模是迭代的過程,根據逐漸深入的領域知識來精化模型。不過,領域驅動設計并不排斥其他的分析技術,例如分析模式,或者通過測試驅動開發來引導我們找到問題域的領域模型。
領域建模并非領域驅動設計所獨有。在RUP中,領域建模就是一個非常重要的環節。它是一種用例驅動的開發方法,通過獲得的用例來幫助分析和設計人員尋找對象,以及對象之間的關系。根據我個人的經驗,我喜歡采用兩種截然不同的方式來獲得模型。一種是用例驅動,一種則是測試驅動。在得到初步的領域模型中,我會嘗試利用領域驅動設計的思想為對象分類,找到實體、值對象、聚合以及服務對象,并通過分析對象的生命周期,為不同類型的對象建立資源庫和工廠對象。
本文將以一個讀者耳熟能詳的圖書館管理系統作為我們要分析的領域,嘗試講解如何在項目開發中應用領域驅動設計。我將選擇用例驅動的方式來獲得我最初的領域模型。簡單起見,我先給出分析領域的用例以及用例圖。
借書:讀者攜帶要借書籍到借書處。圖書館工作人員首先掃描讀者的借書卡,獲得讀者信息,然后掃描書籍的條形碼。如果借閱多本,則掃描多本書籍。掃描時,需要判斷當前讀者的類型,獲得讀者可借書籍數。如果借閱書籍超出,則提示。如果掃描失敗,允許工作人員手工輸入編號。借閱的期限為1個月。
還書:讀者攜帶要還書籍到還書處。圖書館工作人員掃描書籍的條形碼,進行還書操作。如果借閱的書籍超期,則提示,并計算出應收罰款的數額。如果掃描失敗,允許工作人員手工輸入編號。
我采用了摘要方式來描述用例。我喜歡這樣一種簡潔的方式,它實際上等同于XP中的用戶故事。在需求并不復雜的時候,或者在對文檔要求并不嚴格的時候,都可以采用這種方式來編寫用例。
以下是表達上述兩個用例的用例圖展現:
可以首先利用名詞/動詞法找到模型中的領域對象。這種方法雖然極度地簡單與低級,然后在建立領域模型之初,是非常有效的手段。通過對用例的分析,大致可以獲得如下對象:Reader,Administrator,Book,Library Card以及Scanner。也許還有我們未曾發現的領域對象,這可以通過深入領域或與客戶交談來進一步獲得。我們可以嘗試著先獲得一個最簡單的領域模型,如下所示。
我們發現Administrator對象是一個孤立的對象,它與其他領域對象沒有產生任何關系。至少在借書、還書用例中,我們并不需要管理這個對象,可以考慮刪除它。模型中的Scanner對象非常特殊,表面上它會對Book與LibraryCard進行操作,然而對于Scanner而言,它并不關心操作的是什么對象,而只需要掃描條形碼,返回一個字符串。這是一種行為的體現。在整個系統中,Scanner對象可以只擁有一個,沒有屬性和狀態,僅提供掃描功能,或者說是服務,因此可以考慮將其定義為服務對象。
Reader與Book之間的關系非常直接,可是在引入LibraryCard之后,這個關系就顯得有些尷尬了。仔細閱讀用例,我們發現識別讀者的信息,是通過借書卡來獲取的。無論是借書還是還書,都可以通過借書卡來獲得讀者當前借閱的書。此時,讀者與書之間就不存在任何關系了,它已經進行了轉嫁。既然借書卡已經實現了對借書關系的管理,我們還有必要保留Reader對象嗎?閱讀用例,我們知道在掃描借書卡時,會獲得讀者的信息。雖然我們可以在借書卡中保留這些信息,但根據單一職責原則(SRP),將其專門封裝為一個對象仍有必要。
目前,借書卡僅僅維護了讀者當前借閱的書籍,那么,還需要維護借閱和返還的歷史記錄嗎?從用例的描述來看,并沒有這一功能。我們感到疑惑,因為保留歷史記錄是大多數系統所必備的。此時,客戶的答案就顯得格外重要。“哦,是的,我們需要查看歷史記錄!”這是客戶給我們的肯定答復。顯然,查看歷史記錄屬于另一個用例,它甚至可能屬于另外一個上下文(Context),例如關于“查詢”的上下文。然而,這一信息的來源卻來自于借閱與返回用例,我們應該將其識別出來。如果其他用例需要用到,我認為這個對象是需要共享的。細化后的領域模型如下:
通過對掃描行為的分析,我認為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的對象。
我們還要考慮許多業務規則,例如是否允許讀者借書的規則,是否超期的規則,計算罰款額度的規則。如果這些規則極為簡單,且不具有變化的可能,可以放在領域對象中。然而,一旦規則變得復雜,就會嚴重干擾相關領域對象的職責。根據職責分離的原則,我們可以提供專門的規則對象,即領域驅動設計中規格模式的應用。如果可能變化,我們甚至可以引入策略模式,對這些規則進行抽象。經過分析后得到的領域模型如下所示:
Reader實體對象和LibraryCard實體對象處于同一個聚合中,其中Reader為聚合根。BorrowingSpecification和ReturningSepecification均為值對象,并放在Reader聚合中。FineCalculator是一個服務對象,它會調用FineRule值對象獲得罰款規則,通過計算后返回Money值對象值。由于聚合的原因,原來FineCalculator與LibraryCard之間的關系已經修改為計算Reader的罰款。
BorrowingHistory和Book均為實體對象,而IdentityValidator則為服務對象,負責驗證掃描碼。
接下來需要為領域對象選擇資源庫(Repository)。在領域模型中,只有Reader、BorrowingHistory和Book三個實體為聚合根對象,因此只需要為這三個對象建立資源庫對象即可。
由于需求較為簡單,建立的領域模型已經比較完善,我們可以著手編碼,對這些模型進行驗證。本文沒有考慮限定上下文的情況,我希望未來的文章能夠以真實的案例對此進行表述。整體而言,根據這個案例,我們已經能夠初步領略領域驅動設計的基本步驟。
鄭重聲明:本文版權歸原作者所有,轉載文章僅為傳播更多信息之目的,如作者信息標記有誤,請第一時間聯系我們修改或刪除,多謝。