本文共 19901 字,大约阅读时间需要 66 分钟。
本文将继续构建 SportsStore 示例应用程序。在上一章中,添加了对购物车的基本支持,现在打算改善并完成其功能。
1.使用模型绑定
MVC 框架使用了一个叫作“模型绑定”的系统,以便通过 HTTP 请求来创建一些 C# 对象,目的是把它们作为参数值传递给动作方法。例如,MVC 处理表单的方式就是这样。框架会考察目标动作方法的参数,并用一个模型绑定器来获取表单中 input 元素的值,并把它们转换成同名的参数类型。
模型绑定器能够通过请求中可用的信息来创建 C# 类型,这是 MVC 框架的核心特性之一。本节将创建一个自定义模型绑定器来盖上 CartController 类。
人们喜欢使用 Cart 控制器中的会话状态特性来存储和管理 Cart 对象,但却不喜欢它要采取的工作方式。它不符合本应用程序模型的其余部分,而那是基于动作方法参数的(因为动作方法参数的操作以模型为基础,而会话状态的操作需要设置键值对,两者的工作方式不一致)。另外。除非模仿基类的 Session 参数,否则不能适当的对 CartController 类进行单元测试,而这意味着需要模仿 Controller 类(控制器的基类),以及其他一些不希望处理的东西。
为了解决这一问题,我们打算创建一个自定义模型绑定器,以获得包含在会话数据中的 Cart 对象(注意,常规的模型绑定器能够直接处理请求中的数据来创建模型对象,这里创建自定义绑定器的目的是为了处理会话中的数据,手工用会话数据创建 Cart 对象)。然后,MVC 框架能够创建 Cart 对象,并把它们作为参数传递给 Controller 类的动作方法。
创建自定义模型绑定器
通过实现 IModelBinder 接口,可以创建一个自定义模型绑定器。在 SportsStore.WebUI 项目中新建文件夹“Binders”,并新建类文件 CartModelBinder.cs ,代码如下:
using SportsStore.Domain.Entities;using System.Web.Mvc;namespace SportsStore.WebUI.Binders{ public class CartModelBinder : IModelBinder { private const string sessionKey = "Cart"; public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) { //通过会话获取 Cart Cart cart = (Cart)controllerContext.HttpContext.Session[sessionKey]; //若会话中没有 Cart ,则创建一个 if (cart == null) { cart = new Cart(); controllerContext.HttpContext.Session[sessionKey] = cart; } //返回 cart return cart; } }}
这些 IModelBinder 接口定义了一个方法: BindModel 。所提供的两个参数使得创建域模型对象成为可能。 ControllerContext 对控制器类所具有的全部信息提供了访问,这些信息包含了客户端请求的细节。 ModelBindingContext 提供了要求建立的模型对象的信息,以及使绑定更易于处理的工具。
对于本例的目的而言,所关心的是 ControllerContext 类,它具有 HttpContext 属性,它又相应地有一个 Session 属性,该属性能够获取和设置会话数据。通过读取会话数据的键值可以获取 Cart,而在会话中还没有 Cart 时,又可以创建一个 Cart 。
需要告诉 MVC 框架,它可以使用 CartModelBinder 类来创建 Cart 的实例。这需要在 Global.asax 的 Application_Start 方法中进行注册:
using SportsStore.Domain.Entities;using SportsStore.WebUI.Binders;using SportsStore.WebUI.Infrastructure;using System.Web.Http;using System.Web.Mvc;using System.Web.Optimization;using System.Web.Routing;namespace SportsStore.WebUI{ public class MvcApplication : System.Web.HttpApplication { protected void Application_Start() { AreaRegistration.RegisterAllAreas(); WebApiConfig.Register(GlobalConfiguration.Configuration); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); BundleConfig.RegisterBundles(BundleTable.Bundles); ControllerBuilder.Current.SetControllerFactory(new NinjectControllerFactory()); ModelBinders.Binders.Add(typeof(Cart), new CartModelBinder()); } }}
现在可以更新 CartController 类,删除 GetCart 方法而依靠现在的模型绑定器,MVC 框架会自动地应用它,代码如下:
using SportsStore.Domain.Abstract;using SportsStore.Domain.Entities;using SportsStore.WebUI.Models;using System;using System.Collections.Generic;using System.Linq;using System.Web;using System.Web.Mvc;namespace SportsStore.WebUI.Controllers{ public class CartController : Controller { private IProductRepository repository; public CartController(IProductRepository repo) { repository = repo; } public RedirectToRouteResult AddToCart(Cart cart, 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 RedirectToRouteResult RemoveFromCart(Cart cart, 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 ViewResult Index(Cart cart, string returnUrl) { return View(new CartIndexViewModel { Cart = cart, ReturnUrl = returnUrl }); } }}
上面代码删除了 GetCart 方法,并对每个动作方法添加了 Cart 参数。当 MVC 框架接收到一个请求,比如,要求调用 AddToCart 方法时,会首先考察动作方法的参数,然后考察可用的绑定器列表,并试图找到一个能够创建每个参数类型实例的绑定器。这会要求自定义绑定器创建一个 Cart 对象,而这时通过利用会话状态特性来完成的。通过自定义绑定器的默认绑定器,MVC 框架能够创建一组调用动作方法所需要的参数,这让开发者能够重构控制器,以便在接收到请求时知道如何创建 Cart 对象。
像这样使用自定义模型绑定器有几个好处。第一个好处是把用来创建 Cart 与创建控制器的逻辑分离开来了,这让开发者能够修改存储 Cart 对象,而不需要修改控制器。第二个好处是任何使用 Cart 对象的控制器类,都能够简单地把这些对象声明为动作方法参数,并能够利用自定义模型绑定器。第三个好处是它能够对 Cart 控制器进行单元测试,而不需要模仿大量的ASP.NET 通道。
2.完成购物车
前面已经介绍了自定义模型绑定器,现在到了添加两个新特性来完成购物车功能的时候了。第一个特性将允许客户删除购物车物品,第二个特性将在页面的顶部显示购物车的摘要。
2.1 删除购物车物品
前面已经定义了控制器中的 RemoveFromCart 动作方法,因此,让客户删除物品只不过是在视图中将这个方法暴露出来的事情。本文打算在购物车摘要的每一行中添加一个“Remove”按钮来做这件事。对 Views/Cart/Index.cshtml 所做的修改如下:
@foreach (var line in Model.Cart.Lines) { @line.Quantity @line.Product.Name @line.Product.Price.ToString("c") @((line.Quantity * line.Product.Price).ToString("c")) @using (Html.BeginForm("RemoveFromCart", "Cart")) { @Html.Hidden("ProductId", line.Product.ProductID) @Html.HiddenFor(x => x.ReturnUrl) } }
注:可以使用强类型的 Html.HiddenFor 辅助器方法,为 ReturnUrl 模型属性创建一个隐藏字段,但这需要使用基于字符串的 Html.Hidden 辅助器方法,对 ProductID 字段做同样的事情。 如果写成“Html.HiddenFor(x=>line.Product.ProductID)”,该辅助器方法便会渲染一个以“line.Product.ProductID”为名称的隐藏字段。该字段名与 CartController.RemoveFromCart 动作方法的参数名不匹配,这会使默认的模型绑定器无法工作,因此 MVC 框架便不能调用此方法了。
运行应用程序,可以看到效果:
2.2 添加购物车摘要
现在已经实现了一个功能化的购物车,但将该购物车集成到解密的方式还存在一个问题:只有通过查看购物车摘要屏幕,客户才能知道他们的购物车里有些什么。而且,他们只能通过把一个新的物品加入购物车,才能看到购物车的摘要屏幕。
为了解决这一问题,本节打算添加一个小部件,它汇总购物车的内容,并能够通过点击来显示购物车内容。下面将采用与添加导航不见十分相似的方式来完成这一工作——作为一个动作,把它的输出注入到 Razor 布局。
首先,需要对 CartController 类添加一个简单的方法,代码如下:
public PartialViewResult Summary(Cart cart) { return PartialView(cart); }
可以看出,这是一个简单的方法。它只需要渲染一个视图,以当前 Cart (它是自定义模型绑定器获得的)作为视图数据。还需要一个分部视图,它在对这个 Summary 方法调用做出响应时被渲染。添加对应的 Summary 视图文件,代码如下:
@model SportsStore.Domain.Entities.Cart@Html.ActionLink("Checkout", "Index", "Cart", new { returnUrl = Request.Url.PathAndQuery }, null)
这是一个简单的视图,它显示了购物车的物品数、这些物品的总费用,以及把购物车内容显示给用户的一个链接。现在,已经定义了有 Summary 动作方法所返回的这个视图,可以在 _Layout.cshtml 文件中包含它的渲染结果,代码如下:
@ViewBag.Title @{Html.RenderAction("Summary", "Cart");}SPORTS STORE@{Html.RenderAction("Menu", "Nav");}@RenderBody()
最后一步是添加一些 CSS规则,对该分部视图中的元素进行格式化。对 SportsStore.WebUI 项目中的 Site.css 文件添加样式文件如下:
div#cart { float:right;margin:.8em;color:silver;background-color:#555;padding:.5em .5em .5em 1em;}div#cart a { text-decoration:none;padding:.4em 1em .4em 1em;line-height:2.1em;margin-left:.5em;background-color:#333;color:white;border:1px solid black;}
运行程序即可以看到效果,对购物车添加物品时,物品数以及总费用都会增加,如下图所示:
利用这个附件,现在可以让客户知道自己的购物车中有什么。这个附件也显示的提供了一个结算离店的办法。从中再一次看到用 RenderAction 把一个动作方法所渲染的输出组合到一个 Web 页面是多么容易。这是将应用程序功能分解成清晰可重用模块的一种很好的技术。
3.递交订单
现在到了显示 SportsStore 最后一个客户特性的时候了:结算并完成订单的能力。下面将扩充域模型,以提供收集用户送货细节的支持,并添加一个处理这些细节的特性。
3.1 扩充域模型
在 SportsStore.Domain 项目的 Entities 文件夹中新建类 ShippingDetails 。这是用来表示客户送货细节的类,具体代码如下:
using System.ComponentModel.DataAnnotations;namespace SportsStore.Domain.Entities{ public class ShippingDetails { [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 命名空间的验证注解属性,正如之前文章 所做的那样。
3.2 添加结算过程
本例的目的是到达一个程序节点,以此作为用户输入其送货细节并递交订单的入口。为此,需要在购物车摘要视图上添加一个“Checkout now”按钮。修改 Views/Cart/Index.cshtml 文件,修改代码如下:
...Continue shopping @Html.ActionLink("Checkout now", "Checkout")
...
黄色为修改部分,这一修改生成了一个链接,点击这个链接时,调用 Cart 控制器的 Checkout 动作方法。显示效果如下:
现在需要在 CartController 类中定义这个 Checkout 方法:
public ViewResult Checkout(){ return View(new ShippingDetails());}
此 Checkout 方法返回默认视图,并传递一个新的 ShippingDetails 对象作为视图模型。为了创建相应的视图文件 Views/Cart/Checkout.cshtml ,视图内容如下:
@model SportsStore.Domain.Entities.ShippingDetails@{ ViewBag.Title = "SportsStore: Checkout";}Check out now
Please enter your details, and we'll ship your goods right away!@using (Html.BeginForm()){Ship to
Name:@Html.EditorFor(x => x.Name)Address
Line 1: @Html.EditorFor(x => x.Line1)Line 2: @Html.EditorFor(x => x.Line2)Line 3: @Html.EditorFor(x => x.Line3)City: @Html.EditorFor(x => x.City)State: @Html.EditorFor(x => x.State)Zip: @Html.EditorFor(x => x.Zip)Country: @Html.EditorFor(x => x.Country)Options
}
运行程序,可以看到该视图是如何渲染的,效果如下图所示,该视图为收集客户的送货细节渲染了一个表单。
本例用 Html.EditorFor 辅助器方法为每个表单字段渲染了一个 input 元素。该方法是模板化辅助器方法的一个例子(注意,对模型对象的每个属性使用的都是 Html.EditorFor 辅助器方法,并未针对各个属性的类型,去选定特定的辅助器方法)。它让 MVC 框架去决定一个视图模型属性需要采用哪种 input 元素,而不是进行明确的指定(例如,使用 Html.TextBoxFor)。
从显示效果可以看出, MVC 框架为布尔属性渲染了一个复选框,如“Gift wrap these items”选择,而对那些字符串属性渲染了文本框。
3.3 实现订单处理器
在这个应用程序中还需要一个组件,以便能够对订单的细节进行处理。为了与 MVC 模型原理保持一致,本例打算为此功能定义一个借口、编写该接口的一个实现,然后用 DI 容器 Ninject 把两者关联起来。
定义接口
在 SportsStore.Domain 项目的 Abstract 文件夹中新建接口 IOrderProcessor ,内容如下:
using SportsStore.Domain.Entities;namespace SportsStore.Domain.Abstract{ public interface IOrderProcessor { void ProcessOrder(Cart cart, ShippingDetails shippingDetails); }}
实现接口
IOrderProcessor 的实现打算采用的订单处理方式是向网站管理员发送订单右击。当然,这简化了销售过程。大多数电子商务网站不会简单的发送订单邮件,而且也没有提供信用卡处理或其他支付形式的支持,只是希望把事情维持在关注 MVC 方面,因此采用了这种发送邮件作为订单处理的方式。
在 SportsStore.Domain 项目的 Concrete 文件夹中新建类 EmailOrderProcessor,这个类使用了包含在 .NET 框架中内建的 SMTP(简单邮件传输协议)支持,以发送一份电子邮件。具体代码如下:
using SportsStore.Domain.Abstract;using SportsStore.Domain.Entities;using System.Net;using System.Net.Mail;using System.Text;namespace SportsStore.Domain.Concrete{ public class EmailSettings { public string MailToAddress = "orders@example.com"; public string MailFromAddress = "sportsstore@example.com"; public bool UserSsl = true; public string Username = "MySmtpUsername"; public string Password = "MySmtpPassword"; public string ServerName = "smtp.example.com"; public int ServerPort = 587; public bool WriteAsFile = false; public string FileLocation = @"c:\sports_store_emails"; } public class EmailOrderProcessor : IOrderProcessor { private EmailSettings emailSettings; public EmailOrderProcessor(EmailSettings settings) { emailSettings = settings; } public void ProcessOrder(Cart cart, ShippingDetails shippingInfo) { using (var smtpClient = new SmtpClient()) { smtpClient.EnableSsl = emailSettings.UserSsl; smtpClient.Host = emailSettings.ServerName; smtpClient.Port = emailSettings.ServerPort; smtpClient.UseDefaultCredentials = false; smtpClient.Credentials = new NetworkCredential(emailSettings.Username, emailSettings.Password); if (emailSettings.WriteAsFile) { smtpClient.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory; smtpClient.PickupDirectoryLocation = emailSettings.FileLocation; smtpClient.EnableSsl = false; } StringBuilder body = new StringBuilder() .AppendLine("A new order has been submitted") .AppendLine("---") .AppendLine("Items:"); foreach (var line in cart.Lines) { var subTotal = line.Product.Price * line.Quantity; body.AppendFormat("{0} x {1} (subtotal: {2:c})", line.Quantity, line.Product.Name, subTotal); } body.AppendFormat("Total order value: {0:c}", cart.ComputeTotalValue()) .AppendLine("---") .AppendLine("Ship to:") .AppendLine(shippingInfo.Name) .AppendLine(shippingInfo.Line1) .AppendLine(shippingInfo.Line2 ?? "") .AppendLine(shippingInfo.Line3 ?? "") .AppendLine(shippingInfo.City) .AppendLine(shippingInfo.State ?? "") .AppendLine(shippingInfo.Country) .AppendLine(shippingInfo.Zip) .AppendLine("---") .AppendFormat("Gift wrap: {0}", shippingInfo.GiftWrap ? "Yes" : "No"); MailMessage mailmessage = new MailMessage( emailSettings.MailFromAddress,//Form emailSettings.MailToAddress,//To "New order submitted!",//Subject body.ToString());//Body if (emailSettings.WriteAsFile) { mailmessage.BodyEncoding = Encoding.ASCII; } smtpClient.Send(mailmessage); } } }}
为了使事情更简单些,也定义了 EmailSettings 类。 EmailOrderProcessor 的构造器需要这个类(EmailSettings 类)的一个实例,该实例包含了配置 .NET 邮件类所需要的全部设置信息。
提示:如果没有可用的 SMTP 服务器也没关系,可以将 EmailSettings.WriteAsFile属性设置为true,这样会把邮件消息作为文件写到由 FileLocation 属性指定的目录。该目录必须依据存在且是可写入的。邮件文件的扩展名将为 .eml ,但它们可以被任何文本编辑器所读取。
3.4 注册(接口)实现
现在,有了 IOrderProcessor 接口的一个实现以及配置它的手段,便可以用 Ninject 来创建它的实例。编辑 SportsStore.WebUI 项目中的 NinjectController 类(在 Infrastructure 文件夹中),对 AddBindings 方法进行修改:
using Moq;using Ninject;using SportsStore.Domain.Abstract;using SportsStore.Domain.Entities;using System;using System.Linq;using System.Collections.Generic;using System.Web.Mvc;using System.Web.Routing;using SportsStore.Domain.Concrete;using System.Configuration;namespace SportsStore.WebUI.Infrastructure{ public class NinjectControllerFactory : DefaultControllerFactory { private IKernel ninjectKernel; public NinjectControllerFactory() { ninjectKernel = new StandardKernel(); AddBindings(); } protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType) { return controllerType == null ? null : (IController)ninjectKernel.Get(controllerType); } private void AddBindings() { //put bindings here ninjectKernel.Bind().To (); EmailSettings emailSettings = new EmailSettings { WriteAsFile = bool.Parse(ConfigurationManager.AppSettings["Email.WriteAsFile"] ?? "false") }; ninjectKernel.Bind ().To ().WithConstructorArgument("settings", emailSettings); } }}
上述代码创建了一个 EmailSettings 对象,将其用于 Ninject 的 WithConstructorArgument 方法,以便在需要创建一个新实例对 IOrderProcessor 接口的请求进行服务时,把它注入到 EmailOrderProcessor 构造器中。上述代码只为 EmailSettings 中一个属性 WriteAsFiles 指定了值。使用 ConfigurationManager.AppSettings 属性来读取该属性的值,这让用户能够访问已经放在 Web.config 文件中的应用程序,在应用程序配置文件 Web.config 中读取这个 WriteAsFile 属性的设置值,这也是从应用程序配置文件 Web.config 中读取某个属性设置值的方法。配置文件修改如下:
3.5 完成购物车控制器
为了完成 CartController 类,需要修改构造器,以使它要求 IOrderProcessor 接口的一个实现,并添加一个新的动作方法,它将在客户点击“Complete order”按钮时,处理 HTTP 表单的 POST 请求。
using SportsStore.Domain.Abstract;using SportsStore.Domain.Entities;using SportsStore.WebUI.Models;using System;using System.Collections.Generic;using System.Linq;using System.Web;using System.Web.Mvc;namespace SportsStore.WebUI.Controllers{ public class CartController : Controller { private IProductRepository repository; private IOrderProcessor orderProcessor; public CartController(IProductRepository repo, IOrderProcessor proc) { repository = repo; orderProcessor = proc; } public RedirectToRouteResult AddToCart(Cart cart, 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 RedirectToRouteResult RemoveFromCart(Cart cart, 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 ViewResult Index(Cart cart, string returnUrl) { return View(new CartIndexViewModel { Cart = cart, ReturnUrl = returnUrl }); } public PartialViewResult Summary(Cart cart) { return PartialView(cart); } [HttpPost] public ViewResult Checkout(Cart cart, ShippingDetails shippingDetails) { if (cart.Lines.Count() == 0) { ModelState.AddModelError("", "Sorry,your cart is empty!"); } if (ModelState.IsValid) { orderProcessor.ProcessOrder(cart, shippingDetails); cart.Clear(); return View("Completed"); } else { return View(shippingDetails); } } public ViewResult Checkout() { return View(new ShippingDetails()); } }}
可以看出,这里所添加的 Checkout 动作方法是用 HttpPost 注解属性来修饰的,这表明该方法将用于对 POST 请求的处理 —— 在此例中,这是用户递交表单的时候。再次重申,ShippingDetails 参数(这是使用 HTTP 表单数据自动创建的)和 Cart 参数(这是用自定义绑定器创建的)都要依赖于模型绑定器系统。
注:构造器中的修改迫使用户需要对 CartController 类创建的单元测试进行更新。为新的构造器参数传递 null ,便会使单元测试能够通过编译。
在上述代码中, MVC 框架会检查验证约束,这些约束是用数据注解属性而运用于 ShippingDetails 的,并通过 ModelState 属性把非法情况传递给该动作方法。因此,可以通过检查 Model.IsValid 属性来查看是否存在问题。注意,如果购物车中无物品,还调用 ModelState.AddModelError 方法注册了一条错误消息。
3.6 显示验证错误
如果客户输入了非法的错误信息,有问题的那些非法表单字段将被高亮,但没有消息被显示出来。更糟的是,如果客户试图对一个空购物车进行结算,这不会完成这个订单,但客户却根本看不到任何错误信息。为了解决这个问题,需要对视图添加一个验证摘要。下面代码显示了添加到 Checkout.cshtml 视图的内容。
...Check out now
Please enter your details, and we'll ship your goods right away!@using (Html.BeginForm()){ @Html.ValidationSummary();Ship to
Name:@Html.EditorFor(x => x.Name)...
现在,当客户提供非法送货数据或试图对空购物车进行结算时,系统会向他们显示一些有用的错误消息,如下图所示:
3.7 显示致谢界面
为了完成结算过程,需要向客户显示一个已经完成订单处理的确认页面,并感谢他们的购物。新建视图文件 Views/Cart/Completed.cshtml ,代码如下:
@{ ViewBag.Title = "SportsStore: OrderSubmitted";}Thanks!
Thanks for placing your order. We'll ship your goods as soon as possible.
现在,客户可以进行从选择产品到结算离开的整个购物过程。如果客户提供有效的的送货细节(且购物车中有物品),当他们点击“Complete order”按钮时,便会看到一个致谢页面,如下图所示: