第九章 SportsStore: 导航

本章介绍了SportsStore应用的开发过程,包括添加导航支持、构建购物车功能等。具体实现了产品分类导航、改进URL模式、创建购物车模型及控制器,并展示了购物车内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

第九章 SportsStore: 导航

本章中, 将添加应用的导航支持, 并开始构建购物车.

添加导航控制

如果顾客可以按类别查看产品, SportsStore应用将更有用. 我会分三个步骤完成这件事

  • 强化ProductController.List()行为, 添加对存储库中的Product对象的过滤支持.
  • 重写URL模式
  • 创建一个侧边栏分类列表, 高亮现在的分类, 并为其他类别添加链接

过滤产品列表

我将开始增强视图模型类ProductsListViewModel. 我需要将当前的产品类别传递给视图, 以便视图呈现侧边栏, 这是一个很好的七点. 下面的代码显示了我对Views\Shared\ProductsListViewModel.cs所做的更改

using System.Collections.Generic;
using SportsStore.Models;

namespace SportsStore.Models.ViewModels
{
    public class ProductsListViewModel
    {
        public IEnumerable<Product> Products { get; set; }
        public PagingInfo PagingInfo { get; set; }
        public string CurrentCategory { get; set; }
    }
}

在视图模型中添加了一个属性CurrentCategory. 下一步是更新ProductController.List()行为, 以便根据类别筛选产品对象, 并使用新属性来指示选择了哪个类别.

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using System.Linq;
using SportsStore.Models.ViewModels;

namespace SportsStore.Controllers
{
    public class ProductController : Controller
    {
        private IProductRepository repository;
        public int PageSize = 4;

        public ProductController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult List(string category, int productPage = 1)
            => View(new ProductsListViewModel
            {
                Products = repository.Products
                    .Where(p => category == null || p.Category == category)
                    .OrderBy(p => p.ProductID)
                    .Skip((productPage - 1) * PageSize)
                    .Take(PageSize),
                PagingInfo = new PagingInfo
                {
                    CurrentPage = productPage,
                    ItemsPerPage = PageSize,
                    TotalItems = repository.Products.Count()
                },
                CurrentCategory = category
            });
    }
}

修改了List行为的三处. 首先添加了一个字符串参数category, 然后使用Where语句进行查询, 并在最后为新ProductListViewModel对象的CurrentCategory属性赋值. 但这些修改意味着PagingInfo.TotalItems属性计算不正确了, 我稍后会修复.

!单元测试: 更新现有的单元测试

由于更改了List行为的签名, 一些现有的测试将编译失败, 要解决这个问题, 需要给控制器额外传递一个null值. 例如在ProductControllerTests.Can_Paginate中修改Act部分为

// Act
ProductsListViewModel result =  controller.List(null, 2).ViewData.Model as ProductsListViewModel;

Can_Send_Pagination_View_Model()中也有同样的问题

当你习惯测试后, 应该在修改代码的时候同步更改测试.

要查看分类过滤的效果, 可以输入下面的URL

http://localhost:60000/?category=Soccer

你将只看到Soccer分类的内容, 就像下面这样

这里写图片描述

很明显, 用户不希望使用URL导航到分类. 但你可以看到, 一旦基本结构就位, 一点点很小的改变就会对MVC应用产生很大的英雄.

!单元测试: 类别过滤

我需要一个恰当的单元测试, 用来测试分类过滤功能, 确保过滤器可以正确给出指定分类的产品集合. 在ProductControllerTests中创建测试

[Fact]
public void Can_Filter_Products()
{
    // 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 = "Cat1"},
        new Product {ProductID = 2, Name = "P2", Category = "Cat2"},
        new Product {ProductID = 3, Name = "P3", Category = "Cat1"},
        new Product {ProductID = 4, Name = "P4", Category = "Cat2"},
        new Product {ProductID = 5, Name = "P5", Category = "Cat3"}
    }).AsQueryable<Product>());
    // Arrange - create a controller and make the page size 3 items
    ProductController controller = new ProductController(mock.Object);
    controller.PageSize = 3;
    // Action
    Product[] result =
        (controller.List("Cat2", 1).ViewData.Model as ProductsListViewModel)
            .Products.ToArray();
    // Assert
    Assert.Equal(2, result.Length);
    Assert.True(result[0].Name == "P2" && result[0].Category == "Cat2");
    Assert.True(result[1].Name == "P4" && result[1].Category == "Cat2");
}

