作者:Adam Freeman 翻译:陈广 日期:2018-9-3
本章我将继续构建运动商店示例应用程序。在上一章中,我为购物车添加了基本支持,现在我将改进并完成这个功能。
上一章我定义了购物车模型并演示了如何使用会话功能存储它,以允许用户建立一组供购买的产品。管理Cart
类持久性的责任落在购物车控制器身上,后者明确定义了获取和存储Cart
对象的方法。
这种方法的问题在于,在任何使用Cart对象的组件中,我必须重复使用获取和存储它的代码。本节我将使用 ASP.NET Core 内置的服务功能来简化购物车对象的管理方式,使购物车控制器等各个组件不必直接处理细节。
服务通常用于向依赖它们的组件隐藏接口实现的细节。之前有一个示例,当我为IProductRepository
接口创建一个服务时,它允许我用 Entity Framework Core 存储库无缝地替换伪存储库类。但是,服务也可以用于解决许多其他问题,也可以用来塑造和重塑应用程序,即使在使用具体类时也是如此,如Cart
。
改进Cart
类使用方式的第一步是创建一个子类,该子类知道如何使用会话状态存储自己。我将一个名为 SessionCart.cs 的类文件添加到 Models 文件夹中,并使用它来定义清单10-1所示的类。
清单 10-1:Models 文件夹下的 SessionCart.cs 文件的内容
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using SportsStore.Infrastructure;
namespace SportsStore.Models
{
public class SessionCart : Cart
{
public static Cart GetCart(IServiceProvider services)
{
ISession session = services.GetRequiredService<IHttpContextAccessor>()?
.HttpContext.Session;
SessionCart cart = session?.GetJson<SessionCart>("Cart")
?? new SessionCart();
cart.Session = session;
return cart;
}
[JsonIgnore]
public ISession Session { get; set; }
public override void AddItem(Product product, int quantity)
{
base.AddItem(product, quantity);
Session.SetJson("Cart", this);
}
public override void RemoveLine(Product product)
{
base.RemoveLine(product);
Session.SetJson("Cart", this);
}
public override void Clear()
{
base.Clear();
Session.Remove("Cart");
}
}
}
SessionCart
类是Cart
类的子类,并重写了AddItem
、RemoveLine
和Clear
方法,因此它们调用基本实现,然后使用我在第9章中定义的ISession
接口上的扩展方法将更改后的状态存储在会话中。GetCart
静态方法是一个工厂,用于创建SessionCart
对象并为它们提供ISession
对象,以便它们能够存储自己。
获取ISession
对象有点复杂。我必须获得IHttpContextAccessor
服务的一个实例,它为我提供了对HttpContext
对象的访问,而该对象又为我提供了ISession
。这种间接方法是必需的,因为会话不是作为常规服务提供的。
下一步是为Cart
类创建一个服务。我的目标是满足对Cart
对象的请求,并使用SessionCart
对象无缝地存储它们。您可以在清单10-2中看到我是如何创建这个服务的。
清单 10-2:SportsStore 文件夹下的 Startup.cs 文件,创建 Cart 服务
...
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration["Data:SportStoreProducts:ConnectionString"]));
services.AddTransient<IProductRepository, EFProductRepository>();
services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddMvc();
services.AddMemoryCache();
services.AddSession();
}
...
AddScoped
方法指定应该使用同一个对象来满足对Cart
实例的相关请求。可以配置请求的关联方式,但默认情况下,这意味着组件所需的任何 Cart 只要处理相同 HTTP 请求都将接收相同的对象。
我已经指定了一个 lambda 表达式,它将被调用以满足 Cart 请求,而不是像我为存储库所做的那样,为AddScoped
方法提供类型映射。表达式接收已注册的服务集合,并将集合传递给SessionCart
类的GetCart
方法。结果是,对Cart
服务的请求将通过创建SessionCart
对象来处理,当会话数据被修改时,会话对象将作为会话数据进行序列化。
我还使用AddSingleton
方法添加了一个服务,该方法指定应该始终使用同一个对象。我创建的服务告诉 MVC 在需要IHttpContextAccessor
接口实现时使用HttpContextAccessor
类。这个服务是必需的,它让我可以访问清单10-1中的SessionCart
类中的当前会话。
创建这种服务的好处是,它允许我简化使用Cart
对象的控制器。在清单10-3中,我改写了CartController
类以利用新的服务。
清单 10-3:Controllers 文件夹下的 CartController.cs 文件,使用 Cart 服务
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using SportsStore.Models.ViewModels;
namespace SportsStore.Controllers
{
public class CartController : Controller
{
private IProductRepository repository;
private Cart cart;
public CartController(IProductRepository repo, Cart cartService)
{
repository = repo;
cart = cartService;
}
public ViewResult Index(string returnUrl)
{
return View(new CartIndexViewModel
{
Cart = cart,
ReturnUrl = returnUrl
});
}
public RedirectToActionResult AddToCart(int productId, string returnUrl)
{
Product product = repository.Products
.FirstOrDefault(p => p.ProductID == productId);
if (product != null)
{
cart.AddItem(product, 1);
}
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.RemoveLine(product);
}
return RedirectToAction("Index", new { returnUrl });
}
}
}
CartController
类通过声明构造器参数指示它需要一个Cart
对象,该参数允许我从会话中删除读取和写入数据的方法以及写入更新所需的步骤。结果是一个更简单的控制器,它仍然专注于它在应用程序中的角色,而不必担心Cart
对象是如何创建或持久化的。而且,由于服务在整个应用程序中都是可用的,所以任何组件都可以使用相同的技术获得用户的购物车。
现在我已经介绍了Cart
服务,是时候通过添加两个新功能来完成购物车功能了。第一个是允许客户从购物车中移除一个项目,第二个是在页面的顶部显示购物车摘要。
我已经在控制器中定义并测试了RemoveFromCart
action 方法,因此,让客户删除项只是在视图中公开此方法的问题,我将通过在购物车摘要的每一行中添加一个【Remove】按钮来实现这一点。清单10-4显示了对 Views/Cart/Index.cshtml 文件的更改。
清单 10-4:Views/Cart 文件夹下的 Index.cshtml,引入【Remove】按钮
@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>
<td>
<form asp-action="RemoveFromCart" method="post">
<input type="hidden" name="ProductID"
value="@line.Product.ProductID" />
<input type="hidden" name="returnUrl"
value="@Model.ReturnUrl" />
<button type="submit" class="btn btn-sm btn-danger">
Remove
</button>
</form>
</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>
我在表的每一行中添加了一个新列,其中包含一个表单,它包含隐藏的input
元素用于指定要删除的产品和返回的 URL,以及提交表单的按钮。
通过运行应用程序并将项目添加到购物车中,您可以看到【Remove】按钮。请记住,购物车已经包含了删除它的功能,您可以通过单击其中一个新按钮来测试它,如图10-1所示。
图10-1 从购物车中移除项目
我可能有一个功能良好的购物车,但是它集成到接口的方式有一个问题。客户只能通过查看购物车摘要屏幕才能知道购物车中的是什么。他们只能通过向购物车中添加一个新项目来查看购物车摘要屏幕。
为了解决这个问题,我将添加一个小部件,它显示购物车内容的摘要,可以在整个应用程序中通过单击它来显示的购物车内容。我将使用与添加导航小部件相同的方式做这件事 —— 作为视图组件,我可以将其输出包含在 Razor 共享布局中。
作为购物车摘要的一部分,我将显示一个允许用户结账的按钮,而不是在按钮中显示单词【结账】,我想使用购物车符号。由于没有艺术细胞,我将使用 Font Awesome 软件包,这是一组优秀的开源图标,它们作为字体集成到应用程序中,字体中的每个字符都是不同的图像。您可以了解更多关于 Font Awesome 的内容,包括查看它包含的图标:http://www.fontawesome.com.cn/faicons/。
译者注:原文使用 Bower 添加,由于 Visual Studio 已不再支持 Bower,所以我之后使用 LibMan。但现在发现 LibMan 并不支持 Font Awesome,没办法只能手动添加了,以下为手动添加方法。
在上面提到的 Font Awesome 网站中首先下载 Font Awesome 安装包:font-awesome-4.7.0.zip(这个是我写这篇文章时的版本,您下的版本号可能会不同)。解压后,将文件夹名称更名为 font-awesome。然后拷贝到项目文件夹下的 \wwwroot\lib 文件夹内。之后我会在 Layout.cshtml 文件中引用它。
我在 Components 文件夹中添加了一个名为 CartSummaryViewComponent.cs 的新类文件,并使用它定义如清单10-6所示的视图组件。
清单 10-6:Components 文件夹下的 CartSummaryViewComponent.cs 文件的内容
using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
namespace SportsStore.Components
{
public class CartSummaryViewComponent : ViewComponent
{
private Cart cart;
public CartSummaryViewComponent(Cart cartService)
{
cart = cartService;
}
public IViewComponentResult Invoke()
{
return View(cart);
}
}
}
此视图组件能够利用我之前在本章创建的服务,以接收Cart
对象作为构造器参数。结果是一个简单的视图组件类,它将Cart
对象传递给View
方法,以便生成将包含在布局中的 HTML 片段。为了创建布局,我创建了 Views/Shared/Components/CartSummary 文件夹,在其中添加了一个名为 Default.cshtml 的 Razor 视图文件,并添加了清单10-7所示的标记。
清单 10-7:Views/Shared/Components/CartSummary 文件夹下的 Default.cshtml 文件
@model Cart
<div class="">
@if (Model.Lines.Count() > 0)
{
<small class="navbar-text">
<b>Your cart:</b>
@Model.Lines.Sum(x => x.Quantity) item(s)
@Model.ComputeTotalValue().ToString("c")
</small>
}
<a class="btn btn-sm btn-secondary navbar-btn"
asp-controller="Cart" asp-action="Index"
asp-route-returnurl="@ViewContext.HttpContext.Request.PathAndQuery()">
<i class="fa fa-shopping-cart"></i>
</a>
</div>
该视图显示一个带有 Font Awesome 购物车图标的按钮,如果购物车中有项目,则提供一个快照,详细说明项目的数量及其总价值。现在我有了一个视图组件和一个视图,我可以修改共享布局,以便购物车摘要包含在应用程序的控制器生成的响应中,如清单10-8所示。
清单 10-8:Views/Shared 文件夹下的 _Layout.cshtml 文件,添加购物车摘要
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet"
asp-href-include="/lib/twitter-bootstrap/**/*.min.css"
asp-href-exclude="**/*-reboot*,**/*-grid*" />
<link rel="stylesheet" href="/lib/font-awesome/css/font-awesome.min.css" />
<title>SportsStore</title>
</head>
<body>
<div class="navbar navbar-inverse bg-inverse" role="navigation">
<div class="row">
<a class="col navbar-brand" href="#">SPORTS STORE</a>
<div class="col-4 text-right">
@await Component.InvokeAsync("CartSummary")
</div>
</div>
</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>
您可以通过启动应用程序来查看购物车摘要。当购物车为空时,只显示结账按钮。如果将项目添加到购物车中,则显示项目数量及其合计金额,如图10-2所示。有了这个补充,客户知道了购物车有什么,并有了一个明显的结账途径。
图10-2 显示购物车摘要
我现在已经达到了运动商店最后的客户功能:结账和完成订单的能力。在下面的部分中,我将扩展域模型以提供对从用户获取发货详细信息的支持,并添加应用程序支持来处理这些细节。
我在 Models 文件夹中添加了一个名为 Order.cs 的类文件,并对其进行了编辑,以匹配清单10-9所示的内容。
清单 10-9:Models 文件夹下的 Order.cs 文件的内容
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace SportsStore.Models
{
public class Order
{
[BindNever]
public int OrderID { get; set; }
[BindNever]
public ICollection<CartLine> Lines { get; set; }
[Required(ErrorMessage = "Please enter a name")]
public string Name { get; set; }
[Required(ErrorMessage = "Please enter the first address line")]
public string Line1 { get; set; }
public string Line2 { get; set; }
public string Line3 { get; set; }
[Required(ErrorMessage = "Please enter a city name")]
public string City { get; set; }
[Required(ErrorMessage = "Please enter a state name")]
public string State { get; set; }
public string Zip { get; set; }
[Required(ErrorMessage = "Please enter a country name")]
public string Country { get; set; }
public bool GiftWrap { get; set; }
}
}
正如我在第2章中所做的那样,我正在使用System.ComponentModel.DataAnnotations
命名空间中的验证特性,我在第27章中进一步描述了验证。
我还使用了BindNever
特性,它阻止用户在 HTTP 请求中提供这些属性的值。这是模型绑定系统的一个特性,我在第26章中对此进行了描述;它阻止 MVC 使用来自 HTTP 请求的值来填充敏感或重要的模型属性。
目标是使用户能够输入他们的发货详细信息并提交他们的订单。作为开始,我需要在购物车摘要视图中添加一个结账按钮。清单10-10显示了我应用到 Views/Cart/Index.cshtml 文件中的更改。
清单 10-10:Veiws/Cart 文件夹下的 Index.cshtml 文件,添加现在Checkout
按钮
...
<div class="text-center">
<a class="btn btn-primary" href="@Model.ReturnUrl">Continue shopping</a>
<a class="btn btn-primary" asp-action="Checkout" asp-controller="Order">
Checkout
</a>
</div>
...
此更改生成一个链接,我已将其添加一个按钮样式,单击该链接时,调用Order
控制器的Checkout
action 方法,我在下面的部分中创建了该方法。您可以在图10-3中看到该按钮是如何出现的。
图10-3 Checkout 按钮
现在我需要定义Order
控制器。将一个名为 OrderController.cs 的类文件添加到 Controllers 文件夹中,并使用它来定义清单10-11所示的类。
清单 10-11:Controllers 文件夹下的 OrderController.cs 文件的内容
using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
namespace SportsStore.Controllers
{
public class OrderController : Controller
{
public ViewResult Checkout() => View(new Order());
}
}
Checkout
方法返回默认视图并传递一个新的ShippingDetails
对象作为视图模型。为了创建视图,我创建了 Views/Order 文件夹,并添加了一个名为 Checkout.cshtml 的 Razor 视图文件,其标记如清单10-12所示。
清单 10-12:Views/Order 文件夹下的 Checkout.cshtml 文件的内容
@model Order
<h2>Check out now</h2>
<p>Please enter your details, and we'll ship your goods right away!</p>
<form asp-action="Checkout" method="post">
<h3>Ship to</h3>
<div class="form-group">
<label>Name:</label><input asp-for="Name" class="form-control" />
</div>
<h3>Address</h3>
<div class="form-group">
<label>Line 1:</label><input asp-for="Line1" class="form-control" />
</div>
<div class="form-group">
<label>Line 2:</label><input asp-for="Line2" class="form-control" />
</div>
<div class="form-group">
<label>Line 3:</label><input asp-for="Line3" class="form-control" />
</div>
<div class="form-group">
<label>City:</label><input asp-for="City" class="form-control" />
</div>
<div class="form-group">
<label>State:</label><input asp-for="State" class="form-control" />
</div>
<div class="form-group">
<label>Zip:</label><input asp-for="Zip" class="form-control" />
</div>
<div class="form-group">
<label>Country:</label><input asp-for="Country" class="form-control" />
</div>
<h3>Options</h3>
<div class="checkbox">
<label>
<input asp-for="GiftWrap" /> Gift wrap these items
</label>
</div>
<div class="text-center">
<input class="btn btn-primary" type="submit" value="Complete Order" />
</div>
</form>
对于模型中的每个属性,我创建了一个label
元素和一个input
元素来捕获用户输入,并使用 Bootstrap 格式化。input
元素上的asp-for
属性由内置标签助手处理,它根据指定的模型属性生成类型、id、名称和值属性,如第24章所述。
通过启动应用程序,单击页面顶部的Cart
按钮,然后单击【Checkout】按钮,如图10-4所示,您可以看到新 action 方法和视图的效果。还可以通过请求 /Cart/Checkout URL 达到这一点。
图10-4 快递详情表单
我会通过把订单写到数据库中来处理订单。当然,大多数电子商务网站不会简单地停留在那里,我也没有为处理信用卡或其他形式的付款提供支持。但是我想把重点放在 MVC 上,所以一个简单的数据库输入就可以了。
一旦我在第8章中创建的基本管道已经就位,向数据库中添加一种新的模型是很简单的。首先,我向数据库上下文类添加了一个新属性,如清单10-13所示。
清单 10-13:Models 文件夹下的 ApplicationDbContext.cs 文件,添加一个属性
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;
namespace SportsStore.Models
{
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
public DbSet<Product> Products { get; set; }
public DbSet<Order> Orders { get; set; }
}
}
这种更改足以使 Entity Framework Core 创建允许在数据库中存储Order
对象的数据库迁移。要创建迁移,请打开一个新的命令提示符或 Powershell 窗口,导航到运动商店项目文件夹(其中包含Startup.cs文件),并运行以下命令:
dotnet ef migrations add Orders
这个命令告诉 Entity Framework Core 获取应用程序数据模型的新快照,计算它与前一个数据库版本的不同之处,并生成一个名为 Orders 的新迁移。新的迁移将在应用程序启动时自动应用,因为SeedData
调用 Entity Framework Core 提供的Migrate
方法。
重置数据库
当您对模型进行频繁的更改时,就会出现迁移和数据库模式不同步的情况。最简单的做法是删除数据库并重新启动。但是,这仅在开发期间才适用,因为您将丢失任何已存储的数据。
若要删除数据库,请在运动商店项目文件夹中运行以下命令:
dotnet ef database drop --force
移除数据库后,从 SportsStore 文件夹中运行以下命令来重新创建数据库,并通过运行以下命令应用您创建的迁移:
dotnet ef database update
这将重置数据库,使其准确地反映您的模型,并允许您重新开发应用程序。
我将遵循与产品存储库相同的模式,以提供对Order
对象的访问。我在 Models 文件夹中添加了一个名为 IOrderRepository.cs 的类文件,并使用它定义了清单10-14所示的接口。
清单 10-14:Models 文件夹下的 IOrderRepository.cs 文件的内容
using System.Linq;
namespace SportsStore.Models
{
public interface IOrderRepository
{
IQueryable<Order> Orders { get; }
void SaveOrder(Order order);
}
}
为了实现订单存储库接口,我向 Models 文件夹中添加了一个名为 EFOrderRepository.cs 的类文件,并定义了清单10-15所示的类。
清单 10-15:Models 文件夹下的 EFOrderRepository.cs 文件的内容
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace SportsStore.Models
{
public class EFOrderRepository : IOrderRepository
{
private ApplicationDbContext context;
public EFOrderRepository(ApplicationDbContext ctx)
{
context = ctx;
}
public IQueryable<Order> Orders => context.Orders
.Include(o => o.Lines)
.ThenInclude(l => l.Product);
public void SaveOrder(Order order)
{
context.AttachRange(order.Lines.Select(l => l.Product));
if (order.OrderID == 0)
{
context.Orders.Add(order);
}
context.SaveChanges();
}
}
}
该类使用 Entity Framework Core 实现IOrderRepository
,允许检索已存储的Order
对象集,并允许创建或更改订单。
理解订单存储库
实现清单10-15中订单的存储库需要做一些额外的工作。如果它跨越多个表,entity Framework Core 需要指令来加载相关数据。在清单中,我使用了
Include
和ThenInclude
方法来指定,当从数据库读取Order
对象时,与Lines
属性关联的集合也应该与每个集合对象关联的每个Product
对象一起加载。
...
public IQueryable<Order> Orders => context.Orders
.Include(o => o.Lines)
.ThenInclude(l => l.Product);
...
这可以确保我接收所需的所有数据对象,而不必执行查询和直接组装数据。
当我在数据库中存储一个
Order
对象时,还需要一个额外的步骤。当用户的购物车数据从会话存储中反序列化时,JSON 包会创建 Entity Framework Core 不知道的新对象,然后尝试将所有对象写入数据库。对于Product
对象,这意味着 Entity Framework Core 试图写入已经存储的对象,这将导致错误。为了避免这个问题,我通知 Entity Framework Core,除非修改了对象,否则这些对象已存在,不应该存储在数据库中,如下所示:
...
context.AttachRange(order.Lines.Select(l => l.Product));
...
这确保 Entity Framework Core 不会尝试编写与
Order
对象关联的反序列化Product
对象。
在清单10-16中,我已经将Order
存储库注册为Startup
类的ConfigureServices
方法中的服务。
清单 10-16:SportsStore 文件夹下的 Startup.cs 文件,注册订单存储库服务
...
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration["Data:SportStoreProducts:ConnectionString"]));
services.AddTransient<IProductRepository, EFProductRepository>();
services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddTransient<IOrderRepository, EFOrderRepository>();
services.AddMvc();
services.AddMemoryCache();
services.AddSession();
}
...
要完成OrderController
类,我需要修改构造函数,以便它接收处理订单所需的服务,并且需要添加一个新的 action 方法,在用户单击【Complete Order】按钮时处理 HTTP 表单 POST 请求。清单10-17显示了这两个更改。
清单 10-17:Controllers 文件夹下的 OrderController.cs 文件,完成控制器
using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using System.Linq;
namespace SportsStore.Controllers
{
public class OrderController : Controller
{
private IOrderRepository repository;
private Cart cart;
public OrderController(IOrderRepository repoService, Cart cartService)
{
repository = repoService;
cart = cartService;
}
public ViewResult Checkout() => View(new Order());
[HttpPost]
public IActionResult Checkout(Order order)
{
if (cart.Lines.Count() == 0)
{
ModelState.AddModelError("", "Sorry, your cart is empty!");
}
if (ModelState.IsValid)
{
order.Lines = cart.Lines.ToArray();
repository.SaveOrder(order);
return RedirectToAction(nameof(Completed));
}
else
{
return View(order);
}
}
public ViewResult Completed()
{
cart.Clear();
return View();
}
}
}
Checkout
action 方法使用HttpPost
属性进行修饰,这意味着它将在 POST 请求被调用 —— 在本例当用户提交表单时。我再次依赖于模型绑定系统,以便能够接收Order
对象,然后使用Cart
中的数据完成该对象,并将其存储在存储库中。
MVC 使用数据 annotation 属性检查我应用到Order
类的验证约束,所有验证问题都通过ModelState
属性传递给 action 方法。我可以通过检查ModelState.IsValid
属性来查看是否存在任何问题。如果购物车中没有项目,则调用ModelState.AddModelError
方法来注册错误消息。我将在稍后解释如何显示这样的错误,在第27章和第28章中我还有更多关于模型绑定和验证的内容要说。
单元测试:订单处理
要执行
OrderController
类的单元测试,我需要测试Checkout
方法的 POST 版本的行为。虽然该方法看起来很简单,但 MVC 模型绑定的使用意味着需要在幕后进行大量的测试。我只想在购物车中有项目并且客户已经提供了有效的发货细节的情况下处理订单。在所有其他情况下,应该向客户显示一个错误。下面是第一个测试方法,我在一个名为 OrderControllerTests.cs 的类文件中定义了这个方法:
using Microsoft.AspNetCore.Mvc;
using Moq;
using SportsStore.Controllers;
using SportsStore.Models;
using Xunit;
namespace SportsStore.Tests
{
public class OrderControllerTests
{
[Fact]
public void Cannot_Checkout_Empty_Cart()
{
// Arrange - create a mock repository
Mock<IOrderRepository> mock = new Mock<IOrderRepository>();
// Arrange - create an empty cart
Cart cart = new Cart();
// Arrange - create the order
Order order = new Order();
// Arrange - create an instance of the controller
OrderController target = new OrderController(mock.Object, cart);
// Act
ViewResult result = target.Checkout(order) as ViewResult;
// Assert - check that the order hasn't been stored
mock.Verify(m => m.SaveOrder(It.IsAny<Order>()), Times.Never);
// Assert - check that the method is returning the default view
Assert.True(string.IsNullOrEmpty(result.ViewName));
// Assert - check that I am passing an invalid model to the view
Assert.False(result.ViewData.ModelState.IsValid);
}
}
}
这个测试确保我不能用空的购物车结账。我通过确保从未调用模拟
IOrderRepository
实现的SaveOrder
来检查这一点,方法返回的视图是默认视图(它将重新显示客户输入的数据并给他们一个纠正它的机会),并且传递给视图的模型状态被标记为无效。这可能看起来像是一组带括号的断言,但需要这三种方法都确保我有正确的行为。下一种测试方法的工作方式与视图模型基本相同,但在视图模型中注入了一个错误,以模拟模型绑定程序报告的问题(在生成过程中,当客户输入无效的发货数据时):
...
[Fact]
public void Cannot_Checkout_Invalid_ShippingDetails()
{
// Arrange - create a mock order repository
Mock<IOrderRepository> mock = new Mock<IOrderRepository>();
// Arrange - create a cart with one item
Cart cart = new Cart();
cart.AddItem(new Product(), 1);
// Arrange - create an instance of the controller
OrderController target = new OrderController(mock.Object, cart);
// Arrange - add an error to the model
target.ModelState.AddModelError("error", "error");
// Act - try to checkout
ViewResult result = target.Checkout(new Order()) as ViewResult;
// Assert - check that the order hasn't been passed stored
mock.Verify(m => m.SaveOrder(It.IsAny<Order>()), Times.Never);
// Assert - check that the method is returning the default view
Assert.True(string.IsNullOrEmpty(result.ViewName));
// Assert - check that I am passing an invalid model to the view
Assert.False(result.ViewData.ModelState.IsValid);
}
...
在确定空购物车或无效的详细信息将阻止订单被处理后,我需要确保在适当的时候处理订单。下面是测试:
...
[Fact]
public void Can_Checkout_And_Submit_Order()
{
// Arrange - create a mock order repository
Mock<IOrderRepository> mock = new Mock<IOrderRepository>();
// Arrange - create a cart with one item
Cart cart = new Cart();
cart.AddItem(new Product(), 1);
// Arrange - create an instance of the controller
OrderController target = new OrderController(mock.Object, cart);
// Act - try to checkout
RedirectToActionResult result =
target.Checkout(new Order()) as RedirectToActionResult;
// Assert - check that the order has been stored
mock.Verify(m => m.SaveOrder(It.IsAny<Order>()), Times.Once);
// Assert - check that the method is redirecting to the Completed action
Assert.Equal("Completed", result.ActionName);
}
...
我不需要测试是否能够识别有效的发货细节。这是由模型绑定器使用应用于
Order
类属性的特性自动为我处理的。
为了完成结账过程,我需要创建视图,当浏览器被重定向到Order
控制器上的Completed
action 时将显示该视图。我在 Views/Order 文件夹中添加了一个名为 Completed.cshtml 的 Razor 视图文件,并添加了清单10-19中所示的标记。
清单 10-19:Views/Order 文件夹下的 Completed.cshtml 文件的内容
<h2>Thanks!</h2>
<p>Thanks for placing your order.</p>
<p>We'll ship your goods as soon as possible.</p>
不需要进行任何代码更改来将此视图集成到应用程序中,因为在定义Completed
action 方法时,我已经添加了所需的语句。现在客户可以使用完整的过程,从选择产品到结账。如果它们提供有效的快递细节(并且在它们的购物车中存在项目),则当单击【Complete Order】按钮时,它们将看到摘要页面,如图10-6所示。
图10-6 已完成订单摘要视图
我已经完成了运动商店的所有面向客户的部分。这可能不足以让亚马逊担心,但我有一个产品目录,可以按类别和页面浏览,一个整洁的购物车,和一个简单的结账过程。
分离良好的体系结构意味着我可以轻松地改变应用程序中任何部分的行为,而不会在其他地方引发问题或不一致。例如,我可以改变存储订单的方式,它不会对购物车、产品目录或应用程序的任何其他部分产生任何影响。在下一章中,我添加了管理该运动商店应用程序所需的功能。
;