使用模型绑定 MVC框架使用了模型绑定 系统,通过HTTP请求来创建C#对象 ,将他们作为参数值传递给动作方法 。
例如,MVC会考察目标动作方法的参数,用一个模型绑定器 来获取由浏览器发送过来的表单值,并在传递给动作方法之前将它们转换成同名参数的类型。
模型绑定器 能够通过请求中可用的各种信息来创建C#类 ,这是MVC框架的核心特性 之一。
创建自定义模型绑定器 通过实现System.Web.Mvc.IModelBinder接口 ,可以创建一个自定义模型绑定器。
在SportsStore.WebUI项目的Infrastructure文件夹 下,新建Binders文件夹 ,在其中创建CartModelBinder.cs 文件。
public class CartModelBinder : IModelBinder { private const string sessionKey = "Cart" ; public object BindModel (ControllerContext controllerContext, ModelBindingContext bindingContext ) { Cart cart = null ; if (controllerContext.HttpContext.Session != null ) { cart = (Cart)controllerContext.HttpContext.Session[sessionKey]; } if (cart == null ) { cart = new Cart(); if (controllerContext.HttpContext.Session != null ) { controllerContext.HttpContext.Session[sessionKey] = cart; } } return cart; } }
注意 :
IModelBinder接口 定义了一个方法:BindModel ,为其提供的两个参数能够用来创建域模型对象 。
ControllerContext 能够访问控制器类的全部信息 ,包括客户端请求的细节。
ModelBindingContext 能够提供的信息包括:要求你建立的模型对象以及使绑定过程更易于处理的工具。
修改Global.asax 的Application_Start方法 ,告诉MVC框架,使用CartModelBinder类创建Cart对象。
protected void Application_Start ( ) { AreaRegistration.RegisterAllAreas(); RouteConfig.RegisterRoutes(RouteTable.Routes); ModelBinders.Binders.Add(typeof (Cart), new CartModelBinder()); }
更新CartController类 ,删除GetCart方法 ,并依靠模型绑定器 为控制器提供Cart对象。
public class CartController : Controller { private IProductRepository repository; public CartController (IProductRepository repo ) { repository = repo; } public ViewResult Index (Cart cart, string returnUrl ) { return View(new CartIndexViewModel { ReturnUrl = returnUrl, Cart = cart }); } 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.Remove(product); } return RedirectToAction("Index" , new { returnUrl }); } }
注意 :CartController删除了GetCart方法 ,并对每个动作方法添加了一个Cart参数 。当MVC框架接收到一个请求,比如调用AddToCart方法,会先考察动作方法的参数 ,然后考察可用的绑定器列表 ,尝试找到一个能够为各个参数类型创建实例 的绑定器。
使用自定义模型绑定器的好处 :
把创建Cart对象与控制器的逻辑分离 开来,这样开发者能够修改存储Cart对象的方式,而不用修改控制器。
任何使用Cart对象的控制器类,都能够简单地把这些对象声明为动作方法参数 ,并能够利用自定义模型绑定器 。
能够对Cart控制器进行单元测试 ,而不需要模仿大量的ASP.NET通道 。
单元测试:购物车控制器 在CartTests单元测试 ,添加一些方法,测试该控制器的3个不同方面 。
AddToCart方法应该将所选的产品添加到客户的购物车
将一个产品添加到购物车之后,应该将用户重定向到Index视图。
用户随后可以返回到产品分类页面,URL应该被正确地传递给Index动作方法。
[TestClass ] public class CartTests { [TestMethod ] public void Can_Add_To_Cart ( ) { Mock<IProductRepository> mock = new Mock<IProductRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new Product { ProductID = 1 , Name = "P1" , Category = "Apples" }, }.AsQueryable()); Cart cart = new Cart(); CartController target = new CartController(mock.Object); target.AddToCart(cart, 1 , null ); Assert.AreEqual(cart.Lines.Count(), 1 ); Assert.AreEqual(cart.Lines.ToArray()[0 ].Product.ProductID, 1 ); } [TestMethod ] public void Adding_Product_To_Cart_Goes_To_Cart_Screen ( ) { Mock<IProductRepository> mock = new Mock<IProductRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new Product { ProductID = 1 , Name = "P1" , Category = "Apples" }, }.AsQueryable()); Cart cart = new Cart(); CartController target = new CartController(mock.Object); RedirectToRouteResult result = target.AddToCart(cart, 1 , "myUrl" ); Assert.AreEqual(result.RouteValues["action" ], "Index" ); Assert.AreEqual(result.RouteValues["returnUrl" ], "myUrl" ); } [TestMethod ] public void Can_View_Cart_Contents ( ) { Cart cart = new Cart(); CartController target = new CartController(null ); CartIndexViewModel result = (CartIndexViewModel)target.Index(cart, "myUrl" ).ViewData.Model; Assert.AreSame(result.Cart, cart); Assert.AreEqual(result.ReturnUrl, "myUrl" ); } }
完成购物车功能 接下来添加两个新特性来完成购物车的功能。
允许客户删除购物车的商品
在页面的顶部显示购物车的摘要
删除购物车物品 修改Views/Cart/Index.cshtml 文件,在购物车摘要的每一行中添加Remove(删除)按钮 。
@model SportsStore.WebUI.Models.CartIndexViewModel @{ ViewBag.Title = "Sports Store: Your Cart"; } <style > #cartTable td { vertical-align: middle; } </style > <h2 > Your cart</h2 > <table id ="cartTable" class ="table" > <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 > @using (Html.BeginForm("RemoveFromCart", "Cart")) { @Html.Hidden("ProductId", line.Product.ProductID) @Html.HiddenFor(x => x.ReturnUrl) <input class ="btn btn-sm btn-warning" type ="submit" value ="Remove" /> } </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 >
注意 :
使用强类型的Html.HiddenFor辅助器方法 ,为ReutrnUrl模型属性 创建一个隐藏字段。
必须对ProductID字段 使用Html.Hidden辅助器方法 ,因为该字段名与RemoveFromCart动作方法 的参数名不匹配 ,这会使默认的模型绑定器无法工作。
添加购物车摘要 添加一个小部件 ,它汇总了购物车的内容 ,并在整个应用程序中都能通过单击来显示购物车的内容。
在CartController 中添加Summary动作方法 。
public PartialViewResult Summary (Cart cart ) { return PartialView(cart); }
添加Summary视图 ,以Cart对象 作为视图数据。
@model SportsStore.Domain.Entities.Cart <div class ="navbar-right" > @Html.ActionLink("checkout", "Index", "Cart", new { returnUrl = Request.Url.PathAndQuery }, new { @class = "btn btn-default navbar-btn" }) </div > <div class ="navbar-text navbar-right" > <b > Your cart:</b > @Model.Lines.Sum(x => x.Quantity) item(s), @Model.ComputeTotalValue().ToString("c") </div >
该视图显示了购物车中的物品数量 ,物品的总价 ,以及显示购物车详情的链接 。
修改_Layout.cshtml 文件,添加使用Summary分部视图 。
<!DOCTYPE html> <html > <head > <meta charset ="utf-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <link href ="~/Content/bootstrap.css" rel ="stylesheet" /> <link href ="~/Content/bootstrap-theme.css" rel ="stylesheet" /> <title > @ViewBag.Title</title > </head > <body > <div class ="navbar navbar-inverse" role ="navigation" > <a class ="navbar-brand" href ="#" > SPORTS STORE</a > @Html.Action("Summary", "Cart") </div > <div class ="row panel" > <div id ="categories" class ="col-xs-3" > @Html.Action("Menu", "Nav") </div > <div class ="col-xs-8" > @RenderBody() </div > </div > </body > </html >
注意 :使用Html.Action辅助器方法 ,可以将一个动作方法 输出组合到其他视图 。这是将应用程序功能分解成清晰可重用模块 的一种很好的技术。
递交订单 扩充域模型 在SportsStore.Domain项目 的Entities文件夹 中,添加ShippingDetails.cs 文件,该类用于表示客户送货细节 。
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命名空间 的验证注解属性 。
ShippingDetails类 没有任何功能,因此不需要单元测试。
添加结算过程 修改Views\Cart\Index.cshtml 文件,在购物车摘要视图 添加“Checkout now(立即结算)”按钮 。
... <div class ="text-center" > <a class ="btn btn-primary" href ="@Model.ReturnUrl" > Continue shopping</a > @Html.ActionLink("Checkout now", "Checkout", null, new { @class = "btn btn-primary" }) </div > ...
接下来在CartController 中定义Checkout动作方法 。
public ViewResult Checkout ( ) { return View(new ShippingDetails()); }
Checkout动作方法 ,返回默认视图,并传递一个新的ShippingDetails对象 作为视图模型。
创建这个Checkout视图 。
@model SportsStore.Domain.Entities.ShippingDetails @{ ViewBag.Title = "SportStore: Checkout"; } <h2 > Check out now</h2 > <p > Please enter your details, and we'll ship your goods right away!</p > @using (Html.BeginForm()) { <h3 > Ship to</h3 > <div class ="form-group" > <label > Name:</label > @Html.TextBoxFor(x => x.Name, new { @class = "form-control" }) </div > <h3 > Address</h3 > <div class ="form-group" > <label > Line 1:</label > @Html.TextBoxFor(x => x.Line1, new { @class = "form-control" }) </div > <div class ="form-group" > <label > Line 2:</label > @Html.TextBoxFor(x => x.Line2, new { @class = "form-control" }) </div > <div class ="form-group" > <label > Line 3:</label > @Html.TextBoxFor(x => x.Line3, new { @class = "form-control" }) </div > <div class ="form-group" > <label > City:</label > @Html.TextBoxFor(x => x.City, new { @class = "form-control" }) </div > <div class ="form-group" > <label > State:</label > @Html.TextBoxFor(x => x.State, new { @class = "form-control" }) </div > <div class ="form-group" > <label > Zip:</label > @Html.TextBoxFor(x => x.Zip, new { @class = "form-control" }) </div > <div class ="form-group" > <label > Country:</label > @Html.TextBoxFor(x => x.Country, new { @class = "form-control" }) </div > <h3 > Options</h3 > <div class ="checkbox" > <label > @Html.EditorFor(x => x.GiftWrap) Gift wrap these items </label > </div > <div class ="text-center" > <input class ="btn btn-primary" type ="submit" value ="Complete order" /> </div > }
运行应用程序,点击页面顶部的Checkout按钮 ,再点击Checkout now按钮 ,即可看到该视图。
为了减少 Checkout视图中的重复标记 。可以获取视图模型对象的元数据 ,将它与C#和Razor的混合表达式 相结合。
修改Checkout.cshtml 文件如下。
@model SportsStore.Domain.Entities.ShippingDetails @{ ViewBag.Title = "SportStore: Checkout"; } <h2 > Check out now</h2 > <p > Please enter your details, and we'll ship your goods right away!</p > @using (Html.BeginForm()) { <h3 > Ship to</h3 > <div class ="form-group" > <label > Name:</label > @Html.TextBoxFor(x => x.Name, new { @class = "form-control" }) </div > <h3 > Address</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 > Options</h3 > <div class ="checkbox" > <label > @Html.EditorFor(x => x.GiftWrap) Gift wrap these items </label > </div > <div class ="text-center" > <input class ="btn btn-primary" type ="submit" value ="Complete order" /> </div > }
注意 :
静态的ViewData.ModelMetadata属性 返回的是一个System.Web.Mvc.ModelMetaData对象 ,该对象提供了视图的模型类型信息 。
代码中的foreach 和if关键字 在Razor表达式的范围之内,因此不需要加@前缀 。
?? 是空合并运算符 。如果DisplayName为空,则返回PropertyName,否则返回DisplayName。
修改ShippingDetails.cs 文件,在模型类上运用Display注解属性 。
[Display(Name = "Line 1" ) ] public string Line1 { get ; set ; }[Display(Name = "Line 2" ) ] public string Line2 { get ; set ; }[Display(Name = "Line 3" ) ] public string Line3 { get ; set ; }
为Display注解属性 设置Name值后,可以在视图中通过DisplayName属性 读取所设置的值。
实现订单处理器 定义接口 在SportsStore.Domain项目 的Abstract文件夹 中,添加IOrderProcessor接口 。
public interface IOrderProcessor { void ProcessOrder (Cart cart, ShippingDetails shippingDetails ) ; }
实现接口 IOrderProcessor的实现,打算采用的订单处理方式是向网站管理员发送订单邮件 。
在SportsStore.Domain项目 的Concrete文件夹 中,新建EmailOrderProcessor.cs 文件,并使用.NET框架下的SMTP 发送电子邮件。
public class EmailSettings { public string MailToAddress = "orders@example.com" ; public string MailFromAddress = "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 = @"e:\sports_store_emails" ; } public class EmailOrderProcessor : IOrderProcessor { private EmailSettings emailSettings; public EmailOrderProcessor (EmailSettings settings ) { emailSettings = settings; } public void ProcessOrder (Cart cart, ShippingDetails shipppingInfo ) { 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("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(shipppingInfo.Name) .AppendLine(shipppingInfo.Line1) .AppendLine(shipppingInfo.Line2 ?? "" ) .AppendLine(shipppingInfo.Line3 ?? "" ) .AppendLine(shipppingInfo.City) .AppendLine(shipppingInfo.State ?? "" ) .AppendLine(shipppingInfo.Country) .AppendLine(shipppingInfo.Zip) .AppendLine("---" ) .AppendFormat("Gift wrap: {0}" , shipppingInfo.GiftWrap ? "yes" : "No" ); MailMessage mailMessage = new MailMessage( emailSettings.MailFromAddress, emailSettings.MailToAddress, "New order submitted!" , body.ToString()); if (emailSettings.WriteAsFile) { mailMessage.BodyEncoding = Encoding.ASCII; } smtpClient.Send(mailMessage); } } }
注册接口实现 编辑NinjectDependencyResolver.cs 文件,修改AddBindings方法 ,配置IOrderProcessor接口 的实现。
private void AddBindings ( ) { kernel.Bind<IProductRepository>().To<EFProductRepository>(); EmailSettings emailSettings = new EmailSettings { WriteAsFile = bool .Parse(ConfigurationManager.AppSettings["Email.WriteAsFile" ] ?? "false" ) }; kernel.Bind<IOrderProcessor>().To<EmailOrderProcessor>().WithConstructorArgument("settings" , emailSettings); }
在应用程序配置文件web.config 中添加配置 。
<appSettings > <add key ="Email.WriteAsFile" value ="true" /> </appSettings >
完成购物车控制器 修改CartController ,构造器接受一个IOrderProcessor接口的实现 ,并添加新的动作方法 ,在客户单击完成订单时,处理POST请求 。
public class CartController : Controller { private IProductRepository repository; private IOrderProcessor orderProcessor; public CartController (IProductRepository repo, IOrderProcessor proc ) { repository = repo; orderProcessor = proc; } [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); } } }
注意 :
Checkout方法是用HttpPost注解属性 修饰,表明该方法用于处理POST请求 。
由于修改了构造器 ,因此需要修改CartController的单元测试 ,为构造器参数传递null,以通过编译。
MVC框架检查验证约束 ,通过ModelState属性 把非法情况传递给动作方法,并通过ModelState.IsValid属性 检查是否存在问题。可以调用ModelState.AddModelError方法 注册一条错误信息。
单元测试:订单处理 需要对Checkout重载方法进行测试。
[TestClass ] public class CartTests { [TestMethod ] public void Cannot_Checkout_Empty_Cart ( ) { Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>(); Cart cart = new Cart(); ShippingDetails shippingDetails = new ShippingDetails(); CartController target = new CartController(null , mock.Object); ViewResult result = target.Checkout(cart, shippingDetails); mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()), Times.Never()); Assert.AreEqual("" , result.ViewName); Assert.AreEqual(false , result.ViewData.ModelState.IsValid); } [TestMethod ] public void Cannot_Checkout_Invalid_ShippingDetails ( ) { Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>(); Cart cart = new Cart(); cart.AddItem(new Product(), 1 ); CartController target = new CartController(null , mock.Object); target.ModelState.AddModelError("error" , "error" ); ViewResult result = target.Checkout(cart, new ShippingDetails()); mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()), Times.Never()); Assert.AreEqual("" , result.ViewName); Assert.AreEqual(false , result.ViewData.ModelState.IsValid); } [TestMethod ] public void Can_Checkout_And_Submit_Order ( ) { Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>(); Cart cart = new Cart(); cart.AddItem(new Product(), 1 ); CartController target = new CartController(null , mock.Object); ViewResult result = target.Checkout(cart, new ShippingDetails()); mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()), Times.Once()); Assert.AreEqual("Completed" , result.ViewName); Assert.AreEqual(true , result.ViewData.ModelState.IsValid); } }
显式验证错误 MVC框架使用ShippingDetails类中的验证注解属性 来验证用户的输入,必须将问题显示给用户 ,通过高亮问题字段以及显示问题摘要来报告验证错误。
用户提交的数据在验证之前发送给服务器,经过处理并生成结果页面之后,用户才会得到错误报告(服务器端验证) 。
在Checkout.cshtml 文件中,添加验证摘要“@Html.ValidationSummary()” 。
... @using (Html.BeginForm()) { @Html.ValidationSummary() <h3 > Ship to</h3 > <div class ="form-group" > <label > Name:</label > @Html.TextBoxFor(x => x.Name, new { @class = "form-control" }) </div > ... }
创建一些CSS样式,用于用户输入非法数据时,所使用的非法元素。
在SportsStore.WebUI项目 的Content文件夹 中,添加样式表文件ErrorStyles.css 。
.field-validation-error { color : #f00 ; } .field-validation-valid { display : none; } .input-validation-error { border : 1px solid #f00 ; background-color : #fee ; } .validation-summary-errors { font-weight : bold; color : #f00 ; } .validation-summary-valid { display : none; }
在_Layout.cshtml 文件中引用ErrorStyles.css 样式文件。
<head > <meta charset ="utf-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <link href ="~/Content/bootstrap.css" rel ="stylesheet" /> <link href ="~/Content/bootstrap-theme.css" rel ="stylesheet" /> <link href ="~/Content/ErrorStyles.css" rel ="stylesheet" /> <title > @ViewBag.Title</title > </head >
显示致谢页面 结算成功之后,需要向客户显示一个已经完成订单处理的确认页面,并感谢他们购物。
在Views\Cart文件夹 中,新建Completed.cshtml 文件。
@{ ViewBag.Title = "SportsStore: Order Submitted"; } <h2 > Thanks!</h2 > Thanks for placing your order. We'll ship your good as soon as possible.
运行项目,当客户填写完运输信息,点击“Complete order”按钮时,便会看到致谢页面。