此测试创建了一个包含一系列不同分类产品的伪存储库. 通过行为方法指定一个分类, 检测结果是否与预期相同.

改善URL模式

没有人想看到像/?category=Soccer这样丑陋的URL. 要改善这种情况, 我需要修改StartUp.Configure()中的路由设置

注意: 一定要按以下的顺序排列路由, 否则会产生奇怪的错误.

app.UseMvc(routes => {
    routes.MapRoute(
        name: null,
        template: "{category}/Page{productPage:int}",
        defaults: new { controller = "Product", action = "List" }
    );

    routes.MapRoute(
        name: null,
        template: "Page{productPage:int}",
        defaults: new { controller = "Product", action = "List", productPage = 1 }
    );

    routes.MapRoute(
        name: null,
        template: "{category}",
        defaults: new { controller = "Product", action = "List", productPage = 1 }
    );

    routes.MapRoute(
        name: null,
        template: "",
        defaults: new { controller = "Product", action = "List", productPage = 1 }
    );

    routes.MapRoute(name: null, template: "{controller}/{action}/{id?}");

});

下表描述了这些路由代表的方案. 我将在第十五到十六章中介绍路由系统

URL指向
/列出所有分类产品的第一页
/Page2列出所有分类产品的第二页
/Soccer列出”Soccer”分类的第一页
/Soccer/Page2列出”Soccer”分类的第二页

MVC使用netCore路由系统来处理客户端发来的请求, 也生成符合URL方案的的URL, 并可以嵌入web页面中. 通过使用路由系统来处理传入请求和传出的URL, 可以确保应用程序中所有的URL都是一致的.

IUrlHepler接口提供了对url生成功能的访问, 我在上一章中, 在新创建的标记助手中使用了这个接口和行为方法. 现在我想要开始生成更复杂的URL, 需要一种方法来接收来自视图的附加信息, 而不必想标记助手类添加额外的属性. 幸运的是, 标记助手有一个很好的特性, 允许在单个集合中接收带有公共前缀的属性.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using SportsStore.Models.ViewModels;
using System.Collections.Generic;

namespace SportsStore.Infrastructure
{
    [HtmlTargetElement("div", Attributes = "page-model")]
    public class PageLinkTagHelper : TagHelper
    {
        private IUrlHelperFactory urlHelperFactory;

        public PageLinkTagHelper(IUrlHelperFactory helperFactory)
        {
            urlHelperFactory = helperFactory;
        }

        [ViewContext]
        [HtmlAttributeNotBound]
        public ViewContext ViewContext { get; set; }
        public PagingInfo PageModel { get; set; }
        public string PageAction { get; set; }

        [HtmlAttributeName(DictionaryAttributePrefix = "page-url-")]
        public Dictionary<string, object> PageUrlValues { get; set; } = new Dictionary<string, object>();

        public bool PageClassesEnabled { get; set; } = false;
        public string PageClass { get; set; }
        public string PageClassNormal { get; set; }
        public string PageClassSelected { get; set; }

        public override void Process(TagHelperContext context,
                TagHelperOutput output)
        {
            IUrlHelper urlHelper = urlHelperFactory.GetUrlHelper(ViewContext);
            TagBuilder result = new TagBuilder("div");
            for (int i = 1; i <= PageModel.TotalPages; i++)
            {
                TagBuilder tag = new TagBuilder("a");
                PageUrlValues["productPage"] = i;
                tag.Attributes["href"] = urlHelper.Action(PageAction, PageUrlValues);
                if (PageClassesEnabled)
                {
                    tag.AddCssClass(PageClass);
                    tag.AddCssClass(i == PageModel.CurrentPage
                        ? PageClassSelected : PageClassNormal);
                }
                tag.InnerHtml.Append(i.ToString());
                result.InnerHtml.AppendHtml(tag);
            }
            output.Content.AppendHtml(result.InnerHtml);
        }
    }
}

使用HtmlAttributeName属性修饰tagHelper的成员属性, 允许我为DOM元素上的属性名称指定一个前缀, 本例中为page-url. 任何以这个前缀开头的属性值都将被添加到分配给PageUrlValues属性的字典中, 然后传递给IUrlHelper.Action方法, 为标记助手制造的a元素的herf属性生成URL.
在下面的代码中, 我在标记助手处理的div元素中添加了一个新元素, 用于指定生成URL的类别. 我只向视图List.cshtml添加了一个新属性, 但任何具有相同前缀的属性都将被添加到字典中.

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    @Html.Partial("ProductSummary", p)
}

