一步步构建“半自动”数据分页模块
一步步构建“半自动”数据分页模块
引言
GridView等数据控件提供的数据分页功能非常方便,甚至不需要编写代码就可以完成分页,但是它有一个缺陷,就是每次都会取出所有数据但仅显示一页数据,这在大数据量的情况下显然是不合适的。除此以外,在Web站点的前台页面,我们仍会大量使用Repeater控件,这样避免不了进行手工数据分页。通常,我都是在数据库中写成存储过程,但是这样有一个很大的缺陷:没有代码重用,针对不同表或者不同视图都需要去编写存储过程。本文将一步步构建一个进行数据分页的代码模块,来尝试解决这个问题。
分页思路
这个程序的思路不过是将采用存储过程进行分页的思路转移到了代码中:根据表名、主键、Where子句、Order by子句、当前页码、分页大小等参数,来动态生成一个SQL语句,然后将这个SQL语句提交给数据库去执行,最后获得返回结果。而这个SQL语句的分页算法分为下面几个步骤,其中花括号中的内容代表变量:
- 声明一个表变量,它含有两个字段,一个名为recordNo,并将它的类型设置为int类型的自增量,它用于标识记录的顺序号;另一个字段的名称和类型与要进行分页的表的主键相同。
- 根据where子句和order子句获取要进行分页的表中的所有记录的主键,并将主键插入到声明的表变量中,代码类似于:Insert Into @temp({key}) Select {key} From {table} {where} {order},其中@temp为表变量。通过这样便会自动为表中的所有记录添加一个序号,因为recordNo是一个自增量。
- 使用Top语句,根据分页大小和起始索引获得@temp中的主键列。语句例如 Select top {pageSize} {key} From @temp Where recordNo > {startIndex},其中startIndex是起始的顺序号,可以由分页大小和当前页码算得。
- 根据上一步获得的主键获得所需的数据。语句例如Select {columns} From {table} where {key} in {上一步获得的主键} {order}。
实现过程1 - 辅助类
从上面我们可以看出有非常多的变量需要定义,而对于数据库来说,这些语句又受一定格式的制约,比如order子句就只能是类似这样的字符串“order by ColunmA, ColumnB desc”;而对于主键来说,@temp表变量和想要进行分页的表中的主键又必须一致。所以我们免不了需要定义一些辅助类型来约束它们。
开始之前先创建一个空解决方案,然后在其下加入一个Web站点项目WebSite。
SortDirection枚举
我们首先在App_Code中添加文件SortDirection.cs,并在其中创建一个SortDirection枚举,它可以代表排序的顺序,升序或者降序:
public enum SortDirection {
Asc = 0,
Desc
}
OrderColumn类
这个类定义了用于排序的表的字段:
// 进行排序的字段
public class OrderColumn {
private string name;
private SortDirection direction;
public OrderColumn(string name, SortDirection direction) {
this.name = name;
this.direction = direction;
}
public OrderColumn(string name) : this(name, SortDirection.Asc) { }
public string Name {
get { return name; }
}
public SortDirection Direction {
get { return direction; }
}
// 覆盖基类的ToString()方法
public override string ToString() {
return String.Format("{0} {1}", name, direction);
}
}
注意到上面覆盖了基类的ToString()方法,因为Order子句是一个字符串的形式。同时我们可以考虑到这样两个问题:
- 排序经常是多个字段的排序,可以预见到进行分页的方法需要接收一个OrderColumn的数组,那么它就需要进行这样一个操作:将这个数组转化为一个合法的Order语句。我们可以将这个操作定义在OrderColunm类型中。
- 如果对多个字段排序,尽管创建数组能保证格式的正确性,但是却会比较麻烦。如果直接输入合法的Order字符串会更加方便一些,所以我们将来应该提供重载的方法接收一个字符串作为Order子句,那么就需要对这个字符串的格式加以限制,我们也添加到这个类中。
根据上面两点分析,添加下面两个静态方法到OrderColumn中:
// 获取用于排序的子字符串
public static string GetOrderQuery(OrderColumn[] columnArray) {
if (columnArray == null || columnArray.Length == 0)
return "";
List<string> columnList = new List<string>();
foreach (OrderColumn column in columnArray) {
columnList.Add(column.ToString());
}
string orderColumns = String.Join(",", columnList.ToArray());
return "Order by " + orderColumns;
}
// 判断输入一个子查询的格式是否合法
// 合法格式举例:Column1 asc, Column2 desc
public static string GetOrderQuery(string subQuery) {
// 如果为空,则不对列进行排序
if (String.IsNullOrEmpty(subQuery))
return "";
// 如果不小心又输入了"Order By",则删除之
subQuery = subQuery.Replace("Order by", "");
subQuery = subQuery.Trim();
string pattern = @"\w[\w\d]*(\s+(asc|desc))?(\s*,\s*\w[\w\d]*\s+(asc|desc))*";
if (Regex.IsMatch(subQuery, pattern))
return "Order by " + subQuery;
throw new ArgumentException("subQuery 并非有效的SQL Order子句.");
}
ColumnType结构
从最上面的分页方法可以看到,在@temp表变量中,我们需要声明一个和想要进行分页的表的主键相匹配的字段。因此,我们定义ColumnType结果来表示主键的类型,我只添加了常见的三种类型:
// 主键的类型,生成SQL语句的时候用
public struct ColumnType {
private string type;
private ColumnType(string type) {
this.type = type;
}
public static readonly ColumnType Int = new ColumnType("Int");
public static readonly ColumnType NVarchar = new ColumnType("NVarchar(400)");
public static readonly ColumnType Varchar = new ColumnType("Varchar(200)");
public override string ToString() {
return type;
}
}
这个结构比较有趣,大家知道枚举对应的都是数字类型,上面这个结构模拟了字符串类型的枚举。
PrimayKey类
这个类定义了表的主键,还是用于生成SQL语句:
public class PrimaryKey {
private string name;
private ColumnType type;
public PrimaryKey(string name, ColumnType type) {
this.name = name;
this.type = type;
}
public PrimaryKey(string name) : this(name, ColumnType.Int) { }
public string Name {
get { return name; }
}
public ColumnType Type {
get { return type; }
}
}
实现过程2 - 核心类
终于把这些辅助类定义完毕,接下来我们来看一下含有核心算法的类BasePager:
public class BasePager : IDisposable {
private SqlConnection conn; // 连接对象
private PrimaryKey key; // 主键
private string table; // 表格或者视图的名称
private string whereQuery; // where子句
private int recordCount; // 记录集总数
public BasePager(string connString, string table, PrimaryKey key, string whereQuery) {
this.conn = new SqlConnection(connString);
this.table = table;
this.key = key;
if (!String.IsNullOrEmpty(whereQuery))
this.whereQuery = "Where " + whereQuery.Replace("Where", "");
else
this.whereQuery = "";
recordCount = -1;
}
public BasePager(string table, PrimaryKey key, string whereQuery){ }
public BasePager(string table, string keyName, string whereQuery){ }
public BasePager(string table, string keyName) { }
// 根据页码获得某一页的数据
public DataSet GetDataSet(int pageIndex, int pageSize, string[] columnArray, OrderColumn[] orderColumnArray) { }
public DataSet GetDataSet(int pageIndex, int pageSize, string[] columnArray, string orderSubQuery) { }
public DataSet GetDataSet(int pageIndex, int pageSize, string columns) { }
public DataSet GetDataSet(int pageIndex, int pageSize) { }
public DataSet GetDataSet(int pageIndex) { }
public DataSet GetDataSet(int pageIndex, int pageSize, string columns, string orderSubQuery) {
if (pageIndex <= 0)
throw new ArgumentException("pageIndex必须大于0");
// 起始索引的位置
int startIndex = (pageIndex - 1) * pageSize;
// 调用实际获取数据集的方法
return getDataSet(startIndex, pageSize, columns, orderSubQuery);
}
// 根据起始索引获得数据集
private DataSet getDataSet
(int startIndex, int pageSize, string columns, string orderSubQuery) {
if (startIndex < 0)
throw new ArgumentException("startIndex必须大于0");
if (pageSize <= 0)
throw new ArgumentException("pageSize必须大于0");
string orderQuery = OrderColumn.GetOrderQuery(orderSubQuery);
CheckColumns(columns); // 对列的格式做一个简单的约束
string sql =
String.Format(@"Declare @temp table(
recordNo int identity(1,1),
" + key.Name + " " + key.Type.ToString() + @"
)
Insert Into @temp(" + key.Name + @")
Select " + key.Name + " From " + table + " " + whereQuery + " " + orderQuery + @"
-- 获得记录总数
Select @RecordCount = Count(*) From " + table + " " + whereQuery + @"
Select " + columns + " From " + table + " where " + key.Name + @" in
(Select top " + pageSize + " " + key.Name + @" From @temp
where recordNo > " + startIndex + @" order by recordNo asc
) " + orderQuery);
SqlCommand cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.Add(new SqlParameter("@recordCount", SqlDbType.Int));
cmd.Parameters["@recordCount"].Direction = ParameterDirection.Output;
SqlDataAdapter adapter = new SqlDataAdapter(cmd);
DataSet ds = new DataSet();
try {
// 填充数据表
adapter.Fill(ds);
// 获得记录集总数
recordCount = (int)cmd.Parameters["@recordCount"].Value;
} finally {
conn.Close();
adapter.Dispose();
cmd.Dispose();
}
return ds;
}
// 获取记录集总数
private int getRecordCount() {
string sql = "Select Count(*) as result From " + table + " " + whereQuery;
SqlCommand cmd = conn.CreateCommand();
cmd.CommandText = sql;
int total = -1;
if (conn.State != ConnectionState.Open)
conn.Open();
try {
total = (int)cmd.ExecuteScalar();
} finally {
cmd.Dispose();
conn.Close();
}
// 给recordCount 赋值
recordCount = total;
return total;
}
// 获取记录集总数
public int GetRecordCount() {
if (recordCount != -1)
return recordCount;
return getRecordCount();
}
public void Dispose() {
conn.Dispose();
}
// 检查列的格式
// 正确格式为:“*”,或者 “Column1, Column
private void CheckColumns(string columns) { }
}
上面提供了很多的方法重载,重载无非是对参数进行了一些转换,所以我就没有将它们列出来,想要了解的话可以查看本文附带的代码。上面最重要的方法就是getDataSet()了,它根据起始索引、分页大小、所需字段、Order子句获取了数据集。注意在构造函数我传入了表名、主键和where子句。这是因为当使用GetRecordCount()方法获取记录数目时,仍然需要使用表名和where子句。
实现过程3 - 控件绑定
既然要进行演示,那么就首先需要填充点数据进行测试,在本文所附带的代码中,App_Date中已经有一个SampleDB数据库,它只含有一个Article表,以及一些数据。我还提供了一个FillData.Sql文件,你可以通过这个文件将数据生成到其他地方。
Repeater控件绑定
在引言中我就已经说过了创建它的两个目的,其中之一就是配合Repeater控件使用。有了上面的类型,我们再在BasePager中添加一个绑定到Repeater控件的方法并非难事:
// 绑定到Repeater
public virtual void BindRepeater
(Repeater repeater, int pageIndex, int pageSize,
string[] columnArray, OrderColumn[] orderColumnArray)
{
DataSet ds = GetDataSet(pageIndex, pageSize, columnArray, orderColumnArray);
repeater.DataSource = ds;
repeater.DataBind();
}
这里我没有添加重载方法,当然你可以自己添加。下面我们看下如何使用它来进行绑定,因为使用Repeater控件时我们还需要显示页码链接,这个我在本文的姐妹篇――Asp.Net分页显示控件 中已经实现了,所以我们直接拿来使用。在Web站点下添加一个文件BindRepeater.aspx,然后放置一个Repeater控件,我将它命名为了rpArticle,另外在放置一个PlaceHolder控件用来放置我们即将动态创建的分页显示控件,我将它命名为了pnHolder。接下来我们看下代码后置文件:
public partial class BindRepeater : System.Web.UI.Page {
BasePager pager = new BasePager("Article", "Id", "PostDate > '
private int recordCount {
get {
if (ViewState["recordCount"] == null) {
ViewState["recordCount"] = pager.GetRecordCount();
}
return Convert.ToInt32(ViewState["recordCount"]);
}
}
protected void Page_Load(object sender, EventArgs e) {
UrlManager manager = new DefaultUrlManager(recordCount, 12, "Page");
if (!IsPostBack) {
string[] columns = { "Id", "Title", "PostDate" };
OrderColumn[] orderArray = {new OrderColumn("Title")};
pager.BindRepeater(
rpArticle,
manager.CurrentPageIndex,
manager.PageSize, columns, orderArray);
}
PagerControl c = new PagerControl();
c.UrlManager = manager;
phHolder.Controls.Add(c);
}
}
这里的代码比较清晰,首先先声明了一个BasePager的实例,参数的含义是对Article表进行分页,主键是Id,主键类型是int(这里使用了重载的构造函数),并且where子句是“PostDate > '
如果你使用一下事件探查器(Sql Server Profiler),可以发现在点击页面的按钮进行一个PostBack的时候,并不会重新访问数据库。
GridView控件绑定
对GridView进行绑定稍微麻烦一些,还需要引入ObjectDataSource控件。通过让GridView使用ObjectDataSource控件,并且设置ObjectDataSource控件的TypeName、SelectCountMethod、DataObjectTypeName、StartRowIndexParameterName、MaximumRowsParameterName、SortParameterName属性,来让GridView控件使用我们自定义的分页方式。我先对这几个属性解释一下:
- SelectMethod,也就是用于选择数据的方法的名称,因为我们使用BasePager,所以它就是GetDataSet。
- TypeName,含有SelectMethod所指定的方法的类型,这里就是BasePager。
- SelectCountMethod,获取记录总数的方法名称,也就是BasePager类的GetRecordCount()方法。
- DataObjectTypeName,方法的返回值类型,因为我们返回了DataSet,所以是System.Data.DataSet。
- StartRowIndexParameterName,SelectMethod方法的参数名称,这个参数表示了起始索引,相当于限制了SelectMethod方法的签名。
- SortParameterName,SelectMethod方法的参数名称,这个参数表示用于排序的字段,相当于限制了SelectMethod方法的签名,使得只能针对一个字段进行排序(记得我们的BasePager可是可以针对多个字段排序的)。
- MaximumRowsParameterName,SelectMethod方法的参数名称,这个参数表示的是分页的大小,还是限制SelectMethod方法的签名。
从上面看出,我们的分页方法只能接收3个参数,也就是说我们必须选择所有字段(不能使用含有string[] columns参数的GetDataSet()重载方法),因为GridView的操作方式就是取出所有字段,然后通过设置选择要显示的列。但是最重要的一个问题是:ObjectDataSource要求一个无参数的构造函数。而我们的BasePager在构造函数中提供了连接字符串、表名、where子句、主键等重要参数,这些参数如何传递呢?我采用了继承BasePager的方法,然后在继承类的无参数构造函数中对上面参数进行设置,这样的不便就是我们可能需要创建大量的BasePager继承类,这也是我将它称为“半自动”的原因。
根据SelectMethod的要求,为BasePager再添加一个方法:
// 专门用于搭配GridView使用
public DataSet GetGridData(int startIndex, int pageSize, string orderColumn) {
return getDataSet(startIndex, pageSize, "*", orderColumn);
}
然后类似于Repeater,我们再添加一个绑定到GridView的方法:
// 绑定GridView和ObjectDataSouce,让GridView使用这里的分页逻辑
public virtual void BindGridView(GridView grid, ObjectDataSource objDataSource) {
objDataSource.SelectMethod = "GetGridData";
objDataSource.TypeName = this.GetType().FullName;
objDataSource.SelectCountMethod = "GetRecordCount";
objDataSource.DataObjectTypeName = "System.Data.DataSet";
objDataSource.EnablePaging = true;
objDataSource.StartRowIndexParameterName = "startIndex";
objDataSource.MaximumRowsParameterName = "pageSize";
objDataSource.SortParameterName = "orderColumn";
grid.DataSourceID = objDataSource.ID;
}
最后,我们需要定义一个不含有任何方法的继承类ArticlePager,在它的无参数构造函数中指定BasePager的参数:
public class ArticlePager :BasePager {
public ArticlePager()
:base(ConfigurationManager.ConnectionStrings["LocalSqlServer"].ConnectionString, "Article",
new PrimaryKey("id",ColumnType.Int),
"PostDate > '
}
好了,一切设置妥当,我们再来试运行一下。新建一个页面,拖放一个GridView和一个ObjectDataSource控件。然后在后置代码中进行一下设置:
public partial class Test : System.Web.UI.Page {
BasePager pager = new ArticlePager();
private int recordCount {
get {
if (ViewState["recordCount"] == null) {
ViewState["recordCount"] = pager.GetRecordCount();
}
return Convert.ToInt32(ViewState["recordCount"]);
}
}
protected void Page_Load(object sender, EventArgs e) {
if (!IsPostBack) {
pager.BindGridView(GridView1, ObjectDataSource1);
Label1.Text =
GetPageStatus(GridView1.PageIndex +1, GridView1.PageSize, pager.GetRecordCount());
}
}
}
你应该可以看到这样的画面:
总结
本文讨论了如何实现数据分页这一常见需求,我们先讨论了分页的实现思路:将以往使用存储过程编写的T-SQL代码移植到C#代码来,根据各种参数进行一个拼装,然后提交给数据库并获得请求。接着我们一步步实现了整个过程,先定义了一些辅助类然后在BasePager类中实现了前面讨论的算法。最后,我们为这个类扩展了功能,使它能够绑定Repeater和GridView这两个常见的控件。
在绑定GridView的时候还有很大的不足,因为需要为每个不同的GridView都去创建一个继承自BasePager的类型。我相信一定会有办法解决,只是现在还没有想到而已。
感谢阅读,希望这篇文章能给你带来帮助!
[原文出处:www.tracefact.net]