Использование модели связывания данных
MVC Framework использует систему под названием модель связывания данных для создания объектов C# из HTTP-запросов и передачи их в качестве значений параметров в методы действий. Таким образом, например, MVC обрабатывает формы. Платформа смотрит на параметры целевого метода действия и использует механизм связывания данных модели, чтобы получить значения входных элементов формы и преобразовать их в одноименный тип параметра.
Механизмы связывания могут создавать типы C# из любых данных, доступных в запросе. Это является одной из центральных возможностей MVC Framework. Мы создадим пользовательский механизм связывания, чтобы улучшить наш класс CartController
.
Нам нравится использовать состояние сеанса в контроллере Cart
для хранения и управления объектами Cart
, но нам не нравится то, как мы должны это делать. Это не соответствует всей остальной модели приложения, которая основана на параметрах методов действий. Мы не сможем должным образом протестировать класс CartController
, если только не создадим имитацию параметра Session
базового класса, а это означает, что нужно будет создавать имитацию класса Controller
и много других вещей, с которыми мы бы предпочли не иметь дела.
Чтобы решить эту проблему, мы создадим пользовательский механизм связывания, который будет получать объект Cart
, содержащийся в данных сессии. Тогда MVC Framework сможет создавать объекты Cart
и передавать их в качестве параметров методов действий в наш класс CartController
. Связывание данных – очень мощная и гибкая возможность. Мы рассмотрим ее подробнее в главе 22, но этот пример отлично подходит, чтобы с ней познакомиться.
Создаем пользовательский механизм связывания данных
Мы создаем пользовательский механизм связывания путем реализации интерфейса IModelBinder
. Создайте новую папку под названием Binders
в проекте SportsStore.WebUI
, а в ней - класс CartModelBinder
. Определение класса CartModelBinder
показано в листинге 9-1.
Листинг 9-1: Класс CartModelBinder
using System;
using System.Web.Mvc;
using SportsStore.Domain.Entities;
namespace SportsStore.WebUI.Binders
{
public class CartModelBinder : IModelBinder
{
private const string sessionKey = "Cart";
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
// get the Cart from the session
Cart cart = (Cart)controllerContext.HttpContext.Session[sessionKey];
// create the Cart if there wasn't one in the session data
if (cart == null)
{
cart = new Cart();
controllerContext.HttpContext.Session[sessionKey] = cart;
}
// return the cart
return cart;
}
}
}
Интерфейс IModelBinder
определяет один метод: BindModel
. Мы передаем в него два параметра для того, чтобы сделать возможным создание объекта доменной модели. ControllerContext
обеспечивает доступ ко всей информации, которой располагает класс контроллера и которая включает в себя детали запроса клиента. ModelBindingContext
предоставляет информацию об объекте создаваемой модели, и некоторые инструменты, которые облегчат процесс связывания. Мы вернемся к этому классу в главе 22.
Класс ControllerContext
интересует нас больше всего. У него есть свойство HttpContext
, у которого, в свою очередь, есть свойство Session
, которое позволяет получать и устанавливать данные сессии. Прочитав значение ключа из данных сессии, мы получаем объект Cart
или, если его еще не существует, создаем новый.
Мы должны сообщить MVC Framework, что она может использовать класс CartModelBinder
для создания экземпляров объекта Cart
. Мы делаем это в методе Application_Start
файла Global.asax
, как показано в листинге 9-2.
Листинг 9-2: Регистрируем класс CartModelBinder
using SportsStore.Domain.Entities;
using SportsStore.WebUI.Binders;
using SportsStore.WebUI.Infrastructure;
using System.Web.Http;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
namespace SportsStore.WebUI
{
public class MvcApplication : System.Web.HttpApplication
{
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
WebApiConfig.Register(GlobalConfiguration.Configuration);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
ControllerBuilder.Current.SetControllerFactory(new NinjectControllerFactory());
ModelBinders.Binders.Add(typeof(Cart), new CartModelBinder());
}
}
}
Теперь мы можем обновить класс CartController
, чтобы удалить метод GetCart
и задействовать на наш механизм связывания, который MVC Framework будет применять автоматически. Изменения показаны в листинге 9-3.
Листинг 9-3: Используем механизм связывания в CartController
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using SportsStore.WebUI.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace SportsStore.WebUI.Controllers
{
public class CartController : Controller
{
private IProductRepository repository;
public CartController(IProductRepository repo)
{
repository = repo;
}
public ViewResult Index(Cart cart, string returnUrl)
{
return View(new CartIndexViewModel
{
Cart = cart,
ReturnUrl = returnUrl
});
}
public RedirectToRouteResult AddToCart(Cart cart, int productId, string returnUrl)
{
Product product = repository.Products.FirstOrDefault(p => p.ProductID == productId);
if (product != null)
{
cart.AddItem(product, 1);
}
return RedirectToAction("Index", new { returnUrl });
}
public RedirectToRouteResult RemoveFromCart(Cart cart, int productId, string returnUrl)
{
Product product = repository.Products.FirstOrDefault(p => p.ProductID == productId);
if (product != null)
{
cart.RemoveLine(product);
}
return RedirectToAction("Index", new { returnUrl });
}
}
}
Мы удалили метод GetCart
и добавили параметр Cart
в каждый метод действия. Когда MVC Framework получает запрос, который требует, скажем, вызвать метод AddToCart
, она будет сначала смотреть на параметры для метода действия. Она рассмотрит список доступных механизмов связывания и попытается найти тот, который сможет создать экземпляры каждого типа параметра. Наш пользовательский механизм связывания должен будет создать объект Cart
, что он и сделает, используя состояние сеанса. Между обращениями к нашему механизму связывания и механизму по умолчанию MVC Framework может создать набор параметров, которые необходимы для вызова метода действия, позволяя нам реорганизовать контроллер так, чтобы в нем не осталось сведений о том, как создаются объекты Cart
при получении запросов.
Использование подобного механизма связывания дает нам несколько преимуществ. Первое заключается в том, что мы отделили логику для создания объектов Cart
от контроллера, что позволит нам изменять способ сохранения этих объектов без необходимости изменять контроллер. Вторым преимуществом является то, что любой класс контроллера, который работает с объектами Cart
, может просто объявить их как параметры метода действия и воспользоваться пользовательским механизмом связывания. Третье и, на наш взгляд, самое главное преимущество состоит в том, что теперь мы сможем тестировать контроллер Cart
, не создавая имитаций встроенных возможностей ASP.NET.
Модульный тест: контроллер
Cart
Мы можем протестировать класс
CartController
, создавая объектыCart
и передавая их в методы действия. Мы хотим проверить три аспекта этого контроллера:
- Метод
AddToCart
должен добавить выбранный товар в корзину покупателя.- После добавления товара в корзину он должен перенаправить нас в представление
Index
.- URL, по которому пользователь сможет вернуться в каталог, должен быть корректно передан в метод действия
Index
.Вот модульные тесты, которые мы добавили в файл
CartTests.cs
проектаSportsStore.UnitTests
:using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using SportsStore.Domain.Entities; using System.Linq; using Moq; using SportsStore.Domain.Abstract; using SportsStore.WebUI.Controllers; using System.Web.Mvc; using SportsStore.WebUI.Models; namespace SportsStore.UnitTests { [TestClass] public class CartTests { //...existing test methods omitted for brevity... [TestMethod] public void Can_Add_To_Cart() { // 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", Category = "Apples"}, }.AsQueryable()); // Arrange - create a Cart Cart cart = new Cart(); // Arrange - create the controller CartController target = new CartController(mock.Object); // Act - add a product to the cart target.AddToCart(cart, 1, null); // Assert Assert.AreEqual(cart.Lines.Count(), 1); Assert.AreEqual(cart.Lines.ToArray()[0].Product.ProductID, 1); } [TestMethod] public void Adding_Product_To_Cart_Goes_To_Cart_Screen() { // 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", Category = "Apples"}, }.AsQueryable()); // Arrange - create a Cart Cart cart = new Cart(); // Arrange - create the controller CartController target = new CartController(mock.Object); // Act - add a product to the cart RedirectToRouteResult result = target.AddToCart(cart, 2, "myUrl"); // Assert Assert.AreEqual(result.RouteValues["action"], "Index"); Assert.AreEqual(result.RouteValues["returnUrl"], "myUrl"); } [TestMethod] public void Can_View_Cart_Contents() { // Arrange - create a Cart Cart cart = new Cart(); // Arrange - create the controller CartController target = new CartController(null); // Act - call the Index action method CartIndexViewModel result = (CartIndexViewModel)target.Index(cart, "myUrl").ViewData.Model; // Assert Assert.AreSame(result.Cart, cart); Assert.AreEqual(result.ReturnUrl, "myUrl"); } } }