Efficient Data Paging with the ASP.NET 2.0 DataList Control and ObjectDataSource
Last weekend I posted
about how to implement super efficient data-paging using
the new ROW_NUMBER() within SQL 2005 (which is supported
with the free SQL Express edition and up) and the new
ASP.NET 2.0 GridView/ObjectDataSource controls.
The BIG win
with this approach is that it enables a web-application
to efficiently retrieve only the specific rows it needs
from the database, and avoid having to pull back dozens,
hundreds, or even thousands of results to the web-server
– only to then end up trimming them down to the 10-15
you end up displaying on a web-page (which is what most
data-paging solutions do today). This can lead to significant performance wins within
your application (both in terms of latency and
throughput), and reduce the load on your database.
A few people have asked me to also provide a similar
sample that demonstrates how to also implement efficient
data-paging using an ASP.NET DataList or Repeater
control instead (since neither of these have built-in
paging UI semantics – but they do have the big benefit
of allowing developers to have tight control over the
html generated).
You can download a sample app that demonstrates how
to-do this
here.
For the scenario in the sample I basically “borrowed”
the UI used on several popular ecommerce web-sites
(although I tried to be a good CSS citizen, and
implemented it using CSS rules instead of a table-heavy
format):
It provides a list of product-categories on the
left-hand side of the product listing page (the value in
parenthesis indicates the number of products in each
category), and then a 2 column layout for the products
on the right (the default page size is 10 per page –
although you can change this with a 1 line code-change
if you want a different size). You can page back and forward throughout the listing,
and are provided UI that helps indicate where you are in
the paging series (the Previous/More links also
auto-hide if you are on the first or last page of the
results).
What is cool about the sample is that all of the data
on the right-hand side of the screen (everything but the
product category listing on the left) is retrieved with
a single database call on each page view (so no multiple
database trips). This single database call also only returns the product
rows of data we end up displaying on the given data page
(so with the screen-shot above it returns 10 rows of
data instead of all 97 rows in that product
category). This
makes it super fast and efficient.
The entire sample to implement this app is only about
~20 lines of total code (this includes all the code in
both the UI and data code layers).
How To Run and Use the Sample
To setup the sample, follow the below steps:
1) Have VS 2005 or Visual Web Developer and SQL Express
installed. Visual Web Developer and SQL Express are both free
downloads and provide everything you need to build,
debug and run the application. You can download them from here.
2) Download
this .zip file containing the sample. Expand it
into any directory you want.
3) Using Visual Web Developer or VS 2005, choose
File->Open Web-site and point to the root directory
of the sample sub-directory (the one with the files in
it).
4) Select the “Products.aspx” file and hit run.
This will bring up a page like the screenshot above
that lists products and allows you to page back and
forth throughout a
product category listing (note that the next/previous
buttons will auto-hide if you are at the beginning or
end of the listing). Click on the left-hand side of the page to switch
category listings.
To add a new product category, run the “AddData.aspx”
page:

When you add a new product category, you can specify
the number of products to add to it. You can add dozens, hundreds, thousands or tens of
thousands of products (the AddData.aspx page names the
products sequentially for you automatically so you can
simulate different sized loads).
Because the data paging implementation in the sample
uses an intelligent paging algorithm that only pulls the
needed page rows to the web-server (by default only 10
rows per web request), your server won’t start to run
into performance issues even if you have a category or
result with tens of thousands of product results.
How this Sample is Built
Here is what the solution directory looks like in
Visual Web Developer:

It contains three pages – “Products.aspx”,
“ProductDetails.aspx”, and “AddData.aspx” – that are
each based on the “Site.master” master-page.
The database is implemented in Products.mdf and
contains 2 simple tables – “Products” and “Categories”
(note: you can create new SQL Express databases by
select File->Add New Item->Database File). Here is what they look like in the database designer
built-into the free Visual Web Developer IDE:

