Главная страница   /   7.5. Добавление нумерации страниц (ASP.NET MVC 4

ASP.NET MVC 4

ASP.NET MVC 4

Адам Фриман

7.5. Добавление нумерации страниц

Как вы можете видеть на рисунке 7-13, все товары из базы данных отображаются на одной странице. В этом разделе мы добавим поддержку нумерации страниц, чтобы на одной странице отображать определенное количество товаров, и чтобы пользователь мог бы посматривать каталог, переходя с одной страницы на другую. Для этого мы добавим в метод List контроллера Product параметр, показанный в листинге 7-15.

Листинг 7-15: Добавляем поддержку нумерации страниц в метод List контроллера Product
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;

namespace SportsStore.WebUI.Controllers
{
	public class ProductController : Controller
	{
		private IProductRepository repository;
		public int PageSize = 4;
		public ProductController(IProductRepository productRepository)
		{
			this.repository = productRepository;
		}

		public ViewResult List(int page = 1)
		{
			return View(repository.Products
				.OrderBy(p => p.ProductID)
				.Skip((page - 1) * PageSize)
				.Take(PageSize));
		}
	}
}

Дополнения в классе контроллера выделены жирным шрифтом. Поле PageSize указывает, что мы хотим видеть четыре товара на странице. В дальнейшем мы вернемся к этому механизму и заменим его на более действенный. Мы добавили дополнительный параметр в метод List. Это означает, что при вызове метода без параметра (List ()) наш вызов обрабатывается так, как если бы мы указали значение параметра (List (1)). В результате, если мы не указываем номер страницы, мы получим первую страницу. LINQ делает нумерацию страниц очень простой. В методе List мы получаем объекты Product из хранилища, упорядочиваем их по первичному ключу, пропускаем товары, которые идут до начала нашей страницы, и берем количество товаров, указанное в поле PageSize.

Модульный тест: нумерация страниц

Чтобы протестировать функцию нумерации страниц, мы можем создать имитированное хранилище, внедрить его в конструктор класса ProductController, а затем вызвать метод List и запросить конкретную страницу. Далее мы можем сравнить те объекты Product, которые получили, с теми, которые ожидали получить из тестовых данных в имитированной реализации. Подробно создание модульных тестов описано в главе 6. Вот тест, который мы создали для этой цели:

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

namespace SportsStore.UnitTests
{
	[TestClass]
	public class UnitTest1
	{
		[TestMethod]
		public void Can_Paginate()
		{
			// Arrange
			Mock<IProductRepository> mock = new Mock<IProductRepository>();
			mock.Setup(m => m.Products).Returns(new Product[] {
						new Product {ProductID = 1, Name = "P1"},
						new Product {ProductID = 2, Name = "P2"},
						new Product {ProductID = 3, Name = "P3"},
						new Product {ProductID = 4, Name = "P4"},
						new Product {ProductID = 5, Name = "P5"}
					}.AsQueryable());

			ProductController controller = new ProductController(mock.Object);

			controller.PageSize = 3;

			// Act
			IEnumerable<Product> result = (IEnumerable<Product>)controller.List(2).Model;

			// Assert
			Product[] prodArray = result.ToArray();
			Assert.IsTrue(prodArray.Length == 2);
			Assert.AreEqual(prodArray[0].Name, "P4");
			Assert.AreEqual(prodArray[1].Name, "P5");
		}
	}
}

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

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

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

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

Вам нужно будет изменить порт в URL, чтобы он соответствовал порту вашего рабочего сервера ASP.NET. С помощью этих строк запросов мы можем просмотреть весь каталог товаров.

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

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

Для поддержки вспомогательного метода HTML, мы будем передавать в представление информацию о количестве доступных страниц, текущей странице и общем количестве товаров в хранилище. Самый простой способ это сделать - создать модель представления, о которой мы кратко упоминали в главе 3. Добавьте класс под названием PagingInfo, показанный в листинге 7-16, в папку в Models в проекте SportsStore.WebUI.

Листинг 7-16: Класс модели представления PagingInfo
using System;

namespace SportsStore.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); }
		}
	}
}

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

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

Теперь, когда у нас есть модель представления, мы реализуем вспомогательный метод HTML, который назовем PageLinks. Создайте в проекте SportsStore.WebUI новую папку под названием HtmlHelpers, и добавьте новый статический класс под названием PagingHelpers. Содержимое файла класса показано в листинге 7-17.

Листинг 7-17: Класс PagingHelpers
using System;
using System.Text;
using System.Web.Mvc;
using SportsStore.WebUI.Models;