<div page-model="@Model.PagingInfo" page-action="List" page-classes-enabled="true"
     page-class="btn" page-class-normal="btn-secondary"
     page-class-selected="btn-primary" page-url-category="@Model.CurrentCategory" class="btn-group pull-right m-1">
</div>

在更改前, 生成的分页连接如下

http://<myserver>:<port>/Page1

如果用户点击这样的链接, 就会丢失类别筛选信息, 应用程序跳转到包含所有类别产品的页面. 添加视图模型中的CurrentCategory后, 将会生成这样的链接

http://<myserver>:<port>/Chess/Page1

用户点击此类链接时, 当前类别信息被传递给List行为, 过滤将被保留. 在完成此修改后, URL正确包含了类别.

添加一个类别导航菜单

我需要为客户提供一种不直接输入URL就能查看不同类别的方法, 这意味着要提供可用类别的列表, 并指出当前有哪些类别(如果有的话). 在构建应用的时候, 我将在多个控制器中使用这个类别列表, 因此我需要一些自包含和可重用的东西.
netCoreMvc具有视图组件的概念, 这非常适合用于创建诸如可重用的导航栏组件之类的东西. 视图组件是一个C#类, 提供少量的可重用程序逻辑, 能够选择和选择部分Razor视图. 我将在第22章中详细描述视图组件.
在本例中, 我将创建一个视图组件, 该组件渲染导航菜单, 并通过从共享布局调用组件, 将其集成到应用程序中. 这种方法给了我一个常规的C#类, 它可以包含任何我想要的程序逻辑, 并可以像其他类一样进行单元测试. 这是一种很好的策略, 可以创建应用程序的小部分, 同时维持整体的MVC方式

创建导航视图组件

我创建了一个文件夹Components, 这是一个惯例. 在这个文件夹下创建NavigationMenuViewComponent.cs

using Microsoft.AspNetCore.Mvc;

namespace SportsStore.Components {

    public class NavigationMenuViewComponent : ViewComponent {

        public string Invoke() {
            return "Hello from the Nav View Component";
        }
    }
}

视图组件被Razor页面使用的时候, 会调用Invoke()方法, Invoke()的返回值会被插入HTML中. 之后我会用动态HTML内容替换现在的字符串
我想让分类列表在所有页面显示, 所以我将在共享布局Views\Shared\_Layout.cshtml中使用这个视图组件, 而不是在某一个特定的视图内. 在视图中, 视图组件使用@await Component.InvokeAsync表达式调用.

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <link rel="stylesheet"
          asp-href-include="/lib/bootstrap/dist/**/*.min.css"
          asp-href-exclude="**/*-reboot*,**/*-grid*" />
    <title>SportsStore</title>
</head>
<body>
    <div class="navbar navbar-inverse bg-inverse" role="navigation">
        <a class="navbar-brand" href="#">SPORTS STORE</a>
    </div>
    <div class="row m-1 p-1">
        <div id="categories" class="col-3">
            @await Component.InvokeAsync("NavigationMenu")
        </div>
        <div class="col-9">
            @RenderBody()
        </div>
    </div>
</body>
</html>

移除了原来的文本占位符, 并调用了Component.InvokeAsync方法. 方法参数是组件类的名字, 忽略ViewComponent部分. 运行程序后可以看到组件内容.

这里写图片描述

生成分类列表

现在我可以回到导航视图控制器了, 并生成一系列真正的分类. 我可以以编程的方式为这些类别构建HTML, 就像为分页的tagHelper做的那样. 但视图组件的好处之一是可以渲染一部分Razor视图. 这意味着我可以使用视图组件来生成组件列表, 然后使用更具表现力的Razor语法来渲染HTML. 第一部是更新视图组件

using Microsoft.AspNetCore.Mvc;
using System.Linq;
using SportsStore.Models;

namespace SportsStore.Components
{
    public class NavigationMenuViewComponent : ViewComponent
    {
        private IProductRepository repository;

        public NavigationMenuViewComponent(IProductRepository repo)
        {
            repository = repo;
        }

        public IViewComponentResult Invoke()
        {
            return View(repository.Products
                .Select(x => x.Category)
                .Distinct()
                .OrderBy(x => x));
        }
    }
}

构造函数定义了一个IProductRepository参数, 和上一章效果相同. 在Invoke方法中, 使用LINQ来选择产品种类, 并传递给视图方法, 详见第22章.

!单元测试: 创建分类列表

