Корзина покупок

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

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

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

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

Определение класса модели корзины

Корзина для покупок является частью предметной области приложения, поэтому для представления корзины имеет смысл создать сущность в модели предметной области. Добавьте файл класса по имени Cart.cs в папку Entities проекта GameStore.Domain и определите классы, как показано в примере ниже:

using System.Collections.Generic;
using System.Linq;

namespace GameStore.Domain.Entities
{
    public class Cart
    {
        private List<CartLine> lineCollection = new List<CartLine>();

        public void AddItem(Game game, int quantity)
        {
            CartLine line = lineCollection
                .Where(g => g.Game.GameId == game.GameId)
                .FirstOrDefault();

            if (line == null)
            {
                lineCollection.Add(new CartLine
                {
                    Game = game,
                    Quantity = quantity
                });
            }
            else
            {
                line.Quantity += quantity;
            }
        }

        public void RemoveLine(Game game)
        {
            lineCollection.RemoveAll(l => l.Game.GameId == game.GameId);
        }

        public decimal ComputeTotalValue()
        {
            return lineCollection.Sum(e => e.Game.Price * e.Quantity);

        }
        public void Clear()
        {
            lineCollection.Clear();
        }

        public IEnumerable<CartLine> Lines
        {
            get { return lineCollection; }
        }
    }

    public class CartLine
    {
        public Game Game { get; set; }
        public int Quantity { get; set; }
    }
}

Класс Cart использует класс CartLine, который определен в том же самом файле и представляет товар, выбранный пользователем, а также приобретаемое его количество. Мы определили методы для добавления элемента в корзину, удаления элемента из корзины, вычисления общей стоимости элементов в корзине и очистки корзины путем удаления всех элементов. Мы также предоставили свойство, которое позволяет обратиться к содержимому корзины с использованием IEnumerable<CartLine>. Все это было довольно легко реализовано с помощью кода C# и небольшой доли кода LINQ.

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

Класс Cart относительно прост, но в нем имеется несколько важных аспектов поведения, в корректной работе которых необходимо удостовериться. Неверно функционирующая корзина нарушит работу всего приложения GameStore. Мы должны протестировать каждое средство по отдельности. Для размещения этих тестов мы создадим в проекте GameStore.UnitTests новый файл модульного тестирования по имени CartTests.cs.

Первое поведение относится к добавлению элемента в корзину. При самом первом добавлении в корзину объекта Game должен быть добавлен новый экземпляр CartLine. Ниже показан тестовый метод, включая определение класса модульного тестирования:

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;
using System.Collections.Generic;
using GameStore.Domain.Entities;

namespace GameStore.UnitTests
{
    [TestClass]
    public class CartTests
    {
        [TestMethod]
        public void Can_Add_New_Lines()
        {
            // Организация - создание нескольких тестовых игр
            Game game1 = new Game { GameId = 1, Name = "Игра1" };
            Game game2 = new Game { GameId = 2, Name = "Игра2" };

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

            // Действие
            cart.AddItem(game1, 1);
            cart.AddItem(game2, 1);
            List<CartLine> results = cart.Lines.ToList();

            // Утверждение
            Assert.AreEqual(results.Count(), 2);
            Assert.AreEqual(results[0].Game, game1);
            Assert.AreEqual(results[1].Game, game2);
        }
    }
}

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

// ...
[TestMethod]
public void Can_Add_Quantity_For_Existing_Lines()
{
    // Организация - создание нескольких тестовых игр
    Game game1 = new Game { GameId = 1, Name = "Игра1" };
    Game game2 = new Game { GameId = 2, Name = "Игра2" };

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

    // Действие
    cart.AddItem(game1, 1);
    cart.AddItem(game2, 1);
    cart.AddItem(game1, 5);
    List<CartLine> results = cart.Lines.OrderBy(c => c.Game.GameId).ToList();

    // Утверждение
    Assert.AreEqual(results.Count(), 2);
    Assert.AreEqual(results[0].Quantity, 6);    // 6 экземпляров добавлено в корзину
    Assert.AreEqual(results[1].Quantity, 1);
}

Мы также должны проверить, что пользователи имеют возможность менять свое решение и удалять товары из корзины. Это средство реализовано в виде метода RemoveLine(). Ниже приведен необходимый тестовый метод:

