【譯著】第8章 SportsStore:導航與購物車 — 《精通ASP.NET MVC 3框架》


C H A P T E R 8
■ ■ ■

SportsStore: Navigation and Cart
SportsStore:導航與購物車

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框架提供的一些附加特性。

Adding Navigation Controls
添加導航控件

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應用程序將會更加適用得多。我們將從三個方面來做這件事:

  • Enhance the List action model in the ProductController class so that it is able to filter the Product objects in the repository.
    增強ProductController類中的List動作方法,以使它能夠對過濾儲庫中的Product對象。
  • Revisit and enhance our URL scheme and revise our rerouting strategy.
    重新考察並增強URL方案,修訂我們的路由策略。
  • Create the category list that will go into the sidebar of the site, highlighting the current category and linking to others.
    創建加入到網站工具條中的產品分類列表,高亮當前分類,並鏈接到其它分類。

Filtering the Product List
過濾產品列表

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所示。

圖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對象。在“動作”部分請求一個特定的分類,並檢查其結果,以確認該結果是正確順序的正確對象。

Refining the URL Scheme
細化URL方案

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章詳細解釋路由系統。

Table 8-1. Route Summary
表8-1. 路由摘要
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,這就會看到頁面底部的鏈接是正確地包含該分類的頁面鏈接。

Building a Category Navigation Menu
建立分類導航菜單

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整體框架前提下,創建應用程序小型片段的一種很好的辦法。

Creating the Navigation Controller
創建導航控制器

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所示。

圖8-2

Figure 8-2. Displaying the output from the Menu action method
圖8-2. 顯示Menu動作方法的輸出

Generating Category Lists
生成分類列表

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.
我們創建了一個模仿存儲庫的實現,它包含了重復性且無序的分類。我們斷言,去掉了重復,並實現了按字母排序。

Creating the Partial View
創建分部視圖

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所示。

圖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所示。如果你點擊一個分類,條目列表會作出更新,只顯示所選分類的條目。

圖8-4

Figure 8-4. The category links
圖8-4. 分類鏈接

Highlighting the Current Category
高亮當前分類

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所示。

圖8-5

Figure 8-5. Highlighting the selected category
圖8-5. 高亮選中的分類

Correcting the Page Count
修正頁面計數

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看到這種情況。

圖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所示。

圖8-7

Figure 8-7. Displaying category-specific page counts
圖8-7. 顯示特定分類的頁面計數

Building the Shopping Cart
建立購物車

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所示的購物車體驗。曾作過在線購物的人對它是熟悉的。

圖8-9

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(現在付費)”按鈕來完成訂購,並結束購物會話。

Defining the Cart Entity
定義購物車實體

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.
有時,正如上述情況一樣,測試一個類型的功能所需的代碼比該類型本身要長得多且復雜得多。不要讓這種情況讓你放棄單元測試。在簡單類中的缺陷,尤其像這種在我們應用程序中起着重要作用的購物車如果有缺陷,有可能會產生巨大的影響。

Adding the Add to Cart Buttons
添加Add to Cart按鈕

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.
對我們而言,為每個按鈕創建一個表單並不是技術上的要求。然而,由於每個表單將會回遞給同一個控制器方法,但卻帶有了一組不同的參數值,所以,這是處理按鈕點擊的一種很好而簡單的方式。

Implementing the Cart Controller
實現購物車控制器

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變量與這些參數關聯起來,意即,我們不需要自己來處理這個表單。

Displaying the Contents of the Cart
顯示購物車內容

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所示。

圖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(繼續購物)”按鈕,並返回到我們從中而來的產品頁面 — 一切都很好而順利。

圖8-10

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控制器的設計,並作一些修改。

Using Model Binding
使用模型綁定

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章更深入地了解這一特性,但這里是讓我們着手進行工作的一個很好的例子。

Creating a Custom Model Binder
創建一個自定義模型綁定器

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類進行單元測試。我們希望測試該控制器的三個不同方面:

  • The AddToCart method should add the selected product to the customer’s cart.
    AddToCart方法應該把所選的產品添加到客戶的購物車
  • After adding a product to the cart, we should be redirected to the Index view.
    把一個產品添加到購物車之后,我們應該被重定向到Index視圖。
  • The URL that the user can follow to return to the catalog should be correctly passed to the Index action method.
    用戶隨后可以返回到的產品分類的URL應該被正確地傳遞給Index動作方法。

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"); }

Completing the Cart
完成購物車

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.
現在,我們已經介紹了自定義模型綁定器,到了我們通過添加兩個新特性來完成購物車功能的時候了。第一個特性將允許客戶刪除購物車的條目。第二個特性將在頁面的頂部顯示購物車的摘要。

Removing Items from the Cart
刪除購物車的條目

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所示。

圖8-11

Figure 8-11. Removing an item from the shopping cart
圖8-11. 刪除購物車條目

Adding the Cart Summary
添加購物車摘要

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所示。我們需要一個分部視圖,因為我們打算把它注入到我們的所有頁面,因此,選中“創建分部視圖”復選框。

圖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所示。

圖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頁面是多么容易。這是把應用程序的功能分解成清晰的可重用塊的一種很好的技術。

Submitting Orders
遞交訂單

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最后客戶特性的時刻:結算並完成訂單的功能。在以下章節中,我們將擴充我們的域模型,以提供對收集用戶送貨細節的支持,並添加一個特性以處理這些細節。

Extending the Domain Model
擴充域模型

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類沒有任何功能,因此也就沒有需要我們進行單元測試任何東西。

Adding the Checkout Process
添加結算過程

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中看到該按鈕是如何顯示的。

圖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域類作為這個強類型視圖的基礎。選中“使用布局”復選框,因為我們要渲染一個完整的頁面並希望它與應用程序的其余部分一致。

圖8-15

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中所看到的,該視圖為收集客戶的送貨細節渲染了一個表單。

圖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)標記。然而,我們希望把這些元素分離開來,以使名字、地址、以及一些選項能夠出現在表單的不同區域,因此,直接簡單地引用了每個屬性。

Implementing the Order Processor
實現訂單處理器

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把兩者關聯起來。

Defining the Interface
定義接口

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);
    }
}

Implementing the Interface
實現接口

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 = "orders@example.com"; public string MailFromAddress = "sportsstore@example.com"; public bool UseSsl = true; public string Username = "MySmtpUsername"; public string Password = "MySmtpPassword"; public string ServerName = "smtp.example.com"; public int ServerPort = 587; public bool WriteAsFile = false; public string FileLocation = @"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,但它們不能被任何文本編輯器所讀取。

Registering the Implementation
注冊該(接口)實現

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>

Completing the Cart Controller
完成購物車控制器

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類屬性的性質為我們自動進行處理的。

Displaying Validation Errors
顯示驗證錯誤

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所示。

圖8-17

Figure 8-17. Displaying validation messages
圖8-17. 顯示驗證消息

Displaying a Summary Page
顯示摘要頁面

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所示。

圖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所示。

圖8-19

Figure 8-19. The thank-you page
圖8-19. 致謝頁面

Summary
小結

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應用程序,這將使我們可以管理產品分類,並對每個產品進行更新、存儲以及顯示圖片。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM