C H A P T E R 8
■ ■ ■
In the previous chapter, we set up the core infrastructure of the SportsStore application. Now we will use the infrastructure to add key features to the application, and you’ll start to see how the investment in the basic plumbing pays off. We will be able to add important customer-facing features simply and easily. Along the way, you’ll see some additional features that the MVC Framework provides.
在上一章中,我们建立了SportsStore应用程序的核心基础结构。现在,我们将利用这一基础结构把一些关键特性添加到该应用程序上。你将看到,上一章在构建基础结构方面的付出得到怎样的回报。我们能够简单而容易地添加面向客户的重要特性。通过这种方式,你还会看到MVC框架提供的一些附加特性。
The SportsStore application will be a lot more usable if we let customers navigate products by category. We will do this in three parts:
如果我们让客户通过产品分类(category)对产品进行导航,SportsStore应用程序将会更加适用得多。我们将从三个方面来做这件事:
We are going to start by enhancing our view model class, ProductsListViewModel. We need to communicate the current category to the view in order to render our sidebar, and this is as good a place to start as any. Listing 8-1 shows the changes we made.
我们打算从增强视图模型类ProductsListViewModel开始。我们需要把当前分类传递给视图,以渲染我们的工具条,而且这是从事其它工作的一个很好的开端。清单8-1是我们所作的修改。
Listing 8-1. Enhancing the ProductsListViewModel Class
清单8-1. 增强ProductsListViewModel类
using System.Collections.Generic; using SportsStore.Domain.Entities;
namespace SportsStore.WebUI.Models { public class ProductsListViewModel {
public IEnumerable<Product> Products { get; set; } public PagingInfo PagingInfo { get; set; }
public string CurrentCategory { get; set; } } }
We added a new property called CurrentCategory. The next step is to update the ProductController class so that the List action method will filter Product objects by category and use the new property we added to the view model to indicate which category has been selected. The changes are shown in Listing 8-2.
我们添加了一个叫做CurrentCategory的新属性。下一步是更新ProductController类,以使List动作方法能通过分类来过滤Product对象,并用我们添加到视图模型的这个新属性来指示已选择了哪个分类。其修改如清单8-2所示。
Listing 8-2. Adding Category Support to the List Action Method
清单8-2. 对List动作方法添加分类支持
public ViewResult List(string category, int page = 1) {
ProductsListViewModel viewModel = 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(viewModel); }
We’ve made three changes to this method. First, we added a new parameter called category. This category is used by the second change, which is an enhancement to the LINQ query—if category isn’t null, only those Product objects with a matching Category property are selected. The last change is to set the value of the CurrentCategory property we added to the ProductsListViewModel class. However, these changes mean that the value of TotalItems is incorrectly calculated—we’ll fix this in a while.
我们已经对此方法作了三处修改。第一,我们添加了一个名为category的新参数。这个category由第二个修改来使用,以增强LINQ查询 — 如果category非空,则只选出与Category属性匹配的那些Product对象。最后一个修改是设置我们添加到ProductsListViewModel类上的CurrentCategory属性的值。然而,这些修改意味着会不正确地计算TotalIterms的值 — 我们一会儿修正它。
UNIT TEST: UPDATING EXISTING UNIT TESTS
单元测试:更新现有的单元测试
We have changed the signature of the List action method, which will prevent some of our existing unit test methods from compiling. To address this, pass null as the first parameter to the List method in those unit tests that work with the controller. For example, in the Can_Send_Pagination_View_Model test, the action section of the unit test becomes as follows:
我们已经修改了List动作方法的签名,这会阻碍已有的单元测试方法进行编译。为了修正它,在使用这个控制器的那些单元测试中,把null作为第一个参数传递给List方法。例如,在Can_Send_Pagination_View_Model测试中,单元测试的“动作”部分成为这样:
ProductsListViewModel result = (ProductsListViewModel)controller.List(null, 2).Model;
By using null, we receive all of the Product objects that the controller gets from the repository, which is the same situation we had before we added the new parameter.
通过使用null,我们接收控制器从存储库获取的全部Product对象,这与我们添加这个新参数之前的情况相同。
Even with these small changes, we can start to see the effect of the filtering. If you start the application and select a category using the query string, like this:
即使用这些微小的变化,我们也能够看出过滤的效果。如果你运行此应用程序,并用查询字串选择一个分类,像这样:
http://localhost:23081/?category=Soccer
you’ll see only the products in the Soccer category, as shown in Figure 8-1.
你就会只看到Soccer分类中的产品,如图8-1所示。
Figure 8-1. Using the query string to filter by category
图8-1. 通过category使用查询字串进行过滤
UNIT TEST: CATEGORY FILTERING
单元测试:分类过滤
We need a unit test to properly test the category filtering function, to ensure that we can filter correctly and receive only products in a specified category. Here is the test:
我们需要一个单元测试来适当地测试分类的过滤功能,以确保能够正确地进行过滤,并且只接收指定分类中的产品。以下是这个测试:
[TestMethod] public void Can_Filter_Products() { // Arrange - create the mock repository // 布置 — 创建模仿存储库 Mock<IProductRepository> mock = new Mock<IProductRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new Product {ProductID = 1, Name = "P1", Category = "Cat1"}, new Product {ProductID = 2, Name = "P2", Category = "Cat2"}, new Product {ProductID = 3, Name = "P3", Category = "Cat1"}, new Product {ProductID = 4, Name = "P4", Category = "Cat2"}, new Product {ProductID = 5, Name = "P5", Category = "Cat3"} }.AsQueryable());
// Arrange - create a controller and make the page size 3 items // 布置 — 创建一个控制器,并把页面大小设置为3个条目 ProductController controller = new ProductController(mock.Object); controller.PageSize = 3;
// Action // 动作 Product[] result = ((ProductsListViewModel)controller.List("Cat2", 1).Model) .Products.ToArray();
// Assert // 断言 Assert.AreEqual(result.Length, 2); Assert.IsTrue(result[0].Name == "P2" && result[0].Category == "Cat2"); Assert.IsTrue(result[1].Name == "P4" && result[1].Category == "Cat2"); }
This test creates a mock repository containing Product objects that belong to a range of categories. One specific category is requested using the Action method, and the results are checked to ensure that the results are the right objects in the right order.
这个测试创建了一个模仿存储库,该存储库包含了一些属于各个分类的Product对象。在“动作”部分请求一个特定的分类,并检查其结果,以确认该结果是正确顺序的正确对象。
No one wants to see or use ugly URLs such as /?category=Soccer. To address this, we are going to revisit our routing scheme to create an approach to URLs that suits us (and our customers) better. To implement our new scheme, change the RegisterRoutes method in Global.asax to match Listing 8-3.
没人希望看到或使用像/?category=Soccer这种难看的URL。为了改善它,我们打算重新考察我们的路由方案,以创建一种更适合于我们(及我们的客户)的URL方法。为了实现这种新方案,修改Global.asax中的RegisterRoutes方法,使之符合清单8-3。
Listing 8-3. The New URL Scheme
清单8-3. 新的URL方案
public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(null, "", // Only matches the empty URL (i.e. /)(只匹配空的URL(如,/) new { controller = "Product", action = "List", category = (string)null, page = 1 } );
routes.MapRoute(null, "Page{page}", // Matches /Page2, /Page123, but not /PageXYZ // 匹配“/page1”、“/page123”等,但不匹配“/pageXTZ” new { controller = "Product", action = "List", category = (string)null }, new { page = @"\d+" } // Constraints: page must be numerical // 约束:page必须是数字 );
routes.MapRoute(null, "{category}", // Matches /Football or /AnythingWithNoSlash // 匹配“/Football”,或“/<任何不带/的东西>” new { controller = "Product", action = "List", page = 1 } );
routes.MapRoute(null, "{category}/Page{page}", // Matches /Football/Page567 // 匹配“/Football/Page567” new { controller = "Product", action = "List" }, // Defaults(默认) new { page = @"\d+" } // Constraints: page must be numerical(约束:page必须是数字) );
routes.MapRoute(null, "{controller}/{action}"); }
■ Caution It is important to add the new routes in Listing 8-3 in the order they are shown. Routes are applied in the order in which they are defined, and you’ll get some odd effects if you change the order.
小心:清单8-3中重要的是按所示的顺序添加新路由。路由是按其定义的顺序来运用的,如果你改变了这种顺序,你会得到奇怪的效果。
Table 8-1 describes the URL scheme that these routes represent. We will explain the routing system in detail in Chapter 11.
表8-1描述了这些路由所表示的URL方案。我们将在第11章详细解释路由系统。
URL | Leads To 作用 |
---|---|
/ | Lists the first page of products from all categories 列出所有分类产品的第一页 |
/Page2 | Lists the specified page (in this case, page 2), showing items from all categories 列出显示所有分类条目的指定页(这里是page2) |
/Soccer | Shows the first page of items from a specific category (in this case, the Soccer category) 显示指定分类条目中的第一页(这里是Soccer分类) |
/Soccer/Page2 | Shows the specified page (in this case, page 2) of items from the specified category (in this case, Soccer) 显示指定分类(这里是Soccer)条目的指定页(这里是page2) |
/Anything/Else | Calls the Else action method on the Anything controller 调用Anything控制器上的Else动作方法 |
The ASP.NET routing system is used by MVC to handle incoming requests from clients, but it also requests outgoing URLs that conform to our URL scheme and that we can embed in web pages. This way, we make sure that all of the URLs in the application are consistent.
ASP.NET路由系统是由MVC来使用的,以处理来自客户端的请求,但它也请求符合URL方案的输出URL,以使我们能够把这个输出URL嵌入在web页面中。这样,我们可以确保应用程序中的所有URL都是一致的。
■ Note We show you how to unit test routing configurations in Chapter 11.
注:我们将在第11章中向你演示如何单元测试路由配置。
The Url.Action method is the most convenient way of generating outgoing links. In the previous chapter, we used this help method in the List.cshtml view in order to display the page links. Now that we’ve added support for category filtering, we need to go back and pass this information to the helper method, as shown in Listing 8-4.
Url.Action方法是生成输出链接最方便的办法。在上一章中,我们为了显示页面连接,在List.cshtml视图中使用了这个辅助器方法。现在,我们已经添加了对分类过滤的支持,我们需要回过头来把这个信息传递给这个辅助器方法,如清单8-4所示。
Listing 8-4. Adding Category Information to the Pagination Links
清单8-4. 将分类信息添加到分页链接
@model SportsStore.WebUI.Models.ProductsListViewModel
@{ ViewBag.Title = "Products"; }
@foreach (var p in Model.Products) { Html.RenderPartial("ProductSummary", p); }
<div class="pager"> @Html.PageLinks(Model.PagingInfo, x => Url.Action("List", new {page = x, category = Model.CurrentCategory})) </div>
Prior to this change, the links we were generating for the pagination links were like this:
在这个修改之前,我们为分页连接所生成的连接是这样的:
http://<myserver>:<port>/Page2
If the user clicked a page link like this, the category filter he applied would be lost, and he would be presented with a page containing products from all categories. By adding the current category, which we have taken from the view model, we generate URLs like this instead:
如果用户点击这样的页面链接,他所运用的分类过滤会不起作用,显示给他的将是一个包含所有分类产品的页面。通过添加从视图模型获取的当前分类,我们生成了如下所示的URL:
http://<myserver>:<port>/Chess/Page2
When the user clicks this kind of link, the current category will be passed to the List action method, and the filtering will be preserved. After you’ve made this change, you can visit a URL such as /Chess or /Soccer, and you’ll see that the page link at the bottom of the page correctly includes the category.
当用户点击这种链接时,当前分类将被传递给List动作方法,过滤就会起作用了。作了这些修改之后,你可以访问/Chess或/Soccer这样的URL,这就会看到页面底部的链接是正确地包含该分类的页面链接。
We now need to provide the customers with a way to select a category. This means that we need to present them with a list of the categories available and indicate which, if any, they’ve selected. As we build out the application, we will use this list of categories in multiple controllers, so we need something that is self-contained and reusable.
现在我们需要给客户提供一种选择一个分类的方法。意即,我们需要表现一个可用分类列表,并指示出他们之中哪一个是被选择的。随着对应用程序的扩建,我们将在多个控制中使用这个分类列表,因此,我们需要做一些让它是自包含且可重用的事情。
The ASP.NET MVC Framework has the concept of child actions, which are perfect for creating items such as a reusable navigation control. A child action relies on the HTML helper method called RenderAction, which lets you include the output from an arbitrary action method in the current view. In this case, we can create a new controller (we’ll call ours NavController) with an action method (Menu, in this case) that renders a navigation menu and inject the output from that method into the layout.
ASP.NET MVC框架具有一种叫做子动作的概念,它对创建诸如可重用导航控件之类的事情特别理想。子动作依赖于叫做RenderAction的HTML辅助器方法,它让你能够在当前视图中包含一个任意动作方法的输出。在这里,我们可以创建一个新控制器(称之为NavController),它有一个动作方法(这里是Menu),它渲染一个导航菜单,并把此动作方法的输出注入到布局之中。
This approach gives us a real controller that can contain whatever application logic we need and that can be unit tested like any other controller. It’s a really nice way of creating smaller segments of an application while preserving the overall MVC Framework approach.
这种方法使我们拥有了一个真正的控制器,它能够包含我们所需的各种应用程序逻辑,并且能够像其它控制器一样被单元测试。这是保持MVC整体框架前提下,创建应用程序小型片段的一种很好的办法。
Right-click the Controllers folder in the SportsStore.WebUI project and select Add → Controller from the pop-up menu. Set the name of the new controller to NavController, select the Empty controller option from the Template menu, and click Add to create the class.
右击SportsStore.WebUI项目的Controllers文件夹,从弹出菜单选择“添加” → “控制器”。将此新控制器名设为NavController,在“模板”菜单中选择“空控制器”选项,点击“添加”创建这个类。
Remove the Index method that Visual Studio creates by default and add the Menu action method shown in Listing 8-5.
删除Visual Studio默认创建的Index方法,并添加如清单8-5所示的Menu动作方法。
Listing 8-5. The Menu Action Method
清单8-5. Menu动作方法
using System.Web.Mvc;
namespace SportsStore.WebUI.Controllers {
public class NavController : Controller {
public string Menu() { return "Hello from NavController"; } } }
This method returns a canned message string, but it is enough to get us started while we integrate the child action into the rest of the application. We want the category list to appear on all pages, so we are going to render the child action in the layout. Edit the Views/Shared/_Layout.cshtml file so that it calls the RenderAction helper method, as shown in Listing 8-6.
该方法返回一个固定的消息字符串,但它足以让我们把这个子动作集成到应用程序的其余部分。我们希望分类列表出现在所有页面上,因此我们打算在布局中渲染这个子动作。编辑View/Shared/_Layout.cshtml文件,以使它调用RenderAction辅助方法,如清单8-6所示。
Listing 8-6. Adding the RenderAction Call to the Razor Layout
清单8-6. 将RenderAction调用添加到Razor布局
<!DOCTYPE html> <html> <head> <title>@ViewBag.Title</title> <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" /> <script src="@Url.Content("~/Scripts/jquery-1.4.4.min.js")" type="text/javascript"></script> </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>
We’ve removed the placeholder text that we added in Chapter 7 and replaced it with a call to the RenderAction method. The parameters to this method are the action method we want to call (Menu) and the controller we want to use (Nav).
我们已经去掉了第7章添加的占位文本,代之以调用RenderAction方法。该方法的参数是我们想调用的动作方法(Menu),和我们想使用的控制器(Nav)。
■ Note The RenderAction method writes its content directly to the response stream, just like the RenderPartial method introduced in Chapter 7. This means that the method returns void, and therefore can’t be used with a regular Razor @ tag. Instead, we must enclose the call to the method inside a Razor code block (and remember to terminate the statement with a semicolon). You can use the Action method as an alternative if you don’t like this code-block syntax.
注:RenderAction方法直接把它的内容写入到响应流,就像第7章所介绍的RenderPartial方法一样。意即,该方法返回void,因此不能用一个规则的Razor标签@。我们必须把这个调用封装在一个Razor代码块中(而且要记住以分号为语句结束符)。如果你不喜欢这种代码语法,你可以选用Action方法来代替。
If you run the application, you’ll see that the output of the Menu action method is included in every page, as shown in Figure 8-2.
如果你运行这个应用程序,你将看到每个页面都包含了这个Menu动作方法的输出,如图8-2所示。
Figure 8-2. Displaying the output from the Menu action method
图8-2. 显示Menu动作方法的输出
We can now return to the controller and generate a real set of categories. We don’t want to generate the category URLs in the controller. We are going to use a helper method in the view to do that. All we need to do in the Menu action method is create the list of categories, which we’ve done in Listing 8-7.
现在我们回到这个控制器,并生成一组实际分类。我们不想在该控制器中生成分类的URL。我们打算在视图中使用一个辅助器方法来做这件事。在Menu动作方法中所要做的是创建分类列表,用清单8-7来实现。
Listing 8-7. Implementing the Menu Method
清单8-7. 实现Menu方法
using System.Collections.Generic; using System.Linq; using System.Web.Mvc; using SportsStore.Domain.Abstract; using SportsStore.WebUI.Models;
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); } } }
The Menu action method is very simple. It just uses a LINQ query to obtain a list of category names and passes them to the view.
Menu动作方法很简单。它只使用一个LINQ查询来获得一个分类名的列表并把它传递给视图。
UNIT TEST: GENERATING THE CATEGORY LIST
单元测试:生成分类列表
The unit test for our ability to produce a category list is relatively simple. Our goal is to create a list that is sorted in alphabetical order and contains no duplicates. The simplest way to do this is to supply some test data that does have duplicate categories and that is not in order, pass this to the NavController, and assert that the data has been properly cleaned up. Here is the unit test we used:
产生分类列表能力的单元测试是相对比较简单。我们的目标是创建一个按字母顺序排列且无重复的列表。最简单的办法是提供的测试数据是有重复且无序的分类,把它传递给NavController,并断言该数据得到了适当的整理。以下是我们所用的单元测试:
[TestMethod] public void Can_Create_Categories() { // Arrange - create the mock repository // 布置 — 创建模仿存储库 Mock<IProductRepository> mock = new Mock<IProductRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new Product {ProductID = 1, Name = "P1", Category = "Apples"}, new Product {ProductID = 2, Name = "P2", Category = "Apples"}, new Product {ProductID = 3, Name = "P3", Category = "Plums"}, new Product {ProductID = 4, Name = "P4", Category = "Oranges"}, }.AsQueryable());
// Arrange - create the controller // 布置 — 创建控制器 NavController target = new NavController(mock.Object);
// Act = get the set of categories // 动作 — 获取这组分类 string[] results = ((IEnumerable<string>)target.Menu().Model).ToArray();
// Assert // 断言 Assert.AreEqual(results.Length, 3); Assert.AreEqual(results[0], "Apples"); Assert.AreEqual(results[1], "Oranges"); Assert.AreEqual(results[2], "Plums"); }
We created a mock repository implementation that contains repeating categories and categories that are not in order. We assert that the duplicates are removed and that alphabetical ordering is imposed.
我们创建了一个模仿存储库的实现,它包含了重复性且无序的分类。我们断言,去掉了重复,并实现了按字母排序。
Since the navigation list is just part of the overall page, it makes sense to create a partial view for the Menu action method. Right-click the Menu method in the NavController class and select Add View from the pop-up menu.
由于导航列表只是整个页面的一部分,故对Menu动作方法创建分部视图是有意义的。右击NavController类中的Menu方法,并从弹出菜单选择“添加视图”。
Leave the view name as Menu, check the option to create a strongly typed view, and enter IEnumerable<string> as the model class type, as shown in Figure 8-3.
保留视图名为Menu,选中“创建强类型视图”复选框,输入IEnumerable<string>作为模型类的类型,如图8-3所示。
Figure 8-3. Creating the Menu partial view
图8-3. 创建Menu分部视图
Check the option to create a partial view. Click the Add button to create the view. Edit the view contents so that they match those shown in Listing 8-8.
选中“创建分部视图”复选框。点击“添加”按钮以创建这个视图。编辑该视图内容,使之与清单8-8吻合。
Listing 8-8. The Menu Partial View
清单8-8. Menu分部视图
@model IEnumerable<string>
@{ Layout = null; }
@Html.ActionLink("Home", "List", "Product")
@foreach (var link in Model) { @Html.RouteLink(link, new { controller = "Product", action = "List", category = link, page = 1 }) }
We’ve added a link called Home that will appear at the top of the category list and will take the user back to the first page of the list of all products with no category filter. We did this using the ActionLink helper method, which generates an HTML anchor element using the routing information we configured earlier.
我们添加了一个叫做Home的连接,它出现在分类列表的顶部,并将用户带到无分类过滤情况下所有产品列表的第一页。这是用ActionLink辅助器方法来实现的,该方法用我们之前配置的路由信息生成了一个HTML锚点元素(超链接元素)。
We then enumerated the category names and created links for each of them using the RouteLink method. This is similar to ActionLink, but it lets us supply a set of name/value pairs that are taken into account when generating the URL from the routing configuration. Don’t worry if all this talk of routing doesn’t make sense yet—we explain everything in depth in Chapter 11.
然后我们枚举分类名,并用RouteLink方法为每个分类名创建了连接,但在根据路由配置生成URL时,它让我们针对性地提供了一组“名字/值”对。如果不能理解这里所说的路由含义,不用担心 — 我们会在第11章详细解释路由的方方面面。
The links we generate will look pretty ugly by default, so we’ve defined some CSS that will improve their appearance. Add the styles shown in Listing 8-9 to the end of the Content/Site.css file in the SportsStore.WebUI project.
默认情况下,我们生成的连接很丑陋,因此我们定义了一些CSS以改善它的外观。把清单8-9所示的样式加到SportsStore.WebUI项目Content/Site.css文件的尾部。
Listing 8-9. CSS for the Category Links
清单8-9. 用于分类链接的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; }
You can see the category links if you run the application, as shown in Figure 8-4. If you click a category, the list of items is updated to show only items from the selected category.
如果运行访应用程序,你就能看到这些分类链接了,如图8-4所示。如果你点击一个分类,条目列表会作出更新,只显示所选分类的条目。
Figure 8-4. The category links
图8-4. 分类链接
At present, we don’t indicate to users which category they are viewing. It might be something that the customer could infer from the items in the list, but it is preferable to provide some solid visual feedback.
此刻,我们还没有给用户指明他们正在查看哪个分类。也许用户可以根据所列出的条目进行推断,但更好的是提供某种特定的视觉反馈。
We could do this by creating a view model that contains the list of categories and the selected category, and in fact, this is exactly what we would usually do. But instead, we are going to demonstrate the View Bag feature we mentioned in the Razor section of Chapter 5. This feature allows us to pass data from the controller to the view without using a view model. Listing 8-10 shows the changes to the Menu action method.
这件事我们可以通过创建一个含有分类列表和所选分类的视图模型来实现,而且事实上,这恰恰是我们通常的做法。但在这里,我们打算演示第5章在Razor章节所提到的View Bag(视图包)特性。该特性允许我们把控制器的数据传递给视图而不需要用视图模型。清单8-10演示了对Menu动作方法的修改。
Listing 8-10. Using the View Bag Feature
清单8-10. 使用视图包特性
public ViewResult Menu(string category = null) {
ViewBag.SelectedCategory = category;
IEnumerable<string> categories = repository.Products .Select(x => x.Category) .Distinct() .OrderBy(x => x);
return View(categories); }
We’ve added a parameter to the Menu action method called category. The value for this parameter will be provided automatically by the routing configuration. Inside the method body, we’ve dynamically created a SelectedCategory property in the ViewBag object and set its value to be the parameter value. In Chapter 5, we explained that ViewBag is a dynamic object, and we can create new properties simply by setting values for them.
我们给Menu动作方法添加了一个名为category的参数。这个参数的值将由路由配置自动提供。在方法体中,我们在ViewBag对象中动态地创建了一个SelectedCategory属性,并将它的值设置为这个参数的值。在第5章中,我们解释过ViewBag是一个动态对象,可以简单地通过为属性设置值的办法来创建新属性。
UNIT TEST: REPORTING THE SELECTED CATEGORY
单元测试:报告被选中分类
We can test that the Menu action method correctly adds details of the selected category by reading the value of the ViewBag property in a unit test, which is available through the ViewResult class. Here is the test:
通过在单元测试中读取ViewBag属性值,我们可以测试Menu动作方法正确添加了被选中分类的细节。以下是该测试:
[TestMethod] public void Indicates_Selected_Category() {
// Arrange - create the mock repository // 布置 创建模仿存储库 Mock<IProductRepository> mock = new Mock<IProductRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new Product {ProductID = 1, Name = "P1", Category = "Apples"}, new Product {ProductID = 4, Name = "P2", Category = "Oranges"}, }.AsQueryable());
// Arrange - create the controller // 布置 — 创建控制器 NavController target = new NavController(mock.Object);
// Arrange - define the category to selected // 布置 — 定义被选中的分类 string categoryToSelect = "Apples";
// Action // 动作 string result = target.Menu(categoryToSelect).ViewBag.SelectedCategory;
// Assert // 断言 Assert.AreEqual(categoryToSelect, result); }
Notice that we don’t need to cast the property value from the ViewBag. This is one the advantages of using the ViewBag object in preference to ViewData.
注意,我们不需要转换ViewBag的属性值。这是用ViewBag对象优于ViewData的优点之一。
Now that we are providing information about which category is selected, we can update the view to take advantage of this, and add a CSS class to the HTML anchor element that represents the selected category. Listing 8-11 shows the changes to the Menu.cshtml partial view.
现在,我们提供了哪个分类被选中的信息,我们可以更新视图以利用这一信息,并把一个CSS的class加到表示被选中分类的HTML锚点元素。清单8-11显示了对Menu.cshtml分部视图的修改。
Listing 8-11. Highlighting the Selected Category
清单8-11. 高亮选中的分类
@model IEnumerable<string>
@{ Layout = null; }
@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 } ) }
We have taken advantage of an overloaded version of the RouteLink method, which lets us provide an object whose properties will be added to the HTML anchor element as attributes. In this case, the link that represents the currently selected category is assigned the selected CSS class.
我们利用了RouteLink方法的重载版本,它让我们提供一个对象,该对象的属性将作为HTML属性被添加到这个HTML锚点元素上。这里,表示当前被选中分类的连接被赋予了selected的CSS的class。
■ Note Notice that we used @class in the anonymous object we passed as the new parameter to the RouteLink helper method. This is not a Razor tag. We are using a C# feature to avoid a conflict between the HTML keyword class (used to assign a CSS style to an element) and the C# use of the same word (used to create a class). The @ character allows us to use reserved keywords without confusing the compiler. If we just called the parameter class (without the @), the compiler would assume we are defining a new C# type. When we use the @ character, the compiler knows we want to create a parameter in the anonymous type called class, and we get the result we need.
注:注意,我们在这个匿名对象中使用了@class,把它作为新参数传递给RouteLink辅助器方法。这不是一个Razor标签。我们使用的是一个C#特性,以避免HTML关键词class(用来把一个CSS样式赋给一个元素)与C#的同样关键词class(用来创建一个类)之间的冲突。@字符允许我们用保留关键词而不至使编译器产生混淆。如果我们只把这个参数写成class(不带@),编译器会假设我们正在定义一个新的C#类型。当我们使用@字符时,编译器就知道我们是想创建一个叫做class的匿名类型参数,于是我们得到了我们所需要的结果。
Running the application shows the effect of the category highlighting, which you can also see in Figure 8-5.
运行这个应用程序显示了分类高亮的效果,如图8-5所示。
Figure 8-5. Highlighting the selected category
图8-5. 高亮选中的分类
The last thing we need to do is correct the page links so that they work correctly when a category is selected.
我们要做的最后一件事是修正页面连接,以使它们在选择了一个分类时能正确地工作。
Currently, the number of page links is determined by the total number of products, not the number of products in the selected category. This means that the customer can click the link for page 2 of the Chess category and end up with an empty page because there are not enough chess products to fill the second page. You can see how this looks in Figure 8-6.
当前,页面链接的数目是由产品总数确定的,而不是由被选中分类中的产品数所确定。这意味着,客户可以点击Chess分类的第2页而终止于一个空白页面,因为没有足够的棋类产品来填充第二个页面。你可以在图8-6看到这种情况。
Figure 8-6. Displaying the wrong page links when a category is selected
图8-6. 当一个分类被选中时显示错误的页面链接
We can fix this by updating the List action method in ProductController so that the pagination information takes the categories into account. You can see the required changes in Listing 8-12.
通过更新ProductController中的List动作方法,我们可以修正这种情况,以使分页信息把分类考虑进来。你可以在清单8-12中看到所需的修改。
Listing 8-12. Creating Category-Aware Pagination Data
清单8-12. 创建分类感应的分页数据
public ViewResult List(string category, int page = 1) {
ProductsListViewModel viewModel = new ProductsListViewModel { Products = repository.Products .Where(p => category == null ? true : 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(viewModel); }
If a category is selected, we return the number of items in that category; if not, we return the total number of products.
如果选中了一个分类,我们返回该分类中的条目数,如果没选,则返回产品总数。
UNIT TEST: CATEGORY-SPECIFIC PRODUCT COUNTS
单元测试:特定分类的产品数
Testing that we are able to generate the current product count for different categories is very simple—we create a mock repository that contains known data in a range of categories and then call the List action method requesting each category in turn. We will also call the List method specifying no category to make sure we get the right total count as well. Here is the unit test:
测试我们能够对不同的分类生成当前产品数是很简单的 — 创建一个模仿存储库,它含有一系列分类的已知数据,然后依次调用请求每个分类的List动作方法。我们也调用未指定分类的List方法,以确保也得到了正确的总数。以下是该单元测试:
[TestMethod] public void Generate_Category_Specific_Product_Count() { // Arrange - create the mock repository // 布置 — 创建模仿存储库 Mock<IProductRepository> mock = new Mock<IProductRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new Product {ProductID = 1, Name = "P1", Category = "Cat1"}, new Product {ProductID = 2, Name = "P2", Category = "Cat2"}, new Product {ProductID = 3, Name = "P3", Category = "Cat1"}, new Product {ProductID = 4, Name = "P4", Category = "Cat2"}, new Product {ProductID = 5, Name = "P5", Category = "Cat3"} }.AsQueryable());
// Arrange - create a controller and make the page size 3 items // 布置 — 创建控制器,并让页面大小为3个条目 ProductController target = new ProductController(mock.Object); target.PageSize = 3;
// Action - test the product counts for different categories // 动作 — 测试不同分类的产品数 int res1 = ((ProductsListViewModel)target.List("Cat1").Model).PagingInfo.TotalItems; int res2 = ((ProductsListViewModel)target.List("Cat2").Model).PagingInfo.TotalItems; int res3 = ((ProductsListViewModel)target.List("Cat3").Model).PagingInfo.TotalItems; int resAll = ((ProductsListViewModel)target.List(null).Model).PagingInfo.TotalItems;
// Assert // 断言 Assert.AreEqual(res1, 2); Assert.AreEqual(res2, 2); Assert.AreEqual(res3, 1); Assert.AreEqual(resAll, 5); }
Now when we view a category, the links at the bottom of the page correctly reflect the number of products in the category, as shown in Figure 8-7.
现在,当我们查看一个分类时,页面底部的链接正确地反映了该分类中的产品数目,如图8-7所示。
Figure 8-7. Displaying category-specific page counts
图8-7. 显示特定分类的页面计数
Our application is progressing nicely, but we can’t sell any products until we implement a shopping cart. In this section, we’ll create the shopping cart experience shown in Figure 8-8. This will be familiar to anyone who has ever made a purchase online.
我们的应用程序进展良好,但在没有实现购物车之前,还不能销售任何产品。在本章节中,我们将创建如图8-8所示的购物车体验。曾作过在线购物的人对它是熟悉的。
Figure 8-8. The basic shopping cart flow
图8-8. 基本的购物车流程
An Add to cart button will be displayed alongside each of the products in our catalog. Clicking this button will show a summary of the products the customer has selected so far, including the total cost. At this point, the user can click the Continue shopping button to return to the product catalog, or click the Checkout now button to complete the order and finish the shopping session.
在一个分类中的每个产品的旁边都显示一个“Add to cart(加入购物车)”的按钮。点击这个按钮将显示该客户已选的产品摘要,包括总费用。在这里,客户可以点击“Continue shopping(继续购物)”按钮,以回到产品分类,或点击“Check out now(现在付费)”按钮来完成订购,并结束购物会话。
A shopping cart is part of our application’s business domain, so it makes sense to represent a cart by creating an entity in our domain model. Add a class called Cart to the Entities folder in the SportsStore.Domain project. These classes are shown in Listing 8-13.
购物车是我们应用程序事务域的一部分,因此,在我们的域模型中创建一个表现购物车的实体是有意义的。在SportsStore.Domain项目中的Entities文件夹中添加一个名为Cart的类。这些类如清单8-13所示。
Listing 8-13. The Cart Domain Entity
清单8-13. 购物车域实体
using System.Collections.Generic; using System.Linq;
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; } } }
The Cart class uses CartLine, defined in the same file, to represent a product selected by the customer and the quantity the user wants to buy. We have defined methods to add an item to the cart, remove a previously added item from the cart, calculate the total cost of the items in the cart, and reset the cart by removing all of the selections. We have also provided a property that gives access to the contents of the cart using an IEnumerable<CartLine>. This is all straightforward stuff, easily implemented in C# with the help of a little LINQ.
这个Cart类使用了在同一个文件中定义的CartLine,来表示由客户选择的一个产品和用户想要购买的数量。我们定义了一些方法,包括:把一个条目添加到购物车、从购物车中删除之前加入的条目、计算购物车条目总费用、以及删除全部选择重置购物车等。我们也提供了一个属性,它使用IEnumerable<CartLine>对购物车的内容进行访问。所有这些都很直观,利用一点点LINQ的辅助,很容易用C#来实现。
UNIT TEST: TESTING THE CART
单元测试:测试购物车
The Cart class is relatively simple, but it has a range of important behaviors that we must ensure work properly. A poorly functioning cart would undermine the entire SportsStore application. We have broken down the features and tested them individually.
Cart类相对简单,但它有一些我们必须确保能正确工作的行为。贫乏的购物车功能会破坏整个SportsStore应用程序。我们已经分解了这些特性,并分别对它们进行测试。
The first behavior relates to when we add an item to the cart. If this is the first time that a given Product has been added to the cart, we want a new CartLine to be added. Here is the test:
第一个行为关系到我们把一个条目添加到购物车的时候。如果这是第一次把一个给定的Product添加到购物车,我们希望增加一个新的CartLine。以下是该测试:
[TestMethod] public void Can_Add_New_Lines() { // Arrange - create some test products // 布置 — 创建一些测试用的product Product p1 = new Product { ProductID = 1, Name = "P1" }; Product p2 = new Product { ProductID = 2, Name = "P2" };
// Arrange - create a new cart // 布置 — 创建一个新购物车 Cart target = new Cart();
// Act // 动作 target.AddItem(p1, 1); target.AddItem(p2, 1); CartLine[] results = target.Lines.ToArray();
// Assert // 断言 Assert.AreEqual(results.Length, 2); Assert.AreEqual(results[0].Product, p1); Assert.AreEqual(results[1].Product, p2); }
However, if the customer has already added a Product to the cart, we want to increment the quantity of the corresponding CartLine and not create a new one. Here is the test:
然而,如果客户已经把一个Product加到了购物车,我们希望增加相应CartLine的数量,而不要创建一个新的CartLine对象。以下是该测试:
[TestMethod] public void Can_Add_Quantity_For_Existing_Lines() { // Arrange - create some test products // 布置 — 创建一些测试用product Product p1 = new Product { ProductID = 1, Name = "P1" }; Product p2 = new Product { ProductID = 2, Name = "P2" };
// Arrange - create a new cart // 布置 — 创建一个新购物车 Cart target = new Cart();
// Act // 动作 target.AddItem(p1, 1); target.AddItem(p2, 1); target.AddItem(p1, 10); CartLine[] results = target.Lines.OrderBy(c => c.Product.ProductID).ToArray();
// Assert // 断言 Assert.AreEqual(results.Length, 2); Assert.AreEqual(results[0].Quantity, 11); Assert.AreEqual(results[1].Quantity, 1); }
We also need to check that users can change their mind and remove products from the cart. This feature is implemented by the RemoveLine method. Here is the test:
我们也需要测试用户改变主意,并从购物车删除产品的行为。这一特性是由RemoveLine方法来实现的。以下是该测试:
[TestMethod] public void Can_Remove_Line() { // Arrange - create some test products // 布置 — 创建一些测试用product Product p1 = new Product { ProductID = 1, Name = "P1" }; Product p2 = new Product { ProductID = 2, Name = "P2" }; Product p3 = new Product { ProductID = 3, Name = "P3" };
// Arrange - create a new cart // 布置 — 创建一个新购物车 Cart target = new Cart();
// Arrange - add some products to the cart // 布置 — 把一些产品添加到购物车 target.AddItem(p1, 1); target.AddItem(p2, 3); target.AddItem(p3, 5); target.AddItem(p2, 1);
// Act // 动作 target.RemoveLine(p2);
// Assert // 断言 Assert.AreEqual(target.Lines.Where(c => c.Product == p2).Count(), 0); Assert.AreEqual(target.Lines.Count(), 2); }
The next behavior we want to test is our ability to calculate the total cost of the items in the cart. Here’s the test for this behavior:
我们想要测试的下一个行为是计算购物车中各条目总费用的能力。以下是用于该行为的测试:
[TestMethod] public void Calculate_Cart_Total() { // Arrange - create some test products // 布置 — 创建一些测试用product Product p1 = new Product { ProductID = 1, Name = "P1", Price = 100M}; Product p2 = new Product { ProductID = 2, Name = "P2" , Price = 50M};
// Arrange - create a new cart // 布置 — 创建一个新购物车 Cart target = new Cart();
// Act // 动作 target.AddItem(p1, 1); target.AddItem(p2, 1); target.AddItem(p1, 3); decimal result = target.ComputeTotalValue();
// Assert // 断言 Assert.AreEqual(result, 450M); }
The final test is very simple. We want to ensure that the contents of the cart are properly removed when we reset it. Here is the test:
最后一个测试很简单。我们希望在重置购物车时,恰当地删除了购物车的内容。以下是该测试:
[TestMethod] public void Can_Clear_Contents() { // Arrange - create some test products // 布置 — 创建一些测试用product Product p1 = new Product { ProductID = 1, Name = "P1", Price = 100M }; Product p2 = new Product { ProductID = 2, Name = "P2", Price = 50M };
// Arrange - create a new cart // 布置 — 创建一个新购物车 Cart target = new Cart();
// Arrange - add some items // 布置 — 添加一些条目 target.AddItem(p1, 1); target.AddItem(p2, 1);
// Act - reset the cart // 动作 — 重置购物车 target.Clear();
// Assert // 断言 Assert.AreEqual(target.Lines.Count(), 0); }
Sometimes, as in this case, the code required to test the functionality of a type is much longer and much more complex than the type itself. Don’t let that put you off writing the unit tests. Defects in simple classes, especially ones that play such an important role as Cart does in our application, can have huge impacts.
有时,正如上述情况一样,测试一个类型的功能所需的代码比该类型本身要长得多且复杂得多。不要让这种情况让你放弃单元测试。在简单类中的缺陷,尤其像这种在我们应用程序中起着重要作用的购物车如果有缺陷,有可能会产生巨大的影响。
We need to edit the Views/Shared/ProductSummary.cshtml partial view to add the buttons to the product listings. The changes are shown in Listing 8-14.
我们需要编辑Views/Shared/ProductSummary.cshtml分部视图,以把这些按钮添加到产品列表。清单8-14显示了所作的修改。
Listing 8-14. Adding the Buttons to the Product Summary Partial View
清单8-14. 产品摘要分部视图上添加按钮
@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>
We’ve added a Razor block that creates a small HTML form for each product in the listing. When this form is submitted, it will invoke the AddToCart action method in the Cart controller (we’ll implement this method in just a moment).
我们对列表中的每个产品添加了一个Razor代码块,它创建一个小型表单(Form)。当这个表单被递交时,它将请求Cart控制器中的AddToCart动作方法(我们一会儿就会实现这个方法)。
■ Note By default, the BeginForm helper method creates a form that uses the HTTP POST method. You can change this so that forms use the GET method, but you should think carefully about doing so. The HTTP specification requires that GET requests must be idempotent, meaning that they must not cause changes, and adding a product to a cart is definitely a change. We’ll have more to say on this topic in Chapter 9, including an explanation of what can happen if you ignore the need for idempotent GET requests.
注:默认地,BeginForm辅助方法创建一个使用HTTP POST方法的表单。你可以对之进行修改,以使表单使用GET方法,但你这么做时应该仔细考虑。HTTP规范要求GET请求必须是幂等的,意即,它们必须不会引起变化,而把一个产品添加到购物车显然是一个变化(所以我们没用GET — 译者注)。关于这一论题,我们在第9章会有更多论述,并解释如果你对幂等的GET请求忽略了这种需求会发生什么。
We want to keep the styling of these buttons consistent with the rest of the application, so add the CSS shown in Listing 8-15 to the end of the Content/Site.css file.
我们希望这些按钮的样式与应用程序的其余部分一致,因此,把清单8-15所示的CSS样式加到Content/Site文件的尾部。
Listing 8-15. Styling the Buttons
清单8-15. 设置按钮样式
FORM { margin: 0; padding: 0; } DIV.item FORM { float:right; } DIV.item INPUT { color:White; background-color: #333; border: 1px solid black; cursor:pointer; }
CREATING MULTIPLE HTML FORMS IN A PAGE
在一个页面中创建多个HTML表单
Using the Html.BeginForm helper in each product listing means that every Add to cart button is rendered in its own separate HTML form element. This may be surprising if you’ve been developing with ASP.NET Web Forms, which imposes a limit of one form per page. ASP.NET MVC doesn’t limit the number of forms per page, and you can have as many as you need.
在每个产品列表中使用Html.BeginForm辅助方法,意味着“Add to cart(加入购物车)”按钮会被渲染成它自己独立的HTML的form元素。如果你一直是用ASP.NET的Web表单从事开发,这可能是很奇怪的事情,因为Web表单具有每个页面只有一个表单的限制。ASP.NET MVC并不限制每页表单的个数,你可以有所需要的任意多个。
There is no technical requirement for us to create a form for each button. However, since each form will postback to the same controller method, but with a different set of parameter values, it is a nice and simple way to deal with the button presses.
对我们而言,为每个按钮创建一个表单并不是技术上的要求。然而,由于每个表单将会回递给同一个控制器方法,但却带有了一组不同的参数值,所以,这是处理按钮点击的一种很好而简单的方式。
We need to create a controller to handle the Add to cart button presses. Create a new controller called CartController and edit the content so that it matches Listing 8-16.
我们需要创建一个控制器来处理“Add to cart(加入购物车)”按钮的点击。创建一个名为CartController的新控制器,并编辑其内容,使之与清单8-16吻合。
Listing 8-16. Creating the Cart Controller
清单8-16. 创建购物车控制器
using System.Linq; using System.Web.Mvc; using SportsStore.Domain.Abstract; using SportsStore.Domain.Entities;
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; } } }
There are a few points to note about this controller. The first is that we use the ASP.NET session state feature to store and retrieve Cart objects. This is the purpose of the GetCart method. ASP.NET has a nice session feature that uses cookies or URL rewriting to associate requests from a user together, to form a single browsing session. A related feature is session state, which allows us to associate data with a session. This is an ideal fit for our Cart class. We want each user to have his own cart, and we want the cart to be persistent between requests. Data associated with a session is deleted when a session expires (typically because a user hasn’t made a request for a while), which means that we don’t need to manage the storage or life cycle of the Cart objects. To add an object to the session state, we set the value for a key on the Session object, like this:
这个控制器有几点需要注意。第一是我们运用ASP.NET会话(session)状态特性来存储和接收Cart对象。这是GetCart方法的意图。ASP.NET有一个很好的会话特性,它使用重写cookies或URL的办法把一个用户的各个请求关联在一起。一个相关的特性是会话状态,它允许我们把数据与会话关联起来。这对我们的Cart类很合适。我们希望每个用户有他自己的购物车,而且我们希望购物在各次请求之间是保持的。当会话过期(典型地是用户好一会儿没有任何请求)时,数据与会话的关联被删除,这意味着我们不需要管理Cart对象的存储或其生命周期。要把一个对象添加到一个会话状态,我们只要对Session对象上的一个键设置一个值即可,像这样:
Session["Cart"] = cart;
To retrieve an object again, we simply read the same key, like this:
要再次接收一个对象,我们只要简单地读取同一个键,像这样:
Cart cart = (Cart)Session["Cart"];
■ Tip Session state objects are stored in the memory of the ASP.NET server by default, but you can configure a range of different storage approaches, including using a SQL database.
提示:Session状态对象默认存储在ASP.NET服务器的内存中,但你可以配置不同的存储方式,包括使用一个SQL数据库。
For the AddToCart and RemoveFromCart methods, we have used parameter names that match the input elements in the HTML forms we created in the ProductSummary.cshtml view. This allows the MVC Framework to associate incoming form POST variables with those parameters, meaning we don’t need to process the form ourselves.
对于AddToCart和RemoveFromCart方法,我们使用了与HTML表单中input元素匹配的参数名,这些HTML表单是我们在ProductSummary.cshtml视图中创建的。这可以让MVC Framework把输入表单的POST变量与这些参数关联起来,意即,我们不需要自己来处理这个表单。
The final point to note about the Cart controller is that both the AddToCart and RemoveFromCart methods call the RedirectToAction method. This has the effect of sending an HTTP redirect instruction to the client browser, asking the browser to request a new URL. In this case, we have asked the browser to request a URL that will call the Index action method of the Cart controller.
对Cart控制器要注意的最后一点是AddToCart和RemoveFromCart方法都调用了RedirectToAction方法。其效果是,把一个HTTP重定向指令发送到客户端浏览器、要求浏览器请求一个新的URL。这里,我们要求浏览器请求一个URL,它调用Cart控制器的Index动作方法。
We are going to implement the Index method and use it to display the contents of the Cart. If you refer back to Figure 8-8, you’ll see that this is our workflow when the user clicks the Add to cart button.
我们打算实现这个Index方法,并用它显示Cart的内容。如果你参考前述的图8-8,当用户点击“Add to cart(加入购物车)”按钮时,你就会明白这就是我们的工作流。
We need to pass two pieces of information to the view that will display the contents of the cart: the Cart object and the URL to display if the user clicks the Continue shopping button. We will create a simple view model class for this purpose. Create a new class called CartIndexViewModel in the Models folder of the SportsStore.WebUI project. The contents of this class are shown in Listing 8-17.
我们需要把两个数据片段传递给显示购物车内容的视图:Cart对象以及如果用户点击“Continue shopping”按钮时要显示的URL。我们将为此目的创建一个简单的视图模型类。在SportsStore.WebUI项目的Models文件夹中创建一个名为CartIndexViewModel的新类。该类的内容如清单8-17所示。
Listing 8-17. The CartIndexViewModel Class
清单8-17. CartIndexViewModel类
using SportsStore.Domain.Entities; namespace SportsStore.WebUI.Models { public class CartIndexViewModel { public Cart Cart { get; set; } public string ReturnUrl { get; set; } } }
Now that we have the view model, we can implement the Index action method in the Cart controller class, as shown in Listing 8-18.
现在,我们有了这个视图模型,我们可以实现Cart控制器中的Index动作方法了,如清单8-18所示。
Listing 8-18. The Index Action Method
清单8-18. Index动作方法
public ViewResult Index(string returnUrl) { return View(new CartIndexViewModel { Cart = GetCart(), ReturnUrl = returnUrl }); }
The last step is to display the contents of the cart is to create the new view. Right-click the Index method and select Add View from the pop-up menu. Set the name of the view to Index, check the option to create a strongly typed view, and select CartIndexViewModel as the model class, as shown in Figure 8-9.
显示购物车内容的最后一步是创建这个新视图。右击Index方法并从弹出菜单选择“添加视图”。将视图名设为Index,选中“强类型视图”复选框,并选择CartIndexViewModel作为模型类,如图8-9所示。
Figure 8-9. Adding the Index view
图8-9. 添加Index视图
We want the contents of the cart to be displayed consistently with the rest of the application pages, so ensure that the option to use a layout is checked, and leave the text box empty so that we use the default _Layout.cshtml file. Click Add to create the view and edit the contents so that they match Listing 8-19.
我们希望购物车的内容显示与应用程序的其它页面一致,为此,确保选中“使用布局”复选框,并保持其文本框为空,以使我们使用默认的_Layout.cshtml文件。点击“添加”以创建这个视图,并编辑其内容,使之与清单8-19吻合。
Listing 8-19. The Index View
清单8-19. Index视图
@model SportsStore.WebUI.Models.CartIndexViewModel
@{ ViewBag.Title = "Sports Store: Your Cart"; }
<h2>Your cart</h2> <table width="90%" align="center"> <thead><tr> <th align="center">Quantity</th> <th align="left">Item</th> <th align="right">Price</th> <th align="right">Subtotal</th> </tr></thead> <tbody> @foreach(var line in Model.Cart.Lines) { <tr> <td align="center">@line.Quantity</td> <td align="left">@line.Product.Name</td> <td align="right">@line.Product.Price.ToString("c")</td> <td align="right">@((line.Quantity * line.Product.Price).ToString("c"))</td> </tr> } </tbody> <tfoot><tr> <td colspan="3" align="right">Total:</td> <td align="right"> @Model.Cart.ComputeTotalValue().ToString("c") </td> </tr></tfoot> </table> <p align="center" class="actionButtons"> <a href="@Model.ReturnUrl">Continue shopping</a> </p>
The view looks more complicated than it is. It just enumerates the lines in the cart and adds rows for each of them to an HTML table, along with the total cost per line and the total cost for the cart. The final step is to add some more CSS. Add the styles shown in Listing 8-20 to the Site.css file.
该视图看上去比它本身更复杂一些。其实它只是枚举了购物车中的各行信息,并把每行加入到一个HTML的表格,包括每行的总费用以及整个购物车的总费用。最后一步是再添加一些CSS。将清单8-20所示的样式添加到Site.css文件。
Listing 8-20. CSS for Displaying the Contents of the Cart
清单8-20. 显示购物车内容的样式
H2 { margin-top: 0.3em } TFOOT TD { border-top: 1px dotted gray; font-weight: bold; } .actionButtons A, 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; }
We now have the basic functions of the shopping cart in place. When we click the Add to cart button, the appropriate product is added to our cart and a summary of the cart is displayed, as shown in Figure 8-10. We can click the Continue shopping button and return to the product page we came from—all very nice and slick.
现在我们有了购物车的基本功能。当我们点击“Add to cart(加入购物车)”按钮时,相应的产品被添加到我们的购物车,并显示如图8-10所示的购物车摘要。我们可以点击“Continue shopping(继续购物)”按钮,并返回到我们从中而来的产品页面 — 一切都很好而顺利。
Figure 8-10. Displaying the contents of the shopping cart
图8-10. 显示购物车内容
We have more work to do. We need to allow users to remove items from a cart and also to complete their purchase. We will implement these features later in this chapter. Next, we are going to revisit the design of the Cart controller and make some changes.
我们还有更多工作要做。我们需要允许用户从购物车删除条目,以及完成它们的购物。我们将在本章后面实现这些特性。下一步,我们打算重返Cart控制器的设计,并作一些修改。
The MVC Framework uses a system called model binding to create C# objects from HTTP requests in order to pass them as parameter values to action methods. This is how MVC processes forms, for example. The framework looks at the parameters of the action method that has been targeted, and uses a model binder to get the values of the form input elements and convert them to the type of the parameter with the same name.
MVC Framework使用了一个叫做模型绑定的系统,以创建一些HTTP请求的C#对象,目的是把这些对象作为参数值传递给动作方法。例如,MVC是这样处理表单的:框架会考查目标动作方法的参数,用一个模型绑定器来获取表单的input元素的值,并以同样的名字把它们转换成参数的类型。
■注:要理解模型绑定器的作用,要仔细理解上面这句话。模型绑定器的作用是:把用户在视图表单的input元素中输入的值装配成目标动作方法参数所需要的对象。这是通过下述步骤完成的:(1)考查目标动作方法的参数,于是便知道了该参数对象的类型,以及该类型中各个属性的类型;(2)收集HTML表单中各个input元素的值,并把它们转换成对应的属性类型并进行赋值;(3)利用这些属性和值装配成与参数类型对应的对象;(4)把这个对象赋给动作方法。— 译者注
Model binders can create C# types from any information that is available in the request. This is one of the central features of the MVC Framework. We are going to create a custom model binder to improve our CartController class.
模型绑定器可以根据请求中可用的任何信息来创建C#类型。这是MVC框架的核心特性之一。我们打算创建一个自定义模型绑定器来改善我们的CartController类。
We like using the session state feature in the Cart controller to store and manage our Cart objects, but we really don’t like the way we have to go about it. It doesn’t fit the rest of our application model, which is based around action method parameters. We can’t properly unit test the CartController class unless we mock the Session parameter of the base class, and that means mocking the Controller class and a whole bunch of other stuff we would rather not deal with.
我们喜欢使用Cart控制器中的会话状态特性来存储和管理我们的Cart对象,但我们实在是不喜欢它要我们完成的方式(应当是指对会话状态数据的存取方式 — 译者注)。它不符合我们应用程序模型的其余部分,而这是动作方法参数的基础(其意思似乎是,动作方法参数的操作是以模型为基础的,而会话状态的操作却不是,所以需要模型绑定 — 译者注)。除非我们模仿基类的Session(会话)参数,否则我们不能适当地对CartController类进行单元测试,而这意味着,我们要模仿Controller类以及其它一大堆我们不想处理的东西。
To solve this problem, we are going to create a custom model binder that obtains the Cart object contained in the session data. The MVC Framework will then be able to create Cart objects and pass them as parameters to the action methods in our CartController class. The model binding feature is very powerful and flexible. We go into a lot more depth about this feature in Chapter 17, but this is a nice example to get us started.
为了解决这个问题,我们打算创建一个自定义模型绑定器,以获得包含在会话数据中的Cart对象。MVC框架然后将能够创建Cart对象,并把它们作为参数传递给CartController类中的动作方法。这种模型绑定特性功能十分强大而灵活。我们将在第17章更深入地了解这一特性,但这里是让我们着手进行工作的一个很好的例子。
We create a custom model binder by implementing the IModelBinder interface. Create a new folder in the SportsStore.WebUI project called Binders and create the CartModelBinder class inside that folder. Listing 8-21 shows the implementation of this class.
我们通过实现IModelBinder接口来创建一个自定义模型绑定器。在SportsStore.WebUI项目中创建一个名为Binders的文件夹,并在这个文件夹中创建一个CartModelBinder类。清单8-21显示了这个类的实现。
Listing 8-21. The CartModelBinder Class
清单8-21. CartModelBinder类
using System; 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 bindingContext) {
// get the Cart from the session // 获取会话中的Cart Cart cart = (Cart)controllerContext.HttpContext.Session[sessionKey];
// create the Cart if there wasn't one in the session data // 如果会话数据中没有Cart,创建它 if (cart == null) { cart = new Cart(); controllerContext.HttpContext.Session[sessionKey] = cart; }
// return the cart // 返回cart return cart; } } }
The IModelBinder interface defines one method: BindModel. The two parameters are provided to make creating the domain model object possible. The ControllerContext provides access to all of the information that the controller class has, which includes details of the request from the client. The ModelBindingContext gives you information about the model object you are being asked to build and tools for making it easier. We’ll come back to this class in Chapter 17.
IModelBinder接口定义了一个方法:BindModel。提供两个参数使得创建域模型对象成为可能。ControllerContext提供了对控制器类所有信息的访问,包括客户端请求的细节。ModelBindingContext给你提供了要求你建立的模型对象的信息,以及使之更容易的工具。我们将在第17章回过头来讨论这个类。
For our purposes, the ControllerContext class is the one we’re interested in. It has the HttpContext property, which in turn has a Session property that lets us get and set session data. We obtain the Cart by reading a key value from the session data, and create a Cart if there isn’t one there already.
对于我们的意图,ControllerContext类是我们感兴趣的。它具有HttpContext属性,这又转而有了一个Session属性,它让我们可以获取和设置会话数据。我们通过读取会话数据的键值来获得Cart,并在还不存在Cart时创建一个Cart。
We need to tell the MVC Framework that it can use our CartModelBinder class to create instances of Cart. We do this in the Application_Start method of Global.asax, as shown in Listing 8-22.
我们需要告诉MVC框架,它可以使用这个CartModelBinder类来创建Cart的实例。我们在Global.asax的Application_Start方法中完成这件事,如清单8-22所示。
Listing 8-22. Registering the CartModelBinder Class
清单8-22. 注册CartModelBinder类
protected void Application_Start() {
AreaRegistration.RegisterAllAreas();
RegisterGlobalFilters(GlobalFilters.Filters); RegisterRoutes(RouteTable.Routes);
ControllerBuilder.Current.SetControllerFactory(new NinjectControllerFactory()); ModelBinders.Binders.Add(typeof(Cart), new CartModelBinder()); }
We can now update the CartController class to remove the GetCart method and rely on our model binder. Listing 8-23 shows the changes.
现在,我们可以更新CartController类,删去GetCart方法,并进行依赖于这个模型绑定器的一些更新。清单8-23显示了这些修改。
Listing 8-23. Relying on the Model Binder in CartController
清单8-23. CartController中的模型绑定依赖
using System.Linq; using System.Web.Mvc; using SportsStore.Domain.Abstract; using SportsStore.Domain.Entities; using SportsStore.WebUI.Models;
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 }); } } }
We have removed the GetCart method and added a Cart parameter to each of the action methods.
我们删除了GetCart方法,并对每个动作方法添加了Cart参数。
When the MVC Framework receives a request that requires, say, the AddToCart method to be invoked, it begins by looking at the parameters for the action method. It looks at the list of binders available and tries to find one that can create instances of each parameter type. Our custom binder is asked to create a Cart object, and it does so by working with the session state feature. Between our binder and the default binder, the MVC Framework is able to create the set of parameters required to call the action method. And so it does, allowing us to refactor the controller so that it has no view as to how Cart objects are created when requests are received.
当MVC框架接收到一个请求,比如说,要求调用AddToCart方法时,首先从查看该动作方法的参数开始。接着查看可用的绑定器列表,并试图找到一个能够创建每个参数类型实例的绑定器。我们的自定义绑定器是要求创建一个Cart对象,并且这是通过与会话状态特性进行工作来完成的。在我们的绑定器与默认绑定器之间,MVC框架能够创建一组调用该动作方法所需要的参数。也确实如此,当收到请求时,允许我们重构控制器,以便在没有视图情况下知道如何创建Cart对象。
There are a few benefits to using a custom model binder like this. The first is that we have separated the logic used to create a Cart from that of the controller, which allows us to change the way we store Cart objects without needing to change the controller. The second benefit is that any controller class that works with Cart objects can simply declare them as action method parameters and take advantage of the custom model binder. The third benefit, and the one we think is most important, is that we can now unit test the Cart controller without needing to mock a lot of ASP.NET plumbing.
像这样使用一个自定义模型绑定器有几个好处。第一是我们把用来创建Cart的逻辑与控制器分离开来了,这允许我们能够修改存储Cart对象的方式,而不需要修改控制器。第二个好处是任何与Cart对象一起工作的控制器类都能够简单地把这些对象声明为动作参数,并利用这个自定义模型绑定器。第三个好处,也是我们认为最重要的好处是,我们现在能够单元测试Cart控制器,而不需要模仿大量的ASP.NET通道。
UNIT TEST: THE CART CONTROLLER
单元测试:购物车控制器
We can unit test the CartController class by creating Cart objects and passing them to the action methods. We want to test three different aspects of this controller:
通过创建Cart对象并把它们传递给动作方法,我们可以对CartController类进行单元测试。我们希望测试该控制器的三个不同方面:
Here are the unit tests we used:
以下是我们所使用的单元测试:
[TestMethod] public void Can_Add_To_Cart() { // Arrange - create the mock repository // 布置 — 创建模仿存储库 Mock<IProductRepository> mock = new Mock<IProductRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new Product {ProductID = 1, Name = "P1", Category = "Apples"}, }.AsQueryable());
// Arrange - create a Cart // 布置 — 创建购物车 Cart cart = new Cart();
// Arrange - create the controller // 布置 — 创建控制器 CartController target = new CartController(mock.Object);
// Act - add a product to the cart // 动作 — 把一个产品添加到购物车 target.AddToCart(cart, 1, null);
// Assert // 断言 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() { // Arrange - create the mock repository // 布置 — 创建模仿存储库 Mock<IProductRepository> mock = new Mock<IProductRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new Product {ProductID = 1, Name = "P1", Category = "Apples"}, }.AsQueryable());
// Arrange - create a Cart // 布置 — 创建购物车 Cart cart = new Cart();
// Arrange - create the controller // 布置 — 创建控制器 CartController target = new CartController(mock.Object);
// Act - add a product to the cart // 动作 — 把一个产品添加到购物车 RedirectToRouteResult result = target.AddToCart(cart, 2, "myUrl");
// Assert // 断言 Assert.AreEqual(result.RouteValues["action"], "Index"); Assert.AreEqual(result.RouteValues["returnUrl"], "myUrl"); }
[TestMethod] public void Can_View_Cart_Contents() { // Arrange - create a Cart // 布置 — 创建购物车 Cart cart = new Cart();
// Arrange - create the controller // 布置 — 创建控制器 CartController target = new CartController(null);
// Act - call the Index action method // 动作 — 调用Index动作方法 CartIndexViewModel result = (CartIndexViewModel)target.Index(cart, "myUrl").ViewData.Model;
// Assert // 断言 Assert.AreSame(result.Cart, cart); Assert.AreEqual(result.ReturnUrl, "myUrl"); }
Now that we’ve introduced our custom model binder, it’s time to complete the cart functionality by adding two new features. The first feature will allow the customer to remove an item from the cart. The second feature will display a summary of the cart at the top of the page.
现在,我们已经介绍了自定义模型绑定器,到了我们通过添加两个新特性来完成购物车功能的时候了。第一个特性将允许客户删除购物车的条目。第二个特性将在页面的顶部显示购物车的摘要。
We have already defined and tested the RemoveFromCart action method in the controller, so letting the customer remove items is just a matter of exposing this method in a view, which we are going to do by adding a Remove button in each row of the cart summary. The changes to Views/Cart/Index.cshtml are shown in Listing 8-24.
我们已经定义并测试了控制器中的RemoveFromCart动作方法,因此,让客户删除条目只不过是一个在视图中暴露此方法的事情,我们打算在购物车摘要的每一行中添加一个“Remove(删除)”按钮的办法来实现它。按清单8-24对Views/Cart/Index.cshtml进行修改。
Listing 8-24. Introducing a Remove Button
清单8-24. 引入删除按钮
... <td align="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="actionButtons" type="submit" value="Remove" /> } </td> ...
■ Note We can use the strongly typed Html.HiddenFor helper method to create a hidden field for the ReturnUrl model property, but we need to use the string-based Html.Hidden helper to do the same for the Product ID field. If we had written Html.HiddenFor(x => line.Product.ProductID), the helper would render a hidden field with the name line.Product.ProductID. The name of the field would not match the names of the parameters for the CartController.RemoveFromCart action method, which would prevent the default model binders from working, so the MVC Framework would not be able to call the method.
注:我们可以用强类型的Html.HiddenFor辅助器方法对ReturnUrl模型属性来创建一个隐藏字段,但是这需要使用基于字符串的Html.Hidden辅助器方法对ProductID字段做同样的事情。如果我们写成Html.HiddenFor(x => line.Product.ProductID),该辅助器将以line.Product.ProductID为名字来渲染一个隐藏字段。该字段的名字将与CartController.RemoveFromCart动作方法的参数名不匹配,这会阻碍默认的模型绑定器进行工作,因此,MVC框架便不能调用此方法。
You can see the Remove buttons at work by running the application, adding some items to the shopping cart, and then clicking one of them. The result is illustrated in Figure 8-11.
运行应用程序,把一些条目添加到购物车,然后点击“Remove(删除)”按钮之一,你可以看到按钮起作用了。结果如图8-11所示。
Figure 8-11. Removing an item from the shopping cart
图8-11. 删除购物车条目
We have a functioning cart, but we have an issue with the way we’ve integrated the cart into the interface. Customers can tell what’s in their cart only by viewing the cart summary screen. And they can view the cart summary screen only by adding a new a new item to the cart.
我们有了一个功能化的购物车,但我们把此购物车集成到接口的方式存在一个问题。只有通过查看购物车摘要屏幕,客户才可以知道他们的购物车中有什么。而且,只能通过把一个新条目加到购物车,他们才能够查看购物车摘要屏幕。
To solve this problem, we are going to add a widget that summarizes the contents of the cart and can be clicked to display the cart contents. We’ll do this in much the same way that we added the navigation widget—as an action whose output we will inject into the Razor layout.
为了解决这一问题,我们打算添加一个汇总购物车内容,并能够点击以显示购物车内容的小部件。我们采用与添加导航部件十分相似的方式来做这件事 — 作为一个动作,我们将把它的输出注入到Razor布局。
To start, we need to add the simple method shown in Listing 8-25 to the CartController class.
首先,我们需要把如清单8-25所示的一个简单方法加到CartController类。
Listing 8-25. Adding the Summary Method to the Cart Controller
清单8-25. 将Summary模型添加到购物车控制器
... public ViewResult Summary(Cart cart) { return View(cart); } ...
You can see that this is a very simple method. It just needs to render a view, supplying the current Cart (which will be obtained using our custom model binder) as view data. We need to create a partial view that will be rendered in response to the Summary method being called. Right-click the Summary method and select Add View from the pop-up menu. Set the name of the view to Summary, check the option for a strongly typed view, and set the model class to be Cart, as shown in Figure 8-12. We want a partial view since we are going to inject it into our overall page, so check the Create as a partial view option.
你可以看到,这是一个很简单的方法。它只需要渲染一个视图,提供当前Cart(它是用我们的自定义模型绑定器获得的)作为视图数据。我们需要创建一个分部视图,它在对Summary方法调用作出响应时被渲染。右击Summary方法并从弹出菜单选择“添加视图”。将视图名设为Summary,选中“强类型视图”复选框,并把模型类设置为Cart,如图8-12所示。我们需要一个分部视图,因为我们打算把它注入到我们的所有页面,因此,选中“创建分部视图”复选框。
Figure 8-12. Adding the Summary view
图8-12. 添加Summary视图
Edit the new partial view so that it matches Listing 8-26.
编辑这个新的分部视图使之与清单8-26吻合。
Listing 8-26. The Summary Partial View
清单8-26. Summary分部视图
@model SportsStore.Domain.Entities.Cart
@{ Layout = null; }
<div id="cart"> <span class="caption"> <b>Your cart:</b> @Model.Lines.Sum(x => x.Quantity) item(s), @Model.ComputeTotalValue().ToString("c") </span>
@Html.ActionLink("Checkout", "Index", "Cart", new { returnUrl = Request.Url.PathAndQuery }, null) </div>
This is a simple view that displays the number of items in the cart, the total cost of those items, and a link that shows the contents of the cart to the user. Now that we’ve defined the view that is returned by the Summary action method, we can include the rendered result in the _Layout.cshtml file, as shown in Listing 8-27.
这是一个简单的视图,它显示购物车中的条目数、这些条目的总费用、以及把购物车内容显示给用户的一个连接。现在我们已经定义了由Summary动作方法返回的这个视图,我们可以把它渲染的结果包含到_Layout.cshtml文件中,如清单8-27所示。
Listing 8-27. Adding the Cart Summary Partial View to the Layout
清单8-27. 将购物车摘要分部视图添加到布局
... <body> <div id="header"> @{Html.RenderAction("Summary", "Cart");} <div class="title">SPORTS STORE</div> </div> <div id="categories"> @{ Html.RenderAction("Menu", "Nav"); } ...
The last step is to add some additional CSS rules to format the elements in the partial view. Add the styles in Listing 8-28 to the Site.css file in the SportsStore.WebUI project.
最后一步是添加一些CSS规则,对该分部视图中的元素进行格式化。将清单8-28的样式加到SportsStore.WebUI项目中的Site.css文件。
Listing 8-28. Adding Styles to Site.css
清单8-28. 将样式添加到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;}
You can see the cart summary by running the application. As you add items to the cart, the item count and total increase, as shown by Figure 8-13.
运行此应用程序,你可以看到购物车摘要。当你把条目添加到购物车时,条目数以及总费用都会增加,如图8-13所示。
Figure 8-13. The cart summary widget
图8-13. 购物车摘要部件
With this addition, we now let customers know what’s in their cart, and we also provide an obvious way to check out from the store. You can see, once again, how easy it is to use RenderAction to incorporate the rendered output from an action method in a web page. This is a nice technique for breaking down the functionality of an application into distinct, reusable blocks.
利用这个附件,我们现在让客户知道他们的购物车中有什么,我们也显式地提供了一个付费离店的办法。再一次地,你可以看到用RenderAction把一个动作方法所渲染的输出组合到一个web页面是多么容易。这是把应用程序的功能分解成清晰的可重用块的一种很好的技术。
We have now reached the final customer feature in SportsStore: the ability to check out and complete an order. In the following sections, we will extend our domain model to provide support for capturing the shipping details from a user and add a feature to process those details.
我们现在已经到达了完成SportsStore最后客户特性的时刻:结算并完成订单的功能。在以下章节中,我们将扩充我们的域模型,以提供对收集用户送货细节的支持,并添加一个特性以处理这些细节。
Add a class called ShippingDetails to the Entities folder of the SportsStore.Domain project. This is the class we will use to represent the shipping details for a customer. The contents are shown in Listing 8-29.
把一个名为ShippingDetails的类添加到SportsStore.Domain项目的Entities文件夹。这是我们用来表示客户送货细节的一个类。其内容如清单8-29所示。
Listing 8-29. The ShippingDetails Class
清单8-29. 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; } } }
You can see from Listing 8-29 that we are using the validation attributes from the System.ComponentModel.DataAnnotations namespace, just as we did in Chapter 3. In order to use these attributes, we must add a reference to the assembly of the same name to the SportsStore.Domain project. We will explore validation further in Chapter 18.
你可以从清单8-29看出,我们使用了System.ComponentModel.DataAnnotations命名空间的验证属性,正如我们在第3章中所做的那样。为了使用这些属性,我们必须把一个对相同名称的程序集的引用添加到SportsStore.Domain项目(即在SportsStore.Domain项目中添加对System.ComponentModel.DataAnnotations程序集的引用 — 译者注)。我们将在第18章进一步考察验证。
■ Note The ShippingDetails class doesn’t have any functionality, so there is nothing that we can sensibly unit test.
注:ShippingDetails类没有任何功能,因此也就没有需要我们进行单元测试任何东西。
Our goal is to reach the point where users are able to enter their shipping details and submit their order. To start this off, we need to add a Checkout now button to the cart summary view. Listing 8-30 shows the change we need to apply to the Views/Cart/Index.cshtml file.
我们的目标是要达到用户能够输入其送货细节并递交其订单的功能点。为了开始这项工作,我们需要把一个“Checkout now(立即结算)”按钮添加到购物摘要视图。清单8-30显示了我们需要对Views/Cart/Index.cshtml文件的修改。
Listing 8-30. Adding the Checkout Now Button
清单8-30. 添加Checkout Now(结算)按钮
... </table> <p align="center" class="actionButtons"> <a href="@Model.ReturnUrl">Continue shopping</a> @Html.ActionLink("Checkout now", "Checkout") </p>
This single change generates a link that, when clicked, calls the Checkout action method of the Cart controller. You can see how this button appears in Figure 8-14.
这个修改生成一个链接,点击的时候调用Cart控制器的Checkout动作方法。你可以在图8-14中看到该按钮是如何显示的。
Figure 8-14. The Checkout now button
图8-14. Checkout now按钮
As you might expect, we now need to define the Checkout method in the CartController class. as shown in Listing 8-31.
正如你可能想到的,我们现在需要在CartController类中定义这个Checkout方法。如清单8-31所示。
Listing 8-31. The Checkout Action Method
清单8-31. Checkout动作方法
public ViewResult Checkout() { return View(new ShippingDetails()); }
The Checkout method returns the default view and passes a new ShippingDetails object as the view model. To create the corresponding view, right-click the Checkout method, select Add View, and fill in the dialog box as shown in Figure 8-15. We are going to use the ShippingDetails domain class as the basis for the strongly typed view. Check the option to use a layout, since we are rendering a full page and want it to be consistent with the rest of the application.
Checkout方法返回默认视图,并传递一个新的ShippingDetails对象作为视图模型。为了创建相应的视图,右击Checkout方法,选择“添加视图”,并按图8-15填充其对话框。我们打算用ShippingDetails域类作为这个强类型视图的基础。选中“使用布局”复选框,因为我们要渲染一个完整的页面并希望它与应用程序的其余部分一致。
Figure 8-15. Adding the Checkout view
图8-15. 添加Checkout视图
Set the contents of the view to match the markup shown in Listing 8-32.
设置该视图的内容,使之与清单8-32所示的标记相匹配。
Listing 8-32. The Checkout.cshtml View
清单8-23. Checkout.cshtml视图
@model SportsStore.Domain.Entities.ShippingDetails
@{ ViewBag.Title = "SportStore: Checkout"; }
<h2>Check out now</h2> Please enter your details, and we'll ship your goods right away! @using (Html.BeginForm()) { <h3>Ship to</h3> <div>Name: @Html.EditorFor(x => x.Name)</div>
<h3>Address</h3> <div>Line 1: @Html.EditorFor(x => x.Line1)</div> <div>Line 2: @Html.EditorFor(x => x.Line2)</div> <div>Line 3: @Html.EditorFor(x => x.Line3)</div> <div>City: @Html.EditorFor(x => x.City)</div> <div>State: @Html.EditorFor(x => x.State)</div> <div>Zip: @Html.EditorFor(x => x.Zip)</div> <div>Country: @Html.EditorFor(x => x.Country)</div>
<h3>Options</h3> <label> @Html.EditorFor(x => x.GiftWrap) Gift wrap these items </label>
<p align="center"> <input class="actionButtons" type="submit" value="Complete order" /> </p> }
You can see how this view is rendered by running the application, adding an item to the shopping cart, and clicking the Checkout now button. As you can see in Figure 8-16, the view is rendered as a form for collecting the customer’s shipping details.
运用此应用程序,把一个条目添加到购物车,然后点击“Checkout now(立即结算)”按钮,你可以看到该视图是如何渲染的。正如你在图8-16中所看到的,该视图为收集客户的送货细节渲染了一个表单。
Figure 8-16. The shipping details form
图8-16. 运送细节表单
We have rendered the input elements for each of the form fields using the Html.EditorFor helper method. This method is an example of a templated view helper. We let the MVC Framework work out what kind of input element a view model property requires, instead of specifying it explicitly (by using Html.TextBoxFor, for example).
我们用Html.EditorFor辅助器方法为每个表单字段渲染了一个input元素。该方法是模板视图辅助器的一个例子。我们让MVC框架去决定一个视图模型属性需要采用哪种input元素,而不是进行明确的指定(例如用Html.TextBoxFor)。
We will explain templated view helpers in detail in Chapter 16, but you can see from the figure that the MVC Framework is smart enough to render a checkbox for bool properties (such as the gift wrap option) and text boxes for the string properties.
我们将在第16章详细解释模板视图辅助器,但你可以从上图看到,MVC框架足够智能地对布尔属性(如“彩带包装”选项)渲染了一个复选框(checkbox)、对字符串属性渲染了一个文本框。
■ Tip We could go further and replace most of the markup in the view with a single call to the Html.EditorForModel helper method, which would generate the labels and inputs for all of the properties in the ShippingDetails view model class. However, we wanted to separate the elements so that the name, address, and options appear in different regions of the form, so it is simple to refer to each property directly.
提示:我们可以更进一步,并逐一调用Html.EditorForModel辅助器方法来替换视图中的大部分标记,这会对ShippingDetails视图模型类中的所有属性生成标签(label)和输入(input)标记。然而,我们希望把这些元素分离开来,以使名字、地址、以及一些选项能够出现在表单的不同区域,因此,直接简单地引用了每个属性。
We need a component in our application to which we can hand details of an order for processing. In keeping with the principles of the MVC model, we are going to define an interface for this functionality, write an implementation of the interface, and then associate the two using our DI container, Ninject.
在这个应用程序中,我们需要一个组件,以便能够对订单的细节进行处理。与MVC模型原理一致,我们打算为此功能定义一个接口、编写该接口的一个实现、然后用我们的DI容器Ninject把两者关联起来。
Add a new interface called IOrderProcessor to the Abstract folder of the SportsStore.Domain project and edit the contents so that they match Listing 8-33.
把名为IOrderProcessor的接口添加到SportsStore.Domain项目的Abstract文件夹,并编辑其内容使之与清单8-33吻合。
Listing 8-33. The IOrderProcessor Interface
清单8-33. IOrderProcessor接口
using SportsStore.Domain.Entities; namespace SportsStore.Domain.Abstract { public interface IOrderProcessor { void ProcessOrder(Cart cart, ShippingDetails shippingDetails); } }
Our implementation of IOrderProcessor is going to deal with orders by e-mailing them to the site administrator. We are, of course, simplifying the sales process. Most e-commerce sites wouldn’t simply e-mail an order, and we haven’t provided support for processing credit cards or other forms of payment. But we want to keep things focused on MVC, and so e-mail it is.
IOrderProcessor的实现打算通过把订单邮件发送给网站管理员的办法来处理订单。当然,我们简化了销售过程。大多数电子商务网站不会简单地发送订单邮件,而且我们没有提供对处理信用卡或其它支付表单的支持。但我们希望保持事情聚焦在MVC方面,因此选择了发送邮件。
Create a new class called EmailOrderProcessor in the Concrete folder of the SportsStore.Domain project and edit the contents so that they match Listing 8-34. This class uses the built-in SMTP support included in the .NET Framework library to send an e-mail.
在SportsStore.Domain项目中的Concrete文件夹中创建一个名为EmailOrderProcessor的新类,并按清单8-34编辑其内容。这个类使用了包含在.NET Framework库中内建的SMTP支持以发送一份电子邮件。
Listing 8-34. The EmailOrderProcessor Class
清单8-34. EmailOrderProcessor类
using System.Net.Mail; using System.Text; using SportsStore.Domain.Abstract; using SportsStore.Domain.Entities; using System.Net;
namespace SportsStore.Domain.Concrete {
public class EmailSettings {
public string MailToAddress = "[email protected]"; public string MailFromAddress = "[email protected]"; 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 = @"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.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(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, // From emailSettings.MailToAddress, // To "New order submitted!",// Subject body.ToString());// Body if (emailSettings.WriteAsFile) { mailMessage.BodyEncoding = Encoding.ASCII; } smtpClient.Send(mailMessage); } } } } // 这里少了一个括号 — 译者注
To make things simpler, we have defined the EmailSettings class in Listing 8-34 as well. An instance of this class is demanded by the EmailOrderProcessor constructor and contains all of the settings that are required to configure the .NET e-mail classes.
为了使事情简单,我们在清单8-34中也定义了EmailSettings类。EmailOrderProcessor的构造器需要这个类(EmailSettings)的一个实例,这个类的实例也包含了配置.NET邮件类所需要设置的全部信息。
■ Tip Don’t worry if you don’t have an SMTP server available. If you set the EmailSettings.WriteAsFile property to true, the e-mail messages will be written as files to the directory specified by the FileLocation property. This directory must exist and be writable. The files will be written with the .eml extension, but they can be read with any text editor.
提示:如果你还没有可用的SMTP服务器,不必担心。如果将EmailSettings.WriteAsFile属性设置为true,将会把邮件消息作为文件写到由FileLcation属性指定的目录。该目录必须已经存在且是可写入的。邮件文件的扩展名将为.eml,但它们不能被任何文本编辑器所读取。
Now that we have an implementation of the IOrderProcessor interface and the means to configure it, we can use Ninject to create instances of it. Edit the NinjectControllerFactory class in the SportsStore.WebUI project and make the changes shown in Listing 8-35 to the AddBindings method.
现在,我们有了IOrderProcessor接口的一个实现以及配置它的手段,我们可以用Ninject来创建它的实例。编辑SportsStore.WebUI项目中的NinjectControllerFactory类(在Infrastructure文件夹中 — 译者注),对AddBindings方法进行如清单8-35所示的修改。
Listing 8-35. Adding Ninject Bindings for IOrderProcessor
清单8-35. 添加对IOrderProcessor的Ninject绑定
private void AddBindings() { // put additional bindings here // 这里放置附加绑定器 ninjectKernel.Bind<IProductRepository>().To<EFProductRepository>();
EmailSettings emailSettings = new EmailSettings { WriteAsFile = bool.Parse(ConfigurationManager.AppSettings["Email.WriteAsFile"] ?? "false") };
ninjectKernel.Bind<IOrderProcessor>() .To<EmailOrderProcessor>() .WithConstructorArgument("settings", emailSettings); }
We created an EmailSettings object, which we use with the Ninject WithConstructorArgument method so that we can inject it into the EmailOrderProcessor constructor when new instances are created to service requests for the IOrderProcessor interface. In Listing 8-35, we specified a value for only one of the EmailSettings properties: WriteAsFile. We read the value of this property using the ConfigurationManager.AppSettings property, which allows us to access application settings we’ve placed in the Web.config file (the one in the root project folder), which are shown in Listing 8-36.
我们创建了一个EmailSettings对象,把它与Ninject的WithConstructorArgument方法一起使用,以便在需要创建一个新实例来对IOrderProcessor接口的请求进行服务时,我们可以把它注入到EmailOrderProcessor构造器之中。在清单8-35中,我们只为EmailSettings的一个属性指定了一个值:WriteAsFiles。我们使用ConfigurationManager.AppSettings属性来读取这个值,这允许我们访问已经放在Web.config文件(根项目文件夹中的Web.config)中的应用程序设置,如清单8-36所示。
Listing 8-36. Application Settings in the Web.config File
清单8-36. Web.config文件中的应用程序设置
<appSettings> <add key="ClientValidationEnabled" value="true"/> <add key="UnobtrusiveJavaScriptEnabled" value="true"/> <add key="Email.WriteAsFile" value="true"/> </appSettings>
To complete the CartController class, we need to modify the constructor so that it demands an implementation of the IOrderProcessor interface and add a new action method that will handle the HTTP form POST when the user clicks the Complete order button. Listing 8-37 shows both changes.
为了完成CartController类,我们需要修改构造器,以使它要求IOrderProcessor接口的一个实现,并添加一个新的动作方法,它将在用户点击“Complete order(完成订单)”按钮时,处理HTTP表单的POST。清单8-37显示了这两个修改。
Listing 8-37. Completing the CartController Class
清单8-37. 完成CartController类
using System.Linq; using System.Web.Mvc; using SportsStore.Domain.Abstract; using SportsStore.Domain.Entities; 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; }
[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()); } ...rest of class...
You can see that the Checkout action method we’ve added is decorated with the HttpPost attribute, which means that it will be invoked for a POST request—in this case, when the user submits the form. Once again, we are relying on the model binder system, both for the ShippingDetails parameter (which is created automatically using the HTTP form data) and the Cart parameter (which is created using our custom binder).
你可以看到,我们已经添加的Checkout动作方法是用HttpPost属性来修饰的,这表示它将由POST请求来调用 — 这里是当用户递交表单时。再一次地,对ShippingDetails参数(使用HTTP表单数据自动创建)和Cart参数(用我们的自定义绑定器创建),我们都要依赖于模型绑定器系统。
■ Note The change in constructor forces us to update the unit tests we created for the CartController class. Passing null for the new constructor parameter will let the unit tests compile.
注:构造器中的修改迫使我们要更新为CartController类创建的单元测试。为新的构造器参数传递null会使单元测试能够通过编译。
The MVC Framework checks the validation constraints that we applied to ShippingDetails using the data annotation attributes in Listing 8-29, and any violations are passed to our action method through the ModelState property. We can see if there are any problems by checking the ModelState.IsValid property. Notice that we call the ModelState.AddModelError method to register an error message if there are no items in the cart. We’ll explain how to display such errors shortly, and we’ll have much more to say about model binding and validation in Chapters 17 and 18.
MVC框架使用清单8-29中的数据注解属性检查我们用于ShippingDetails的验证约束,并通过ModelState属性把非法情况传递给我们的动作方法。我们可以通过检查ModelState.IsValid属性看看是否有什么问题。注意,如果购物车中无条目,我们调用ModelState.AddModelError方法注册了一条错误消息。我们将在第17、18章中解释如何快捷地显示这种错误,并更多地讨论模型绑定和验证。
UNIT TEST: ORDER PROCESSING
单元测试:订单处理
To complete the unit testing for the CartController class, we need to test the behavior of the new overloaded version of the Checkout method. Although the method looks short and simple, the use of MVC Framework model binding means that there is a lot going on behind the scenes that needs to be tested.
为了完成对CartController类的单元测试,我们需要测试新重载版本的Checkout方法。虽然该方法看上去短而简单,但MVC框架模型绑定的使用意味着在需要被测试的场景背后要进行很多事。
We should process an order only if there are items in the cart and the customer has provided us with valid shipping details. Under all other circumstances, the customer should be shown an error. Here is the first test method:
如果购物车中有条目,而且客户已经向我们提供了有效的运送细节,我们应该只处理一份订单。在所有其它情况下,都应该向客户显示一条错误消息。以下是第一个测试方法:
[TestMethod] public void Cannot_Checkout_Empty_Cart() { // Arrange - create a mock order processor // 布置 — 创建一个模仿订单处理器 Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>();
// Arrange - create an empty cart // 布置 — 创建一个空的购物车 Cart cart = new Cart();
// Arrange - create shipping details // 布置 — 创建送货细节 ShippingDetails shippingDetails = new ShippingDetails();
// Arrange - create an instance of the controller // 布置 — 创建一个控制器实例 CartController target = new CartController(null, mock.Object);
// Act // 动作 ViewResult result = target.Checkout(cart, shippingDetails);
// Assert - check that the order hasn't been passed on to the processor // 断言 — 检查,订单尚未传递给处理器 mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()), Times.Never());
// Assert - check that the method is returning the default view // 断言 — 检查,该方法返回的是默认视图 Assert.AreEqual("", result.ViewName);
// Assert - check that we are passing an invalid model to the view // 断言 — 检查,我们是在把一个非法模型传递给视图 Assert.AreEqual(false, result.ViewData.ModelState.IsValid); }
This test ensures that we can’t check out with an empty cart. We check this by ensuring that the ProcessOrder of the mock IOrderProcessor implementation is never called, that the view that the method returns is the default view (which will redisplay the data entered by customers and give them a chance to correct it), and that the model state being passed to the view has been marked as invalid. This may seem like a belt-and-braces set of assertions, but we need all three to be sure that we have the right behavior. The next test method works in much the same way, but injects an error into the view model to simulate a problem reported by the model binder (which would happen in production when the customer enters invalid shipping data):
这个测试确保我们不能对空购物车进行结算。我们通过确保模仿IOrderProcessor实现的ProcessOrder方法不被调用的办法对此进行检查,该方法返回的视图是默认视图(它将重新显示由客户输入的数据并让客户进行修改),而且传递给视图的模型状态已经被标记为无效。
[TestMethod] public void Cannot_Checkout_Invalid_ShippingDetails() { // Arrange - create a mock order processor // 布置 — 创建一个模仿订单处理器 Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>();
// Arrange - create a cart with an item // 布置 — 创建含有一个条目的购物车 Cart cart = new Cart(); cart.AddItem(new Product(), 1);
// Arrange - create an instance of the controller // 布置 — 创建一个控制器实例 CartController target = new CartController(null, mock.Object);
// Arrange - add an error to the model // 布置 — 把一个错误添加到模型 target.ModelState.AddModelError("error", "error");
// Act - try to checkout // 动作 — 试图结算 ViewResult result = target.Checkout(cart, new ShippingDetails());
// Assert - check that the order hasn't been passed on to the processor // 断言 — 检查,订单尚未传递给处理器 mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()), Times.Never());
// Assert - check that the method is returning the default view // 断言 — 检查,方法返回的是默认视图 Assert.AreEqual("", result.ViewName);
// Assert - check that we are passing an invalid model to the view // 断言 — 检查,我们正在把一个非法模型传递给视图 Assert.AreEqual(false, result.ViewData.ModelState.IsValid); }
Having established that an empty cart or invalid details will prevent an order from being processed, we need to ensure that we do process orders when appropriate. Here is the test:
建立空购物车或非法细节将阻止订单被处理,我们需要确保在适当的时候进行订单处理。以下是此测试:
[TestMethod] public void Can_Checkout_And_Submit_Order() { // Arrange - create a mock order processor // 布置 — 创建一个模仿订单处理器 Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>();
// Arrange - create a cart with an item // 布置 — 创建含有一个条目的购物车 Cart cart = new Cart(); cart.AddItem(new Product(), 1);
// Arrange - create an instance of the controller // 布置 — 创建一个控制器实例 CartController target = new CartController(null, mock.Object);
// Act - try to checkout // 动作 — 试图结算 ViewResult result = target.Checkout(cart, new ShippingDetails());
// Assert - check that the order has been passed on to the processor // 断言 — 检查,订单已经被传递给处理器 mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()), Times.Once());
// Assert - check that the method is returning the Completed view // 断言 — 检查,方法返回的是Completed(已完成)视图 Assert.AreEqual("Completed", result.ViewName);
// Assert - check that we are passing a valid model to the view // 断言 — 检查,我们正在把一个有效的模型传递给视图 Assert.AreEqual(true, result.ViewData.ModelState.IsValid); }
Notice that we didn’t need to test that we can identify valid shipping details. This is handled for us automatically by the model binder using the attributes we applied to the properties of the ShippingDetails class.
注意,我们不需要测试我们可以识别的合法送货细节。这是通过模型绑定器使用我们运用于ShippingDetails类属性的性质为我们自动进行处理的。
If users enter invalid shipping information, the individual form fields that contain the problems will be highlighted, but no message will be displayed. Worse, if users try to check out an empty cart, we don’t let them complete the order, but they won’t see any error message at all. To address this, we need to add a validation summary to the view, much as we did back in Chapter 3. Listing 8-38 shows the addition to Checkout.cshtml view.
如果用户输入非法的送货信息,有问题的那些非法表单字段将被高亮,但没有消息被显示出来。更坏的是,如果用户试图对一个空购物车进行结算,我们不会让他们完成这份订单,但他们却根本看不到任何错误消息。为了改正它,我们需要把一个验证摘要添加到视图,这很象我们在第3章所做的那样。清单8-38显示了添加到Checkout.cshtml视图的内容。
Listing 8-38. Adding a Validation Summary
清单8-38. 添加验证摘要
... <h2>Check out now</h2> Please enter your details, and we'll ship your goods right away! @using (Html.BeginForm()) {
@Html.ValidationSummary()
<h3>Ship to</h3> <div>Name: @Html.EditorFor(x => x.Name)</div> ...
Now when customers provide invalid shipping data or try to check out an empty cart, they are shown useful error messages, as shown in Figure 8-17.
现在,当客户提供非法送货数据或试图对空购物车结算时,会向他们显示一些有用的错误消息,如图8-17所示。
Figure 8-17. Displaying validation messages
图8-17. 显示验证消息
To complete the checkout process, we will show customers a page that confirms the order has been processed and thanks them for their business. Right-click either of the Checkout methods in the CartController class and select Add View from the pop-up menu. Set the name of the view to Completed, as shown in Figure 8-18.
为了完成结算过程,我们将向客户显示一个已经完成订单处理的确认页面并感谢他们的购物。右击CartController类中的任何一个Checkout方法,并从弹出菜单选择“添加视图”。将视图名设为Completed,如图8-18所示。
Figure 8-18. Creating the Completed view
图8-18. 创建Completed视图
We don’t want this view to be strongly typed because we are not going to pass any view models between the controller and the view. We do want to use a layout, so that the summary page will be consistent with the rest of the application. Click the Add button to create the view and edit the content so that it matches Listing 8-39.
我们不希望这个视图是强类型视图,因为我们不打算在视图和控制器之间传递任何模型。我们要用一个布局,以使这个摘要页面与应用程序的其余部分一致。点击“添加”按钮创建这个视图,并编辑其内容使之与清单8-39吻合。
Listing 8-39. The Completed.cshtml View
清单9-39. Completed.cshtml视图
@{ ViewBag.Title = "SportsStore: Order Submitted"; }
<h2>Thanks!</h2>
Thanks for placing your order. We'll ship your goods as soon as possible.
Now customers can go through the entire process, from selecting products to checking out. If they provide valid shipping details (and have items in their cart), they will see the summary page when they click the Complete order button, as shown in Figure 8-19.
现在,客户可以进行整个过程,从选择产品到结算离开。如果他们提供有效的送货细节(并在购物车中有条目),当他们点击“Complete order(完成订单)”按钮时,他们将看到这个摘要页面,如图8-19所示。
Figure 8-19. The thank-you page
图8-19. 致谢页面
We’ve completed all the major parts of the customer-facing portion of SportsStore. It might not be enough to worry Amazon, but we have a product catalog that can be browsed by category and page, a neat shopping cart, and a simple checkout process.
我们已经完成了SportsStore面向客户部分的所有主要部件。这也许还不足以让Amazon感到担忧,但我们有了一个能够通过分类和页面进行浏览的产品分类,一个灵活的购物车,和一个简单的结算过程。
The well-separated architecture means we can easily change the behavior of any piece of the application without worrying about causing problems or inconsistencies elsewhere. For example, we could process orders by storing them in a database, and it wouldn’t have any impact on the shopping cart, the product catalog, or any other area of the application.
分离良好的体系结构,意味着我们可以很容易地修改应用程序任何片段的行为,而不必担心会引起其它问题或不兼容。例如,我们可以通过把它们存储到数据库的办法来处理订单,那么它不会对购物车、产品分类、或应用程序的其它区域有任何影响。
In the next chapter, we’ll complete the SportsStore application by adding the administration features, which will let us manage the product catalog and upload, store, and display images for each product.
在下一章中,我们将添加管理特性来完成这个SportsStore应用程序,这将使我们可以管理产品分类,并对每个产品进行更新、存储以及显示图片。