对生成分类列表特性做的单元测试比较简单, 目标是创建一个字母序的分类列表, 不含重复. 最简单的方法是提供一些测试数据, 这些数据有重复的类别, 不按顺序排列, 将其传递给标记助手类, 并断言数据被正确处理了. 我在测试项目中新建了单元测试类NavigationMenuViewComponentTests.cs

using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Moq;
using SportsStore.Components;
using SportsStore.Models;
using Xunit;

namespace SportsStore.Tests
{
    public class NavigationMenuViewComponentTests
    {
        [Fact]
        public void Can_Select_Categories()
        {
            // Arrange
            Mock<IProductRepository> mock = new Mock<IProductRepository>();
            mock.Setup(m => m.Products).Returns((new Product[] {
                new Product {ProductID = 1, Name = "P1", Category = "Apples"},
                new Product {ProductID = 2, Name = "P2", Category = "Apples"},
                new Product {ProductID = 3, Name = "P3", Category = "Plums"},
                new Product {ProductID = 4, Name = "P4", Category = "Oranges"},
            }).AsQueryable<Product>());
            NavigationMenuViewComponent target =
                new NavigationMenuViewComponent(mock.Object);
            // Act = get the set of categories
            string[] results = ((IEnumerable<string>)(target.Invoke()
                as ViewViewComponentResult).ViewData.Model).ToArray();
            // Assert
            Assert.True(Enumerable.SequenceEqual(new string[] { "Apples",
                "Oranges", "Plums" }, results));
        }
    }
}
创建视图

Razor为视图组件的默认视图位置提供了一个不同的约定. 对于视图组件Components\NavigationMenuViewComponent.cs, 对应的视图的位置是Views/Shared/Components/NavigationMenu/Default.cshtml.

@model IEnumerable<string>

<a class="btn btn-block btn-secondary" asp-action="List" asp-controller="Product" asp-route-category="">Home</a>
@foreach (string category in Model)
{
    <a class="btn btn-block btn-secondary" asp-action="List" asp-controller="Product" asp-route-category="@category" asp-route-productPage="1">@category</a>
}

这个视图使用内置的标记助手来创建href属性.
运行程序后可以看到如下结果

这里写图片描述

高亮现在的分类

对用户来说, 没有正在选择的分类的反馈, 所以需要这个功能咯. netCoreMVC组件(如控制器和视图)可以通过访问上下文对象获取现在的请求的相关信息. 大多数时间, 你可以依赖用于创建组件的基类来获取上下文对象, 例如使用Controller基类来创建控制器的时候.
ViewComponent基类也不例外, 它通过一组属性提供对上下文对象的访问. 其中一个属性是RouteData, 它提供关于路由系统如何处理请求URL的信息.
在下面的代码中, 我使用RouteData属性来访问请求数据, 以获取现在选中的分类的值. 我可以创建另一个视图模型来传递这个分类值(这是我会在真正的应用中做的), 但为了简便, 我使用ViewBag特性来传值. 修改NavigationMenuViewComponent.cs

using Microsoft.AspNetCore.Mvc;
using System.Linq;
using SportsStore.Models;

namespace SportsStore.Components
{
    public class NavigationMenuViewComponent : ViewComponent
    {
        private IProductRepository repository;

        public NavigationMenuViewComponent(IProductRepository repo)
        {
            repository = repo;
        }

        public IViewComponentResult Invoke()
        {
            ViewBag.SelectedCategory = RouteData?.Values["category"];
            return View(repository.Products
                .Select(x => x.Category)
                .Distinct()
                .OrderBy(x => x));
        }
    }
}

仅在Invoke()方法中为ViewBag添加了一个属性值.

!单元测试: 报告选择的分类

我可以在单元测试中读取ViewBag的属性值, 来测试视图组件是否正确添加了所选类别的细节, 该属性可以从ViewViewComponentResult类中获得, 详见第22章. 将测试添加到NavigatioMenuViewComponentTests类中

[Fact]
public void Indicates_Selected_Category()
{
    // Arrange
    string categoryToSelect = "Apples";
    Mock<IProductRepository> mock = new Mock<IProductRepository>();
            mock.Setup(m => m.Products).Returns((new Product[] {
        new Product {ProductID = 1, Name = "P1", Category = "Apples"},
        new Product {ProductID = 4, Name = "P2", Category = "Oranges"},
    }).AsQueryable<Product>());
    NavigationMenuViewComponent target =
        new NavigationMenuViewComponent(mock.Object);
    target.ViewComponentContext = new ViewComponentContext
    {
        ViewContext = new ViewContext
        {
            RouteData = new RouteData()
        }
    };
    target.RouteData.Values["category"] = categoryToSelect;
    // Action
    string result = (string)(target.Invoke() as
        ViewViewComponentResult).ViewData["SelectedCategory"];
    // Assert
    Assert.Equal(categoryToSelect, result);
}

