基于業務對象的篩選
引言
可能大家對SQL語句太過熟悉了,也可能雖然已經從Asp過度到了Asp.Net時代,但是Asp的觀念沒有發生太大變化。結果就是我們将應用程式大部分的邏輯都交給了資料庫去完成,忘記了.Net Framework提供給我們靈活強大的資料操控能力。比如說,當我們需要對資料進行篩選的時候,我們想到的是“Where”子句,而不是List<T>.FindAll();當我們需要對資料進行排序的時候,我們想到的是“Order By”子句,而不是List<T>.Sort();當我們需要對資料進行分頁的時候我們想到的是存儲過程,而不是List<T>.GetRange()。
當然,讓資料庫去完成這些工作在很多情況下效率會很高,尤其是在資料量較大的時候。然而在資料量不大的情況下,一次将所有資料取出,然後緩存在伺服器上,對于以後的排序、篩選、分頁請求僅針對緩存進行,則會使效率提高很多。
沒有哪種方法絕對好或者絕對不好,都有一個适用範圍。本文将介紹的也是一樣,當資料量非常大的時候,我們可能不僅希望資料庫先用“Where”子句進行篩選,進而再一次篩選隻傳回目前頁需要顯示的資料條目。
本文僅僅提出同一個問題的另一種解決思路,何時使用根據情況而定。
你是否還在拼裝SQL語句?篩選資料的傳統方式
對資料進行篩選應該是最常見不過的操作了,我們以NorthWind資料庫的Orders訂單表為例說明。如果我們需要對它按年、月、日的任意組合進行篩選,你大概會怎麼做呢?我想應該是這樣的:
- 在頁面上建立三個下拉框,用于對年、月、日的選擇。
- 使用者第一次通路頁面,顯示所有資料。此時有一次對資料庫的通路,傳回全部資料,SQL語句諸如“Select * From Orders”。
- 使用者選擇年、月、日中的任意一個,産生PostBack。
- 根據使用者的選擇拼裝一個SQL語句,諸如“Where Year(OrderDate) = @Date and Month(OrderDate)= @Month”。
- 将SQL語句發送到資料庫,資料庫傳回查詢結果,顯示到使用者界面。
- 如此反複。
可以看到,在上面的模式中,為了根據使用者的選擇顯示不同的資料,每次使用者的操作都要進行對資料庫的一次通路,我們看一下具體的實作是怎樣的。
拼裝SQL語句的典型實作方式
首先,建立一個頁面(SqlApproach.aspx),在頁面上放置三個DropDownList控件,一個GridView控件,以及一個ObjectDataSource控件,如同下圖所示:
在 App_Code 檔案夾中根據Orders表建立業務對象Order(位于Order.cs)。
public class Order
{
private int orderId; // 訂單Id
private string customerId; // 使用者Id
private DateTime orderDate; // 下單日期
private string country; // 國家
public int OrderId {
get { return orderId; }
set { orderId = value; }
}
public string CustomerId
{
get { return customerId; }
set { customerId = value; }
public DateTime OrderDate
get { return orderDate; }
set { orderDate = value; }
public string Country
get { return country; }
set { country = value; }
}
對于集合(清單、或者叫行集)資料,我們使用List<Order>來存儲。接下來在App_Code目錄下再建立一個檔案 OrderManager.cs 用于從資料庫傳回結果集、填充清單,通常包含類似這樣的代碼:
public class OrderManager
// 根據SQL語句擷取清單對象
public static List<Order> GetList(string query)
List<Order> list = null;
SqlDataReader reader = ExecuteReader(query);
if (reader.HasRows) {
list = new List<Order>();
while (reader.Read()){
list.Add(GetItem(reader));
}
}
reader.Close();
return list;
// 擷取一個SqlDataReader對象
private static SqlDataReader ExecuteReader(string query)
string connString = ConfigurationManager.ConnectionStrings["NorthwindConnectionString"].ConnectionString;
SqlConnection conn = new SqlConnection(connString);
SqlCommand cmd = new SqlCommand(query, conn);
conn.Open();
SqlDataReader reader = cmd.ExecuteReader(CommandBehavior.CloseConnection);
return reader;
// 從一行擷取一個Order對象
private static Order GetItem(IDataRecord record)
Order item = new Order();
item.OrderId = Convert.ToInt32(record["orderId"]);
item.CustomerId = record["CustomerId"].ToString();
item.OrderDate = Convert.ToDateTime(record["OrderDate"]);
item.Country = record["ShipCountry"].ToString();
return item;
上面的代碼很好了解:GetList()方法接受一個查詢語句,然後傳回List<Order>清單。在GetList()方法内部,調用了 ExecuteReader()方法,ExecuteReader()方法根據傳遞進去的查詢語句query建立并傳回了一個SqlDataReader對象,它用于讀取資料庫傳回的資料。在While語句中,調用了GetItem()方法,它根據每一個資料行建立了一個Order對象。最後将Order對象添加到List<Order>清單中,然後傳回了清單。
可見,我們需要在頁面上調用的方法就是GetList(query)方法了,我們看下頁面檔案SqlFilter.aspx的主要代碼:
<asp:ObjectDataSource ID="objdsOrderList" runat="server" SelectMethod="GetList"
TypeName="OrderManager" OnSelecting="objdsOrderList_Selecting">
<SelectParameters>
<asp:Parameter Name="query" Type="string" />
</SelectParameters>
</asp:ObjectDataSource>
ObjectDataSource使用GetList作為SelectCommand, ObjectDataSource的ID将會用于GridView的DataSourceID。
現在我們繼續看SqlFilter.aspx的後置代碼通常是什麼樣的(我們設定當DropDownList的Text為“全部”的時候,它的Value為“0”):
public partial class SqlApproach : System.Web.UI.Page
public int Year{
get { return Convert.ToInt32(ddlYear.SelectedValue); }
public int Month{
get { return Convert.ToInt32(ddlMonth.SelectedValue); }
public int Day{
get { return Convert.ToInt32(ddlDay.SelectedValue); }
// 擷取查詢語句
public string QuerySql
get
{
int year = Year;
int month = Month;
int day = Day;
List<string> sqlList = new List<string>();
string subSql = string.Empty;
if(year != 0)
sqlList.Add(String.Format("Year(OrderDate) = {0}", year));
if(month != 0)
sqlList.Add(String.Format("Month(OrderDate) = {0}", month));
if (day != 0)
sqlList.Add(String.Format("Day(OrderDate) = {0}", day));
if (sqlList.Count > 0) // 如果選擇了任意一個下拉框,那麼就拼裝Sql語句
{
string[] list = sqlList.ToArray();
subSql = "Where (" + String.Join(" and ", list) + ")";
// 傳回拼裝好的SQL語句
return "Select CustomerId, ShipCountry, OrderDate, OrderId From Orders " + subSql;
// 頁面加載的事件
protected void Page_Load(object sender, EventArgs e)
if (!IsPostBack)
{
AppendListItem(ddlMonth, 12); // 共12個月
AppendListItem(ddlDay, 31); // 預設為31天
}
// 月份改變
protected void ddlMonth_SelectedIndexChanged(object sender, EventArgs e) {
gvOrderList.DataBind();
// 年份改變
protected void ddlYear_SelectedIndexChanged(object sender, EventArgs e) {
// 天改變
protected void ddlDay_SelectedIndexChanged(object sender, EventArgs e) {
// 為清單添加項目
protected void AppendListItem(ListControl list, int end) {
for (int i = 1; i <= end; i++) {
list.Items.Add(new ListItem(i.ToString()));
// 每個清單的PostBack都會調用gvOrderList.DataBind(),然後觸發這裡
// 進而調用了OrderManager.GetList(query)方法,從資料庫傳回資料。
protected void objdsOrderList_Selecting(object sender, ObjectDataSourceSelectingEventArgs e) {
e.InputParameters["query"] = QuerySql;
這段代碼使用Year, Month, Day三個屬性分别擷取年、月、日DropDownList的值。主要的邏輯包含在了QuerySql屬性中,它根據三個清單的狀态進行SQL語句的拼裝。最後,在ObjectDataSource的Selecting事件中,傳遞QuerySql方法進去,獲得清單對象,進而顯示在頁面上。
NOTE:為了使代碼簡單,我沒有對類似1997-2-30這樣特殊日期的處理。即便使用這個日期作為查詢條件,僅僅會傳回一個空清單,不會使程式出錯,由于這隻是個示例程式,我覺得還可以接受。
了解了傳統的基于拼裝SQL語句的篩選,現在我們看看基于對象的篩選是怎麼樣的,又是如何來提升性能的。
- 将傳回的資料(已經轉換成了List<Order>業務對象),全部進行緩存。
- 根據使用者的選擇對緩存中的 List<Order> 進行篩選,傳回篩選結果,顯示在頁面上。
- 如此反複,以後每次使用者請求都隻針對緩存進行。
緩存政策
因為這裡我們使用了緩存,是以有必要讨論一下緩存。我們知道緩存共有三種,一種是OutputCache,一種是基于資料源控件的資料緩存,一種是基于 System.Web.Caching.Cache 類的對象緩存。在這三種緩存中,OutputCache和 資料緩存 又可以應用SqlCacheDependency緩存過期政策,SqlCacheDependency說簡單些就是當資料庫的資料發生改變的時候使依賴于此資料庫(表)的緩存自動過期,SqlCacheDependency又分為Sql Server2000的基于輪詢(Polling)的拉機制,和Sql Server2005 基于通知(Notification)的推機制兩種不同政策。而應用System.Web.Caching.Cache時不能應用SqlCacheDependency過期政策,隻能應用基于某個檔案改變或其他Cache項目改變的過期政策。
NOTE:Sql Server2000 的輪詢(Polling)機制意思是說Asp.Net程序每隔一段時間就對資料庫程序進行一此通路,因為間隔時間是固定的,是以叫輪詢(通路時間以毫秒為機關,可以在Web.Config裡設定)。當某次輪詢時發現與上一次通路時的資料不一樣,那麼就立刻使依賴于此資料的緩存過期。Sql Server2005 的通知(Notification)機制是說Asp.Net隻管做自己的事情,不對資料庫程序進行詢問,而當資料庫的資料發生變動時,Sql Server 2005程序主動通知Asp.Net程序,告訴它資料發生了改變,然後Asp.Net讓緩存過期。由此可見,使用Sql Server2005 的通知機制效率要高得多。
如何開啟SqlDependency本文不講述了,可以查閱相關書籍。
當我提到緩存的時候你可能會覺得對于基于業務對象的篩選我使用了緩存,而對于拼裝SQL的方式我沒有,這樣去比較它們顯得不公平,那麼我現在列張表,對于它們各自應用緩存時的表現做一個對比(SqlDependency使用SqlServer 2000的輪詢機制):
緩存名稱 | 基于拼裝SQL篩選 | 基于業務對象篩選 |
OutputCache VaryByParam="*" Duration="600" | 當下拉框的選項較少的時候比較有意義,在緩存有效期内,不會對資料庫進行通路。但當選項較多時,會對較多頁進行緩存,第一次通路時仍要通路資料庫,緩存多個頁面結果,效果不夠好。資料庫資料改動時,緩存不會過期。 | 沒有意義,因為業務對象已經是自緩存讀出。資料庫資料改動時,緩存不過期。 |
Duration="999999" SqlDependency="Northwind:Orders" | 與上同,但是當資料變動時會使緩存過期。 | 沒有意義,當資料庫資料改動時,會使頁面緩存過期,頁面緩存要求重新加載資料,但是重新加載的資料依然來自緩存中的對象。結果是即使資料庫發生改變,頁面顯示結果依然沒有改變。 |
ObjectDataSource EnableCaching="true" CacheDuration="600" | 在緩存有效時間内,下拉清單的功能失效。因為在Cache有效期内,GridView的DataBind()方法不會使資料源重新讀取資料(資料源不會觸發Selecting事件),換言之,資料源不會調用GetList(query)方法,是以清單功能失效。 | 效果與拼裝Sql方法一樣,清單失效。 |
CacheDuration=" infinite" | 清單失效,與上面效果相同,差別僅僅是在資料庫改動時緩存過期(在失效後的第一次通路,清單有效)。 | 清單失效,與拼裝Sql方法一樣。差別是SqlDependency也失效,因為在資料庫資料變動時,資料緩存過期,資料源重新讀資料,但是資料依然來自于緩存。 |
Catch Insert("fullList", List<Order>) | 基本不可實施(對每次傳回結果進行緩存,效果基本等同于全部傳回,且非常麻煩) | 本文對象即是應用此方法緩存。 |
很明顯,本文使用的方法的問題就是:當資料庫資料變動時,緩存不能夠即時過期。解決方法有兩種:一個是使用Cache.Insert()的重載方法,設定緩存的自動過期時間(時間設的短了緩存優勢不明顯,時間設的長了資料變化不能即時反應);還有一個是在對資料庫進行增删改時使用Cache.Remove()手動移除緩存(比較麻煩容易遺漏)。
本文不是講述如何使用緩存的,上面是讓大家了解使用緩存都會發生哪些可能,隻要知道使用Cache.Insert(key,value)方法可以添加緩存就可以了。最後再說一下當我們使用Cache.Insert(key,value)插入緩存時,雖然沒有設定過期時間,但是當伺服器記憶體空間不足的時候,依然會将緩存移除。
對業務對象進行篩選
基于業務對象篩選其實就是基于List<Order>進行篩選(當然你的業務對象也可能不是List<Order>),思路似乎很簡單,我們先通過一個重載的GetList()方法擷取全部清單,在這個GetList()方法中應用緩存。然後周遊業務對象,選出它符合條件的項目,然後将符合條件的項目加入到新清單中,最後傳回新清單。
// 擷取全部清單
public static List<Order> GetList() {
List<Order> list = HttpContext.Current.Cache["fullList"] as List<Order>;
if (list == null) {
list = GetList("Select OrderId, CustomerId, ShipCountry, OrderDate From Orders");
// 添加緩存,永不過期(可以在删除、更新操作時手動讓緩存過期)
HttpContext.Current.Cache.Insert("fullList", list);
return list;
// 根據一個全部項目的清單,以及年、月、日對清單進行篩選
public static List<Order> GetList(List<Order> fullList, int year, int month, int day)
List<Order> list = null;
bool canAdd; // 标記變量,說明目前項目是否符合添加的條件
if (fullList != null)
list = new List<Order>();
foreach (Order item in fullList)
canAdd = true;
if (year != 0 && year != item.Date.Year)
canAdd = false;
if (month != 0 && month != item.Date.Month)
if (day != 0 && day != item.Date.Day)
if (canAdd == true) // 如果全部條件滿足,那麼加入清單
list.Add(item);
上面無參數的GetList()方法在沒有緩存的情況下調用GetList(query)方法,傳回全部清單,然後加入緩存;有緩存的情況下直接使用緩存中的資料。在GetList(fullList, year, month, day)方法中,根據 年、月、日 對傳遞進去的清單(全部清單)進行了篩選。
使用List<T>.FindAll(Predicate<T> match)進行篩選
上面的方法雖然可以完成任務,但是不夠好,為什麼呢?
- 我們将篩選的條件(年、月、日)緊耦合到了GetList()方法中,如果日後想添加對其他列,比如國家的篩選,那麼我們的方法簽名就需要改變(添加國家),而所有調用GetList()方法的地方都需要修改。
- 代碼沒有重用,針對年、月、日來進行篩選是一項很常見的任務,我們應該把這部分封裝起來,以後對其他的業務對象進行篩選時,使這些代碼可以重用。
實際上,這些問題.Net Framework已經為我們想到了,并在List<T>上提供了一個FindAll(Predicate<T> match)方法來進行篩選的工作,而Predicate<T>類型的參數,封裝了篩選的規則。Predicate<T>是一個泛型委托,這意味着match參數是一個傳回bool類型的方法,在FindAll()内部,會調用我們傳遞進去的這個方法。
public delegate bool Predicate<T>(T obj);
NOTE:我看到過這樣的一句話,是問Librariy和Framework的差別是什麼?回答是:我們調用Librariy的方法,但是Framework調用我們的方法(當然我們也會調用Framework)。可見Framework是一個擴充性和彈性很高的東西,在很多地方我們可以将自己的代碼融入到Framework中去。
現在我們來看下如何定義滿足 Predicate<T>委托的方法。如果我們将方法寫在OrderManager類的内部,那麼似乎可以這樣寫:
// 進行資料篩選的主要邏輯
public bool MatchRule(Order item)
if (year != 0 && year != item.Date.Year)
return false;
if (month != 0 && month != item.Date.Month)
if (day != 0 && day != item.Date.Day)
return true;
可實際上,你發現沒有地方來傳遞year, month, day參數,因為Predicate<T>(T obj)要求隻接受一個參數,在這裡是Order類型的item。是以,實際上我們要對這個方法進行一個簡單的封裝,讓我們可以傳遞year, month, day參數進去。在進行封裝之前,我們應該考慮:對于年、月、日的篩選是很常見的操作,我們要讓代碼重用。
我們先定義一個接口,這個接口僅要求傳回一個DateTime類型的屬性Date,對于所有實作了這個接口的類,都應該可以使用我們的篩選方法(一個沒有日期的對象顯然不能按年、月、日篩選)。
public interface IDate
DateTime Date { get; }
此時我們的Order類也應該進行修改,讓它來實作這個接口,我們隻需要它傳回orderDate字段就可以了:
public class Order :IDate
{
// ... 略
public DateTime Date
接下來定義可以用于篩選的類,建立一個DateFilter.cs檔案:
// 用于按照年、月、日篩選清單的泛型類,基類
public class DateFilter<T> where T : IDate
private int year;
private int month;
private int day;
public DateFilter(int year, int month, int day)
this.year = year;
this.month = month;
this.day = day;
// 友善使用的一組構造函數
public DateFilter(DateTime date) : this(date.Year, date.Month, date.Day) { }
public DateFilter(int year, int month) : this(year, month, 0) { }
public DateFilter(int year) : this(year, 0, 0) { }
public DateFilter() : this(0, 0, 0) { }
// 進行資料篩選的主要邏輯
public virtual bool MatchRule(T item)
if (year != 0 && year != item.Date.Year)
return false;
if (month != 0 && month != item.Date.Month)
if (day != 0 && day != item.Date.Day)
return true;
可以看到,Predicate<T>委托類型的方法MatchRule和前面幾乎沒有差別,唯一的不同是改成了虛拟方法,以便在子類中覆寫它,以支援對更多列(屬性)的篩選。還有值得注意的地方是這個泛型類使用了限制,我們要求類型參數T必須實作IDate接口。
實際上這個類通常用作基類(也可以直接使用,非抽象類),現在來看下如果我們希望可以對Country也進行篩選,應該如何擴充它:
// 可以添加對國家的篩選
public class OrderFilter : DateFilter<Order>
private string country;
public OrderFilter(int year, int month, int day, string country)
: base(year, month, day) // 調用基類構造函數
this.country = country;
public override bool MatchRule(Order item)
// 先擷取基類關于日期的對比結果
bool result = base.MatchRule(item);
if (result == false) // 如果日期都不滿足,直接傳回false
// 繼續進行 country 的對比
if (String.IsNullOrEmpty(country) || string.Compare(item.Country, country, true) == 0)
return true;
} else
頁面實作
我們現在為OrderManager類添加一個新方法,使用我們上面建立的OrderFilter,看看它是什麼樣的,它僅僅是在fullList上調用了FindAll()方法,傳遞了我們自定義的DateFilter,然後傳回了結果:
// 擷取清單對象,使用 filter 作為篩選的條件
public static List<Order> GetList(List<Order> fullList, DateFilter<Order> filter)
list = fullList.FindAll(new Predicate<Order>(filter.MatchRule));
在ObjFilter.aspx頁面上布局與使用拼裝SQL幾乎沒有差別,ObjectDataSource控件的屬性有一些變化:
TypeName="OrderManager" OnSelecting="objdsOrderList_Selecting">
<SelectParameters>
<asp:Parameter Name="fullList" Type="Object" />
<asp:Parameter Name="filter" Type="Object" />
</SelectParameters>
調用了新的重載了的GetList()方法。然後我們看一下CodeBehind檔案上如何進行設定ObjectDataSource的Selecting事件:
// 屬性,擷取用于篩選的對象
public DateFilter<Order> Filter {
get {
DateFilter<Order> filter = new OrderFilter(Year, Month, Day, Country);
return filter;
// 設定參數
protected void objdsOrderList_Selecting(object sender, ObjectDataSourceSelectingEventArgs e)
e.InputParameters["fullList"] = OrderManager.GetList();// 擷取全部清單
e.InputParameters["filter"] = Filter;
注意上面Year、Month、Day屬性的擷取代碼以及DropDownList的SelectedIndexChanged事件代碼我都省略了以節省篇幅。
事件探查器
OK,現在我們的所有工作都已經完成了,我們來測試一下通過這種方式對資料庫依賴的減小。大家可以打開Sql Server2000的事件探查器(Sql Server2005下的Sql Server Profiler)。選擇“檔案” --> “建立” --> “跟蹤” --> 進行登入。之後應該如下圖所示:
選擇“事件”頁籤,之後如下圖所示:
從右側“標明的事件”中删除“存儲過程”、“安全審查”、“會話”,隻保留“T-SQL”,我們隻對它進行監視。然後可以看到類似下圖,我們對資料庫的每次通路都可以在這裡看到:
點選上面的“橡皮擦”圖示,可以對清單進行清除。然後我們先打開SqlFilter.aspx檔案,可以看到我們對清單的每次操作,不管是翻頁還是篩選,都會對資料庫進行一次查詢操作。然後我們點選“橡皮擦”清除掉清單,然後打開ObjFilter.aspx檔案,可以看到在對資料庫進行了第一次通路以後,後繼的動作,無論是進行分頁還是篩選操作,都不再對資料庫構成依賴。
總結
在這篇文章中,我們主要讨論了如何對業務對象進行篩選。我先提出了很多人的一個思維定勢:将操作交給資料庫。随後列出了這種操作的典型流程,并在本文中将它稱為“基于拼裝SQL進行篩選”,然後給出了代碼示範。
後半部分,我們詳細讨論了基于業務對象進行篩選的方法――将對象緩存在伺服器上來對請求提供支援。與前半部分一樣,我們先了解了流程,學習了緩存政策,然後進行了代碼實作。最後我們使用Sql Server提供的事件探查器對兩種情況下對資料庫請求的狀況進行了跟蹤。
感謝閱讀,希望這篇文章能給你帶來幫助!