The Categories table contains a list of product
categories, and the Products table contains a list of
products contained within them. The ProductID column in
the Products table is the primary key (and is
automatically indexed), and an index has also been
created on the CategoryId column (to create an index in
the designer, just right click on a column and select
“Indexes/Keys” to bring up the Index manager
dialog). These
indexes are going to be important if we put 100,000s of
products of entries into our Products table.
The database has one stored procedure called
“GetProductsByCategoryId” that was created using the
ROW_NUMBER() approach described in David’s blog
to enable us to retrieve only the specific product data
rows we want from the Products database (this means that
we retrieve just the 10 or so products we need for the
current page instead of the 1000 products that might be
in the product category:
Notice that I’m also returning the total number of
products in a category as well as the friendly name of
the category as output parameters of the SPROC. Originally I was fetching these with a separate
database call, but to demonstrate how to implement a
nice performance optimization I’m returning them along
with our 10 rows of data as output parameters here. The benefit of doing this optimization (versus a
separate call to the database – which would actually be
logically cleaner) is that it means you can retrieve
all of
the data you need with a single database query (which is
also in turn only returning 10 rows of data). For a high-volume page like this where performance
matters, it is a performance optimization to seriously
consider.
The data access layer was then implemented using a
DataSet designer (choose File->Add New
Item->DataSet to create one), which allows us to
avoid having to write any manual ADO.NET data access
code – and instead just use strongly-typed data classes
that are generated and maintained in the project
automatically by defining methods/relationships using
the DataSet designer.
The generated DataSet definitions for this sample are
stored within the “MyDataLayer.xsd” file in the App_Code
directory and contain two table-adapters (note: if you
want to be advanced you can open and edit this XML file
defining the DAL structure by right-clicking on it,
choosing “Open With”, and selecting the XML source
editor):
The GetProductsByCategoryId() method goes against the
SPROC we defined earlier, but all other methods are
normal SQL queries defined within the DAL layer.
Once defined using the DataSet designer, it is possible
to write procedural code like the code below to invoke
and use a method defined with our new DAL
components:
int categoryId = 0;
int pageIndex = 0;
int pageSize = 10;
int numTotalProducts =
0;
string categoryName =
String.Empty;
ProductsTableAdapter
products = new ProductsTableAdapter();
MyDalLayer.ProductsDataTable
products = products.GetProductsByCategoryId( categoryId,
pageIndex,
pageSize,
ref categoryName,
ref
numTotalProducts);
foreach (MyDalLayer.ProductsRow product
in products) {
int productId =
product.ProductId;
string
productDescription = product.Description;
string productImage =
product.ProductImage;
}
The DAL components generated are strongly typed with
both type and data relationship validation
built-in. You
can add custom validation and/or code to the generated
DAL components very cleanly via either code inheritance
(subclass the DAL and override/add your own logic), or
by adding a partial type to the project which will be
compiled with the DAL (allowing you to avoid having to
worry about the designer stomping on your code). Here is an article
that covers the DataSet designer in more detail.
In my previous blog sample
on efficient data paging using the GridView control, I
showed how to optionally build a custom business façade
that then wraps our generated DAL layer (which was built
using the DataSet designer like above), and in turn
provides another layer of isolation and separation. For this sample, though, I’m just going to use the DAL
directly.
In terms of building the UI for the sample, the
“Site.Master” page defines the outer “chrome” of all
pages within the site, as well as the product listing on
the left:
Within the Site.Master master page, I’m using an
<asp:repeater> control to dynamically build the
list of products and associated links (note the use of
the new ASP.NET 2.0 Eval() data-binding syntax that
provides a much terser way to evaluate data-binding
expressions against the container parent):
<div
id="productmenu">
<h5>Products</h5>
<ul>
<asp:repeater
ID="ProductNav"
DataSourceID="CategoryDataSource"
runat="server">
<ItemTemplate>
<li>
<a
href="Products.aspx?categoryid=<%#Eval("CategoryId")
%>"><%#Eval("Name")%></a>
(<%#Eval("ProductCount")
%>)
</li>
</ItemTemplate>
</asp:repeater>
</ul>
</div>
<asp:ObjectDataSource
ID="CategoryDataSource"
TypeName="MyDalLayerTableAdapters.CategoriesTableAdapter"
SelectMethod="GetCategories"
runat="server"
/>
I’m using declarative data-binding with the new ASP.NET
2.0 ObjectDataSource control to bind the
<asp:repeater> against the
“MyDalLayerTableAdapters.CategoriesTableAdapter” class
and its GetCategories() data method – which is one of
the data classes defined and built for us as part of our
DataSet designer based DAL.
The products.aspx page is then based on the Site.Master
master page, and contains a <asp:DataList> control
as well as some standard hyperlink html elements that
we’ll use to programmatically control paging
navigation:
And in source-view:
A few quick things to point out above: 1) the
ItemTemplate within the DataList is using <divs>
and CSS for styling and positioning, 2) Eval() supports
an optional data-formatting syntax that I’m using to
format the price of each product as currency, 3) I’ve
disabled view-state for the page (since we don’t need
it), and 4) the “tag-navigator” (bottom of the
screen-shot) and tag-highlighting features in the free
Visual Web Developer html source editor are pretty
convenient to identify where your cursor is within the
document (they dynamically update as you move the cursor
around the html source).
As you can see above, I’m using declarative
data-binding with the ObjectDataSource control for this
DataList control as well (alternatively I could have
just written procedural code to invoke the
ProductAdapter, set the datasource, and call databind on
the control). There are a couple of benefits to doing this the
ObjectDataSource way – one is that it handles when to
grab the data in the page lifecycle automatically. Another is that the WYSIWYG page designer will offer to
automatically generate default data templates for you
within the DataList above when you wire one up to the
ObjectDataSource (it will reflect on the data from the
returned method and generate a default template based on
it that you can then easily edit). The last is that you can declaratively bind parameter
values from other controls, querystring/form values, and
the new ASP.NET Profile object – all without having to
write any code yourself. You can see this in action with this last point – where
I’m declaratively specifying that the CategoryId and
PageIndex values should be pulled from the QueryString
(and a value of “0” should be used if it isn’t
present).
The above markup is actually all we need in our page to
bind to our DAL, retrieve 10 rows of data, and generate
pretty output containing the results. If you ran a page with just this, you’d be able to page
back and forth through the product listing data by
manually adding a “CategoryId” and “PageIndex” value to
the querystring of the page (for example:
Products.aspx?CategoryId=0&PageIndex=2).
Rather than force people to manually do this, though,
obviously what we want to-do instead is provide some
built-in navigation UI to enable this. To accomplish this, I added a <div> section at
the bottom of the page with some hyperlinks that we’ll
use to page back and forth, as well as a label that I
can use to output where the user currently is
(specifically text that says something like: “1-10 of 56
Products”).
<div
id="Navigation"
class="navigation"
runat="server">
<div
id="leftnav">
<a
id="PreviousPageNav"
runat="server">
<< Previous
Products</a>
</div>
<div
id="rightnav">
<a
id="NextPageNav"
runat="server">More
Products >></a>
</div>
<div
id="numnav">
<asp:Label
ID="PagerLocation"
runat="server"
/>
</div>
</div>
Note the use of standard hyperlinks above. They have a runat=”server” attribute on them so that I
can program against them on the server. I chose to implement the paging semantics within this
sample using standard HTTP GET requests for everything –
instead of using post-backs. Doing the navigation via post-backs would have been
easier, but I wanted to enable users to easily bookmark
pages (which will automatically persist the querystring
values for me), as well as to enable cross-linking from
things like search engines.
To dynamically update the hyperlink values (as well as
other elements of the page), I added an event-handler to
the ObjectDataSource so that I’ll be called after it has
fetched the data from our DAL (specifically: I’m using
the “selected” event – I would have used the “selecting”
event if I wanted to inject code immediately before the
DAL was called):
protected
void
ProductDataSource_Selected(object
sender,
ObjectDataSourceStatusEventArgs
e) {
// Retrieve output parameter values returned from the
"GetProductsByCategoryId"
// method invoked by the ObjectDataSource control on
the ProductsTableAdapter class
int productCount =
(int)
e.OutputParameters["CategoryProductCount"];
string categoryName =
(string)e.OutputParameters["CategoryName"];
// Retrieve pageIndex and categoryId from querystring,
pageSize pulled from ObjectDataSource
int pageIndex =
Convert.ToInt32(Request.QueryString["pageIndex"]);
int categoryId =
Convert.ToInt32(Request.QueryString["categoryid"]);
int pageSize =
Int32.Parse(ProductDataSource.SelectParameters["NumRows"].DefaultValue);
// Update various page elements with data values
UpdateTitles(categoryName);
UpdatePagerLocation(pageIndex, pageSize,
productCount);
UpdateNextPrevLinks(categoryId, pageIndex, pageSize,
productCount);
}
Notice above that I am using the event argument
(specifically its OutputParameters collection) to
retrieve the output parameter results from the DAL
method (e.ReturnValue provides access to the return
value of the method).
I’m retrieving other HTTP GET parameters from the
Request.QueryString collection. I’m using Convert.ToInt32() to convert them to integers
instead of Int32.Parse() because Convert.ToInt32() will
return a 0 value instead of throwing if the querystring
isn’t specified (and so it saves me having to-do a null
check).
At the end of the event, I then call three helper
methods that I’m using the update the page contents with
the various data results. The last two are used to customize the html navigation
<div> above. Specifically, the “UpdatePagerLocation” method emits
the location text (“1-10 of 44 Products”):
void
UpdatePagerLocation(int
pageIndex, int pageSize,
int productCount) {
int currentStartRow =
(pageIndex * pageSize) + 1;
int currentEndRow =
(pageIndex * pageSize) + pageSize;
if (currentEndRow >
productCount)
currentEndRow = productCount;
PagerLocation.Text = currentStartRow +
"-" + currentEndRow +
" of " + productCount +
" products";
}
And then the “UpdateNextPrevLinks” just updates and
auto shows/hides the <a> elements depending on
whether we are at the beginning or end of the product
listing (note: we are using the CategoryProductCount
that was returned as an output parameter to calculate
the total number of products in the category):
void
UpdateNextPrevLinks(int
categoryId,
int pageIndex,
int pageSize,
int productCount) {
string navigationFormat
=
"products.aspx?categoryId={0}&pageIndex={1}";
PreviousPageNav.HRef =
String.Format(navigationFormat, categoryId, pageIndex - 1);
PreviousPageNav.Visible = (pageIndex > 0) ?
true :
false;
NextPageNav.HRef =
String.Format(navigationFormat, categoryId, pageIndex + 1);
NextPageNav.Visible = (pageIndex + 1) * pageSize <
productCount ? true :
false;
}
Last but not least, I have a simple method that updates
the Page’s title element (using the new ASP.NET 2.0
Page.Title property), as well as a <h1> header at
the top of the page:
void UpdateTitles(string
title) {
ProductHeader.Text = title;
Page.Title
= "Products: " +
title;
}
And that is all the code there is to the sample….
One thing to play with on the ObjectDataSource is to adjust the “NumRows” parameter value.
For example, if
you changed this to “4” instead of “10”
(which is what the sample ships with), you’d get 4 rows
of products per page:
No additional code changes are required to enable this – just change the value in one place and you are good to go (no DAL, code-behind or other changes needed).
You can also experiment with the number of columns
rendered by the DataList – try changing the
“RepeatColumns” property on it to 1 or 3 to see a
different layout.
Summary
Because we only retrieve and return the rows needed for the current page of rendering from the database, and because we only use a single database
call to retrieve all of the data for the product-listing page, the execution of the page should be very, very fast and scalable (even when you have thousands
of results). As
you can see above,
the code to implement this is pretty small and
clean.
Because we are using standard navigational HTTP GET requests everywhere in the sample, no client-script is needed or emitted on the page (do a view-source
in the browser and you’ll notice there isn’t a single line of javascript anywhere on the page). All markup in the sample is also XHTML compliant and cross-browser.
I used FireFox for all the
screen-shots above – but obviously it also works with
IE.
Hope this helps,
Scott