这个单元测试通过ViewComponentContext.ViewContext属性提供特定视图的上下文数据的访问, ViewContext通过RouteData属性提供对路由信息的访问.

现在我提供了关于选择哪个类别的信息, 可以更新视图组件中我选择的分类的显示风格, 利用CSS类. 改动了Default.cshtml文件

@model IEnumerable<string>

<a class="btn btn-block btn-secondary" asp-action="List" asp-controller="Product" asp-route-category="">Home</a>
@foreach (string category in Model)
{
    <a class="btn btn-block @(category == ViewBag.SelectedCategory ? "btn-primary": "btn-secondary")"
        asp-action="List" asp-controller="Product" asp-route-category="@category" asp-route-productPage="1">@category</a>
}

class属性中使用了一个Razor表达式, 来决定使用btn-primary还是btn-secondary. 效果如下

这里写图片描述

修正页面计数

我需要修改页面的链接, 一遍在选择类别时正确工作. 现在页面链接的数量取决于存储库汇总产品的总数, 而不是所选类别的产品数量. 这意味着客户可以点击Chess类别第二页的链接, 然后显示一个空页面, 因为没有足够多的Chess产品来填满两页.

这里写图片描述

通过更新List行为来修正这个错误

public ViewResult List(string category, int productPage = 1)
=> View(new ProductsListViewModel
{
    Products = repository.Products
        .Where(p => category == null || p.Category == category)
        .OrderBy(p => p.ProductID)
        .Skip((productPage - 1) * PageSize)
        .Take(PageSize),
    PagingInfo = new PagingInfo
    {
        CurrentPage = productPage,
        ItemsPerPage = PageSize,
        TotalItems = category == null ? repository.Products.Count() : repository.Products.Where(e => e.Category == category).Count()
    },
    CurrentCategory = category
});

修改了new ProductsListViewModel.PagingInfo.TotalItems的值, 逻辑相当自然

!单元测试: 指定类的产品数量

测试我能产生不同种类的正确产品数量很简单, 创建一个伪存储库, 调用List行为测试每个种类, 添加测试到ProductControllerTests如下

[Fact]
public void Generate_Category_Specific_Product_Count()
{
    // Arrange
    Mock<IProductRepository> mock = new Mock<IProductRepository>();
            mock.Setup(m => m.Products).Returns((new Product[] {
        new Product {ProductID = 1, Name = "P1", Category = "Cat1"},
        new Product {ProductID = 2, Name = "P2", Category = "Cat2"},
        new Product {ProductID = 3, Name = "P3", Category = "Cat1"},
        new Product {ProductID = 4, Name = "P4", Category = "Cat2"},
        new Product {ProductID = 5, Name = "P5", Category = "Cat3"}
    }).AsQueryable<Product>());
    ProductController target = new ProductController(mock.Object);
    target.PageSize = 3;
    Func<ViewResult, ProductsListViewModel> GetModel = result =>
        result?.ViewData?.Model as ProductsListViewModel;
    // Action
    int? res1 = GetModel(target.List("Cat1"))?.PagingInfo.TotalItems;
    int? res2 = GetModel(target.List("Cat2"))?.PagingInfo.TotalItems;
    int? res3 = GetModel(target.List("Cat3"))?.PagingInfo.TotalItems;
    int? resAll = GetModel(target.List(null))?.PagingInfo.TotalItems;
    // Assert
    Assert.Equal(2, res1);
    Assert.Equal(2, res2);
    Assert.Equal(1, res3);
    Assert.Equal(5, resAll);
}

构建购物车

现在的应用进展已经不错了, 但我在实现购物车之前没有办法卖东西. 在这一部分, 我将创建一个如下图的购物体验, 这对任何在网上购物的人来说都很熟悉.

这里写图片描述

在每个产品旁边都显示”添加到购物车”按钮. 单击此按钮将显示客户已选择的所有产品的摘要, 包括总成本. 此时单击”继续购物”将返回产品目录, 或单机”付款”按钮完成订单.

定义购物车模型

新建类Models\Cart.cs

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

