ASP.NET MVC 4
Адам Фриман
Создание корзины покупателя
Наше приложение хорошо развивается, но мы не можем продавать какие-либо товары, пока не реализуем корзину. В этом разделе мы создадим функционал, показанный на рисунке 8-8. Он будет знаком любому человеку, который когда-либо делал покупки в интернете.
Рисунок 8-8: Базовый поток корзины
Кнопка Add to cart
будет отображаться рядом с каждым из продуктов в нашем каталоге. После нажатия этой кнопки будет отображена информация о товарах, которые клиент уже выбрал, и их общая стоимость. В этот момент пользователь может нажать кнопку Continue shopping
, чтобы вернуться в каталог товаров, или нажать кнопку Checkout now, чтобы выполнить заказ и завершить сессию.
Определяем сущность корзины
Корзина является частью бизнес-логики нашего приложения, так что имеет смысл представить ее, создав сущность в нашей доменной модели. Добавьте файл класса под названием Cart
в папку Entities
проекта SportsStore.Domain
и определите классы, показанные в листинге 8-13.
Листинг 8-13: КлассыCart
иCartLine
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SportsStore.Domain.Entities
{
public class Cart
{
private List<CartLine> lineCollection = new List<CartLine>();
public void AddItem(Product product, int quantity)
{
CartLine line = lineCollection
.Where(p => p.Product.ProductID == product.ProductID)
.FirstOrDefault();
if (line == null)
{
lineCollection.Add(new CartLine
{
Product = product,
Quantity = quantity
});
}
else
{
line.Quantity += quantity;
}
}
public void RemoveLine(Product product)
{
lineCollection.RemoveAll(l => l.Product.ProductID == product.ProductID);
}
public decimal ComputeTotalValue()
{
return lineCollection.Sum(e => e.Product.Price * e.Quantity);
}
public void Clear()
{
lineCollection.Clear();
}
public IEnumerable<CartLine> Lines
{
get { return lineCollection; }
}
}
public class CartLine
{
public Product Product { get; set; }
public int Quantity { get; set; }
}
}
Класс Cart
использует CartLine
, определенный в том же файле, чтобы представлять товар, выбранный покупателем, и количество данного товара. Мы определили методы, которые позволяют добавлять товар в корзину, удалять ранее добавленный товар, рассчитать общую стоимость товаров в корзине и очистить корзину, удалив все выбранное. Мы также предоставили свойство, которое дает доступ к содержимому корзины с помощью IEnumerble<CartLine>
. Это все очень простые вещи, которые легко реализовать с помощью C# и немного LINQ.
Модульный тест: тестирование корзины
Класс
Cart
относительно простой, но у него есть ряд важных линий поведения, и мы должны гарантировать, что они работают должным образом. Плохо функционирующая корзина подорвет все приложение SportsStore. Мы разобрали все функции и протестировали их индивидуально. Для этих тестов мы создали новый файл модульных тестов в проектеSportsStore.UnitTests
под названиемCartTests.cs
. Первая линия поведения относится к добавлению элемента в корзину. Если данный объектProduct
добавляется в корзину в первый раз, то мы хотим, чтобы был добавлен новый объектCartLine
. Ниже приведен тест и определение класса модульного тестирования:using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using SportsStore.Domain.Entities; using System.Linq; namespace SportsStore.UnitTests { [TestClass] public class CartTests { [TestMethod] public void Can_Add_New_Lines() { // Arrange - create some test products Product p1 = new Product { ProductID = 1, Name = "P1" }; Product p2 = new Product { ProductID = 2, Name = "P2" }; // Arrange - create a new cart Cart target = new Cart(); // Act target.AddItem(p1, 1); target.AddItem(p2, 1); CartLine[] results = target.Lines.ToArray(); // Assert Assert.AreEqual(results.Length, 2); Assert.AreEqual(results[0].Product, p1); Assert.AreEqual(results[1].Product, p2); } } }
Однако, если
Product
уже есть в корзине, мы хотим увеличить количество в соответствующем объектеCartLine
и не создавать новый. Вот тест:[TestMethod] public void Can_Add_Quantity_For_Existing_Lines() { // Arrange - create some test products Product p1 = new Product { ProductID = 1, Name = "P1" }; Product p2 = new Product { ProductID = 2, Name = "P2" }; // Arrange - create a new cart Cart target = new Cart(); // Act target.AddItem(p1, 1); target.AddItem(p2, 1); target.AddItem(p1, 10); CartLine[] results = target.Lines.OrderBy(c => c.Product.ProductID).ToArray(); // Assert Assert.AreEqual(results.Length, 2); Assert.AreEqual(results[0].Quantity, 11); Assert.AreEqual(results[1].Quantity, 1); }
Мы также должны убедиться, что пользователи могут передумать и удалить товары из корзины. Эта функция реализуется с помощью метода
RemoveLine
. Вот тест:[TestMethod] public void Can_Remove_Line() { // Arrange - create some test products Product p1 = new Product { ProductID = 1, Name = "P1" }; Product p2 = new Product { ProductID = 2, Name = "P2" }; Product p3 = new Product { ProductID = 3, Name = "P3" }; // Arrange - create a new cart Cart target = new Cart(); // Arrange - add some products to the cart target.AddItem(p1, 1); target.AddItem(p2, 3); target.AddItem(p3, 5); target.AddItem(p2, 1); // Act target.RemoveLine(p2); // Assert Assert.AreEqual(target.Lines.Where(c => c.Product == p2).Count(), 0); Assert.AreEqual(target.Lines.Count(), 2); }
Следующая линия поведения, которую мы хотим протестировать, - это расчет общей стоимости товаров в корзине. Вот тест:
[TestMethod] public void Calculate_Cart_Total() { // Arrange - create some test products Product p1 = new Product { ProductID = 1, Name = "P1", Price = 100M }; Product p2 = new Product { ProductID = 2, Name = "P2", Price = 50M }; // Arrange - create a new cart Cart target = new Cart(); // Act target.AddItem(p1, 1); target.AddItem(p2, 1); target.AddItem(p1, 3); decimal result = target.ComputeTotalValue(); // Assert Assert.AreEqual(result, 450M); }
Последний тест очень простой. Мы хотим гарантировать, что содержимое корзины удаляется, когда мы ее очищаем. Вот тест:
[TestMethod] public void Can_Clear_Contents() { // Arrange - create some test products Product p1 = new Product { ProductID = 1, Name = "P1", Price = 100M }; Product p2 = new Product { ProductID = 2, Name = "P2", Price = 50M }; // Arrange - create a new cart Cart target = new Cart(); // Arrange - add some items target.AddItem(p1, 1); target.AddItem(p2, 1); // Act - reset the cart target.Clear(); // Assert Assert.AreEqual(target.Lines.Count(), 0); }
Иногда в таких случаях код, необходимый для проверки функциональности какого-либо типа, намного длиннее и сложнее, чем сам тип. Не позволяйте этому оттолкнуть вас от написания модульных тестов. Дефекты в простых классах, особенно таких важных, как
Cart
в нашем приложении, могут иметь серьезные последствия.
Добавляем кнопку Add to Cart
Чтобы добавить кнопки к спискам товаров, нам нужно изменить частичное представление Views/Shared/ProductSummary.cshtml
. Изменения показаны в листинге 8-14.
Листинг 8-14: Добавляем кнопки в частичное представление ProductSummary
@model SportsStore.Domain.Entities.Product
<div class="item">
<h3>@Model.Name</h3>
@Model.Description
@using (Html.BeginForm("AddToCart", "Cart")) {
@Html.HiddenFor(x => x.ProductID)
@Html.Hidden("returnUrl", Request.Url.PathAndQuery)
<input type="submit" value="+ Add to cart" />
}
<h4>@Model.Price.ToString("c")</h4>
</div>
Мы добавили блок Razor, который создает небольшую HTML-форму для каждого товара в списке. Отправка этой форма вызовет метод действия AddToCart
в контроллере Cart
(мы скоро реализуем этот метод).
Примечание
По умолчанию вспомогательный метод
BeginForm
создает форму, которая использует метод HTTPPOST
. Вы можете изменить это так, чтобы формы использовали методGET
, но об этом нужно хорошо подумать. Спецификация HTTP требует, чтобы запросыGET
были идемпотентными, что означает, что они не должны вызывать изменений, а добавление товара в корзину, безусловно, является изменением. Об этом мы подробнее поговорим в главе 14, в которой и объясним последствия игнорирования идемпотентных запросовGET
.
Мы хотим, чтобы стиль этих кнопок соответствовал стилю всего приложения, поэтому добавьте в конец файла Content/Site.css
код CSS, показанный в листинге 8-15.
Листинг 8-15: Применяем стили к кнопкам
FORM { margin: 0; padding: 0; }
DIV.item FORM { float:right; }
DIV.item INPUT {
color:White; background-color: #333; border: 1px solid black; cursor:pointer;
}
Создаем несколько HTML-форм на странице
Использование вспомогательного метода
Html.BeginForm
в каждом списке товаров означает, что каждая кнопкаAdd to cart
визуализируется в своем отдельном HTML-элементеform
. Это может вас удивить, если раньше вы работали с ASP.NET Web Forms, где количество форм на странице ограничено одной. В ASP.NET MVC нет лимита на количество форм на странице, у вас их может быть столько, сколько вам нужно.Нет такого технического требования, согласно которому мы должны создавать форму для каждой кнопки. Однако, так как каждая форма будет отправлять данные к одному и тому же методу контроллера, но с разными значениями параметров, будет лучше и проще поработать с нажатиями кнопок.
Реализуем Cart Controller
Нам нужно создать контроллер для обработки нажатий кнопки Add to cart
. Создайте новый контроллер под названием CartController
и отредактируйте его содержимое так, чтобы он соответствовал листингу 8-16.
Листинг 8-16: Создем CartController
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
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 RedirectToRouteResult AddToCart(int productId, string returnUrl)
{
Product product = repository.Products
.FirstOrDefault(p => p.ProductID == productId);
if (product != null)
{
GetCart().AddItem(product, 1);
}
return RedirectToAction("Index", new { returnUrl });
}
public RedirectToRouteResult RemoveFromCart(int productId, string returnUrl)
{
Product product = repository.Products
.FirstOrDefault(p => p.ProductID == productId);
if (product != null)
{
GetCart().RemoveLine(product);
}
return RedirectToAction("Index", new { returnUrl });
}
private Cart GetCart()
{
Cart cart = (Cart)Session["Cart"];
if (cart == null)
{
cart = new Cart();
Session["Cart"] = cart;
}
return cart;
}
}
}
По поводу этого контроллера есть несколько замечаний. Первое касается того, что мы используем состояние сессии ASP.NET для сохранения и извлечения объектов Cart
. Это задача метода GetCart
. В ASP.NET есть объект Session
, который использует cookie или перезапись URL для группировки запросов от пользователя, чтобы сформировать одну сессию просмотра. Состояние сессии (session state) позволяет связывать данные с сессией. Оно идеально подходит для нашего класса Cart
. Мы хотим, чтобы у каждого пользователя была своя корзина, и чтобы она сохранялась в промежутках времени между запросами. Данные, которые связываются с сессией, удаляются, когда сессия истекает (обычно потому, что пользователь не отправлял запросы некоторое время). Это означает, что мы не должны управлять хранением или жизненным циклом объектов Cart
. Чтобы добавить объект в состояние сессии, мы устанавливаем значение для ключа в объекте Session
, например:
Session["Cart"] = cart;
Чтобы извлечь объект снова, мы просто считываем тот же ключ, например:
Cart cart = (Cart)Session["Cart"];
Совет
Объекты состояния сеанса хранятся в памяти сервера ASP.NET по умолчанию, но вы можете настроить различные другие подходы к хранению, в том числе с использованием базы данных SQL.
Для методов AddToCart
и RemoveFromCart
мы использовали имена параметров, которые соответствуют элементам input
в формах HTML, которые мы создали в представлении ProductSummary.cshtml
. Это позволяет MVC Framework связывать входящие переменные формы POST
с этими параметрами, что избавляет нас от необходимости обрабатывать форму самим.
Отображаем содержимое корзины
Последнее замечание по поводу контроллера Cart
состоит в том, что и метод AddToCart
, и RemoveFromCart
вызывают метод RedirectToAction
. В результате этого браузеру клиента отправляется HTTP-инструкция перенаправления, которая сообщает браузеру запросить новый URL. В этом случае мы сообщаем браузеру запросить URL, который будет вызывать метод действия Index
контроллера Cart
.
Мы реализуем метод Index
и будем использовать его для отображения содержимого корзины. Если вы вернетесь к рисунку 8-8, то увидите, это наш рабочий поток после того, как пользователь нажимает кнопку Add to cart
.
Нам нужно передать две порции информации в представление, которое будет отображать содержимое корзины: объект Cart
и URL, который будет отображен, если пользователь нажмет кнопку Continue shopping
. Для этого мы создадим простой класс модели представления. Создайте новый класс под названием CartIndexViewModel
в папке Models
проекта SportsStore.WebUI
. Содержание этого класса показано в листинге 8-17.
Листинг 8-17: Класс CartIndexViewModel
using SportsStore.Domain.Entities;
namespace SportsStore.WebUI.Models
{
public class CartIndexViewModel
{
public Cart Cart { get; set; }
public string ReturnUrl { get; set; }
}
}
Когда у нас готова модель представления, мы можем реализовать метод действия Index
в классе контроллера Cart
, как показано в листинге 8-18.
Листинг 8-18: Метод действия Index
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(string returnUrl)
{
return View(new CartIndexViewModel
{
Cart = GetCart(),
ReturnUrl = returnUrl
});
}
// ...other action methods omitted for brevity...
}
}
Последнее, что нужно сделать, чтобы отобразить содержимое корзины, - это создать новое представление. Щелкните правой кнопкой мыши метод Index
и выберите Add View
из контекстного меню. Назовите представление Index
, отметьте флажком опцию Сreate a strongly typed view
и выберите CartIndexViewModel
как класс модели, как показано на рисунке 8-9.
Рисунок 8-9: Добавляем представление Index
Мы хотим, чтобы содержимое корзины выглядело так же, как и остальные страницы приложения, так что убедитесь, что вы выбрали опцию Use a layout
и оставили текстовое поле пустым, чтобы по умолчанию использовался файл _Layout.cshtml
. Нажмите кнопку Add
, чтобы создать представление, и отредактируйте содержимое так, чтобы оно соответствовало листингу 8-19.
Листинг 8-19: Представление Index
@model SportsStore.WebUI.Models.CartIndexViewModel
@{
ViewBag.Title = "Sports Store: Your Cart";
}
<h2>Your cart</h2>
<table width="90%" align="center">
<thead>
<tr>
<th align="center">Quantity</th>
<th align="left">Item</th>
<th align="right">Price</th>
<th align="right">Subtotal</th>
</tr>
</thead>
<tbody>
@foreach (var line in Model.Cart.Lines) {
<tr>
<td align="center">@line.Quantity</td>
<td align="left">@line.Product.Name</td>
<td align="right">@line.Product.Price.ToString("c")</td>
<td align="right">@((line.Quantity * line.Product.Price).ToString("c"))</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3" align="right">Total:</td>
<td align="right">
@Model.Cart.ComputeTotalValue().ToString("c")
</td>
</tr>
</tfoot>
</table>
<p align="center" class="actionButtons">
<a href="@Model.ReturnUrl">Continue shopping</a>
</p>
Представление выглядит сложнее, чем оно есть на самом деле. Оно просто перечисляет строки в корзине и добавляет ряды для каждой из них HTML-таблицу, а также общую стоимость в каждом ряду и общую стоимость всей корзины. Нам осталось добавить еще немного CSS. Добавьте стили, показанные в листинге 8- 20, к файлу Site.css
.
Листинг 8-20: CSS для отображения содержимого корзины
H2 { margin-top: 0.3em }
TFOOT TD { border-top: 1px dotted gray; font-weight: bold; }
.actionButtons A, INPUT.actionButtons {
font: .8em Arial; color: White; margin: .5em;
text-decoration: none; padding: .15em 1.5em .2em 1.5em;
background-color: #353535; border: 1px solid black;
}
Теперь у нас готовы базовые функции корзины. Во-первых, товары выводятся с кнопкой для добавления в корзину, как показано на рисунке 8-10.
Рисунок 8-10: Кнопка Add to cart
Во-вторых, когда мы нажимаем кнопку Add to cart
, соответствующий товар добавляется в корзину и отображаются общие сведения о корзине, как показано на рисунке 8-11. Мы можем нажать кнопку Continue shopping
и вернуться на страницу товара, с которой пришли - все выглядит очень красиво и работает гладко.
Рисунок 8-11: Отображение содержимого корзины