領域模型管理與AOP

作者 Mats Helander 譯者 王麗娟 發佈於 2008年2月27日 下午11時9分

社區
Architecture
主題
AOP
標籤
領域驅動設計,
ORM

導言

正如從像《領域驅動設計》[Evans DDD]和《領域驅動設計和模式應用[Nilsson ADDDP]這些書中學到的一樣,在應用架構中引入領域模型模式(《企業應用架構模式》[Fowler PoEAA])一定會有很多益處,但是它們並不是無代價的。

使用領域模型,很少會像創建實際領域模型類、然後使用它們那麼簡單。很快你就會發現,領域模型必須得到相當數量的基礎架構代碼的支持。

領域模型所需基礎架構當中最顯著的當然是持久化——通常是持久化到關係型數據庫中,也就是對象/關係(O/R)映射出場的地方。但是,情況並不止持久化那麼簡單。在一個複雜的應用中,用來在運行時管理領域模型對象的部分佔了基礎架構的很大一部分。我將基礎架構的這部分稱爲領域模型管理(Domain Model Management)[Helander DMM],或簡稱爲DMM。

基礎架構代碼放在哪裏?

隨着基礎架構代碼的增長,找到一個處理它的優良架構變得越來越重要。問題主要在於——我們是否允許把一些基礎架構代碼放在我們的領域模型類裏面,還是無論如何應該避免這樣做?

避免基礎架構代碼進入領域模型類的論點是強有力的:領域模型應該表示應用程序所處理的核心業務概念。對於想大量使用其領域模型的應用來說,保持這些類乾淨、輕量級、易於維護是一個極好的架構目標。

另一方面,我們接下來將會看到,保持領域模型類完全不含基礎架構代碼——通常被稱爲使用POJO/POCO(Plain Old Java/CLR Objects)領域模型,這種極端的路線也被證明是有問題的。最終往往導致採用笨重的、低效率的變通方法來解決問題——而且有些功能用這種方式根本不可能實現。

也就是說,我們遇到的還是一個權衡利弊的情況,我們應該儘量在領域模型類裏面只放必不可少的基礎架構代碼,決不超出這個限度。我們付出領域模型的輕微發胖,換來效率的提高以及使一些必要領域模型管理功能有可能實現。畢竟,軟件架構很大程度上是關於如何做一筆好買賣。

重構的時機到了

不幸的是,長遠看來,檯面上的交易條件可能不夠好。爲了支持許多最有用和最強大的功能,你需要在領域模型類中放入基礎架構代碼實在太多了。其數量之大,很可能你的系統還沒完成,業務邏輯代碼就已經被淹沒了。

也就是說,除非我們能找到一種方法魚和熊掌兼得。本文試圖分析我們能否找到這樣一種方式,既能將必要的基礎架構代碼分佈到領域模型中,卻又不會使領域模型類變得雜亂。

我們先從一個應用看起,它將所有有關的基礎架構代碼都放到了領域模型類中。接着我們將重構這個應用,並且只用衆所周知的、可靠的、真正面向對象的設計模式,使應用最後能具備相同的功能,但是基礎架構代碼卻不會弄亂領域模型類。最後,我們將看看我們如何使用面向方面編程(Aspect Oriented Programming,AOP)來更簡單地達到相同的效果。

但是,爲了看出AOP爲何能幫助我們處理DMM需求,我們首先看看沒有AOP的時候我們的代碼會是什麼樣——首先是“最原始”的形式,這種形式裏,所有的基礎架構代碼都放在領域模型類裏面,然後是重構後的形式,其中基礎架構代碼已經被分離出領域模型類——雖然仍然分佈在領域模型中!

重構肥領域模型

大部分的領域模型運行時管理是基於攔截的——也就是說,當你在代碼中訪問領域模型對象時,你所有對對象的訪問都會根據相應功能的需要被攔截下來。