namespace SportsStore.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"); // Construct an <a> tag
				tag.MergeAttribute("href", pageUrl(i));
				tag.InnerHtml = i.ToString();
				if (i == pagingInfo.CurrentPage)
					tag.AddCssClass("selected");
				result.Append(tag.ToString());
			}

			return MvcHtmlString.Create(result.ToString());
		}
	}
}

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

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

Для проверки вспомогательного метода PageLinks мы вызываем метод с тестовыми данными и сравниваем результаты с ожидаемым HTML. Метод модульного теста выглядит следующим образом:

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

namespace SportsStore.UnitTests
{
	[TestClass]
	public class UnitTest1
	{
		[TestMethod]
		public void Can_Paginate()
		{
			// ...statements removed for brevity...
		}

		[TestMethod]
		public void Can_Generate_Page_Links()
		{
			// Arrange - define an HTML helper - we need to do this
			// in order to apply the extension method
			HtmlHelper myHelper = null;

			// Arrange - create PagingInfo data
			PagingInfo pagingInfo = new PagingInfo
			{
				CurrentPage = 2,
				TotalItems = 28,
				ItemsPerPage = 10
			};

			// Arrange - set up the delegate using a lambda expression
			Func<int, string> pageUrlDelegate = i => "Page" + i;

			// Act
			MvcHtmlString result = myHelper.PageLinks(pagingInfo, pageUrlDelegate);

			// Assert
			Assert.AreEqual(result.ToString(), @"<a href=""Page1"">1</a>"
				+ @"<a class=""selected"" href=""Page2"">2</a>"
				+ @"<a href=""Page3"">3</a>");
		}
	}
}

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

Метод расширения доступен для использования только тогда, когда содержащее его пространство имен находится в области видимости. В файле кода это делается с помощью оператора using; в представлении Razor мы должны добавить запись конфигурации в файл Web.config, или оператор @using в само представление. Это может привести к путанице, но в проекте MVC Razor есть два файла Web.config: основной, который находится в корневой директории проекта приложения, и специальный для представлений, который находится в папке Views. Данное изменение мы должны провести в файле Views/Web.config, и оно показано в листинге 7-18.

Листинг 7-18: Добавляем пространство имен вспомогательного метода HTML в файл Views/Web.config
<system.web.webPages.razor>
	<host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc,
Version=4.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.Optimization"/>
			<add namespace="System.Web.Routing" />
			<add namespace="SportsStore.WebUI.HtmlHelpers"/>
		</namespaces>
	</pages>
</system.web.webPages.razor>

Каждое пространство имен, к которому нам потребуется обратиться в представлении Razor, нужно объявить либо таким образом, либо в самом представлении с помощью оператора @using.

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

Мы еще не вполне готовы использовать наш вспомогательный метод HTML. Нам осталось предоставить экземпляр класса модели представления PagingInfo в представление. Это можно было бы сделать, используя ViewBag, но лучше мы объединим все данные, которые мы собираемся отправить из контроллера в представление, в один класс модели представления. Для этого добавьте новый класс под названием ProductsListViewModel в папку Models проекта SportsStore.WebUI. Содержание этого класса показано в листинге 7-19.

Листинг 7-19: Класс ProductsListViewModel
using System.Collections.Generic;
using SportsStore.Domain.Entities;

namespace SportsStore.WebUI.Models
{
	public class ProductsListViewModel
	{
		public IEnumerable<Product> Products { get; set; }
		public PagingInfo PagingInfo { get; set; }
	}
}

Теперь мы можем обновить метод List в классе ProductController так, чтобы он начал использовать класс ProductsListViewModel и предоставлять представлению сведения о товарах, которые нужно отображать на странице, и информацию о нумерации страниц, как показано в листинге 7-20.

Листинг 7-20: Обновляем метод List
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using SportsStore.WebUI.Models;

namespace SportsStore.WebUI.Controllers
{
	public class ProductController : Controller
	{
		private IProductRepository repository;
		public int PageSize = 4;

		public ProductController(IProductRepository productRepository)
		{
			this.repository = productRepository;
		}

		public ViewResult List(int page = 1)
		{
			ProductsListViewModel model = new ProductsListViewModel
			{
				Products = repository.Products
					.OrderBy(p => p.ProductID)
					.Skip((page - 1) * PageSize)
					.Take(PageSize),
				PagingInfo = new PagingInfo
				{
					CurrentPage = page,
					ItemsPerPage = PageSize,
					TotalItems = repository.Products.Count()
				}
			};

			return View(model);
		}
	}
}

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

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

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

