Разбиение на страницы

160 Исходный код проекта

На данный момент мы реализовали хранилище данных и создали тестовое представление List.cshtml, в котором выводится список всех товаров магазина. В этой статье мы добавим поддержку разбиения на страницы, чтобы представление отображало на странице заданное количество товаров, и пользователь мог переходить от одной страницы к другой с целью просмотра всего каталога.

Для этого в метод действия List() контроллера Game необходимо добавить параметр, как показано в примере ниже:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using GameStore.Domain.Abstract;
using GameStore.Domain.Entities;

namespace GameStore.WebUI.Controllers
{
    public class GameController : Controller
    {
        private IGameRepository repository;
        public int pageSize = 4;

        public GameController(IGameRepository repo)
        {
            repository = repo;
        }

        public ViewResult List(int page = 1)
        {
            return View(repository.Games
                .OrderBy(game => game.GameId)
                .Skip((page - 1)*pageSize)
                .Take(pageSize));
        }
	}
}

Поле PageSize указывает, что на одной странице должны отображаться сведения о четырех товарах. Позже мы вернемся к этому свойству и заменим его более совершенным механизмом.

В метод List() добавлен необязательный параметр. Это означает, что в случае вызова метода без параметра List() вызов обрабатывается так, словно ему было передано значение, указанное в определении параметра по умолчанию List(1). В результате метод действия отображает первую страницу сведений о товарах, когда MVC Framework вызывает его без аргумента. Внутри тела метода действия мы получаем объекты Game, упорядочиваем их по первичному ключу, пропускаем товары, которые располагаются до начала текущей страницы, и выбираем то количество товаров, которое указано в поле PageSize.

Модульное тестирование: разбиение на страницы

Модульное тестирование средства разбиения на страницы можно провести, создав имитированное хранилище, внедрив его в конструктор класса GameController и вызвав метод List(), чтобы запросить конкретную страницу. Затем полученные объекты Game можно сравнить с теми, которые ожидались от тестовых данных в имитированной реализации.

Написание модульных тестов обсуждалось в статье "ASP.NET MVC 5 - Модульное тестирование". Ниже показан созданный для этой цели модульный тест, который находится в файле UnitTest1.cs проекта GameStore.UnitTests:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using GameStore.Domain.Abstract;
using GameStore.Domain.Entities;
using GameStore.WebUI.Controllers;

namespace GameStore.UnitTests
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void Can_Paginate()
        {
            // Организация (arrange)
            Mock<IGameRepository> mock = new Mock<IGameRepository>();
            mock.Setup(m => m.Games).Returns(new List<Game>
            {
                new Game { GameId = 1, Name = "Игра1"},
                new Game { GameId = 2, Name = "Игра2"},
                new Game { GameId = 3, Name = "Игра3"},
                new Game { GameId = 4, Name = "Игра4"},
                new Game { GameId = 5, Name = "Игра5"}
            });
            GameController controller = new GameController(mock.Object);
            controller.pageSize = 3;

            // Действие (act)
            IEnumerable<Game> result = (IEnumerable<Game>)controller.List(2).Model;

            // Утверждение (assert)
            List<Game> games = result.ToList();
            Assert.IsTrue(games.Count == 2);
            Assert.AreEqual(games[0].Name, "Игра4");
            Assert.AreEqual(games[1].Name, "Игра5");
        }
    }
}

Обратите внимание, насколько просто получить данные, возвращаемые методом контроллера. Мы обращаемся к свойству Model объекта результата, чтобы получить последовательность IEnumerable<Game>, сгенерированную методом List(). Затем выполняется проверка, являются ли эти данные ожидаемыми. В этом случае мы преобразовали последовательность в коллекцию с помощью LINQ-метода ToList() и проверили длину и значения отдельных объектов.

Отображение ссылок на страницы

Запустив приложение, несложно убедиться, что на странице отображаются только четыре позиции каталога. Если нужно просмотреть другую страницу, в конец URL следует добавить параметры строки запроса, например:

