Thursday, April 17, 2014

Paging in ASP.NET MVC4

В этой статье рассмотрим тему разбиения на страницы (paging) на ASP.NET MVC4 и немножко к этому всему добавим Bootstrap, чтобы это хорошо выглядело. Первое мое знакомство с paging в ASP.NET MVC 4 началось со статьи "Creating a resusable custom paging in ASP.NET MVC". Я решил пойти таким же путем, чтобы рассмотреть плюсы и минусы данного подхода. В этом примере есть несколько недочетов, поэтому мы пройдем тем путем, каким пошел автор статьи, и дойдем до того, как нужно было это сделать. Для этого создадим простое приложение ASP.NET MVC 4 с использованием готового шаблона Internet Application и назовем наш проект SimplePagingSample, как показано на рисунке ниже.
Для нашей задачи создадим тестовую таблицу Customers  в базе данных (БД) MS SQL, так как хотим проверить, как будет работать наше разбиение страниц с большим количеством записей. Пример скрипта для создания данной таблицы можно посмотреть ниже.
CREATE TABLE [dbo].[Customers](
      [Id] [int] IDENTITY(1,1) NOT NULL,
      [Name] [nvarchar](255) NOT NULL,
      [Address] [nvarchar](255) NOT NULL
 CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED
(
      [Id] ASC
)WITH (PAD_INDEX  = OFF, STATISTICS_NORECOMPUTE  = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS  = ON, ALLOW_PAGE_LOCKS  = ON) ON [PRIMARY]
) ON [PRIMARY]
Для теста в таблицу было добавлено 100 тысяч строк. Пример скрипта добавления записей в базу через ADO.NET провайдер можно посмотреть в примере ниже. Для вставки данных использовалась наработка с BLToolkit с паттерном репозиторий, пример которой вы можете найти по ссылках Источник 1, Источник 2 и Источник 3. Ниже представлено только готовое решение. Мапинг на таблицу Customers выглядит так:
[TableName("Customers")]
public abstract class CustomerEntity : EntityBase
{
       [MaxLength(255), Required]
       [MapField("Name")]
       [Description("Имя")]
       public abstract string Name { get; set; }

       [MaxLength(255), Required]
       [MapField("Address")]
       [Description("Адрес проживания")]
       public abstract string Address { get; set; }
}
Код для добавления 100 тысяч записей приведен ниже.
class Program
{
       static void Main(string[] args)
       {
             try
             {
                    var repository = new Repository<CustomerEntity>();
                    for (int i = 0; i < 100000; i++)
                    {
                           var entity = repository.CreateNewEntity();
                           entity.Name = "Name" + i;
                           entity.Address = "Address" + i;
                           repository.Save(entity);
                    }
             }
             catch (Exception ex)
             {
                    Console.WriteLine(ex.ToString());
             }
                   
             Console.ReadLine();
       }
}
Для самого примера с разбиением страниц будет использован классический подход с ADO.NET, чтобы не углубляться в детали работы с BLToolkit и паттерном Repository. Основная задача состоит в том, чтобы показать все как можно проще, без лишних сложностей. Перейдем у папку Models в нашем проекте и добавим класс Customer, который будет сохранять информацию о клиентах.
public class Customer
{
       public int Id { get; set; }
       public string Name { get; set; }
       public string Address { get; set; }
}
Путь к базе данных задается через файл конфига Web.config. Например: 
<connectionStrings>
      <!--<add name="StatisticDatabase" connectionString="Data Source=vm-0161;Initial Catalog=Statistic;User id=statistic;Password=123;" />-->
      <add
                name             = "TestDatabase"
                connectionString = "Server=.;Database=Test;Integrated Security=SSPI"
                providerName     = "System.Data.SqlClient" />
  </connectionStrings>
Подключение к БД у вас может отличаться, в зависимости от того, как вы настроили MS SQL сервер. Следующим этапом создадим папку Helpers, в которую добавим класс ScriptsProvider, задача которого будет предоставлять описание для скриптов.
Немного лирическое отступление по поводу запроса для разбиения на страницы имеет следующий вид:
SELECT * FROM(SELECT ROW_NUMBER()
      OVER (ORDER BY [Name]) AS rowNum, 
      [Name] ,
      [Address]
FROM [dbo].[Customers]) AS x WHERE rowNum Between 1 AND 30
В нашем классе ScriptsProvider это выглядит так:
public static class ScriptsProvider
{
       public static string GetConnectionString()
       {
             return ConfigurationManager.ConnectionStrings["TestDatabase"].ConnectionString;
       }

