【译著】第9章 SportsStore:管理 — 《精通ASP.NET MVC 3框架》

C H A P T E R 9
■ ■ ■

SportsStore: Administration

In this final chapter on building the SportsStore application, we will give the site administrator a way of managing the product catalog. We will add support for creating, editing, and removing items from the product repository, as well as for uploading and displaying images alongside products in the catalog. And, since these are administrative functions, we’ll show you how to use authentication and filters to secure access to controllers and action methods, and to prompt users for credentials when needed.

Adding Catalog Management

The convention for managing collections of items is to present the user with two types of pages: a list page and an edit page, as shown in Figure 9-1.


Figure 9-1. Sketch of a CRUD UI for the product catalog
图9-1. 用于产品分类的CRUD界面略图

Together, these pages allow a user to create, read, update, and delete items in the collection. As noted in Chapter 7, collectively, these actions are known as CRUD. Developers need to implement CRUD so often that Visual Studio tries to help by offering to generate MVC controllers that have action methods for CRUD operations and view templates that support them.
这些页面合起来便是让用户创建、读取、更新、和删除集合中的条目。正如第7章所说明的,合起来,这些动作称为CRUD。开发人员往往需要实现CRUD,Visual Studio试图对此提供帮助,它让你能够生成CRUD控制器,这种控制器含有进行CRUD操作的动作方法,另外还提供了支持这些操作的视图模板。

Creating a CRUD Controller

We will create a new controller to handle our administration functions. Right-click the Controllers folder of the SportsStore.WebUI project and select Add † Controller from the pop-up menu. Set the name of the controller to AdminController and select Controller with empty read/write actions from the Template drop-down list, as shown in Figure 9-2.
我们将创建一个新控制器来处理这些管理功能。右击SportsStore.WebUI项目的Controllers文件夹,并从弹出菜单中选择“添加控制器”。将该控制器名设为AdminController,并在“模板”下拉列表中选择“Controller with empty read/write actions(带有空读/写动作的控制器)”,如图9-2所示。


Figure 9-2. Creating a controller using the Add Controller dialog box
图9-2. 用添加控制器对话框创建一个控制器

Click the Add button to create the controller. You can see the code that the template produces in Listing 9-1.

Listing 9-1. The Visual Studio CRUD Template
清单9-1. Visual Studio的CRUD模板

