注意:本文可能与原始英文版存在微小差异(代码),这些差异都是经过本人一步步构建程序的源代码,比如部分插件bower的版本更新或者类似情况,按照本文操作实践,肯定应用程序完美运行,如有问题请留言,[本人采用VS2017最新版本]
前面9章的翻译,请参考[crf-moonlight]大神的博客,
第11章,点击https://blog.csdn.net/zpczmc/article/details/86772554跳转
这个章节,继续构建示例应用:SportsStore,上一章节,我给购物车添加了基本的功能,现在我会提升并完善购物车的功能
使用服务优化购物车模型
我在前面的章节定义了一个Cart Model并且用它借助Session 的特性演示了怎样实现保存用户增加的购物车的信息,他允许用户选择产品添加到购物车,Catr控制器负责管理维持Cart信息的保存和获取,控制器明确的定义了如何储存cart对象和获取cart对象.
这种方法的问题是我必须在使用cart组件的对象的地方复制获取和存储Cart的代码,在本节中,我们将通过使用ASP.NET CORE核心服务特性来方便容易的实现Cart对象的管理,这种方式释放了组件,比如Cart控制器不需要直接的处理细节.
服务经常用于隐藏组件实现接口的细节,接线来我为IProductRepository 接口创建一个服务,他可以让我用实体框架模型无缝替换虚拟存储类库, 但服务也可用于解决许多其他问题同时也可用于塑造和重塑应用程序,正像您使用的Cart这个实际的类,
创建具有储存-感知特性的Cart类
第一步是创建一个能通过Session 状态来感知自己 工作的继承于Cart类的子类,我在Models文件夹中增加了一个称作 SessionCart.cs的类,他的内容定义如下 Listing 10-1
Listing 10-1. The Contents of the SessionCart.cs File in the Models Folder
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方法,重写的方法调用了基类的方法并用第九章节ISession 接口的拓展方法在session中更新了状态,静态方法GetCart用于在session中获取一个实现了ISession接口的SessionCart对象,如果服务中不存在这个对象则创建这个对象用于提供Cart类的基础支持.
掌握ISession对象有点复杂,我通过IHttpContextAccessor 服务创建一个实例,通过这个实例可以实现对HttpContext对象的访问,反之,HttpContext对象实现了ISession接口,由于session不提供持久的服务所以这种间接的实现方法是必须的.
注册服务
下一步是为Cart类创建一个服务,我的目标是通过SessionCart的自动感知特性无缝实现对于Cart类的请求,你可以在Listing 10-2中看我是如何创建这个服务的
Listing 10-2. Creating the Cart Service in the Startup.cs File in the SportsStore Folder
services.AddScoped<Cart>(sp => SessionCart.GetCart(sp)); services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddMvc();
在AddMvc方法上增加上面的两行代码,
在AddScoped方法中指定处理相关请求(我理解就是同一个人操作购物车)需要用相同的对象(即同一个购物车),这个相关的请求是可以配置的,(比如同一个账号在不同客户端的登录),但是默认的,这意味着购物车处理相同的HTTP请求,依赖注入的对象是同一个,
我没有像Repository那样提供给AddScoped方法一个类型映射,我通过指定了一个lambda表达式通过Cart类被调用时注入,表达式接受注册的服务集合并通过SessionCart类的GetCart方法得到合适的Cart对象,结果是对于Cart 服务的请求创建了SessionCart对象,而SessionCart对象具有当自己被修改时自动序列化为session,
我同时也用AddSingleton方法添加了服务,它确保了必须采用相同的对象,创建这个服务是为了告诉MVC当IHttpContextAccessor接口被请求时调用HttpContextAccessor类,为了实现我通过SessionCart类访问当前的session采用这个服务是必须的.
简化Cart控制器类
创建这种服务的好处是我可以简化Cart控制器类,我利用新创建的服务重新设计了Cart控制器类,具体见Listing 10-3
Listing 10-3. Using the Cart Service in the CartController.cs File in the Controllers Folder
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using SportsStore.Infrastructure; 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 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 }); } public IActionResult Index(string returnUrl) { return View(new CartIndexViewModel { Cart = cart, ReturnUrl = returnUrl }); } } }
CartController类通过声明构造函数参数来指示它需要Cart对象,这让我可以删除从会话和步骤中读取和写入数据的方法以及需要写入session的变更的方法,其结果是Cart类简化了,同时它依然专注于他在应用中的角色,而不是怎样创建Cart对象并且持久的保存数据,另外,由于服务在应用开始启动后一直是可用的,任何其他组件均可以通过这种技术获取用户的购物车.
完成购物车功能
现在我已经介绍完了Cart 服务,是时候通过添加两个特性来完成购物车的功能了,第一,我们允许用户从他的购物车删除一些他不想买的物品,第二,我们应该在页面顶端显示以下购物车的摘要(比如总共寄件物品,需要多少money)
从购物车删除项目
我已经在控制器类中定义并测试了移除项目的方法,所以让用户实现此方法的关键就是创建一个视图可以访问此方法,我将在购物车的每个项目上增加一个移除按钮,Listing 10-4 展示了实现此效果的更改
Listing 10-4. Introducing a Remove Button to the Index.cshtml File in the Views/Cart Folder
@model CartIndexViewModel <h2>Your Cart</h2> <table class=" table table-bordered table-striped"> <thead> <tr> <td>Quantity</td> <td>Item</td> <td class=" text-right">Price</td> <td class=" text-right">Subtotal</td> </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元素,其中一个是定位物品的ProductID,另一个则是跳转的链接地址,还有一个提交表单的按钮,
你可以通过运行应用来看下这个按钮的效果,先往购物车增加物品,然后点击移除按钮,就像你看到的 Figure 10-1
Figure 10-1. Removing an item from the shopping cart
添加购物车摘要小组件
我已经有了一个实现功能的购物车,但是他的集成界面还存在一点问题,顾客只能通过查看购物车摘要页面才能知道自己选购了什么商品,并且用户只能通过添加物品到购物车才能访问购物车摘要页面,
为了解决这个问题,我将要添加一个小组件,可以摘要显示购物车的内容,并且可以通过单击在整个应用程序内随时访问购物车的内容,这个实现的方式和添加上图左侧导航栏的方式类似,通过输出视图组件,其可以包含在共享的布局页中,
添加Font Awesome包
作为购物车摘要的一部分,我要显示一个允许用户结账的按钮而不是在按钮中显示 Check out 文字,我想采用购物车的符号,因为我没有艺术技巧来绘制这个按钮,我要用Font Awesome包来实现这个功能,这是个可以集成到应用字体库的一个卓越的开源图标库,其中包含的每个字符都是不同的图像,你可以通过访问http://fortawesome.github.io/Font-Awesome来了解关于Font Awesome的更多信息,比如它包含有什么图像,
在右侧的解决方案资源管理器中找到bower.json,并编辑如下Listing 10-5
Listing 10-5. Adding the Font Awesome Package in the bower.json File in the SportsStore Folder
{
"name": "asp.net",
"private": true,
"dependencies": {
"bootstrap": "v4.0.0-alpha.6",
"font-awesome": "Font-Awesome#v4.7.0"
}
}
当bower.json保存后,vs将会自动下载这个包并安装它,安装目录在 wwwroot\lib\font-awesome,
创建视图组件类和视图
我在Components文件夹下新建了CartSummaryViewComponent.cs的文件用于定义视图组件.参看Listing 10-6
Listing 10-6. The Contents of the CartSummaryViewComponent.cs File in the Components Folder
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对象,结果是通过Cart对象传递给View实现了简单的视图组件类,他通过HTML布局被Layout调用呈现出来,我通过创建 Views/Shared/Components/CartSummary 文件夹,并在其中添加一个Default.cshtml的视图文件来实现Layout的搭建,其内容如下Listing 10-7
Listing 10-7. The Default.cshtml File in the Views/Shared/Components/CartSummary Folder
@model Cart
<div class="">
@if (Model.Lines.Count() > 0)
{
<small class=" navbar-text">
<b>
Your cart:
</b>
@Model.Lines.Sum(x => x.Quantity) itsm(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包中的购物车图标显示了一个按钮,如果购物车中存在物品,提供一个显示购物车内总物品数量和总钱数的快照,现在我有一个视图组件和一个视图,我可以修改共享布局,以便购物车组件包含在应用程序生成的响应式页面中,就像Listing 10-8 中看到的这样
Listing 10-8. Adding the Cart Summary in the _Layout.cshtml File in the Views/Shared Folder
<!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*" />
<link rel="stylesheet" asp-href-include="/lib/fontawesome/css/*.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>
你可以通过启动应用看一下购物车组件的效果,当购物车是空的时候,只有结账按钮是可以看见的,当你添加了一些物品到购物车内,那么物品的数量和总价将会显示在购物车组件中,如图Figure 10-2 所示,通过这种改变,用户知道他的购物车里有什么,并且非常清晰的知道他在购物车的物品的数量和总价,注意在引用css文件时,要对应目录下的文件夹,比如我本机上的文件夹是font-awesome而不是fontawesome
Figure 10-2. Displaying a summary of the cart
提交订单
在Models文件夹中增加一个名称为 Order.cs 的类,然后对它的内容进行更改,如下面Listing 10-9 所示,我将用这个类完成处理客户的订单详细信息,
Listing 10-9. The Contents of the Order.cs File in the Models Folder
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; }
}
}
这里我用了System.ComponentModel.DataAnnotations命名空间下的验证特性,我将会在第27章讲解验证特性(数据注解).
我也使用了BindNever特性,这会阻止用户通过HTTP请求为这些属性提供值,这是模型绑定系统的特性,我将在第26章阐述,它会阻止MVC用HTTP请求或者提供的值来填充敏感和重要的模型属性.
增加结算流程
为了允许用户添加他们的收件地址完成提交他们的订单,我们需要在购物车摘要视图中增加一个结算按钮,更改Views/Cart/Index.cshtml文件如Listing 10-10 所示
Listing 10-10. Adding the Checkout Now Button to the Index.cshtml File in the Views/Cart Folder
<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方法的按钮,我接线来将会创建这个控制器和Checkout方法,你可以先看下这个按钮已经出现在了购物车摘要页面中
Figure 10-3. The Checkout button
现在需要定义这个按钮功能的控制器,在Controllers文件夹中新建一个OrderController.cs的控制器类,其内容如Listing 10-11所示,
Listing 10-11. The Contents of the OrderController.cs File in the Controllers Folder
using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
namespace SportsStore.Controllers
{
public class OrderController : Controller
{
public ViewResult Checkout() => View(new Order());
}
}
这个方法返回了一个包含新订单的视图,为了显示Order类对应的内容,在Views/Order文件夹下创建一个Checkout.cshtml的视图文件,如Listing 10-12所示
Listing 10-12. The Contents of the Checkout.cshtml File in the Views/Order Folder
@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 this items
</label>
</div>
<div class="text-center">
<input class="btn btn-primary" type="submit" value="Complete Order" />
</div>
</form>
对于每一个模型中的属性,分别创建了一个label元素和input元素,其中input用于采集用户的输入内容,asp-for特性是taghelper内建的,可以利用他生成对于 id name value基于特殊的模型属性,我将在第24章阐述它,
现在可以启动应用程序,看一下效果了,在页面顶端点击购物车图标按钮,然后点击 checkout 按钮,效果如图Figure 10-4所示,当然,你直接输入url 导航到 /Cart/Checkout 也可以,
Figure 10-4. The shipping details form
实施订单处理
接下来通过写入数据库进行订单处理,大部分电子商务网站并不是如此简单就算OK的,比如我并没有提供用户处理信用卡支付或者其他支付方式的支持,但这些没这么重要,我们现在集中精力放在示例程序本身(MVC实现上)即可,所以该处理数据库了,
扩展数据库
给数据库添加一个新的模型对应的表是很简单的,就像我第8章做过的那样,第一步,增加一个order属性给数据上下文,就像Listing 10-13看到的这样
Listing 10-13. Adding a Property in the ApplicationDbContext.cs File in the Models Folder
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{
}
public DbSet<Product> Products { get; set;}
//--------------------------------------------------------
public DbSet<Order> Orders { get; set; }
//--------------------------------------------------------
}
这个改变对于实体框架利用Order对象存储在数据库中创建一个数据迁移已经足够了,打开命令提示符或Powershell窗口,导航到SportsStore项目目录文件夹,(就是包含Startup.cs文件的那个文件夹),然后运行以下命令;
dotnet ef migrations add Orders
这条命令告诉实体框架模型给应用程序数据库创建一个新的快照,先找到模型类与之前的数据库有什么不同,然后创建一个迁移叫做Orders,应用程序启动时,将会自动应用这个迁移,因为SeedData会调用实体框架迁移的方法.
重置数据库
当你频繁的更换数据模型的时候,反复的迁移可能出现数据不同步的问题,比较好的解决方案就是删除了数据库重新开始,然而幸运的是,这种情况只出现在您的开发阶段,所以重置数据库导致的所有的数据丢失也没有什么问题,删除数据库的命令如下
dotnet ef database drop --force
当数据库删除后,运行以下命令既可以从新创建数据库并应用您之前所创建的迁移
dotnet ef database update
这将会重新准确反应您模型的数据库,你可以继续开发您的应用程序了
创建订单存储库
我将按照产品存储库的模式构建订单存储库,提供对订单对象的访问,在Models文件夹总新建一个叫做 IOrderRepository.cs的文件,其定义如Listing 10-14所示
Listing 10-14. The Contents of the IOrderRepository.cs File in the Models Folder
using System.Linq;
namespace SportsStore.Models
{
public interface IOrderRepository
{
IQueryable<Order> Orders { get; }
void SaveOrder(Order order);
}
}
要实现这个订单存储库接口,我在Models文件夹新建EFOrderRepository.cs文件,其内容如Listing 10-15 所示
Listing 10-15. The Contents of the EFOrderRepository.cs File in the Models Folder
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();
}
}
}
这个类利用实体框架实现了接口IOrderRepository,实现了对订单对象的存储更改,检索,
理解订单存储库
要实现Listing 10-15的订单存储库还需要做一点额外工作,实体如果使用多个表,实体框架需要指令来加载相关数据,就像你看到的那样,当从数据库中读取Order时我使用了Include和ThenInclude方法来指定相关表,当加载Order对象时,与Lines相关联的结合和与Product相关联的结合都加载了.
...
public IQueryable<Order> Orders => context.Orders
.Include(o => o.Lines)
.ThenInclude(l => l.Product);
...
这就确保了我可以不通过查询语句直接查询数据库就可以了得到所有的订单对象数据,
在我存储一个对象到数据库中时需要一个附件步骤,当用户的购物车数据从session 存储中解析出来后,JSON包创建了一个新的对象然而实体框架却并不知道,它会试着往数据库写入所有的数据,对于产品信息也是这样,这意味着实体框架要去写入已经存在于数据库的数据,这样会引起错误,为了避免这种情况,我通知了实体框架那些对象已经存在就不要在写入数据库了,除非他们有所改动,像下面这样
...
context.AttachRange(order.Lines.Select(l => l.Product));
...
这确保实体框架不会试着写入与订单相关的已经存在于数据库的产品对象,
要让他生效,需要在StartUp类的ConfigureSerives方法中注册订单存储库,像Listing 10-16这样
Listing 10-16. Registering the Order Repository Service in the Startup.cs File in the SportsStore Folder
public void ConfigureServices(IServiceCollection services)
{
services.AddMemoryCache();
services.AddSession();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddTransient<IProductRepository, EFProductRepository>();
//------------------------------------------------------------------------------
services.AddTransient<IOrderRepository, EFOrderRepository>();
//------------------------------------------------------------------------------
services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddMvc();
}
完成订单控制器(OrderController)
为了完成这个订单控制器类,我需要修改构造函数,以便他可以从服务中接受订单对象,我还要增加一个动作方法,它可以处理用户点击完成订单的按钮,这些变更参考Listing 10-17
Listing 10-17. Completing the Controller in the OrderController.cs File in the Controllers Folder
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方法用httppost 修饰,这确保了这个方法只能被POST请求调用,当用户提交表单时,通过模型绑定系统接受提交的Order对象数据,联合购物车中的数据存储到存储库中.
MVC会检查我在Order类中通过数据注解定义的验证约束,任何验证问题都会通过ModelState验证传递给方法,我们可以通过检查ModelState的IsValid属性是否通过来发现提交的数据是否合法,如果验证数据不通过,那么我们通过调用ModelState.AddModelError方法给用户注册一个错误信息,我很快会解释如何显示这个错误,关于更多模型绑定的内容我会在第27章和28章来详细说明,
单元测试:订单
要对OrderController类执行单元测试,我需要测试POST版本的行为Checkout方法。 虽然该方法看起来简短,但使用MVC模型绑定意味着需要测试的幕后有很多事情发生。我只想在购物车中有物品并且客户提供有效物品时才处理订单送货细节。 在所有其他情况下,应向客户显示错误。 这里是第一个测试方法,我在SportsStore.Tests中创建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从未被调用,该方法返回的视图是默认视图(将重新显示客户输入的数据并让他们有机会更正),传递给视图的模型状态已经标记为无效,我一共做了三个断言测试确保结果的准确性,下一个测试方法基本相同,我在视图模型中注入了一个错误用模型绑定报告这个问题,(当客户在视图的input中输入无效的数据是就会引发)
...
[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);
}
...确认购物车是空的或者用户填写的信息不合法则会阻止订单处理,当一切OK的时候就可以处理订单了,下面是测试程序
[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 redirecting to the completed action Assert.Equal("Completed", result.ActionName); }
上面这段程序可以加到刚刚测试程序的OrderControllerTests.cs中,
我不需要测试我可以识别的有效信息,这一部分是通过Order类中的模型绑定属性的特性来自动完成的.
显示验证错误
MVC会利用Order类中的验证特性来验证用户数据,然而,我需要做一些简单的改变去显示这些问题,这需要依赖于另一个内建的验证助手验证数据,并为每个出现的问题提供警告信息,Listing 10-18 展示了通过标签验证助手 Tag Helper添加到HTML的元素中,如下Checkout.cshtml显示
Listing 10-18. Adding a Validation Summary to the Checkout.cshtml File in the Views/Order Folder
@model Order
<h2>Check out now</h2>
<p>Please enter your details, and we'll ship your goods right away!</p>
<div asp-validation-summary="All" class="text-danger"></div>
<form asp-action="Checkout" method="post">
<h3>Ship to</h3>
...
经过这个简单的改变,验证的错误将会显示给用户,为了看效果,访问 /Order/Checkout URL,在购物车是空的或者填写一些订单信息的情况下,标签助手 Tag Helper会生成这些错误提示信息,如图Figure 10-5 所示 更详细的内容我会在第27节说明
Figure 10-5. Displaying validation messages
提示:这些数据会在验证之前就提交到服务器端(因为验证时服务器端进行的),MVC的服务器端具有非常出色的验证功能,问题是服务器端的验证需要客户把数据提交提交到服务器然后才能生成验证信息(即使信息是非法的),另外如果在一个比较繁忙的服务器上,这个过程可能会花费一些时间,基于这些原因,服务器端的验证往往作为客户端验证的补充,客户端采用JavaScript验证,这个过程是在数据发送到服务器端之前,我会在第27章的客户端验证来说明这一部分
显示摘要页面
为了完成订单处理,需要增加一个视图当浏览器导航到Order控制器的Completed方法时调用,增加一个Razor视图Completed.cshtml在Views/Order文件夹中,其内容显示如下Listing 10-19
Listing 10-19. The Contents of the Completed.cshtml File in the Views/Order Folder
<h2>Thanks!</h2>
<p>Thanks for placing your order.</p>
<p>We'll ship your goods as soon as possible.</p>
我不需要做代码更改就可以把这个视图集成到应用程序中,因为我已经在操作方法中添加了必要的代码,现在客户从选择产品,提交订单,添加运输信息都可以完成,点击完成后将会显示如下图Figure 10-6 的信息
Figure 10-6. The completed order summary view
总结
现在已经完成了SportsStore面向客户的所有主要部分,当然对于亚马逊这样的网站这些还远远不够,然而我现在有一个可以按照产品类别筛选的产品列表,一个整洁优雅的购物车和一个简约的订单处理,
通过采用分离良好的架构意味着可以对应用程序的任何部分进行轻松更改而不用担心会引起任何问题,举个例子,我可以改变点单的存储方式,而不会影响到购物车或者产品目录,或应用程序的其他部分,在下一章我将会给应用程序添加管理功能.
本章到此结束,许多地方还存在各种问题,语义不清,毕竟本人英语水平提升空间较大,还请包涵,后续有时间的话会进行校对优化.
2019年2月6日16:58:48 星期三 家