       public static string SelectActiveCustomers()
       {
             return "SELECT * FROM(" +
                           "SELECT ROW_NUMBER() " +
                           "OVER (ORDER BY [Name]) AS rowNum, " +
                           " [Name] " +
                           ",[Address] " +
                           "FROM [Statistic].[dbo].[Customers]" +
                           ") AS x WHERE rowNum Between {0} AND {1}";
       }

       public static string SelectCustomersCount()
       {
             return "SELECT COUNT(*) " +
                           "FROM [Statistic].[dbo].[Customers];";
       }
}
Запрос выше с использованием функции OVER и ROW_NUMBER() необходим нам, чтобы использовать к привычным нам Linq конструкциям Skip() и Take(). Перейдем после проделанных действий и немного изменим реализацию нашего контроллера HomeController .
public class HomeController : Controller
{
       private int PageSize = 30;
       public ActionResult Index(int? page = 1)
       {
             int currentPageIndex = page.HasValue ? page.Value - 1 : 0;
             var customerCount = GetCustomersCount();
             var customers = GetAllCustomers(PageSize, currentPageIndex * PageSize);
             return View();
       }

       private int GetCustomersCount()
       {
             var connectionString = ScriptsProvider.GetConnectionString();
             using (var connection = new SqlConnection(connectionString))
             {
                    connection.Open();
                    using (var command = connection.CreateCommand())
                    {
                           command.CommandText =
                                  ScriptsProvider.SelectCustomersCount();
                           return (int)command.ExecuteScalar();
                    }
             }
       }

       private IEnumerable<Customer> GetAllCustomers(int pageSize, int skipRow)
       {
             var customerList = new List<Customer>();
             var connectionString = ScriptsProvider.GetConnectionString();
             using (var connection = new SqlConnection(connectionString))
             {
                    connection.Open();
                    using (var command = connection.CreateCommand())
                    {
                           command.CommandText = string.Format(
                                  ScriptsProvider.SelectActiveCustomers(), skipRow + 1, skipRow + pageSize);

                           using (var reader = command.ExecuteReader())
                           {
                                  while (reader.Read())
                                  {
                                        customerList.Add(GetCustomer(reader));
                                  }
                           }
                    }
             }

             return customerList;
       }

       private Customer GetCustomer(SqlDataReader reader)
       {
             var customer = new Customer();
             customer.Id = (int)reader.GetInt64(0);
             customer.Name = SafeGetString(reader, 1);
             customer.Address = SafeGetString(reader, 2);
             return customer;
       }

       private static string SafeGetString(SqlDataReader reader, int colIndex)
       {
             return !reader.IsDBNull(colIndex) ?
                    reader.GetString(colIndex) :
                    string.Empty;
       }

       public ActionResult About()
       {
             ViewBag.Message = "Your app description page.";

             return View();
       }

       public ActionResult Contact()
       {
             ViewBag.Message = "Your contact page.";

             return View();
       }
}
В нашем контроллере добавлено функции для получения количества клиентов (Customers) и функции для получения этих самых клиентов для одной страницы. Следующим этапом добавим модель PagingInfoModel, в которой будем хранить информацию о текущей странице, количестве страниц и количестве записей на странице. Реализация данной модели приведена ниже.
public class PagingInfoModel
{
       public int TotalRecords { get; set; }
       public int RecordPerPage { get; set; }
       public int CurrentPage { get; set; }