using System.Web.Mvc;
namespace SportsStore.WebUI.Controllers { public class AdminController : Controller {
public ActionResult Index() { return View(); } public ActionResult Details(int id) { return View();} public ActionResult Create() { return View();}
[HttpPost] public ActionResult Create(FormCollection collection) { try { // TODO: Add insert logic here // TODO: 这里添加插入逻辑 return RedirectToAction("Index"); } catch { return View(); } }
public ActionResult Edit(int id) { return View();}
[HttpPost] public ActionResult Edit(int id, FormCollection collection) { try { // TODO: Add update logic here // TODO: 这里添加更新逻辑 return RedirectToAction("Index"); } catch { return View(); } }
public ActionResult Delete(int id) { return View();}
[HttpPost] public ActionResult Delete(int id, FormCollection collection) { try { // TODO: Add delete logic here // TODO: 这里添加删除逻辑 return RedirectToAction("Index"); } catch { return View(); } } } }

This is Visual Studio’s default CRUD template. However, we aren’t going to use it for our SportsStore application because it isn’t ideal for our purposes. We want to demonstrate how to build up the controller and explain each step as we go. So, remove all of the methods in the controller and edit the code so that it matches Listing 9-2.
这是Visual Studio默认的CRUD模板。然而,我们不打算把它用于我们的SportsStore应用程序,因为它对我们的目标不很理想。我们希望演示如何建立这种控制器,并对我们所做的每一个步骤进行解释。因此,删掉此控制器中的所有动作方法,并编辑代码使之与清单9-2吻合。

Listing 9-2. Starting Over with the AdminController Class
清单9-2. AdminController类的大致开始

using System.Web.Mvc;
using SportsStore.Domain.Abstract; 
namespace SportsStore.WebUI.Controllers {
public class AdminController : Controller { private IProductRepository repository;
public AdminController(IProductRepository repo) { repository = repo; } } }

Rendering a Grid of Products in the Repository

To support the list page shown in Figure 9-1, we need to add an action method that will display all of the products in the repository. Following the MVC Framework conventions, we’ll call this method Index. Add the action method to the controller, as shown in Listing 9-3.

Listing 9-3. The Index Action Method
清单9-3. Index动作方法

using System.Web.Mvc;
using SportsStore.Domain.Abstract; 
namespace SportsStore.WebUI.Controllers {
public class AdminController : Controller { private IProductRepository repository;
public AdminController(IProductRepository repo) { repository = repo; }
public ViewResult Index() { return View(repository.Products); } } }


The behavior that we care about for the Index method is that it correctly returns the Product objects that are in the repository. We can test this by creating a mock repository implementation and comparing the test data with the data returned by the action method. Here is the unit test:

public void Index_Contains_All_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"},
        new Product {ProductID = 2, Name = "P2"},
        new Product {ProductID = 3, Name = "P3"},
// Arrange - create a controller // 布置 — 创建控制器 AdminController target = new AdminController(mock.Object);
// Action // 动作 Product[] result = ((IEnumerable<Product>)target.Index().ViewData.Model).ToArray();
// Assert // 断言 Assert.AreEqual(result.Length, 3); Assert.AreEqual("P1", result[0].Name); Assert.AreEqual("P2", result[1].Name); Assert.AreEqual("P3", result[2].Name); }

Creating a New Layout

We are going to create a new Razor layout to use with the SportsStore administration views. This will be a simple layout that provides a single point where we can apply changes to all of the administration views.

To create the layout, right-click the Views/Shared folder in the SportsStore.WebUI project and select Add → New Item. Select the MVC 3 Layout Page (Razor) template and set the name to _AdminLayout.cshtml, as shown in Figure 9-3. Click the Add button to create the new file.
为了创建这个布局,右击SportsStore.WebUI项目的Views/Shared文件夹,并选择“添加”→“新项目”。选择“MVC 3 Layout Page (Razor)(MVC 3布局页(Razor))”模板,并设置其名字为_AdminLayout.cshtml,如图9-3所示。点击“添加”按钮以创建这个新文件。


Figure 9-3. Creating a new Razor layout
图9-3. 创建一个新的Razor布局

The convention is to start the layout name with an underscore (_). Razor is also used by another Microsoft technology called WebMatrix, which uses the underscore to prevent layout pages from being served to browsers. MVC doesn’t need this protection, but the convention for naming layouts is carried over to MVC applications anyway.

We want to create a reference to a CSS file in the layout, as shown in Listing 9-4.

Listing 9-4. The _AdminLayout.cshtml File
清单9-4. _AdminLayout.cshtml文件

<!DOCTYPE html> 
<html> <head> <title>@ViewBag.Title</title> <link href="@Url.Content("~/Content/Admin.css")" rel="stylesheet" type="text/css" /> </head> <body> <div> @RenderBody() </div> </body> </html>

The addition (shown in bold) is a reference to a CSS file called Admin.css in the Content folder. To create the Admin.css file, right-click the Content folder, select Add † New Item, select the Style Sheet template, and set the name to Admin.css, as shown in Figure 9-4.


Figure 9-4. Creating the Admin.css file
图9-4. 创建Admin.css文件

Replace the contents of the Admin.css file with the styles shown in Listing 9-5.

Listing 9-5. The CSS Styles for the Admin Views
清单9-5. 用于Admin视图的CSS样式

BODY, TD { font-family: Segoe UI, Verdana }
H1 { padding: .5em; padding-top: 0; font-weight: bold;
   font-size: 1.5em; border-bottom: 2px solid gray; }
DIV#content { padding: .9em; }
TABLE.Grid TD, TABLE.Grid TH { border-bottom: 1px dotted gray; text-align:left; }
TABLE.Grid { border-collapse: collapse; width:100%; }
TABLE.Grid TH.NumericCol, Table.Grid TD.NumericCol {
  text-align: right; padding-right: 1em; }
FORM {margin-bottom: 0px; }
DIV.Message { background: gray; color:White; padding: .2em; margin-top:.25em; }
.field-validation-error { color: red; display: block; }
.field-validation-valid { display: none; }
.input-validation-error { border: 1px solid red; background-color: #ffeeee; }
.validation-summary-errors { font-weight: bold; color: red; }
.validation-summary-valid { display: none; }

Implementing the List View

Now that we have created the new layout, we can add a view to the project for the Index action method of the Admin controller. Right-click inside the Index method and select Add View from the pop-up menu. Set the name of the view to Index, as shown in Figure 9-5.


Figure 9-5. Creating the Index view
图9-5. 创建Index视图

We are going to use a scaffold view, which is where Visual Studio looks at the class we select for a strongly typed view and creates a view containing markup tailored for that model type. To do this, select Product from the list of model classes and List for the scaffold template, as shown in Figure 9-5.
我们打算使用一个支架(scaffold)视图,在这个视图中,Visual Studio会考查我们对强类型视图所选择的类,并且创建的视图包含了对这个模型类型量身定制的标记。为此,从模型列表中选择Product,并在支架模板中选择List,如图9-5所示。

■ Note When using the List scaffold, Visual Studio assumes you are working with an IEnumerable sequence of the model view type, so you can just select the singular form of the class from the list.
注:当使用List支架时,Visual Studio假设你要使用的是一个IEnumaerable的模型视图类型序列,因此,你只能从列表中选择一个类。

We want to apply our newly created layout, so check the option to use a layout for the view and select the _AdminLayout.cshtml file from the Views/Shared folder. Click the Add button to create the view. The scaffold view that Visual Studio creates is shown in Listing 9-6.
我们要运用新创建的布局,因此为此视图选中“使用布局”复选框,并选择Views/Shared文件夹中的_AdminLayout.cshtml文件。点击“添加”按钮创建这个视图。Visual Studio所创建的这个支架视图如清单9-6所示。

Listing 9-6. The Scaffold for List Views
清单9-6. List视图的支架

@model IEnumerable<SportsStore.Domain.Entities.Product> 
@{ ViewBag.Title = "Index"; Layout = "~/Views/Shared/_AdminLayout.cshtml"; }
<h2>Index</h2> <p> @Html.ActionLink("Create New", "Create") </p> <table> <tr> <th></th> <th>Name</th> <th>Description</th> <th>Price</th> <th>Category</th> </tr>
@foreach (var item in Model) { <tr> <td> @Html.ActionLink("Edit", "Edit", new { id=item.ProductID }) | @Html.ActionLink("Details", "Details", new { id=item.ProductID }) | @Html.ActionLink("Delete", "Delete", new { id=item.ProductID }) </td> <td>@item.Name</td> <td>@item.Description</td> <td>@String.Format("{0:F}", item.Price)</td> <td>@item.Category</td> </tr> } </table>

You can see how this view is rendered by requesting the Admin/Index URL from the application, as shown in Figure 9-6.


Figure 9-6. Rendering the scaffold List view
图9-6. 渲染支架List视图

The scaffold view does a pretty good job of setting things up for us. We have columns for each of the properties in the Product class and links for other CRUD operations that refer to action methods in the same controller. That said, the markup is a little verbose. Also, we want something that ties in with the CSS we created earlier. Edit your Index.cshtml file to match Listing 9-7.

Listing 9-7. Modifying the Index.cshtml View
清单9-7. 修改Index.cshtml视图

@model IEnumerable<SportsStore.Domain.Entities.Product> 
@{ ViewBag.Title = "Admin: All Products"; Layout = "~/Views/Shared/_AdminLayout.cshtml"; }
<h1>All Products</h1> <table class="Grid"> <tr> <th>ID</th> <th>Name</th> <th class="NumericCol">Price</th> <th>Actions</th> </tr> @foreach (var item in Model) { <tr> <td>@item.ProductID</td> <td>@Html.ActionLink(item.Name, "Edit", new { item.ProductID })</td> <td class="NumericCol">@item.Price.ToString("c")</td> <td> @using (Html.BeginForm("Delete", "Admin")) { @Html.Hidden("ProductID", item.ProductID) <input type="submit" value="Delete"/> } </td> </tr> } </table> <p>@Html.ActionLink("Add a new product", "Create")</p>

This view presents the information in a more compact form, omitting some of the properties from the Product class and using a different approach to lay out the links to specific products. You can see how this view renders in Figure 9-7.


Figure 9-7. Rendering the modified Index view
图9-7. 渲染修改后的Index视图

Now we have a nice list page. The administrator can see the products in the catalog, and there are links or buttons to add, delete, and inspect items. In the following sections, we’ll add the functionality to support each of these features.

Editing Products

To provide create and update features, we will add a product-editing page similar to the one shown in Figure 9-1. There are two halves to this job:

  • Display a page that will allow the administrator to change values for the properties of a product.
  • Add an action method that can process those changes when they are submitted.

Creating the Edit Action Method

Listing 9-8 shows the Edit method we have added to the AdminController class. This is the action method we specified in the calls to the Html.ActionLink helper method in the Index view.

Listing 9-8. The Edit Method
清单9-8. Edit方法

public ViewResult Edit(int productId) {
    Product product = repository.Products.FirstOrDefault(p => p.ProductID == productId);
    return View(product);

This simple method finds the product with the ID that corresponds to the productId parameter and passes it as a view model object.


We want to test for two behaviors in the Edit action method. The first is that we get the product we ask for when we provide a valid ID value. Obviously, we want to make sure that we are editing the product we expected. The second behavior is that we don’t get any product at all when we request an ID value that is not in the repository. Here are the test methods:

public void Can_Edit_Product() {
    // 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"},
        new Product {ProductID = 2, Name = "P2"},
        new Product {ProductID = 3, Name = "P3"},
// Arrange - create the controller // 布置 — 创建控制器 AdminController target = new AdminController(mock.Object);
// Act // 动作 Product p1 = target.Edit(1).ViewData.Model as Product; Product p2 = target.Edit(2).ViewData.Model as Product; Product p3 = target.Edit(3).ViewData.Model as Product;
// Assert // 断言 Assert.AreEqual(1, p1.ProductID); Assert.AreEqual(2, p2.ProductID); Assert.AreEqual(3, p3.ProductID); }
[TestMethod] public void Cannot_Edit_Nonexistent_Product() { // 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"}, new Product {ProductID = 2, Name = "P2"}, new Product {ProductID = 3, Name = "P3"}, }.AsQueryable()); // Arrange - create the controller AdminController target = new AdminController(mock.Object); // Act Product result = (Product)target.Edit(4).ViewData.Model; // Assert Assert.IsNull(result); }

Creating the Edit View

Now that we have an action method, we can create a view for it to render. Right-click in the Edit action method and select Add View. Leave the view name as Edit, check the option for a strongly typed view, and ensure that the Product class is selected as the model class, as shown in Figure 9-8.


Figure 9-8. Creating the Edit view
图9-8. 创建Edit视图

There is a scaffold view for the Edit CRUD operation, which you can select if you are interested in seeing what Visual Studio creates. We will use our own markup again, so we have selected Empty from the list of scaffold options. Don’t forget to check the option to apply a layout to the view and select _AdminLayout.cshtml as the view to use. Click the Add button to create the view, which will be placed in the Views/Admin folder. Edit the view so that the content matches Listing 9-9.
有一个用于Edit的CRUD操作的支架视图,如果你有兴趣要看看Visual Studio会创建什么,你可以选择它。我们仍要采用我们自己的标记,因此,我们在支架选项中选择Empty。不要忘记对此视图选中“运用布局”复选框,并选择_AdminLayout.cshtml用于该视图。点击“添加”创建这个视图,它将被放置在Views/Admin文件夹中。编辑该视图使其内容与清单9-9吻合。

Listing 9-9. The Edit View
清单9-9. Edit视图

@model SportsStore.Domain.Entities.Product
@{ ViewBag.Title = "Admin: Edit " + @Model.Name; Layout = "~/Views/Shared/_AdminLayout.cshtml"; }
<h1>Edit @Model.Name</h1>
@using (Html.BeginForm()) { @Html.EditorForModel() <input type="submit" value="Save" /> @Html.ActionLink("Cancel and return to List", "Index") }

Instead of writing out markup for each of the labels and inputs by hand, we have called the Html.EditorForModel helper method. This method asks the MVC Framework to create the editing interface for us, which it does by inspecting the model type—in this case, the Product class.
代替手工地为每个标签和输入项编写标记,我们调用了Html.EditorForModel辅助器方法。这个方法要求MVC框架为我们创建编辑接口,这是通过探测其模型类型来实现的 — 即,Product类。

To see the page that is generated from the Edit view, run the application and navigate to /Admin/Index. Click one of the product names, and you will see the page shown in Figure 9-9.


Figure 9-9. The page generated using the EditorForModel helper method
图9-9. 用EditorForModel辅助器方法生成的页面

Let’s be honest—the EditorForModel method is convenient, but it doesn’t produce the most attractive results. In addition, we don’t want the administrator to be able to see or edit the ProductID attribute, and the text box for the Description property is far too small.
我们得承认 — EditorForModel方法很方便,但它并不产生最引人的结果。此外,我们不希望管理员可以看到或编辑ProductID属性,而且,用于Description的文本框太小了。

We can give the MVC Framework directions about how to create editors for properties by using model metadata,. This allows us to apply attributes to the properties of the new model class to influence the output of the Html.EditorForModel method. Listing 9-10 shows how to use metadata on the Product class in the SportsStore.Domain project.
通过使用模型元数据,我们可以指示MVC框架如何创建属性的编辑器(属性编辑器是指HTML页面上对某属性进行输入或编辑的UI — 译者注)。这允许我们能够把注解属性运用于这个新模型类的属性上,以影响Html.EditorForModel方法的输出。清单9-10演示了如何在SportsStore.Domain项目中的Product类上使用元数据。

Listing 9-10. Using Model Metadata
清单9-10. 使用模型元数据

using System.ComponentModel.DataAnnotations;
using System.Web.Mvc; 
namespace SportsStore.Domain.Entities { public class Product {
[HiddenInput(DisplayValue=false)] public int ProductID { get; set; }
public string Name { get; set; }
[DataType(DataType.MultilineText)] public string Description { get; set; }
public decimal Price { get; set; } public string Category { get; set; } } }

The HiddenInput attribute tells the MVC Framework to render the property as a hidden form element, and the DataType attribute allows us to specify how a value is presented and edited. In this case, we have selected the MultilineText option. The HiddenInput attribute is part of the System.Web.Mvc namespace, which means that we must add a reference to the System.Web.Mvc assembly in the SportsStore.Domain project. The other attributes are contained in the System.ComponentModel.DataAnnotations namespace, whose containing assembly is included in an MVC application project by default.

Figure 9-10 shows the Edit page once the metadata has been applied. You can no longer see or edit the ProductId property, and you have a multiline text box for entering the description. However, the UI still looks pretty poor.


Figure 9-10. The effect of applying metadata
图9-10. 运用元数据的效果

We can make some simple improvements using CSS. When the MVC Framework creates the input fields for each property, it assigns different CSS classes to them. When you look at the source for the page shown in Figure 9-10, you can see that the textarea element that has been created for the product description has been assigned the "text-box-multi-line" CSS class:

<div class="editor-field">
<textarea class="text-box multi-line" id="Description" name="Description">...description text...</textarea>

To improve the appearance of the Edit view, add the styles shown in Listing 9-11 to the Admin.css file in the Content folder of the SportsStore.WebUI project.
为了改善Edit视图的外观,把清单9-11所示的样式添加到SportsStore.WebUI 项目的Content文件夹中的Admin.css文件。

Listing 9-11. CSS Styles for the Editor Elements
清单9-11. 用于编辑器元素的样式

.editor-field { margin-bottom: .8em; }
.editor-label { font-weight: bold; }
.editor-label:after { content: ":" }
.text-box { width: 25em; }
.multi-line { height: 5em; font-family: Segoe UI, Verdana; }

Figure 9-11 shows the effect these styles have on the Edit view.


Figure 9-11. Applying CSS to the editor elements
图9-11. 将CSS运用于编辑器元素

The rendered view is still pretty basic, but it is functional and will do for our administration needs.

As you saw in this example, the page a template view helper like EditorForModel creates won’t always meet your requirements. We’ll discuss using and customizing template view helpers in detail in Chapter 16.

Updating the Product Repository

Before we can process edits, we need to enhance the product repository so that we can save changes. First, we will add a new method to the IProductRepository interface, as shown in Listing 9-12.

Listing 9-12. Adding a Method to the Repository Interface
清单9-12. 把一个方法添加到存储库接口

using System.Linq;
using SportsStore.Domain.Entities; 
namespace SportsStore.Domain.Abstract {
public interface IProductRepository { IQueryable<Product> Products { get; }
void SaveProduct(Product product); } }

We can then add this method to our Entity Framework implementation of the repository, the EFProductRepository class, as shown in Listing 9-13.

Listing 9-13. Implementing the SaveProduct Method
清单9-13. 实现SaveProduct方法

using System.Linq;
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities; 
namespace SportsStore.Domain.Concrete {
public class EFProductRepository : IProductRepository { private EFDbContext context = new EFDbContext();
public IQueryable<Product> Products { get { return context.Products; } }
public void SaveProduct(Product product) { if (product.ProductID == 0) { context.Products.Add(product); } context.SaveChanges(); } } }

The implementation of the SaveChanges method adds a product to the repository if the ProductID is 0; otherwise, it applies any changes to the existing product.

Handling Edit POST Requests

At this point, we are ready to implement an overload of the Edit action method that will handle POST requests when the administrator clicks the Save button. The new method is shown in Listing 9-14.

Listing 9-14. Adding the POST-Handling Edit Action Method
清单9-14. 添加处理POST的Edit动作方法

public ActionResult Edit(Product product) {
if (ModelState.IsValid) { repository.SaveProduct(product); TempData["message"] = string.Format("{0} has been saved", product.Name); return RedirectToAction("Index"); } else { // there is something wrong with the data values // 数据值有错误 return View(product); } }

We check that the model binder has been able to validate the data submitted to the user. If everything is OK, we save the changes to the repository, and then invoke the Index action method to return the user to the list of products. If there is a problem with the data, we render the Edit view again so that the user can make corrections.

After we have saved the changes in the repository, we store a message using the Temp Data feature. This is a key/value dictionary, similar to the session data and View Bag features we have used previously. The key difference is that TempData is deleted at the end of the HTTP request.
在存储库中保存了这些修改之后,我们用Temp Data(临时数据)特性存储了一条消息。这是一个键/值字典,它类似于我们之前已经用过的会话数据和View Bag(视图包)特性。关键差别是TempData在HTTP请求结束时被删掉了。

Notice that we return the ActionResult type from the Edit method. We’ve been using the ViewResult type until now. ViewResult is derived from ActionResult, and it is used when you want the framework to render a view. However, other types of ActionResults are available, and one of them is returned by the RedirectToAction method. We use that in the Edit action method to invoke the Index action method.
注意,Edit方法返回的是ActionResult类型。到目前为止,我们一直在用ViewResult类型。ViewResult派生于ActionResult,而且它是在你希望框架去渲染一个视图时使用的。然而,其它类型的ActionResults也是可用的,RedirectToAction方法所返回的是其中之一(意即,ActionResults的类型有好几种,RedirectToAction的返回类型是ActionResult类型的一种 — 译者注)。我们在Edit动作方法中用它去请求Index动作方法。

We can’t use ViewBag in this situation because the user is being redirected. ViewBag passes data between the controller and view, and it can’t hold data for longer than the current HTTP request. We could have used the session data feature, but then the message would be persistent until we explicitly removed it, which we would rather not have to do. So, the Temp Data feature is the perfect fit. The data is restricted to a single user’s session (so that users don’t see each other’s TempData) and will persist until we have read it. We will read the data in the view rendered by the action method to which we have redirected the user.
这种情况下我们不能使用ViewBag,因为用户被重定向了。ViewBag在控制器与视图之间传递数据,但它保持数据的时间不能比当前HTTP请求还长(注意,重定向意味着用户是跨请求的,故ViewBag不能用于重定向情况下控制与视图之间的数据传递 — 译者注)。也许我们可以使用会话数据特性,但在另一方面,消息会是持久的,直到我们明确地删除它为止,那我们还不如不用它。因此,Temp Data特性是十分合适的。其数据被限制到一个单一用户的会话(于是用户不会看到相互的TempData),并且将保持到我们已经读取了它为止。在动作方法渲染的视图中,我们把这些数据读给已经被重定向的用户。


For the POST-processing Edit action method, we need to make sure that valid updates to the Product object that the model binder has created are passed to the product repository to be saved. We also want to check that invalid updates—where a model error exists—are not passed to the repository. Here are the test methods:
对于处理POST的Edit动作方法,我们需要确保,对模型绑定器创建的Product对象所作的有效更新,被传递给产品存储库进行了保存。我们还要检查非法更新 — 存在模型错误 — 不会被传递给存储库。以下是相应的测试方法:

public void Can_Save_Valid_Changes() {
    // Arrange - create mock repository
    // 布置 — 创建模仿存储库
    Mock<IProductRepository> mock = new Mock<IProductRepository>(); 
// Arrange - create the controller // 布置 — 创建控制器 AdminController target = new AdminController(mock.Object);
// Arrange - create a product // 布置 — 创建一个产品 Product product = new Product {Name = "Test"};
// Act - try to save the product // 动作 — 试着保存这个产品 ActionResult result = target.Edit(product);
// Assert - check that the repository was called // 断言 — 检查,调用了存储库 mock.Verify(m => m.SaveProduct(product));
// Assert - check the method result type // 断言 — 检查方法的结果类型 Assert.IsNotInstanceOfType(result, typeof(ViewResult)); }
[TestMethod] public void Cannot_Save_Invalid_Changes() { // Arrange - create mock repository // 布置 — 创建模仿存储库 Mock<IProductRepository> mock = new Mock<IProductRepository>();
// Arrange - create the controller // 布置 — 创建控制器 AdminController target = new AdminController(mock.Object);
// Arrange - create a product // 布置 — 创建一个产品 Product product = new Product { Name = "Test" };
// Arrange - add an error to the model state // 布置 — 把一个错误添加到模型状态 target.ModelState.AddModelError("error", "error");
// Act - try to save the product // 动作 — 试图保存产品 ActionResult result = target.Edit(product);
// Assert - check that the repository was not called // 断言 — 存储库未被调用 mock.Verify(m => m.SaveProduct(It.IsAny<Product>()), Times.Never());
// Assert - check the method result type // 断言 — 检查方法的结果类型 Assert.IsInstanceOfType(result, typeof(ViewResult)); }

Displaying a Confirmation Message

We are going to deal with the message we stored using TempData in the _AdminLayout.cshtml layout file. By handling the message in the template, we can create messages in any view that uses the template, without needing to create additional Razor blocks. Listing 9-15 shows the change to the file.

Listing 9-15. Handling the ViewBag Message in the Layout
清单9-15. 在布局中处理ViewBag消息

<!DOCTYPE html>
    <link href="@Url.Content("~/Content/Admin.css")" rel="stylesheet" type="text/css" />
        @if (TempData["message"] != null) {
            <div class="Message">@TempData["message"]</div>

■ Tip The benefit of dealing with the message in the template like this is that users will see it displayed on whatever page is rendered after they have saved a change. At the moment, we return them to the list of products, but we could change the workflow to render some other view, and the users will still see the message (as long as the next view also uses the same layout).

We how now have all the elements we need to test editing products. Run the application, navigate to the Admin/Index URL, and make some edits. Click the Save button. You will be returned to the list view, and the TempData message will be displayed, as shown in Figure 9-12.


Figure 9-12. Editing a product and seeing the TempData message
图9-12. 编辑一个产品并看到TempData消息

The message will disappear if you reload the product list screen, because TempData is deleted when it is read. That is very convenient, since we don’t want old messages hanging around.

Adding Model Validation

As is always the case, we need to add validation rules to our model entity. At the moment, the administrator could enter negative prices or blank descriptions, and SportsStore would happily store that data in the database. Listing 9-16 shows how we have applied data annotations attributes to the Product class, just as we did for the ShippingDetails class in the previous chapter.
情况总是这样,我们需要对我们的模型实体添加验证规则。此刻,管理员能够输入负数价格或空白的产品描述,那么SportsStore也将会愉快地把这些数据存储到数据库中(这当然不行,所以要添加验证规则 — 译者注)。清单9-16演示了我们把数据注解属性(Data annotations attributes)运用于Product类,就像我们上一章对ShippingDetails类所做的那样。

Listing 9-16. Applying Validation Attributes to the Product Class
清单9-16. 将验证属性运用于Product类

using System.ComponentModel.DataAnnotations;
using System.Web.Mvc; 
namespace SportsStore.Domain.Entities {
public class Product { [HiddenInput(DisplayValue=false)] public int ProductID { get; set; }
[Required(ErrorMessage = "Please enter a product name")] public string Name { get; set; }
[Required(ErrorMessage = "Please enter a description")] [DataType(DataType.MultilineText)] public string Description { get; set; }
[Required] [Range(0.01, double.MaxValue, ErrorMessage = "Please enter a positive price")] public decimal Price { get; set; }
[Required(ErrorMessage = "Please specify a category")] public string Category { get; set; } } }

■ Note We have reached the point with the Product class where there are more attributes than properties. Don’t worry if you feel that the attributes make the class unreadable. You can move the attributes into a different class and tell MVC where to find them. We’ll show you how to do this in Chapter 16.

When we used the Html.EditorForModel helper method to create the form elements to edit a Product, the MVC Framework added all the markup and CSS needed to display validation errors inline. Figure 9-13 shows how this appears when you edit a product and enter data that breaks the validation rules we applied in Listing 9-16.


Figure 9-13. Data validation when editing products
图9-13. 编辑产品时的数据验证

Enabling Client-Side Validation

At present, our data validation is applied only when the administrator submits edits to the server. Most web users expect immediate feedback if there are problems with the data they have entered. This is why web developers often want to perform client-side validation, where the data is checked in the browser using JavaScript. The MVC Framework can perform client-side validation based on the data annotations we applied to the domain model class.

This feature is enabled by default, but it hasn’t been working because we have not added links to the required JavaScript libraries. The simplest place to add these links is in the _AdminLayout.cshtml file, so that client validation can work on any page that uses this layout. Listing 9-17 shows the changes to the layout. The MVC client-side validation feature is based on the jQuery JavaScript library, which can be deduced from the name of the script files.

Listing 9-17. Importing JavaScript Files for Client-Side Validation
清单9-17. 引入用于客户端验证的JavaScript文件

<!DOCTYPE html>
    <link href="@Url.Content("~/Content/Admin.css")" rel="stylesheet" type="text/css" />
    <script src="@Url.Content("~/Scripts/jquery-1.4.4.min.js")"
    <script src="@Url.Content("~/Scripts/jquery.validate.min.js")"
    <script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")"
        @if (TempData["message"] != null) {
            <div class="Message">@TempData["message"]</div>

With these additions, client-side validation will work for our administration views. The appearance of error messages to the user is the same, because the CSS classes that are used by the server validation are also used by the client-side validation. But the response is immediate and doesn’t require a request to be sent to the server.

In most situations, client-side validation is a useful feature, but if, for some reason, you don’t want to validate at the client, you need to use the following statements:

HtmlHelper.ClientValidationEnabled = false;
HtmlHelper.UnobtrusiveJavaScriptEnabled = false;

If you put these statements in a view or in a controller, then client-side validation is disabled only for the current action. You can disable client-side validation for the entire application by using those statements in the Application_Start method of Global.asax or by adding values to the Web.config file, like this:

        <add key="ClientValidationEnabled" value="false"/>
        <add key="UnobtrusiveJavaScriptEnabled" value="false"/>

Creating New Products

Next, we will implement the Create action method, which is the one specified in the Add a new product link in the product list page. This will allow the administrator to add new items to the product catalog. Adding the ability to create new products will require only one small addition and one small change to our application. This is a great example of the power and flexibility of a well-thought-out MVC application.
下一步,我们将实现Create动作方法,这是在产品列表页面中“Add a new product(添加新产品)”链接所指定的方法。它允许管理员把一个新条目添加到产品分类。添加创建新产品的能力只需要一个小的附件,并对我们的应用程序作一些小的修改即可。这是精心构思MVC应用程序功能和适应性的一个很好的例子。

First, add the Create method, shown in Listing 9-18, to the AdminController class.

Listing 9-18. Adding the Create Action Method to the Admin Controller
清单9-18. 将Create动作方法添加到Admin控制器

public ViewResult Create() {
    return View("Edit", new Product());

The Create method doesn’t render its default view. Instead, it specifies that the Edit view should be used. It is perfectly acceptable for one action method to use a view that is usually associated with another view. In this case, we inject a new Product object as the view model so that the Edit view is populated with empty fields.

This leads us to the modification. We would usually expect a form to postback to the action that rendered it, and this is what the Html.BeginForm assumes by default when it generates an HTML form. However, this doesn’t work for our Create method, because we want the form to be posted back to the Edit action so that we can save the newly created product data. To fix this, we can use an overloaded version of the Html.BeginForm helper method to specify that the target of the form generated in the Edit view is the Edit action method of the Admin controller, as shown in Listing 9-19.
这使我们能够进行修改。通常,我们期望一个表单回递给渲染它的动作,而且这正是Html.BeginForm在生成一个HTML表单时所假设的默认情况。然而,我们的Create方法并不是这样,因为我们希望此表单被回递给Edit动作,以便我们可以保存这个新创建的产品数据。为了对此进行修正(这里,Create动作方法调用了Edit视图,当用户在此视图的表单中编辑数据,然后进行递交时,默认会被回递给Create动作方法,但我们希望被回递给Edit动作方法,故需要修正 — 译者注),我们可以用重载的Html.BeginForm辅助器方法来指明:在Edit视图中生成的表单的目标(始终)是Admin控制器的Edit动作方法,如清单9-19所示。

Listing 9-19. Explicitly Specifying an Action Method and Controller for a Form
清单9-19. 明确地指定表单所用的控制器和动作方法

@model SportsStore.Domain.Entities.Product
@{ ViewBag.Title = "Admin: Edit " + @Model.Name; Layout = "~/Views/Shared/_AdminLayout.cshtml"; }
<h1>Edit @Model.Name</h1> @using (Html.BeginForm("Edit", "Admin")) { @Html.EditorForModel() <input type="submit" value="Save" /> @Html.ActionLink("Cancel and return to List", "Index") }

Now the form will always be posted to the Edit action, regardless of which action rendered it. We can create products by clicking the Add a new product link and filling in the details, as shown in Figure 9-14.
现在,此表单将总是被递交给Edit动作,而不管渲染它的是哪个动作。通过点击“Add a new product(添加新产品)”链接,并进行详细填充,我们可以创建产品,如图9-14所示。


Figure 9-14. Adding a new product to the catalog
图9-14. 对分类添加一个新产品

Deleting Products

Adding support for deleting items is fairly simple. First, we add a new method to the IProductRepository interface, as shown in Listing 9-20.

Listing 9-20. Adding a Method to Delete Products
清单9-20. 添加一个删除产品的方法

using System.Linq;
using SportsStore.Domain.Entities; 
namespace SportsStore.Domain.Abstract {
public interface IProductRepository { IQueryable<Product> Products { get; }
void SaveProduct(Product product);
void DeleteProduct(Product product); } }

Next, we implement this method in our Entity Framework repository class, EFProductRepository, as shown in Listing 9-21.
下一步,在我们的Entity Framework存储库类EFProductRepository中实现这个方法,如清单9-21所示。

Listing 9-21. Implementing Deletion Support in the Entity Framework Repository Class
清单9-21. 在实体框架存储库类中实现删除支持

public void DeleteProduct(Product product) {

The final step is to implement a Delete action method in the Admin controller. This action method should support only POST requests, because deleting objects is not an idempotent operation. As we’ll explain in Chapter 11, browsers and caches are free to make GET requests without the user’s explicit consent, so we must be careful to avoid making changes as a consequence of GET requests. Listing 9-22 shows the new action method.

Listing 9-22. The Delete Action Method
清单9-22. Delete动作方法

public ActionResult Delete(int productId) {
    Product prod = repository.Products.FirstOrDefault(p => p.ProductID == productId); 
if (prod != null) { repository.DeleteProduct(prod); TempData["message"] = string.Format("{0} was deleted", prod.Name); }
return RedirectToAction("Index"); }


We want to test two behaviors of the Delete action method. The first is that when a valid ProductID is passed as a parameter, the action method calls the DeleteProduct method of the repository and passes the correct Product object to be deleted. Here is the test:

public void Can_Delete_Valid_Products() {
    // Arrange - create a Product
    // 布置 — 创建一个产品
    Product prod = new Product { ProductID = 2, Name = "Test" };
// 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"}, prod, new Product {ProductID = 3, Name = "P3"}, }.AsQueryable());
// Arrange - create the controller // 布置 — 创建控制器 AdminController target = new AdminController(mock.Object);
// Act - delete the product // 动作 — 删除产品 target.Delete(prod.ProductID);
// Assert - ensure that the repository delete method was // called with the correct Product // 断言 — 确保存储库的删除方法是针对正确的产品被调用的 mock.Verify(m => m.DeleteProduct(prod)); }

The second test is to ensure that if the parameter value passed to the Delete method does not correspond to a valid product in the repository, the repository DeleteProduct method is not called. Here is the test:

public void Cannot_Delete_Invalid_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"},
        new Product {ProductID = 2, Name = "P2"},
        new Product {ProductID = 3, Name = "P3"},
// Arrange - create the controller // 布置 — 创建控制器 AdminController target = new AdminController(mock.Object);
// Act - delete using an ID that doesn't exist // 动作 — 用一个不存在的ID进行删除 target.Delete(100);
// Assert - ensure that the repository delete method was // called with the correct Product // 断言 — 确保存储库删除方法是针对正确的Product进行调用的 mock.Verify(m => m.DeleteProduct(It.IsAny<Product>()), Times.Never()); }

You can see the new function at work simply by clicking one of the Delete buttons in the product list page, as shown in Figure 9-15. As shown in the figure, we have taken advantage of the TempData variable to display a message when a product is deleted from the catalog.


Figure 9-15. Deleting a product from the catalog
图9-15. 从分类中删除一个产品

And at this point, we’ve implemented all of the CRUD operations. We can now create, read, update, and delete products.

Securing the Administration Features

It won’t have escaped your attention that anyone would be able to modify the product catalog if we deployed the application right now. All someone would need to know is that the administration features are available using the Admin/Index URL. To prevent random people from wreaking havoc, we are going to password-protect access to the entire Admin controller.

Setting Up Forms Authentication

Since ASP.NET MVC is built on the core ASP.NET platform, we have access to the ASP.NET Forms Authentication facility, which is a general-purpose system for keeping track of who is logged in. We’ll cover forms authentication in more detail in Chapter 22. For now, we’ll simply show you how to set up the most basic of configurations.
由于ASP.NET MVC建立在核心的ASP.NET平台之上,我们可以访问ASP.NET的表单认证工具,它是对已登录人员保持跟踪的一个通用系统。我们将在第22章更详细地涉及表单认证。现在,我们只简单地向你演示如何建立最基本的配置。

If you open the Web.config file, you will be able to find a section entitled authentication, like this one:

<authentication mode="Forms">
  <forms loginUrl="~/Account/LogOn" timeout="2880"/>

As you can see, forms authentication is enabled automatically in an MVC application created with the Empty or Internet Application template. The loginUrl attribute tells ASP.NET which URL users should be directed to when they need to authenticate themselves—in this case, the /Account/Logon page. The timeout attribute specifies how long a user is authenticated after logging in. By default, this is 48 hours (2,880 minutes). We’ll explain some of the other configuration options in Chapter 22.
正如你所看到的,在一个用Empty或Internet应用程序模板创建的MVC应用程序中,表单认证是自动可用的。LoginUrl属性告诉ASP.NET,当用户需要对其自己进行认证时,他们应该被定向到哪个URL — 这里是/Account/Logon页面。Timeout属性指明一个被认证的用户登录之后的时间有多长。默认地,是48小时(2880分钟)。我们将在第22章解释一些其它配置选项。

■ Note The main alternative to forms authentication is Windows authentication, where the operating system credentials are used to identify users. This is a great facility if you are deploying intranet applications and all of your users are in the same Windows domain. However, it’s not applicable for Internet applications.

If we had selected the MVC Internet Application template when we created the SportsStore project, Visual Studio would have created the AccountController class and its LogOn action method for us. The implementation of this method would have used the core ASP.NET membership feature to manage accounts and passwords, which we’ll cover in Chapter 22. Here, the membership system would be overkill for our application, so we will use a simpler approach. We will create the controller ourselves.
如果我们在创建SportsStore项目时已经选择了“MVC Internet Application(MVC网络应用程序)”模板,Visual Studio将会为我们创建AccountController类及其LogOn动作方法。这个方法的实现将使用核心ASP.NET的成员特性来管理账号和口令,这些将在第22章涉及。这里,对我们的应用程序而言,成员系统是不必要的过度行为,因此我们将使用一种更简单一点的办法。我们将创建一个我们自己的控制器。

To start, we will create a username and password that will grant access to the SportsStore administration features. Listing 9-23 shows the changes to apply to the authentication section of the Web.config file.

Listing 9-23. Defining a Username and Password
清单9-23. 定义用户名和口令

<authentication mode="Forms">
    <forms loginUrl="~/Account/LogOn" timeout="2880">
        <credentials passwordFormat="Clear">
            <user name="admin" password="secret" />

We have decided to keep things very simple and hard-code a username (admin) and password (secret) in the Web.config file. Most web applications using forms authentication store user credentials in a database, which we show you how to do in Chapter 22. Our focus in this chapter is applying basic security to an MVC application, so hard-coded credentials suit us just fine.

Applying Authorization with Filters

The MVC Framework has a powerful feature called filters. These are .NET attributes that you can apply to an action method or a controller class. They introduce additional logic when a request is processed. Different kinds of filters are available, and you can create your own custom filters, too, as we’ll explain in Chapter 13. The filter that interests us at the moment is the default authorization filter, Authorize. We will apply it to the AdminController class, as shown in Listing 9-24.

Listing 9-24. Adding the Authorize Attribute to the Controller Class
清单9-24. 将Authorize(授权)属性添加到控制器类

using System.Web.Mvc;
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using System.Linq; 
namespace SportsStore.WebUI.Controllers { [Authorize] public class AdminController : Controller { private IProductRepository repository; public AdminController(IProductRepository repo) { repository = repo; } ...

When applied without any parameters, the Authorize attribute grants access to the controller action methods if the user is authenticated. This means that if you are authenticated, you are automatically authorized to use the administration features. This is fine for SportsStore, where there is only one set of restricted action methods and only one user. In Chapters 13 and 22, you’ll see how to apply the Authorize filter more selectively to separate the notions of authentication (being identified by the system) and authorized (being allowed to access a given action method).

■ Note You can apply filters to an individual action method or to a controller. When you apply a filter to a controller, it works as though you had applied it to every action method in the controller class. In Listing 9-24, we applied the Authorize filter to the class, so all of the action methods in the Admin controller are available only to authenticated users.

You can see the effect that the Authorize filter has by running the application and navigating to the /Admin/Index URL. You will see an error similar to the one shown in Figure 9-16.


Figure 9-16. The effect of the Authorize filter
图9-16. Authorize过滤器的效果

When you try to access the Index action method of the Admin controller, the MVC Framework detects the Authorize filter. Since you have not been authenticated, you are redirected to the URL specified in the Web.config forms authentication section: Account/LogOn. We have not created the Account controller yet, but you can still see that the authentication is working, although it doesn’t prompt us to authenticate ourselves.

Creating the Authentication Provider

Using the forms authentication feature requires us to call two static methods of the System.Web.Security.FormsAuthentication class:

  • The Authenticate method lets us validate credentials supplied by the user.
  • The SetAuthCookie method adds a cookie to the response to the browser, so that users don’t need to authenticate every time they make a request.

The problem with calling static methods in action methods is that it makes unit testing the controller difficult. Mocking frameworks such as Moq can mock only instance members. This problem arises because the FormsAuthentication class predates the unit-testing-friendly design of MVC. The best way to address this is to decouple the controller from the class with the static methods using an interface. An additional benefit is that this fits in with the broader MVC design pattern and makes it easier to switch to a different authentication system later.

We start by defining the authentication provider interface. Create a new folder called Abstract in the Infrastructure folder of the SportsStore.WebUI project and add a new interface called IAuthProvider. The contents of this interface are shown in Listing 9-25.

Listing 9-25. The IAuthProvider Interface
清单9-25. IAuthProvider接口

namespace SportsStore.WebUI.Infrastructure.Abstract {
    public interface IAuthProvider {
        bool Authenticate(string username, string password);

We can now create an implementation of this interface that acts as a wrapper around the static methods of the FormsAuthentication class. Create another new folder in Infrastructure—this time called Concrete—and create a new class called FormsAuthProvider. The contents of this class are shown in Listing 9-26.
我们现在可以创建该接口的一个实现,以此作为FormsAuthentication类中静态方法的封装程序。在Infrastructure文件夹中创建另一个新文件夹 — 这次叫做Concrete — 并创建一个名为FormsAuthProvider的新类。这个类的内容如清单9-26所示。

Listing 9-26. The FormsAuthProvider Class
清单8-26. FormsAuthProvider类

using System.Web.Security;
using SportsStore.WebUI.Infrastructure.Abstract; 
namespace SportsStore.WebUI.Infrastructure.Concrete {
public class FormsAuthProvider : IAuthProvider {
public bool Authenticate(string username, string password) { bool result = FormsAuthentication.Authenticate(username, password); if (result) { FormsAuthentication.SetAuthCookie(username, false); } return result; } } }

The implementation of the Authenticate model calls the static methods that we wanted to keep out of the controller. The final step is to register the FormsAuthProvider in the AddBindings method of the NinjectControllerFactory class, as shown in Listing 9-27 (the addition is shown in bold).

Listing 9-27. Adding the Authentication Provider Ninject Binding
清单9-27. 添加认证提供器的Ninject绑定

private void AddBindings() {
    // put additional bindings here
    // 这里放置附加绑定器
// create the email settings object // 创建邮件设置对象 EmailSettings emailSettings = new EmailSettings { WriteAsFile = bool.Parse(ConfigurationManager.AppSettings["Email.WriteAsFile"] ?? "false") }; ninjectKernel.Bind<IOrderProcessor>() .To<EmailOrderProcessor>() .WithConstructorArgument("settings", emailSettings);
ninjectKernel.Bind<IAuthProvider>().To<FormsAuthProvider>(); }

Creating the Account Controller

The next task is to create the Account controller and the LogOn action method. In fact, we will create two versions of the LogOn method. The first will render a view that contains a login prompt, and the other will handle the POST request when users submit their credentials.

To get started, we will create a view model class that we will pass between the controller and the view. Add a new class to the Models folder of the SportsStore.WebUI project called LogOnViewModel and edit the content so that it matches Listing 9-28.

Listing 9-28. The LogOnViewModel Class
清单9-28. LogOnViewModel类

using System.ComponentModel.DataAnnotations; 
namespace SportsStore.WebUI.Models {
public class LogOnViewModel { [Required] public string UserName { get; set; }
[Required] [DataType(DataType.Password)] public string Password { get; set; } } }

This class contains properties for the username and password, and uses the data annotations to specify that both are required. In addition, we use the DataType attribute to tell the MVC Framework how we want the editor for the Password property displayed.

Given that there are only two properties, you might be tempted to do without a view model and rely on the ViewBag to pass data to the view. However, it is good practice to define view models so that the data passed from the controller to the view and from the model binder to the action method is typed consistently. This allows us to use template view helpers more easily.

Next, create a new controller called AccountController, as shown in Listing 9-29.

Listing 9-29. The AccountController Class
清单9-29. AccountController类

using System.Web.Mvc;
using SportsStore.WebUI.Infrastructure.Abstract;
using SportsStore.WebUI.Models; 
namespace SportsStore.WebUI.Controllers {
public class AccountController : Controller { IAuthProvider authProvider;
public AccountController(IAuthProvider auth) { authProvider = auth; }
public ViewResult LogOn() { return View(); }
[HttpPost] public ActionResult LogOn(LogOnViewModel model, string returnUrl) { if (ModelState.IsValid) { if (authProvider.Authenticate(model.UserName, model.Password)) { return Redirect(returnUrl ?? Url.Action("Index", "Admin")); } else { ModelState.AddModelError("", "Incorrect username or password"); return View(); } } else { return View(); } } } }

Creating the View

Right-click in one of the action methods in the Account controller class and select Add View from the pop-up menu. Create a strongly typed view called LogOn that uses LogOnViewModel as the view model type, as shown in Figure 9-17. Check the option to use a Razor layout and select _AdminLayout.cshtml.
右击Account控制器类中的一个动作方法,并从弹出菜单选择“添加视图”。创建一个名为LogOn的强类型视图,用LogOnViewModel作为该视图的模型类型,如图9-17所示。选中“use a Razor layout(使用一个Razor布局)”复选框,并选择_AdminLayout.cshtml。


Figure 9-17. Adding the LogOn view
图9-17. 添加LogOn视图

Click the Add button to create the view and edit the markup so that it matches Listing 9-30.

Listing 9-30. The LogOn View
清单9-30. LogOn视图

@model SportsStore.WebUI.Models.LogOnViewModel
@{ ViewBag.Title = "Admin: Log In"; Layout = "~/Views/Shared/_AdminLayout.cshtml"; }
<h1>Log In</h1> <p>Please log in to access the administrative area:</p> @using(Html.BeginForm()) { @Html.ValidationSummary(true) @Html.EditorForModel() <p><input type="submit" value="Log in" /></p> }

You can see how the view looks in Figure 9-18.


Figure 9-18. The LogOn view
图9-18. LogOn视图

The DataType attribute has led the MVC Framework to render the editor for the Password property as an HTML password-input element, which means that the characters in the password are not visible. The Required attribute that we applied to the properties of the view model are enforced using client-side validation (the required JavaScript libraries are included in the layout). Users can submit the form only after they have provided both a username and password, and the authentication is performed at the server when we call the FormsAuthentication.Authenticate method.

■ Caution In general, using client-side data validation is a good idea. It off-loads some of the work from your server and gives users immediate feedback about the data they are providing. However, you should not be tempted to perform authentication at the client, since this would typically involve sending valid credentials to the client so they can be used to check the username and password that the user has entered, or at least trusting the client’s report of whether they have successfully authenticated. Authentication must always be done at the server.

When we receive bad credentials, we add an error to the ModelState and rerender the view. This causes our message to be displayed in the validation summary area, which we have created by calling the Html.ValidationSummary helper method in the view.

■ Note Notice that we call the Html.ValidationSummary helper method with a bool parameter value of true in Listing 9-27. Doing so excludes any property validation messages from being displayed. If we had not done this, any property validation errors would be duplicated in the summary area and next to the corresponding input element.


Testing the Account controller requires us to check two behaviors: a user should be authenticated when valid credentials are supplied, and a user should not be authenticated when invalid credentials are supplied. We can perform these tests by creating mock implementations of the IAuthProvider interface and checking the type and nature of the result of the controller LogOn method, like this:

public void Can_Login_With_Valid_Credentials() {
    // Arrange - create a mock authentication provider
    // 布置 — 创建模仿认证提供器
    Mock<IAuthProvider> mock = new Mock<IAuthProvider>();
    mock.Setup(m => m.Authenticate("admin", "secret")).Returns(true); 
// Arrange - create the view model // 布置 — 创建视图模型 LogOnViewModel model = new LogOnViewModel { UserName = "admin", Password = "secret" };
// Arrange - create the controller // 布置 — 创建控制器 AccountController target = new AccountController(mock.Object);
// Act - authenticate using valid credentials // 动作 — 用有效的凭据进行认证 ActionResult result = target.LogOn(model, "/MyURL");
// Assert // 断言 Assert.IsInstanceOfType(result, typeof(RedirectResult)); Assert.AreEqual("/MyURL", ((RedirectResult)result).Url); }
[TestMethod] public void Cannot_Login_With_Invalid_Credentials() { // Arrange - create a mock authentication provider // 布置 — 创建模仿认证提供器 Mock<IAuthProvider> mock = new Mock<IAuthProvider>(); mock.Setup(m => m.Authenticate("badUser", "badPass")).Returns(false);
// Arrange - create the view model // 布置 — 创建视图模型 LogOnViewModel model = new LogOnViewModel { UserName = "badUser", Password = "badPass" };
// Arrange - create the controller // 布置 — 创建控制器 AccountController target = new AccountController(mock.Object);
// Act - authenticate using valid credentials // 动作 — 用有效凭据认证 ActionResult result = target.LogOn(model, "/MyURL");
// Assert // 断言 Assert.IsInstanceOfType(result, typeof(ViewResult)); Assert.IsFalse(((ViewResult)result).ViewData.ModelState.IsValid); }

This takes care of protecting the SportsStore administration functions. Users will be allowed to access these features only after they have supplied valid credentials and received a cookie, which will be attached to subsequent requests. We’ll come back to authentication in Chapters 13 and 22.

■ Tip It is best to use Secure Sockets Layer (SSL) for applications that require authentication so that the credentials and the authentication cookie (which is used to subsequently identify the user, as we’ll describe in Chapter 22) are transmitted over a secure connection. Setting this up is worth doing. See the IIS documentation for details.

Image Uploads

We are going to complete the SportsStore application with something a little more sophisticated, We will add the ability for the administrator to upload product images and store them in the database so that they are displayed in the product catalog.

Extending the Database

Open the Visual Studio Server Explorer window and navigate to the Products table in the database we created in Chapter 7. Right-click the table and select Open Table Definition from the pop-up menu. Add the two new columns that are shown in Figure 9-19.
打开Visual Studio的服务器资源管理器窗口,并导航到我们在第7章创建的数据库中的Products表。右击此表并从弹出菜单选择“打开表定义”。添加如图9-19所示的两个新列。


Figure 9-19. Adding new columns to the Products table
图9-19. 把新列加到Products表

Select Save Products from the File menu (or press Control+S) to save the changes to the table.
从文件菜单选择“保存Products”(或按Ctrl + S)来保存对此表的修改。

Enhancing the Domain Model

We need to add two new fields to the Products class in the SportsStore.Domain project that correspond to the columns we added to the database. The additions are shown in bold in Listing 9-31.

Listing 9-31. Adding Properties to the Product Class
清单9-31. 在Product类上添加属性

using System.ComponentModel.DataAnnotations;
using System.Web.Mvc; 
namespace SportsStore.Domain.Entities {
public class Product { [HiddenInput(DisplayValue=false)] public int ProductID { get; set; }
[Required(ErrorMessage = "Please enter a product name")] public string Name { get; set; }
[Required(ErrorMessage = "Please enter a description")] [DataType(DataType.MultilineText)] public string Description { get; set; }
[Required] [Range(0.01, double.MaxValue, ErrorMessage = "Please enter a positive price")] public decimal Price { get; set; }
[Required(ErrorMessage = "Please specify a category")] public string Category { get; set; }
public byte[] ImageData { get; set; } // 原文这里有错 — 译者注 [HiddenInput(DisplayValue = false)] public string ImageMimeType { get; set; } } }

We don’t want either of these new properties to be visible when the MVC Framework renders an editor for us. To that end, we use the HiddenInput attribute on the ImageMimeType property. We don’t need to do anything with the ImageData property, because the framework doesn’t render an editor for byte arrays. It does this only for “simple” types, such as int, string, DateTime, and so on.

■ Caution Make sure that the names of the properties that you add to the Product class exactly match the names you gave to the new columns in the database.

Updating the Entity Framework Conceptual Model

■注:本小节是多余的,在SportsStore应用程序中不需要做这部分工作。而且,如果做了,会出现错误 — 译者注

We have created the new columns in the database and the corresponding properties in the Product class. Now we must update the Entity Framework conceptual model so that the two are mapped together properly. This is a quick- and-easy process. Open the SportsStore.edmx file in the Concrete/ORM folder of the SportsStore.Domain project. You will see the current conceptual representation of the Product class as it is known by the Entity Framework, shown in the left panel of Figure 9-20.


Figure 9-20. Updating the conceptual model
图9-20. 更新概念模型

Right-click in the space that surrounds the Product object and select Update Model from Database from the pop-up menu. The Update Wizard dialog box appears and begins to query the database. Without making any changes, click the Finish button. This causes the Entity Framework to refresh its understanding of the parts of the database it is already aware of. After a moment, you will see that the ImageData and ImageMimeType properties have been added to the conceptual Product, as shown in the right panel of Figure 9-20.
右击Product对象周围的空白处,并从弹出菜单选择“Update Model from Database(从数据库更新模型)”。会出现更新向导对话框,并开始查询数据库。不用进行任何修改,点击“Finish(完成)”按钮。这会导致实体框刷新它已经感知的对数据库部分的理解。一会儿之后,你将看到ImageData和ImageMimeType属性已经被添加到Product概念模型,如图9-29右侧的面板所示。

Creating the Upload User Interface Elements

Our next step is to add support for handling file uploads. This involves creating a UI that the administrator can use to upload an image. Modify the Views/Admin/Edit.cshtml view so that it matches Listing 9-32 (the additions are in bold).

Listing 9-32. Adding Support for Images
清单9-32. 添加对图像的支持

@model SportsStore.Domain.Entities.Product
@{ ViewBag.Title = "Admin: Edit " + @Model.Name; Layout = "~/Views/Shared/_AdminLayout.cshtml"; }
<h1>Edit @Model.Name</h1>
@using (Html.BeginForm("Edit", "Admin", FormMethod.Post, new { enctype = "multipart/form-data" })) {
<div class="editor-label">Image</div> <div class="editor-field"> @if (Model.ImageData == null) { @:None } else { <img width="150" height="150" src="@Url.Action("GetImage", "Product", new { Model.ProductID })" /> } <div>Upload new image: <input type="file" name="Image" /></div> </div>
<input type="submit" value="Save" /> @Html.ActionLink("Cancel and return to List", "Index") }

You may not be aware that web browsers will upload files properly only when the HTML form element defines an enctype value of multipart/form-data. In other words, for a successful upload, the form element must look like this:

<form action="/Admin/Edit" enctype="multipart/form-data" method="post">

Without the enctype attribute, the browser will transmit only the name of the file and not its content, which is no use to us at all. To ensure that the enctype attribute appears, we must use an overload of the Html.BeginForm helper method that lets us specify HTML attributes, like this:

@using (Html.BeginForm("Edit", "Admin",
    FormMethod.Post, new { enctype = "multipart/form-data" })) {

Also notice that if the Product being displayed has a non-null ImageData property value, we add an img element and set its source to be the result of calling the GetImage action method of the Product controller. We’ll implement this shortly.

Saving Images to the Database

We need to enhance the POST version of the Edit action method in the AdminController class so that we take the image data that has been uploaded to us and save it in the database. Listing 9-33 shows the changes that are required.

Listing 9-33. Handling Image Data in the AdminController Class
清单9-33. 在AdminController类中处理图像数据

public ActionResult Edit(Product product, HttpPostedFileBase image) {
    if (ModelState.IsValid) {
if (image != null) { product.ImageMimeType = image.ContentType; product.ImageData = new byte[image.ContentLength]; image.InputStream.Read(product.ImageData, 0, image.ContentLength); }
// save the product // 保存产品 repository.SaveProduct(product);
// add a message to the viewbag // 将消息添加到viewbag TempData["message"] = string.Format("{0} has been saved", product.Name);
// return the user to the list // 将用户返回到列表页面 return RedirectToAction("Index"); } else { // there is something wrong with the data values // 存在数据错误 return View(product); } }

We have added a new parameter to the Edit method, which the MVC Framework uses to pass the uploaded file data to us. We check to see if the parameter value is null; if it is not, we copy the data and the MIME type from the parameter to the Product object so that it is saved to the database.

■ Note You’ll need to update your unit tests to reflect the new parameter in Listing 9-33. Providing a null parameter value will satisfy the compiler.

Implementing the GetImage Action Method

In Listing 9-32, we added an img element whose content was obtained through a GetImage action method. We are going to implement this so that we can display images contained in the database. Listing 9-34 shows the method we added to the ProductController class.

Listing 9-34. The GetImage Action Method
清单9-34. GetImage动作方法

public FileContentResult GetImage(int productId) {
    Product prod = repository.Products.FirstOrDefault(p => p.ProductID == productId);
    if (prod != null) {
        return File(prod.ImageData, prod.ImageMimeType);
    } else {
        return null;

This method tries to find a product that matches the ID specified by the parameter. The FileContentResult class is returned from an action method when we want to return a file to the client browser, and instances are created using the File method of the base controller class. We’ll discuss the different types of results you can return from action methods in Chapter 12.


We want to make sure that the GetImage method returns the correct MIME type from the repository and make sure that no data is returned when we request a product ID that doesn’t exist. Here are the test methods we created:

public void Can_Retrieve_Image_Data() {
// Arrange - create a Product with image data // 布置 — 创建一个带有图像的产品 Product prod = new Product { ProductID = 2, Name = "Test", ImageData = new byte[] {}, ImageMimeType = "image/png" };
// 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"}, prod, new Product {ProductID = 3, Name = "P3"} }.AsQueryable());
// Arrange - create the controller // 布置 — 创建控制器 ProductController target = new ProductController(mock.Object);
// Act - call the GetImage action method // 动作 — 调用GetImage动作方法 ActionResult result = target.GetImage(2);
// Assert // 断言 Assert.IsNotNull(result); Assert.IsInstanceOfType(result, typeof(FileResult)); Assert.AreEqual(prod.ImageMimeType, ((FileResult)result).ContentType); }
[TestMethod] public void Cannot_Retrieve_Image_Data_For_Invalid_ID() {
// 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"}, new Product {ProductID = 2, Name = "P2"} }.AsQueryable());
// Arrange - create the controller // 布置 — 创建控制器 ProductController target = new ProductController(mock.Object);
// Act - call the GetImage action method // 动作 — 调用GetImage动作方法 ActionResult result = target.GetImage(100);
// Assert // 断言 Assert.IsNull(result); }

When dealing with a valid product ID, we check that we get a FileResult result from the action method and that the content type matches the type in our mock data. The FileResult class doesn’t let us access the binary contents of the file, so we must be satisfied with a less-than-perfect test. When we request an invalid product ID, we simply check to ensure that the result is null.

The administrator can now upload images for products. You can try this yourself by editing one of the products. Figure 9-21 shows an example.


Figure 9-21. Adding an image to a product listing
图9-21. 把一个图像添加到一个产品列表

Displaying Product Images

All that remains is to display the images alongside the product description in the product catalog. Edit the Views/Shared/ProductSummary.cshtml view to reflect the changes shown in bold in Listing 9-35.

Listing 9-35. Displaying Images in the Product Catalog
清单9-35. 在产品分类中显示图像

@model SportsStore.Domain.Entities.Product
<div class="item"> 
@if (Model.ImageData != null) { <div style="float:left;margin-right:20px"> <img width="75" height="75" src="@Url.Action("GetImage", "Product", new { Model.ProductID })" /> </div> }
<h3>@Model.Name</h3> @Model.Description
<div class="item">
@using(Html.BeginForm("AddToCart", "Cart")) { @Html.HiddenFor(x => x.ProductID) @Html.Hidden("returnUrl", Request.Url.PathAndQuery) <input type="submit" value="+ Add to cart" /> }
</div> <h4>@Model.Price.ToString("c")</h4> </div>

With these changes in place, the customers will see images displayed as part of the product description when they browse the catalog, as shown in Figure 9-22.


Figure 9-22. Displaying product images
图9-22. 显示产品图像


In this and the previous two chapters, we have demonstrated how the ASP.NET MVC Framework can be used to create a realistic e-commerce application. This extended example has introduced many of the framework’s key features: controllers, action methods, routing, views, model binding, metadata, validation, layouts, authentication, and more. You have also seen how some of the key technologies related to MVC can be used. These included the Entity Framework, Ninject, Moq, and the Visual Studio support for unit testing.
在本章以及前面两章中,我们已经演示了,可以如何运用ASP.NET MVC框架来创建真实的电子商务应用程序。这个扩展示例已经介绍了框架的许多关键特性:控制器、动作方法、路由、视图、模型绑定、元数据、验证、布局、认证等等。你也已经看到了如何使用与MVC相关的一些关键技术。这些包括实体框架、Ninject、Moq、以及Visual Studio对单元测试的支持。

We have ended up with an application that has a clean, component-oriented architecture that separates out the various concerns, leaving us with a code base that will be easy to extend and maintain. The second part of this book digs deep into each MVC Framework component to give you a complete guide to its capabilities.
我们最终实现了一个应用程序,它具有整洁的、实现了关注分离的面向组件的体系结构,给我们留下了易于扩展和维护的代码基础(意即,可以在现有代码的基础上,进一步开发此应用程序,或用它开发其它应用程序 — 译者注)。本书的第二部分将深入到MVC框架每个组件的内部,以对它的能力给出完整指南。

posted @ 2012-06-20 11:53  r01cn  阅读(3225)  评论(14编辑  收藏  举报