Использование привязки модели и завершение корзины

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

Привязка модели

Инфраструктура MVC Framework использует систему привязки модели для создания объектов C# из HTTP-запросов и передает эти объекты в виде значений параметров в методы действий. Подобным образом инфраструктура MVC Framework обрабатывает, к примеру, формы: она просматривает параметры целевого метода действия и применяет связыватель модели для получения значений формы, отправленной браузером, и преобразования их в типы параметров с совпадающими именами перед их передачей методу действия.

Связыватели моделей могут создавать типы C# из любой информации, доступной в запросе. Это одно из центральных средств MVC Framework. Мы собираемся создать специальный связыватель модели, чтобы усовершенствовать класс CartController.

Нам нравится использовать средство состояния сеанса в контроллере Cart для хранения и управления объектами Cart, созданными в предыдущей статье, но нас не устраивает способ, которым это должно делаться. Он не вписывается в остальные части модели приложения, которые основаны на параметрах методов действий. Мы не можем провести исчерпывающее модульное тестирование класса CartController до тех пор, пока не построим имитацию параметра Session базового класса, а это означает имитацию класса Controller и множество других вещей, с которыми лучше не связываться.

Для решения этой проблемы мы создадим специальный связыватель модели, который будет получать объект Cart, содержащийся внутри данных сеанса. В результате у инфраструктуры MVC Framework появится возможность создавать объекты Cart и передавать их в виде параметров методам действий класса CartController. Средство привязки моделей является очень мощным и гибким, приведенный пример послужит хорошей демонстрацией его возможностей.

Создание специального связывателя модели

Мы построим специальный связыватель модели путем реализации интерфейса System.Web.Mvc.IModelBinder. Чтобы создать эту реализацию, добавьте в проект GameStore.WebUI новую папку по имени Infrastructure/Binders и поместите в нее файл класса с содержимым, показанным в примере ниже:

using GameStore.Domain.Entities;
using System.Web.Mvc;

namespace GameStore.WebUI.Infrastructure.Binders
{
    public class CartModelBinder : IModelBinder
    {
        private const string sessionKey = "Cart";

        public object BindModel(ControllerContext controllerContext,
            ModelBindingContext bindingContext)
        {
            // Получить объект Cart из сеанса
            Cart cart = null;
            if (controllerContext.HttpContext.Session != null)
            {
                cart = (Cart)controllerContext.HttpContext.Session[sessionKey];
            }

            // Создать объект Cart если он не обнаружен в сеансе
            if (cart == null)
            {
                cart = new Cart();
                if (controllerContext.HttpContext.Session != null)
                {
                    controllerContext.HttpContext.Session[sessionKey] = cart;
                }
            }

            // Возвратить объект Cart
            return cart;
        }
    }
}

В интерфейсе IModelBinder определен один метод: BindModel(). Этот метод принимает два параметра, чтобы обеспечить возможность создания объекта модели предметной области. Параметр ControllerContext обеспечивает доступ ко всей информации, которой располагает класс контроллера, включая детали запроса от клиента. Параметр ModelBindingContext предоставляет сведения об объекте модели, который требуется создать, а также набор инструментов для упрощения процесса привязки.

В рассматриваемом случае нас интересует класс ControllerContext. Он имеет свойство HttpContext, которое, в свою очередь, содержит свойство Session, позволяющее получать и устанавливать данные сеанса. Объект Cart получается за счет чтения значения для ключа из данных сеанса и создания экземпляра Cart, если оказалось, что он не существует.

Инфраструктуре MVC Framework необходимо сообщить о том, что она может использовать класс CartModelBinder для создания экземпляров Cart. Это делается в методе Application_Start() внутри файла Global.asax, как показано в примере ниже:

using System.Web.Mvc;
using System.Web.Routing;
using GameStore.Domain.Entities;
using GameStore.WebUI.Infrastructure.Binders;

namespace GameStore.WebUI
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            ModelBinders.Binders.Add(typeof(Cart), new CartModelBinder());
        }
    }
}

Теперь можно модифицировать контроллер Cart, избавившись от метода GetCart() и переложив всю работу на наш связыватель модели, который будет снабжать контроллер объектами Cart. Необходимые изменения показаны в примере ниже:

using System.Linq;
using System.Web.Mvc;
using GameStore.Domain.Entities;
using GameStore.Domain.Abstract;
using GameStore.WebUI.Models;

namespace GameStore.WebUI.Controllers
{
    public class CartController : Controller
    {
        private IGameRepository repository;
        public CartController(IGameRepository repo)
        {
            repository = repo;
        }