       public int TotalPages
       {
             get { return (int)Math.Ceiling((decimal)TotalRecords / RecordPerPage); }
       }
}
Также добавим в наш пример класс RecordPager, который вернет нам список страниц для перехода. Этот класс нужно добавить в созданную ранее папочку Helpers.
public static class RecordPager
{
       public static MvcHtmlString RecordPage(this HtmlHelper html, PagingInfoModel pageInfo,
                    Func<int, string> url)
       {

             var result = new StringBuilder();

             for (int i = 1; i <= pageInfo.TotalPages; i++)
             {
                    var builder = new TagBuilder("a");
                    builder.MergeAttribute("href", url(i));
                    builder.InnerHtml = i.ToString();
                    if (i == pageInfo.CurrentPage)
                           builder.AddCssClass("current");
                    result.Append(builder.ToString());
             }

             return MvcHtmlString.Create(result.ToString());
       }
}
Осталось сделать несколько финальных штрихов. Во-первых, нужно добавить модель CustomerViewModel, которая будет реализовывать всю работу, связанную с разбиением страниц.
public class CustomerViewModel
{
       public IEnumerable<Customer> Customers { get; set; }
       public PagingInfoModel PagingInfoModel { get; set; }
}
После проделанных действий перепишем логику нашего контроллера.
public ActionResult Index(int? page = 1)
{
       int currentPageIndex = page.HasValue ? page.Value - 1 : 0;
       var customerCount = GetCustomersCount();
       var customers = GetAllCustomers(PageSize, currentPageIndex * PageSize);
       var vm = new CustomerViewModel
       {
             Customers = customers,
             PagingInfoModel = new PagingInfoModel
             {
                    CurrentPage = currentPageIndex,
                    RecordPerPage = PageSize,
                    TotalRecords = customerCount
             }
       };
       return View(vm);
}
Осталось подправить представление View. Для этого откроем файл Views\Home\Index.cshtml и перепишем его, как показано в примере ниже.
@{
    ViewBag.Title = "Home Page";
}

<table>
    <tr>
        <th>Id</th>
        <th>Name</th>
        <th>Address</th>
    </tr>
@using SimplePagingSample.Helpers
@model SimplePagingSample.Models.CustomerViewModel
    @foreach (var item in Model.Customers) {

        <tr>
            <td>
                @item.Id
            </td>
            <td>
                @item.Name
            </td>
            <td>
                @item.Address
            </td>
        </tr>
}

</table>
<nav class="pagination">
    @Html.RecordPage(Model.PagingInfoModel, x => Url.Action("Index", new { page = x }))
</nav>
Затем нужно подправить файл RouteConfig.cs, добавив в него реализацию, как показано ниже.
routes.MapRoute(
       name: null,
       url: "Page/{page}",
       defaults: new { Controller = "Home", action = "Index" }
       );
После запуска примера вы увидите результат:
Ваш проект будет очень сильно тормозить из-за того, что для вывода страниц их пришлось стразу отрисовать. В реальных проектах так не делается. Этот пример будет работать, если только страниц у нас немного. В чем-то более сложном он будет сильно подвисать. Поэтому пойдем другим путем и воспользуемся готовой разбивкой страниц MvcPaging, которая доступна через NuGet.
После этого переделаем нашу модель CustomerViewModel, как показано ниже.
public class CustomerViewModel
{
       public IPagedList<Customer> Customers { get; set; }
}
Наш контроллер изменился совсем немного. Просто добавилась логика по использованию extension метода ToPagedList.
public ActionResult Index(int? page = 1)
{
       int currentPageIndex = page.HasValue ? page.Value - 1 : 0;
       var customerCount = GetCustomersCount();
       var customers = GetAllCustomers(PageSize, currentPageIndex * PageSize);
       var pagedCustomers = customers.ToPagedList(currentPageIndex, PageSize, customerCount);
       var vm = new CustomerViewModel();
       vm.Customers = pagedCustomers;
       return View(vm);
}
Еще пропала ненужная теперь модель PagingInfoModel. UI для отображения тоже изменился лишь немного.
@{
    ViewBag.Title = "Home Page";
}

@model SimplePagingSample.Models.CustomerViewModel

<table>
    <tr>
        <th>Id</th>
        <th>Name</th>
        <th>Address</th>
    </tr>
    @foreach (var item in Model.Customers) {

        <tr>
            <td>
                @item.Id
            </td>
            <td>
                @item.Name
            </td>
            <td>
                @item.Address
            </td>
        </tr>
}

</table>
<div class="pager">
    @Html.Pager(Model.Customers.PageSize, Model.Customers.PageNumber, Model.Customers.TotalItemCount)