namespace SportsStore.Models
{
    public class Cart
    {
        private List<CartLine> lineCollection = new List<CartLine>();

        public virtual 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 virtual void RemoveLine(Product product) => lineCollection.RemoveAll(l => l.Product.ProductID == product.ProductID);

        public virtual decimal ComputeTotalValue() => lineCollection.Sum(e => e.Product.Price * e.Quantity);

        public virtual void Clear() => lineCollection.Clear();

        public virtual IEnumerable<CartLine> Lines => lineCollection;
    }

    public class CartLine
    {
        public int CartLineID { get; set; }
        public Product Product { get; set; }
        public int Quantity { get; set; }
    }
}

Cart使用CartLine来表示顾客选择的产品编号和数量. 我定义了添加\删除\计算总价\清空购物车的方法. 我也提供了提供购物车内容访问的属性IEnumerable<CartLine> Lines. 这些都是很简单的东西, 使用LINQ很容易实现.

!单元测试: 测试购物车

Cart类相对简单, 但它的功能非常重要, 必须正常工作. 不能正常运行的购物车将破坏整个应用程序. 所以, 对Cart的特性进行分解并分别进行测试, 在测试项目中创建一个名为CartTest.cs的新的测试文件

using System.Linq;
using SportsStore.Models;
using Xunit;

namespace SportsStore.Tests
{
    public class CartTests
    {
        /// <summary>
        /// 第一个行为与向购物车添加项目有关, 如果第一次将给定的产品添加到购物车, 则添加一个新的CartLine
        /// </summary>
        [Fact]
        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.Equal(2, results.Length);
            Assert.Equal(p1, results[0].Product);
            Assert.Equal(p2, results[1].Product);
        }

        /// <summary>
        /// 如果客户已经添加了这个产品, 希望增加相应的CartLine的数量, 而不是创建一个新的
        /// </summary>
        [Fact]
        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.Equal(2, results.Length);
            Assert.Equal(11, results[0].Quantity);
            Assert.Equal(1, results[1].Quantity);
        }

        /// <summary>
        /// 也需要测试用户是否能从购物车中删除产品
        /// </summary>
        [Fact]
        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.Empty(target.Lines.Where(c => c.Product == p2));
            Assert.Equal(2, target.Lines.Count());
        }

        /// <summary>
        /// 要测试的下一个行为是计算购物车中项目的总成本
        /// </summary>
        [Fact]
        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.Equal(450M, result);
        }

        /// <summary>
        /// 测试是否能清空购物车
        /// </summary>
        [Fact]
        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.Empty(target.Lines);
        }
    }
}

有时测试类代码比类本身更长更复杂(如本例), 不要害怕从而停止单元测试. 简单类中的缺陷可能会产生巨大的影响, 特别是像在示例应用程序中Cart这样重要的角色.

添加”添加到购物车”按钮

编辑Views\Shared\ProductSummary.cshtml来添加一个”添加购物车”按钮, 为了准备这件事情, 添加一个类”Infrastructure\UrlExtensions.cs”

using Microsoft.AspNetCore.Http;

namespace SportsStore.Infrastructure
{
    public static class UrlExtensions
    {
        public static string PathAndQuery(this HttpRequest request) =>
            request.QueryString.HasValue ? $"{request.Path}{request.QueryString}" : request.Path.ToString();
    }
}

PathAndQuery扩展方法对HttpRequest进行操作(aspNetCore中用于描述Http请求的类). 扩展方法生成一个购物车更新后返回的URL. 如果有查询字符串则需要考虑. 在下面的代码中, 我将包含扩展方法的名称空间添加到视图导入文件Views\_ViewImports.cshtml中, 以便在部分视图中使用.

@using SportsStore.Models
@using SportsStore.Models.ViewModels
@using SportsStore.Infrastructure
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper SportsStore.Infrastructure.*, SportsStore

然后更新Views\Shared\ProductSummary.cshtml

@model Product

<div class="card card-outline-primary m-1 p-1">
    <div class="bg-faded p-1">
        <h4>
            @Model.Name
            <span class="badge badge-pill badge-primary" style="float:right">
                <small>@Model.Price.ToString("c")</small>
            </span>
        </h4>
    </div>
    <form id="@Model.ProductID" asp-action="AddToCart"
          asp-controller="Cart" method="post">
        <input type="hidden" asp-for="ProductID" />
        <input type="hidden" name="returnUrl"
               value="@ViewContext.HttpContext.Request.PathAndQuery()" />
        <span class="card-text p-1">
            @Model.Description
            <button type="submit"
                    class="btn btn-success btn-sm pull-right" style="float:right">
                Add To Cart
            </button>
        </span>
    </form>
