一:使用模型绑定
MVC框架使用了一个叫做“模型绑定”的系统,以便通过HTTP请求来创建一些C#对象,目的是把它们作为参数值传给动作方法。例如,MVC处理表单的方式就是这样的,框架会考察目标动作方法的参数,并用一个模型绑定器来获取表单中的input元素的值,并把它们转换成同名参数类型。
模型绑定可以通过请求中可用的信息来创建C#类,这是MVC框架的核心特性之一。本文将创建一个自定义模型绑定器来改善CartController类。
创建自定义模型绑定器
通过实现IModelBinder接口,可以创建一个自定义模型绑定器。在SportsStore.WebUI项目中创建一个名为Binders的新文件夹,并在此文件夹中创建一个CartModelBinder类。如下所示。
using System; using System.Collections.Generic; using System.Linq; using System.Web.Mvc; using SportsStore.Domain.Entities; namespace SportsStore.WebUI.Binders { public class CartModelBinder:IModelBinder { private const string sessionKey = "Cart"; public object BindModel(ControllerContext controllerContext,ModelBindingContext modelBindingContext) { //通过会话获取Cart Cart cart = (Cart)controllerContext.HttpContext.Session[sessionKey]; if (cart==null) { cart = new Cart(); controllerContext.HttpContext.Session[sessionKey] = cart; } return cart; } } }
IModelBinder接口定义了一个方法:BindModel。所提供的两个参数使得创建域模型对象称为可能。ControllerContext对控制器所具有的全部信息提供了访问,这些信息包含了客户端请求的细节。ModelBindingContext提供了要求建立模型对象的信息,以及绑定更易处理的工具。
ControllerContext类具有HttpContext属性,它又有一个Session属性,该属性能够获取和设置会话数据。通过读取会话数据的键值可以获取Cart,而会话中还没有Cart时,可以创建一个。
需要告诉MVC框架,它可以用CartModelBinder类来创建Cart类,这需要在Global.asax的Application_Start方法中进行注册,如下所示。
using System.Web.Optimization; using System.Web.Routing; using SportsStore.WebUI.Infrastructure; using SportsStore.WebUI.Binders; using SportsStore.Domain.Entities; namespace SportsStore.WebUI { public class MvcApplication : System.Web.HttpApplication { protected void Application_Start() { AreaRegistration.RegisterAllAreas(); GlobalConfiguration.Configure(WebApiConfig.Register); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); BundleConfig.RegisterBundles(BundleTable.Bundles); ControllerBuilder.Current.SetControllerFactory(new NinjectControllerFctory()); ModelBinders.Binders.Add(typeof(Cart), new CartModelBinder()); } } }
现在可以更新CartController类,删掉GetCart方法而依靠现在的模型绑定器,如下所示。
private IProductRepository repository; private IOrderProcessor orderProcessor; public CartController(IProductRepository repo,IOrderProcessor proc) { repository = repo; orderProcessor = proc; } public ViewResult Index(Cart cart, string returnUrl) { return View(new CartIndexViewModel { Cart = cart, ReturnUrl = returnUrl }); } 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 }); }
现在当MVC框架接收到一个请求,比如要求调用AddToCart方法时,会首先考察动作方法的参数,然后考察可用的绑定器列表,并试图找到一个能够创建每个参数类型实例的绑定器。这会要求自定义绑定器创建一个Cart对象,而这是通过利用会话状态来完成的。
像这样使用自定义模型绑定器又几个好处。第一个好处时把用来创建Cart与创建控制器的逻辑分离开了,这让开发者能够修改存储Cart对象的方式,而不需要修改控制器。第二个好处是任何使用Cart对象的控制器都能够简单地把这些对象声明作为动作方法参数,并能够利用自定义模型绑定器。第三个好处是它现在能够对Cart控制器进行单元测试,而不需要模仿大量的ASP.NET通道。
二:完成购物车
现在添加两个新的特性来完成购物车的功能,一是允许客户删除购物车的物品,二是在页面的顶部显示购物车的摘要。
2.1删除购物车物品
前面已经定义了控制器中的RemoveFromCart动作方法,因此现在只需要在视图中将这个方法暴露出来。为购物车中每一行产品添加一个删除按钮,如下所示。
<table class="table table-hover"> <caption>详细信息</caption> <thead> <tr><th>名称</th><th>数量</th><th>价格</th><th>小计</th><th> </th></tr> </thead> <tbody> @foreach (var line in Model.Cart.Lines) { <tr> <td>@line.Product.Name</td> <td>@line.Quantity</td> <td>@line.Product.Price.ToString("c")</td> <td>@((line.Product.Price*line.Quantity).ToString("c"))</td> <td> @using (Html.BeginForm("RemoveFromCart", "Cart")) { @Html.Hidden("ProductId",line.Product.ProductID) @Html.HiddenFor(x=>x.ReturnUrl) <input class="btn btn-danger btn-sm" type="submit" value="X 删除" /> } </td> </tr> } </tbody> <tfoot> <tr> <td colspan="3" class="text-right"><strong>总计:</strong></td> <td colspan="2"><strong>@Model.Cart.ComputeTotalValue().ToString("c")</strong></td> </tr> </tfoot> </table>
运行应用程序,对购物车添加一些物品,点击删除按钮,便可以看到该按钮起作用了。
2.2添加购物车摘要
目前客户只能添加一个物品到购物车时才会进入购物车页面,知道购物车里有多少物品,价值几何,这是很不方便的。为了解决这一问题,可以添加一个小部件,它汇总购物车的内容,并且可以通过点击来显示购物车详细内容。
在CartController类中添加一个名为Summary的动作方法,如下所示。
public PartialViewResult Summary(Cart cart) { return PartialView(cart); }
再创建一个名为Summary的分部视图,视图模型选择Cart,如下所示。
@model SportsStore.Domain.Entities.Cart @if (Model.Lines.Sum(x => x.Quantity) > 0) { <ul class="nav navbar-nav navbar-right"> <li> <a href="javascript:;" data-toggle="dropdown"> @Model.Lines.Sum(x => x.Quantity) 件商品, 共计:@Model.ComputeTotalValue().ToString("c") <span class="glyphicon glyphicon-shopping-cart" aria-hidden="true"></span><b></b> </a> <ul> @foreach (var line in Model.Lines) { <li><a> @line.Product.Name x @line.Quantity 小计: @((line.Product.Price * line.Quantity).ToString("c")) </a></li> } <li></li> <li> <a href="@Url.Action("Index","Cart",new {returnUrl=Request.Url.PathAndQuery })"> <span class="glyphicon glyphicon-shopping-cart" aria-hidden="true"></span> 去结算 </a> </li> </ul> </li> </ul> }
这是利用Bootstrap的导航组件来显示购物车的物品数,总费用,点击还可以显示下拉菜单,显示详细的物品个数和价格,最后还有一个到购物车页面的链接。
接下来修改布局页_Layout.cshtml文件来包含这个视图的渲染结果,如下所示。
<div class="navbar navbar-inverse navbar-fixed-top"> <div> <div> <button type="button" data-toggle="collapse" data-target=".navbar-collapse"> <span></span> <span></span> <span></span> </button> @Html.ActionLink("运动商城", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" }) </div> <div class="navbar-collapse collapse"> <ul class="nav navbar-nav"> @{Html.RenderAction("Menu", "Nav",new { fromcontroller= ViewContext.RouteData.Values["Controller"] });} </ul> @{Html.RenderAction("Summary","Cart"); } </div> </div> </div>
现在运行应用程序,可以实时看到购物车里的情况,如下所示。
三:提交订单
现在到了实现最后一个特性的时候了,结算并完成订单。
3.1扩充域模型
在SportsStore.Domain项目的Entities文件夹下添加一个名为ShippingDetails的类,用来表示客户的配送细节,如下所示。
using System.ComponentModel.DataAnnotations; namespace SportsStore.Domain.Entities { public class ShippingDetails { [Required(ErrorMessage ="请输入姓名")] public string Name { get; set; } [Required(ErrorMessage = "请输入地址信息")] public string Line1 { get; set; } public string Line2 { get; set; } public string Line3 { get; set; } [Required(ErrorMessage = "请输入城市名")] public string City { get; set; } [Required(ErrorMessage = "请输入省份/州名")] public string State { get; set; } public string Zip { get; set; } [Required(ErrorMessage = "请输入国家名")] public string Country { get; set; } public bool GiftWrap { get; set; } } }
3.2添加结算过程
在购物车页面添加一个立即结算的按钮,以此作为用户输入配送信息并提交订单的入口。修改Views/Cart/Index.cshtml如下所示。
<div class="row text-center"> <a href="@(Model.ReturnUrl??"/")" class="btn btn-warning btn-lg">继续购物</a> @Html.ActionLink("立即结算", "Checkout", null, new {@class= "btn btn-primary btn-lg" }) </div>
点击立即结算,调用Cart控制器的Checkout方法,现在需要建一个Checkout方法,如下所示。
public ViewResult Checkout() { return View(new ShippingDetails()); }
此方法返回一个默认视图并传递一个新的ShippingDetails对象作为视图模型,创建名为Checkout的强类型视图,类型选择ShippingDetails,如下所示。
@model SportsStore.Domain.Entities.ShippingDetails @{ ViewBag.Title = "订单结算"; } <h2>订单结算</h2> <p>请输入您的地址信息,我们会尽快发货。</p> <hr /> <div> @using (Html.BeginForm()) { <div> <div class="col-sm-offset-2 col-sm-10"> <h3>发给</h3> </div> </div> <div> <label class="col-sm-2 control-label">名字</label> <div> @Html.TextBoxFor(x => x.Name, null, new { @class = "form-control", placeholder = "请输入名字" }) </div> </div> <div> <div class="col-sm-offset-2 col-sm-10"> <h3>地址</h3> </div> </div> <div> <label class="col-sm-2 control-label">地址信息1</label> <div> @Html.TextBoxFor(x => x.Line1, new { @class = "form-control", placeholder = "请输入地址信息1" }) </div> </div> <div> <label class="col-sm-2 control-label">地址信息2</label> <div> @Html.TextBoxFor(x => x.Line2, new { @class = "form-control", placeholder = "请输入地址信息2" }) </div> </div> <div> <label class="col-sm-2 control-label">地址信息3</label> <div> @Html.TextBoxFor(x => x.Line3, new { @class = "form-control", placeholder = "请输入地址信息3" }) </div> </div> <div> <label class="col-sm-2 control-label">城市名</label> <div> @Html.TextBoxFor(x => x.City, new { @class = "form-control", placeholder = "请输入城市名" }) </div> </div> <div> <label class="col-sm-2 control-label">州/省</label> <div> @Html.TextBoxFor(x => x.State, new { @class = "form-control", placeholder = "请输入州/省份名" }) </div> </div> <div> <label class="col-sm-2 control-label">邮编</label> <div> @Html.TextBoxFor(x => x.Zip, new { @class = "form-control", placeholder = "请输入邮编" }) </div> </div> <div> <label class="col-sm-2 control-label">国家</label> <div> @Html.TextBoxFor(x => x.Country, new { @class = "form-control", placeholder = "请输入国家" }) </div> </div> <div> <div class="col-sm-offset-2 col-sm-10"> <h3>可选</h3> </div> </div> <div> <div class="col-sm-offset-2 col-sm-10"> <div> <label> @Html.EditorFor(x => x.GiftWrap) 礼物包装 </label> </div> </div> </div> <div> <div class="col-sm-7 text-center"> <input type="submit" class="btn btn-default" value="提交" /> </div> </div> } </div>
运行此应用程序,向购物车中添加一个物品,点击立即结算按钮,便可以看到如下渲染结果。
3.3实现订单处理器
需要一个组件,以便对订单的细节进行处理。本例打算为此功能定义一个接口、编写该接口的一个实现,然后用DI容器Ninject把两者关联起来。
定义接口
在SportsStore.Domain项目的Abstract文件夹下新建一个名为IOrderProcessor的接口,内容如下。
using SportsStore.Domain.Entities; namespace SportsStore.Domain.Abstrace { public interface IOrderProcessor { void ProcessOrder(Cart cart, ShippingDetails shippingDetails); } }
实现接口
本例打算精简订单处理功能,实现订单处理的方式使向网站管理员发送一个邮件。在SportsStore.Domain项目的Concrete文件夹中新建一个名为EmailOrderProcessor的新类,内容如下所示。
using System.Text; using System.Net; using System.Net.Mail; using SportsStore.Domain.Entities; using SportsStore.Domain.Abstrace; namespace SportsStore.Domain.Concrete { public class EmailSettings { public string MailToAddress = "orders@example.com"; public string MailFormAddress = "sportsstore@example.com"; public bool UseSsl = 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 = @"d:\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.UseSsl; 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("有新的订单") .AppendLine("------") .AppendLine("产品:"); foreach (var line in cart.Lines) { var subTotal = line.Product.Price * line.Quantity; body.AppendFormat("{0}x{1},小计{2:c}", line.Product.Name, line.Quantity, subTotal).AppendLine(); } body.AppendFormat("订单总金额:{0:c}", cart.ComputeTotalValue()).AppendLine() .AppendLine("------") .AppendLine("配送到:") .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("礼物包装:{0}", shippingInfo.GiftWrap ? "是" : "否"); MailMessage mailMessage = new MailMessage( emailSettings.MailFormAddress, emailSettings.MailToAddress, "有新的订单", body.ToString() ); smtpClient.Send(mailMessage); } } } }
3.4注册接口实现
现在有了IOrderProcessor接口的一个实现以及配置它的手段,便可以用Ninject来创建它的实例,编辑SportsStore.WebUI项目中的NinjectControllerFctory类,对AddBindings方法进行修改,如下所示。
using System; using System.Collections.Generic; using System.Linq; using System.Configuration; using System.Web; using System.Web.Mvc; using System.Web.Routing; using SportsStore.Domain.Entities; using SportsStore.Domain.Abstrace; using SportsStore.Domain.Concrete; using Ninject; using Moq; namespace SportsStore.WebUI.Infrastructure { public class NinjectControllerFctory:DefaultControllerFactory { private IKernel ninjectKernel; public NinjectControllerFctory() { ninjectKernel = new StandardKernel(); AddBindings(); } protected override IController GetControllerInstance(RequestContext requestContext,Type controllerType) { return controllerType == null ? null : (IController)ninjectKernel.Get(controllerType); } private void AddBindings() { ninjectKernel.Bind<IProductRepository>().To<EFProductRepository>(); EmailSettings emailSettings = new EmailSettings { MailToAddress = ConfigurationManager.AppSettings["Email.MailToAddress"], MailFormAddress = ConfigurationManager.AppSettings["Email.MailFormAddress"], UserName = ConfigurationManager.AppSettings["Email.UserName"], PassWord = ConfigurationManager.AppSettings["Email.PassWord"], ServerName = ConfigurationManager.AppSettings["Email.ServerName"], WriteAsFile = bool.Parse(ConfigurationManager.AppSettings["Email.WriteAsFile"] ?? "False"), FileLocation = HttpContext.Current.Server.MapPath(ConfigurationManager.AppSettings["Email.FileLocation"]) }; ninjectKernel.Bind<IOrderProcessor>().To<EmailOrderProcessor>().WithConstructorArgument("settings", emailSettings); } } }
创建了一个EmailSettings对象,将其用于Ninject的WithConstructorArgument方法,以便需要创建一个新实例对IOrderProcessor接口的请求进行服务时,把它注入到EmailOrderProcessor的构造器中。为EmailSettings属性指定值时,使用了ConfigurationManager.AppSettings属性来读取。
3.5完成购物车控制器
现在需要修改CartContorller的构造器,以使它要求IOrderProcessor接口的一个实现,并添加一个新的动作方法,它将在客户点击提交按钮时,处理HTTP表单的POST请求,如下所示。
using System.Linq; using System.Web; using System.Web.Mvc; using SportsStore.Domain.Entities; using SportsStore.Domain.Abstrace; using SportsStore.WebUI.Models; 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 ViewResult Index(Cart cart, string returnUrl) { return View(new CartIndexViewModel { Cart = cart, ReturnUrl = returnUrl }); } 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 PartialViewResult Summary(Cart cart) { return PartialView(cart); } public ViewResult Checkout() { return View(new ShippingDetails()); } [HttpPost] public ViewResult Checkout(Cart cart,ShippingDetails shippingDetails) { if (cart.Lines.Count() == 0) ModelState.AddModelError("", "您的购物车是空的"); if (ModelState.IsValid) { orderProcessor.ProcessOrder(cart, shippingDetails); cart.Clear(); return View("Completed"); } else return View(shippingDetails); } } }
3.6显示验证错误
如果客户输入了非法的送货信息或者干脆不输入任何信息就点击提交按钮,这不会完成订单,但是客户看不到任何错误信息。为了解决这个问题,需要对视图添加一个验证摘要,如下所示。
<div> @using (Html.BeginForm()) { @Html.ValidationSummary() <div> <div class="col-sm-offset-2 col-sm-10"> <h3>发给</h3> </div> </div>
现在当客户提交非法数据时,系统将会在页面显示一些具体的错误消息,如下所示。
3.7显示致谢页面
最后需要向客户显示一个完成订单的确认页面,添加一个名为Complete的视图文件,目前不需要为视图传递任何视图模型,只是一个通知,所以很简单,内容如下。
@{ ViewBag.Title = "订单完成"; } <h2>订单已提交</h2> <hr /> 订单已成功提交,我们将尽快为您发货! <br /> <br /> <a href="/" class="btn btn-default">返回首页</a>
现在,完成了整个购物流程,可以从加入购物车到致谢页面体验下。
四:小节
本文完成了SportsStore面向客户部分的重要部件。包括一个能通过分类和页面进行浏览的产品分类、一个灵活的购物车和一个简单的结算过程。
分离良好的体系结构,意味着可以更容易地修改应用程序任何片段的行为,而不必担心会引起其他问题或矛盾。例如,对订单处理,可以把订单存到数据库,这对购物车、产品分类、或应用程序的其他区域不会产生任何影响。