        public ViewResult Index(Cart cart, string returnUrl)
        {
            return View(new CartIndexViewModel
            {
                Cart = cart,
                ReturnUrl = returnUrl
            });
        }

        public RedirectToRouteResult AddToCart(Cart cart, int gameId, string returnUrl)
        {
            Game game = repository.Games
                .FirstOrDefault(g => g.GameId == gameId);

            if (game != null)
            {
                cart.AddItem(game, 1);
            }
            return RedirectToAction("Index", new { returnUrl });
        }

        public RedirectToRouteResult RemoveFromCart(Cart cart, int gameId, string returnUrl)
        {
            Game game = repository.Games
                .FirstOrDefault(g => g.GameId == gameId);

            if (game != null)
            {
                cart.RemoveLine(game);
            }
            return RedirectToAction("Index", new { returnUrl });
        }
	}
}

Мы удалили метод GetCart() и добавили к каждому методу действия параметр Cart. Когда инфраструктура MVC Framework получает запрос, требующий вызова, скажем, метода AddToCart(), она начинает с просмотра параметров для этого метода действия. Она берет список доступных связывателей и пытается найти в нем тот, который может создать экземпляры каждого типа параметра.

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

Применение такого специального связывателя модели характеризуется несколькими преимуществами. Первое из них касается разделения логики создания Cart и логики контроллера, что позволяет изменять способ хранения объектов Cart без необходимости в модификации самого контроллера.

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

Третье преимущество, которое по нашему мнению является самым важным, состоит в том, что теперь можно проводить модульное тестирование контроллера Cart без необходимости в имитации множества встроенных средств ASP.NET.

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

Модульное тестирование класса CartController осуществляется за счет создания объектов Cart и передачи их методам действий. Нам необходимо протестировать три разных аспекта этого контроллера:

  • Метод AddToCart() должен добавлять выбранный товар в корзину пользователя.

  • После добавления товара в корзину должно произойти перенаправление на представление Index.

  • Методу действия Index() должен быть корректно передан URL, по которому пользователь может вернуться в каталог.

Ниже показаны модульные тесты, добавленные в файл CartTests.cs проекта GameStore.UnitTests:

// ...

/// <summary>
/// Проверяем добавление в корзину
/// </summary>
[TestMethod]
public void Can_Add_To_Cart()
{
    // Организация - создание имитированного хранилища
    Mock<IGameRepository> mock = new Mock<IGameRepository>();
    mock.Setup(m => m.Games).Returns(new List<Game> {
        new Game {GameId = 1, Name = "Игра1", Category = "Кат1"},
    }.AsQueryable());

    // Организация - создание корзины
    Cart cart = new Cart();

    // Организация - создание контроллера
    CartController controller = new CartController(mock.Object);

    // Действие - добавить игру в корзину
    controller.AddToCart(cart, 1, null);

    // Утверждение
    Assert.AreEqual(cart.Lines.Count(), 1);
    Assert.AreEqual(cart.Lines.ToList()[0].Game.GameId, 1);
}

/// <summary>
/// После добавления игры в корзину, должно быть перенаправление на страницу корзины
/// </summary>
[TestMethod]
public void Adding_Game_To_Cart_Goes_To_Cart_Screen()
{
    // Организация - создание имитированного хранилища
    Mock<IGameRepository> mock = new Mock<IGameRepository>();
    mock.Setup(m => m.Games).Returns(new List<Game> {
        new Game {GameId = 1, Name = "Игра1", Category = "Кат1"},
    }.AsQueryable());

    // Организация - создание корзины
    Cart cart = new Cart();

    // Организация - создание контроллера
    CartController controller = new CartController(mock.Object);

    // Действие - добавить игру в корзину
    RedirectToRouteResult result = controller.AddToCart(cart, 2, "myUrl");

    // Утверждение
    Assert.AreEqual(result.RouteValues["action"], "Index");
    Assert.AreEqual(result.RouteValues["returnUrl"], "myUrl");
}

// Проверяем URL
[TestMethod]
public void Can_View_Cart_Contents()
{
    // Организация - создание корзины
    Cart cart = new Cart();

    // Организация - создание контроллера
    CartController target = new CartController(null);

    // Действие - вызов метода действия Index()
    CartIndexViewModel result
        = (CartIndexViewModel)target.Index(cart, "myUrl").ViewData.Model;

    // Утверждение
    Assert.AreSame(result.Cart, cart);
    Assert.AreEqual(result.ReturnUrl, "myUrl");
}