</div>

我添加了一个form元素, 它包含了隐藏的input元素来指定从视图模型传过来的ProductID的值和购物车更新后应该指向的值. form元素和一个input元素使用内置的标记助手来配置, 这是生成带有模型值和目标控制器行为的表单的有效方法, 将在第二十四章中详细描述. 其他的input元素使用我创建的拓展方法来设置返回URL. 我还添加了一个button来提交表单.

注意: 我已经将表单的请求方式设置为POST, 你也可以更改为GET, 但需要慎重考虑. HTTP规范中规定GET请求必须是幂等的, 不能修改值, 而向购物车中添加项目肯定修改了值. 我将在第16章中详细介绍, 包括如果你忽略了GET的幂等性会发生什么

启用会话(session)

我将使用会话状态来存储用户购物车的详细信息. 会话状态是存储在服务器上, 并与用户请求相关联的数据. aspNet提供了一系列存储会话状态的不同方法, 包括将其存储在内存中, 这是我将要使用的方法, 简单易用, 但意味着当应用程序停止或重启时, 会话数据将丢失. 启动会话需要在StartUp中配置服务和中间件


//...
services.AddMemoryCache();
services.AddSession();
//...
app.UseSession();
//...

AddMemoryCache方法设置了内存数据存储. AddSession注册了用于访问会话数据的服务, UseSession方法允许会话系统在客户端请求到达时自动与会话关联起来.

购物车控制器的实现

我需要一个控制器来处理”添加到购物车”按钮. 我创建了一个新类Controllers\CartController.cs

using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SportsStore.Infrastructure;
using SportsStore.Models;

namespace SportsStore.Controllers
{
    public class CartController : Controller
    {
        private IProductRepository repository;

        public CartController(IProductRepository repo)
        {
            repository = repo;
        }

        public RedirectToActionResult AddToCart(int productId, string returnUrl)
        {
            Product product = repository.Products
                .FirstOrDefault(p => p.ProductID == productId);
            if (product != null)
            {
                Cart cart = GetCart();
                cart.AddItem(product, 1);
                SaveCart(cart);
            }
            return RedirectToAction("Index", new { returnUrl });
        }

        public RedirectToActionResult RemoveFromCart(int productId, string returnUrl)
        {
            Product product = repository.Products
                .FirstOrDefault(p => p.ProductID == productId);
            if (product != null)
            {
                Cart cart = GetCart();
                cart.RemoveLine(product);
                SaveCart(cart);
            }
            return RedirectToAction("Index", new { returnUrl });
        }

        private Cart GetCart()
        {
            Cart cart = HttpContext.Session.GetJson<Cart>("Cart") ?? new Cart();
            return cart;
        }

        private void SaveCart(Cart cart)
        {
            HttpContext.Session.SetJson("Cart", cart);
        }
    }
}

关于这个控制器有几点需要注意. 第一是我使用aspNet会话状态特性来存储和检索Cart对象, 这是GetCart方法的目的. 我在上一节中注册的中间件使用cookie和URL重写将多个请求关联在一起, 形成一个单一的浏览会话. 一个相关的特性是会话状态, 它将数据与会话关联起来, 这非常适合Cart类. 我希望每个用户都有自己的Cart, 且希望购物者在请求之间是持久的. 当会话过期时, 与会话有关的数据将被删除(通常是因为用户暂时没有发出请求), 这意味着我不需要管理Cart对象的存储和生命周期.
对于AddToCartRemoveFromCart行为, 我使用了与ProductSummary.cshtml中创建的HTML表单中的input元素相匹配的参数名称. 这允许MVC将表单内容与参数关联, 这意味着我不需要自己处理表单, 称为”模型绑定”, 是简化控制器的强大工具, 详见第26章.

定义会话状态扩展方法

aspNetCore的会话状态特性仅存储int\string\byte[]值. 因为我想存储Cart对象, 我需要定义一个ISession接口的扩展方法, 用于访问会话状态数据, 将Cart对象序列化为json, 并反序列化. 我添加了一个类Infrastructure\SessionExtensions.cs

using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;

