第九章 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
对象的存储和生命周期.
对于AddToCart
和RemoveFromCart
行为, 我使用了与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
中要指出的最后一点是AddToCart
和RemoveFromCart
方法都调用了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应用中编写客户的部分. 我为用于提供了按类别导航的方法, 并为想购物车中添加商品设置了基本的块.
还有很多工作要做, 我将在下一章中继续应用程序开发.