Главная страница   /   8.2. Создание корзины покупателя (ASP.NET MVC 4

ASP.NET MVC 4

ASP.NET MVC 4

Адам Фриман

8.2. Создание корзины покупателя

Наше приложение хорошо развивается, но мы не можем продавать какие-либо товары, пока не реализуем корзину. В этом разделе мы создадим функционал, показанный на рисунке 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 создает форму, которая использует метод HTTP POST. Вы можете изменить это так, чтобы формы использовали метод 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: Отображение содержимого корзины