// ...
[TestMethod]
public void Can_Remove_Line()
{
    // Организация - создание нескольких тестовых игр
    Game game1 = new Game { GameId = 1, Name = "Игра1" };
    Game game2 = new Game { GameId = 2, Name = "Игра2" };
    Game game3 = new Game { GameId = 3, Name = "Игра3" };

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

    // Организация - добавление нескольких игр в корзину
    cart.AddItem(game1, 1);
    cart.AddItem(game2, 4);
    cart.AddItem(game3, 2);
    cart.AddItem(game2, 1);

    // Действие
    cart.RemoveLine(game2);

    // Утверждение
    Assert.AreEqual(cart.Lines.Where(c => c.Game == game2).Count(), 0);
    Assert.AreEqual(cart.Lines.Count(), 2);
}

Следующее проверяемое поведение касается возможности вычисления общей стоимости элементов в корзине. Вот как выглядит соответствующий модульный тест:

// ...	
[TestMethod]
public void Calculate_Cart_Total()
{
    // Организация - создание нескольких тестовых игр
    Game game1 = new Game { GameId = 1, Name = "Игра1", Price = 100 };
    Game game2 = new Game { GameId = 2, Name = "Игра2", Price = 55 };

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

    // Действие
    cart.AddItem(game1, 1);
    cart.AddItem(game2, 1);
    cart.AddItem(game1, 5);
    decimal result = cart.ComputeTotalValue();

    // Утверждение
    Assert.AreEqual(result, 655);
}

Последний тест очень прост. Мы должны удостовериться, что в результате очистки корзины ее содержимое корректно удаляется. Ниже показан требуемый модульный тест:

// ...	
[TestMethod]
public void Can_Clear_Contents()
{
    // Организация - создание нескольких тестовых игр
    Game game1 = new Game { GameId = 1, Name = "Игра1", Price = 100 };
    Game game2 = new Game { GameId = 2, Name = "Игра2", Price = 55 };

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

    // Действие
    cart.AddItem(game1, 1);
    cart.AddItem(game2, 1);
    cart.AddItem(game1, 5);
    cart.Clear();

    // Утверждение
    Assert.AreEqual(cart.Lines.Count(), 0);
}

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

Создание кнопок добавления в корзину

Мы должны модифицировать частичное представление Views/Shared/GameSummary.cshtml, добавив к спискам товаров кнопки. Изменения показаны в примере ниже:

@model GameStore.Domain.Entities.Game

<div class="well">
    <h3>
        <strong>@Model.Name</strong>
        <span class="pull-right label label-primary">@Model.Price.ToString("# руб")</span>
    </h3>
    @using (Html.BeginForm("AddToCart", "Cart"))
    {
        <div class="pull-right">
            @Html.HiddenFor(x => x.GameId)
            @Html.Hidden("returnUrl", Request.Url.PathAndQuery)
            <input type="submit" class="btn btn-success" value="Добавить в корзину" />
        </div>
    }
    <span class="lead">@Model.Description</span>
</div>

Мы добавили блок Razor, который создает небольшую HTML-форму для каждого товара в списке. Отправка этой формы приводит к вызову метода действия AddToCart() из контроллера Cart (который вскоре будет реализован).

По умолчанию вспомогательный метод BeginForm() создает форму, которая использует HTTP-метод POST. Это можно изменить, обеспечив работу формы с HTTP-методом GET, но следует соблюдать осторожность. В спецификации HTTP указано, что запросы GET не должны быть изменяющими, а добавление товара в корзину определенно считается изменением.

Использование вспомогательного метода Html.BeginForm() в списке товаров означает, что каждая кнопка "Добавить в корзину" визуализируется в собственном отдельном HTML-элементе <form>. Это может поначалу удивить, если вам ранее приходилось разрабатывать приложения с помощью ASP.NET Web Forms. В ASP.NET Web Forms существует ограничение, допускающее определение только одной формы на странице, если требуется наличие средства состояния представления или сложных элементов управления (которые обычно полагаются на состояние представления).

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

Реализация контроллера для корзины

Для обработки щелчков на кнопках "Добавить в корзину" понадобится создать контроллер. Создайте новый контроллер по имени CartController в проекте GameStore.WebUI и приведите его содержимое в соответствие с кодом ниже:

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 RedirectToRouteResult AddToCart(int gameId, string returnUrl)
        {
            Game game = repository.Games
                .FirstOrDefault(g => g.GameId == gameId);

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

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

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

        public Cart GetCart()
        {
            Cart cart = (Cart)Session["Cart"];
            if (cart == null)
            {
                cart = new Cart();
                Session["Cart"] = cart;
            }
            return cart;
        }
	}
}

