6. 為領域模型Permission編碼 現在我們為賬戶子系統(AccountSubsystem)設計領域對象並編碼實現細節。 賬號、角色、許可權是賬戶子系統里已知的3個事物,而一個子系統裡面可以有多個內聚模型,所以我們首先要思考的問題是:以誰為聚合根創建第一個內聚模型? 與劃分子系統的思路一樣,我們 ...
6. 為領域模型Permission編碼
現在我們為賬戶子系統(AccountSubsystem)設計領域對象並編碼實現細節。
賬號、角色、許可權是賬戶子系統里已知的3個事物,而一個子系統裡面可以有多個內聚模型,所以我們首先要思考的問題是:以誰為聚合根創建第一個內聚模型?
與劃分子系統的思路一樣,我們以最簡單、最獨立的事物作為突破口。簡單是指事物在特定領域里的特征比較少,沒有那麼複雜。很明顯,許可權是最簡單、最獨立的,它不依賴於賬號、角色而獨立存在,而且從目前收集到的需求來看,許可權的特征只需要有名稱即可。所以我們嘗試以許可權(Permission)為聚合根創建第一個內聚模型。請各位註意,我在這裡用“嘗試”一詞表達要做的工作,因為我們並不能保證當前做的決策100%是對的,但是勇敢的去嘗試總比畏首畏尾、不敢邁出第一個步子、始終原地踏步要好的多。所以各位在實踐的時候,如果有了靈感、有了大致的思路,就算思路還不夠全面、不夠清晰,你也可以大膽的去嘗試,CA可以保證即便設計有誤也能及時修正。使用CA開發項目的過程就是不斷的在分析、設計、實踐、修正中反覆迭代的過程,最終你會提煉出與事物本質特征相符的領域模型。
在考慮將Permission作為聚合根後,我們依然要對這一決策提出質疑,要反問自己Permission是值對象還是實體對象。如果Permission是值對象,那麼它就不能作為聚合根了,因為聚合根必須是實體對象。使用CA做開發,我們要善於使用這種思考技巧:先根據腦海的“嗅覺”做出設計上的判斷,再反問自己各類問題以便驗證或推翻這項判斷。這種先做決策再試圖推翻的思考方式會帶給你意想不到的驚喜,如果你推翻不了它,證明所做的決策就是對的,反之就需要改進這項決策,然後再去想辦法推翻新的決策,一直到你找到無法推翻的決策為止。
判斷Permission是否為實體對象的依據之一就是外部事物是否需要直接找到它。這裡的外部事物是指"應用層"和"領域模型層里除Permission以外的領域對象"。首先,要判斷角色是否擁有某項許可權,我們肯定需要建立角色和許可權的引用關係,由此可以推斷出,許可權應該是需要被外部對象角色所直接引用的(註意,由於角色這一事物還沒有開始設計,所以這裡我們只是做的假設,輔助我們判斷Permission的設計)。另外,許可權的名稱、描述等信息需要由系統的使用者去直接填寫或更改,所以我們可以想象得到,應用層需要根據標識符獲取Permission對象,將其讀取後呈現相關信息給系統使用者查看(註意,我們這裡是藉助UI操作的方式來輔助我們判斷Permission是否為實體對象,再次聲明,領域模型的建立不僅僅是為了滿足UI操作,但是正確的領域模型一定可以完全滿足UI操作,因此,藉助它來幫助我們分析領域對象如何設計是可以的,只是註意要適度,不要局限於某一種UI操作來設計對象。)。所以我們判定Permission是實體對象,它具有成為聚合根的基本條件。
然後我們再思考,Permission是聚合根還是內聚成員?很明顯,Permission只能是聚合根,因為我們還無法從許可權事物里找出第二個相關的事物,Permission只能作為聚合根存在。至此,對Permission的初步分析工作就完成了,下麵貼出Permission的初期代碼並作出詳細說明:
1 using System; 2 3 using CodeArt.DomainDriven; 4 5 namespace AccountSubsystem 6 { 7 /// <summary> 8 /// 許可權對象 9 /// </summary> 10 [ObjectRepository(typeof(IPermissionRepository))] 11 [ObjectValidator(typeof(PermissionSpecification))] 12 public class Permission : AggregateRoot<Permission, Guid> 13 { 14 internal static readonly DomainProperty NameProperty = DomainProperty.Register<string, Permission>("Name"); 15 16 /// <summary> 17 /// 許可權名稱 18 /// </summary> 19 [PropertyRepository()] 20 [NotEmpty()] 21 [StringLength(2, 25)] 22 public string Name 23 { 24 get 25 { 26 return GetValue<string>(NameProperty); 27 } 28 set 29 { 30 SetValue(NameProperty, value); 31 } 32 } 33 34 35 internal static readonly DomainProperty MarkedCodeProperty = DomainProperty.Register<string, Permission>("MarkedCode"); 36 37 38 /// <summary> 39 /// <para>許可權的唯一標示,可以由用戶設置</para> 40 /// <para>可以通過唯一標示找到許可權對象</para> 41 /// <para>該屬性可以為空</para> 42 /// </summary> 43 [PropertyRepository()] 44 [StringLength(0, 50)] 45 public string MarkedCode 46 { 47 get 48 { 49 return GetValue<string>(MarkedCodeProperty); 50 } 51 set 52 { 53 SetValue(MarkedCodeProperty, value); 54 } 55 } 56 57 /// <summary> 58 /// 是否定義了標識碼 59 /// </summary> 60 public bool DeclareMarkedCode 61 { 62 get 63 { 64 return !string.IsNullOrEmpty(this.MarkedCode); 65 } 66 } 67 68 69 private static readonly DomainProperty DescriptionProperty = DomainProperty.Register<string, Permission>("Description"); 70 71 /// <summary> 72 /// <para>描述</para> 73 /// </summary> 74 [PropertyRepository()] 75 [StringLength(0, 200)] 76 public string Description 77 { 78 get 79 { 80 return GetValue<string>(DescriptionProperty); 81 } 82 set 83 { 84 SetValue(DescriptionProperty, value); 85 } 86 } 87 88 [ConstructorRepository()] 89 public Permission(Guid id) 90 : base(id) 91 { 92 this.OnConstructed(); 93 } 94 95 #region 空對象 96 97 private class PermissionEmpty : Permission 98 { 99 public PermissionEmpty() 100 : base(Guid.Empty) 101 { 102 this.OnConstructed(); 103 } 104 105 public override bool IsEmpty() 106 { 107 return true; 108 } 109 } 110 111 public static readonly Permission Empty = new PermissionEmpty(); 112 113 #endregion 114 } 115 }
這是我們第一個代碼示例,旨在讓各位熟領域對象的基本寫法。所以此處並沒有涉及到領域行為、對象引用關係、領域事件、移動領域對象等高級話題。
1)using CodeArt.DomainDriven; 表示引入CodeArt.DomainDriven命名空間,該命名空間提供了領域設計的技術支持。要使用該命名空間你需要在賬戶子系統中引用CodeArt.DomainDriven的程式集:
2)namespace AccountSubsystem 表示Permission對象處於賬戶子系統內。請註意子系統的命名約定:在子系統的實際名稱上追加Subsystem尾碼組成。例如:UserSubsystem(用戶子系統)、CarSubsystem(車輛子系統)。
3)我們在Permission的類定義里標記了特性標簽 [ObjectRepository(typeof(IPermissionRepository))] 指示對象是可以被倉儲的,並且Permission的倉儲介面是IPermissionRepository。但是請大家一定註意,我們已經決定了Permission是根對象,因此這個對象繼承自AggregateRoot<Permission, Guid>(這段代碼後文會有詳細說明),所以就算Permission沒有標記ObjectRepository特性,只要Permission繼承了AggregateRoot<Permission, Guid>這個基類,就表示Permission是聚合根,那麼它就是一定可以被倉儲保存的。那麼這個特性的意義何在?意義在於提高開發效率,縮短開發時間。只要當你對聚合根標記了ObjectRepository,那麼你就可以使用CA內置的ORM工具,自動化存儲Permission,你不需要寫一行代碼就可以實現保存Permission,甚至連表都不需要設計,CA的內置模塊會幫你搞定這一切。要使用ObjectRepository特性請引用程式集CodeArt.DomainDriven.DataAccess:
CodeArt.DomainDriven.DataAccess是CodeArt 3.0提供的新組件。與使用CA 2.0版本相比,程式員的工作量降低了50%。當然,你也可以不使用CA提供的ORM特性,自行編碼如何存儲對象,這點後文會有介紹。但是強烈建議你使用這一特性,隨著CA的發展,我們會逐步提升DataAccess的各項指標,你的項目同步更新CA新版本就可以享受我們的工作成果。
4)緊接著我們為Permission類又標記了[ObjectValidator(typeof(PermissionSpecification))]。正如其名,ObjectValidator表示對象驗證器,還記得我們在前文里說過“每個領域對象都具有驗證固定規則的能力”這個領域規則嗎?ObjectValidator就是用於對象驗證的,為對象標記這個特性並且傳入參數PermissionSpecification,就表示Permission對象需要滿足類型名稱為PermissionSpecification的規格。在PermissionSpecification的代碼里,我們會編碼定義規格的細節。CA強調每個對象都應該滿足1個或者多個需要滿足的規格,所以你可以傳入多個規格類型給ObjectValidator特性。當對象被提交給倉儲的時候,這些規格會被自動驗證。PermissionSpecification的代碼如下:
我們稍後會結合屬性規則驗證詳細講解PermissionSpecification里代碼的含義,現在請將思路放回到Permission代碼段里。
5)public class Permission : AggregateRoot<Permission, Guid>,這段代碼定義了Permission類,該類繼承自AggregateRoot<Permission, Guid>,這是一個泛型基類,第一個泛型參數傳入Permission類型即可,第二個泛型參數表示Permission這個聚合根的標識符類型,我們在這裡定義為Guid。由於聚合根也是實體對象,所以必須為聚合根指定標識符的類型。另外,使用CA做開發,聚合根都需要繼承自AggregateRoot<TRoot, TIdentity>基類,它實現了多項有關聚合根的技術細節,大家不必自己去實現IAggreateRoot介面。
6)internal static readonly DomainProperty NameProperty = DomainProperty.Register<string, Permission>("Name");這句代碼很關鍵,這是CA里註冊領域屬性定義的方式,從概念上講領域屬性是指能夠反映事物在某一領域本質特征的屬性。從代碼實現上來說,與普通屬性相比,領域對象要對領域屬性有更強的控制性,這體現在屬性什麼時候被更改、屬性是否為髒的(與數據倉儲里的數據不一樣就是髒的)、能夠以不破壞原有對象的代碼情況下擴展一個領域屬性、重寫或追加屬性的GET或SET方法等。這些CA都已經做了充分的支持,你只需要按照語法編寫定義領域屬性的代碼即可。
在說明該代碼之前,大家先搞清楚“領域屬性定義”和“領域屬性”的區別,定義是對領域屬性的特征描述,比如領域屬性的名稱為Name,這就是定義的一部分。我們這裡的代碼是說明如何定義領域屬性的。至於領域屬性的使用在後面的代碼段中會有說明。
首先,請註意訪問修飾符internal,這表示該領域屬性僅程式集內部可見,你也可以根據需要設置成為public、private,我們建議你在不知道如何選擇的時候就填寫private,確保領域屬性的定義僅對象內部可見。這裡補充一個對象思想的小技巧:不論是方法還是屬性使用私有定義意味著該對象不對外作出任何承諾,僅內部使用。對外承諾的越多(public修飾符)對象需要履行的義務就越多,就越複雜,複雜就容易出錯。因此儘可能的使用private,只在必要的時候使用public是一個良好的編程習慣。我們為Permission設計的領域屬性Name是internal而不是private是因為PermissionSpecification這個規格需要用到它(詳見之前的代碼貼圖),所以將原本私有的訪問修飾變為了程式集內部可見的。
static readonly 是必備的修飾符,表示領域屬性是靜態且不可改變的。定義它為靜態的是因為領域屬性是對事物某項特征的描述,學生的年齡就是屬於學生這個事物“按年計算存在的時間”的特征,是所有的學生實例都會有的特征,而不是某個學生獨有的。因此年齡的領域屬性為靜態的。
領域屬性的定義一旦給出就不可改變,我們可以擴展它的職責但不能抹去它的存在(改變屬性定義的指向也算是抹去之前屬性定義的存在)。因為事物的本質特征是不會被抹去的( 比如說,一個學生的年齡今天會有,難道明天就不見了?)。當然,也有可能由於我們設計上的錯誤造成了一個領域屬性不該存在,這時候你刪除該領域屬性相關的代碼就可以了,所以只要是設計好了的領域屬性定義就一定是靜態只讀的。
DomainProperty NameProperty 是領域屬性定義的聲明,DomainProperty是領域屬性定義的類型,所有領域屬性定義都應該使用這個類型。請註意領域屬性定義名稱NameProperty,CA規定所有的領域屬性定義必須在真實的屬性名後追加Porperty,也就是XXXProperty的格式表示XXX領域屬性的定義,這是使用CA做開發需要遵守的原則之一。
NameProperty = DomainProperty.Register<string, Permission>("Name"); Register是DomainProperty提供的靜態泛型方法,該方法的返回值是DomainProperty的實例。第一個泛型參數string,表示屬性值的類型為string,第二個泛型參數Permission表示該領域屬性屬於類型為Permission的領域對象。參數“Name”表示屬性的名稱為Name。
通俗的講,我們使用 internal static readonly DomainProperty NameProperty = DomainProperty.Register<string, Permission>("Name"); 這句代碼為領域對象Permission註冊了一個Name屬性的定義,這個定義里說明瞭領域屬性的名稱為Name,領域屬性的值類型為字元串,領域屬性屬於領域對象Permission。關於這方面更多細節的討論請繼續看後文。
7)下麵來看代碼段:
/// <summary>
/// 許可權名稱
/// </summary>
[PropertyRepository()]
[NotEmpty()]
[StringLength(2, 25)]
public string Name
{
get
{
return GetValue<string>(NameProperty);
}
set
{
SetValue(NameProperty, value);
}
}
僅描述事物的特征但不去運用它是沒有意義的。上述代碼就將領域屬性定義NameProperty應用到了Permission實例上。令Permission類型一旦實例化了,就具備提供自身名稱的能力。Name就是Permission的領域屬性。請註意Name屬性的Get和Set方法,由於我們使用了領域屬性的概念,所以當你為領域對象編寫領域屬性代碼的時候,請直接使用語法GetValue<T>(DomainProperty) 和 SetValue(DomainProperty, value) 來實現領域屬性的Get和Set方法,不要在這兩個方法里編寫其他的代碼,這也是使用CA的原則之一。GetValue的泛型參數T表示需要獲取屬性的值的類型,DomainProperty參數表示領域屬性定義(在這裡是之前編寫的NameProperty)。SetValue這個方法的調用比較簡單,在此不過多的說明。
我們再來討論這項屬性被標記的3個特性。請註意,這3個特性都是用來定義Name的,我們之前提到過,我們使用DomainProperty類型來表達領域屬性的定義,那麼為什麼這裡的3個特性被標記在Name上,而不是直接標記在NameProperty上呢?例如:
/// <summary>
/// 許可權名稱
/// </summary>
[PropertyRepository()]
[NotEmpty()]
[StringLength(2, 25)]
internal static readonly DomainProperty NameProperty = DomainProperty.Register<string, Permission>("Name");
事實上你完全可以這樣做,這也是CA提供的標準寫法。只是考慮到程式員們在其他框架里習慣對屬性直接打特性了,所以CA才提供了相容性的寫法,即:直接在屬性上標記特性以便更詳細的描述領域屬性的定義。在某些情況下,你只能將特性標記在領域屬性定義上,比如在為對象靜態擴展屬性時。因為我們第一個代碼示例還未涉及到這方面的話題,所以我們的代碼里是按照程式員的習慣將特性寫在領域屬性上,而非領域屬性定義上。
[PropertyRepository()]特性,與之前提到的ObjectRepository類似,該特性由CodeArt.DomainDriven.DataAccess提供,表示該屬性是可以被倉儲的。也就是說你為屬性打上這個特性,CA的ORM模塊在存儲對象的時候就會考慮將該屬性的值存入到倉儲里。該特性不是領域模型必備特性,如果你要自己實現對象的持久化操作可以不必標記該特性,但這往往沒有必要。
[NotEmpty()]該特性指示屬性的值是不能為空的,請註意,之前我們討論過Not Null的話題,在CA的領域世界里,所有領域對象以及領域屬性值都不能有null值,所以就算你不寫NotEmpty也表示Name屬性不能有null值,但是NotEmpty表示的意思是不允許為空值,對於字元串來說""或者string.Empty表示的就是空值。因此這裡的意思是字元串屬性Name不允許是空的,必須有至少1個或1個以上的字元。
[StringLength(2, 25)]這個特性大家肯定都能理解,表示字元串的最小長度和最大長度。
這裡的NotEmpty和StringLength特性,都是之前提到的固定規則的一種體現,在CA里,你可以為對象標記ObjectValidator特性併為這個特性傳入多項規格標準(實現IObjectValidator的介面就可以成為規格標準)來驗證對象級別的合法性,也可以對領域屬性直接以標記特性的方式定義屬性需要滿足的規則。為屬性打特性實現屬性驗證這點並不是CA特有的方式,許多其他框架也有類似的機制,因此不再過多說明。
8)以上介紹了領域屬性的相關話題,現在我們回頭看看之前提到的對象規格的代碼實現:
[SafeAccess]
internal sealed class PermissionSpecification : ObjectValidator<Permission>
{
public PermissionSpecification()
{
}
protected override void Validate(Permission obj, ValidationResult result)
{
Validator.CheckPropertyRepeated(obj, Permission.NameProperty, result);
Validator.CheckPropertyRepeated(obj, Permission.MarkedCodeProperty, result);
}
}
[SafeAccess]由命名空間CodeArt.Concurrent提供並位於CodeArt程式集內。該特性指示對象是併發訪問安全的,也就是多線程訪問安全的。任何類型只要標記這個特性,當CA內部在構造該類型的實例時,就會緩存實例。當需要下次創建時直接返回該實例,因為對象是併發訪問安全的,只需要一個實例即可。因此,當你設計的類型是併發訪問安全的同時你也希望它以單例的形式出現,那麼就可以為類型標記該特性。這裡的PermissionSpecification對象沒有任何屬性成員,內部的方法實現也與狀態無關,因此可以作為單例的形式出現,所以標記了該特性。該特性可以提高應用程式性能,重覆使用同一個對象。
PermissionSpecification繼承了泛型基類ObjectValidator<Permission>,這是對象驗證器的基礎類,繼承這個對象可以節省大家處理其他細節的時間。泛型參數里記得填寫聚合根的類型,也就是Permission。
protected override void Validate(Permission obj, ValidationResult result) 是派生類必須重寫的方法,你要在這裡面編寫驗證邏輯。 ValidationResult表示的是驗證結果的對象,你可以使用這個對象追加錯誤信息。在本示例里只是簡單的將該參數傳遞給了Validator使用。
由於我們經常會遇到某個屬性不能重覆出現的需求(比如用戶名不能重覆等),因此CA提供的Validator工具對象里定義了CheckPropertyRepeated方法,用於檢查屬性的值是否重覆。Validator.CheckPropertyRepeated(obj, Permission.NameProperty, result);就是檢查對象obj的Name屬性的值是否已經在別的對象里出現了,如果出現了,result參數里就會增加一條錯誤,該錯誤最終會由CA框架處理,拋出錯誤異常。用該方法判定屬性重覆規則很方便,請註意Validator的定義在CodeArt.DomainDriven.DataAccess程式集中,也就是說,這個工具類是由基礎設施層提供的,不是領域模型層的內容,因為判定屬性值是否重覆需要由倉儲的實現支持,技術上的原因導致該工具類只能出現在基礎設施層。另外,我們可以定義更加複雜的固定規則,比如一個班的學生人數不能超過50人等。在後續的示例里我們再演示更加複雜的情況。
說明瞭CA里如何驗證對象固定規則的代碼後,我們回過頭來談談這方面的思想問題。大家也許認為我們這裡的領域屬性以及屬性驗證和傳統開發模式里的表設計是一回事,比如在表permission里有個名稱為name的欄位,這個欄位要有唯一性約束,並且長度是50以內。不可否認,從這個角度來看,領域對象和資料庫表設計確實有點相似之處,但是兩者完全不是同一個概念。
我們之所以用領域驅動設計就是為了探尋事物在特定領域里的本質特征,為了實現這一目標,我們會基於領域的思想去考慮問題,思考的結果之一就是探尋到了事物固有的規則,這個固定規則就是描述事物本質特征的一個方面。但是資料庫欄位設計是基於表的設計,設計的表有可能是某個業務需要的表,也有可能是中間表,甚至臨時表,它們被設計的目的就是為了方便存儲數據或者為某個業務處理做數據上的支持。請註意,資料庫里的表為某個業務的實現做數據上的支持,不代表數據表自身處理了業務,事實上,開發人員還需要編寫大量操作資料庫的代碼來實現業務邏輯。與之相反的是,領域對象天生就具備處理複雜業務的能力,它不是數據的提供者而是業務的處理者,這是兩者最為本質的區別。
我們從業務的角度分析對象,摸索出事物的本質,然後為其制定固定規則。這和表設計是兩種不同思考問題的方式。雖然有可能兩種實施方式下偶爾得到了相同的結果(這裡指的結果僅僅是存儲結果,因為領域對象最終也是要被加入到倉儲的,用資料庫做倉儲的實現還是需要為該領域對象設計表,那麼我們設計好的對象在映射表的時候,有可能對象表的設計會與傳統開發設計出來的表相同),但這不代表對象設計和表設計是同樣一件事,事實上大部分情況下兩者設計的結果是截然不同的。所以,領域對象的設計和資料庫表的設計不能混為一談,用資料庫持久化領域對象確實需要設計表,但是表的欄位、約束等規則都是依賴於領域對象的設計,先有領域對象才會有資料庫里的表,就算你不用資料庫存儲領域對象,領域對象依然存在,它依然可以處理業務!
9)再來看看關於MarkedCode的代碼段:
internal static readonly DomainProperty MarkedCodeProperty = DomainProperty.Register<string, Permission>("MarkedCode");
/// <summary>
/// <para>許可權的唯一標示,可以由用戶設置</para>
/// <para>可以通過唯一標示找到許可權對象</para>
/// <para>該屬性可以為空</para>
/// </summary>
[PropertyRepository()]
[StringLength(0, 50)]
public string MarkedCode
{
get
{
return GetValue<string>(MarkedCodeProperty);
}
set
{
SetValue(MarkedCodeProperty, value);
}
}
/// <summary>
/// 是否定義了標識碼
/// </summary>
public bool DeclareMarkedCode
{
get
{
return !string.IsNullOrEmpty(this.MarkedCode);
}
}
與Name屬性類似,同樣的語法定義了MarkedCode(標記碼)屬性。在第一次編碼Permission對象的時候並沒有這個屬性,隨著項目的推進我們發現有必要為Permisson對象追加標記碼機制。
大家試想一下,系統既然有許可權機制,那麼必然會有驗證的需要,比如在員工列表的頁面(我們就以表現層是B/S站點為例說明)有個Page_Load方法,該方法里也許會驗證當前登錄人是否有查看該員工信息的許可權。假設驗證許可權的示意代碼是 ValidatePermission(“查看員工信息”)。ValidatePermission是驗證許可權的方法,該方法是表現層定義的,與領域模型層無關,這個方法會將當前登錄人的編號和許可權的名稱提交到門戶服務,由門戶服務判斷結果。大家不要在意實現的細節,門戶服務處理請求的機制後續教程中會講解。在這裡各位請考慮一個問題,我們將許可權的名稱以硬編碼的形式提交給門戶服務,門戶服務需要通過許可權名稱找到許可權對象,然後調用許可權對象的領域方法判斷登錄人是否合法,那麼問題就來了,如果哪一天由於一些原因,我們需要在系統後臺更改這個許可權的名稱怎麼辦?我們把許可權名稱“查看員工信息”修改為“查看多個員工信息”,這時候我們不得不找到員工列表頁,手工改代碼修改名稱,然後編譯代碼,重新上傳到伺服器。可以想象得到,當許可權數量多了這種維護非常麻煩。
那麼我們在ValidatePermission方法里傳遞許可權的編號呢?要知道編號是不會被改變的。但是使用編號有兩個問題,1.編號是GUID(你也可以設置為整型),這種數字化的值不夠直觀:ValidatePermission(125),你能看出該方法是驗證什麼許可權嗎?2.如果我們把查看員工信息的許可權誤刪除了,然後我們又重新創建該許可權,那麼“查看員工信息”的許可權編號就變了,我們依然要在頁面里改驗證代碼。
引起該問題的本質原因是什麼呢?是因為在站點里有許可權上的需要,這是站點在開發期間固化的硬性需要,是硬編碼實現的,比如: ValidatePermission(“查看員工信息”)就是硬編碼實現驗證登錄人是否有“查看員工信息”的許可權。而我們設計的許可權機制是一個通用型的,是為了在多個站點里都可以重用許可權驗證的機制。因此,我們提供門戶服務的後臺入口,由系統管理員可以設置每個站點自己的許可權信息,並提交給門戶服務保存,比如:A站點是一個OA系統,所以我們為A站點創建了“查看員工信息”和“創建員工信息”這兩個許可權。請記住,門戶服務本身是個獨立站點,你在A站點提交許可權信息給門戶服務,門戶服務就為A站點保存了這兩項許可權的信息。許可權的數據是放在門戶服務的倉儲里,不是在A站點里。另外,B站點是一個資訊項目,所以我們又在門戶服務里為B站點保存了“文章管理”的許可權。那麼,你可以在A站點和B站點里,調用門戶服務提供的驗證許可權的API,來判斷當前登錄人是否有指定的許可權。這樣我們多個項目都可以共用門戶服務,我們不必在新項目里為許可權機制重覆付出勞動力。
所以,問題出現在站點對許可權的要求是硬性的、是硬編碼的,而許可權對象的定義是保存在遠程門戶服務的倉儲里的,一個是硬編碼,一個是倉儲里的對象,他們兩者沒有映射關係。因此我們就設計了“標記碼”來體現這種映射關係。你可以為每個許可權對象設置一個MarkedCode的屬性值,這個值同時也是站點硬性編碼的值,將該值提交給門戶服務,門戶服務就可以通過該值找到唯一一個對應的許可權對象。這就是我們為什麼要設計MarkedCode屬性的原因。有了這項機制後,站點里可以調用類似這樣的代碼:ValidatePermission(“ViewEmployeeInfo”)來表示需要驗證當前登錄者是否具備查看員工信息的許可權,而我們在創建名稱為“查看員工信息”的許可權的時候,可以指定MarkedCode為“ViewEmployeeInfo”,這樣映射關係就建立好了,由於標記碼是我們硬編碼的需要而創建的,所以它不會像許可權名稱那樣有可能會改變,可以放心使用。這種以標識碼的方式將系統的硬編碼和對應的領域對象一一映射的機制也可以用於其他需求,不必局限於許可權模塊。
除了MarkedCode的定義外,代碼段里還編寫了DeclareMarkedCode屬性,嚴格的講,DeclareMarkedCode應該是一個領域方法,應該是這樣的格式:
public bool DeclareMarkedCode()
{
return !string.IsNullOrEmpty(this.MarkedCode);
}
只不過.NET提供了屬性的寫法,DeclareMarkedCode不需要任何參數,內部實現也很簡單,所以我們就將其定義成了屬性的寫法,這樣調用起來比較方便、直觀,例如:
if(permission.DeclareMarkedCode)
{
//該許可權定義了標識碼
}
else
{
//該許可權沒有定義標識碼
}
之所以在Permission里定義DeclareMarkedCode是因為我們認為不是所有的需求都是直接使用許可權來限定用戶訪問的,有時候僅判斷用戶是否屬於某個角色即可認證訪問安全。所以我們不必為每個Permission都填寫標識碼,只需要為站點里需要用到的許可權填寫標識碼。這也是為什麼MarkedCode屬性沒有標記[NotEmpty()]的原因,它可以是空的。為了在領域對象Permission里突出“標識碼不是必須的”這一點特征,我們額外編寫了DeclareMarkedCode屬性,以便在需要的時候可以直接判斷。事實上,在往後的日子里,該屬性幾乎沒有被用到過,寫這段代碼是我們隨手而為之,你可以認為這是一種過度設計,但是這無傷大雅,因為實現DeclareMarkedCode屬性的成本很低。當然,建議大家在實施項目時僅在有必要的時候才為領域對象編寫額外的屬性或方法,不要過度設計,這個話題後文會有詳述。
10)Description屬性表示許可權的描述,系統管理員在設置許可權的時候可以填寫簡短的描述以便查閱使用,該領域屬性非常簡單不做過多的說明。
11)再來看看Permission的構造函數的代碼:
[ConstructorRepository()] public Permission(Guid id) : base(id) { this.OnConstructed(); }
[ConstructorRepository()]特性由CodeArt.DomainDriven.DataAccess提供,屬於CA框架里ORM的定義。該特性表示領域對象被倉儲創建時調用的構造函數是Permission(Guid id)的版本。由於Permission比較簡單,所以它的構造函數只有一個。有時候我們會為領域對象編寫多個構造函數,這時候標示出倉儲使用哪個構造函數就很重要了。同樣的,如果你不使用CA提供的ORM那麼可以不標記該特性。
this.OnConstructed();代碼很關鍵,表示構造對象的工作已全部完成。使用CA編寫領域對象,當對象構造函數工作完畢的時候,必須調用 OnConstructed 方法,這是各位需要遵守的使用原則。之所以有這項原則是因為目前還沒有技術平臺(.NET、JAVA等)提供了對象被構造完成的事件給程式員使用。而CA需要監視各種領域事件,這包括領域對象被構造完成的事件,這些事件會對領域設計帶來很大的好處(後續教程會詳述)。因此需要大家手動調用OnConstructed方法給予框架提示對象構造已完成。在CA後續的版本里我們會考慮追加動態編譯的機制來實現自動化處理,但在當前版本中請大家遵守這個使用約定。
12)最後我們看看關於Permission的空定義:
#region 空對象 private class PermissionEmpty : Permission { public PermissionEmpty() : base(Guid.Empty) { this.OnConstructed(); } public override bool IsEmpty() { return true; } } public static readonly Permission Empty = new PermissionEmpty(); #endregion
CA規定每一個領域對象都應該有一個空對象定義,並且以名稱為Empty的靜態成員公佈出來。也就是說,我們可以使用領域對象.Empty的形式使用這個領域對象的空對象,Permission.Empty就代表Permission的空對象。上述代碼是編寫空對象的固定模式,大家請遵循該模式編寫空對象,說明如下:
private class PermissionEmpty : Permission 請註意訪問修飾符是私有的private,表示我們對外不直接公開PermissionEmpty,要使用空對象必須以Permission.Empty的語法。這可以保證空對象是全局唯一的,不會有多個實例,提升系統性能。由於空的許可權對象還是許可權對象,所以PermissionEmpty繼承自Permission。另外,空對象類型的命名我們約定為領域對象名稱追加Empty尾碼。
構造函數代碼不必多說,大家只要註意一點,空對象也是領域對象,因此也要遵守約定調用 OnConstructed 方法以便提示框架對象已構造完畢。
IsEmpty方法是DomainObject的提供的基類方法。所有的領域空對象定義里都要重寫IsEmpty方法,並且返回true作為結果。
public static readonly Permission Empty = new PermissionEmpty(); 請註意訪問修飾符public代表外界可以使用Permisson.Empty屬性,另外,請註意Empty成員的類型為Permission而實例為PermissionEmpty,這樣對於外界代碼而言既隱藏了PermissionEmpty的定義又公佈出了Permission的Empty成員。
關於空對象最後一個註意事項是“請將空對象的定義放在所有的領域屬性的定義之後或者直接放在領域對象代碼的底部”,有這項約定不是因為設計上的問題而是在代碼實現上,如果不將空對象放在領域屬性的定義之後,有可能引起一個技術問題,由於這個技術問題隱藏得比較深,所以在這裡不過多說明,當大家完全瞭解了CA的工作方法後,我再來剖析框架的實現細節,那時候再解答引起問題的原因。在這裡各位只用記住需要遵守這個約定就可以了。
講解完空對象的編碼說明,相信大家應該有一個疑惑,那就是“我們究竟為什麼要實現空對象?”。雖然前面的教程里講過空對象的思想,不過具體它對我們編寫程式有什麼實質上的幫助各位應該還不清楚。
要回答這個問題,首先請回想一下,在傳統開發里我們經常會遇到刪除某條數據要級聯刪除相關的數據,而刪除相關的數據又要級聯刪除相關數據的相關數據。讀取數據也一樣,我們常常inner join多個表,有的項目中一行sql代碼甚至會連接10個以上的數據表。連接這麼多的表就表明著表與表之間有耦合關係,一旦其中一個表發生變化,需要維護的地方就會很多,這往往令程式員焦頭爛額。然而,這些與空對象有什麼關係呢?
在CA里是用一個整體策略來避免這類問題,空對象是這個策略的一個環節。首先,我們獲取對象只能通過倉儲查詢聚合根。在倉儲內部執行查詢的時候,聚合根的成員也會被隨之載入(除非你設置了延遲載入,這個話題後續章節里有詳細說明),也就是說,當我們載入聚合根的時候,倉儲會以聚合根對應的數據表為from表,inner join 內聚成員表, 然後根據查詢的數據結果構造聚合根對象和內聚成員對象,並且填充它們的屬性值。(這是一種簡化的說明,實際上CA提供的ORM的內部機制比較複雜,載入對象的時候會進行緩存、併發控制、對象繼承和擴展的識別等工作)。由於聚合根的成員類型數量會很少,這裡我們沒有用“一般很少”來形容,因為無論需求多麼複雜,我們始終可以保證聚合根的成員類型數量幾乎不超過3個。也就是說,在倉儲內部inner join 的表不會超過3個,大多數情況下僅0-2個。
那為什麼CA可以保證聚合根的成員數量很少呢?因為無論業務多麼複雜,我們都可以將複雜的業務拆分成多個內聚模型,每個內聚模型僅負責1個關註點,這樣一個內聚模型里的聚合根和內聚成員的總數會非常少。每個聚合根會提供領域方法以便應用層調用。有時候也會出現兩個聚合根共同完成某項任務的情況,但是這種“共同完成”指的是聚合根A調用聚合根B的方法,B的方法在B內部定義,聚合根A不會深入到以聚合根B內部去告訴B它應該怎麼樣實現方法。也就是說多個聚合根就算在一起工作,但是它們的職責依然是分離的,各自履行各自的承諾,只是在一起協助完成任務而已。
但是有一種情況比較特殊,那就是有可能內聚模型a內部引用了內聚模型b里的聚合根B。比如說:文章(Article)對象是一個聚合根,文章分類(ArticleCategory)也是一個聚合根,大家不必糾結於為什麼這樣設計,文章系統的設計後面會有案例剖析,目前我們就認為已經設計成這樣子了。示意代碼如下:
public class Article:AggreateRoot<Article,int> { 其他成員的定義..... Public ArticleCategory Category{ get ; set;} 其他成員的定義...... } public class ArticleCategory:AggreateRoot<ArticleCategory,int> { 會有多個成員的定義..... }
其中Article有項領域屬性叫Category(文章所屬的分類),類型為ArticleCategory。也就是說以Article為聚合根的內聚模型有個名稱為Category的成員類型為另外一個內聚模型的聚合根ArticleCategory。假設ArticleCategory模型里也有自己的成員,比如自定義文章模板(也就是說,發佈在這個分類下的文章必須遵守的內容模板)等。那麼如果我們載入聚合根Article,當倉儲查詢數據的時候是不是要查詢成員Category,因此要inner jion 文章分類的表,由於文章分類的也有成員,那麼還要inner join 文章分類的成員對應的表,導致最終以文章為聚合根的查詢 inner join 的表數量也會很多,超過10個左右呢?
不會。因為Category是文章內聚模型以外的外部聚合根,這種外部聚合根預設情況下是不需要載入的,只有當你需要的時候才會再次載入。也就是說,當你第一次載入Article對象的時候,Category屬性根本就沒有被讀取而是當你使用類似的語法article.Category訪問分類屬性的時候,CA才會調用倉儲載入聚合根ArticleCategory對象。也許你會問這樣會不會造成性能問題?在傳統開發里我們可以使用一句sql載入出文章和文章分類的信息:
select * from article inner join articleCategory on article.categoryId = articleCategory.Id;
執行該sql就可以一次性查出文章和文章所屬分類的信息,而在剛纔的例子里,如果我們先載入了Article對象,然後代碼執行在某個地方時使用了Category屬性,導致再次載入分類信息,進行了二次查詢,這樣性能會不會比直接執行sql差呢?
不會,性能問題是個綜合性話題,並不是一次性查的數據越多性能就越高。事實上,資料庫IO讀取是以頁為最小單位的,每個頁8K(這裡以SQL Server 2005為例,其他資料庫大同小異)。也就是說,只要你執行查詢操作,就算你查詢的數據只有1個位元組,資料庫依然會讀取一個8K的數據頁(資料庫最小讀取頁為8K,實際工作時常常也以64K為單位查詢),那麼你想想,如果我們讀取的數據體積越小,我們可以載入的數據是不是越多?這也是為什麼資料庫設計里有個重要的原則,設計欄位類型的時候占用位元組數越小越好。因為欄位類型占用位元組數越小,每行數據占的體積就越少,那麼資料庫IO一次每頁可以容納的數據行數就越多:1行數據體積是1K,8K就可以載入8行數據,但是1行數據如果是500位元組,那麼8K就可以載入16行數據,所以數據類型占用的位元組數小,我們一次IO讀取的有效數據就越多,這樣就減少了IO讀取的次數,提升了系統性能。