http://localhost:53985/?page=2

Номер порта в URL понадобится заменить номером порта, на котором действует ваш сервер разработки ASP.NET. Используя эти строки запроса, можно осуществлять навигацию по каталогу товаров.

Естественно, у пользователей нет какого-либо способа выяснить о существовании этих параметров строки запроса, и даже если бы он был, пользователи, скорее всего, не захотели бы выполнять навигацию в подобном стиле.

Вместо этого необходимо визуализировать в нижней части каждого списка товаров ссылки на определенные страницы, чтобы пользователи могли переходить между страницами. Для этого мы реализуем многократно используемый вспомогательный метод HTML, аналогичный методам Html.TextBoxFor() и Html.BeginForm(). Этот вспомогательный метод будет генерировать HTML-разметку для требуемых навигационных ссылок.

Добавление модели представления

Чтобы обеспечить поддержку нового вспомогательного метода HTML, мы собираемся передать представлению информацию о количестве доступных страниц, текущей странице и общем количестве товаров в хранилище. Проще всего это сделать, создав модель представления. Добавьте в папку Models проекта GameStore.WebUI класс по имени PagingInfo, код которого приведен в примере ниже:

using System;

namespace GameStore.WebUI.Models
{
    public class PagingInfo
    {
		// Кол-во товаров
        public int TotalItems { get; set; }
		
		// Кол-во товаров на одной странице
        public int ItemsPerPage { get; set; }
		
		// Номер текущей страницы
        public int CurrentPage { get; set; }

		// Общее кол-во страниц
        public int TotalPages
        {
            get { return (int)Math.Ceiling((decimal)TotalItems / ItemsPerPage); }
        }
    }
}

Модель представления не является частью модели предметной области. Это всего лишь удобный класс для передачи данных между контроллером и представлением. Чтобы подчеркнуть указанное обстоятельство, мы поместили класс PagingInfo в проект GameStore.WebUI для сохранения его отдельно от классов модели предметной области.

Добавление вспомогательного метода HTML

Теперь, имея модель представления, можно реализовать вспомогательный метод HTML, который будет назван PageLinks(). Создайте в проекте GameStore.WebUI новую папку HtmlHelpers и поместите в нее новый файл класса по имени PagingHelper.cs с содержимым, показанным в примере ниже:

using System;
using System.Text;
using System.Web.Mvc;
using GameStore.WebUI.Models;

namespace GameStore.WebUI.HtmlHelpers
{
    public static class PagingHelpers
    {
        public static MvcHtmlString PageLinks(this HtmlHelper html,
                                              PagingInfo pagingInfo,
                                              Func<int, string> pageUrl)
        {
            StringBuilder result = new StringBuilder();
            for (int i = 1; i <= pagingInfo.TotalPages; i++)
            {
                TagBuilder tag = new TagBuilder("a");
                tag.MergeAttribute("href", pageUrl(i));
                tag.InnerHtml = i.ToString();
                if (i == pagingInfo.CurrentPage)
                {
                    tag.AddCssClass("selected");
                    tag.AddCssClass("btn-primary");
                }
                tag.AddCssClass("btn btn-default");
                result.Append(tag.ToString());
            }
            return MvcHtmlString.Create(result.ToString());
        }
    }
}

Расширяющий метод PageLinks() генерирует HTML-разметку для набора ссылок на страницы с использованием информации, предоставленной в объекте PagingInfo. Параметр Func принимает делегат, который применяется для генерации ссылок, обеспечивающих просмотр других страниц.

Модульное тестирование: создание ссылок на страницы

Чтобы протестировать вспомогательный метод PageLinks(), мы вызываем метод с тестовыми данными и сравниваем результаты с ожидаемой HTML-разметкой. Метод модульного теста имеет следующий вид:

// ...
using GameStore.WebUI.Models;
using GameStore.WebUI.HtmlHelpers;

