前言:PetShop是一個範例,微軟用它來展示.Net企業系統開發的能力。業界有許多.Net與J2EE之爭,許多數據是從微軟的PetShop和Sun的PetStore而來。這種爭論不可避免帶有濃厚的商業色彩,對於我們開發人員而言,沒有必要過多關注。然而PetShop隨着版本的不斷更新,至現在基於.Net 2.0的PetShop4.0爲止,整個設計逐漸變得成熟而優雅,卻又很多可以借鑑之處。PetShop是一個小型的項目,系統架構與代碼都比較簡單,卻也凸現了許多頗有價值的設計與開發理念。本系列試圖對PetShop作一個全方位的解剖,依據的代碼是PetShop4.0,可以從鏈接http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnbda/html/bdasamppet4.asp中獲得。
一、PetShop的系統架構設計
在軟件體系架構設計中,分層式結構是最常見,也是最重要的一種結構。微軟推薦的分層式結構一般分爲三層,從下至上分別爲:數據訪問層、業務邏輯層(又或成爲領域層)、表示層,如圖所示:
圖一:三層的分層式結構
數據訪問層:有時候也稱爲是持久層,其功能主要是負責數據庫的訪問。簡單的說法就是實現對數據表的Select,Insert,Update,Delete的操作。如果要加入ORM的元素,那麼就會包括對象和數據表之間的mapping,以及對象實體的持久化。在PetShop的數據訪問層中,並沒有使用ORM,從而導致了代碼量的增加,可以看作是整個設計實現中的一大敗筆。
業務邏輯層:是整個系統的核心,它與這個系統的業務(領域)有關。以PetShop爲例,業務邏輯層的相關設計,均和網上寵物店特有的邏輯相關,例如查詢寵物,下訂單,添加寵物到購物車等等。如果涉及到數據庫的訪問,則調用數據訪問層。
表示層:是系統的UI部分,負責使用者與整個系統的交互。在這一層中,理想的狀態是不應包括系統的業務邏輯。表示層中的邏輯代碼,僅與界面元素有關。在PetShop中,是利用ASP.Net來設計的,因此包含了許多Web控件和相關邏輯。
分層式結構究竟其優勢何在?Martin Fowler在《Patterns of Enterprise Application Architecture》一書中給出了答案:
1、開發人員可以只關注整個結構中的其中某一層;
2、可以很容易的用新的實現來替換原有層次的實現;
3、可以降低層與層之間的依賴;
4、有利於標準化;
5、利於各層邏輯的複用。
概括來說,分層式設計可以達至如下目的:分散關注、鬆散耦合、邏輯複用、標準定義。
一個好的分層式結構,可以使得開發人員的分工更加明確。一旦定義好各層次之間的接口,負責不同邏輯設計的開發人員就可以分散關注,齊頭並進。例如UI人員只需考慮用戶界面的體驗與操作,領域的設計人員可以僅關注業務邏輯的設計,而數據庫設計人員也不必爲繁瑣的用戶交互而頭疼了。每個開發人員的任務得到了確認,開發進度就可以迅速的提高。
鬆散耦合的好處是顯而易見的。如果一個系統沒有分層,那麼各自的邏輯都緊緊糾纏在一起,彼此間相互依賴,誰都是不可替換的。一旦發生改變,則牽一髮而動全身,對項目的影響極爲嚴重。降低層與層間的依賴性,既可以良好地保證未來的可擴展,在複用性上也是優勢明顯。每個功能模塊一旦定義好統一的接口,就可以被各個模塊所調用,而不用爲相同的功能進行重複地開發。
進行好的分層式結構設計,標準也是必不可少的。只有在一定程度的標準化基礎上,這個系統纔是可擴展的,可替換的。而層與層之間的通信也必然保證了接口的標準化。
“金無足赤,人無完人”,分層式結構也不可避免具有一些缺陷:
1、降低了系統的性能。這是不言而喻的。如果不採用分層式結構,很多業務可以直接造訪數據庫,以此獲取相應的數據,如今卻必須通過中間層來完成。
2、有時會導致級聯的修改。這種修改尤其體現在自上而下的方向。如果在表示層中需要增加一個功能,爲保證其設計符合分層式結構,可能需要在相應的業務邏輯層和數據訪問層中都增加相應的代碼。
前面提到,PetShop的表示層是用ASP.Net設計的,也就是說,它應是一個BS系統。在.Net中,標準的BS分層式結構如下圖所示:
圖二:.Net中標準的BS分層式結構
隨着PetShop版本的更新,其分層式結構也在不斷的完善,例如PetShop2.0,就沒有采用標準的三層式結構,如圖三:
圖三:PetShop 2.0的體系架構
從圖中我們可以看到,並沒有明顯的數據訪問層設計。這樣的設計雖然提高了數據訪問的性能,但也同時導致了業務邏輯層與數據訪問的職責混亂。一旦要求支持的數據庫發生變化,或者需要修改數據訪問的邏輯,由於沒有清晰的分層,會導致項目作大的修改。而隨着硬件系統性能的提高,以及充分利用緩存、異步處理等機制,分層式結構所帶來的性能影響幾乎可以忽略不計。
PetShop3.0糾正了此前層次不明的問題,將數據訪問邏輯作爲單獨的一層獨立出來:
圖四:PetShop 3.0的體系架構
PetShop4.0基本上延續了3.0的結構,但在性能上作了一定的改進,引入了緩存和異步處理機制,同時又充分利用了ASP.Net 2.0的新功能MemberShip,因此PetShop4.0的系統架構圖如下所示:
圖五:PetShop 4.0的體系架構
比較3.0和4.0的系統架構圖,其核心的內容並沒有發生變化。在數據訪問層(DAL)中,仍然採用DAL Interface抽象出數據訪問邏輯,並以DAL Factory作爲數據訪問層對象的工廠模塊。對於DAL Interface而言,分別有支持MS-SQL的SQL Server DAL和支持Oracle的Oracle DAL具體實現。而Model模塊則包含了數據實體對象。其詳細的模塊結構圖如下所示:
圖六:數據訪問層的模塊結構圖
可以看到,在數據訪問層中,完全採用了“面向接口編程”思想。抽象出來的IDAL模塊,脫離了與具體數據庫的依賴,從而使得整個數據訪問層利於數據庫遷移。DALFactory模塊專門管理DAL對象的創建,便於業務邏輯層訪問。SQLServerDAL和OracleDAL模塊均實現IDAL模塊的接口,其中包含的邏輯就是對數據庫的Select,Insert,Update和Delete操作。因爲數據庫類型的不同,對數據庫的操作也有所不同,代碼也會因此有所區別。
此外,抽象出來的IDAL模塊,除了解除了向下的依賴之外,對於其上的業務邏輯層,同樣僅存在弱依賴關係,如下圖所示:
圖七:業務邏輯層的模塊結構圖
圖七中BLL是業務邏輯層的核心模塊,它包含了整個系統的核心業務。在業務邏輯層中,不能直接訪問數據庫,而必須通過數據訪問層。注意圖中對數據訪問業務的調用,是通過接口模塊IDAL來完成的。既然與具體的數據訪問邏輯無關,則層與層之間的關係就是鬆散耦合的。如果此時需要修改數據訪問層的具體實現,只要不涉及到IDAL的接口定義,那麼業務邏輯層就不會受到任何影響。畢竟,具體實現的SQLServerDAL和OracalDAL根本就與業務邏輯層沒有半點關係。
因爲在PetShop 4.0中引入了異步處理機制。插入訂單的策略可以分爲同步和異步,兩者的插入策略明顯不同,但對於調用者而言,插入訂單的接口是完全一樣的,所以PetShop 4.0中設計了IBLLStrategy模塊。雖然在IBLLStrategy模塊中,僅僅是簡單的IOrderStategy,但同時也給出了一個範例和信息,那就是在業務邏輯的處理中,如果存在業務操作的多樣化,或者是今後可能的變化,均應利用抽象的原理。或者使用接口,或者使用抽象類,從而脫離對具體業務的依賴。不過在PetShop中,由於業務邏輯相對簡單,這種思想體現得不夠明顯。也正因爲此,PetShop將核心的業務邏輯都放到了一個模塊BLL中,並沒有將具體的實現和抽象嚴格的按照模塊分開。所以表示層和業務邏輯層之間的調用關係,其耦合度相對較高:
圖八:表示層的模塊結構圖
在圖五中,各個層次中還引入了輔助的模塊,如數據訪問層的Messaging模塊,是爲異步插入訂單的功能提供,採用了MSMQ(Microsoft Messaging Queue)技術。而表示層的CacheDependency則提供緩存功能。
二、PetShop數據訪問層之數據庫訪問設計
在系列一中,我從整體上分析了PetShop的架構設計,並提及了分層的概念。從本部分開始,我將依次對各層進行代碼級的分析,以求獲得更加細緻而深入的理解。在PetShop 4.0中,由於引入了ASP.Net 2.0的一些新特色,所以數據層的內容也更加的廣泛和複雜,包括:數據庫訪問、Messaging、MemberShip、Profile四部分。在系列二中,我將介紹有關數據庫訪問的設計。
在PetShop中,系統需要處理的數據庫對象分爲兩類:一是數據實體,對應數據庫中相應的數據表。它們沒有行爲,僅用於表現對象的數據。這些實體類都被放到Model程序集中,例如數據表Order對應的實體類OrderInfo,其類圖如下:
這些對象並不具有持久化的功能,簡單地說,它們是作爲數據的載體,便於業務邏輯針對相應數據表進行讀/寫操作。雖然這些類的屬性分別映射了數據表的列,而每一個對象實例也恰恰對應於數據表的每一行,但這些實體類卻並不具備對應的數據庫訪問能力。
由於數據訪問層和業務邏輯層都將對這些數據實體進行操作,因此程序集Model會被這兩層的模塊所引用。
第二類數據庫對象則是數據的業務邏輯對象。這裏所指的業務邏輯,並非業務邏輯層意義上的領域(domain)業務邏輯(從這個意義上,我更傾向於將業務邏輯層稱爲“領域邏輯層”),一般意義上說,這些業務邏輯即爲基本的數據庫操作,包括Select,Insert,Update和Delete。由於這些業務邏輯對象,僅具有行爲而與數據無關,因此它們均被抽象爲一個單獨的接口模塊IDAL,例如數據表Order對應的接口IOrder:
將數據實體與相關的數據庫操作分離出來,符合面向對象的精神。首先,它體現了“職責分離”的原則。將數據實體與其行爲分開,使得兩者之間依賴減弱,當數據行爲發生改變時,並不影響Model模塊中的數據實體對象,避免了因一個類職責過多、過大,從而導致該類的引用者發生“災難性”的影響。其次,它體現了“抽象”的精神,或者說是“面向接口編程”的最佳體現。抽象的接口模塊IDAL,與具體的數據庫訪問實現完全隔離。這種與實現無關的設計,保證了系統的可擴展性,同時也保證了數據庫的可移植性。在PetShop中,可以支持SQL Server和Oracle,那麼它們具體的實現就分別放在兩個不同的模塊SQLServerDAL、OracleDAL中。
以Order爲例,在SQLServerDAL、OracleDAL兩個模塊中,有不同的實現,但它們同時又都實現了IOrder接口,如圖:
從數據庫的實現來看,PetShop體現出了沒有ORM框架的臃腫與醜陋。由於要對數據表進行Insert和Select操作,以SQL Server爲例,就使用了SqlCommand,SqlParameter,SqlDataReader等對象,以完成這些操作。尤其複雜的是Parameter的傳遞,在PetShop中,使用了大量的字符串常量來保存參數的名稱。此外,PetShop還專門爲SQL Server和Oracle提供了抽象的Helper類,包裝了一些常用的操作,如ExecuteNonQuery、ExecuteReader等方法。
在沒有ORM的情況下,使用Helper類是一個比較好的策略,利用它來完成數據庫基本操作的封裝,可以減少很多和數據庫操作有關的代碼,這體現了對象複用的原則。PetShop將這些Helper類統一放到DBUtility模塊中,不同數據庫的Helper類暴露的方法基本相同,只除了一些特殊的要求,例如Oracle中處理bool類型的方式就和SQL Server不同,從而專門提供了OraBit和OraBool方法。此外,Helper類中的方法均爲static方法,以利於調用。OracleHelper的類圖如下:
對於數據訪問層來說,最頭疼的是SQL語句的處理。在早期的CS結構中,由於未採用三層式架構設計,數據訪問層和業務邏輯層是緊密糅合在一起的,因此,SQL語句遍佈與系統的每一個角落。這給程序的維護帶來極大的困難。此外,由於Oracle使用的是PL-SQL,而SQL Server和Sybase等使用的是T-SQL,兩者雖然都遵循了標準SQL的語法,但在很多細節上仍有區別,如果將SQL語句大量的使用到程序中,無疑爲可能的數據庫移植也帶來了困難。
最好的方法是採用存儲過程。這種方法使得程序更加整潔,此外,由於存儲過程可以以數據庫腳本的形式存在,也便於移植和修改。但這種方式仍然有缺陷。一是存儲過程的測試相對困難。雖然有相應的調試工具,但比起對代碼的調試而言,仍然比較複雜且不方便。二是對系統的更新帶來障礙。如果數據庫訪問是由程序完成,在.Net平臺下,我們僅需要在修改程序後,將重新編譯的程序集xcopy到部署的服務器上即可。如果使用了存儲過程,出於安全的考慮,必須有專門的DBA重新運行存儲過程的腳本,部署的方式受到了限制。
我曾經在一個項目中,利用一個專門的表來存放SQL語句。如要使用相關的SQL語句,就利用關鍵字搜索獲得對應語句。這種做法近似於存儲過程的調用,但卻避免了部署上的問題。然而這種方式卻在性能上無法得到保證。它僅適合於SQL語句較少的場景。不過,利用良好的設計,我們可以爲各種業務提供不同的表來存放SQL語句。同樣的道理,這些SQL語句也可以存放到XML文件中,更有利於系統的擴展或修改。不過前提是,我們需要爲它提供專門的SQL語句管理工具。
SQL語句的使用無法避免,如何更好的應用SQL語句也無定論,但有一個原則值得我們遵守,就是“應該儘量讓SQL語句盡存在於數據訪問層的具體實現中”。
當然,如果應用ORM,那麼一切就變得不同了。因爲ORM框架已經爲數據訪問提供了基本的Select,Insert,Update和Delete操作了。例如在NHibernate中,我們可以直接調用ISession對象的Save方法,來Insert(或者說是Create)一個數據實體對象:
public void Insert(OrderInfo order)
{
ISession s = Sessions.GetSession();
ITransaction trans = null;
try
{
trans = s.BeginTransaction();
s.Save( order);
trans.Commit();
}
finally
{
s.Close();
}
}
沒有SQL語句,也沒有那些煩人的Parameters,甚至不需要專門去考慮事務。此外,這樣的設計,也是與數據庫無關的,NHibernate可以通過Dialect(方言)的機制支持不同的數據庫。唯一要做的是,我們需要爲OrderInfo定義hbm文件。
當然,ORM框架並非是萬能的,面對紛繁複雜的業務邏輯,它並不能完全消滅SQL語句,以及替代複雜的數據庫訪問邏輯,但它卻很好的體現了“80/20(或90/10)法則”(也被稱爲“帕累托法則”),也就是說:花比較少(10%-20%)的力氣就可以解決大部分(80%-90%)的問題,而要解決剩下的少部分問題則需要多得多的努力。至少,那些在數據訪問層中佔據了絕大部分的CRUD操作,通過利用ORM框架,我們就僅需要付出極少數時間和精力來解決它們了。這無疑縮短了整個項目開發的週期。
還是回到對PetShop的討論上來。現在我們已經有了數據實體,數據對象的抽象接口和實現,可以說有關數據庫訪問的主體就已經完成了。留待我們的還有兩個問題需要解決:
1、數據對象創建的管理
2、利於數據庫的移植
在PetShop中,要創建的數據對象包括Order,Product,Category,Inventory,Item。在前面的設計中,這些對象已經被抽象爲對應的接口,而其實現則根據數據庫的不同而有所不同。也就是說,創建的對象有多種類別,而每種類別又有不同的實現,這是典型的抽象工廠模式的應用場景。而上面所述的兩個問題,也都可以通過抽象工廠模式來解決。標準的抽象工廠模式類圖如下:
例如,創建SQL Server的Order對象如下:
PetShopFactory factory = new SQLServerFactory();
IOrder = factory.CreateOrder();
要考慮到數據庫的可移植性,則factory必須作爲一個全局變量,並在主程序運行時被實例化。但這樣的設計雖然已經達到了“封裝變化”的目的,但在創建PetShopFactory對象時,仍不可避免的出現了具體的類SQLServerFactory,也即是說,程序在這個層面上產生了與SQLServerFactory的強依賴。一旦整個系統要求支持Oracle,那麼還需要修改這行代碼爲:
PetShopFactory factory = new OracleFactory();
修改代碼的這種行爲顯然是不可接受的。解決的辦法是“依賴注入”。“依賴注入”的功能通常是用專門的IoC容器提供的,在Java平臺下,這樣的容器包括Spring,PicoContainer等。而在.Net平臺下,最常見的則是Spring.Net。不過,在PetShop系統中,並不需要專門的容器來實現“依賴注入”,簡單的做法還是利用配置文件和反射功能來實現。也就是說,我們可以在web.config文件中,配置好具體的Factory對象的完整的類名。然而,當我們利用配置文件和反射功能時,具體工廠的創建就顯得有些“畫蛇添足”了,我們完全可以在配置文件中,直接指向具體的數據庫對象實現類,例如PetShop.SQLServerDAL.IOrder。那麼,抽象工廠模式中的相關工廠就可以簡化爲一個工廠類了,所以我將這種模式稱之爲“具有簡單工廠特質的抽象工廠模式”,其類圖如下:
DataAccess類完全取代了前面創建的工廠類體系,它是一個sealed類,其中創建各種數據對象的方法,均爲靜態方法。之所以能用這個類達到抽象工廠的目的,是因爲配置文件和反射的運用,如下的代碼片斷所示:
public sealed class DataAccess
{
// Look up the DAL implementation we should be using
private static readonly string path = ConfigurationManager.AppSettings["WebDAL"];
private static readonly string orderPath = ConfigurationManager.AppSettings["OrdersDAL"];
public static PetShop.IDAL.IOrder CreateOrder()
{
string className = orderPath + ".Order";
return (PetShop.IDAL.IOrder)Assembly.Load(orderPath).CreateInstance(className);
}
}
在PetShop中,這種依賴配置文件和反射創建對象的方式極其常見,包括IBLLStategy、CacheDependencyFactory等等。這些實現邏輯散佈於整個PetShop系統中,在我看來,是可以在此基礎上進行重構的。也就是說,我們可以爲整個系統提供類似於“Service Locator”的實現:
public static class ServiceLocator
{
private static readonly string dalPath = ConfigurationManager.AppSettings["WebDAL"];
private static readonly string orderPath = ConfigurationManager.AppSettings["OrdersDAL"];
//……
private static readonly string orderStategyPath = ConfigurationManager.AppSettings["OrderStrategyAssembly"];
public static object LocateDALObject(string className)
{
string fullPath = dalPath + "." + className;
return Assembly.Load(dalPath).CreateInstance(fullPath);
}
public static object LocateDALOrderObject(string className)
{
string fullPath = orderPath + "." + className;
return Assembly.Load(orderPath).CreateInstance(fullPath);
}
public static object LocateOrderStrategyObject(string className)
{
string fullPath = orderStategyPath + "." + className;
return Assembly.Load(orderStategyPath).CreateInstance(fullPath);
}
//……
}
那麼和所謂“依賴注入”相關的代碼都可以利用ServiceLocator來完成。例如類DataAccess就可以簡化爲:
public sealed class DataAccess
{
public static PetShop.IDAL.IOrder CreateOrder()
{
return (PetShop.IDAL.IOrder)ServiceLocator. LocateDALOrderObject("Order");
}
}
通過ServiceLocator,將所有與配置文件相關的namespace值統一管理起來,這有利於各種動態創建對象的管理和未來的維護。
三、PetShop數據訪問層之消息處理
在進行系統設計時,除了對安全、事務等問題給與足夠的重視外,性能也是一個不可避免的問題所在,尤其是一個B/S結構的軟件系統,必須充分地考慮訪問量、數據流量、服務器負荷的問題。解決性能的瓶頸,除了對硬件系統進行升級外,軟件設計的合理性尤爲重要。
在前面我曾提到,分層式結構設計可能會在一定程度上影響數據訪問的性能,然而與它給設計人員帶來的好處相比,幾乎可以忽略。要提供整個系統的性能,還可以從數據庫的優化着手,例如連接池的使用、建立索引、優化查詢策略等等,例如在PetShop中就利用了數據庫的Cache,對於數據量較大的訂單數據,則利用分庫的方式爲其單獨建立了Order和Inventory數據庫。而在軟件設計上,比較有用的方式是利用多線程與異步處理方式。
在PetShop4.0中,使用了Microsoft Messaging Queue(MSMQ)技術來完成異步處理,利用消息隊列臨時存放要插入的數據,使得數據訪問因爲不需要訪問數據庫從而提供了訪問性能,至於隊列中的數據,則等待系統空閒的時候再進行處理,將其最終插入到數據庫中。
PetShop4.0中的消息處理,主要分爲如下幾部分:消息接口IMessaging、消息工廠MessagingFactory、MSMQ實現MSMQMessaging以及數據後臺處理應用程序OrderProcessor。
從模塊化分上,PetShop自始自終地履行了“面向接口設計”的原則,將消息處理的接口與實現分開,並通過工廠模式封裝消息實現對象的創建,以達到鬆散耦合的目的。
由於在PetShop中僅對訂單的處理使用了異步處理方式,因此在消息接口IMessaging中,僅定義了一個IOrder接口,其類圖如下:
在對消息接口的實現中,考慮到未來的擴展中會有其他的數據對象會使用MSMQ,因此定義了一個Queue的基類,實現消息Receive和Send的基本操作:
public virtual object Receive()
{
try
{
using (Message message = queue.Receive(timeout, transactionType))
return message;
}
catch (MessageQueueException mqex)
{
if (mqex.MessageQueueErrorCode == MessageQueueErrorCode.IOTimeout)
throw new TimeoutException();
throw;
}
}
public virtual void Send(object msg)
{
queue.Send(msg, transactionType);
}
其中queue對象是System.Messaging.MessageQueue類型,作爲存放數據的隊列。MSMQ隊列是一個可持久的隊列,因此不必擔心用戶不間斷地下訂單會導致訂單數據的丟失。在PetShopQueue設置了timeout值,OrderProcessor會根據timeout值定期掃描隊列中的訂單數據。
MSMQMessaging模塊中,Order對象實現了IMessaging模塊中定義的接口IOrder,同時它還繼承了基類PetShopQueue,其定義如下:
public class Order:PetShopQueue, PetShop.IMessaging.IOrder
方法的實現代碼如下:
public new OrderInfo Receive()
{
// This method involves in distributed transaction and need Automatic Transaction type
base.transactionType = MessageQueueTransactionType.Automatic;
return (OrderInfo)((Message)base.Receive()).Body;
}
public OrderInfo Receive(int timeout)
{
base.timeout = TimeSpan.FromSeconds(Convert.ToDouble(timeout));
return Receive();
}
public void Send(OrderInfo orderMessage)
{
// This method does not involve in distributed transaction and optimizes performance using Single type
base.transactionType = MessageQueueTransactionType.Single;
base.Send(orderMessage);
}
所以,最後的類圖應該如下:
注意在Order類的Receive()方法中,是用new關鍵字而不是override關鍵字來重寫其父類PetShopQueue的Receive()虛方法。因此,如果是實例化如下的對象,將會調用PetShopQueue的Receive()方法,而不是子類Order的Receive()方法:
PetShopQueue queue = new Order();
queue.Receive();
從設計上來看,由於PetShop採用“面向接口設計”的原則,如果我們要創建Order對象,應該採用如下的方式:
IOrder order = new Order();
order.Receive();
考慮到IOrder的實現有可能的變化,PetShop仍然利用了工廠模式,將IOrder對象的創建用專門的工廠模塊進行了封裝:
在類QueueAccess中,通過CreateOrder()方法利用反射技術創建正確的IOrder類型對象:
public static PetShop.IMessaging.IOrder CreateOrder()
{
string className = path + ".Order";
return PetShop.IMessaging.IOrder)Assembly.Load(path).CreateInstance(className);
}
path的值通過配置文件獲取:
private static readonly string path = ConfigurationManager.AppSettings["OrderMessaging"];
而配置文件中,OrderMessaging的值設置如下:
<add key="OrderMessaging" value="PetShop.MSMQMessaging"/>
之所以利用工廠模式來負責對象的創建,是便於在業務層中對其調用,例如在BLL模塊中OrderAsynchronous類:
public class OrderAsynchronous : IOrderStrategy
{
private static readonly PetShop.IMessaging.IOrder asynchOrder = PetShop.MessagingFactory.QueueAccess.CreateOrder();
public void Insert(PetShop.Model.OrderInfo order)
{
asynchOrder.Send(order);
}
}
一旦IOrder接口的實現發生變化,這種實現方式就可以使得客戶僅需要修改配置文件,而不需要修改代碼,如此就可以避免程序集的重新編譯和部署,使得系統能夠靈活應對需求的改變。例如定義一個實現IOrder接口的SpecialOrder,則可以新增一個模塊,如PetShop.SpecialMSMQMessaging,而類名則仍然爲Order,那麼此時我們僅需要修改配置文件中OrderMessaging的值即可:
<add key="OrderMessaging" value="PetShop.SpecialMSMQMessaging"/>
OrderProcessor是一個控制檯應用程序,不過可以根據需求將其設計爲Windows Service。它的目的就是接收消息隊列中的訂單數據,然後將其插入到Order和Inventory數據庫中。它利用了多線程技術,以達到提高系統性能的目的。
在OrderProcessor應用程序中,主函數Main用於控制線程,而核心的執行任務則由方法ProcessOrders()實現:
private static void ProcessOrders()
{
// the transaction timeout should be long enough to handle all of orders in the batch
TimeSpan tsTimeout = TimeSpan.FromSeconds(Convert.ToDouble(transactionTimeout * batchSize));
Order order = new Order();
while (true)
{
// queue timeout variables
TimeSpan datetimeStarting = new TimeSpan(DateTime.Now.Ticks);
double elapsedTime = 0;
int processedItems = 0;
ArrayList queueOrders = new ArrayList();
using (TransactionScope ts = new TransactionScope(TransactionScopeOption.Required, tsTimeout))
{
// Receive the orders from the queue
for (int j = 0; j < batchSize; j++)
{
try
{
//only receive more queued orders if there is enough time
if ((elapsedTime + queueTimeout + transactionTimeout) < tsTimeout.TotalSeconds)
{
queueOrders.Add(order.ReceiveFromQueue(queueTimeout));
}
else
{
j = batchSize; // exit loop
}
//update elapsed time
elapsedTime = new TimeSpan(DateTime.Now.Ticks).TotalSeconds - datetimeStarting.TotalSeconds;
}
catch (TimeoutException)
{
//exit loop because no more messages are waiting
j = batchSize;
}
}
//process the queued orders
for (int k = 0; k < queueOrders.Count; k++)
{
order.Insert((OrderInfo)queueOrders[k]);
processedItems++;
totalOrdersProcessed++;
}
//batch complete or MSMQ receive timed out
ts.Complete();
}
Console.WriteLine("(Thread Id " + Thread.CurrentThread.ManagedThreadId + ") batch finished, " + processedItems + " items, in " + elapsedTime.ToString() + " seconds.");
}
}
首先,它會通過PetShop.BLL.Order類的公共方法ReceiveFromQueue()來獲取消息隊列中的訂單數據,並將其放入到一個ArrayList對象中,然而再調用PetShop.BLL.Order類的Insert方法將其插入到Order和Inventory數據庫中。
在PetShop.BLL.Order類中,並不是直接執行插入訂單的操作,而是調用了IOrderStrategy接口的Insert()方法:
public void Insert(OrderInfo order)
{
// Call credit card procesor
ProcessCreditCard(order);
// Insert the order (a)synchrounously based on configuration
orderInsertStrategy.Insert(order);
}
在這裏,運用了一個策略模式,類圖如下所示:
在PetShop.BLL.Order類中,仍然利用配置文件來動態創建IOrderStategy對象:
private static readonly PetShop.IBLLStrategy.IOrderStrategy orderInsertStrategy = LoadInsertStrategy();
private static PetShop.IBLLStrategy.IOrderStrategy LoadInsertStrategy()
{
// Look up which strategy to use from config file
string path = ConfigurationManager.AppSettings["OrderStrategyAssembly"];
string className = ConfigurationManager.AppSettings["OrderStrategyClass"];
// Using the evidence given in the config file load the appropriate assembly and class
return (PetShop.IBLLStrategy.IOrderStrategy)Assembly.Load(path).CreateInstance(className);
}
由於OrderProcessor是一個單獨的應用程序,因此它使用的配置文件與PetShop不同,是存放在應用程序的App.config文件中,在該文件中,對IOrderStategy的配置爲:
<add key="OrderStrategyAssembly" value="PetShop.BLL" />
<add key="OrderStrategyClass" value="PetShop.BLL.OrderSynchronous" />
因此,以異步方式插入訂單的流程如下圖所示:
Microsoft Messaging Queue(MSMQ)技術除用於異步處理以外,它主要還是一種分佈式處理技術。分佈式處理中,一個重要的技術要素就是有關消息的處理,而在System.Messaging命名空間中,已經提供了Message類,可以用於承載消息的傳遞,前提上消息的發送方與接收方在數據定義上應有統一的接口規範。
MSMQ在分佈式處理的運用,在我參與的項目中已經有了實現。在爲一個汽車製造商開發一個大型系統時,分銷商Dealer作爲.Net客戶端,需要將數據傳遞到管理中心,並且該數據將被Oracle的EBS(E-Business System)使用。由於分銷商管理系統(DMS)採用的是C/S結構,數據庫爲SQL Server,而汽車製造商管理中心的EBS數據庫爲Oracle。這裏就涉及到兩個系統之間數據的傳遞。
實現架構如下:
首先Dealer的數據通過MSMQ傳遞到MSMQ Server,此時可以將數據插入到SQL Server數據庫中,同時利用FTP將數據傳送到專門的文件服務器上。然後利用IBM的EAI技術(企業應用集成,Enterprise Application Itegration)定期將文件服務器中的文件,利用接口規範寫入到EAI數據庫服務器中,並最終寫道EBS的Oracle數據庫中。
上述架構是一個典型的分佈式處理結構,而技術實現的核心就是MSMQ和EAI。由於我們已經定義了統一的接口規範,在通過消息隊列形成文件後,此時的數據就已經與平臺無關了,使得在.Net平臺下的分銷商管理系統能夠與Oracle的EBS集成起來,完成數據的處理。
四、PetShop之ASP.NET緩存
如果對微型計算機硬件系統有足夠的瞭解,那麼我們對於Cache這個名詞一定是耳熟能詳的。在CPU以及主板的芯片中,都引入了這種名爲高速緩衝存儲器(Cache)的技術。因爲Cache的存取速度比內存快,因而引入Cache能夠有效的解決CPU與內存之間的速度不匹配問題。硬件系統可以利用Cache存儲CPU訪問概率高的那些數據,當CPU需要訪問這些數據時,可以直接從Cache中讀取,而不必訪問存取速度相對較慢的內存,從而提高了CPU的工作效率。軟件設計借鑑了硬件設計中引入緩存的機制以改善整個系統的性能,尤其是對於一個數據庫驅動的Web應用程序而言,緩存的利用是不可或缺的,畢竟,數據庫查詢可能是整個Web站點中調用最頻繁但同時又是執行最緩慢的操作之一,我們不能被它老邁的雙腿拖緩我們前進的征程。緩存機制正是解決這一缺陷的加速器。
4.1 ASP.NET緩存概述
作爲.Net框架下開發Web應用程序的主打產品,ASP.NET充分考慮了緩存機制。通過某種方法,將系統需要的數據對象、Web頁面存儲在內存中,使得Web站點在需要獲取這些數據時,不需要經過繁瑣的數據庫連接、查詢和複雜的邏輯運算,就可以“觸手可及”,如“探囊取物”般容易而快速,從而提高整個Web系統的性能。
ASP.NET提供了兩種基本的緩存機制來提供緩存功能。一種是應用程序緩存,它允許開發者將程序生成的數據或報表業務對象放入緩存中。另外一種緩存機制是頁輸出緩存,利用它,可以直接獲取存放在緩存中的頁面,而不需要經過繁雜的對該頁面的再次處理。
應用程序緩存其實現原理說來平淡無奇,僅僅是通過ASP.NET管理內存中的緩存空間。放入緩存中的應用程序數據對象,以鍵/值對的方式存儲,這便於用戶在訪問緩存中的數據項時,可以根據key值判斷該項是否存在緩存中。
放入在緩存中的數據對象其生命週期是受到限制的,即使在整個應用程序的生命週期裏,也不能保證該數據對象一直有效。ASP.NET可以對應用程序緩存進行管理,例如當數據項無效、過期或內存不足時移除它們。此外,調用者還可以通過CacheItemRemovedCallback委託,定義回調方法使得數據項被移除時能夠通知用戶。
在.Net Framework中,應用程序緩存通過System.Web.Caching.Cache類實現。它是一個密封類,不能被繼承。對於每一個應用程序域,都要創建一個Cache類的實例,其生命週期與應用程序域的生命週期保持一致。我們可以利用Add或Insert方法,將數據項添加到應用程序緩存中,如下所示:
Cache["First"] = "First Item";
Cache.Insert("Second", "Second Item");
我們還可以爲應用程序緩存添加依賴項,使得依賴項發生更改時,該數據項能夠從緩存中移除:
string[] dependencies = {"Second"};
Cache.Insert("Third", "Third Item",
new System.Web.Caching.CacheDependency(null, dependencies));
與之對應的是緩存中數據項的移除。前面提到ASP.NET可以自動管理緩存中項的移除,但我們也可以通過代碼編寫的方式顯式的移除相關的數據項:
Cache.Remove("First");
相對於應用程序緩存而言,頁輸出緩存的應用更爲廣泛。它可以通過內存將處理後的ASP.NET頁面存儲起來,當客戶端再一次訪問該頁面時,可以省去頁面處理的過程,從而提高頁面訪問的性能,以及Web服務器的吞吐量。例如,在一個電子商務網站裏,用戶需要經常查詢商品信息,這個過程會涉及到數據庫訪問以及搜索條件的匹配,在數據量較大的情況下,如此的搜索過程是較爲耗時的。此時,利用頁輸出緩存就可以將第一次搜索得到的查詢結果頁存儲在緩存中。當用戶第二次查詢時,就可以省去數據查詢的過程,減少頁面的響應時間。
頁輸出緩存分爲整頁緩存和部分頁緩存。我們可以通過@OutputCache指令完成對Web頁面的輸出緩存。它主要包含兩個參數:Duration和VaryByParam。Duration參數用於設置頁面或控件進行緩存的時間,其單位爲秒。如下的設置表示緩存在60秒內有效:
<%@ OutputCache Duration=“60“ VaryByParam=“none“ %>
只要沒有超過Duration設置的期限值,當用戶訪問相同的頁面或控件時,就可以直接在緩存中獲取。
使用VaryByParam參數可以根據設置的參數值建立不同的緩存。例如在一個輸出天氣預報結果的頁面中,如果需要爲一個ID爲txtCity的TextBox控件建立緩存,其值將顯示某城市的氣溫,那麼我們可以進行如下的設置:
<%@ OutputCache Duration=”60” VaryByParam=”txtCity” %>
如此一來,ASP.NET會對txtCity控件的值進行判斷,只有輸入的值與緩存值相同,才從緩存中取出相應的值。這就有效地避免了因爲值的不同而導致輸出錯誤的數據。
利用緩存的機制對性能的提升非常明顯。通過ACT(Application Center Test)的測試,可以發現設置緩存後執行的性能比未設置緩存時的性能足足提高三倍多。
引入緩存看來是提高性能的“完美”解決方案,然而“金無足赤,人無完人”,緩存機制也有缺點,那就是數據過期的問題。一旦應用程序數據或者頁面結果值發生的改變,那麼在緩存有效期範圍內,你所獲得的結果將是過期的、不準確的數據。我們可以想一想股票系統利用緩存所帶來的災難,當你利用錯誤過期的數據去分析股市的風雲變幻時,你會發現獲得的結果真可以說是“失之毫釐,謬以千里”,看似大好的局面就會像美麗的泡沫一樣,用針一戳,轉眼就消失得無影無蹤。
那麼我們是否應該爲了追求高性能,而不顧所謂“數據過期”所帶來的隱患呢?顯然,在類似於股票系統這種數據更新頻繁的特定場景下,數據過期的糟糕表現甚至比低效的性能更讓人難以接受。故而,我們需要在性能與數據正確性間作出權衡。所幸的是,.Net Framework 2.0引入了一種新的緩存機制,它爲我們的“魚與熊掌兼得”帶來了技術上的可行性。
.Net 2.0引入的自定義緩存依賴項,特別是基於MS-SQL Server的SqlCacheDependency特性,使得我們可以避免“數據過期”的問題,它能夠根據數據庫中相應數據的變化,通知緩存,並移除那些過期的數據。事實上,在PetShop 4.0中,就充分地利用了SqlCacheDependency特性。
4.2 SqlCacheDependency特性
SqlCacheDependency特性實際上是通過System.Web.Caching.SqlCacheDependency類來體現的。通過該類,可以在所有支持的SQL Server版本(7.0,2000,2005)上監視特定的SQL Server數據庫表,並創建依賴於該表以及表中數據行的緩存項。當數據表或表中特定行的數據發生更改時,具有依賴項的數據項就會失效,並自動從Cache中刪除該項,從而保證了緩存中不再保留過期的數據。
由於版本的原因,SQL Server 2005完全支持SqlCacheDependency特性,但對於SQL Server 7.0和SQL Server 2000而言,就沒有如此幸運了。畢竟這些產品出現在.Net Framework 2.0之前,因此它並沒有實現自動監視數據表數據變化,通知ASP.NET的功能。解決的辦法就是利用輪詢機制,通過ASP.NET進程內的一個線程以指定的時間間隔輪詢SQL Server數據庫,以跟蹤數據的變化情況。
要使得7.0或者2000版本的SQL Server支持SqlCacheDependency特性,需要對數據庫服務器執行相關的配置步驟。有兩種方法配置SQL Server:使用aspnet_regsql命令行工具,或者使用SqlCacheDependencyAdmin類。
4.2.1 利用aspnet_regsql工具
aspnet_regsql工具位於Windows\Microsoft.NET\Framework\[版本]文件夾中。如果直接雙擊該工具的執行文件,會彈出一個嚮導對話框,提示我們完成相應的操作:
圖4-1 aspnet_regsql工具
如圖4-1所示中的提示信息,說明該向導主要用於配置SQL Server數據庫,如membership,profiles等信息,如果要配置SqlCacheDependency,則需要以命令行的方式執行。以PetShop 4.0爲例,數據庫名爲MSPetShop4,則命令爲:
aspnet_regsql -S localhost -E -d MSPetShop4 -ed
以下是該工具的命令參數說明:
-? 顯示該工具的幫助功能;
-S 後接的參數爲數據庫服務器的名稱或者IP地址;
-U 後接的參數爲數據庫的登陸用戶名;
-P 後接的參數爲數據庫的登陸密碼;
-E 當使用windows集成驗證時,使用該功能;
-d 後接參數爲對哪一個數據庫採用SqlCacheDependency功能;
-t 後接參數爲對哪一個表採用SqlCacheDependency功能;
-ed 允許對數據庫使用SqlCacheDependency功能;
-dd 禁止對數據庫採用SqlCacheDependency功能;
-et 允許對數據表採用SqlCacheDependency功能;
-dt 禁止對數據表採用SqlCacheDependency功能;
-lt 列出當前數據庫中有哪些表已經採用sqlcachedependency功能。
以上面的命令爲例,說明將對名爲MSPetShop4的數據庫採用SqlCacheDependency功能,且SQL Server採用了windows集成驗證方式。我們還可以對相關的數據表執行aspnet_regsql命令,如:
aspnet_regsql -S localhost -E -d MSPetShop4 -t Item -et
aspnet_regsql -S localhost -E -d MSPetShop4 -t Product -et
aspnet_regsql -S localhost -E -d MSPetShop4 -t Category -et
當執行上述的四條命令後,aspnet_regsql工具會在MSPetShop4數據庫中建立一個名爲AspNet_SqlCacheTablesForChangeNotification的新數據庫表。該數據表包含三個字段。字段tableName記錄要追蹤的數據表的名稱,例如在PetShop 4.0中,要記錄的數據表就包括Category、Item和Product。notificationCreated字段記錄開始追蹤的時間。changeId作爲一個類型爲int的字段,用於記錄數據表數據發生變化的次數。如圖4-2所示:
圖4-2 AspNet_SqlCacheTablesForChangeNotification數據表
除此之外,執行該命令還會爲MSPetShop4數據庫添加一組存儲過程,爲ASP.NET提供查詢追蹤的數據表的情況,同時還將爲使用了SqlCacheDependency的表添加觸發器,分別對應Insert、Update、Delete等與數據更改相關的操作。例如Product數據表的觸發器:
CREATE TRIGGER dbo.[Product_AspNet_SqlCacheNotification_Trigger] ON [Product]
FOR INSERT, UPDATE, DELETE AS BEGIN
SET NOCOUNT ON
EXEC dbo.AspNet_SqlCacheUpdateChangeIdStoredProcedure N'Product'
END
其中,AspNet_SqlCacheUpdateChangeIdStoredProcedure即是工具添加的一組存儲過程中的一個。當對Product數據表執行Insert、Update或Delete等操作時,就會激活觸發器,然後執行AspNet_SqlCacheUpdateChangeIdStoredProcedure存儲過程。其執行的過程就是修改AspNet_SqlCacheTablesForChangeNotification數據表的changeId字段值:
CREATE PROCEDURE dbo.AspNet_SqlCacheUpdateChangeIdStoredProcedure
@tableName NVARCHAR(450)
AS
BEGIN
UPDATE dbo.AspNet_SqlCacheTablesForChangeNotification WITH (ROWLOCK) SET changeId = changeId + 1
WHERE tableName = @tableName
END
GO
4.2.2 利用SqlCacheDependencyAdmin類
我們也可以利用編程的方式來來管理數據庫對SqlCacheDependency特性的使用。該類包含了五個重要的方法:
DisableNotifications |
爲特定數據庫禁用 SqlCacheDependency對象更改通知 |
DisableTableForNotifications |
爲數據庫中的特定表禁用SqlCacheDependency對象更改通知 |
EnableNotifications |
爲特定數據庫啓用SqlCacheDependency對象更改通知 |
EnableTableForNotifications |
爲數據庫中的特定表啓用SqlCacheDependency對象更改通知 |
GetTablesEnabledForNotifications |
返回啓用了SqlCacheDependency對象更改通知的所有表的列表 |
表4-1 SqlCacheDependencyAdmin類的主要方法
假設我們定義瞭如下的數據庫連接字符串:
const string connectionStr = "Server=localhost;Database=MSPetShop4";
那麼爲數據庫MSPetShop4啓用SqlCacheDependency對象更改通知的實現爲:
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
SqlCacheDependencyAdmin.EnableNotifications(connectionStr);
}
}
爲數據表Product啓用SqlCacheDependency對象更改通知的實現則爲:
SqlCacheDependencyAdmin.EnableTableForNotifications(connectionStr, "Product");
如果要調用表4-1中所示的相關方法,需要注意的是訪問SQL Server數據庫的帳戶必須具有創建表和存儲過程的權限。如果要調用EnableTableForNotifications方法,還需要具有在該表上創建SQL Server觸發器的權限。
雖然說編程方式賦予了程序員更大的靈活性,但aspnet_regsql工具卻提供了更簡單的方法實現對SqlCacheDependency的配置與管理。PetShop 4.0採用的正是aspnet_regsql工具的辦法,它編寫了一個文件名爲InstallDatabases.cmd的批處理文件,其中包含了對aspnet_regsql工具的執行,並通過安裝程序去調用該文件,實現對SQL Server的配置。
4.3 在PetShop 4.0中ASP.NET緩存的實現
PetShop作爲一個B2C的寵物網上商店,需要充分考慮訪客的用戶體驗,如果因爲數據量大而導致Web服務器的響應不及時,頁面和查詢數據遲遲得不到結果,會因此而破壞客戶訪問網站的心情,在耗盡耐心的等待後,可能會失去這一部分客戶。無疑,這是非常糟糕的結果。因而在對其進行體系架構設計時,整個系統的性能就顯得殊爲重要。然而,我們不能因噎廢食,因爲專注於性能而忽略數據的正確性。在PetShop 3.0版本以及之前的版本,因爲ASP.NET緩存的侷限性,這一問題並沒有得到很好的解決。PetShop 4.0則引入了SqlCacheDependency特性,使得系統對緩存的處理較之以前大爲改觀。
4.3.1 CacheDependency接口
PetShop 4.0引入了SqlCacheDependency特性,對Category、Product和Item數據表對應的緩存實施了SQL Cache Invalidation技術。當對應的數據表數據發生更改後,該技術能夠將相關項從緩存中移除。實現這一技術的核心是SqlCacheDependency類,它繼承了CacheDependency類。然而爲了保證整個架構的可擴展性,我們也允許設計者建立自定義的CacheDependency類,用以擴展緩存依賴。這就有必要爲CacheDependency建立抽象接口,並在web.config文件中進行配置。
在PetShop 4.0的命名空間PetShop.ICacheDependency中,定義了名爲IPetShopCacheDependency接口,它僅包含了一個接口方法:
public interface IPetShopCacheDependency
{
AggregateCacheDependency GetDependency();
}
AggregateCacheDependency是.Net Framework 2.0新增的一個類,它負責監視依賴項對象的集合。當這個集合中的任意一個依賴項對象發生改變時,該依賴項對象對應的緩存對象都將被自動移除。
AggregateCacheDependency類起到了組合CacheDependency對象的作用,它可以將多個CacheDependency對象甚至於不同類型的CacheDependency對象與緩存項建立關聯。由於PetShop需要爲Category、Product和Item數據表建立依賴項,因而IPetShopCacheDependency的接口方法GetDependency()其目的就是返回建立了這些依賴項的AggregateCacheDependency對象。
4.3.2 CacheDependency實現
CacheDependency的實現正是爲Category、Product和Item數據表建立了對應的SqlCacheDependency類型的依賴項,如代碼所示:
public abstract class TableDependency : IPetShopCacheDependency
{
// This is the separator that's used in web.config
protected char[] configurationSeparator = new char[] { ',' };
protected AggregateCacheDependency dependency = new AggregateCacheDependency();
protected TableDependency(string configKey)
{
string dbName = ConfigurationManager.AppSettings["CacheDatabaseName"];
string tableConfig = ConfigurationManager.AppSettings[configKey];
string[] tables = tableConfig.Split(configurationSeparator);
foreach (string tableName in tables)
dependency.Add(new SqlCacheDependency(dbName, tableName));
}
public AggregateCacheDependency GetDependency()
{
return dependency;
}
}
需要建立依賴項的數據庫與數據表都配置在web.config文件中,其設置如下:
<add key="CacheDatabaseName" value="MSPetShop4"/>
<add key="CategoryTableDependency" value="Category"/>
<add key="ProductTableDependency" value="Product,Category"/>
<add key="ItemTableDependency" value="Product,Category,Item"/>
根據各個數據表間的依賴關係,因而不同的數據表需要建立的依賴項也是不相同的,從配置文件中的value值可以看出。然而不管建立依賴項的多寡,其創建的行爲邏輯都是相似的,因而在設計時,抽象了一個共同的類TableDependency,並通過建立帶參數的構造函數,完成對依賴項的建立。由於接口方法GetDependency()的實現中,返回的對象dependency是在受保護的構造函數創建的,因此這裏的實現方式也可以看作是Template Method模式的靈活運用。例如TableDependency的子類Product,就是利用父類的構造函數建立了Product、Category數據表的SqlCacheDependency依賴:
public class Product : TableDependency
{
public Product() : base("ProductTableDependency") { }
}
如果需要自定義CacheDependency,那麼創建依賴項的方式又有不同。然而不管是創建SqlCacheDependency對象,還是自定義的CacheDependency對象,都是將這些依賴項添加到AggregateCacheDependency類中,因而我們也可以爲自定義CacheDependency建立專門的類,只要實現IPetShopCacheDependency接口即可。
4.3.3 CacheDependency工廠
繼承了抽象類TableDependency的Product、Category和Item類均需要在調用時創建各自的對象。由於它們的父類TableDependency實現了接口IPetShopCacheDependency,因而它們也間接實現了IPetShopCacheDependency接口,這爲實現工廠模式提供了前提。
在PetShop 4.0中,依然利用了配置文件和反射技術來實現工廠模式。命名空間PetShop.CacheDependencyFactory中,類DependencyAccess即爲創建IPetShopCacheDependency對象的工廠類:
public static class DependencyAccess
{
public static IPetShopCacheDependency CreateCategoryDependency()
{
return LoadInstance("Category");
}
public static IPetShopCacheDependency CreateProductDependency()
{
return LoadInstance("Product");
}
public static IPetShopCacheDependency CreateItemDependency()
{
return LoadInstance("Item");
}
private static IPetShopCacheDependency LoadInstance(string className)
{
string path = ConfigurationManager.AppSettings["CacheDependencyAssembly"];
string fullyQualifiedClass = path + "." + className;
return (IPetShopCacheDependency)Assembly.Load(path).CreateInstance(fullyQualifiedClass);
}
}
整個工廠模式的實現如圖4-3所示:
圖4-3 CacheDependency工廠
雖然DependencyAccess類創建了實現了IPetShopCacheDependency接口的類Category、Product、Item,然而我們之所以引入IPetShopCacheDependency接口,其目的就在於獲得創建了依賴項的AggregateCacheDependency類型的對象。我們可以調用對象的接口方法GetDependency(),如下所示:
AggregateCacheDependency dependency = DependencyAccess.CreateCategoryDependency().GetDependency();
爲了方便調用者,似乎我們可以對DependencyAccess類進行改進,將原有的CreateCategoryDependency()方法,修改爲創建AggregateCacheDependency類型對象的方法。
然而這樣的做法擾亂了作爲工廠類的DependencyAccess的本身職責,且創建IPetShopCacheDependency接口對象的行爲仍然有可能被調用者調用,所以保留原有的DependencyAccess類仍然是有必要的。
在PetShop 4.0的設計中,是通過引入Facade模式以方便調用者更加簡單地獲得AggregateCacheDependency類型對象。
4.3.4 引入Facade模式
利用Facade模式可以將一些複雜的邏輯進行包裝,以方便調用者對這些複雜邏輯的調用。就好像提供一個統一的門面一般,將內部的子系統封裝起來,統一爲一個高層次的接口。一個典型的Facade模式示意圖如下所示:
圖4-4 Facade模式
Facade模式的目的並非要引入一個新的功能,而是在現有功能的基礎上提供一個更高層次的抽象,使得調用者可以直接調用,而不用關心內部的實現方式。以CacheDependency工廠爲例,我們需要爲調用者提供獲得AggregateCacheDependency對象的簡便方法,因而創建了DependencyFacade類:
public static class DependencyFacade
{
private static readonly string path = ConfigurationManager.AppSettings["CacheDependencyAssembly"];
public static AggregateCacheDependency GetCategoryDependency()
{
if (!string.IsNullOrEmpty(path))
return DependencyAccess.CreateCategoryDependency().GetDependency();
else
return null;
}
public static AggregateCacheDependency GetProductDependency()
{
if (!string.IsNullOrEmpty(path))
return DependencyAccess.CreateProductDependency().GetDependency();
else
return null;
}
public static AggregateCacheDependency GetItemDependency()
{
if (!string.IsNullOrEmpty(path))
return DependencyAccess.CreateItemDependency().GetDependency();
else
return null;
}
}
DependencyFacade類封裝了獲取AggregateCacheDependency類型對象的邏輯,如此一來,調用者可以調用相關方法獲得創建相關依賴項的AggregateCacheDependency類型對象:
AggregateCacheDependency dependency = DependencyFacade.GetCategoryDependency();
比起直接調用DependencyAccess類的GetDependency()方法而言,除了方法更簡單之外,同時它還對CacheDependencyAssembly配置節進行了判斷,如果其值爲空,則返回null對象。
在PetShop.Web的App_Code文件夾下,靜態類WebUtility的GetCategoryName()和GetProductName()方法調用了DependencyFacade類。例如GetCategoryName()方法:
public static string GetCategoryName(string categoryId)
{
Category category = new Category();
if (!enableCaching)
return category.GetCategory(categoryId).Name;
string cacheKey = string.Format(CATEGORY_NAME_KEY, categoryId);
// 檢查緩存中是否存在該數據項;
string data = (string)HttpRuntime.Cache[cacheKey];
if (data == null)
{
// 通過web.config的配置獲取duration值;
int cacheDuration = int.Parse(ConfigurationManager.AppSettings["CategoryCacheDuration"]);
// 如果緩存中不存在該數據項,則通過業務邏輯層訪問數據庫獲取;
data = category.GetCategory(categoryId).Name;
// 通過Facade類創建AggregateCacheDependency對象;
AggregateCacheDependency cd = DependencyFacade.GetCategoryDependency();
// 將數據項以及AggregateCacheDependency 對象存儲到緩存中;
HttpRuntime.Cache.Add(cacheKey, data, cd, DateTime.Now.AddHours(cacheDuration), Cache.NoSlidingExpiration, CacheItemPriority.High, null);
}
return data;
}
GetCategoryName()方法首先會檢查緩存中是否已經存在CategoryName數據項,如果已經存在,就通過緩存直接獲取數據;否則將通過業務邏輯層調用數據訪問層訪問數據庫獲得CategoryName,在獲得了CategoryName後,會將新獲取的數據連同DependencyFacade類創建的AggregateCacheDependency對象添加到緩存中。
WebUtility靜態類被表示層的許多頁面所調用,例如Product頁面:
public partial class Products : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
Page.Title = WebUtility.GetCategoryName(Request.QueryString["categoryId"]);
}
}
顯示頁面title的邏輯是放在Page_Load事件方法中,因而每次打開該頁面都要執行獲取CategoryName的方法。如果沒有采用緩存機制,當Category數據較多時,頁面的顯示就會非常緩慢。
4.3.5 引入Proxy模式
業務邏輯層BLL中與Product、Category、Item有關的業務方法,其實現邏輯是調用數據訪問層(DAL)對象訪問數據庫,以獲取相關數據。爲了改善系統性能,我們就需要爲這些實現方法增加緩存機制的邏輯。當我們操作增加了緩存機制的業務對象時,對於調用者而言,應與BLL業務對象的調用保持一致。也即是說,我們需要引入一個新的對象去控制原來的BLL業務對象,這個新的對象就是Proxy模式中的代理對象。
以PetShop.BLL.Product業務對象爲例,PetShop爲其建立了代理對象ProductDataProxy,並在GetProductByCategory()等方法中,引入了緩存機制,例如:
public static class ProductDataProxy
{
private static readonly int productTimeout = int.Parse(ConfigurationManager.AppSettings["ProductCacheDuration"]);
private static readonly bool enableCaching = bool.Parse(ConfigurationManager.AppSettings["EnableCaching"]);
public static IList
GetProductsByCategory(string category)
{
Product product = new Product();
if (!enableCaching)
return product.GetProductsByCategory(category);
string key = "product_by_category_" + category;
IList data = (IList )HttpRuntime.Cache[key];
// Check if the data exists in the data cache
if (data == null)
{
data = product.GetProductsByCategory(category);
// Create a AggregateCacheDependency object from the factory
AggregateCacheDependency cd = DependencyFacade.GetProductDependency();
// Store the output in the data cache, and Add the necessary AggregateCacheDependency object
HttpRuntime.Cache.Add(key, data, cd, DateTime.Now.AddHours(productTimeout), Cache.NoSlidingExpiration, CacheItemPriority.High, null);
}
return data;
}
}
與業務邏輯層Product對象的GetProductsByCategory()方法相比,增加了緩存機制。當緩存內不存在相關數據項時,則直接調用業務邏輯層Product的GetProductsByCategory()方法來獲取數據,並將其與對應的AggregateCacheDependency對象一起存儲在緩存中。
引入Proxy模式,實現了在緩存級別上對業務對象的封裝,增強了對業務對象的控制。由於暴露在對象外的方法是一致的,因而對於調用方而言,調用代理對象與真實對象並沒有實質的區別。
從職責分離與分層設計的角度分析,我更希望這些Proxy對象是被定義在業務邏輯層中,而不像在PetShop的設計那樣,被劃分到表示層UI中。此外,如果需要考慮程序的可擴展性與可替換性,我們還可以爲真實對象與代理對象建立統一的接口或抽象類。然而,單以PetShop的表示層調用來看,採用靜態類與靜態方法的方式,或許更爲合理。我們需要謹記,“過度設計”是軟件設計的警戒線。
如果需要對UI層採用緩存機制,將應用程序數據存放到緩存中,就可以調用這些代理對象。以ProductsControl用戶控件爲例,調用方式如下:
productsList.DataSource = ProductDataProxy.GetProductsByCategory(categoryKey);
productsList對象屬於自定義的CustomList類型,這是一個派生自System.Web.UI.WebControls.DataList控件的類,它的DataSource屬性可以接受IList集合對象。
不過在PetShop 4.0的設計中,對於類似於ProductsControl類型的控件而言,採用的緩存機制是頁輸出緩存。我們可以從ProductsControl.ascx頁面的Source代碼中發現端倪:
<%@ OutputCache Duration="100000" VaryByParam="page;categoryId" %>
與ASP.NET 1.x的頁輸出緩存不同的是,在ASP.NET 2.0中,爲ASP.NET用戶控件新引入了CachePolicy屬性,該屬性的類型爲ControlCachePolicy類,它以編程方式實現了對ASP.NET用戶控件的輸出緩存設置。我們可以通過設置ControlCachePolicy類的Dependency屬性,來設置與該用戶控件相關的依賴項,例如在ProductsControl用戶控件中,進行如下的設置:
protected void Page_Load(object sender, EventArgs e)
{
this.CachePolicy.Dependency = DependencyFacade.GetProductDependency();
}
採用頁輸出緩存,並且利用ControlCachePolicy設置輸出緩存,能夠將業務數據與整個頁面放入到緩存中。這種方式比起應用程序緩存而言,在性能上有很大的提高。同時,它又通過引入的SqlCacheDependency特性有效地避免了“數據過期”的缺點,因而在PetShop 4.0中被廣泛採用。相反,之前爲Product、Category、Item業務對象建立的代理對象則被“投閒散置”,僅僅作爲一種設計方法的展示而“倖存”與整個系統的源代碼中。
五 PetShop之業務邏輯層設計
業務邏輯層(Business Logic Layer)無疑是系統架構中體現核心價值的部分。它的關注點主要集中在業務規則的制定、業務流程的實現等與業務需求有關的系統設計,也即是說它是與系統所應對的領域(Domain)邏輯有關,很多時候,我們也將業務邏輯層稱爲領域層。例如Martin Fowler在《Patterns of Enterprise Application Architecture》一書中,將整個架構分爲三個主要的層:表示層、領域層和數據源層。作爲領域驅動設計的先驅Eric Evans,對業務邏輯層作了更細緻地劃分,細分爲應用層與領域層,通過分層進一步將領域邏輯與領域邏輯的解決方案分離。
業務邏輯層在體系架構中的位置很關鍵,它處於數據訪問層與表示層中間,起到了數據交換中承上啓下的作用。由於層是一種弱耦合結構,層與層之間的依賴是向下的,底層對於上層而言是“無知”的,改變上層的設計對於其調用的底層而言沒有任何影響。如果在分層設計時,遵循了面向接口設計的思想,那麼這種向下的依賴也應該是一種弱依賴關係。因而在不改變接口定義的前提下,理想的分層式架構,應該是一個支持可抽取、可替換的“抽屜”式架構。正因爲如此,業務邏輯層的設計對於一個支持可擴展的架構尤爲關鍵,因爲它扮演了兩個不同的角色。對於數據訪問層而言,它是調用者;對於表示層而言,它卻是被調用者。依賴與被依賴的關係都糾結在業務邏輯層上,如何實現依賴關係的解耦,則是除了實現業務邏輯之外留給設計師的任務。
5.1 與領域專家合作
設計業務邏輯層最大的障礙不在於技術,而在於對領域業務的分析與理解。很難想象一個不熟悉該領域業務規則和流程的架構設計師能夠設計出合乎客戶需求的系統架構。幾乎可以下定結論的是,業務邏輯層的設計過程必須有領域專家的參與。在我曾經參與開發的項目中,所涉及的領域就涵蓋了電力、半導體、汽車等諸多行業,如果缺乏這些領域的專家,軟件架構的設計尤其是業務邏輯層的設計就無從談起。這個結論唯一的例外是,架構設計師同時又是該領域的專家。然而,正所謂“千軍易得,一將難求”,我們很難尋覓到這樣卓越出衆的人才。
領域專家在團隊中扮演的角色通常稱爲Business Consultor(業務諮詢師),負責提供與領域業務有關的諮詢,與架構師一起參與架構與數據庫的設計,撰寫需求文檔和設計用例(或者用戶故事User Story)。如果在測試階段,還應該包括撰寫測試用例。理想的狀態是,領域專家應該參與到整個項目的開發過程中,而不僅僅是需求階段。
領域專家可以是專門聘請的對該領域具有較深造詣的諮詢師,也可以是作爲需求提供方的客戶。在極限編程(Extreme Programming)中,就將客戶作爲領域專家引入到整個開發團隊中。它強調了現場客戶原則。現場客戶需要參與到計劃遊戲、開發迭代、編碼測試等項目開發的各個階段。由於領域專家與設計師以及開發人員組成了一個團隊,貫穿開發過程的始終,就可以避免需求理解錯誤的情況出現。即使項目的開發與實際需求不符,也可以在項目早期及時修正,從而避免了項目不必要的延期,加強了對項目過程和成本的控制。正如Steve McConnell在構建活動的前期準備中提及的一個原則:發現錯誤的時間要儘可能接近引入該錯誤的時間。需求的缺陷在系統中潛伏的時間越長,代價就越昂貴。如果在項目開發中能夠與領域專家充分的合作,就可以最大效果地規避這樣一種惡性的鏈式反應。
傳統的軟件開發模型同樣重視與領域專家的合作,但這種合作主要集中在需求分析階段。例如瀑布模型,就非常強調早期計劃與需求調研。然而這種未雨綢繆的早期計劃方式,對架構師與需求調研人員的技能要求非常高,它強調需求文檔的精確性,一旦分析出現偏差,或者需求發生變更,當項目開發進入設計階段後,由於缺乏與領域專家溝通與合作的機制,開發人員估量不到這些錯誤與誤差,因而難以及時作出修正。一旦這些問題像毒瘤一般在系統中蔓延開來,逐漸暴露在開發人員面前時,已經成了一座難以逾越的高山。我們需要消耗更多的人力物力,才能夠修正這些錯誤,從而導致開發成本成數量級的增加,甚至於導致項目延期。當然還有一個好的選擇,就是放棄整個項目。這樣的例子不勝枚舉,事實上,項目開發的“滑鐵盧”,究其原因,大部分都是因爲業務邏輯分析上出現了問題。
迭代式模型較之瀑布模型有很大地改進,因爲它允許變更、優化系統需求,整個迭代過程實際上就是與領域專家的合作過程,通過向客戶演示迭代所產生的系統功能,從而及時獲取反饋,並逐一解決迭代演示中出現的問題,保證系統向着合乎客戶需求的方向演化。因而,迭代式模型往往能夠解決早期計劃不足的問題,它允許在發現缺陷的時候,在需求變更的時候重新設計、重新編碼並重新測試。
無論採用何種開發模型,與領域專家的合作都將成爲項目成敗與否的關鍵。這基於一個軟件開發的普遍真理,那就是世界上沒有不變的需求。一句經典名言是:“沒有不變的需求,世上的軟件都改動過3次以上,唯一一個只改動過兩次的軟件的擁有者已經死了,死在去修改需求的路上。”一語道盡了軟件開發的殘酷與艱辛!
那麼應該如何加強與領域專家的合作呢?James Carey和Brent Carlson根據他們在參與的IBM SanFrancisco項目中獲得的經驗,提出了Innocent Questions模式,其意義即“改進領域專家和技術專家的溝通質量”。在一個項目團隊中,如果我們沒有一位既能擔任首席架構師,同時又是領域專家的人選,那麼加強領域專家與技術專家的合作就顯得尤爲重要了。畢竟,作爲一個領域專家而言,可能並不熟悉軟件設計方法學,也不具備面向對象開發和架構設計的能力,同樣,大部分技術專家很有可能對該項目所涉及的業務領域僅停留在一知半解的地步。如果領域專家與技術專家不能有效溝通,則整個項目的前途就岌岌可危了。
Innocent Questions模式提出的解決方案包括:
(1)選用可以與人和諧相處的人員組建開發團隊;
(2)清楚地定義角色和職權;
(3)明確定義需要的交互點;
(4)保持團隊緊密;
(5)僱傭優秀的人。
事實上,這已經從技術的角度上升到對團隊的管理層次了。就好比籃球運動一樣,即使你的球隊集合了五名世界上最頂尖最有天賦的球員,如果各自爲戰,要想取得比賽的勝利依舊是非常困難的。團隊精神與權責分明纔是取得勝利的保障,軟件開發同樣如此。
與領域專家合作的基礎是保證開發團隊中永遠保留至少一名領域專家。他可以是系統的客戶,第三方公司的諮詢師,最理想是自己公司僱傭的專家。如果項目中缺乏這樣的一個人,那麼我的建議是去僱傭他,如果你不想看到項目遭遇“西伯利亞寒流”的話。
確定領域專家的角色任務與職責。必須要讓團隊中的每一個人明確領域專家在整個團隊中究竟扮演什麼樣的角色,他的職責是什麼。一個合格的領域專家必須對業務領域有足夠深入的理解,他應該是一個能夠俯瞰整個系統需求、總攬全局的人物。在項目開發過程中,將由他負責業務規則和流程的制定,負責與客戶的溝通,需求的調研與討論,並於設計師一起參與系統架構的設計。編檔是領域專家必須參與的工作,無論是需求文檔還是設計文檔,以及用例的編寫,領域專家或者提出意見,或者作爲撰寫的作者,至少他也應該是評審委員會的重要成員。
規範業務領域的術語和技術術語。領域專家和技術專家必須在保證不產生二義性的語義環境下進行溝通與交流。如果出現理解上的分歧,我們必須及時解決,通過討論確立術語標準。很難想象兩個語言不通的人能夠相互合作愉快,解決的辦法是加入一位翻譯人員。在領域專家與技術專家之間搭建一座語義上的橋樑,使其能夠相互理解、相互認同。還有一個辦法是在團隊內部開展培訓活動。尤其對於開發人員而言,或多或少地瞭解一些業務領域知識,對於項目的開發有很大的幫助。在我參與過的半導體領域的項目開發,團隊就專門邀請了半導體行業的專家就生產過程的業務邏輯進行了全方位的介紹與培訓。正所謂“磨刀不誤砍柴工”,雖然我們消費了培訓的時間,但對於掌握了業務規則與流程的開發人員,卻能夠提升項目開發進度,總體上節約了開發成本。
加強與客戶的溝通。客戶同時也可以作爲團隊的領域專家,極限編程的現場客戶原則是最好的示例。但現實並不都如此的完美,在無法要求客戶成爲開發團隊中的固定一員時,聘請或者安排一個專門的領域專家,加強與客戶的溝通,就顯得尤爲重要。項目可以通過領域專家獲得客戶的及時反饋。而通過領域專家去了解變更了的需求,會在最大程度上減少需求誤差的可能。
5.2 業務邏輯層的模式應用
Martin Fowler在《企業應用架構模式》一書中對領域層(即業務邏輯層)的架構模式作了整體概括,他將業務邏輯設計分爲三種主要的模式:Transaction Script、Domain Model和Table Module。
Transaction Script模式將業務邏輯看作是一個個過程,是比較典型的面向過程開發模式。應用Transaction Script模式可以不需要數據訪問層,而是利用SQL語句直接訪問數據庫。爲了有效地管理SQL語句,可以將與數據庫訪問有關的行爲放到一個專門的Gateway類中。應用Transaction Script模式不需要太多面向對象知識,簡單直接的特性是該模式全部價值之所在。因而,在許多業務邏輯相對簡單的項目中,應用Transaction Script模式較多。
Domain Model模式是典型的面向對象設計思想的體現。它充分考慮了業務邏輯的複雜多變,引入了Strategy模式等設計模式思想,並通過建立領域對象以及抽象接口,實現模式的可擴展性,並利用面向對象思想與身俱來的特性,如繼承、封裝與多態,用於處理複雜多變的業務邏輯。唯一制約該模式應用的是對象與關係數據庫的映射。我們可以引入ORM工具,或者利用Data Mapper模式來完成關係向對象的映射。
與Domain Model模式相似的是Table Module模式,它同樣具有面向對象設計的思想,唯一不同的是它獲得的對象並非是單純的領域對象,而是DataSet對象。如果爲關係數據表與對象建立一個簡單的映射關係,那麼Domain Model模式就是爲數據表中的每一條記錄建立一個領域對象,而Table Module模式則是將整個數據表看作是一個完整的對象。雖然利用DataSet對象會丟失面向對象的基本特性,但它在爲表示層提供數據源支持方面卻有着得天獨厚的優勢。尤其是在.Net平臺下,ADO.NET與Web控件都爲Table Module模式提供了生長的肥沃土壤。
5.3 PetShop的業務邏輯層設計
PetShop在業務邏輯層設計中引入了Domain Model模式,這與數據訪問層對於數據對象的支持是分不開的。由於PetShop並沒有對寵物網上商店的業務邏輯進行深入,也省略了許多複雜細節的商務邏輯,因而在Domain Model模式的應用上並不明顯。最典型地應該是對Order領域對象的處理方式,通過引入Strategy模式完成對插入訂單行爲的封裝。關於這一點,我已在第27章有了詳盡的描述,這裏就不再贅述。
本應是系統架構設計中最核心的業務邏輯層,由於簡化了業務流程的緣故,使得PetShop在這一層的設計有些乏善可陳。雖然在業務邏輯層中,針對B2C業務定義了相關的領域對象,但這些領域對象僅僅是完成了對數據訪問層中數據對象的簡單封裝而已,其目的僅在於分離層次,以支持對各種數據庫的擴展,同時將SQL語句排除在業務邏輯層外,避免了SQL語句的四處蔓延。
最能體現PetShop業務邏輯的除了對訂單的管理之外,還包括購物車(Shopping Cart)與Wish List的管理。在PetShop的BLL模塊中,定義了Cart類來負責相關的業務邏輯,定義如下:
[Serializable]
public class Cart
{
private Dictionary cartItems = new Dictionary();
public decimal Total
{
get
{
decimal total = 0;
foreach (CartItemInfo item in cartItems.Values)
total += item.Price * item.Quantity;
return total;
}
}
public void SetQuantity(string itemId, int qty)
{
cartItems[itemId].Quantity = qty;
}
public int Count
{
get { return cartItems.Count; }
}
public void Add(string itemId)
{
CartItemInfo cartItem;
if (!cartItems.TryGetValue(itemId, out cartItem))
{
Item item = new Item();
ItemInfo data = item.GetItem(itemId);
if (data != null)
{
CartItemInfo newItem = new CartItemInfo(itemId, data.ProductName, 1, (decimal)data.Price, data.Name, data.CategoryId, data.ProductId);
cartItems.Add(itemId, newItem);
}
}
else
cartItem.Quantity++;
}
//其他方法略;
}
Cart類通過一個Dictionary對象來負責對購物車內容的存儲,同時定義了Add、Remove、Clear等方法,來實現對購物車內容的管理。
在前面我提到PetShop業務邏輯層中的領域對象僅僅是完成對數據對象的簡單封裝,但這種分離層次的方法在架構設計中依然扮演了舉足輕重的作用。以Cart類的Add()方法爲例,在方法內部引入了PetShop.BLL.Item領域對象,並調用了Item對象的GetItem()方法。如果沒有在業務邏輯層封裝Item對象,而是直接調用數據訪問層的Item數據對象,爲保證層次間的弱依賴關係,就需要調用工廠對象的工廠方法來創建PetShop.IDAL.IItem接口類型對象。一旦數據訪問層的Item對象被多次調用,就會造成重複代碼,既不離於程序的修改與擴展,也導致程序結構生長爲臃腫的態勢。
此外,領域對象對數據訪問層數據對象的封裝,也有利於表示層對業務邏輯層的調用。在三層式架構中,表示層應該是對於數據訪問層是“無知”的,這樣既減少了層與層間的依賴關係,也能有效避免“循環依賴”的後果。
值得商榷的是Cart類的Total屬性。其值的獲取是通過遍歷購物車集合,然後累加價格與商品數量的乘積。這裏顯然簡化了業務邏輯,而沒有充分考慮需求的擴展。事實上,這種獲取購物車總價格的算法,在大多數情況下僅僅是其中的一種策略而已,我們還應該考慮折扣的情況。例如,當總價格超過100元時,可以給與顧客一定的折扣,這是與網站的促銷計劃相關的。除了給與折扣的促銷計劃外,網站也可以考慮贈送禮品的促銷策略,因此我們有必要引入Strategy模式,定義接口IOnSaleStrategy:
public interface IOnSaleStrategy
{
decimal CalculateTotalPrice(Dictionary cartItems);
}
如此一來,我們可以爲Cart類定義一個有參數的構造函數:
private IOnSaleStrategy m_onSale;
public Cart(IOnSaleStrategy onSale)
{
m_onSale = onSale;
}
那麼Total屬性就可以修改爲:
public decimal Total
{
get {return m_onSale.CalculateTotalPrice(cartItems);}
}
如此一來,就可以使得Cart類能夠有效地支持網站推出的促銷計劃,也符合開-閉原則。同樣的,這種設計方式也是Domain Model模式的體現。修改後的設計如圖5-1所示:
圖5-1 引入Strategy模式
作爲一個B2C的電子商務架構,它所涉及的業務領域已爲大部分設計師與開發人員所熟悉,因而在本例中,與領域專家的合作顯得並不那麼重要。然而,如果我們要開發一個成功的電子商務網站,與領域專家的合作仍然是必不可少的。以訂單的管理而言,如果考慮複雜的商業應用,就需要管理訂單的跟蹤(Tracking),與網上銀行的合作,賬戶安全性,庫存管理,物流管理,以及客戶關係管理(CRM)。整個業務過程卻涵蓋了諸如電子商務、銀行、物流、客戶關係學等諸多領域,如果沒有領域專家的參與,業務邏輯層的設計也許會“敗走麥城”。
5.4 與數據訪問層的通信
業務邏輯層需要與數據訪問層通信,利用數據訪問層訪問數據庫,因此業務邏輯層與數據訪問層之間就存在依賴關係。在數據訪問層引入接口程序集以及數據工廠的設計前提下,能夠做到兩者間關係爲弱依賴。我們從業務邏輯層的引用程序集中可以看到,BLL模塊並沒有引用SQLServerDAL和OracleDAL程序集。在業務邏輯層中,有關數據訪問層中數據對象的調用,均利用多態原理定義了抽象的接口類型對象,然後利用工廠對象的工廠方法創建具體的數據對象。如PetShop.BLL.PetShop領域對象所示:
namespace PetShop.BLL
{
public class Product
{
//根據工廠對象創建IProduct接口類型實例;
private static readonly IProduct dal = PetShop.DALFactory.DataAccess.CreateProduct();
//調用IProduct對象的接口方法GetProductByCategory();
public IList
GetProductsByCategory(string category)
{
// 如果爲空則新建List對象;
if(string.IsNullOrEmpty(category))
return new List ();
// 通過數據訪問層的數據對象訪問數據庫;
return dal.GetProductsByCategory(category);
}
//其他方法略;
}
}
在領域對象Product類中,利用數據訪問層的工廠類DALFactory.DataAccess創建PetShop.IDAL.IProduct類型的實例,如此就可以解除對具體程序集SQLServerDAL或OracleDAL的依賴。只要PetShop.IDAL的接口方法不變,即使修改了IDAL接口模塊的具體實現,都不會影響業務邏輯層的實現。這種鬆散的弱耦合關係,才能夠最大程度地支持架構的可擴展。
領域對象Product實際上還完成了對數據對象Product的封裝,它們暴露在外的接口方法是一致地,正是通過封裝,使得表示層可以完全脫離數據庫以及數據訪問層,表示層的調用者僅需要關注業務邏輯層的實現邏輯,以及領域對象暴露的接口和調用方式。事實上,只要設計合理,規範了各個層次的接口方法,三層式架構的設計完全可以分離開由不同的開發人員同時開發,這就可以有效地利用開發資源,縮短項目開發週期。
5.5 面向接口設計
也許是業務邏輯比較簡單地緣故,在業務邏輯層的設計中,並沒有秉承在數據訪問層中面向接口設計的思想。除了完成對插入訂單策略的抽象外,整個業務邏輯層僅以BLL模塊實現,沒有爲領域對象定義抽象的接口。因而PetShop的表示層與業務邏輯層就存在強依賴關係,如果業務邏輯層中的需求發生變更,就必然會影響表示層的實現。唯一可堪欣慰的是,由於我們採用分層式架構將用戶界面與業務領域邏輯完全分離,一旦用戶界面發生更改,例如將B/S架構修改爲C/S架構,那麼業務邏輯層的實現模塊是可以完全重用的。
然而,最理想的方式仍然是面向接口設計。根據第28章對ASP.NET緩存的分析,我們可以將表示層App_Code下的Proxy類與Utility類劃分到業務邏輯層中,並修改這些靜態類爲實例類,並將這些類中與業務領域有關的方法抽象爲接口,然後建立如數據訪問層一樣的抽象工廠。通過“依賴注入”方式,解除與具體領域對象類的依賴,使得表示層僅依賴於業務邏輯層的接口程序集以及工廠模塊。
那麼,這樣的設計是否有“過度設計”的嫌疑呢?我們需要依據業務邏輯的需求情況而定。此外,如果我們需要引入緩存機制,爲領域對象創建代理類,那麼爲領域對象建立接口,就顯得尤爲必要。我們可以建立一個專門的接口模塊IBLL,用以定義領域對象的接口。以Product領域對象爲例,我們可以建立IProduct接口:
public interface IProduct
{
IList GetProductByCategory(string category);
IList GetProductByCategory(string[] keywords);
ProductInfo GetProduct(string productId);
}
在BLL模塊中可以引入對IBLL程序集的依賴,則領域對象Product的定義如下:
public class Product:IProduct
{
public IList GetProductByCategory(string category) { //實現略; }
public IList GetProductByCategory(string[] keywords) { //實現略; }
public ProductInfo GetProduct(string productId) { //實現略; }
}
然後我們可以爲代理對象建立專門的程序集BLLProxy,它不僅引入對IBLL程序集的依賴,同時還將依賴於BLL程序集。此時代理對象ProductDataProxy的定義如下:
using PetShop.IBLL;
using PetShop.BLL;
namespace PetShop.BLLProxy
{
public class ProductDataProxy:IProduct
{
public IList GetProductByCategory(string category)
{
Product product = new Product();
//其他實現略;
}
public IList GetProductByCategory(string[] keywords) { //實現略; }
public ProductInfo GetProduct(string productId) { //實現略; }
}
}
如此的設計正是典型的Proxy模式,其類結構如圖5-2所示:
圖5-2 Proxy模式
參照數據訪問層的設計方法,我們可以爲領域對象及代理對象建立抽象工廠,並在web.config中配置相關的配置節,然後利用反射技術創建具體的對象實例。如此一來,表示層就可以僅僅依賴PetShop.IBLL程序集以及工廠模塊,如此就可以解除表示層與具體領域對象之間的依賴關係。表示層與修改後的業務邏輯層的關係如圖5-3所示:
圖5-3 修改後的業務邏輯層與表示層的關係
圖5-4則是PetShop 4.0原有設計的層次關係圖:
圖5-4 PetShop 4.0中表示層與業務邏輯層的關係
通過比較圖5-3與圖5-4,雖然後者不管是模塊的個數,還是模塊之間的關係,都相對更加簡單,然而Web Component組件與業務邏輯層之間卻是強耦合的,這樣的設計不利於應對業務擴展與需求變更。通過引入接口模塊IBLL與工廠模塊BLLFactory,解除了與具體模塊BLL的依賴關係。這種設計對於業務邏輯相對比較複雜的系統而言,更符合面向對象的設計思想,有利於我們建立可抽取、可替換的“抽屜”式三層架構。
六 PetShop之表示層設計
表示層(Presentation Layer)的設計可以給系統客戶最直接的體驗和最十足的信心。正如人與人的相交相識一樣,初次見面的感覺總是永難忘懷的。一件交付給客戶使用的產品,如果在用戶界面(User Interface,UI)上缺乏吸引人的特色,界面不友好,操作不夠體貼,即使這件產品性能非常優異,架構設計合理,業務邏輯都滿足了客戶的需求,卻仍然難以討得客戶的歡心。俗語云:“佛要金裝,人要衣裝”,特別是對於Web應用程序而言,Web網頁就好比人的衣裝,代表着整個系統的身份與臉面,是招徠“顧客”的最大賣點。
“獻醜不如藏拙”,作爲藝術細胞缺乏的我,並不打算在用戶界面的美術設計上大做文章,是以本書略過不提。本章所關注的表示層設計,還是以架構設計的角度,闡述在表示層設計中對模式的應用,ASP.NET控件的設計與運用,同時還包括了對ASP.NET 2.0新特色的介紹。
6.1 MVC模式
表示層設計中最重要的模式是MVC(Model-View-Controller,即模型-視圖-控制器)模式。MVC模式最早是由SmallTalk語言研究團提出的,被廣泛應用在用戶交互應用程序中。Controller根據用戶請求(Response)修改Model的屬性,此時Event(事件)被觸發,所有依賴於Model的View對象會自動更新,並基於Model對象產生一個響應(Response)信息,返回給Controller。Martin Fowler在《企業應用架構模式》一書中,展示了MVC模式應用的全過程,如圖6-1所示:
圖6-1 典型的MVC模式
如果將MVC模式拆解爲三個獨立的部分:Model、View、Controller,我們可以通過GOF設計模式來實現和管理它們之間的關係。在體系架構設計中,業務邏輯層的領域對象以及數據訪問層的數據值對象都屬於MVC模式的Model對象。如果要管理Model與View之間的關係,可以利用Observer模式,View作爲觀察者,一旦Model的屬性值發生變化,就會通知View基於Model的值進行更新。而Controller作爲控制用戶請求/響應的對象,則可以利用Mediator模式,專門負責請求/響應任務之間的調節。而對於View本身,在面向組件設計思想的基礎上,我們通常將它設計爲組件或者控件,這些組件或者控件根據自身特性的不同,共同組成一種類似於遞歸組合的對象結構,因而我們可以利用Composite模式來設計View對象。
然而在.NET平臺下,我們並不需要自己去實現MVC模式。對於View對象而言,ASP.NET已經提供了常用的Web控件,我們也可以通過繼承System.Web.UI.UserControl,自定義用戶控件,並利用ASPX頁面組合Web控件來實現視圖。ASP.NET定義了System.Web.UI.Page類,它相當於MVC模式的Controller對象,可以處理用戶的請求。由於利用了codebehind技術,使得用戶界面的顯示與UI實現邏輯完全分離,也即是說,View對象與Controller對象成爲相對獨立的兩部分,從而有利於代碼的重用性。比較ASP而言,這種編程方式更符合開發人員的編程習慣,同時有利於開發人員與UI設計人員的分工與協作。至於Model對象,則爲業務邏輯層的領域對象。此外,.NET平臺通過ADO.NET提供了DataSet對象,便於與Web控件的數據源綁定。
6.2 Page Controller模式的應用
通觀PetShop的表示層設計,充分利用了ASP.NET的技術特點,通過Web頁面與用戶控件控制和展現視圖,並利用codebehind技術將業務邏輯層的領域對象加入到表示層實現邏輯中,一個典型的Page Controller模式呼之欲出。
Page Controller模式是Martin Fowler在《企業應用架構模式》中最重要的表示層模式之一。在.NET平臺下,Page Controller模式的實現非常簡單,以Products.aspx頁面爲例。首先在aspx頁面中,進行如下的設置:
Aspx頁面繼承自System.Web.UI.Page類。Page類對象通過繼承System.Web.UI.Control類,從而擁有了Web控件的特性,同時它還實現了IHttpHandler接口。作爲ASP.NET處理HTTP Web請求的接口,提供瞭如下的定義:
Level=AspNetHostingPermissionLevel.Minimal),
AspNetHostingPermission(SecurityAction.LinkDemand,
Level=AspNetHostingPermissionLevel.Minimal)]
public interface IHttpHandler
{
void ProcessRequest(HttpContext context);
bool IsReusable { get; }
}
Page類實現了ProcessRequest()方法,通過它可以設置Page對象的Request和Response屬性,從而完成對用戶請求/相應的控制。然後Page類通過從Control類繼承來的Load事件,將View與Model建立關聯,如Products.aspx.cs所示:
{
protected void Page_Load(object sender, EventArgs e)
{
//get page header and title
Page.Title = WebUtility.GetCategoryName(Request.QueryString["categoryId"]);
}
}
事件機制恰好是observer模式的實現,當ASPX頁面的Load事件被激發後,系統通過WebUtility類(在第28章中有對WebUtility類的詳細介紹)的GetCategoryName()方法,獲得Category值,並將其顯示在頁面的Title上。Page對象作爲Controller,就好似一個調停者,用於協調View與Model之間的關係。
由於ASPX頁面中還可以包含Web控件,這些控件對象同樣是作爲View對象,通過Page類型對象完成對它們的控制。例如在CheckOut.aspx頁面中,當用戶發出CheckOut的請求後,作爲System.Web.UI.WebControls.Winzard控件類型的wzdCheckOut,會在整個嚮導過程結束時,觸發FinishButtonClick事件,並在該事件中調用領域對象Order的Insert()方法,如下所示:
protected void wzdCheckOut_FinishButtonClick(object sender, WizardNavigationEventArgs e) {
if (Profile.ShoppingCart.CartItems.Count > 0) {
if (Profile.ShoppingCart.Count > 0) {
// display ordered items
CartListOrdered.Bind(Profile.ShoppingCart.CartItems);
// display total and credit card information
ltlTotalComplete.Text = ltlTotal.Text;
ltlCreditCardComplete.Text = ltlCreditCard.Text;
// create order
OrderInfo order = new OrderInfo(int.MinValue, DateTime.Now, User.Identity.Name, GetCreditCardInfo(), billingForm.Address, shippingForm.Address, Profile.ShoppingCart.Total, Profile.ShoppingCart.GetOrderLineItems(), null);
// insert
Order newOrder = new Order();
newOrder.Insert(order);
// destroy cart
Profile.ShoppingCart.Clear();
Profile.Save();
}
}
else {
lblMsg.Text = "<p><br>Can not process the order. Your cart is empty.</p><p class=SignUpLabel><a class=linkNewUser href=Default.aspx>Continue shopping</a></p>";
wzdCheckOut.Visible = false;
}
}
在上面的一段代碼中,非常典型地表達了Model與View之間的關係。它通過獲取控件的屬性值,作爲參數值傳遞給數據值對象OrderInfo,從而利用頁面上產生的訂單信息創建訂單對象,然後再調用領域對象Order的Inser()方法將OrderInfo對象插入到數據表中。此外,它還對領域對象ShoppingCart的數據項作出判斷,如果其值等於0,就在頁面中顯示UI提示信息。此時,View的內容決定了Model的值,而Model值反過來又決定了View的顯示內容。
6.3 ASP.NET控件
ASP.NET控件是View對象最重要的組成部分,它充分利用了面向對象的設計思想,通過封裝與繼承構建一個個控件對象,使得用戶在開發Web頁面時,能夠重用這些控件,甚至自定義自己的控件。在第8章中,我已經介紹了.NET Framework中控件的設計思想,通過引入一種“複合方式”的Composite模式實現了控件樹。在ASP.NET控件中,System.Web.UI.Control就是這棵控件樹的根,它定義了所有ASP.NET控件共有的屬性、方法和事件,並負責管理和控制控件的整個執行生命週期。
Control基類並沒有包含UI的特定功能,如果需要提供與UI相關的方法屬性,就需要從System.Web.UI.WebControls.WebControl類派生。該類實際上也是Control類的子類,但它附加了諸如ForeColor、BackColor、Font等屬性。
除此之外,還有一個重要的類是System.Web.UI.UserControl,即用戶控件類,它同樣是Control類的子類。我們可以自定義一些用戶控件派生自UserControl,在Visual Studio的Design環境下,我們可以通過拖動控件的方式將多種類型的控件組合成一個自定義用戶控件,也可以在codebehind方式下,爲自定義用戶控件類添加新的屬性和方法。
整個ASP.NET控件類的層次結構如圖6-2所示:
圖6-2 ASP.NET控件類的層次結構
ASP.NET控件的執行生命週期如表6-1所示:
階段 |
控件需要執行的操作 |
要重寫的方法或事件 |
初始化 |
初始化在傳入 Web
請求生命週期內所需的設置。 |
Init 事件(OnInit
方法) |
加載視圖狀態 |
在此階段結束時,就會自動填充控件的 ViewState
屬性,控件可以重寫 LoadViewState 方法的默認實現,以自定義狀態還原。 |
LoadViewState 方法 |
處理回發數據 |
處理傳入窗體數據,並相應地更新屬性。 注意:只有處理回發數據的控件參與此階段。 |
LoadPostData 方法(如果已實現 IPostBackDataHandler) |
加載 |
執行所有請求共有的操作,如設置數據庫查詢。此時,樹中的服務器控件已創建並初始化、狀態已還原並且窗體控件反映了客戶端的數據。 |
Load 事件(OnLoad
方法) |
發送回發更改通知 |
引發更改事件以響應當前和以前回發之間的狀態更改。 注意:只有引發回發更改事件的控件參與此階段。 |
RaisePostDataChangedEvent
方法(如果已實現 IPostBackDataHandler) |
處理回發事件 |
處理引起回發的客戶端事件,並在服務器上引發相應的事件。
注意:只有處理回發事件的控件參與此階段。 |
RaisePostBackEvent
方法(如果已實現 IPostBackEventHandler) |
預呈現 |
在呈現輸出之前執行任何更新。可以保存在預呈現階段對控件狀態所做的更改,而在呈現階段所對的更改則會丟失。 |
PreRender 事件(OnPreRender
方法) |
保存狀態 |
在此階段後,自動將控件的 ViewState
屬性保持到字符串對象中。此字符串對象被髮送到客戶端並作爲隱藏變量發送回來。爲了提高效率,控件可以重寫 SaveViewState
方法以修改 ViewState 屬性。 |
SaveViewState 方法 |
呈現 |
生成呈現給客戶端的輸出。 |
Render 方法 |
處置 |
執行銷燬控件前的所有最終清理操作。在此階段必須釋放對昂貴資源的引用,如數據庫鏈接。 |
Dispose 方法 |
卸載 |
執行銷燬控件前的所有最終清理操作。控件作者通常在 Dispose
中執行清除,而不處理此事件。 |
UnLoad 事件(On UnLoad
方法) |
表6-1 ASP.NET控件的執行生命週期
在這裏,控件設計利用了Template Method模式,Control基類提供了大部分protected虛方法,留待其子類改寫其方法。以PetShop 4.0爲例,就定義了兩個ASP.NET控件,它們都屬於System.Web.UI.WebControls.WebControl的子類。其中,CustomList控件派生自System.Web.UI.WebControls.DataList,CustomGrid控件則派生自System.Web.UI.WebControls.Repeater。
由於這兩個控件都改變了其父類控件的呈現方式,故而,我們可以通過重寫父類的Render虛方法,完成控件的自定義。例如CustomGrid控件:
//Static constants
protected const string HTML1 = "<table cellpadding=0
cellspacing=0><tr><td colspan=2>";
protected const string HTML2 = "</td></tr><tr><td class=paging align=left>";
protected const string HTML3 = "</td><td align=right class=paging>";
protected const string HTML4 = "</td></tr></table>";
private static readonly Regex RX = new Regex(@"^&page=\d+",
RegexOptions.Compiled);
private const string LINK_PREV = "<a href=?page={0}>< Previous</a>";
private const string LINK_MORE = "<a href=?page={0}>More ></a>";
private const string KEY_PAGE = "page";
private const string COMMA = "?";
private const string AMP = "&";
override protected void Render(HtmlTextWriter writer) {
//Check there is some data attached
if (ItemCount == 0) {
writer.Write(emptyText);
return;
}
//Mask the query
string query = Context.Request.Url.Query.Replace(COMMA, AMP);
query = RX.Replace(query, string.Empty);
// Write out the first part of the control, the table header
writer.Write(HTML1);
// Call the inherited method
base.Render(writer);
// Write out a table row closure
writer.Write(HTML2);
//Determin whether next and previous buttons are required
//Previous button?
if (currentPageIndex > 0)
writer.Write(string.Format(LINK_PREV, (currentPageIndex - 1) + query));
//Close the table data tag
writer.Write(HTML3);
//Next button?
if (currentPageIndex < PageCount)
writer.Write(string.Format(LINK_MORE, (currentPageIndex + 1) + query));
//Close the table
writer.Write(HTML4);
}
由於CustomGrid繼承自Repeater控件,因而它同時還繼承了Repeater的DataSource屬性,這是一個虛屬性,它默認的set訪問器屬性如下:
{
get {… }
set
{
if (((value != null) && !(value is IListSource)) && !(value is IEnumerable))
{
throw new ArgumentException(SR.GetString("Invalid_DataSource_Type", new object[] { this.ID }));
}
this.dataSource = value;
this.OnDataPropertyChanged();
}
}
對於CustomGrid而言,DataSource屬性有着不同的設置行爲,因而在定義CustomGrid控件的時候,需要改寫DataSource虛屬性,如下所示:
private int itemCount;
override public object DataSource {
set {
//This try catch block is to avoid issues with the VS.NET designer
//The designer will try and bind a datasource which does not derive from ILIST
try {
dataSource = (IList)value;
ItemCount = dataSource.Count;
}
catch {
dataSource = null;
ItemCount = 0;
}
}
}
當設置的value對象值不爲IList類型時,set訪問器就將捕獲異常,然後將dataSource字段設置爲null。
由於我們改寫了DataSource屬性,因而改寫Repeater類的OnDataBinding()方法也就勢在必行。此外,CustomGrid還提供了分頁的功能,我們也需要實現分頁的相關操作。與DataSource屬性不同,Repeater類的OnDataBinding()方法實際上是繼承和改寫了Control基類的OnDataBinding()虛方法,而我們又在此基礎上改寫了Repeater類的OnDataBinding()方法:
//Work out which items we want to render to the page
int start = CurrentPageIndex * pageSize;
int size = Math.Min(pageSize, ItemCount - start);
IList page = new ArrayList();
//Add the relevant items from the datasource
for (int i = 0; i < size; i++)
page.Add(dataSource[start + i]);
//set the base objects datasource
base.DataSource = page;
base.OnDataBinding(e);
}
此外,CustomGrid控件類還增加了許多屬於自己的屬性和方法,例如PageSize、PageCount屬性以及SetPage()方法等。正是因爲ASP.NET控件引入了Composite模式與Template Method模式,當我們在自定義控件時,就可以通過繼承與改寫的方式來完成控件的設計。自定義ASP.NET控件一方面可以根據系統的需求實現特定的功能,也能夠最大限度地實現對象的重用,既可以減少編碼量,同時也有利於未來對程序的擴展與修改。
在PetShop 4.0中,除了自定義了上述WebControl控件的子控件外,最主要的還是利用了用戶控件。在Controls文件夾下,一共定義了11個用戶控件,內容涵蓋客戶地址信息、信用卡信息、購物車信息、期望列表(Wish List)信息以及導航信息、搜索結果信息等。它們相當於是一些組合控件,除了包含了子控件的方法和屬性外,也定義了一些必要的UI實現邏輯。以ShoppingCartControl用戶控件爲例,它會在該控件被呈現(Render)之前,做一些數據準備工作,獲取購物車數據,並作爲數據源綁定到其下的Repeater控件:
protected void Page_PreRender(object sender, EventArgs e) {
if (!IsPostBack) {
BindCart();
}
}
private void BindCart() {
ICollection<CartItemInfo> cart = Profile.ShoppingCart.CartItems;
if (cart.Count > 0) {
repShoppingCart.DataSource = cart;
repShoppingCart.DataBind();
PrintTotal();
plhTotal.Visible = true;
}
else {
repShoppingCart.Visible = false;
plhTotal.Visible = false;
lblMsg.Text = "Your cart is empty.";
}
}
在ShoppingCart頁面下,我們可以加入該用戶控件,如下所示:
由於ShoppingCartControl用戶控件已經實現了用於呈現購物車數據的邏輯,那麼在ShoppingCart.aspx.cs中,就可以不用負責這些邏輯,在充分完成對象重用的過程中,同時又達到了職責分離的目的。用戶控件的設計者與頁面設計者可以互不干擾,分頭完成自己的設計。特別是對於頁面設計者而言,他可以是單一的UI設計人員角色,僅需要關注用戶界面是否美觀與友好,對於表示層中對領域對象的調用與操作就可以不必理會,整個頁面的代碼也顯得結構清晰、邏輯清楚,無疑也“乾淨”了不少。
6.4 ASP.NET 2.0新特性
由於PetShop 4.0是基於.NET Framework 2.0平臺開發的電子商務系統,因而它在表示層也引入了許多ASP.NET 2.0的新特性,例如MemberShip、Profile、Master Page、登錄控件等特性。接下來,我將結合PetShop 4.0的設計分別介紹它們的實現。
6.4.1 Profile特性
Profile提供的功能是針對用戶的個性化服務。在ASP.NET 1.x版本時,我們可以利用Session、Cookie等方法來存儲用戶的狀態信息。然而Session對象是具有生存期的,一旦生存期結束,該對象保留的值就會失效。Cookie將用戶信息保存在客戶端,它具有一定的安全隱患,一些重要的信息不能存儲在Cookie中。一旦客戶端禁止使用Cookie,則該功能就將失去應用的作用。
Profile的出現解決了如上的煩惱,它可以將用戶的個人化信息保存在指定的數據庫中。ASP.NET 2.0的Profile功能默認支持Access數據庫和SQL Server數據庫,如果需要支持其他數據庫,可以編寫相關的ProfileProvider類。Profile對象是強類型的,我們可以爲用戶信息建立屬性,以PetShop 4.0爲例,它建立了ShoppingCart、WishList和AccountInfo屬性。
由於Profile功能需要訪問數據庫,因而在數據訪問層(DAL)定義了和Product等數據表相似的模塊結構。首先定義了一個IProfileDAL接口模塊,包含了接口IPetShopProfileProvider:
{
AddressInfo GetAccountInfo(string userName, string appName);
void SetAccountInfo(int uniqueID, AddressInfo addressInfo);
IList<CartItemInfo> GetCartItems(string userName, string appName,
bool isShoppingCart);
void SetCartItems(int uniqueID, ICollection<CartItemInfo> cartItems,
bool isShoppingCart);
void UpdateActivityDates(string userName, bool activityOnly, string appName);
int GetUniqueID(string userName, bool isAuthenticated, bool ignoreAuthenticationType,
string appName);
int CreateProfileForUser(string userName, bool isAuthenticated, string appName);
IList<string> GetInactiveProfiles(int authenticationOption,
DateTime userInactiveSinceDate, string appName);
bool DeleteProfile(string userName, string appName);
IList<CustomProfileInfo> GetProfileInfo(int authenticationOption,
string usernameToMatch, DateTime userInactiveSinceDate, string appName,
out int totalRecords);
}
因爲PetShop 4.0版本分別支持SQL Server和Oracle數據庫,因而它分別定義了兩個不同的PetShopProfileProvider類,實現IPetShopProfileProvider接口,並放在兩個不同的模塊SQLProfileDAL和OracleProfileDAL中。具體的實現請參見PetShop 4.0的源代碼。
同樣的,PetShop 4.0爲Profile引入了工廠模式,定義了模塊ProfileDALFActory,工廠類DataAccess的定義如下:
private static readonly string profilePath = ConfigurationManager.AppSettings["ProfileDAL"];
public static PetShop.IProfileDAL.IPetShopProfileProvider CreatePetShopProfileProvider() {
string className = profilePath + ".PetShopProfileProvider";
return (PetShop.IProfileDAL.IPetShopProfileProvider)Assembly.Load(profilePath).CreateInstance(className);
}
}
在業務邏輯層(BLL)中,單獨定義了模塊Profile,它添加了對BLL、IProfileDAL和ProfileDALFactory模塊的程序集。在該模塊中,定義了密封類PetShopProfileProvider,它繼承自System.Web.Profile.ProfileProvider類,該類作爲Profile的Provider基類,用於在自定義配置文件中實現相關的配置文件服務。在PetShopProfileProvider類中,重寫了父類ProfileProvider中的一些方法,例如Initialize()、GetPropertyValues()、SetPropertyValues()、DeleteProfiles()等方法。此外,還爲ShoppingCart、WishList、AccountInfo屬性提供了Get和Set方法。至於Provider的具體實現,則調用工廠類DataAccess創建的具體類型對象,如下所示:
private static readonly IPetShopProfileProvider dal = DataAccess.CreatePetShopProfileProvider();
定義了PetShop.Profile.PetShopProfileProvider類後,纔可以在web.config配置文件中配置如下的配置節:
<providers>
<add name="ShoppingCartProvider" connectionStringName="SQLProfileConnString" type="PetShop.Profile.PetShopProfileProvider" applicationName=".NET Pet Shop 4.0"/>
<add name="WishListProvider" connectionStringName="SQLProfileConnString" type="PetShop.Profile.PetShopProfileProvider" applicationName=".NET Pet Shop 4.0"/>
<add name="AccountInfoProvider" connectionStringName="SQLProfileConnString" type="PetShop.Profile.PetShopProfileProvider" applicationName=".NET Pet Shop 4.0"/>
</providers>
<properties>
<add name="ShoppingCart" type="PetShop.BLL.Cart" allowAnonymous="true" provider="ShoppingCartProvider"/>
<add name="WishList" type="PetShop.BLL.Cart" allowAnonymous="true" provider="WishListProvider"/>
<add name="AccountInfo" type="PetShop.Model.AddressInfo" allowAnonymous="false" provider="AccountInfoProvider"/>
</properties>
</profile>
在配置文件中,針對ShoppingCart、WishList和AccountInfo(它們的類型分別爲PetShop.BLL.Cart、PetShop.BLL.Cart、PetShop.Model.AddressInfo)屬性分別定義了ShoppingCartProvider、WishListProvider、AccountInfoProvider,它們的類型均爲PetShop.Profile.PetShopProfileProvider類型。至於Profile的信息究竟是存儲在何種類型的數據庫中,則由以下的配置節決定:
<add key="ProfileDAL" value="PetShop.SQLProfileDAL"/>
而鍵值爲ProfileDAL的值,正是Profile的工廠類PetShop.ProfileDALFactory.DataAccess在利用反射技術創建IPetShopProfileProvider類型對象時獲取的。
在表示層中,可以利用頁面的Profile屬性訪問用戶的個性化屬性,例如在ShoppingCart頁面的codebehind代碼ShoppingCart.aspx.cs中,調用Profile的ShoppingCart屬性:
protected void Page_PreInit(object sender, EventArgs e) {
if (!IsPostBack) {
string itemId = Request.QueryString["addItem"];
if (!string.IsNullOrEmpty(itemId)) {
Profile.ShoppingCart.Add(itemId);
Profile.Save();
// Redirect to prevent duplictations in the cart if user hits "Refresh"
Response.Redirect("~/ShoppingCart.aspx", true);
}
}
}
}
在上述的代碼中,Profile屬性的值從何而來?實際上,在我們爲web.config配置文件中對Profile進行配置後,啓動Web應用程序,ASP.NET會根據該配置文件中的相關配置創建一個ProfileCommon類的實例。該類繼承自System.Web.Profile.ProfileBase類。然後調用從父類繼承來的GetPropertyValue和SetPropertyValue方法,檢索和設置配置文件的屬性值。然後,ASP.NET將創建好的ProfileCommon實例設置爲頁面的Profile屬性值。因而,我們可以通過智能感知獲取Profile的ShoppingCart屬性,同時也可以利用ProfileCommon繼承自ProfileBase類的Save()方法,根據屬性值更新Profile的數據源。
6.4.2 Membership特性
PetShop 4.0並沒有利用Membership的高級功能,而是直接讓Membership特性和ASP.NET 2.0新增的登錄控件進行綁定。由於.NET Framework 2.0已經定義了針對SQL Server的SqlMembershipProvider,因此對於PetShop 4.0而言,實現Membership比之實現Profile要簡單,僅僅需要爲Oracle數據庫定義MembershipProvider即可。在PetShop.Membership模塊中,定義了OracleMembershipProvider類,它繼承自System.Web.Security.MembershipProvider抽象類。
OracleMembershipProvider類的實現具有極高的參考價值,如果我們需要定義自己的MembershipProvider類,可以參考該類的實現。
事實上OracleMemberShip類的實現並不複雜,在該類中,主要是針對用戶及用戶安全而實現相關的行爲。由於在父類MembershipProvider中,已經定義了相關操作的虛方法,因此我們需要作的是重寫這些虛方法。由於與Membership有關的信息都是存儲在數據庫中,因而OracleMembershipProvider與SqlMembershipProvider類的主要區別還是在於對數據庫的訪問。對於SQL Server而言,我們利用aspnet_regsql工具爲Membership建立了相關的數據表以及存儲過程。也許是因爲知識產權的原因,Microsoft並沒有爲Oracle數據庫提供類似的工具,因而需要我們自己去創建membership的數據表。此外,由於沒有創建Oracle數據庫的存儲過程,因而OracleMembershipProvider類中的實現是直接調用SQL語句。以CreateUser()方法爲例,剔除那些繁雜的參數判斷與安全性判斷,SqlMembershipProvider類的實現如下:
{
MembershipUser user1;
//前面的代碼略;
try
{
SqlConnectionHolder holder1 = null;
try
{
holder1 = SqlConnectionHelper.GetConnection(this._sqlConnectionString, true);
this.CheckSchemaVersion(holder1.Connection);
DateTime time1 = this.RoundToSeconds(DateTime.UtcNow);
SqlCommand command1 = new SqlCommand("dbo.aspnet_Membership_CreateUser", holder1.Connection);
command1.CommandTimeout = this.CommandTimeout;
command1.CommandType = CommandType.StoredProcedure;
command1.Parameters.Add(this.CreateInputParam("@ApplicationName", SqlDbType.NVarChar, this.ApplicationName));
command1.Parameters.Add(this.CreateInputParam("@UserName", SqlDbType.NVarChar, username));
command1.Parameters.Add(this.CreateInputParam("@Password", SqlDbType.NVarChar, text2));
command1.Parameters.Add(this.CreateInputParam("@PasswordSalt", SqlDbType.NVarChar, text1));
command1.Parameters.Add(this.CreateInputParam("@Email", SqlDbType.NVarChar, email));
command1.Parameters.Add(this.CreateInputParam("@PasswordQuestion", SqlDbType.NVarChar, passwordQuestion));
command1.Parameters.Add(this.CreateInputParam("@PasswordAnswer", SqlDbType.NVarChar, text3));
command1.Parameters.Add(this.CreateInputParam("@IsApproved", SqlDbType.Bit, isApproved));
command1.Parameters.Add(this.CreateInputParam("@UniqueEmail", SqlDbType.Int, this.RequiresUniqueEmail ? 1 : 0));
command1.Parameters.Add(this.CreateInputParam("@PasswordFormat", SqlDbType.Int, (int) this.PasswordFormat));
command1.Parameters.Add(this.CreateInputParam("@CurrentTimeUtc", SqlDbType.DateTime, time1));
SqlParameter parameter1 = this.CreateInputParam("@UserId", SqlDbType.UniqueIdentifier, providerUserKey);
parameter1.Direction = ParameterDirection.InputOutput;
command1.Parameters.Add(parameter1);
parameter1 = new SqlParameter("@ReturnValue", SqlDbType.Int);
parameter1.Direction = ParameterDirection.ReturnValue;
command1.Parameters.Add(parameter1);
command1.ExecuteNonQuery();
int num3 = (parameter1.Value != null) ? ((int) parameter1.Value) : -1;
if ((num3 < 0) || (num3 > 11))
{
num3 = 11;
}
status = (MembershipCreateStatus) num3;
if (num3 != 0)
{
return null;
}
providerUserKey = new Guid(command1.Parameters["@UserId"].Value.ToString());
time1 = time1.ToLocalTime();
user1 = new MembershipUser(this.Name, username, providerUserKey, email, passwordQuestion, null, isApproved, false, time1, time1, time1, time1, new DateTime(0x6da, 1, 1));
}
finally
{
if (holder1 != null)
{
holder1.Close();
holder1 = null;
}
}
}
catch
{
throw;
}
return user1;
}
代碼中,aspnet_Membership_CreateUser爲aspnet_regsql工具爲membership創建的存儲過程,它的功能就是創建一個用戶。
OracleMembershipProvider類中對CreateUser()方法的定義如下:
//前面的代碼略;
//Create connection
OracleConnection connection = new OracleConnection(OracleHelper.ConnectionStringMembership);
connection.Open();
OracleTransaction transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted);
try {
DateTime dt = DateTime.Now;
bool isUserNew = true;
// Step 1: Check if the user exists in the Users table: create if not
int uid = GetUserID(transaction, applicationId, username, true, false, dt, out isUserNew);
if(uid == 0) { // User not created successfully!
status = MembershipCreateStatus.ProviderError;
return null;
}
// Step 2: Check if the user exists in the Membership table: Error if yes.
if(IsUserInMembership(transaction, uid)) {
status = MembershipCreateStatus.DuplicateUserName;
return null;
}
// Step 3: Check if Email is duplicate
if(IsEmailInMembership(transaction, email, applicationId)) {
status = MembershipCreateStatus.DuplicateEmail;
return null;
}
// Step 4: Create user in Membership table
int pFormat = (int)passwordFormat;
if(!InsertUser(transaction, uid, email, pass, pFormat, salt, "", "", isApproved, dt)) {
status = MembershipCreateStatus.ProviderError;
return null;
}
// Step 5: Update activity date if user is not new
if(!isUserNew) {
if(!UpdateLastActivityDate(transaction, uid, dt)) {
status = MembershipCreateStatus.ProviderError;
return null;
}
}
status = MembershipCreateStatus.Success;
return new MembershipUser(this.Name, username, uid, email, passwordQuestion, null, isApproved, false, dt, dt, dt, dt, DateTime.MinValue);
}
catch(Exception) {
if(status == MembershipCreateStatus.Success)
status = MembershipCreateStatus.ProviderError;
throw;
}
finally {
if(status == MembershipCreateStatus.Success)
transaction.Commit();
else
transaction.Rollback();
connection.Close();
connection.Dispose();
}
}
代碼中,InsertUser()方法就是負責用戶的創建,而在之前則需要判斷創建的用戶是否已經存在。InsertUser()方法的定義如下:
string insert = "INSERT INTO MEMBERSHIP (USERID, EMAIL, PASSWORD, PASSWORDFORMAT, PASSWORDSALT, PASSWORDQUESTION, PASSWORDANSWER, ISAPPROVED, CREATEDDATE, LASTLOGINDATE, LASTPASSWORDCHANGEDDATE) VALUES (:UserID, :Email, :Pass, :PasswordFormat, :PasswordSalt, :PasswordQuestion, :PasswordAnswer, :IsApproved, :CDate, :LLDate, :LPCDate)";
OracleParameter[] insertParms = { new OracleParameter(":UserID", OracleType.Number, 10), new OracleParameter(":Email", OracleType.VarChar, 128), new OracleParameter(":Pass", OracleType.VarChar, 128), new OracleParameter(":PasswordFormat", OracleType.Number, 10), new OracleParameter(":PasswordSalt", OracleType.VarChar, 128), new OracleParameter(":PasswordQuestion", OracleType.VarChar, 256), new OracleParameter(":PasswordAnswer", OracleType.VarChar, 128), new OracleParameter(":IsApproved", OracleType.VarChar, 1), new OracleParameter(":CDate", OracleType.DateTime), new OracleParameter(":LLDate", OracleType.DateTime), new OracleParameter(":LPCDate", OracleType.DateTime) };
insertParms[0].Value = userId;
insertParms[1].Value = email;
insertParms[2].Value = password;
insertParms[3].Value = passFormat;
insertParms[4].Value = passSalt;
insertParms[5].Value = passQuestion;
insertParms[6].Value = passAnswer;
insertParms[7].Value = OracleHelper.OraBit(isApproved);
insertParms[8].Value = dt;
insertParms[9].Value = dt;
insertParms[10].Value = dt;
if(OracleHelper.ExecuteNonQuery(transaction, CommandType.Text, insert, insertParms) != 1)
return false;
else
return true;
}
在爲Membership建立了Provider類後,還需要在配置文件中配置相關的配置節,例如SqlMembershipProvider的配置:
<providers>
<add name="SQLMembershipProvider" type="System.Web.Security.SqlMembershipProvider" connectionStringName="SQLMembershipConnString" applicationName=".NET Pet Shop 4.0" enablePasswordRetrieval="false" enablePasswordReset="true" requiresQuestionAndAnswer="false" requiresUniqueEmail="false" passwordFormat="Hashed"/>
</providers>
</membership>
對於OracleMembershipProvider而言,配置大致相似:
<providers>
<clear/>
<add name="OracleMembershipProvider"
type="PetShop.Membership.OracleMembershipProvider"
connectionStringName="OraMembershipConnString"
enablePasswordRetrieval="false"
enablePasswordReset="false"
requiresUniqueEmail="false"
requiresQuestionAndAnswer="false"
minRequiredPasswordLength="7"
minRequiredNonalphanumericCharacters="1"
applicationName=".NET Pet Shop 4.0"
hashAlgorithmType="SHA1"
passwordFormat="Hashed"/>
</providers>
</membership>
有關配置節屬性的意義,可以參考MSDN等相關文檔。
6.4.3 ASP.NET登錄控件
這裏所謂的登錄控件並不是指一個控件,而是ASP.NET 2.0新提供的一組用於解決用戶登錄的控件。登錄控件與Membership進行集成,快速簡便地實現用戶登錄的處理。ASP.NET登錄控件包括Login控件、LoginView控件、LoginStatus控件、LoginName控件、PasswordRescovery控件、CreateUserWizard控件以及ChangePassword控件。
PetShop 4.0猶如一本展示登錄控件用法的完美教程。我們可以從諸如SignIn、NewUser等頁面中,看到ASP.NET登錄控件的使用方法。例如在SignIn.aspx中,用到了Login控件。在該控件中,可以包含TextBox、Button等類型的控件,用法如下所示:
</asp:Login>
又例如NewUser.aspx中對CreateUserWizard控件的使用:
RequireEmail="False" SkinID="NewUser">
<WizardSteps>
<asp:CreateUserWizardStep ID="CreateUserWizardStep1" runat="server">
</asp:CreateUserWizardStp>
</WizardSteps>
</asp:CreateUserWizard>
使用了登錄控件後,我們毋需編寫與用戶登錄相關的代碼,登錄控件已經爲我們完成了相關的功能,這就大大地簡化了這個系統的設計與實現。
6.4.4 Master Page特性
Master Page相當於是整個Web站點的統一模板,建立的Master Page文件擴展名爲.master。它可以包含靜態文本、html元素和服務器控件。Master Page由特殊的@Master指令識別,如:
使用Master Page可以爲網站建立一個統一的樣式,且能夠利用它方便地創建一組控件和代碼,然後將其應用於一組頁。對於那些樣式與功能相似的頁而言,利用Master Page就可以集中處理爲Master Page,一旦進行修改,就可以在一個位置上進行更新。
在PetShop 4.0中,建立了名爲MasterPage.master的Master Page,它包含了header、LoginView控件、導航菜單以及用於呈現內容的html元素,如圖6-3所示:
圖6-3 PetShop 4.0的Master Page
@Master指令的定義如下:
Master Page同樣利用codebehind技術,以PetShop 4.0的Master Page爲例,codebehind的代碼放在文件MasterPage.master.cs中:
private const string HEADER_PREFIX = ".NET Pet Shop :: {0}";
protected void Page_PreRender(object sender, EventArgs e) {
ltlHeader.Text = Page.Header.Title;
Page.Header.Title = string.Format(HEADER_PREFIX, Page.Header.Title);
}
protected void btnSearch_Click(object sender, EventArgs e) {
WebUtility.SearchRedirect(txtSearch.Text);
}
}
注意Master Page頁面不再繼承自System.Web.UI.Page,而是繼承System.Web.UI.MasterPage類。與Page類繼承TemplateControl類不同,它是UserControl類的子類。因此,可以應用在Master Page上的有效指令與UserControl的可用指令相同,例如AutoEventWireup、ClassName、CodeFile、EnableViewState、WarningLevel等。
每一個與Master Page相關的內容頁必須在@Page指令的MasterPageFile屬性中引用相關的Master Page。例如PetShop 4.0中的CheckOut內容頁,其@Page指令的定義如下:
Master Page可以進行嵌套,例如我們建立了父Master Page頁面Parent.master,那麼在子Master Page中,可以利用master屬性指定其父MasterPage:
<%@ Master Language="C#" master="Parent.master"%>
而內容頁則可以根據情況指向Parent.master或者Child.master頁面。
雖然說Master Page大部分情況下是以聲明方式創建,但我們也可以建立一個類繼承System.Web.UI.MasterPage,從而完成對Master Page的編程式創建。但在採用這種方式的同時,應該同時創建.master文件。此外對Master Page的調用也可以利用編程的方式完成,例如動態地添加Master Page,我們重寫內容頁的Page_PreInit()方法,如下所示:
{
this.MasterPageFile = "~/NewMaster.master";
}
之所以重寫Page_PreInit()方法,是因爲Master Page會在內容頁初始化階段進行合併,也即是說是在PreInit階段完成Master Page的分配。
ASP.NET 2.0引入的新特性,並不僅僅限於上述介紹的內容。例如Theme、Wizard控件等新特性在PetShop 4.0中也得到了大量的應用。雖然ASP.NET 2.0及時地推陳出新,對表示層的設計有所改善,然而作爲ASP.NET 2.0的其中一部分,它們僅僅是對現有框架缺失的彌補與改進,屬於“錦上添花”的範疇,對於整個表示層設計技術而言,起到的推動作用卻非常有限。
直到AJAX(Asynchronous JavaScript and XML)的出現,整個局面才大爲改觀。雖然AJAX技術帶有幾分“舊瓶裝新酒”的味道,然而它從誕生之初,就具備了王者氣象,大有席捲天下之勢。各種支持AJAX技術的框架如雨後春筍般紛紛吐出新芽,支撐起百花齊放的繁榮,氣勢洶洶地營造出唯AJAX獨尊的態勢。如今,AJAX已經成爲了Web應用的主流開發技術,許多業界大鱷都呲牙咧嘴開始了對這一塊新領地的搶灘登陸。例如IBM、Oracle、Yahoo等公司都紛紛啓動了開源的AJAX項目。微軟也不甘落後,及時地推出了ASP.NET AJAX,這是一個基於ASP.NET的AJAX框架,它包括了ASP.NET AJAX服務端組件和ASP.NET AJAX客戶端組件,並集成在Visual Studio中,爲ASP.NET開發者提供了一個強大的AJAX應用環境。
我現在還無法預知AJAX技術在未來的走向,然而單單從表示層設計的角度而言,AJAX技術亦然帶了一場全新的革命。我們或者可以期待未來的PetShop 5.0,可以在表示層設計上帶來更多的驚喜。