Завершение построения корзины

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

Удаление элементов из корзины

Метод действия RemoveFromCart() в контроллере уже определен и протестирован, поэтому для предоставления пользователям возможности удаления элементов достаточно лишь сделать доступным этот метод в представлении, для чего мы добавим кнопки "Удалить" ко всем строкам в итоговой информации по корзине.

Изменения, которые должны быть внесены в файл Views/Cart/Index.cshtml, показаны в примере ниже:

<!-- ... -->
<style>
    #cartTable td { vertical-align: middle; }
</style>

<!-- ... -->

<table id="cartTable" class="table">
<!-- ... -->
    <tbody>
        @foreach (var line in Model.Cart.Lines)
        {
            <tr>
                <td class="text-center">@line.Quantity</td>
                <td class="text-left">@line.Game.Name</td>
                <td class="text-right">@line.Game.Price.ToString("# руб")</td>
                <td class="text-right">
                    @((line.Quantity * line.Game.Price).ToString("# руб"))
                </td>
                <td>
                    @using (Html.BeginForm("RemoveFromCart", "Cart"))
                    {
                        @Html.Hidden("GameId", line.Game.GameId)
                        @Html.HiddenFor(x => x.ReturnUrl)
                        <input class="btn btn-sm btn-warning" type="submit" value="Удалить" />
                    }
                </td>
            </tr>
        }
    </tbody>
<!-- ... -->

К каждой строке таблицы был добавлен новый столбец, который содержит элемент <form> с элементом <input> внутри. С помощью библиотеки Bootstrap элемент input стилизован в виде кнопки, кроме того, определен элемент <style>, а к элементу <table> добавлен атрибут id, чтобы обеспечить подходящее выравнивание кнопки и содержимого других столбцов.

Для создания скрытого поля для свойства ReturnUrl модели использовался строго типизированный вспомогательный метод Html.HiddenFor(), но чтобы сделать то же самое для поля GameId, должен применяться строковый вспомогательный метод Html.Hidden(). Если записать Html.HiddenFor(x => line.Game.GameId), то вспомогательный метод визуализирует скрытое поле с именем line.Game.GameId. Имя этого поля не будет соответствовать именам параметров метода действия CartController.RemoveFromCart(), поэтому стандартные связыватели модели не сработают, a MVC Framework не сможет вызвать упомянутый метод действия.

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

Удаление элемента из корзины для покупок

Добавление итоговой информации по корзине

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

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

Для начала необходимо добавить в класс CartController простой метод, приведенный в примере ниже:

public class CartController : Controller
{
     // ...

    public PartialViewResult Summary(Cart cart)
    {
        return PartialView(cart);
    }
}

Этот простой метод должен визуализировать представление, передавая в качестве данных представления текущий объект Cart (который будет получен с использованием специального связывателя модели). Чтобы создать представление, щелкните правой кнопкой мыши на методе действия Summary() и выберите в контекстном меню пункт Add View (Добавить представление). Установите имя представления в Summary и щелкните на кнопке ОК для создания файла Views/Cart/Summary.cshtml. Приведите содержимое файла к виду, показанному в примере ниже:

@model GameStore.Domain.Entities.Cart

<div class="navbar-right">
    @Html.ActionLink("Заказать", "Index", "Cart",
    new { returnUrl = Request.Url.PathAndQuery },
    new { @class = "btn btn-default navbar-btn" })
</div>

<div class="navbar-text navbar-right">
    <b>Ваша корзина:</b>
    @Model.Lines.Sum(x => x.Quantity) игр,
    @Model.ComputeTotalValue().ToString("# руб")
</div>

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

Теперь, когда создано представление, возвращаемое методом действия Summary(), этот метод можно вызвать в файле _Layout.cshtml, чтобы отобразить итоговую информацию по корзине:

...
<body>
    <div class="navbar navbar-inverse" role="navigation">
        <a class="navbar-brand" href="#">GameStore - магазин компьютерных игр</a>
        @Html.Action("Summary", "Cart")
    </div>
    ...
</body>
</html>

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

Виджет итоговой информации по корзине

Благодаря этому дополнению, пользователь знает, какие товары находятся в корзине, и получает очевидный путь для перехода к оплате покупок. Вы снова увидели, насколько просто применять вспомогательный метод Html.Action() для внедрения в представление вывода из метода действия. Это удобный прием разделения функциональности на изолированные многократно используемые блоки.

Пройди тесты
Лучший чат для C# программистов