namespace GameStore.UnitTests
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void Can_Paginate()
        {
            // ...
        }

        [TestMethod]
        public void Can_Generate_Page_Links()
        {

            // Организация - определение вспомогательного метода HTML - это необходимо
            // для применения расширяющего метода
            HtmlHelper myHelper = null;

            // Организация - создание объекта PagingInfo
            PagingInfo pagingInfo = new PagingInfo
            {
                CurrentPage = 2,
                TotalItems = 28,
                ItemsPerPage = 10
            };

            // Организация - настройка делегата с помощью лямбда-выражения
            Func<int, string> pageUrlDelegate = i => "Page" + i;

            // Действие
            MvcHtmlString result = myHelper.PageLinks(pagingInfo, pageUrlDelegate);

            // Утверждение
            Assert.AreEqual(@"<a class=""btn btn-default"" href=""Page1"">1</a>"
                + @"<a class=""btn btn-default btn-primary selected"" href=""Page2"">2</a>"
                + @"<a class=""btn btn-default"" href=""Page3"">3</a>",
                result.ToString());
        }
    }
}

Этот тест проверяет вывод вспомогательного метода с использованием литерального строкового значения, содержащего двойные кавычки. Язык C# позволяет успешно работать с такими строками. Нужно только не забывать предварять строку символом @ и применять два набора двойных кавычек ("") вместо одного. Необходимо также помнить, что нельзя разносить литеральную строку по нескольким строкам файла, если только строка, с которой производится сравнение, не разнесена аналогичным образом. Например, литерал, используемый в тестовом методе, был размещен в двух строчках из-за недостаточной ширины страницы сайта. Мы не добавляли символ новой строки - иначе тест не прошел бы.

Расширяющий метод доступен для применения, только когда содержащее его пространство имен находится в области видимости. В файле кода это достигается с помощью оператора using, но для представления Razor должна быть добавлена конфигурационная запись в файл Web.config, либо оператор @using к самому представлению.

Несколько сбивает с толку то, что проект Razor MVC содержит два файла Web.config: главный файл в корневом каталоге проекта приложения и файл, который специфичен для представления и находится в папке Views. Изменение потребуется внести в файл Views/Web.config, как показано в примере ниже:

<system.web.webPages.razor>
    <host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
    <pages pageBaseType="System.Web.Mvc.WebViewPage">
      <namespaces>
        <add namespace="System.Web.Mvc" />
        <add namespace="System.Web.Mvc.Ajax" />
        <add namespace="System.Web.Mvc.Html" />
        <add namespace="System.Web.Routing" />
        <add namespace="GameStore.WebUI" />
        <add namespace="GameStore.WebUI.HtmlHelpers"/>
      </namespaces>
    </pages>
</system.web.webPages.razor>

Каждое пространство имен, на которое необходимо ссылаться в представлении Razor для явного его использования, должно быть объявлено в файле Views/Web.config или применяться с помощью выражения @using.

Добавление данных модели представления

Пока еще не все готово к использованию созданного вспомогательного метода HTML. Нужно также предоставить представлению экземпляр класса модели представления PagingInfo. Это можно было бы сделать с применением объекта ViewBag, но предпочтительнее поместить все данные, которые требуется передать из контроллера представлению, в один класс модели представления. Для этого добавьте в папку Models проекта GameStore.WebUI новый файл класса по имени GamesListViewModel.cs. Содержимое этого файла приведено в примере ниже:

using System.Collections.Generic;
using GameStore.Domain.Entities;

namespace GameStore.WebUI.Models
{
    public class GamesListViewModel
    {
        public IEnumerable<Game> Games { get; set; }
        public PagingInfo PagingInfo { get; set; }
    }
}

Теперь можно обновить метод действия List() класса GameController так, чтобы он использовал класс GamesListViewModel для снабжения представления сведениями о товарах, отображаемых на страницах, и информацией о разбиении на страницы:

// ...
using GameStore.WebUI.Models;

namespace GameStore.WebUI.Controllers
{
    public class GameController : Controller
    {
        // ...
       