Относительно этого контроллера необходимо сделать несколько замечаний. Для сохранения и извлечения объектов Cart применяется средство состояния сеанса ASP.NET. Для этого предназначен метод GetCart(). Инфраструктура ASP.NET поддерживает удобное средство сеансов, которое использует cookie-наборы или переписывание URL, чтобы ассоциировать вместе множество запросов от определенного пользователя с целью формирования отдельного сеанса просмотра. С данным средством связано состояние сеанса, позволяющее ассоциировать данные с сеансом. Это идеально подходит для класса Cart.

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

Чтобы добавить объект в состояние сеанса, мы устанавливаем значение для определенного ключа в объекте Session примерно так, как показано ниже:

Session["Cart"] = cart;

Для извлечения объекта мы просто читаем значение с тем же самым ключом:

Cart cart = (Cart)Session["Cart"];

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

Для методов AddToCart() и RemoveFromCart() применялись имена параметров, которые соответствуют элементам <input> в HTML-формах, созданных в представлении GameSummary.cshtml. Это позволяет MVC Framework ассоциировать входящие переменные HTTP-запроса POST формы с параметрами и означает, что ничего дополнительного по обработке формы делать не придется.

Отображение содержимого корзины

Финальное замечание о контроллере Cart касается того, что методы AddToCart() и RemoveFromCart() вызывают метод RedirectToAction(). В результате этого клиентскому браузеру отправляется инструкция перенаправления HTTP, заставляя браузер запросить новый URL. В этом случае браузер запросит URL, который вызывает метод действия Index() контроллера Cart.

Мы собираемся реализовать метод Index() и применять его для отображения содержимого Cart. Если вы еще раз взглянете на рисунок вначале статьи, то увидите, что это та часть рабочего потока, которая инициируется щелчком пользователя на кнопке добавления в корзину.

Представлению, которое будет отображать содержимое корзины, необходимо передать две порции информации: объект Cart и URL для отображения, когда пользователь щелкает на кнопке "Продолжить покупку". Для этой цели мы создадим простой класс модели представления. Создайте новый файл класса по имени CartIndexViewModel.cs в папке Models проекта GameStore.WebUI. Содержимое этого файла приведено в примере ниже:

using GameStore.Domain.Entities;

namespace GameStore.WebUI.Models
{
    public class CartIndexViewModel
    {
        public Cart Cart { get; set; }
        public string ReturnUrl { get; set; }
    }
}

Имея модель представления, можно реализовать метод действия Index() в CartController, как показано в примере ниже:

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
    {
        public ViewResult Index(string returnUrl)
        {
            return View(new CartIndexViewModel
            {
                Cart = GetCart(),
                ReturnUrl = returnUrl
            });
        }
		
		// ...
	}
}

Последний шаг при отображении содержимого корзины предусматривает создание нового представления. Щелкните правой кнопкой мыши на методе действия Index() и выберите в контекстном меню пункт Add View (Добавить представление). Установите имя представления в Index и щелкните на кнопке ОК, чтобы создать файл представления Index.cshtml. Приведите содержимое этого файла в соответствие с кодом ниже:

@model GameStore.WebUI.Models.CartIndexViewModel

@{
    ViewBag.Title = "GameStore: ваша корзина";
}

<h2>Ваша корзина</h2>
<table class="table">
    <thead>
        <tr>
            <th>Кол-во</th>
            <th>Игра</th>
            <th class="text-right">Цена</th>
            <th class="text-right">Общая цена</th>
        </tr>
    </thead>
    <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>
            </tr>
        }
    </tbody>
    <tfoot>
        <tr>
            <td colspan="3" class="text-right">Итого:</td>
            <td class="text-right">
                @Model.Cart.ComputeTotalValue().ToString("# руб")
            </td>
        </tr>
    </tfoot>
</table>

<div class="text-center">
    <a class="btn btn-primary" href="@Model.ReturnUrl">Продолжить покупки</a>
</div>

В представлении производится проход по элементам в корзине с добавлением в HTML-таблицу строки для каждого элемента. Кроме того, включается суммарная стоимость по строке и итоговая стоимость по корзине. Классы, назначенные элементам, соответствуют стилям Bootstrap для таблиц и выравнивания текста.

Теперь доступна базовая функциональность корзины для покупок. Во-первых, товары выводятся вместе с кнопками "Добавить в корзину", как показано на рисунке ниже:

Кнопка Добавить в корзину

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

Отображение содержимого корзины для покупок
Пройди тесты
Лучший чат для C# программистов