一個明顯的例子就是髒跟蹤(dirty tracking。它可以用於應用的很多部分,以瞭解一個對象什麼時候已經被修改了、但是仍未保存(它處於“髒”狀態)。用戶界面可以利用該信息提醒用戶是否打算放棄任何未保存的修改,而持久化機制則可以利用它來辨明哪些對象是真正需要被保存到持久化介質中的,從而避免保存所有的對象。

髒跟蹤的一種方法是保持領域對象最初的、未修改版本的拷貝,並在每次想知道一個對象是否已經被修改的時候去比較它們。這個方案的問題是既浪費內存,又慢。一個更有效率的方法是攔截對領域對象setter方法的調用,以便每當調用對象的一個setter方法的時候,都爲該對象設置一個髒標記。

髒標記放在哪裏?

現在我們來看看把髒標記放在哪裏的問題。一種是將它放在一個字典結構中,對象和標記分別作爲鍵和值。這樣做的問題在於,我們必須讓程序中所有需要它的部分都能訪問到這個字典。前面的例子已經可以看出,需要訪問字典的包括用戶界面和持久化機制這樣截然不同的部分。


圖 1

將字典放在這些組件的任何一個內部,都會使其它組件難以訪問它。在分層結構中,底層不能調用其上層(除了中心領域模型,它常常處於一個公共的、垂直的層裏面,能被其它所有的層調用),因此要麼把字典放在需要訪問它的最低一層(圖1),要麼放在公共的、垂直的層裏面(圖2)。兩種選擇都不是很有吸引力,因爲它引起了應用組件間不必要的耦合和不均衡的責任分配。


圖 2

一個更吸引人的、順應面向對象思想的選擇,是將髒標記放到領域對象本身中去,這樣每個領域對象都帶有一個布爾型的髒屬性,來表明它是不是髒的(圖3)。這樣,任何組件想知道一個領域對象髒與否,可以直接問它。


圖 3

因此,我們把部分基礎架構功能代碼放在領域模型中,其部分原因就是我們希望從應用的不同部分都能擁有這些功能,而不會過度地增強耦合。用戶界面部分不該知道如何向持久化組件詢問髒標誌,並且,我們寧願在分層的應用架構中設計儘可能少的垂直層。

這個理由很重要,單憑它就足以讓一些人考慮採納本文將要檢驗的這種方法,不過我們還是先看看其他方法。但是在這樣做之前,我們先粗略地看一下爭論的另一方——我們在領域模型類中限制基礎架構代碼的原因。

肥領域模型反模式

讓我們看看,引入髒標記、並在適當時機要求攔截喚醒髒標記之後,領域類會是什麼樣子。這是一個C#代碼的例子。

public class Person : IDirty
{
protected string name;
public virtual string Name
{
get { return name; }
set
{
if (value != name)
((IDirty)this).Dirty = true;
name = value;
}
}

private bool dirty;
bool IDirty.Dirty
{
get { return dirty; }
set { dirty = value; }
}
}

public interface IDirty
{
bool Dirty { get; set; }
}

清單 1

如例中所見,髒標記在接口(IDirty)中定義,然後該接口由類顯式地實現。顯式接口實現(explicit interface implementation)是C#的一個良好的特性,它可以讓我們避免在類的默認API中亂糟糟地堆滿基礎架構的相關成員。

這是有用的,例如在Visual Studio IDE中,除非將對象顯示地轉換爲IDirty接口,否則Dirty標記在代碼完成下拉菜單中是不可見的。事實上,可以從清單2中看出,爲了訪問Dirty屬性,對象首先必須轉換爲IDirty接口。

例子中的攔截代碼只有兩行(清單2)。不過那是因爲到目前爲止,我們的例子中只有一個屬性。如果我們有更多的屬性,我們將不得不在每個setter方法中重複這兩行代碼,並將比較對象改成相對應的屬性。

if (value != name)
((IDirty)this).Dirty = true;

清單 2

因此,寫代碼不會很難——並且我們的領域模型現在支持髒跟蹤,應用中所有的組件只要用到對象模型都可以訪問到髒跟蹤信息。從不需要隨時保留對象未經修改版本的拷貝來說,這種做法還是節省資源和快速的。

這種做法不利的一面是,你的領域模型類不再嚴格關注於商業業務。在領域模型類中忽然添加大量的基礎架構代碼,使得實際的業務邏輯代碼變得更加難以理解,並且類本身變得更加不可靠、難以變更、難以維護。

如果髒跟蹤是唯一必須插入到領域模型類中(或至少這樣做有好處)的基礎架構功能,我們可以不用過於擔憂。但是不幸的是情況並非如此。這些功能的列表在不斷增加,表1中只列舉了一些例子:

髒跟蹤 對象持有一個髒標誌,表示對象是否已經被修改、但仍未保存。基於攔截和新成員的引入(髒標記屬性及其getter、setter方法)。
懶加載
對象第一次被訪問的時候才加載對象狀態。基於攔截和新成員的引入。
初始值跟蹤 當一個屬性被修改(但尚未保存)的時候,對象保留未修改值的拷貝。基於攔截和新成員的引入。
反轉(Inverse)屬性管理 雙向關係中的一對屬性自動保持同步。基於攔截。
數據綁定 對象支持數據綁定接口,從而在數據綁定情況下使用。基於新成員的引入(數據綁定接口的實現)。
複製 對象的變更分發到監聽系統。基於攔截。
緩存 對象(或者至少是它們的狀態,參見備忘錄模式[GoF設計模式])拷貝到本地存儲器中,隨後的請求可以在本地存儲器中查詢,而不用到遠程數據源。基於攔截和新成員的引入。

表 1

這些功能——並且可能還有更多功能——都依賴於添加到領域類中的基礎架構代碼,乾淨、易於維護的領域模型忽然變得似乎遙遠起來。

我曾經描述過這個問題,其中,領域模型類中的代碼不斷增長,以至變得很難處理,就像肥領域模型反模式[Helander肥領域模型]。它和Martin Fowler描述的貧血領域模型反模式[Fowler貧血領域模型]正好相反,貧血領域模型反模式中領域模型類包含的邏輯過少。

Fowler在他對貧血領域模型的描述中,解釋了將業務邏輯放在領域模型之外是一種錯誤——實際上是一種反模式,因爲不能充分利用面向對象的概念和構造。

我同意他的論點——我甚至會更進一步認爲這句話對基礎架構代碼也一樣成立。基礎架構代碼也可以通過分佈在領域模型裏,來利用面向對象的概念和構造。如果一刀切地避免在領域模型中放置任何基礎架構代碼,我們的代碼也將淪爲貧血領域模型反模式相關問題的犧牲品。

不允許在領域模型中添加基礎架構代碼——沒有充分利用面向對象——由此帶來的限制我們已經看過:在應用的不同組件之間共享基礎架構功能變得更難,並且我們常常以低效率的“變通方法”而告終,比如用比較初始值來替代基於攔截的髒跟蹤。甚至還有一些功能在沒有攔截的情況下根本實現不了,如懶加載和反轉屬性管理。

那麼,解決方案是什麼呢?如果我們把所有的基礎架構代碼放置到領域模型類中,這些類就變得“肥胖不堪”——難於使用和維護。但是,如果我們把它放在領域模型類之外,我們又使其變爲“貧血的”領域模型,基礎架構代碼不能充分利用由面向對象的潛力。

我們似乎進退維谷。更具體地說,在一個不易維護的肥領域模型和一堆與貧血領域模型共存的低效的變通辦法之間,我們進退兩難。這顯然不是一個讓人特別愉快的境遇。現在該解決這個存在很久的問題了:什麼方法可以重構,讓我們走出這片混亂?

使用一個公共的基礎架構基類

一種想法是嘗試將儘可能多的基礎架構代碼放到一個公共的基類中去,領域模型類繼承該基類。然而,這個想法的主要問題在於,它對引進新的基礎架構成員(像髒標記)到領域模型類中有用的,但是它沒有爲我們提供攔截,而攔截是我們想要支持的許多功能要求的。

基類方法的另一個問題是,當需要調整功能的應用方式的時候,它不能提供必要級別的粒度。如果不同的領域模型類有不同的基礎架構需求,那麼我們就會遇到問題。我們也許能用一組不同的基類(也許互相繼承)來解決這些問題,但是如果我們遇到單一繼承,比如C#和Java的情況,要是領域模型本身也想使用繼承,就沒法這麼做了。

爲了明白這爲什麼會變成一個問題,讓我們稍稍擴展一下我們的例子,使其變得稍微“生動”一些:假設我們有一個Person類,已經能夠進行髒跟蹤,還有一個Employee類同時具備髒跟蹤和懶加載能力。最後,Employee類需要繼承Person類。

如果我們欣然把基礎架構代碼放進領域模型中,那麼我們有一個像清單1的Person類,一個像清單3的Employee類。

public class Employee : Person, ILazy
{
public override string Name
{
get
{
if (!loaded)
{
((ILazy)this).Loaded = true;

//call the persistence component
//and ask it to load the object
//with data from the data source
//(code omitted for brevity...)
}
return base.Name;
}
set
{
if (!loaded)
{
((ILazy)this).Loaded = true;

//perform lazy loading...
//(omitted)

}
base.Name = value;
}
}

private decimal salary;
public decimal Salary
{
get
{
if (!loaded)
{
((ILazy)this).Loaded = true;

//perform lazy loading...
//(omitted)

}

return salary;
}
set
{
if (!loaded)
{
((ILazy)this).Loaded = true;

//perform lazy loading...
//(omitted)

}

if (value != salary)
((IDirty)this).Dirty = true;

salary = value;
}
}

private bool loaded;
///
///The Loaded property is "write-once" -
/// after you have set it to true you can not set
/// it to false again

///
bool ILazy.Loaded
{
get { return loaded; }
set
{
if (loaded)
return;

loaded = value;
}
}
}

清單 3

正如你看到的,領域模型類中的實際業務內容有被淹沒的危險,基礎代碼的膨脹已經開始悄悄蔓延了。肥領域模型就埋伏在前方,這種跡象應該促動我們嘗試一種不同的方法——即使它可能在前面階段更費事一點兒。我們這麼做是因爲就長期而言,我們希望一個乾淨的領域模型能夠收回在可維護性和可用性上的投資。

因此,我們首先創建一個DirtyBase基類提供髒標誌,然後我們創建一個LazyBase提供一個加載標誌。使用多繼承的語言,我們可以讓Person類繼承DirtyBase類,Employee類繼承DirtyBase和LazyBase,如圖4描繪的一樣。


圖 4

但是如果我們要使用的語言不支持多繼承呢?好,我們能做的第一件事是讓LazyBase類繼承DirtyBase(見圖5)。那樣,我們仍讓Employee類繼承LazyBase類,並仍有髒標記和加載標記。它可能不是最佳的解決方案(如果我們有一個對象需要懶加載,但是不需要髒跟蹤呢?),但是至少在這個例子中,它是一種選擇。


圖 5

不過,這仍然給我們留了一個問題,在像C#和Java這樣的語言裏面,Employee類不能同時繼承Person類和LazyBase類。在這些語言中,剩下的辦法就是創建一個基類同時包括髒標記和加載標記(圖6),就讓Person類繼承一個多餘的加載標記。


圖 6

所以基類方法存在兩個問題:它不能爲許多功能(包括髒跟蹤和懶加載)提供所需的攔截,並且(至少在單繼承平臺上)它導致領域類繼承了一些它們並不必需的功能。

使用基礎架構代理子類

幸運的是,面向對象提供了一個絕好的方法來一石擊二鳥。爲了同時提供攔截和粒度控制,我們所要做的就是創建繼承領域模型類的子類——每個(具體的)領域模型類都有一個新子類。

這個子類能攔截對其基類成員的訪問,通過重寫(override)基類的成員來實現,重寫的版本首先執行攔截相關的活動,然後將執行傳遞給基類的成員。

爲了避免在所有的子類中都創建公共的基礎架構成員(比如髒標記),仍然可以把它們放在所有領域類都會繼承的公共基類裏面;而少數類的專屬功能,則可以將必要的成員放置在相關的子類中(圖7)。


圖 7

公共基類實現了所有領域模型類公有的基礎架構成員。不是所有領域類公用的那些功能則使用接口。

領域模型類脫離開了基礎架構代碼。

代理子類重寫領域類成員來提供攔截。它們還實現了基礎架構成員,這些成員不是所有領域類公有的。

用子類的方式來提供攔截,本質上是代理模式[GoF設計模式]的一種實現。還有一種變種是使用領域模型接口——一個領域模型類一個接口——接口能被代理類和領域類同時實現(圖8)。


圖 8

代理類在內部屬性中持有一個領域類實例,它實現領域接口的時候把所有調用都轉發給背後的領域對象。這實際上是在Gang of Four的《設計模式》[GoF設計模式]中描述的代理模式——只不過使用了子類來實現。

使用子類的好處是,你不必非要創建一堆沒有其他用處的領域接口。同時還有一個好處,代理子類中的代碼能使用“this”關鍵字(VB.NET中是“Me”)調用繼承自領域類的代碼,而基於接口的代理中的代碼將不得不通過內部屬性來引用領域對象。子類還能訪問受保護的成員,而基於接口的代理則要通過反射才能訪問這些受保護的成員。

使用基於接口的代理還有另一個缺點,如果在領域類中有一個方法返回“this”的引用,調用代碼就會得到一個“未代理”的領域類實例,這意味着髒跟蹤和懶加載之類的功能在這個實例中是不可用的。

由於這些問題,我個人傾向於基於子類的代理方法,並且在整篇文章中我們會繼續探討這個方法。但是,請記住,在這篇文章中討論的所有技術,也可以使用基於接口的代理方法來實現。

POJO/POCO領域模型

如果我們比較一下剛剛討論的基於代理模式的方法和我們先前的C#例子代碼,我們的領域類現在變得完全乾淨、沒有任何基礎架構代碼,就像在清單4前面部分中看到的一樣。所有的基礎架構代碼已經移到了基類和代理子類中,如清單4後面部分所示。

//Domain Model Classes
public class Person : DomainBase
{
protected string name;
public virtual string Name
{
get { return name; }
set { name = value; }
}
}

public class Employee : Person
{
protected decimal salary;
public virtual decimal Salary
{
get { return salary; }
set { salary = value; }
}
}

//Infrastructure Base Class
public class DomainBase : IDirty
{
private bool dirty;
bool IDirty.Dirty
{
get { return dirty; }
set { dirty = value; }
}
}

//Infrastructure Proxy Subclasses
public class PersonProxy : Person
{
public override string Name
{
get { return base.Name; }
set
{
if (value != this.name)
((IDirty)this).Dirty = true;
base.Name = value;
}
}
}

public class EmployeeProxy : Employee, ILazy
{
public override string
{
get
{
if (!loaded)
{
((ILazy)this).Loaded = true;

//call the persistence component
//and ask it to load the object
//with data from the data source
//(code omitted for brevity...

}
return base.Name;
}
set
{
if (!loaded)
{
((ILazy)this).Loaded = true;

//perform lazy loading...
//(omitted)

}

if (value != this.name)
((IDirty)this).Dirty = true;

base.Name = value;
}
}

public override decimal Salary
{
get
{
if (!loaded)
{
((ILazy)this).Loaded = true;

//perform lazy loading...
//(omitted)

}
return base.Salary;
}
set
{
if (!loaded)
{
((ILazy)this).Loaded = true;

//perform lazy loading...
//(omitted)

}

if (value != this.salary)
((IDirty)this).Dirty = true;

base.Salary = value;
}
}

private bool loaded;
///
/// The Loaded property is "write-once" -
/// after you have set it to true you can not set
/// it to false again

///
bool ILazy.Loaded
{
get { return loaded; }
set
{
if (loaded)
return;

loaded = value;
}
}
}

清單 4

通過與公共基類結合使用的代理模式,我們成功解決了我們看到的所有問題,無論到目前爲止嘲弄我們的這些問題是來自於貧血領域模型反模式,還是肥領域模型反模式:

我們能將有關的基礎架構代碼分佈到領域模型中,使我們應用中的所有部分都很容易訪問,並使它能用面向對象和高效率的方法實現。 我們能夠把我們想要的基礎架構代碼都分佈到領域模型中,但避免了肥領域模型的結局。事實上,我們實際的領域模型類中的代碼,仍然能夠保持乾淨,完全集中在它們需要關注的業務方面。 我們可以混合搭配,只增加每個領域模型類需要的基礎架構代碼。 我們能構建需要攔截支持的功能。
  •  

應該注意,按照POJO/POCO的嚴格定義,我們的領域模型類不應該繼承基礎架構基類。不過,我們可以很容易改過來,只要將所有的邏輯從公共基類移到代理子類中去,就可以得到一個完全的POJO/POCO領域模型。如果一定要滿足嚴格的POJO/POCO,我們的方法很容易滿足這項要求,只是舉手之勞。如果不強求,我們可以使用基類,此時領域模型類的代碼仍然完全不包含任何實際的基礎架構代碼。

因此到目前爲止我們已經整理出了一個架構,能讓我們把完全POJO/POCO的領域模型類和將基礎架構代碼分佈到領域模型中去的目標結合在一起。如果我們的目標僅僅是避免肥領域模型,而不強求符合POJO/POCO定義,那麼我們就能走“半POJO/POCO”的路線,用一個公共基類來節省一些工作。

使用抽象工廠模式

這聽起來很棒,對不對?你可能在想,用它是不是沒有任何問題呢。軟件業的人都有些吹毛求疵,你大概已經在疑心有那麼一兩個棘手問題該冒頭了吧。

你是對的。有兩件值得關注的事情馬上就自己顯現出來了。第一個相當輕微:爲了讓子類能夠重寫領域模型類的成員,並提供攔截,所有的領域模型成員(或至少打算攔截的那些成員)必須是虛擬的。

第二個問題好像更爲糟糕一些:既然你想讓你的程序使用代理子類的實例,你必須在應用代碼中查找所有創建領域模型類實例的地方,將它們改成創建相應的代理子類的實例。

要修改已存在的應用,聽起來像個噩夢吧?沒錯。要想避免這種大規模的查找和替換操作,只有一條出路,就是從一開始就避免使用“new”關鍵字來創建領域模型類的實例。

避免"new"的傳統方法就是使用抽象工廠模式[GoF設計模式]。調用一個工廠類的Create()方法,而不是任由客戶代碼使用“new”來創建實例。Create()方法負責調用“new”,並且在返回新的實例之前會對其做一些額外的相關設置操作。

如果你在調用領域模型的時候全都使用抽象工廠模式,那就最好不過了,接下來你只要在代碼中改一個地方——工廠類——將返回領域類實例改爲返回代理子類(或者是基於接口的代理)的實例。

這是使用抽象工廠模式來實例化所有的領域對象的一個重要理由——至少使用像Java和C#這樣的語言時是這樣,這些語言不允許奇異特性,比如不能像C++一樣重載(overload)成員取用運算子,不能像ObjectiveC一樣改變“new”關鍵字的行爲,也不能像Ruby一樣在運行時修改類。

繼承反射

對於代理子類的方法,還有一個問題值得一提。雖然只是一種極端情況,但是它相當隱蔽,如果你陷入這個問題卻不知道是什麼引起的,會被它狠狠咬上一口。

如果你看一下圖9,你會發現Employee繼承Person類,這是應該的:無論什麼時候,如果一個方法期望Person對象,那麼傳遞給它Employee對象應該也能工作。此外,PersonProxy類繼承Person類。這也很好,因爲這意味着將一個PersonProxy對象作爲參數傳遞給期望Person對象的方法也是“合法的”。


圖 9

以同樣的方式,EmployeeProxy繼承Employee,意味着你能把一個EmployeeProxy對象作爲參數傳遞給任何期望Employee對象的方法。最後,由於EmployeeProxy繼承Employee,Employee又繼承Person,這表明EmployeeProxy繼承Person。因此,任何期望Person對象的方法也可以接受EmployeeProxy對象。

所有一切都符合我們的期望。當我們讓抽象工廠開始返回代理對象而不是領域模型類的簡單實例時,有一點是很重要的,我們希望客戶端代碼仍然繼續正常運行,而無需重新考慮如何處理我們的類型層次。

換句話說,如果我們原來向一個期望Person對象的方法傳遞Employee對象給,當我們忽然換成給它傳遞EmployeeProxy對象的時候,我們的代碼必須還能正常編譯(並且工作)。幸運的是,由於EmployeeProxy繼承Employee,並且Employee繼承Person,所以沒有問題。

事實上,當開始使用代理來代替領域模型對象時,一切都將如常繼續運轉,正如我們希望的。只不過,有一個個非常微小的例外。

圖9中的紅色繼承線表示EmployeeProxy不繼承PersonProxy。這什麼時候會帶來問題呢?

好,考慮一下這樣一個方法,它接受兩個對象,並用反射來確定其中一種對象是否是另一種對象的子類。當我們把一個Person對象和一個Employee對象傳遞給該方法時,它會返回true。但是當我們把一個PersonProxy對象和一個EmployeeProxy對象傳遞給該方法時,突然它就返回false了。

除非你有基於反射的、檢查繼承層次的代碼,不然應該是安全的。但是萬一你真的有這樣的代碼,這裏先警告一下也沒壞處(你可以通過修改反射代碼來避開這個問題,讓它檢測代理類型,並一路向上直到找到一個非代理類型)。

使用混入(Mixin)和攔截器類

我們已經向着可維護的領域模型架構努力了很長時間,讓它能容下一個有效率的、可訪問的基礎架構來實行運行時領域模型管理。

我們是否可以做得更多呢?

讓我們開始看一下那些只針對某些領域類、而不是全部領域類的功能——對於這些功能,我們需要將所需的基礎架構成員放在代理子類中,而不是在公用的基類中。在我們的例子中,爲懶加載功能設置的加載標記將必須添加到需要支持懶加載的每個類的子類中。

由於我們可能需要在多個子類中放相同的代碼,所以我們明顯會導致代碼重複。只有一個標記的時候情況還好,但是對於需要多個屬性、甚至需要多個有複雜實現的方法的功能呢?

在我們的例子中,加載標記是一次寫入(write-once),所以在變成true之後不能再切換回來。因此,在setter方法中,我們有一些代碼來實施這個規則——在需要懶加載的每個領域模型類的子類中,我們都要在setter方法中重複的那些代碼。


我們會希望創建一個可重用的類來包含這種邏輯。但是,我們已經用了繼承(仍然假定我們工作在單繼承平臺上),那麼這個懶加載類該怎樣被重用呢?

一個很好的答案就是使用組合模式[GoF Design Patterns],而不是爲此採用繼承。使用這種模式,EmployeeProxy子類將包含一個內部屬性來引用可重用的懶加載類的實例(圖10)。


圖 10

這種可重用的類常常被稱爲混入(mixin),它反映了這樣一個事實,即實現被加入到類型中,而不是成爲繼承層次的一部分。使用混入的作用是,讓單繼承語言(甚至是在只支持接口繼承的平臺上,比如COM+)也能夠獲得類似多重繼承的效果。

有一點可以補充說明一下,使用我們這種組合方式,以混入的形式存在的行爲和狀態能動態地被加到目標類中(使用依賴注入),而不是靜態地綁定到目標上去(如果行爲是繼承下來的就會如此)。儘管在這篇文章中我們不會更多關注於如何利用它的潛能,但是它爲支持一些非常靈活和動態的場景提供了非常大的空間。

通過將子類引入的成員分離到一個可重用的混入類,我們向一個真正模塊化、一致的、內聚的架構邁進了一大步,這個架構還是低耦合的、高度代碼可重用的。我們能向這個方向再進一步嗎?

那麼,接下來要做的事就是把攔截代碼從子類中分離出來,並放到可重用的類裏面。這些攔截器類會像混入一樣包含在代理子類裏面,如圖11.


圖 11

Person、Employee和DomainBase與代碼的上一個版本保持不變,但是PersonProxy和EmployeeProxy發生了變化,並且我們引入了兩個新的攔截器類和一個新的混入類。清單5顯示了我們進行這些重構(不包括未修改的類)之後代碼的樣子。

//These proxy subclasses contain only boilerplate
//code now. All actual logic has been refactored
//out into mixin and interceptor classes.

public class PersonProxy : Person
{
private DirtyInterceptor
dirtyInterceptor = new DirtyInterceptor();

public override string Name
{
get { return base.Name; }
set
{
dirtyInterceptor.OnPropertySet(this, this.name, value);
base.Name = value;
}
}
}

public class EmployeeProxy : Employee, ILazy
{
//This mixin contains the implementation
//of the ILazy interface
.
private ILazy lazyMixin = new LazyMixin();

private LazyInterceptor lazyInterceptor = new LazyInterceptor();
private DirtyInterceptor dirtyInterceptor = new DirtyInterceptor();

public override string Name
{
get
{
lazyInterceptor.OnPropertyGetSet(this, "Name");
return base.Name;
}
set
{
lazyInterceptor.OnPropertyGetSet(this, "Name");
dirtyInterceptor.OnPropertySet(this, this.name, value);
base.Name = value;
}
}

public override decimal Salary
{
get
{
lazyInterceptor.OnPropertyGetSet(this, "Salary");
return base.Salary;
}
set
{
lazyInterceptor.OnPropertyGetSet(this, "Salary");
dirtyInterceptor.OnPropertySet(this, this.name, value);
base.Salary = value;
}
}

//The ILazy interface is implemented
//by forwarding the calls to the mixin,
//which contains the actual implementation
.
bool ILazy.Loaded
{
get { return lazyMixin.Loaded; }
set { lazyMixin.Loaded = value; }
}
}

//The following mixin and interceptor classes
//contain all the actual infrastructural logic
//associated with the dirty tracking and
//the lazy loading features.

public class LazyMixin : ILazy
{
private bool loaded;
///
/// The Loaded property is "write-once" -
/// after you have set it to true you can not set
/// it to false again

///
bool ILazy.Loaded
{
get { return loaded; }
set
{
if (loaded)
return;

loaded = value;
}
}
}

public class DirtyInterceptor
{
public void OnPropertySet(
object obj,
object oldValue,
object newValue)
{
if (!oldValue.Equals(newValue))
{
IDirty dirty = obj as IDirty;
if (dirty != null)
dirty.Dirty = true;
}
}
}

public class LazyInterceptor
{
public void OnPropertyGetSet(object obj)
{
ILazy lazy = obj as ILazy;
if (lazy != null)
{
if (!lazy.Loaded)
{
lazy.Loaded = true;

//perform lazy loading...
//(omitted)

}
}
}
}
清單 5

通過重構,最終所有實際的基礎架構邏輯都被放進了混入和攔截器類,代理子類變得很苗條,變成專注於轉發請求到攔截器和混入的輕量級類。事實上,在這一點上,代理子類裏面除了由很容易被代碼生成工具生成的樣板代碼之外,再沒其它什麼內容。

一種選擇是在設計時生成代理子類的代碼,但是像C#和Java這樣的語言能讓你在運行時生成、編譯、執行代碼。因此,我們也可以選擇在運行時生成代理子類,這種做法就是俗稱的運行時子類化(runtime subclassing)。

花一點兒時間回頭想想我們走了多遠。在第一步,我們將肥領域模型重構爲帶有代理子類的POJO/POCO領域模型(還可選擇增加一個基類),同時基礎架構代碼仍然分佈在領域模型中。在重構的第二步,所有實際的基礎架構邏輯從代理子類分離了出來,並加入到可重用的混入和攔截器類,在代理子類中只留下了樣板代碼。

在最後一步,我們轉向使用運行時代碼生成器來爲樣板代理子類生成全部代碼。就這樣我們一路跋涉到了面向方面編程的疆界。

使用面向方面編程

很長一段時間,我對AOP的大肆宣傳都感到很新奇,每次我嘗試研究它是怎麼一回事的時候,就會完全迷惑——這主要因爲AOP社區堅持使用他們哪種奇怪的術語。而且常常有一些(不是全部!)AOP倡導者似乎在故意讓這個領域看起來那麼的不同,讓人興奮並覺得膽怯。

因此,如果說讀到這裏,實際上你已經掌握了面向方面編程的所有主要概念,你可能會感到驚訝——不但如此,而且你已經看到了AOP框架在背後是如何運作的,從頭到腳。

面向方面編程使用了“方面”這個核心概念,方面簡單來說是一組引介(introductions,即混入)和通知(advice,即攔截器)。方面(攔截器和混入)可以通過運行時子類化(runtime subclassing)的方式(以及其他技術)在運行時應用於現有的類。

正如你看到的,你已經理解了大部分怪異但重要的AOP術語。被大量用來描述方面對什麼有益的術語橫切(crosscutting)關注點,簡單意思就是你有一個能應用於大多數(不一定屬於同一個繼承樹的)類的功能——例如我們的髒跟蹤和懶加載功能,這些功能就是橫切跨領域模型的關注點。

我們至今還未真正涵蓋、唯一真正重要的AOP術語是連接點(join point)和切點(pointcutting)。連接點是目標類中的一個地方,你可以在那裏應用攔截器或混入。通常,攔截器所關心的連接點是方法,而混入所關心的連接點則是類本身。簡單地說,連接點就是你能加入方面的點。

爲了確定哪個方面應該被應用到哪個連接點,你可以使用切點語言。這個語言可以很簡單,可以是描述性的語言,比如你可能只是在一個xml文件中命名方面、命名連接點組合;但是也有複雜的可以使用正則表達式、甚至完整的領域特定語言(Domain Specific Languages)來進行更高級的“切點“。

到目前爲止,我們還尚未關注切點,因爲我們已經在我們需要的地方手工應用了我們的方面(我們的混入和攔截器)。但是,如果我們要想走完最後的一步,爲應用我們的方面而使用AOP框架,我們還是需要考慮這個問題。

但在我們做之前,先讓我們停下來思索一下這個計劃。在我們前面已經完成那麼多重構之後,現在加入AOP框架步子其實並不大。它僅僅是很小的一步,因爲我們已經有效地重構了我們的應用,使其使用攔截器(通知)和混入(介紹)形式的方面。

事實上,我會認爲我們實質上已經在利用AOP了——我們只是還沒有用AOP框架來爲我們自動化那些例行公事的部分。但是我們利用着來自AOP的所有基本的概念(攔截和混入),並且我們用一種可重用的方式處理橫切關注點。這聽起來像極了AOP的定義,所以AOP框架能幫助我們自動化完成之前的一些手工工作是一點兒也不會令人感到意外的。

目前攔截器和混入類中的代碼只要輕微地修改一下就是方面了——它已經被重構爲一個泛化的、可重用的形式,這正是方面應該具有的形式。我們需要AOP框架做的只是以某種方式幫助我們將方面應用到領域模型類中。如前所述,AOP框架有許多方式可以做到這一點,其中一種方式是在運行時生成代理子類。

如果用的是運行時子類化框架,生成的代理子類代碼看起來會和我們例子中代理子類的樣板代碼非常近似。換句話說,它很容易生成。如果你認爲你可以寫一個代碼生成器來完成任務,那麼你就在實現你自己的AOP框架引擎的半路上了。

重構到方面

在我們最後的重構中,我們將看一下,如果我們使用一個AOP框架來應用我們的攔截器和混入,那麼我們的例子代碼會怎樣。在這個例子中,我將使用NAspect,這是Roger Johansson和我合作完成的一個針對.NET的開源AOP框架。NAspect使用運行時子類來運用方面。

讀到這裏,至今你應該不難理解NAspect執行的“花招”——它簡單地生成包含樣板代碼的代理子類,樣板代碼需要轉發請求到混入和攔截器,並且它使用標準的、面向對象的、領域類成員的重寫,來提供攔截。

很多人把AOP和最糟糕的巫術聯繫在一起。奇怪的術語已經令人怯步,開發人員在領域類代碼中預見不到的事情忽然並“毫無察覺”地發生在攔截器中,令很多人遭受打擊,深深地感到不安。

當然,具體瞭解這些術語是什麼意思會有幫助。能理解至少一種運用方面的方法也是有幫助的,至少能讓你看到這不是什麼魔術——只是幾個良好的、成熟的、衆所周知的、面向對象的設計模式在發揮作用。

AOP就是用我們熟知的面向對象來對付橫切關注點。AOP框架只是讓你能夠更舒服地運用方面,不用自己手寫許多代理子類。

所以,現在我們不會再被AOP給嚇到了,讓我們看看我們如何使用它來使我們的應用向終點線邁出最後一步。

在這個例子裏,我們要做的第一件事情是包含一個對NAspect框架程序集的引用。準備好之後,我們就可以開始創建我們的方面、確定我們的切點。我們也可以直接刪除PersonProxy和EmployeeProxy類——從現在開始,NAspect會爲我們生成這些類。

如果我們願意,我們甚至可以刪除DomainBase基類——沒有必要使用基類來應用公用於所有基類的混入,我們也可以使用AOP框架來做這些。這意味着,通過使用AOP框架,我們甚至可以無需任何額外工作就能滿足POJO/POCO的嚴格要求。

爲了除去基類,我們只需要把功能移到混入中去。就我們的情況而言,我們僅需要再創建一個混入——DirtyMixin,它持有先前放在基類中的髒標記。

LazyMixin類中的代碼完全保持不變,所以這裏不再列出。實際上,我們真正需要去做的是創建新的DirtyMixin類,並稍微修改兩個攔截器中的代碼,讓它們使用NAspect傳進來的描述被攔截方法的數據結構。

我們還需要修改我們的工廠類,以便它使用NAspect來爲我們創建代理子類。這假定我們已經在應用中的所有地方都轉爲使用抽象工廠模式。如果我們還沒有這樣做,現在我們一定要汲取教訓,因爲沒有抽象工廠模式,我們將不得不進行另一項龐大的搜索、替換操作,仔細檢查代碼中每一個代理子類被實例化的地方,改爲調用NAspect運行時子類化引擎。

重構後的代碼如清單6所示,準備好使用NAspect。

public class DirtyMixin : IDirty
{
private bool dirty;
bool IDirty.Dirty
{
get { return dirty; }
set { dirty = value; }
}
}

public class DirtyInterceptor : IAroundInterceptor
{
public object HandleCall(MethodInvocation call)
{
IDirty dirty = call.Target as IDirty;

if (dirty != null)
{
//Extract the new value from the call object
object newValue = call.Parameters[0].Value;

//Extract the current value using reflection
object value = call.Target.GetType().GetProperty(
call.Method.Name.Substring(4)).GetValue(
call.Target, null);

//Mark as dirty if the new value is
//different from the old value

if (!value.Equals(newValue))
dirty.Dirty = true;
}

return call.Proceed();
}
}

public class LazyInterceptor : IAroundInterceptor
{
public object HandleCall(MethodInvocation call)
{
ILazy lazy = call.Target as ILazy;

if (lazy != null)
{
if (!lazy.Loaded)
{
lazy.Loaded = true;

//perform lazy loading...
//(omitted)

}
}

return call.Proceed();
}
}

public class Factory
{
public static IEngine engine = ApplicationContext.Configure();

public static Domain.Person CreatePerson()
{ return engine.CreateProxyPerson>();
}

public static Domain.Employee CreateEmployee()
{
return engine.CreateProxyEmployee>();
}
}
清單 6

爲了知道你的方面要應用到哪個類,NAspect在程序的配置文件中使用一個xml配置段(也有其它選擇)。現在我們可以在xml文件中定義方面,指出混入和攔截器類的類型,以及我們想應用的類型。大體上,我們準備好了嘗試一下我們的AOP應用。

不過,仍然有一點讓許多開發人員對使用AOP猶豫不決,甚至當他們理解了AOP的術語和理論,那就是由於切點體系定義得不完善而帶來的脆弱性。

雖然我們通過一些巧妙的正則表達式來篩選出打算應用方面的類和成員,但風險是當領域模型變化的時候,你可能會忘了更新切點定義,以致正則表達式忽然篩選到了不應該應用方面的類。就是這類問題給AOP帶來了巫術的壞名聲。

處理切點不明確的一個方式是創建自定義的.NET屬性(Attribute)(Java中的註釋(annotations))。用自定義屬性裝飾領域模型,然後使用屬性做爲切點目標,這樣就可以避免使用正則表達式之類的小花招。方面只應用於我們決定該應用的地方,通過在那裏放置屬性。

因此根據我們的情況,我們繼續創建兩個自定義屬性——LazyAttribute和DirtyAttribute——接下來我們使用它們來裝飾我們的類(清單7)。

public class DirtyAttribute : Attribute
{
}

public class LazyAttribute : Attribute
{
}

[Dirty]
public class Person : DomainBase
{
protected string name;
public virtual string Name
{
get { return name; }
[Dirty]
set { name = value; }
}
}

[Dirty]
[Lazy]

public class Employee : Person
{
protected decimal salary;
public virtual decimal Salary
{
[Lazy]
get { return salary; }
[Dirty]
[Lazy]

set { salary = value; }
}

public override string Name
{
[Lazy]
get { return name; }
[Dirty]
[Lazy]

set { name = value; }
}
}
清單 7

我們接着在應用的配置文件中定義我們的切點(清單8),讓方面以我們自定義的屬性爲目標。





name="naspect"
type="Puzzle.NAspect.Framework.Configuration.NAspectConfigurationHandler, Puzzle.NAspect.Framework.NET2"/>







name="DirtyAspect"
target-attribute="InfoQ.AspectsOfDMM.Attributes.DirtyAttribute, InfoQ.AspectsOfDMM" >

target-attribute="InfoQ.AspectsOfDMM.Attributes.DirtyAttribute, InfoQ.AspectsOfDMM" >

type="InfoQ.AspectsOfDMM.Aspects.DirtyInterceptor, InfoQ.AspectsOfDMM" />







name="LazyAspect"
target-attribute="InfoQ.AspectsOfDMM.Attributes.LazyAttribute, InfoQ.AspectsOfDMM" >

target-attribute="InfoQ.AspectsOfDMM.Attributes.LazyAttribute, InfoQ.AspectsOfDMM" >

type="InfoQ.AspectsOfDMM.Aspects.LazyInterceptor, InfoQ.AspectsOfDMM" />









現在,我們最後測試一下我們的AOP應用。當我們運行應用時,NAspect會在運行時爲我們生成代理子類,並在子類中插入樣板代碼來將所有的請求轉發到攔截器和混入類。

程序最後的架構可參看圖12。注意,這個版本和先前非AOP版本之間的唯一實際差別是,兩個代理類(使用虛線邊框標識)將由AOP框架生成,而之前我們必須手工編碼。

與圖11比較,我們多了一個DirtyTrackerMixin類和一個新的IDirtyTracker接口,來代替DomainBase基類,但這些東西哪怕我們不使用AOP,只要決定不使用公共基礎架構基類(以滿足更嚴格的POJO/POCO需求)就是要有的。換句話說,如果我們不想使用一個公共基類,不管我們是否使用AOP框架,我們最終都會得到一樣的架構(圖12所示)。

圖 12

切點選項

當你添加新的領域模型類時,要想使它們能夠懶加載和髒跟蹤,只需要用自定義屬性裝飾它們,就會”變魔術一樣地“獲得相應功能了。

用屬性/註釋來作爲切點目標,很快你就會注意到,由於每項功能對應一個屬性,很快領域模型中每個成員的屬性就太多了。因此,爲了減少屬性的數量,你可能想找到它們之間的抽象性。

如果你適應基於正則表達式的切點方法,並且你覺得屬性令領域模型變得雜亂,使得保持領域模型與基礎架構關注點完全無關的目標受到了損害,那麼另一種選擇是,你可以只配置你的xml配置文件來匹配相關的目標類,並且不需要用自定義屬性裝飾你的領域類。

另外,NAspect的xml切點語言(像許多其它的AOP框架一樣)允許你簡單地提供一個完整的類型清單,方面會運用於列出的類型,讓你不用去找到一個剛好只匹配正確類的正則表達式。這使配置文件變得更長,但是能讓你的領域模型保持完全乾淨。

在使用切點來動態運用方面的地方使用AOP框架,還有一個好處就是,當方面被用在不同的用例中時,把不同的方面應用於相同的類中會變得很容易。如果一個用例要求某個領域類懶加載,而另一個用例不需要,那麼只有第一個用例需要使用應用懶加載方面的配置文件。

這個動態的方面應用可以非常強大,比如在測試場景可以額外應用一些測試用的方面,提供mocking之類的功能。

使用面向方面編程的結論

我們是否可以在領域模型中放置任何基礎架構代碼,這是我們開始時的問題。我們看到,這絕對可取,因爲它令許多功能得以更有效率地實現,但是可能使我們的領域模型變得“肥胖”。

爲了解決這個問題,我們重構了我們的肥領域模型——首先是把基礎架構代碼移到代理子類和一個基類中,然後將實際的邏輯從代理子類中移出、移到混入和攔截器類中。這使我們的領域模型變得更漂亮,但是在代理子類中最終卻有很多樣板代碼需要寫。爲了解決這個問題,我們轉向了面向方面編程。

結果發現邁向AOP框架的步子原來是如此之小——事實上應用的架構並沒有任何改變——以至它大概使一些讀者感到驚奇,這些讀者甚至有熟悉的感覺,這種感覺就像當你第一次理解一個設計模式時,你意識到它就是你已經用了很多次的東西,只是不知道它有一個這樣的名字。

你很可能在許多應用中有效地使用過AOP,但並沒認識到它是AOP,並且好像沒有使用過AOP框架來幫助你自動化地生成部分樣板代碼。

我們現在在這篇文章的結尾處,很希望在這個地方你會同意我的看法:

AOP不像它看上去的那麼難以理解和令人迷惑,並且使用AOP框架是很容易的。 不管你是否使用AOP框架,使用AOP的概念和建模技術,對處理你應用基礎架構中的橫切領域模型管理關注點來說,仍然是一個非常好的方法。
  •  

它實際上就是一個通過使用良好的、成熟的面向對象模式,穩步提高應用架構的重構問題。這樣做的時候,不要害怕走向一個使用代理來運用攔截器和混入的,可稱爲面向方面的應用架構。

不管你是否進一步讓一個AOP框架來幫助你應用方面,這都不會真正影響你是否在進行AOP。因爲,如果你是順着這篇文章討論的路線來重構你的應用的,你就是在進行AOP——不管有沒有一個框架的幫助。

摘要

通過使用像代理模式、抽象工廠模式、組合模式這些傳統的、知名的面向對象設計模式來重構我們的肥領域模型,我們得到了一個極好地結合了精益領域模型和基礎架構的應用架構,基礎架構充分利用了面向對象的概念和構造。這樣,我們巧妙地避免陷入肥領域模型和貧血領域模型反模式的陷阱。

使用這篇文章中討論的模式,得到了一個可以被形容爲面向方面的應用架構。這是因爲我們最終用混入和攔截器類解決了橫切關注點,混入和攔截器則精密地與面向方面編程中的介紹和通知匹配了起來。

如果你想親自體驗使用AOP是不是真的那麼輕鬆愉快,而且不介意使用不是親手鍵入的代碼,你可以下載本文隨附的Visual Studio 2005工程的所有代碼。

總之,在這篇文章中,我嘗試去展示面向方面的概念和工具是怎樣提供一個偉大的方式去看待和運用許多領域模型管理關注點,以及它們是如何大大減輕了肥領域模型反模式的風險。

關於作者

Mats Helander是Avanade(荷蘭)的一位資深軟件開發諮詢師。業餘時間,他和Roger Johansson一起開發了免費的Puzzle.NET套件,Puzzle.NET是一個開源框架和工具。Puzzle.NET包括NPersist、ObjectMapper(對象/關係映射)、NAspect(面向方面編程)、NFactory(依賴注入)、以及NPath(內存對象查詢)。

Mats Helander的博客——http://www.matshelander.com/wordpress
Puzzle.NET——http://www.puzzleframework.com
Avanade(荷蘭)——http://www.avanade.com/nl/

參考資料

[Evans DDD]

《領域驅動設計:軟件核心複雜性應對之道,Evans Eric著。 ——馬薩諸塞州,波士頓:Addison-Wesley出版社,2004。

[Fowler貧血領域模型]

Fowler Martin:http://martinfowler.com/bliki/AnemicDomainModel.html

[Fowler PoEAA]http://martinfowler.com/bliki/AnemicDomainModel.html
《企業應用架構模式》,Fowler Martin著。 ——馬薩諸塞州,波士頓:Addison-Wesley出版社,2003。

[GoF設計模式]

《設計模式:可複用面向對象軟件的基礎》,Gamma Erich、Richard Helm、Ralph Johnson、John M. Vlissides合著。——馬薩諸塞州,裏丁鎮:Addison-Wesley出版社,1995。

[Helander DMM]
Helander Mats:http://www.matshelander.com/wordpress/?p=30

[Helander肥領域模型]
Helander Mats:http://www.matshelander.com/wordpress/?p=75

[Nilsson ADDDP]
《領域驅動設計和模式應用,Nilsson Jimmy著。——Addison-Wesley出版社,2006。


在這裏獲取源代碼。

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