        public ViewResult List(int page = 1)
        {
            GamesListViewModel model = new GamesListViewModel
            {
                Games = repository.Games
                    .OrderBy(game => game.GameId)
                    .Skip((page - 1)*pageSize)
                    .Take(pageSize),
                PagingInfo = new PagingInfo
                {
                    CurrentPage = page,
                    ItemsPerPage = pageSize,
                    TotalItems = repository.Games.Count()
                }
            };
            return View(model);
        }
	}
}

Внесенные изменения обеспечивают передачу объекта GamesListViewModel представлению в качестве данных модели.

Модульное тестирование: данные разбиения на страницы для модели представления

Необходимо удостовериться, что контроллер отправляет представлению правильную информацию о разбиении на страницы. Ниже приведен модульный тест, добавленный в тестовый проект для обеспечения этой проверки:

// ...
[TestMethod]
public void Can_Send_Pagination_View_Model()
{
    // Организация (arrange)
    Mock<IGameRepository> mock = new Mock<IGameRepository>();
    mock.Setup(m => m.Games).Returns(new List<Game>
    {
        new Game { GameId = 1, Name = "Игра1"},
        new Game { GameId = 2, Name = "Игра2"},
        new Game { GameId = 3, Name = "Игра3"},
        new Game { GameId = 4, Name = "Игра4"},
        new Game { GameId = 5, Name = "Игра5"}
    });
    GameController controller = new GameController(mock.Object);
    controller.pageSize = 3;

    // Act
    GamesListViewModel result
        = (GamesListViewModel)controller.List(2).Model;

    // Assert
    PagingInfo pageInfo = result.PagingInfo;
    Assert.AreEqual(pageInfo.CurrentPage, 2);
    Assert.AreEqual(pageInfo.ItemsPerPage, 3);
    Assert.AreEqual(pageInfo.TotalItems, 5);
    Assert.AreEqual(pageInfo.TotalPages, 2);
}

Кроме того, потребуется изменить ранее созданный модульный тест для проверки разбиения на страницы, содержащийся в методе Can_Paginate(). Он полагается на метод действия List(), возвращающий объект ViewResult, свойством Model которого является последовательность объектов Game, но мы поместили эти данные внутрь другого типа модели представления. После переделки тест получает следующий вид:


[TestMethod]
public void Can_Paginate()
{
    // Организация (arrange)
    Mock<IGameRepository> mock = new Mock<IGameRepository>();
    mock.Setup(m => m.Games).Returns(new List<Game>
    {
        new Game { GameId = 1, Name = "Игра1"},
        new Game { GameId = 2, Name = "Игра2"},
        new Game { GameId = 3, Name = "Игра3"},
        new Game { GameId = 4, Name = "Игра4"},
        new Game { GameId = 5, Name = "Игра5"}
    });
    GameController controller = new GameController(mock.Object);
    controller.pageSize = 3;

    // Действие (act)
    GamesListViewModel result = (GamesListViewModel)controller.List(2).Model;

    // Утверждение
    List<Game> games = result.Games.ToList();
    Assert.IsTrue(games.Count == 2);
    Assert.AreEqual(games[0].Name, "Игра4");
    Assert.AreEqual(games[1].Name, "Игра5");
}

Учитывая определенное сходство между двумя этими тестовыми методами, обычно следовало бы создать общий метод начальной установки.

В настоящий момент представление ожидает последовательность объектов Game, поэтому List.cshtml необходимо модифицировать, как показано в примере ниже, чтобы можно было работать с новым типом модели представления:

@using GameStore.WebUI.Models
@model GamesListViewModel

@{
    ViewBag.Title = "Товары";
}

@foreach (var p in @Model.Games)
{
    <div>
        <h3>@p.Name</h3>
        <p>@p.Description</p>
        <h4>@p.Price.ToString("# руб")</h4>
    </div>
}

Директива @model была изменена для указания Razor на то, что теперь работа выполняется с другим типом данных. Понадобилось также обновить цикл foreach, чтобы источником данных служило свойство Games модели данных.

Отображение ссылок на страницы