[TestMethod]
public void Can_Send_Pagination_View_Model()
{
	// Arrange
	Mock<IProductRepository> mock = new Mock<IProductRepository>();
	mock.Setup(m => m.Products).Returns(new Product[] {
			new Product {ProductID = 1, Name = "P1"},
			new Product {ProductID = 2, Name = "P2"},
			new Product {ProductID = 3, Name = "P3"},
			new Product {ProductID = 4, Name = "P4"},
			new Product {ProductID = 5, Name = "P5"}
		}.AsQueryable());

	// Arrange
	ProductController controller = new ProductController(mock.Object);
	controller.PageSize = 3;

	// Act
	ProductsListViewModel result = (ProductsListViewModel)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 представляет собой последовательность объектов Product, но мы заключили данные в другой тип модели представления. Вот обновленный тест:

[TestMethod]
public void Can_Paginate()
{
	// Arrange
	// - create the mock repository
	Mock<IProductRepository> mock = new Mock<IProductRepository>();
	mock.Setup(m => m.Products).Returns(new Product[] {
			new Product {ProductID = 1, Name = "P1"},
			new Product {ProductID = 2, Name = "P2"},
			new Product {ProductID = 3, Name = "P3"},
			new Product {ProductID = 4, Name = "P4"},
			new Product {ProductID = 5, Name = "P5"}
		}.AsQueryable());

	// create a controller and make the page size 3 items
	ProductController controller = new ProductController(mock.Object);
	controller.PageSize = 3;

	// Action
	ProductsListViewModel result = (ProductsListViewModel)controller.List(2).Model;

	// Assert
	Product[] prodArray = result.Products.ToArray();
	Assert.IsTrue(prodArray.Length == 2);
	Assert.AreEqual(prodArray[0].Name, "P4");
	Assert.AreEqual(prodArray[1].Name, "P5");
}

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

На данный момент представление ожидает последовательность объектов Product, так что нам необходимо обновить List.cshtml, как показано в листинге 7-21, чтобы работать с новым типом модели представления.

Листинг 7-21: Обновляем представление List.cshtml
@model SportsStore.WebUI.Models.ProductsListViewModel

@{
	ViewBag.Title = "Products";
}

@foreach (var p in Model.Products)
{
	<div class="item">
		<h3>@p.Name</h3>
		@p.Description
		<h4>@p.Price.ToString("c")</h4>
	</div>
}

Мы изменили директиву @model, чтобы сообщить Razor, что сейчас мы работаем с другим типом данных. Нам также нужно было обновить цикл foreach так, чтобы источником данных стало свойство Products данных модели.

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

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

Листинг 7-22: Вызываем вспомогательный метод HTML
@model SportsStore.WebUI.Models.ProductsListViewModel

@{
	ViewBag.Title = "Products";
}

@foreach (var p in Model.Products)
{
	<div class="item">
		<h3>@p.Name</h3>
		@p.Description
		<h4>@p.Price.ToString("c")</h4>
	</div>
}

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

Если вы запустите приложение, вы увидите, что появились ссылки на страницы, как показано на рисунке 7-14. Оформлены они довольно просто, но мы исправим это позже в этой главе. На данный момент важно то, что по ссылкам можно переходить с одной страницы на другую и просматривать товары в каталоге.

Рисунок 7-14: Отображаем навигационные ссылки

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

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

То, что мы сделали до сих пор, может не производить глубокого впечатления, но сильно отличается от перетаскивания GridView на поверхность разработки. Во-первых, мы строим приложение с надежной и хорошо поддерживаемой архитектурой, которая способствует надлежащему разделению обязанностей. Здесь мы не связываем непосредственно интерфейс с базой данных, в отличие подхода с использованием GridView, который дает быстрый результат, но вызывает затруднения и неудобства с течением времени. Во-вторых, в процессе работы мы создаем модульные тесты, которые позволяют нам естественным образом проверять поведение приложения, что практически невозможно при использовании GridView.

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

Улучшаем URL

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

http://localhost/?page=2

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

http://localhost/Page2

К счастью, в MVC очень легко изменять схему URL, поскольку она использует функцию маршрутизации ASP.NET. Для этого нам нужно только добавить новый роут в метод RegisterRoutes в файле RouteConfig.cs, который вы найдете в папке App_Start. Изменения показаны в листинге 7-23.

Листинг 7-23: Добавляем новый роут
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

namespace SportsStore.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 = "Product", action = "List" }
			);

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

Важно, чтобы вы добавили этот роут перед роутом Default, который уже есть в файле. Как вы увидите в главе 13, роуты обрабатываются в том порядке, в котором они указаны, а нам нужно, чтобы у нового роута был более высокий приоритет, чем у роута по умолчанию.

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

Рисунок 7-15: Новая схема URL отображается в браузере