基于业务对象的筛选

引言

可能大家对SQL语句太过熟悉了,也可能虽然已经从Asp过度到了Asp.Net时代,但是Asp的观念没有发生太大变化。结果就是我们将应用程序大部分的逻辑都交给了数据库去完成,忘记了.Net Framework提供给我们灵活强大的数据操控能力。比如说,当我们需要对数据进行筛选的时候,我们想到的是“Where”子句,而不是List<T>.FindAll();当我们需要对数据进行排序的时候,我们想到的是“Order By”子句,而不是List<T>.Sort();当我们需要对数据进行分页的时候我们想到的是存储过程,而不是List<T>.GetRange()。

当然,让数据库去完成这些工作在很多情况下效率会很高,尤其是在数据量较大的时候。然而在数据量不大的情况下,一次将所有数据取出,然后缓存在服务器上,对于以后的排序、筛选、分页请求仅针对缓存进行,则会使效率提高很多。

没有哪种方法绝对好或者绝对不好,都有一个适用范围。本文将介绍的也是一样,当数据量非常大的时候,我们可能不仅希望数据库先用“Where”子句进行筛选,进而再一次筛选只返回当前页需要显示的数据条目。

本文仅仅提出同一个问题的另一种解决思路,何时使用根据情况而定。

你是否还在拼装SQL语句?筛选数据的传统方式

对数据进行筛选应该是最常见不过的操作了,我们以NorthWind数据库的Orders订单表为例说明。如果我们需要对它按年、月、日的任意组合进行筛选,你大概会怎么做呢?我想应该是这样的:

  1. 在页面上创建三个下拉框,用于对年、月、日的选择。
  2. 用户第一次访问页面,显示所有数据。此时有一次对数据库的访问,返回全部数据,SQL语句诸如“Select * From Orders”。
  3. 用户选择年、月、日中的任意一个,产生PostBack。
  4. 根据用户的选择拼装一个SQL语句,诸如“Where Year(OrderDate) = @Date and Month(OrderDate)= @Month”。
  5. 将SQL语句发送到数据库,数据库返回查询结果,显示到用户界面。
  6. 如此反复。

可以看到,在上面的模式中,为了根据用户的选择显示不同的数据,每次用户的操作都要进行对数据库的一次访问,我们看一下具体的实现是怎样的。

拼装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)    {
       gvOrderList.DataBind();
    }

    // 天改变
    protected void ddlDay_SelectedIndexChanged(object sender, EventArgs e)    {
       gvOrderList.DataBind();
    }

    // 为列表添加项目
    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语句的筛选,现在我们看看基于对象的筛选是怎么样的,又是如何来提升性能的。

  1. 在页面上创建三个下拉框,用于对年、月、日的选择。
  2. 用户第一次访问页面,显示所有数据。此时有一次对数据库的访问,返回全部数据,SQL语句诸如“Select * From Orders”。
  3. 将返回的数据(已经转换成了List<Order>业务对象),全部进行缓存。
  4. 根据用户的选择对缓存中的 List<Order> 进行筛选,返回筛选结果,显示在页面上。
  5. 如此反复,以后每次用户请求都只针对缓存进行。

缓存策略

因为这里我们使用了缓存,所以有必要讨论一下缓存。我们知道缓存共有三种,一种是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"
当下拉框的选项较少的时候比较有意义,在缓存有效期内,不会对数据库进行访问。但当选项较多时,会对较多页进行缓存,第一次访问时仍要访问数据库,缓存多个页面结果,效果不够好。数据库数据改动时,缓存不会过期。 没有意义,因为业务对象已经是自缓存读出。数据库数据改动时,缓存不过期。
OutputCache
VaryByParam="*"
Duration="999999"
SqlDependency="Northwind:Orders"
与上同,但是当数据变动时会使缓存过期。 没有意义,当数据库数据改动时,会使页面缓存过期,页面缓存要求重新加载数据,但是重新加载的数据依然来自缓存中的对象。结果是即使数据库发生改变,页面显示结果依然没有改变。
ObjectDataSource
EnableCaching="true"
CacheDuration="600"
在缓存有效时间内,下拉列表的功能失效。因为在Cache有效期内,GridView的DataBind()方法不会使数据源重新读取数据(数据源不会触发Selecting事件),换言之,数据源不会调用GetList(query)方法,因此列表功能失效。 效果与拼装Sql方法一样,列表失效。
ObjectDataSource
EnableCaching="true"
CacheDuration=" infinite"
SqlDependency="Northwind:Orders"
列表失效,与上面效果相同,区别仅仅是在数据库改动时缓存过期(在失效后的第一次访问,列表有效)。 列表失效,与拼装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)
              canAdd = false;

           if (day != 0 && day != item.Date.Day)
              canAdd = false;

           if (canAdd == true)      // 如果全部条件满足,那么加入列表
              list.Add(item);
       }
    }

    return list;
}

上面无参数的GetList()方法在没有缓存的情况下调用GetList(query)方法,返回全部列表,然后加入缓存;有缓存的情况下直接使用缓存中的数据。在GetList(fullList, year, month, day)方法中,根据 年、月、日 对传递进去的列表(全部列表)进行了筛选。

使用List<T>.FindAll(Predicate<T> match)进行筛选

上面的方法虽然可以完成任务,但是不够好,为什么呢?

  1. 我们将筛选的条件(年、月、日)紧耦合到了GetList()方法中,如果日后想添加对其他列,比如国家的筛选,那么我们的方法签名就需要改变(添加国家),而所有调用GetList()方法的地方都需要修改。
  2. 代码没有重用,针对年、月、日来进行筛选是一项很常见的任务,我们应该把这部分封装起来,以后对其他的业务对象进行筛选时,使这些代码可以重用。

实际上,这些问题.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)
       return false;

    if (day != 0 && day != item.Date.Day)
       return false;

    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
    {
       get { return orderDate; }
    }
}

接下来定义可以用于筛选的类,创建一个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)
           return false;

       if (day != 0 && day != item.Date.Day)
           return false;

       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
           return false;

       // 继续进行 country 的对比
       if (String.IsNullOrEmpty(country) || string.Compare(item.Country, country, true) == 0)
       {
           return true;     
       } else
       {
           return false;
       }
    }
}

页面实现

我们现在为OrderManager类添加一个新方法,使用我们上面创建的OrderFilter,看看它是什么样的,它仅仅是在fullList上调用了FindAll()方法,传递了我们自定义的DateFilter,然后返回了结果:

// 获取列表对象,使用 filter 作为筛选的条件
public static List<Order> GetList(List<Order> fullList, DateFilter<Order> filter)
{
    List<Order> list = null;
    if (fullList != null)
    {
       list = fullList.FindAll(new Predicate<Order>(filter.MatchRule));
    }
    return list;
}

在ObjFilter.aspx页面上布局与使用拼装SQL几乎没有区别,ObjectDataSource控件的属性有一些变化:

<asp:ObjectDataSource ID="objdsOrderList" runat="server" SelectMethod="GetList"
    TypeName="OrderManager" OnSelecting="objdsOrderList_Selecting">
    <SelectParameters>
       <asp:Parameter Name="fullList" Type="Object" />
       <asp:Parameter Name="filter" Type="Object" />
    </SelectParameters>
</asp:ObjectDataSource>

调用了新的重载了的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提供的事件探查器对两种情况下对数据库请求的状况进行了跟踪。

感谢阅读,希望这篇文章能给你带来帮助!