前面的文章【MVC 4】5.SportsSore —— 一个真实的应用程序 建立了 SportsStore 应用程序的核心基础框架。本文将利用这一基础框架,将一些关键特性添加到该应用程序上。
1.添加导航控件
如果让客户通过产品分类(Category)对象产品进行导航,SportsStore 应用程序会更加适用。这需要从三个方面着手。
* 增强 ProductController 类中的 List 动作类型,以使它能够过滤存储库中的 Product 对象。
* 重载考察并增强 URL 方案,并且修订路由策略。
* 创建一个产品分类列表,将其放入网站工具条,高亮当前分类,并对其他分类进行链接。
1.1 过滤产品列表
本文打算从增强视图模型类 ProductsListViewModel 开始。为了渲染工具条,需要将当前分类传递给视图,而且这是一个很好的起点。修改后代码如下:
using SportsStore.Domain.Entities; using System.Collections.Generic; namespace SportsStore.WebUI.Models { public class ProductsListViewModel { public IEnumerable<Product> Products { get; set; } public PagingInfo PagingInfo { get; set; } public string CurrentCategory { get; set; } } }
在 ProductsListViewModel 类中新添加了一个新属性 CurrentCategory 。下一步是更新 ProductController 类,以使 List 动作方法能通过分类来过滤 Product 对象,并利用这个添加到视图模型的新属性,以指示已选择了哪个分类。其修改如下所示:
using SportsStore.Domain.Abstract; 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 ProductController : Controller { private IProductRepository repository; public int PageSize = 4; public ProductController(IProductRepository productRepository) { this.repository = productRepository; } public ViewResult List(string category, int page = 1) { ProductsListViewModel model = new ProductsListViewModel { Products = repository.Products .Where(p => category == null || p.Category == category) .OrderBy(p => p.ProductID) .Skip((page - 1) * PageSize) .Take(PageSize), PagingInfo = new PagingInfo { CurrentPage = page, ItemsPerPage = PageSize, TotalItems = repository.Products.Count() }, CurrentCategory = category }; return View(model); } } }
以上代码对 List 方法做了三处修改。第一个修改是添加了新参数 category 。第二修改是使用这个额 category 参数,以使 LINQ 查询得到增强——如果 category 非空,则只选出与 Category 属性匹配的那些 Product 对象。最后一个参数是设置添加到 ProductsListViewModel 类上的 CurrentCategory 属性的值。然而,这些修改会导致不能正确地计算 PagingInfo.TotalItems 的值。
1.2 调整 URL 方案
没有人喜欢看到或使用像"/?category=Soccer" 这种丑陋的 URL,为了改变这种格式,需要重新考察之前的路由方案,以创建一种更适合的 URL 方法。为了实现这种新方案,修改 App_Start/RouteConfig.cs 文件中的 RegisterRoutes 方法如下:
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.Mvc; using System.Web.Routing; namespace SportsStore.WebUI { public class RouteConfig { public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute(null, "", new { controller = "Product", action = "List", category = (string)null, page = 1 }); routes.MapRoute(null, "Page{page}", new { controller = "Product", action = "List", category = (string)null }, new { page = @"\d+" } ); routes.MapRoute(null, "{category}", new { controller = "Product", action = "List", page = 1 } ); routes.MapRoute(null, "{category}/Page{page}", new { controller = "Product", action = "List" }, new { page = @"\d+" } ); routes.MapRoute(null, "{controller}/{action}"); } } }
注意:路由是按其定义的顺序来运用的,如果改变这种顺序,会得到奇怪的效果。
下图描述了这些路由所表示的 URL 方案。
ASP.NET 路由系统是由 MVC 用来处理客户端请求的,但它也用来获取符合 URL 方案的输出 URL,以使用户能够把这个输出 URL 嵌入在 Web 页面中。这样,就可以确保应用程序中的所有 URL 都是一致的。
Url.Action 方法是生成输出链接最方便的方法。在 【MVC 4】5.SportsSore —— 一个真实的应用程序 中,为了显示页面链接,已经在List.cshtml 视图中使用过这个辅助区方法。现在,既然已经添加对分类过滤的支持,就需要把这个信息传递给该辅助器方法,代码如下所示:
1.3 建立分类导航菜单
现在需要给客户提供一种选择一个分类的方法。即,需要展示一个可用分类的列表,并指示出它们之中哪一个是被选中的。随着对应用程序的扩建,将在多个控制器中使用这个分类列表,因此,需要确保它是自包含且可重用的。
ASP.NET MVC框架具有一种叫做“子动作”的概率,它特别适用于创建诸如可重用导航控件之类的东西。子动作依赖于叫作“RenderAction”的 HTML 辅助器方法,它让用户能够在当前视图中包含一个任意动作方法的输出。在这里,我们可以创建一个新控制器“NavController”,它带有一个动作方法“Menu”,该方法渲染一个导航菜单,并把该动作的输出注入到布局中。这种办法可以得到一个真正的控制器它能够包含用户所需的各种应用程序逻辑,并且能够像其他控制器一样被单元测试。这是在保持 MVC 整体框架的前提下,创建应用程序小型片段的一种很好的方法。
(1) 创建导航控制器
新建控制器 NavController ,模板为“空控制器”,代码如下:
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.Mvc; namespace SportsStore.WebUI.Controllers { public class NavController : Controller { public string Menu() { return "Hello from NavController"; } } }
Menu 方法返回一个静态的消息字符串,但它足以把这个子动作集成到应用程序的其余部分。由于希望分类列表出现在所有页面上,因此在布局中渲染这个子动作,而不是在一个特定的视图中进行渲染。编辑 Views/Shared/_Layout.cshtml 文件,以使它调用 RenderAction 辅助器方法,代码如下:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width" /> <title>@ViewBag.Title</title> <link href="~/Content/Site.css" type="text/css" rel="stylesheet" /> </head> <body> <div id="header"> <div class="title">SPORTS STORE</div> </div> <div id="categories"> @{Html.RenderAction("Menu", "Nav");} </div> <div id="content"> @RenderBody() </div> </body> </html>
对 RenderAction 方法的参数是希望调用的动作方法(Menu)和需要使用的控制器(Nav)。
注: RenderAction 方法会将它的内容直接写入到响应流。即,该方法返回的是 void(无返回),因此不能对其使用规则的 Razor 标签@(因为以@为前缀调用一个方法时,是将该方法的返回结果插入到页面),而必须把这个调用封装在一个 Razor 代码块中(而且要记住以分号为语句结束符)。
运行效果如下:
(2) 生成分类列表
现在可以回到该控制器,并生成一组实际的分类。本例不希望在控制器中生成分类的URL(注意,要记住控制器的职责,它只负责为视图准备数据或处理从视图而来的数据,不负责为视图表现数据。这就是说,表现 URL 是视图的事,与控制器无关,故不要再控制器中生成 URL)。本例打算在视图中使用一个辅助器方法来做这件事。在 Menu 动作方法中索要做的是创建分类列表,修改代码如下:
using SportsStore.Domain.Abstract; using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.Mvc; namespace SportsStore.WebUI.Controllers { public class NavController : Controller { private IProductRepository repository; public NavController(IProductRepository repo) { repository = repo; } public PartialViewResult Menu() { IEnumerable<string> categories = repository.Products .Select(x => x.Category) .Distinct() .OrderBy(x => x); return PartialView(categories); } } }
目前所做的的第一个修改时添加了构造器,它接受一个 IProductRepository 实现作为其参数——该实现将由 Ninject 提供,这是在控制器实例化时,使用 上一章 中建立的绑定来实现的。
第二个修改是 Menu 动作方法,它现在使用了一个 LINQ 查询,以获取存储库中的分类列表,并将它们传递给视图。注意,由于在这个控制器中使用的是一个分部视图,所以在动作方法中调用了 PartialVire 方法,且结果是一个 PartialViewResult 对象。
(3) 创建分部视图
由于导航列表只是整个页面的一部分,因此对 Menu 动作方法创建分部视图是有意义的。建立对应的视图文件 Menu.cshtml ,修改代码如下:
@model IEnumerable<string> @Html.ActionLink("Home", "List", "Product") @foreach (var link in Model) { @Html.RouteLink(link, new { controller = "Product", action = "List", category = link, page = 1 }) }
上面添加了一个叫做“Home”的链接,它出现在分类列表的顶部,并将用户带到无分类过滤情况下所有产品列表的第一页。这是用 ActionLink 辅助器方法来实现的,该方法用之前配置的路由信息生成了一个 HTML 的锚点元素(即超链接元素)。
然后枚举了分类名,并用 RouteLink 方法为每个分类创建了链接。该辅助器方法与 ActionLink 类似,但在根据路由配置生成 URL 时,它能够有针对地提供一组“名字/值”对(由上面代码可以看出,这些“名字/值”对被放在一个匿名对象中,它们为路由的各个片段提供了数据)。
默认情况下,生成的链接很丑,因此定义一些 CSS ,以改善它们的外观,其 CSS 代码如下:
... div#categories a { font: bold 1.1em "Arial Narrow", "Franklin Gothic Medium", Arial; display: block; text-decoration: none; padding: .6em; color: black; border-bottom: 1px solid silver; } div#categories a.selected { background-color: #666; color: white; } div#categories a:hover { background-color: #ccc; } div#categories a.selected:hover { background-color: #666; } ...
运行该程序,就可以看到下图所示的分类链接了。如果点击一个分类,物品列表会做出更新,显示所选分类物品。
(4)高亮当前分类
此刻,尚未给用户指明他们正在查看的是哪一个分类。用户或许可以根据所列出的物品进行判断,但更好的办法是提供某种实实在在的视觉反馈。
其做法是可以创建一个视图模型,它含有分类列表和所选分类,事实上,这是常规做法。但出于多样性的目的(同一种目标可以采用不同的方法实现),本文打算演示之前介绍过的View Bag(视图包)特性。该特性能够将数据从控制器传递给视图,而不必使用视图模型,修改 Nav 控制器中的 Menu 动作方法如下:
using SportsStore.Domain.Abstract; using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.Mvc; namespace SportsStore.WebUI.Controllers { public class NavController : Controller { private IProductRepository repository; public NavController(IProductRepository repo) { repository = repo; } public PartialViewResult Menu(string category=null) { ViewBag.SelectedCategory = category; IEnumerable<string> categories = repository.Products .Select(x => x.Category) .Distinct() .OrderBy(x => x); return PartialView(categories); } } }
就是给 Menu 动作方法添加了一个名为“category”的参数。此参数的值将由路由配置自动提供。在方法体中,在 ViewBag 对象中动态创建一个 SelectedCtegory 属性,并将它的值设置为 category 参数的值。ViewBag 是一个动态对象,可以简单的创建新的属性,只需为该属性设置一个值即可。
现在,此处提供了被选中的分类信息,可以更新视图,利用这一信息把一个 CSS 的class 添加到表示被选中分类的 HTML 锚点元素上,修改 Menu.cshtml 如下:
@model IEnumerable<string> @Html.ActionLink("Home", "List", "Product") @foreach (var link in Model) { @Html.RouteLink(link, new { controller = "Product", action = "List", category = link, page = 1 }, new { @class = link == ViewBag.SelectedCategory ? "selected" : null }) }
上述代码利用了 RouteLink 方法的一个重载版本,它让用户提供一个对象,该对象的属性将作为标签属性被添加到这个 HTML 锚点元素上。在这个例子中,所添加的标签属性是一个 CSS 的 class 。赋给该属性的值是 selected 。
注:上述代码在匿名对象中使用了@class ,把它作为新参数传递给 RouteLink 辅助器方法。这不是一个 Razor 标签。它使用的是一个 C#特性,以避免 HTML 关键字 class 与 C# 的同样关键字 class 之间的冲突。 @字符允许开发者使用保留关键字,而不致使编译器产生混淆。如果只把这个参数写成 class (不带@),编译器会假设开发者正在定义一个新的 C# 类型。当使用@字符时,编译器就知道开发者是想创建一个叫做 class 的匿名类型参数,于是才能得到所需要的结果。
运行效果如下:
1.4 修正页面计数
要做的最后一件事是修正页面链接,以使它们在选择了一个分类时能正确的工作。当前,页面链接的数目是由产品总数确定的,而不是被选中分类中的产品数所确定。这意味着,客户可以点击 Watersports 分类的第二页,得到的却是一个空白页面,这是因为没有足够的 Watersports 分类产品来填充第二个页面,如下图所示:
通过更新 ProductController 中的 List 动作方法可以对此加以修正,以使分类信息吧分类考虑进来。
public ViewResult List(string category, int page = 1) { ProductsListViewModel model = new ProductsListViewModel { Products = repository.Products .Where(p => category == null || p.Category == category) .OrderBy(p => p.ProductID) .Skip((page - 1) * PageSize) .Take(PageSize), PagingInfo = new PagingInfo { CurrentPage = page, ItemsPerPage = PageSize, TotalItems = category == null ? repository.Products.Count() : repository.Products.Where(e => e.Category == category).Count() }, CurrentCategory = category }; return View(model); }
如果选中了一个分类,则返回该分类中的物品数;如果没选择分类,则返回产品总数。
2.建立购物车
前面部分创建的应用程序进展良好,但在没有实现购物车之前,还不能销售任何产品。本节将创建下图所示的购物车体验。
在一个分类中每个产品的旁边都显示一个 “Add to cart”的按钮。点此按钮将显示客户已选的产品摘要,包括总费用。在这里,客户可以点击“Continue shopping”按钮,以回到产品分类,或者点击“Check out now”按钮来完成订购,并结束购物会话。
2.1 定义购物车实体
购物车是应用程序业务域的一部分,因此,在域模型中创建一个表现购物车的实体是有意义的。在 SportsStore.Domain 项目的 Entities 文件夹中添加新类 Cart.cs ,修改后代码如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace SportsStore.Domain.Entities { public class Cart { private List<CartLine> lineCollection = new List<CartLine>(); public void AddItem(Product product, int quantity) { CartLine line = lineCollection.Where(p => p.Product.ProductID == product.ProductID).FirstOrDefault(); if (line == null) { lineCollection.Add(new CartLine { Product = product, Quantity = quantity }); } else { line.Quantity += quantity; } } public void RemoveLine(Product product) { lineCollection.RemoveAll(l => l.Product.ProductID == product.ProductID); } public decimal ComputeTotalValue() { return lineCollection.Sum(e => e.Product.Price * e.Quantity); } public void Clear() { lineCollection.Clear(); } public IEnumerable<CartLine> Lines { get { return lineCollection; } } } public class CartLine { public Product Product { get; set; } public int Quantity { get; set; } } }
此 Cart 类使用了在同一个文件中定义的 CartLine 类,以表示由客户所选的一个产品和用户想要购买的数量。上述代码定义了一些方法,包括把一个物品添加到购物车、从购物车中删除已加入的物品、计算购物车物品总费用,以及删除全部选择重置购物车等。该示例还提供了一个属性,它使用 IEnumerable<CartLine> 对购物车的内容进行访问。所有这些都很直观,利用一点点 LINQ 的辅助,很容易用 C# 来实现。
2.2 添加“加入购物车”按钮
需要编辑 Views/Shared/ProductSummary.cshtml 分部视图,以便将这些按钮添加到产品列表。修改后代码如下:
@model SportsStore.Domain.Entities.Product <div class="item"> <h3>@Model.Name</h3> @Model.Description @using (Html.BeginForm("AddToCart", "Cart")) { @Html.HiddenFor(x => x.ProductID) @Html.Hidden("returnUrl", Request.Url.PathAndQuery) <input type="submit" value="+ Add to cart" /> } <h4>@Model.Price.ToString("c")</h4> </div>
上述代码添加了一个 Razor 代码块,为列表中的每一个产品创建一个小型的HTML表单。当该表单被递交时,将调用 Cart 控制器中的 AddToCart 动作方法。
在每个产品列表中使用 Html.BeginForm 辅助器方法,意味着每个“Add to cart”按钮都会被渲染成它自己独立的 HTML 的 from 元素。ASP.NET MVC 并不限制每页表单的个数,可以根据需要使用任意多个。
为每个按钮创建一个表单并不是技术上的要求。然而,由于每个表单将会回递给同一个控制器方法,但却带有一组不同的参数值,所以,这是处理按钮点击的一种很好而简单的方式。
另外,希望这些按钮的样式与应用程序的其余部分一直,添加 CSS样式如下:
... form {margin:0;padding:0;} div.item form {float:right;} div.item input {color:white;background-color:#333;border:1px solid black;cursor:pointer;} ...
2.3 实现购物车控制器
此时,需要一个控制器来处理“Add to cart”按钮的点击。为此创建一个名为“CartController”的新控制器,编辑后内容如下:
using SportsStore.Domain.Abstract; using SportsStore.Domain.Entities; 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(int productId, string returnUrl) { Product product = repository.Products.FirstOrDefault(p => p.ProductID == productId); if (product != null) { GetCart().AddItem(product, 1); } return RedirectToAction("Index", new { returnUrl }); } public RedirectToRouteResult RemoveFromCart(int productId, string returnUrl) { Product product = repository.Products.FirstOrDefault(p => p.ProductID == productId); if (product != null) { GetCart().RemoveLine(product); } return RedirectToAction("Index", new { returnUrl }); } private Cart GetCart() { Cart cart = (Cart)Session["Cart"]; if (cart == null) { cart = new Cart(); Session["Cart"] = cart; } return cart; } } }
这个控制器有几点需要注意。
第一是运用 ASP.NET 会话(Session)状态特性来存储和接收 Cart 对象,这是 GetCart 方法的意图。 ASP.NET 有一个很好的会话特性,它使用重写 cookies 或 URL 的办法将一个用户的各个请求关联在一起,形成一个单一的浏览会话。一个相关的特性是会话状态,它允许开发者吧数据和会话关联起来。这对 Cart 类很合适。希望每个用户都有自己的购物车,而且购物车在各次请求之间是保持的。当会话过期(典型情况是用户很长时间没有任何请求)时,与会话关联的数据会被删除,这意味着不需要对 Cart 对象的存储或其生命周期进行管理。要把一个对象添加到一个会话状态,只要对 Session 对象上的一个键设置一个值即可,如下所示:
Session["Cart"] = cart;
再次接收一个对象,只要简单的读取同一个键:
Cart cart = (Cart)Session["Cart"];
提示:Session 状态对象默认存储在ASP.NET 服务器的内存中,但可以配置不同的存储方式,包括使用一个 SQL 数据库。
对于 AddToCart 和 RemoveFromCart 方法,使用与 HTML 表单中 input 元素匹配的参数名,这些 HTML 表单实在 ProductsSummary.cshtml 视图中创建的。这可以让MVC 框架吧输入表单的 POST 变量与这些参数关联起来,即不需要自己去处理这个表单。
2.4 显示购物车内容
对于 Cart 控制器,要注意的最后一点事 AddToCart 和 RemoveFromCart 方法都调用了 RedirectToAction 方法,其效果是,把一个 HTTP 的重定向指令发送带客户端浏览器,要求浏览器请求一个新的 URL 。在此例中,要求浏览器(重新)请求的 URL 是,调用 Cart 控制器的 Index 动作方法。
本例打算实现这个 Index 方法,并用它显示 Cart 的内容。需要把两个数据片段传递给显示购物车内容的视图: Cart 对象,以及如果用户点击“Continue shopping”按钮时要显示的 URL。下面为此在 Models 文件夹中创建一个简单的视图模型类 "CartIndexViewModel",代码如下:
using SportsStore.Domain.Entities; namespace SportsStore.WebUI.Models { public class CartIndexViewModel { public Cart Cart{get;set;} public string ReturnUrl { get; set; } } }
然后编辑控制器文件 CartController.cs ,添加 Index 动作方法:
public ViewResult Index(string returnUrl) { return View(new CartIndexViewModel { Cart = GetCart(), ReturnUrl = returnUrl });
显示购物车内容的最后已不是创建此新视图,创建对应的 /Views/Cart/Index.cshtml 视图文件,代码如下:
@model SportsStore.WebUI.Models.CartIndexViewModel @{ ViewBag.Title = "Sports Store: Your Cart"; } <h2>Your cart</h2> <table class="cart_table"> <thead> <tr> <th class="aling_center">Quantity</th> <th class="aling_left">Item</th> <th class="aling_right">Price</th> <th class="aling_right">Subtotal</th> </tr> </thead> <tbody> @foreach (var line in Model.Cart.Lines) { <tr> <td class="aling_center">@line.Quantity</td> <td class="aling_left">@line.Product.Name</td> <td class="aling_right">@line.Product.Price.ToString("c")</td> <td class="aling_right">@((line.Quantity * line.Product.Price).ToString("c"))</td> </tr> } </tbody> <tfoot> <tr> <td colspan="3" class="aling_right">Total:</td> <td class="aling_right"> @Model.Cart.ComputeTotalValue().ToString("c") </td> </tr> </tfoot> </table> <p class="aling_center actionButtons"> <a href="@Model.ReturnUrl">Continue shopping</a> </p>
该视图看上去比它本身更复杂一些。其实它只是枚举了购物车中的各行信息,并把每行加入到一个 HTML 的表格中包括每行的总费用和整个购物车的总费用。
最后一步是再添加一些CSS 代码如下:
.cart_table {width:90%;text-align:center;} .aling_center {text-align:center;} .aling_left {text-align:left;} .aling_right {text-align:right;} h2 {margin-top:0.3em} tfoot td {border-top:1px dotted gray;font-weight:bold;} .actionButtons,input .actionButtons { font:.8em Arial;color:white;margin:5em;text-decoration:none;padding:.15em 1.5em .2em 1.5em;background-color:#353535;border:1px solid black;}
现在,已经实现了购物车的基本功能。首先,产品连同按钮一起列出,以便将它们添加到购物车。点击“Add to cart”按钮时,相应的产品被添加到购物车,并显示购物车摘要。可以点击“Continue shopping”按钮,返回到之前所在的页面。
运行效果如下图所示: