上部分模型驅動設計的構造塊為維護模型和實現之間的關係打下了基礎。在開發過程中使用一系列成熟的基本構造塊並運用一致的語言,能夠使開發工作更加清晰而有條理。
我們面臨的真正挑戰是找到深層次的模型,這個模型不但能夠捕捉到領域專家的微妙的關注點,還可以驅動切實可行的設計。我們的最終目的是開發出能夠捕捉到領域深層含義的模型。以這種方式設計出來的軟體不但更貼近領域專家的思維方式,而且能更好地滿足使用者的需求。
要想成功地開發出實用的模型,需要注意以下三點:
(1)複雜巧妙的領域模型是可以實現的,也是值得我們去花費力氣實現的。
(2)這樣的模型離開不斷的重構是很難開發出來的,重構需要領域專家和熱愛學習領域知識的開發人員密切參與進來。
(3)要實現並有效地運用模型,需要精通設計技巧。
重構的層次
重構就是在不改變軟體功能的前提下重新設計它。開發人員無需在著手之前做出詳細的設計決策,只需要在開發過程中不斷小幅調整設計即可,這不但能夠保證軟體原有的功能不變,還可以使整個設計更加靈活易懂。
然而,幾乎所有關於重構的文獻都專注於如何機械地修改程式碼,以使其更具有可讀性或在非常細節的層次上有所改進。如果開發人員能夠看準時機,利用成熟的設計模式進行開發,那麼「通過重構得到模式」這種方式就可以讓重構過程更上一層樓。不過,這依然是從技術視角來評估設計的質量。
有些重構能夠極大地提高系統的可用性,它們要麼源於對領域的新認知,要麼能夠通過程式碼清晰地表達出模型的含義。這種重構不能取代設計模式重構和程式碼細節重構,這兩種重構應該持續進行。但是前者新增了一種重構層次:為實現更深層次模型而進行的重構。在深入理解領域的基礎上進行重構,通常需要實現一系列的程式碼細節重構,但這麼做絕不僅僅是為了改程序式碼狀態。相反,程式碼細節重構是一組操作方便的修改單元,通過這些重構可以得到更深層次的模型。其目標在於:開發人員通過重構不僅能夠了解程式碼實現的功能,還能明白箇中原因,並把它們與領域專家的交流聯絡起來。
與所有的探索活動一樣,建模本質上是非結構化的。要跟隨學習與深入思考所指引的一切道路,然後據此重構,才能得到更深層次的理解。
深層模型
物件分析的傳統方法是先在需求檔案中確定名詞和動詞,並將其作為系統的初始物件和方法。這種方式太過於簡單,只適用於教導初學者如何進行物件建模。事實上,初始模型通常都是基於對領域的淺顯認知而構建的,既不成熟也不夠深入。
深層模型能夠穿過領域表象,清楚地表達出領域專家們的主要關注點以及最相關知識。以上定義並沒有涉及抽象。事實上,深層模型通常含有抽象元素,但在切中問題核心的關鍵位置也同樣會出現具體元素。
恰當反映領域的模型通常都具有功能多樣性、簡單易用和解釋力強的特性。這種模型的共同之處在於:它們提供了一種業務專家青睞的簡單語言,儘管這種語言可能也是抽象的。
深層模型/柔性設計
在不斷重構的過程中,設計本身也需要支援重構所帶來的變化。後面部分將探討如何使設計更易於使用,不但方便修改還能夠簡單地將其與系統其他部分整合。
設計自身的某些特性就可以使其更易於修改和使用。這些特性並不複雜,卻很有挑戰性。
幸運的是,如果每次對模型和程式碼所進行的修改都能反映出對領域的新理解,那麼通過不斷的重構就能給系統最需要修改的地方增添靈活性,並找到簡單快捷的方式來實現普通的功能。戴久了的手套在手指關節處會變得柔軟;而其他部分則依然硬實,可起到保護作用。同樣道理,用這種方式來進行建模和設計時,雖然需要反覆嘗試、不斷改正錯誤,但是對模型和設計的修改卻因此而更容易實現,同時反覆的修改也能讓我們越來越接近柔性設計。
柔性設計除了便於修改,還有助於改進模型本身。Model- Driven Design 需要以下兩個方面的支援:深層模型使設計更具有表現力;同時,當設計的靈活性可以讓開發人員進行試驗,而設計又能清晰地表達出領域含義時,那麼這個設計實際上就能夠將開發人員的深層理解反饋到整個模型發現的過程。這段反饋迴路是很重要的,因為我們所尋求的模型並不僅僅只是一套好想法,它還應該是構建系統的基礎。
發現過程
要想建立出確實能夠解決當前問題的設計,首先必須擁有可捕捉到領域核心概念的模型。文章後面會介紹如何主動搜尋這些概念,並將它們融入設計中。
由於模型和設計之間具有緊密的關係,因此如果程式碼難於重構,建模過程也會停滯不前。柔性設計部分將會探討如何為軟體開發者(尤其是為你自己)編寫軟體,以使開發人員能夠高效地擴充套件和修改程式碼。這一設計過程與模型的進一步精化是密不可分的。它通常需要更高階的設計技巧以及更嚴格的模型定義。
需要富有創造力,不斷地嘗試,不斷地發現問題才能找到合適的方法為你所發現的領域概念建模,但有時也可以借用別人已建好的模式。文章後面將會討論「分析模式」和「設計模式」的應用。這些模式並不是現成的解決方案,但是它們可以幫助我們消化領域知識並縮小研究範圍。
一、突破
重構的投入與回報並非呈線性關係。通常,小的調整會帶來小的回報,小的改進也會積少成多。小改進可以防止系統退化,成為避免模型變得陳腐的第一道防線。但是,有些最重要的理解也會突然出現,給整個專案帶來巨大的衝擊。
可以確定的是,專案團隊會積累、消化知識,並將其轉化成模型。微小的重構可能每次只涉及一個物件,在這裡加上一個關聯,在那裡轉移一項職責。然而,一系列微小的重構會逐漸匯聚成深層模型。
一般來說,持續重構讓事物逐步變得有序。程式碼和模型的每一次精化都讓開發人員有了更加清晰的認識。這使得理解上的突破成為可能。之後,一系列快速的改變得到了更符合使用者需要並更加切合實際的模型。其功能性及說明性急速增強,而複雜性卻隨之消失。
這種突破不是某種技巧,而是一個事件。它的困難之處在於你需要判斷髮生了什麼,然後再決定如何處理。
重構的原則是始終小步前進,始終保持系統正常運轉。
機遇
當突破帶來更深層的模型時,通常會令人感到不安。與大部分重構相比,這種變化的回報更多,風險也更高。而且突破出現的時機可能很不合時宜。
過渡到真正的深層模型需要從根本上調整思路,並且對設計做大幅修改。
關注根本
不要試圖去製造突破,那隻會使專案陷入困境。通常,只有在實現了許多適度的重構後才有可能出現突破。在大部分時間裡,我們都在進行微小的改進,而在這種連續的改進中模型深層含義也會逐漸顯現。
要為突破做好準備,應專注於知識消化過程,同時也要逐漸建立起健壯的 Ubiquitous Language。尋找那些重要的領域概念,並在模型中清晰地表達出來。精化模型,使其更具有柔性。提煉模型。利用這些更容易掌握的手段使模型變得更清晰,這通常會帶來突破。
不要猶豫著不去做小的改進,這些改進即使脫離不開常規的概念框架,也可以逐漸加深我們對模型的理解,不要因為好高騖遠而使專案陷入困境。只要隨時注意可能出現的機會就夠了。
二、將隱式概念轉變為顯式概念
深層模型之所以強大是因為它包含了領域的核心概念和抽象,能夠以簡單靈活的方式表達出基本的使用者活動、問題以及解決方案。深層建模的第一步就是要設法在模型中表達出領域的基本概念。隨後,在不斷消化知識和重構的過程中,實現模型的精化。但是實際上這個過程是從我們識別出某個重要概念並且在模型和設計中把它顯式地表達出來的那個時刻開始的。
若開發人員識別出設計中隱含的某個概念或是在討論中受到啟發而發現一個概念時,就會對領域模型和相應的程式碼進行許多轉換,在模型中加入一個或多個物件或關係,從而將此概念顯式地表達出來。
1、概念挖掘
開發人員必須能夠敏銳地捕捉到隱含概念的蛛絲馬跡,但有時他們必須主動尋找線索。要挖掘出大部分的隱含概念,需要開發人員去傾聽團隊語言、仔細檢查設計中的不足之處以及與專家觀點相矛盾的地方、研究領域相關文獻並且進行大量的實驗。
1、傾聽語言
傾聽領域專家使用的語言。有沒有一些術語能夠簡潔地表達出複雜的概念?他們有沒有糾正過你的用詞(也許是很委婉的提醒)?當你使用某個特定詞語時,他們臉上是否已經不再流露出迷惑的表情?這些都暗示了某個概念也許可以改進模型。
這不同於原來的「名詞即物件」概念。聽到新單詞只是個開頭,然後我們還要進行對話、消化知識,這樣才能挖掘出清晰實用的概念。如果使用者或領域專家使用了設計中沒有的詞彙,這就是個警告訊號。而當開發人員和領域專家都在使用設計中沒有的詞彙時,那就是一個倍加嚴重的警告訊號了。
2、檢查不足之處
你所需的概念並不總是浮在表面上,也絕不僅僅是通過對話和檔案就能讓它顯現出來。有些概念可能需要你自己去挖掘和創造。要挖掘的地方就是設計中最不足的地方,也就是操作複雜且難於解釋的地方。每當有新的需求時,似乎都會讓這個地方變得更加複雜。
有時,你很難意識到模型中丟失了什麼概念。也許你的物件能夠實現所有的功能,但是有些職責的實現卻很笨拙。而有時,你雖然能夠意識到模型中丟失了某些東西,但是卻無法找到解決方案。
這個時候,你必須積極地讓領域專家參與到討論中來。如果你足夠幸運,這些專家可能會願意一起思考各種想法,並通過模型來進行驗證。如果你沒那麼幸運,你和你的同事就不得不自己思索出不同的想法,讓領域專家對這些想法進行判斷,並注意觀察專家的表情是認同還是反對。
3、思考矛盾之處
由於經驗和需求的不同,不同的領域專家對同樣的事情會有不同的看法。即使是同一個人提供的資訊,仔細分析後也會發現邏輯上不一致的地方。在挖掘程式需求的時候,我們會不斷遇到這種令人煩惱的矛盾,但它們也為深層模型的實現提供了重要線索。有些矛盾只是術語說法上的不一致,有些則是由於誤解而產生的。但還有一種情況是專家們會給出互相矛盾的兩種說法。
要解決所有矛盾是不太現實的,甚至是不需要的。然而,即使不去解決矛盾,我們也應該仔細思考對立的兩種看法是如何同時應用於同一個外部現實的,這會給我們帶來啟示。
4、查閱書籍
5、嘗試,再嘗試
我們其實別無選擇。只有不斷嘗試才能瞭解什麼有效什麼無效。企圖避免設計上的食物將會導致開發出來的產品質量低劣,因為沒有更多的經驗可用來借鑑,同時也會比進行一系列快速試驗更加費時。
2、如何為那些不太明顯的概念建模
物件導向正規化會引導我們去尋找和創造特定型別的概念。所有事物及其操作行為是大部分物件模型的主要部分。它們就是物件導向設計入門所講到的「名詞和動詞」。但是,其他重要類別的概念也可以在模型中顯式地表現出來。
1、顯式的約束
約束是模型概念中非常的類別。它們通常是隱含的,將它們顯式地表現出來可以極大地提高設計質量。
有時,約束很自然地存在於物件或方法中。Bucket(桶)物件必須滿足一個固定規則——內容(contents)不能超過它的容量(capacity)。
public class Bucket { private float capacity; private float contents; public Bucket() { } public void PourIn(float addedVolumn) { if (contents + addedVolumn > capacity) { contents = capacity; } else { contents = contents + addedVolumn; } } }
這裡的邏輯非常簡單,規則也很明顯。但是不難想象,在更復雜的類中這個約束可能會丟失。讓我們把這個約束提取到一個單獨的方法中,並用清晰直觀的名稱來表達它的意義。
public class Bucket { private float capacity; private float contents; public Bucket() { } public void PourIn(float addedVolumn) { var volumnPresent = contents + addedVolumn; contents = ConstrainedToCapacity(volumnPresent); } /// <summary> /// 容量限制 /// </summary> /// <param name="volumnPlacedIn"></param> /// <returns></returns> private float ConstrainedToCapacity(float volumnPlacedIn) { if (volumnPlacedIn > capacity) { return capacity; } else { return volumnPlacedIn; } } }
這兩個版本的程式碼都實施了約束,但是第二個版本與模型的關係更加明顯(這也是 Model- Driven Design 的基本需求)。這個規則十分簡單,使用最初形式的程式碼也很容易理解,但如果要是執行的規則比較複雜的話,它們就會想=像所有隱式概念一樣被約束的物件或操作淹沒掉。
將約束條件提取到其自己的方法中,這樣就可以通過方法名來表達約束的含義,從而在設計中顯式地表現出來這條約束。現在這個約束條件就是一個「有名有姓」的概念了,我們可以用它的名字來討論它。這種方式也為約束的擴充套件提供了空間。比這更復雜的規則很容易就產生比其呼叫者更長的程式碼方法。這樣,呼叫者就可以簡單一些,並且只專注於處理自己的任務,而約束條件則可以根據需要進行擴充套件。
這種獨立方法為約束預留了一定的增加空間,但是在很多時候,約束條件是無法用單獨的方法來輕鬆表達的。或者,即使方法自身能夠保持其簡單性,但它可能也會呼叫一些資訊,但對於物件的主要職責而言,這些資訊毫無用處。這種規則可能就不適合放到現有物件中。
下面是一些警告訊號,表明約束的存在正在擾亂其「宿主物件」(Host Object)的設計。
(1)計算約束所需的資料從定義上看並不屬於這個物件。
(2)相關規則在多個物件中出現,造成了程式碼重複或導致不屬於同一族的物件之間產生了繼承關係。
(3)很多設計和需求討論是圍繞這些約束進行的,而在程式碼實現中,它們卻隱藏在過程程式碼中。
如果約束的存在掩蓋了物件的基本職責,或者如果約束在領域中非常突出但在模型中卻不明顯,那麼就可以將其提取到一個顯式的物件中,甚至可以把它建模為一個物件和關係的集合。
2、將過程建模為領域物件
首先要說明的是,我們都不希望過程變成模型的主要部分。物件是用來封裝過程的,這樣我們只需考慮物件的業務目的或意圖就可以了。
在這裡,我們討論的是存在於領域中的過程,我們必須在模型中把這些過程表示出來。否則當這些過程顯露出來時,往往會使物件設計變得笨拙。
如果過程的執行有多種方式,那麼我們也可以用另一種方法來處理它,那就是將演演算法本身或其中關鍵部分分放到一個單獨的物件中。這樣,選擇不同的過程就變成選擇不同的物件,每個物件都表示一種不同的 Strategy。
過程是應該被顯式表達出來,還是應該被隱藏起來?區分的方法很簡單:它是經常被領域專家提起呢,還是僅僅被當作計算機程式機制的一部分?
約束和過程是兩大類模型概念,當我們用物件導向語言程式設計時,不會立即想到它們,然而它們一旦被我們視為模型元素,就真的可以讓我們的設計更為清晰。
有些類別的概念很實用,但它們可應用的範圍要窄很多。有一個特殊但常用的概念——規格(specification)。「規格」提供了用於表達特定型別的規則的精確方式,它把這些規則從條件邏輯中提取出來,並在模型中把它們顯式地表示出來。
3、模式:Specification
業務規則通常不適合作為 Entity 或 Value Object 的職責,而且規則的變化和組合也會掩蓋領域物件的基本含義。但是將規則移出領域層的結果會更糟糕,因為這樣一來,領域程式碼就不再表達模型了。
邏輯程式設計提供了一種概念,即「謂詞」這種可分離、可組合的規則物件,但是要把這種概念用物件完全實現是很麻煩的。謂詞是指計算結果為「真」或「假」的函數,並且可以通過操作符(如AND和OR)把它們連線起來以表達更復雜的規則。同時,這種概念過於通用,在表達設計意圖方面,它的針對性不如專門的設計那麼好。
幸運的是,我們並不真正需要完全實現邏輯程式設計即可從中受益。大部分規則可以歸類為幾種特定的情況。我們可以借用謂詞概念來建立可計算出布林值的特殊物件。那些難於控制的測試方法可以巧妙地擴充套件出自己的物件。它們都是些小的真值測試,可以提取到單獨的 Value Object 中。而這個新物件則可以用來計算另一個物件,看看謂詞對那個物件的計算是否為「真」。
換言之,這個新物件就是一個規格。Specification(規格)中宣告的是限制另一個物件狀態的約束,被約束物件可以存在,也可以不存在。Specification 有多種用途,其中一種體現了最基本的概念,這種用途是:Specification 可以測試任何物件以檢驗它們是否滿足指定的標準。
因此:
為特殊目的建立為此形式的顯式的 Value Object。Specification 就是一個謂詞,可用來確定物件是否滿足某些標準。
Specification 將規則保留在領域層。由於規則是一個完備的物件,所以這種設計能夠更加清晰地反映模型。利用工廠,可以用來自其他資源的資訊對規格進行設定。之所以用工廠,是為了避免實體直接存取這些資源,因為這樣會使得實體與這些資源發生不正確的關聯。
4、Specification 的應用與實現
Specification 最有價值的地方在於它可以將看起來完全不同的應用功能統一起來。出於以下3個目的中的一個或多個,我們可能需要指定物件的狀態。
(1)驗證物件,檢查它是否能滿足某些需求或者是否已經為實現某個物件做好了準備。
(2)從集合中選擇一個物件。
(3)指定在建立新物件時必須滿足某種需求。
這三種用法(驗證、選擇和根據要求來建立)從概念層面上來講是相同。如果沒有諸如 Specification 這樣的模式,相同的規則可能會表現為不同的形式,甚至有可能是相互矛盾的形式。這就會喪失概念上的統一性。通過應用 Specification 模式,我們可以使用一致的模型,儘管在實現時可能需要分開處理。
驗證
規格的最簡單用法是驗證,這種用法也最能直觀地展示出它的概念。
選擇(或查詢)
驗證是對一個獨立的物件進行測試,檢查它是否滿足某些標準,然後客戶可能根據驗證的結果來採取行動。另一種常見的需求是根據某些標準從物件集合中選擇一個子集。Specification 概念同樣可以在此應用,但是現實問題會有所不同。
在典型的業務系統中,資料很可能會儲存在關聯式資料庫中。關聯式資料庫具有強大的查詢能力。我們如何才能充分利用這種能力來有效解決這一問題,同時又能保留Specification模型呢?Model-Driven Design 要求模型與實現保持同步,但它同時也讓我們可以自由選擇能夠準確捕捉模型意義的實現方式。
一些物件關係對映框架提供了用模型物件和屬性來表達查詢的方式,並在基礎設施層中建立實際的SQL語句。
根據要求來建立(生成)
我們可以使用描述性的 Specification 來定義生成器的介面,這個介面就顯式地約束了生成器產生的結果。這種方法具有以下幾個優點。
(1)生成器的實現與介面分離。Specification 宣告了輸出的需求,但沒有定義如何得到輸出結果。
(2)介面把規則顯式地表示出來,因此開發人員無需理解所有操作細節即可知曉生成器會產生什麼結果,而如果生成器是採用過程化的方式定義的,那麼要想預測它的行為,唯一的途徑就是在不同的情況下執行或去研究每行程式碼。
(3)介面更為靈活,或者說我們可以增加其靈活性,因為需求由客戶給出,生成器唯一的職責就是實現 Specification 中的要求。
(4)最後一點也很重要。這種介面更加便於測試,因為介面顯式地定義了生成器的輸入,而這同時也可用來驗證輸出。也就是說,傳入生成器介面的用於約束建立過程的同一個 Specification 也可發揮其驗證的作用(如果實現方式能夠支援這一點的話),以保證被建立的物件是正確的。
根據要求來建立可以是從頭建立全新物件,也可以是設定已有物件來滿足 Specification。
三、柔性設計
軟體的最終目的是為使用者服務。但首先它必須為開發人員服務。在強調重構的軟體開發過程中尤其如此。
當具有複雜行為的軟體缺乏良好的設計時,重構或元素的組合會變得很困難。一旦開發人員不能十分肯定地預知計算的全部,就會出現重複。當設計元素都是整塊的而無法重新組合的時候,重複就是一種必然的結果。我們可以對類和方法進行分解,這樣可以更好地重用它們,但這些小部分的行為又變得很難跟蹤。如果軟體沒有一個條理分明的設計,那麼開發人員不僅不願意仔細地分析程式碼,他們更不願意修改程式碼,因為修改程式碼會產生問題——要麼加重了程式碼的混亂狀態,要麼由於某種未預料的依賴而破壞了某些東西。在任何一種系統中,這種不穩定性使我們很難開發出豐富的功能,而且限制了重構和迭代式的精化。
為了使專案能夠隨著開發工作的進行加速前進,而不會由於它自己的老化停滯不前,設計必須要讓人們樂於使用,而且易於做出修改。這就是柔性設計(supple design)。
柔性設計是對深層模型的補充。一旦我們挖掘出隱式概念,並把它們顯示地表達出來,就有了原料。通過迭代迴圈,我們可以把這些原料打造成有用的形式:建立的模型能夠簡單而清晰地捕獲主要關注點;其設計可以讓客戶開發人員真正使用這個模型。在設計和程式碼的開發過程中,我們將獲得新的理解,並通過這些理解改善模型概念。
我們一次又一次回到迭代迴圈中,通過重構得到更深刻的理解。但我們究竟要獲得什麼樣的設計?在這個過程中應該進行哪些試驗?這就是下面要討論的內容。
很多過度設計藉著靈活性的名義而得到合理的外衣。但是,過多的抽象層和間接設計常常成為專案的絆腳石。看一下真正為使用者帶來強大功能的軟體設計,常常會發現一些簡單的東西。簡單並不容易做到。為了把建立的元素裝配到複雜系統中,而且在裝配之後仍然能夠理解它們,必須堅持模型驅動的設計方法,與此同時還要堅持適當嚴格的設計風格。要建立或使用這樣的設計,可能需要我們掌握相對熟練的設計技巧。
柔性設計能夠揭示深層次的底層模型,並把它潛在的部分明確地展示出來。客戶開發人員可以靈活地使用一個最小化的、鬆散耦合的概念集合,並用這些概念來表示領域中的眾多場景。設計元素非常地組合到一起,其結果也是健壯的,可以被清晰地刻畫出來,而且也是可以預知的。
早期的設計版本通常達不到柔性設計的要求。由於專案的時間期限和預算的緣故,很多設計一直就是僵化的。但是,當複雜性阻礙了專案的前進時,就需要仔細修改最關鍵、最複雜的地方,使之變成一個柔性設計,這樣才能突破複雜性帶給我們的限制,而不會陷入遺留程式碼維護的麻煩中。
設計這樣的軟體並沒有公式,運用一些模式可能獲得柔性設計。
1、模式:Intention-Revealing Interfaces (釋意介面)
在領域驅動的設計中,我們希望看到有意義的領域邏輯。如果程式碼只是在執行規則後得到結果,而沒有把規則顯式地表達出來,那麼我們就不得一步一步地去思考軟體的執行步驟。那些只是執行程式碼然後給出結果的計算——沒有顯式地把計算邏輯表達出來,也有同樣的問題。如果不把程式碼與模型清晰地聯絡起來,我們很難理解程式碼的執行效果,也很難預測修改程式碼的影響。
物件的強大功能是它能夠把所有這些細節封裝起來,如此一來,客戶程式碼就能夠很簡單,而且可以用高層概念來解釋。
但是,客戶開發人員想要有效地使用物件,必須知道物件一些資訊,如果介面沒有告訴開發人員這些資訊,那麼他就必須深入研究物件的內部機制,以便理解細節。這就失去了封裝的大部分價值。我們需要避免出現「認識過載」的問題。如果客戶開發人員必須總是思考元件工作方式的大量細節,那麼就無暇理清思路來解決客戶設計的複雜性。
如果開發人員為了使用一個元件而必須要去研究它的實現,那麼就失去了封裝的價值。當某個人開發的物件或操作被別人使用時,如果使用這個元件的新開發者不得不根據其實現來推測其用途,那麼他推測出來的可能並不是那個操作或類的主要用途。如果不是那個元件的用途,雖然程式碼暫時可以工作,但設計的概念基礎已經被無用,兩位開發人員的意圖也是背道而馳。
當我們把概念顯式地建模為類或方法時,為了真正從中獲取價值,必須為這些程式元素賦予一個能夠反映出其概念的名字。類和方法的名稱為開發人員之間的溝通創造了很好的機會,也能夠改善系統的抽象。
所有複雜的機制都應該封裝到抽象介面的後面,介面只表明意圖,而不是表明方法。
在領域的公共介面中,可以把關係和規則表述出來,但不要說明規則是如何實施的;可以把事件和動作描述出來,但不要描述它們是如何執行的;可回憶給出方程式,但不要給出解方程式的數學方法。可以提出問題,但不要給出獲取答案的方法。
整個子領域可以被劃分到獨立的模組中,並用一個表達了其用途的介面把它們封裝起來。這種方法可以使我們把注意力集中在專案上,並控制大型系統的複雜性,這個將在後面詳細討論。
2、模式:Side-Effect-Free Function(無副作用函數)
我們可以寬泛地把操作分為兩個大的類別:命令和查詢。查詢是從系統獲取資訊,查詢的方式可能只是簡單地存取變數中的資料,也可能是用這些資料執行計算。命令(也稱為修改器)是修改系統的操作(一個簡單的例子,設定變數)。任何對未來操作產生影響的系統狀態改變都可以稱為副作用。
為什麼人們會採用「副作用」這個詞來形容那些顯然是有意影響系統狀態的操作呢?這大概是來自於複雜系統的經驗。大多數操作都會呼叫其他的操作,而後者又會呼叫另外一些操作。一旦形成這種任意深度的巢狀,就很難預測呼叫一個操作將要產生的所有後果。第二層和第三層的影響可能並不是客戶開發人員有意為之,於是它們就變成了完全意義上的副作用。在一個複雜的設計中,元素之間的互動同樣也會產生無法預料的結果。副作用這個詞強調了這種互動的不可避免性。
多個規則的相互作用或計算的組合產生的結果是很難預測的。開發人員在呼叫一個操作時,為了預測操作的結果,必須理解它的實現以及它所呼叫的其他方法的實現。如果開發人員不得不「揭開介面的面紗」,那麼介面的抽象作用就受到了限制。如果沒有了可以安全地預見結果的抽象,開發人員就必須限制「組合爆炸」,這就限制了系統行為的豐富性。
在大多數軟體系統中,命令的使用都是不可避免的,但有兩種方法可以減少命令產生的問題。首先,可以把命令和查詢嚴格地放在不同的操作中。確保導致狀態改變的方法不返回領域資料,並儘可能保持簡單。在不引起任何可觀測到的副作用的方法中執行所有查詢和計算。
第二,總是有一些替代的模型和設計,它們不要求對現有物件做任何修改。相反,它們建立並返回一個 Value Object,用於表示計算結果。這是一種很常見的技術。
Value Object 是不可變的,這意味著除了建立期間呼叫的初始化程式之外,它們的所有操作都是函數。像函數一樣,Value Object 使用起來很安全,測試也很簡單。如果一個操作把邏輯或計算與狀態改變混合在一起,那麼我們就應該把這個操作重構為兩個獨立的操作。但從定義上來看,這種把副作用隔離到簡單的命令方法中的做法僅適用於 Entity 。在完成了修改和查詢的分離之後,可以考慮再進行一次重構,把複雜計算的職責轉移到 Value Object 中。通過派生出一個 Value Object (而不是改變現有狀態),或者通過把職責完全轉移到一個 Value Object 中,往往可以完全消除副作用。
因此:
儘可能把程式的放到函數中,因為函數只是返回結果而不產生明顯副作用的操作。嚴格地把命令(引起明顯的狀態改變的方法)隔離到不返回領域資訊的、非常簡單的操作中。當發現了一個非常適合承擔複雜邏輯職責的概念時,就可以把這個複雜邏輯移到 Value Object 中,這樣就可以進一步控制副作用。
3、模式:Assertion(斷言)
把複雜的計算封裝到 Side-Effect-Free Function 中可以簡化問題,但實體仍然會留有一些副作用的命令,使用這些 Entity 的人必須瞭解使用這些命令的後果。在這種情況下,使用 Assertion 可以把副作用明確地表示出來,使它們更易於處理。
確實,一條不包含複雜計算的命令只需檢視一下就能理解。但是,在一個軟體設計中,如果較大的部分是由較小部分構成的,那麼一個命令可能會呼叫其他命令。開發人員在使用高層命令時,必須瞭解每個底層命令所產生的後果,這時封裝也就沒有什麼價值了。而且,由於物件介面並不會限制副作用,因此實現相同介面的兩個子類可能會產生不同的副作用。使它們的開發人員需要知道哪個副作用是由哪個子類產生的,以便預測後果。這樣,抽象和多型也就失去了意義。
我們需要在不深入研究內部機制的情況下理解設計元素的意義和執行操作的後果。Intention-Revealing Interface 可以起到一部分作用,但這樣的介面只能非正式地給出操作的用途,這常常是不夠的。「契約式設計」(design by contract)向前推進了一步,通過給出類和方法的「斷言」使開發人員知道肯定會發生的結果。簡言之,「後置條件」描述了一個操作的副作用,也就是呼叫一個方法之後必然會產生的結果。「前置條件」就像是合同條款,即為了滿足後置條件而必須要滿足的前置條件。類的固定規則規定了在操作結束時物件的狀態。也可以把 Aggregate 作為一個整體來為它宣告固定規則,這些都是嚴格定義的完整性規則。
所有這些斷言都描述了狀態,而不是過程,因此它們更易於分析。類的固定規則在描述類的意義方面起到幫助作用,並且使客戶開發人員能夠更準確地預測物件的行為,從而簡化他們的工作。如果你確信後置條件的保證,那麼就不必考慮方法是如何工作的。斷言已經把呼叫其他操作的效果考慮在內了。
因此:
把操作的後置條件和類及 Aggregate 的固定規則表達清楚。如果在你的程式語言中不能直接編寫 Assertion ,那麼就把它們編寫成自動化的單元測試。還可以把它們寫到檔案或圖中(如果符合專案開發風格的話)。
尋找在概念上內聚的模型,以便使開發人員更容易推斷出預期的 Assertion,從而加快學習過程並避免程式碼矛盾。
4、模式:Conceptual Contour
有時,人們會對功能進行更細的分解,以便靈活地組合它們,有時卻要把功能合成大塊,以便封裝複雜性。有時,人們為了使所有類和操作都具有相似的規模而遵照一種一致的力度。這些方法都過於簡單了,並不能作為通用的規則。但使用這些方法的動機都來自於一系列基本的問題。
如果把模型或設計的所有元素都放在一個整體的大結構中,那麼它們的功能就會發生重複。外部介面無法給出客戶可能關心的全部資訊。由於不同的概念被混合在一起,它們的意義變得很難理解。
而另一方面,把類和方法分解開也可能是毫無意義的,這會使客戶更復雜,迫使客戶物件去理解各個細微部分是如何組合在一起的。更糟的是,有的概念可能會完全丟失。而且,粒度的大小並不是唯一要考慮的問題,我們還要考慮粒度是在哪種場合下使用的。
大部分領域都深深隱含著某種邏輯一致性,否則它們就形不成領域了。這並不是說領域就是絕對一致的,而且人們討論領域的方式肯定也不一樣。但是領域中一定存在著某種十分複雜的原理,否則建模就失去了意義。由於這種隱藏在底層的一致性,當我們找到一個模型,它與領域的某個部分特別溫和,這個模型很可能也會與我們後續發現的這個領域的其他部分一致。有時,新的發現可能與模型不符,在這種情況下,就需要對模型進行重構,以便獲取更深層的理解,並希望下一次新發現能與模型一致。
通過反覆重構最終會實現柔性設計,以上就是其中的一個原因。隨著程式碼不斷適應新理解的概念或需求,Conceptual Contour(概念輪廓)也就逐漸形成了。
從單個方法的設計,到類和 Module 的設計,再到大型結構的設計,高內聚低耦合這一對基苯原則都起著重要的作用。這兩條原則既適用於程式碼,也適用於概念。為了避免機械化地遵循它,我們必須經常根據我們對領域的直觀認識來調整技術思路。在做每個決定時,都要問自己:「這是根據當前模型和程式碼中的特定關係做出的權宜之計,還是反映了底層領域的某種輪廓?」
尋找在概念上有意義的功能單元,這樣可以使得設計既靈活又易懂。在做任何領域中,都有一些細節是使用者不感興趣的。把哪些沒必要分解或重組的元素作為一個整體,這樣可以避免混亂,並且使人們更容易看到那些真正需要重組的元素。
因此:
把設計元素(操作、介面、類和 Aggregate)分解為內聚的單元,在這個過程中,你對領域中一切重要劃分的直觀認識也要考慮在內。在連續的重構過程中觀察發生變化和保證穩定的規律性,並尋找能夠解釋這些變化模式的底層 Conceptual Contour。使模型與領域中那些一致的方面(正式這些方面使得領域成為一個有用的知識體系)相匹配。
我們的目標是得到一組可以在邏輯上組合起來的簡單介面,使我們可以用 Ubiquitous Language 進行合理的表述,並且使那些無關的選項不會分散我們的注意力,也不增加維護負擔。但這通常是通過重構才能得到的結果,很難在前期就實現。而且如果僅僅是從技術角度進行重構,可能永遠也不會出現這種結果。只用通過重構得到更深層的理解,才能實現這樣的目標。
5、模式:Standalone Class
互相依賴使模型和設計變得難以理解、測試和維護。而且,相互依賴很容易越積越多。
當然,每個關聯都是一種依賴,要想理解一個類,必須它與哪些物件有聯絡。與這個類有聯絡的其他物件還會與更多的物件發生聯絡,而這些聯絡也是必須要弄清楚的。每個方法的每個引數的型別也是一個依賴,每個返回值也都是一個依賴。
Module 和 Aggregate 的目的都是為了限制互相依賴的關係網。當我們識別出一個高度內聚的子領域並把它提取到一個 Module 中的時候,一組物件也隨之與系統的其他部分解除了聯絡,這樣就把互相聯絡的概念的數量控制在一個有限的範圍內。但是,即使系統分成了各個 Module ,如果不嚴格控制 Module 內部的依賴的話,那麼 Module 也一樣會讓我們耗費很多精力去考慮依賴關係。
即使是在 Module 內部,設計也會隨著依賴關係的增加而變得越來越難以理解。這加重了我們的思考負擔,從而限制了開發人員能處理的設計複雜度。隱式概念比顯式參照增加的負擔更大。
我們可以將模型一直精煉下去,直到每個剩下的概念關係都表示出概念的基本含義為止。在一個重要的子集中,依賴關係的個數可以減小到零,這樣就得到一個完全獨立的類,它只有很少的幾個基本型別和基礎哭概念。
隱式概念,無論是否已被識別出來,都與顯式參照一樣會加重思考負擔。雖然我們通常可以忽略像整數和字串這樣的基本型別值,但無法忽略它們所表示的意義。
我們應該對每個依賴關係提出質疑,直到證實它確實表示物件的基本概念為止。這個仔細檢查依賴關係的過程從提取模型概念本身開始。然後需要注意每個獨立的關聯和操作。仔細選擇模型和設計能夠大幅減少依賴關係——常常能減少到零。
低耦合是物件設計的一個基本要素。盡一切可能保持低耦合。把其他所有無關概念提取到物件之外。這樣類就變得完全獨立了,這就使得我們可以單獨地研究和理解它。每個這樣的獨立類都極大地減輕了因理解 Module 而帶來的負擔。
當一個類與它所在的模組中的其他類存在依賴關係時,比它與模組外部的類有依賴關係要好得多。同樣,當兩個物件具有自然的緊密耦合關係時,這兩個物件共同設計的多個操作實際上能夠把它們的關係本質明確地表示出來。我們的目標不是消除所有以來,而是消除所有不重要的依賴。當無法消除所有的依賴關係時,每清除一個依賴對開發人員而言都是一種解脫,使他們能夠集中精力處理剩下的依賴關係。
盡力把最複雜的計算提取到 Standalone Class 中,實現此目的一種是從存在大量依賴的類中將 Value Object 建模出來。
低耦合是減少概念過載的最基本方法。獨立的類是低耦合的極致。
消除依賴並不是說要武斷地把模型中的一切都簡化為基本型別,這樣只會削弱模型的表達能力。下面一個模式 Closure Operation (閉合操作)就是一種減小依賴的同時保持豐富介面的技術。
6、模式:Closure Operation(閉合操作)
兩個實數相乘,結果仍為實數。由於這一點永遠成立,因此我們說實數的「乘法運算是閉合的」:乘法運算的結果永遠無法脫離實數這個集合。當我們對集合中的任意兩個元素組合時,結果仍在這個集合中,這就叫閉合操作。
閉合的性質極大地簡化了對操作的理解,而且閉合操作的連結和組合也很容易理解。
因此:
在適當的情況下,在定義操作時讓它的返回型別與其引數的型別相同。如果實現者(implementer)的狀態在計算中會被用到,那麼實現者實際上就是操作的一個引數,因此引數和返回值應該與實現者具有相同的型別。這樣的操作就是在該型別的範例集合中的閉合操作。閉合操作提供了一個高層介面,同時又不會引入對其他該概念的任何依賴。
這種模式更常用於 Value Object 的操作。由於 Entity 的生命週期在領域中十分重要,因此我們不能為了解決某一問題而草率建立一個 Entity。有一些操作是 Entity 型別之下的閉合操作。例如,我們可以通過查詢一個 Employee(員工)物件來返回其主管。
在嘗試和尋找減少相互依賴並提高內聚的過程中,有時我們會遇到「半個閉合操作」這種情況。引數型別與實現者的型別一致,但返回型別不同;或者返回型別與接收者(receiver)的型別相同但引數型別不同。這些操作都不是閉合操作,但它們確實具有 Closure Of Operation 的某些優點。當沒有形成閉合操作的那個多出來的型別是基本型別或基礎類庫時,它幾乎與 Closure Of Operation 一樣減輕了我們的思考負擔。
以上的模式介紹了通用的設計風格和思考設計的方式。把軟體設計得意圖明顯、容易預測且富有表達力,可以有效地發揮抽象和封裝的作用。我們可以對模型進行分解,使得物件更易於理解和使用,同時仍具有功能豐富的、高階的介面。