namespace SportsStore.Infrastructure
{
    public static class SessionExtensions
    {
        public static void SetJson(this ISession session, string key, object value)
        {
            session.SetString(key, JsonConvert.SerializeObject(value));
        }
        public static T GetJson<T>(this ISession session, string key)
        {
            var sessionData = session.GetString(key);
            return sessionData == null ? default(T) : JsonConvert.DeserializeObject<T>(sessionData);
        }
    }
}

这些方法依赖包Json.Net来将对象序列化为json格式, 将在在第二十章中遇到. 包Json.Net不需要手动添加, 因为已经被MVC引用了, 将在第二十一章中介绍. (点击这里查看如何直接使用Json.Net)
扩展方法让存储和检索Cart对象很简单. 要添加Cart对象到会话状态中, 只需要这样

HttpContext.Session.SetJson("Cart", cart);

Controller基类(通常衍生控制器, 并返回一个HttpContext属性)的HttpContext属性提供关于已接收到的请求的上下文数据, 和正在准备的响应.
HttpContext.Session属性返回一个实现ISession接口的对象, 该对象是我定义SetJson方法所在的类型. SetJson方法接受指定一个键的参数和要添加到会话状态的对象, 序列化对象, 并使用ISession接口提供的底层功能来添加到会话状态
要再次检索Cart, 使用另一个扩展方法, 指定键

Cart cart = HttpContext.Session.GetJson<Cart>("Cart");

泛型类型参数指定了我想线索的类型, 用于反序列化过程

在购物车中展示内容

CartController中要指出的最后一点是AddToCartRemoveFromCart方法都调用了RedirectToAction方法. 这相当于向浏览器客户端发送重定向指令, 让浏览器请求一个新的URL. 本例中, 我要求浏览器请求一个URL, 该URL将调用CartController.Index()
我将实现Index方法用于展示Cart的内容. 如果你回去看上一张图, 你会发现这是用户点击”添加到购物车”时的工作流
我需要传向展示购物车内容的视图传递两条信息: Cart对象和继续购物按钮的URL. 我创建了一个新类Models\ViewModels\CartIndexViewModel.cs

using SportsStore.Models;

namespace SportsStore.Models.ViewModels
{
    public class CartIndexViewModel
    {
        public Cart Cart { get; set; }
        public string ReturnUrl { get; set; }
    }
}

有了视图模型后, 可以实现CartController.Index()

using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SportsStore.Infrastructure;
using SportsStore.Models;
using SportsStore.Models.ViewModels;

namespace SportsStore.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
            });
        }

        //...

    }
}

Index方法从会话状态中检索了Cart对象, 并创建了一个CartIndexViewModel对象, 用于向视图传递数据.
最后一步是展示购物车内容, 创建Index行为渲染的新视图. 我创建了Views\Cart\Index.cshtml

@model CartIndexViewModel

<h2>Your cart</h2>
<table class="table table-bordered table-striped">
    <thead>
        <tr>
            <th>Quantity</th>
            <th>Item</th>
            <th class="text-right">Price</th>
            <th class="text-right">Subtotal</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var line in Model.Cart.Lines)
        {
            <tr>
                <td class="text-center">@line.Quantity</td>
                <td class="text-left">@line.Product.Name</td>
                <td class="text-right">@line.Product.Price.ToString("c")</td>
                <td class="text-right">
                    @((line.Quantity * line.Product.Price).ToString("c"))
                </td>
            </tr>
        }
    </tbody>
    <tfoot>
        <tr>
            <td colspan="3" class="text-right">Total:</td>
            <td class="text-right">
                @Model.Cart.ComputeTotalValue().ToString("c")
            </td>
        </tr>
    </tfoot>
</table>

<div class="text-center">
    <a class="btn btn-primary" href="@Model.ReturnUrl">Continue shopping</a>
</div>

视图枚举购物车中的行, 将每行都添加到HTML的table元素中, 还有每行的总价和购物车的总价. DOM元素的类依赖bootstrap.
现在的成果是基本的功能和购物车都齐全了. 首先, 产品的旁边有一个”添加到购物车”按钮

这里写图片描述

然后, 用户点击”添加到购物车”时, 将相应的产品添加到购物车, 展示购物车的概况如下图. 点击”继续购物”按钮将让用户返回之前的产品页面

这里写图片描述

现在购物车URL是这样式的

http://localhost:port/Cart/Index?returnUrl=%2FChess%2FPage1

总结

这一章中, 我开始充实SportsStore应用中编写客户的部分. 我为用于提供了按类别导航的方法, 并为想购物车中添加商品设置了基本的块.
还有很多工作要做, 我将在下一章中继续应用程序开发.

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值