</div>
Он стал немного меньше, чем предыдущий вариант. После запуска нашего приложения мы увидим результат, как на рисунке ниже.
На рисунке показана только часть страницы, в которой указывались страницы для перехода. Скорость нашего приложения возросла в разы. У нас нет зависания, как в первом варианте. Всю логику по обработке и разбивке взяла на себя библиотека MvcPaging. Поэтому прежде чем изобретать какой-то новый велосипед, убедитесь, что его не изобрели до вас. В принципе, у нас имеется готовое решение, которое можно использовать, но нужно придать ему более эстетичный вид. Для этого воспользуемся фрейморком для работы с HTML5, CSS3 и JavaScript - Bootstrap.
После установки Bootstrap перейдем в форму Views\Shared\ _Layout.cshtml и перенесем рендеринг jQuery на самый верх формочки, как показано в участке кода ниже.
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>@ViewBag.Title - My ASP.NET MVC Application</title>
        <link href="~/favicon.ico" rel="shortcut icon" type="image/x-icon" />
        <meta name="viewport" content="width=device-width" />
        @Styles.Render("~/Content/css")
        @Styles.Render("~/Content/themes/base/css")
        @Scripts.Render("~/bundles/modernizr")
       
        @Scripts.Render("~/bundles/jquery")
        @Scripts.Render("~/bundles/jqueryui")
    </head>
Затем создадим папку DisplayTemplates  в папке Home, а в созданную папку добавим шаблон Bootstrap3Pagination.cshtml, в которой переместим логику, отвечающую за отображения страниц.
@using System.Web.Mvc
@using MvcPaging
@model PaginationModel
<ul class="pagination">
    @foreach (var link in Model.PaginationLinks)
    {
        @BuildLink(link)
    }
</ul>

@helper BuildLink(PaginationLink link)
{
    var liBuilder = new TagBuilder("li");
    if (link.IsCurrent)
    {
        liBuilder.MergeAttribute("class", "active");
    }
    if (! link.Active)
    {
        liBuilder.MergeAttribute("class", "disabled");
    }

    var aBuilder = new TagBuilder("a");
    if (link.Url == null)
    {
        aBuilder.MergeAttribute("href", "#");
    }
    else
    {
        aBuilder.MergeAttribute("href", link.Url);
    }
    if (link.DisplayText == "«")
    {
        aBuilder.InnerHtml = "&laquo;";
    }
    else if (link.DisplayText == "»")
    {
        aBuilder.InnerHtml = "&raquo;";
    }
    else
    {
        aBuilder.SetInnerText(link.DisplayText);       
    }
    liBuilder.InnerHtml = aBuilder.ToString();

    @Html.Raw(liBuilder.ToString())
}
Внешняя структура проекта показана на рисунке ниже.
Осталось подправить наш файл Index.cshtml, и вся работа будет сделана. Исправленный Index.cshtml со всеми плюшками и новыми возможностями будет иметь следующий вид:
@{
    ViewBag.Title = "Home Page";
}

<script src="~/Scripts/bootstrap.js"></script>
<link href="~/Content/bootstrap.css" rel="stylesheet" />

@model SimplePagingSample.Models.CustomerViewModel

<div class="container">
    <table class="table table-bordered table-hover">
        <tr>
            <th>Id</th>
            <th>Name</th>
            <th>Address</th>
        </tr>
        @foreach (var item in Model.Customers) {

            <tr>
                <td>
                    @item.Id
                </td>
                <td>
                    @item.Name
                </td>
                <td>
                    @item.Address
                </td>
            </tr>
        }

    </table>
    @Html.Pager(Model.Customers.PageSize, Model.Customers.PageNumber, Model.Customers.TotalItemCount).Options(o => o
                                    .DisplayTemplate("Bootstrap3Pagination")
                                    .MaxNrOfPages(10)
                                    .AlwaysAddFirstPageNumber())
</div>

После запуска нашего сайта мы увидим результат, часть из которого показана ниже.
У нас стала доступна подсветка строк, кнопки выбора страницы стали иметь вменяемый вид. На этой позитивной ноте мы закончим тему с разбиением страниц в ASP.NET MVC 4. Надеюсь, у вас в будущем проблема с paging попросту не возникнет.

Список литературы


No comments:

Post a Comment