9.1 使用模型绑定
MVC 框架利用 “模型绑定” 特性通过 HTTP 请求(尤其是 POST 请求)可以方便地创建一些 C#对象,并将它们作为参数值传递给动作方法。(这是 MVC 处理表单的方式)
MVC 框架会考察目标动作方法的参数,用一个模型绑定器(Model Binder)来获取由浏览器发送过来的表单值,并在传递给动作方法之前将它们转换成同名参数的类型。(转换类型)
模型绑定器能够通过请求中可用的各种信息来创建 C# 类型,这是 MVC 框架的核心特性之一。
本节将创建一个自定义的模型绑定器来改善 CartController 类。
(笔者喜欢使用 Cart 控制器中的 “会话状态” 特性来存储和管理第8章创建的 Cart 对象,但不喜欢它要求采取的工作方式 —— 它不符合本应用程序的其余部分,而那是基于动作方法参数的 —— 动作方法参数的操作以模型为基础,而会话状态的操作需要设置键值对,两者的工作方式不一致)
为了解决这一问题,打算创建一个自定义的模型绑定器,以获取包含在会话数据中的 Cart 对象。(注意,常规的模型绑定器能够直接处理请求中的数据来创建模型对象。这里创建自定义绑定器的目的是为了处理会话中的数据,手工- 用会话数据创建 Cart 对象)
然后,MVC 框架将能够创建 Cart 对象,并将它们作为参数传递给 CartController 类的动作方法。
(模型绑定特性的功能十分强大而灵活,第24章将更深入地探讨这一特性)
创建自定义模型绑定器
通过实现 System.Web.Mvc.IModelBinder 接口,可以创建一个自定义模型绑定器。
为创建该模型绑定器,在 WebUI 项目中添加一个 Infrastructure / Binders 文件夹,并在其中创建一个 CartModelBinder.cs 类文件。
public class CartNodelBinder : IModelBinder { private const string sessionKey = "Cart"; public object BindModel(ControllerContext cc, ModelBindingContext mbc) { //通过会话获取 Cart Cart cart = null; if(cc.HttpContext.Session != null) { cart = (Cart)cc.HttpContext.Session[sessionKey]; } //如果会话中没有Cart,则创建一个 if(cart == null) { cart = new Cart(); if(cc.HttpContext.Session != null) { cc.HttpContext.Session[sessionKey] = cart; } } //返回cart return cart; } }
IModelBinder 接口定义了一个方法:BindModel,为其提供的两个参数能够用来创建域模型对象。
通过 ControllerContext 能够访问控制器类所具有的全部信息,包括客户端请求的细节;
ModelBindingContext 能够提供的信息包括:要求你建立的模型对象以及使绑定过程更易于处理的工具。
对于目标而言,笔者所关心的是 ControllerContext 类,它具有 HttpContext 属性。HttpContext 又相应地有一个 Session 属性,该属性能够获取和设置会话数据。
通过读取会话数据的键值,可以获取与用户会话关联的 Cart 对象,并在没有会话时创建一个 Cart 对象。
下面需要告诉 MVC 框架,它可以使用 CartModelBinder 类创建 Cart 实例。(这需要在 Global.asax 的 Application_Start 方法中进行设置)
ModelBinders.Binders.Add(typeos(Cart), new CartModelBinder());
现在可以更新 CartController 类,删去 GetCart 方法,并依靠模型绑定器为控制器提供 Cart 对象。
public class CartController : Controller { private IProductsRepository repository; //带有依赖项的构造器 public CartController(IProductsRepository repo) { repository = repo; } //用来显示购物车的内容 public ViewResult Index(Cart cart,string returnUrl) { //笔者需要将两个数据片段传递给显示购物车:Cart 对象(有了模型绑定器之后可以直接在参数中声明 Cart 对象而不需要通过 GetCart 方法),以及用户点击“继续购物”按钮时要显示的 URL。 return View(new CartIndexViewModel { Cart = cart, ReturnUrl = returnUrl }); } #region 对于这两个方法,这里使用了与 HTML 表单中 input 元素相匹配的参数名(分部视图ProductSummary中的表单),这可以让 MVC框架将输入的表单的 POST变量自动与这些参数关联起来。 /* * 这两个方法都调用了 RedirectToAction 方法。(其效果是,将一个 HTTP 的重定向指令发送到客户端浏览器,要求浏览器请求一个新的 URL) * 在此例中,要求浏览器(重新)请求的 URL 是调用 Cart 控制器的 Index 动作方法。 */ public RedirectToRouteResult AddToCart(Cart cart, int id, string returnUrl) { Product product = repository.Products.FirstOrDefault(p => p.id == id); if (product != null) { cart.AddItem(product, 1); } return RedirectToAction("Index", new { returnUrl }); } public RedirectToRouteResult RemoveFromCart(Cart cart, int id, string returnUrl) { Product product = repository.Products.FirstOrDefault(p => p.id == id); if (product != null) { cart.RemoveLine(product); } return RedirectToAction("Index", new { returnUrl }); } #endregion }
当 MVC 框架接收到一个请求,比如要求调用 AddToCart 方法时,会首先考查动作方法的参数,然后考查可用的绑定器列表,并尝试找到一个能够为各个参数类型创建实例的绑定器。(于是会要求自定义绑定器创建 Cart 对象,而这是通过使用会话状态特性来完成的)
通过自定义绑定器和默认绑定器,MVC 框架能够创建一组调用动作方法所需要的参数,这让开发者能够重构控制器,以便在接收到请求时,控制器不必知道如何创建 Cart 对象。
像这样使用自定义模型绑定器有几个好处:
第一个好处是把用来创建 Cart 对象与创建控制器的逻辑分离开了,这让开发者能够修改存储 Cart 对象的方式,而不需要修改控制器;
第二个好处是任何使用 Cart 对象的控制器类,都能够简单地把这些对象声明为动作方法参数,并能够利用自定义模型绑定器;(第三个好处在于单元测试)
9.2 完成购物车
9.2.1 删除购物车物品
前面已经定义了控制器中的 RemoveFromCart 动作方法,因此,让客户删除物品只不过是在视图中将这个方法暴露出来。(在购物车摘要的每一行中添加一个 “删除” 按钮)
修改 Views /Cart / Index.cshtml 文件:
@model SportsStore.WebUI.Models.CartIndexViewModel @{ ViewBag.Title = "购物车"; } <style> #cartTable td{vertical-align:middle} </style> <h2>购物车</h2> <table id="cartTable" class="table"> <thead> <tr> <th>商品</th> <th>数量</th> <th class="text-right">单价</th> <th class="text-right">总价</th> </tr> </thead> <tbody> @foreach (var line in Model.Cart.Lines) { <tr> <td class="text-left">@line.Product.Name</td> <td class="text-center">@line.Quantity</td> <td class="text-right">@line.Product.Price.ToString("c")</td> <td class="text-right">@((line.Quantity*line.Product.Price).ToString("c"))</td> <td> @using (Html.BeginForm("RemoveFromCart","Cart")) { @Html.Hidden("id",line.Product.id) @Html.HiddenFor(x=>x.ReturnUrl) <input class="btn btn-sm btn-warning" type="submit" value="移除"> } </td> </tr> } </tbody> <tfoot> <tr> <td colspan="3" class="text-right">合计:</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">继续购买</a> </div>
这里在表格的每一行添加了一个新的表格列,这是一个带有 input 元素的 form(表单元素)。
用 Bootstrap 将 input 元素设置成按钮样式,并添加一个 style 元素,给 table 元素添加一个 id,以确保按钮和其他列的内容适当对齐。
(可以使用强类型的 Html.HiddenFor 辅助器方法,为 ReturnUrl 模型属性创建一个隐藏字段,但这必须使用基于字符串的 Html.Hidden 辅助器方法来对 id 字段做同样的事情)
9.2.2 添加购物车摘要
现在已经有了一个功能化的购物车。本节打算添加一个小部件,它汇总了购物车的内容,并在整个应用程序中都能通过单击来显示购物车的内容。
要采用的做法与添加导航部件的做法十分相似 —— 作为一个动作,把它的输出注入 Razor 布局。
首先,需要对 CartController 类添加一个简单的方法:
…… public PartialViewResult Summary(Cart cart) { return PartialView(cart); }
这个简单方法需要渲染一个视图:以当前 Cart(将用自定义模型绑定器获得)作为视图数据。
为了创建该视图,右击这个 Summary 动作方法,选择 “添加视图”,视图名设置为 “Summary”,选择模型类,勾选“分部视图”,单击“确定”按钮。
@model SportsStore.Domain.Entities.Cart <!-- 这个地方是结算,其实相当于“购物车”按钮,点击该链接(按钮)将展示购物车的详细内容 --> <div class="navbar-right"> @Html.ActionLink("结算","Index","Cart",new { returnUrl=Request.Url.PathAndQuery },new { @class="btn btn-default navbar-btn"}) </div> <!-- 这个地方是摘要,主要包括购物车中的物品数和这些物品的总价。可以删去 --> <div class="navbar-text navbar-right"> <b>购物车:</b> @Model.Lines.Sum(x=>x.Quantity) 件商品, @Model.ComputeTotalValue().ToString("c") </div>
现在已经创建了由 Summary 动作方法所返回的这个视图,可以在 _Layout.cshtml(布局页)中调用 Summary 动作方法,以显示购物车摘要(如果删去摘要部分则主要显示购物车按钮)
<!-- 要写在导航条里 --> @Html.Action("Summary","Cart")
你能够再一次看到,使用 Html.Action 辅助器方法可以很容易地将一个动作方法输出的内容添加到其他视图中。
这是将应用程序功能分解成清晰可重用模块的一种很好的技术。
9.3 递交订单
在以下章节中,将对域模型进行扩充,以便收集客户的送货细节,并对这些细节进行处理。
9.3.1 扩充域模型
在 XXX.Domain 项目的 Entities 文件夹中,添加 ShippingDetails.cs 类文件。
public class ShippingDetails { [Required(ErrorMessage ="请输入姓名")] public string Name { get; set; } [Required(ErrorMessage ="请输入第一个地址行")] [Display(Name="第一行:")] public string Line1 { get; set; } [Display(Name = "第二行:")] public string Line2 { get; set; } [Display(Name = "第三行:")] public string Line3 { get; set; } [Required(ErrorMessage = "请输入省份名称")] [Display(Name = "省份:")] public string Province { get; set; } [Required(ErrorMessage ="请输入城市名称")] [Display(Name = "城市:")] public string City { get; set; } [Display(Name = "邮编:")] public string Zip { get; set; } [Required(ErrorMessage ="请输入国家名称")] [Display(Name = "国家:")] public string Country { get; set; } /// <summary> /// 礼物是否包装 /// </summary> public bool GiftWrap { get; set; } }
9.3.2 添加结算过程
在购物车摘要视图(ViewsCartIndex.cshtml)上添加一个 “立即结算” 按钮,作为用户输入其送货细节并递交订单的入口。
<!-- 生成了一个按钮样式的链接(立即结算),单击这个链接时调用 Cart 控制器的 Checkout 动作方法 --> @Html.ActionLink("立即结算", "Checkout", null, new { @class="btn btn-primary"})
现在需要在 Cart 控制器中定义这个 Checkout 方法:
public ViewResult Checkout() { return View(new ShippingDetails()); }
此 Checkout 方法返回默认视图,并传递一个新的 ShippingDetails 对象作为视图模型。
创建该动作方法的视图,视图名称为 “Checkout”:
@model SportsStore.Domain.Entities.ShippingDetails @{ ViewBag.Title = "结算"; } <h2>结算</h2> <p>请输入您的详细信息,我们将立即发货!</p> @using (Html.BeginForm()) { <h3>收件人</h3> <div class="form-group"> <label>姓名</label> @Html.TextBoxFor(x => x.Name, new { @class="form-control"}) </div> <h3>地址</h3> foreach(var property in ViewData.ModelMetadata.Properties) { if(property.PropertyName!="Name" &&property.PropertyName!="GiftWrap") { <div class="form-group"> <label>@(property.DisplayName ?? property.PropertyName)</label> @Html.TextBox(property.PropertyName,null,new { @class="form-control"}) </div> } } <h3>操作</h3> <div class="checkbox"> <label> @Html.EditorFor(x=>x.GiftWrap) 包装这些商品 </label> </div> <div class="text-center"> <input class="btn btn-primary" type="submit" value="完成订单" /> </div> }
对于模型中的每一个属性,这里都创建一个 label 和一个 Bootstrap 格式化的 input 元素,用以捕捉用户的输入。
静态的 ViewData.ModelMetadata 属性返回一个带有视图模型类型信息的对象。
在 foreach 循环中所使用的 Properties 属性中的每一个都代表了模型类型所定义的属性。
这里使用了 PropertyName 属性,以确保不为 Name 或 GiftWrap 属性生成内容(这两者的内容在视图的其他地方处理),并且为其他所有属性生成一组元素,这些元素都带有 Bootstrap 的 class。
提示:清单中的 for 和 if 关键字位于 Razor 表达式范围内,故不需要以 @ 符号作为前缀。(事实上,要是加了 @前缀,Razor 会报错)
属性名往往不适合形成标签(这一点对中文应用程序尤其如此,因为属性名往往采用英文形式,而标签则需要显示成中文)。
在生成表单元素时,会检查是否有可用的 DisplayName 值:<label>@(property.DisplayName ?? property.PropertyName)</label>
为了利用 DisplayName 属性,需要在模型类上运用 Display 注解属性。(为 Display 注解属性设置 Name 值之后,可以在视图中通过 DisplayName 属性读取所设置的值)
9.3.3 实现订单处理器
应用程序还需要一个组件,以便能够对订单的细节进行处理。
为了与 MVC 模型原理保持一致,打算为此功能定义一个接口、编写该接口的一个实现,然后用 DI 容器(Ninject)将两者关联起来。
1、定义接口
在 XXX.Domain 项目的 Abstract 文件夹中添加 IOrderProcessor 接口:
public interface IOrderProcessor { void ProcessOrder(Cart cart, ShippingDetails shippingDetails); }
2、实现接口
IOrderProcessor 的实现打算采用的订单处理方式是向网站管理员发送订单邮件。
(大多数电子商务网站不会简单地发送订单邮件,而且这里未提供对信用卡处理或其他支付形式的支持,只是希望把事情维持在关注 MVC 方面,因此采用了这种发送邮件的形式)
在 XXX.Domain 项目的 Concreate 文件夹中创建类文件 “EnailOrderProcessor.cs”(这个类使用了包含在 .NET 框架库中的内置 SMTP,以发送一份电子邮件):
为了使事情更简单些,这里也定义了 EmailSettings 类。EmailOrderProcessor 的构造器需要 EmailSettings 类的一个实例,该实例包含了配置 .NET 邮件类所需要的全部设置信息。
9.3.4 注册接口实现
现在,可以用 Ninject 创建 IOrderProcessor 接口的实例。
修改 XXX.WebUI 项目 Infrastructure 文件夹中的 NinjectDependencyResolver.cs 文件的 AddBindings 方法:
EmailSettings emailSettings = new EmailSettings(); kernel.Bind<IOrderProcessor>().To<EmailOrderProcessor>().WithConstructorArgument("settings", emailSettings);
这里创建了一个 EmailSettings 对象,将其用于 Ninject 的 WithConstructorArgument 方法,以便在需要创建一个新实例对 IOrderProcessor 接口的请求进行服务时,把它注入到 EmailOrderProcessor 构造器之中。
9.3.5 完成购物车控制器
为了完成 CartController 类,需要修改其构造器,使其要求 IOrderProcessor 接口的一个实现,并添加一个新的动作方法,它将在客户单击 “完成订单” 按钮时,处理 HTTP 表单的 POST 请求。