Итак, все готово для добавления в представление List ссылок на страницы. Мы создали модель представления, которая содержит информацию о разбиении на страницы, обновили контроллер, чтобы эта информация передавалась представлению, и изменили директиву @model, приведя ее в соответствие с новым типом модели представления. Осталось только вызвать наш вспомогательный метод HTML внутри представления, как показано в примере ниже:

@using GameStore.WebUI.Models
@using GameStore.WebUI.HtmlHelpers
@model GamesListViewModel

@{
    ViewBag.Title = "Товары";
}

@foreach (var p in @Model.Games)
{
    <div>
        <h3>@p.Name</h3>
        <p>@p.Description</p>
        <h4>@p.Price.ToString("# руб")</h4>
    </div>
}

<div>
    @Html.PageLinks(Model.PagingInfo, x => Url.Action("List", new { page = x }))
</div>

Запустив приложение, вы увидите новые ссылки на страницы. Стиль отображения довольно примитивен, но в следующей статье он будет улучшен. Пока же важно то, что ссылки позволяют перемещаться по страницам каталога и знакомиться с товарами, выставленными на продажу.

Отображение ссылок для навигации по страницам

Почему бы просто не воспользоваться элементом управления GridView?

Если вам ранее приходилось работать с ASP.NET, то может показаться, что такой огромный объем работы привел к весьма скромному результату. Пришлось написать немало кода лишь для того, чтобы получить список страниц. Если бы мы использовали инфраструктуру Web Forms, этого же результата можно было бы достичь с помощью готового элемента управления GridView или ListView из ASP.NET Web Forms, привязав его напрямую к таблице Games базы данных.

То, что было сделано до сих пор в этой статье, выглядит не слишком впечатляюще, но отличается от простого перетаскивания элемента управления на поверхность визуального проектирования:

Разумеется, ничто из этого отнюдь не умаляет значимость незамедлительных результатов, которые может предоставить инфраструктура Web Forms, но, как объяснялось в статье "Архитектура ASP.NET MVC 5", такая незамедлительность имеет свою цену, которая может оказаться высокой и болезненной в крупных и сложных проектах.

Улучшение URL

Ссылки на страницы работают, но для передачи информации серверу они по-прежнему используют строку запроса, подобную следующей:

http://localhost:53985/?page=2

Получить более привлекательные URL можно, специально создав схему, которая соответствует шаблону компонуемых URL. Компонуемый URL - это URL, понятный пользователю, такой как показанный ниже:

http://localhost:53985/Page2

Инфраструктура MVC позволяет легко изменять схему URL в приложении, поскольку она пользуется средством маршрутизации ASP.NET. Все что понадобится сделать - это добавить новый маршрут к методу RegisterRoutes() в файле RouteConfig.cs, который находится в папке App_Start проекта GameStore.WebUI. В примере ниже показаны изменения, внесенные в этот файл:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

namespace GameStore.WebUI
{
    public class RouteConfig
    {
        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            routes.MapRoute(
                name: null,
                url: "Page{page}",
                defaults: new { controller = "Game", action = "List" }
            );

            routes.MapRoute(
                name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new { controller = "Game", action = "List", id = UrlParameter.Optional }
            );
        }
    }
}

Важно поместить этот маршрут перед стандартным (Default) маршрутом, который уже присутствует в файле, т.к. система маршрутизации обрабатывает маршруты в порядке их перечисления, а нам требуется, чтобы новый маршрут имел преимущество перед существующим.

Это единственное изменение, которое потребуется внести, чтобы изменить схему URL для разбиения на страницы сведений о товарах. Инфраструктура MVC Framework тесно интегрирована с функцией маршрутизации, поэтому приложение автоматически отражает изменение подобного рода внутри результата, генерируемого методом Url.Action(), который применялся в представлении List.cshtml для генерации ссылок на страницы.

Если запустить приложение и перейти на какую-либо страницу, можно увидеть новую схему URL в действии:

Новая схема URL, отображаемая в браузере
Пройди тесты
Лучший чат для C# программистов