【译著】第9章 SportsStore:管理 — 《精通ASP.NET MVC 3框架》
C H A P T E R 9
■ ■ ■
SportsStore: Administration
SportsStore:管理
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.
在这个建立SportsStore应用程序的最后一章中,将为网站管理员提供一个管理产品分类的方法。我们将添加对产品存储库条目的创建、编辑、和删除、以及上传分类中产品的图片并在产品旁边显示图片的支持功能。另外,由于这些是管理功能,我们将演示如何使用认证和过滤来实现对控制器和动作方法的安全访问,并在需要时提示用户提供凭据。
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.
管理条目集合的惯例是向用户显示两种形式的页面:一个列表页面和一个编辑页面,如图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
创建一个CRUD控制器
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.
点击“添加”按钮以创建这个控制器。你可以在清单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.
为了支持图9-1中的列表页面,我们需要添加一个动作方法,它将显示存储库中的所有产品。根据MVC框架的约定,我们称这个方法为Index。把这个动作方法添加到控制器,如清单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); } } }
UNIT TEST: THE INDEX ACTION
单元测试:INDEX动作
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:
对Index方法,我们所关心的行为是,它正确地返回了存储库中的Product对象。对此进行测试的思路是,创建一个模仿存储库的实现,并把该动作方法返回的数据与测试数据进行比较。以下是该单元测试:
[TestMethod] 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"}, }.AsQueryable());
// 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.
我们打算创建一个新的Razor布局,以用于SportsStore的管理视图。这是一个简单的布局,它提供了一个单一的点,我们可以运用这个点,把它变成所有的管理视图。
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.
约定是用一个下划线字符(_)作为布局名。微软的另一个叫做WebMatrix的技术也使用Razor,它利用下划线来阻止浏览器请求布局页面。MVC不需要这种防护,但这一约定被延续到了MVC应用程序。
We want to create a reference to a CSS file in the layout, as shown in Listing 9-4.
在这个布局中,我们要创建一个对CSS文件的引用,如清单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.
添加的内容(黑体)引用了Content文件夹中一个名为Admin.css的CSS文件。要创建这个Admin.css文件,右击Content文件夹,选择“添加新项目”,选择“样式表”模板,将名字设置为Admin.css,如图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.
用清单9-5所示的样式替换Admin.css文件的内容。
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
实现List视图
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.
现在,已经创建了一个新布局,我们可以把一个用于Admin控制器的Index动作方法的视图添加到项目中。在Index方法中右击,并从弹出菜单选择“添加视图”。将视图名设为Index,如图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.
通过请求应用程序的Admin/Index地址,你可以看到该视图是如何渲染的,如图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.
支架视图为我们做了很好的设置工作。我们有了Product类中每个属性的列、有了进行CRUD操作的链接,它们指向同一控制器中的动作方法。这个标记有点冗长。而且我们希望有些东西与我们先前创建的CSS联系起来。编辑这个Index.cshtml文件使之与清单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.
这个视图以一种更紧凑的形式表现相关信息,忽略了Product类的一些属性,并用一种不同的办法展示了指向产品的链接。你可以从图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:
为了提供创建和更新特性,我们将添加一个产品编辑页面,它类似于图9-1。此工作有两个部分:
- 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
创建Edit动作方法
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.
清单9-8显示了我们已经添加到AdminController类中的Edit方法。这是我们在Index视图的调用Html.ActionLink辅助器方法中所指定的动作方法。
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.
这个简单的方法找到与productId参数对应的ID的产品,并把它作为一个视图模型对象进行传递。
UNIT TEST: THE EDIT ACTION METHOD
单元测试:EDIT动作方法
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:
我们想要测试Edit动作方法中的两个行为。第一个是当我们提供一个有效的ID值时获取我们所查找的产品。显然,我们希望确保我们编辑的是我们预期的产品。第二个行为是当我们请求一个不在存储库中的ID值时,我们根本得不到任何产品。以下是测试方法:
[TestMethod] 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"}, }.AsQueryable());
// 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
创建Edit视图
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.
现在我们有了一个动作方法,我们可以为它创建一个视图以便渲染。右击Edit动作方法并选择“添加视图”。保留视图名为Edit,选中“强类型视图”复选框,并确保选择了Product类作为模型类,如图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.
要看看这个Edit视图所生成的页面,运行应用程序并导航到/Admin/Index。点击一个产品名,于是你将看到如图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.
HiddenInput属性告诉MVC框架,将该属性渲染为隐藏的表单元素,而DataType属性让我们指示如何显示或编辑一个值。这里,我们选择了MultilineText选项。HiddenInput属于System.Web.Mvc命名空间,因此,我们必须在SportsStore.Domain项目中添加对System.Web.Mvc程序集的引用。其它属性包含在System.ComponentModel.DataAnnotations命名空间中,默认地,该命名空间的程序集已经包含在MVC应用程序项目中了。
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.
图9-10再次显示了已经运用了元数据的Edit页面。你不再能看到或编辑ProductId属性了,而且,你有了一个输入description的多行文本框。然而,这个UI看上去还是很差。
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:
我们可以用CSS作一些简单的改善。当MVC框架为每个属性创建input字段时,它给这些input赋予不同的CSS的class值。当你查看图9-10页面的源代码时,你可以看到为产品的description创建的文本框元素被赋予了“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.
图9-11显示了把这些样式运用于Edit视图的效果。
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.
正如你在这个例子中看到的,像EditorForModel这样的模板视图辅助器所创建的页面并不总能满足我们的需求。我们将在第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.
在能够处理编辑之前,我们需要增强产品存储库,以使对所作的修改能够进行保存。首先,我们要对IProductRepository接口添加一个新的方法,如清单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.
然后我们可以把这个方法添加到存储库的实体框架实现上,EFProductRepository类,如清单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.
这个SaveChanges方法的实现在ProductID为0时把一个产品加入存储库,否则,它把任何修改运用于这个已经存在的产品。
Handling Edit POST Requests
处理Edit的POST请求
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.
到了这里,我们已经做好了实现一个重载的Edit动作方法的准备,它在管理员点击Save按钮时处理POST请求。这个新方法如清单9-14所示。
Listing 9-14. Adding the POST-Handling Edit Action Method
清单9-14. 添加处理POST的Edit动作方法
[HttpPost] 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.
我们核查了模型绑定器已经能够验证递交给用户的数据。如果所有事情都OK了,我们便把这些修改保存到存储库,然后请求Index动作方法,让用户返回到产品列表。如果数据有问题,我们再次渲染Edit视图,以使用户能够进行修正。
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),并且将保持到我们已经读取了它为止。在动作方法渲染的视图中,我们把这些数据读给已经被重定向的用户。
UNIT TEST: EDIT SUBMISSIONS
单元测试:EDIT递交
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对象所作的有效更新,被传递给产品存储库进行了保存。我们还要检查非法更新 — 存在模型错误 — 不会被传递给存储库。以下是相应的测试方法:
[TestMethod] 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.
我们打算在_AdminLayout.cshtml布局文件中处理用TempData存储的消息。通过在模板中处理消息,我们可以在任何使用此模板的视图中创建消息,而不需要创建附加的Razor块。清单9-15显示了对此文件的修改。
Listing 9-15. Handling the ViewBag Message in the Layout
清单9-15. 在布局中处理ViewBag消息
<!DOCTYPE html> <html> <head> <title>@ViewBag.Title</title> <link href="@Url.Content("~/Content/Admin.css")" rel="stylesheet" type="text/css" /> </head> <body> <div> @if (TempData["message"] != null) { <div class="Message">@TempData["message"]</div> } @RenderBody() </div> </body> </html>
■ 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.
我们现在有了对编辑产品进行测试的所有元素。运行此应用程序,导航到Admin/Index,作一些编辑。点击“Save”按钮。你将被返回到列表视图,而TempData消息将被显示出来,如图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.
如果你刷新产品列表屏幕,这条消息会消失,因为TempData在读取它时被删除了。这是很方便的,因为,我们不希望还会残留过时的消息。
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.
注:此时,我们已经让Product类的注解属性比类属性还多了。如果你感觉这些注解属性影响了类的可读性,不必担心。你可以把这些注解移到一个不同的类中去,并告诉MVC到哪里去找它们。我们将在第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.
当我们使用Html.EditorForModel辅助器方法来创建form元素以编辑Product时,MVC框架添加了与显示验证错误内联的所有标记和CSS。图9-13演示了,当你编辑一个产品,并且输入的数据打破了我们在清单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.
现在,只有当管理员把编辑递交给服务器时,才会运用我们的数据验证。大多数web用户期望,如果他们输入的数据有问题时,会立即得到反馈。这就是web开发人员经常希望执行客户端验证的原因,此时,数据在浏览器中用JavaScript进行检查。MVC框架可以根据我们运用于域模型类的数据注解来执行客户端验证。
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.
这一特性默认是可用的,但它并不会起作用,因为我们还没有添加对所需的JavaScript库的连接。添加这些连接最简单的地方是在_AdminLayout.cshtml文件中,以使客户端验证能够在使用这个布局的任何页面上起作用。清单9-17显示了对布局的修改。MVC客户端验证特性基于JQuery的JavaScript库,我们可以根据脚本文件名来推断JQuery库。
Listing 9-17. Importing JavaScript Files for Client-Side Validation
清单9-17. 引入用于客户端验证的JavaScript文件
<!DOCTYPE html> <html> <head> <title>@ViewBag.Title</title> <link href="@Url.Content("~/Content/Admin.css")" rel="stylesheet" type="text/css" /> <script src="@Url.Content("~/Scripts/jquery-1.4.4.min.js")" type="text/javascript"></script> <script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script> <script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script> </head> <body> <div> @if (TempData["message"] != null) { <div class="Message">@TempData["message"]</div> } @RenderBody() </div> </body> </html>
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.
通过这些添加,客户端验证将对我们的管理视图生效。显示给用户的错误消息的外观是相同的,因为服务器验证所使用的CSS的class也由客户端验证所使用。但响应是快速的,而且不需要把请求发送到服务器。
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:
如果你把这些语句放在一个视图中或一个控制器中,那末客户端验证只对当前动作失效。通过在Global.asax的Application_Start方法中使用这些语句,你可以取消整个应用程序的客户端验证,或是把这些值运用于Web.config文件,像这样:
<configuration> <appSettings> <add key="ClientValidationEnabled" value="false"/> <add key="UnobtrusiveJavaScriptEnabled" value="false"/> </appSettings> </configuration>
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.
首先,把Create方法加到AdminController类,如清单9-18所示。
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.
Create方法并不渲染它的默认视图。而是,它指明应该使用Edit视图。让一个动作方法去使用一个通常与另一个视图关联的视图是一件很惬意的事情。在这里,我们注入一个新的Product对象作为视图模型,以便Edit视图用空字段进行填充。
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.
添加对条目进行删除的支持相当简单。首先,我们把一个新方法添加到IProductRepository接口,如清单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) { context.Products.Remove(product); context.SaveChanges(); } ...
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.
最后一步是在Admin控制器中实现一个Delete动作方法。这个动作方法应当只支持POST请求,因为删除对象不是一个幂等的(idempotent)操作。正如我们将在第11章要解释的那样,浏览器和缓存会随意地形成GET请求而不要用户明确的同意,因此,我们必须小心地避免形成GET请求的结果。清单9-22演示了这个新方法。
Listing 9-22. The Delete Action Method
清单9-22. Delete动作方法
[HttpPost] 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"); }
UNIT TEST: DELETING PRODUCTS
单元测试:删除产品
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:
我们希望测试Delete动作方法的两个行为。第一是当一个有效的ProductID作为参数传递时,该动作方法调用存储库的DeleteProduct方法,并把正确的Product对象删除掉。以下是该测试:
[TestMethod] 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:
第二个测试是,如果传递给Delete方法的参数值不能对应于存储库中的一个有效的产品,确保存储库的DeleteProduct方法不会被调用。以下是该测试:
[TestMethod] 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"}, }.AsQueryable());
// 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.
简单地点击产品列表页面中的一个Delete按钮,你可以看到这个新功能在工作,如图9-15所示。正如图中所显示的那样,当产品从分类中被删除时,我们利用了TempData变量显示了一条消息。
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.
到了这里,我们已经完成了所有CRUD操作。我们现在可以创建、读取、更新、及删除产品了。
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.
如果现在部署这个应用程序,你肯定会注意到,任何人都可以修改产品分类。一个人只要知道,使用Admin/Index网址,就可以使用管理特性。为了阻止一些人的恶意破坏,我们打算对整个Admin控制器的访问进行口令式保护。
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:
如果打开Web.config文件,你将能够找到一个以authentication为标题的小节,像这样:
<authentication mode="Forms"> <forms loginUrl="~/Account/LogOn" timeout="2880"/> </authentication>
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.
注:形成认证的另一个主要选项是Windows认证,它以操作系统凭据用于标识用户。如果你部署一个企业内部网(intranet)应用程序,而你的所有用户都在同一个Windows域中,这是一个很好的工具。然而,它不适用于互联网(Internet)应用程序。
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.
为了开始工作,我们将创建一个允许访问SportsStore管理特性的用户名和口令。清单9-23显示了运用于Web.config文件的authentication(认证)小节的修改。
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" /> </credentials> </forms> </authentication>
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.
我们决定让事情保持简单,并且在Web.config文件中硬编码了一个用户名(admin)和口令(secret)。大多数使用表单认证的web应用程序会把用户凭据存储在一个数据库中,我们将在第22章演示如何去做。本章中的焦点是把基本的安全性运用于一个MVC应用程序,因此硬编码的凭据正好是合适的。
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.
MVC框架有一个叫做过滤器的功能强大的特性。这些过滤器是一些.NET的注解属性,你可以把它们运用于一个动作方法或一个控制器类。它们在一个请求被处理时,会引入一些附加的逻辑。各种不同的过滤器都是可用的,而且你也可以创建你自己的过滤器,就像我们在第13章所解释的那样。此刻我们感兴趣的过滤器是默认的授权过滤器,Authorize。我们将把它运用于AdminController类,如清单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).
当不带任何参数地运用时,如果用户已被认证,这个Authorize注解属性便允许访问该控制器的动作方法。意即,如果你被认证了,你就被自动地授权使用管理特性。这对SportsStore是很好的,在这里只有一组受限的动作方法并只有一个用户。在第13章和第22章中,你将看到如何更有选择性地运用Authorize过滤器,把认证(由系统标识)与授权(允许访问给定的动作方法)分开来。
■ 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.
注:你可以把过滤器运用于个别的动作方法或控制器。当你把一个过滤器运用于一个控制器时,就如同把它运用于该控制器中的每一个动作方法一样。在清单9-24中,我们把Authorize过滤器运用于这个类,因此,在Admin控制器中的所有动作方法都只对已认证用户才是可用的。
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.
通过运行应用程序,并导航到/Admin/Index网址,便可以看到Authorize过滤器所具有的效果。你将看到类似于图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.
当你试图访问Admin控制器的Index动作方法时,MVC框架检测到了Authorize过滤器。由于你还没有被认证,便被重定向到Web.config表单认证小节所指定的URL:Account/LogOn。我们还没有创建Account控制器,但你仍然可以看到认证已经起作用了,尽管它还没有提示我们进行认证。
Creating the Authentication Provider
创建认证提供器
Using the forms authentication feature requires us to call two static methods of the System.Web.Security.FormsAuthentication class:
使用表单认证特性需要我们调用System.Web.Security.FormsAuthentication类的两个静态方法:
- The Authenticate method lets us validate credentials supplied by the user.
Authenticate方法让我们验证由用户提供的凭据。 - 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.
SetAuthCookie方法把一个cookie添加到对浏览器的响应,这样,用户在发出请求时不需要每次都要认证。
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.
在动作方法中调用静态方法带来的问题是,它会使控制器的单元测试困难。像Moq这样的模仿框架只能模仿实例成员。之所以会出现这一问题,是因为FormsAuthentication先于MVC的友好单元测试设计。解决这一问题的最好办法是,用一个接口去掉控制器与带有这种静态方法的类之间的耦合。一个附带的好处是这符合更广泛的MVC设计模式,并且使它更容易切换到不同的认证系统。
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.
我们从定义认证提供器接口开始。在SportsStore.WebUI项目的Infrastructure文件夹中创建一个名为Abstract的新文件夹,并添加一个名为IAuthProvider的新接口。该接口的内容如清单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).
这个认证模型的实现调用了我们希望放在控制器之外的静态方法。最后一步是在NinjectControllerFactory类的AddBindings方法中注册这个FormsAuthProvider,如清单9-27所示。
Listing 9-27. Adding the Authentication Provider Ninject Binding
清单9-27. 添加认证提供器的Ninject绑定
private void AddBindings() { // put additional bindings here // 这里放置附加绑定器 ninjectKernel.Bind<IProductRepository>().To<EFProductRepository>();
// 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
创建Account控制器
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.
接下来的任务是创建Account控制器和LogOn动作方法。事实上,我们将创建两个版本的LogOn方法。第一个将渲染一个登录提示的视图,另一个将在用户递交他们的凭据时处理POST请求。
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.
为了开始工作,我们将创建一个要在控制器和动作方法之间进行传递的视图模型类。把一个新类添加到SportsStore.WebUI项目的Models文件夹,名为LogOnViewModel,并编辑其内容,使之与清单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.
这个类含有用户名和口令属性,并且使用数据注解来指定两者都是必须的。此外,我们使用DataType属性来告诉MVC框架,我们希望如何显示Password属性的编辑器。
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.
所给定的只有两个属性,这也许会引诱你不使用一个视图模型,而依靠ViewBag来把数据传递给视图。然而,定义视图模型是一种很好的实践,因为,把数据从控制器传递给视图以及从模型绑定器传递给动作方法,是十分典型的。这使我们能够更容易地使用模板视图辅助器。
Next, create a new controller called AccountController, as shown in Listing 9-29.
接下来,创建一个名为AccountController的新控制器,如清单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.
点击“添加”按钮以创建这个视图,并编辑其标记使之与清单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.
你可以在图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.
DataType注解属性让MVC框架把Password属性的编辑器渲染成一个HTML的口令输入元素,意即,在口令中的字符是不可见的。我们运用于视图模型属性的Required注解属性强制使用客户端验证(所需要的JavaScript库被包含在布局中)。用户只可以在他们提供了用户和口令之后才能递交这个表单,而当我们调用FormsAuthentication.Authenticate方法时,认证在服务器端执行。
■ 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.
当接收到一个劣质凭据时,我们把一条错误消息加到了ModelState,并渲染这个视图。这会引起在验证摘要区域显示这条消息,该摘要区是我们在视图中通过调用Html.ValidationSummary辅助器方法创建的区域。
■ 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.
注:注意,在清单9-27中,我们调用了Html.ValidationSummary辅助器方法,其带有一个布尔参数值true。这样便排除了显示任何属性验证消息。如果我们不这么做,属性验证错误将在摘要区和相应的input元素之后重复显示。
UNIT TEST: AUTHENTICATION
单元测试:认证
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:
测试Account控制器需要我们检查两个行为:在提供了有效凭据时,用户应该被认证;而在提供非法凭据时,用户不应该被认证。我们可以通过创建IAuthProvider接口的模仿实现,并检查控制器LogOn方法结果的类型和性质来执行这些测试,像这样:
[TestMethod] 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.
这起到了保护SprotsStore管理功能的作用。只当用户提供了有效的凭据并接收一个cookie之后,才允许用户访问这些功能。客户端所接收的cookie将被附加到后继的请求中。我们将在第13和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.
提示:对需要认证的应用程序最好使用安全套接字层(SSL),以使得凭据和认证cookie(用于后继地标识该用户,正如我们将在第22章描述的那样)通过一个安全连接进行传输。建立SSL是有价值的事情。详细请参阅IIS文档。
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.
我们打算用一些更具技巧的东西来完成这个SportsStore应用程序,我们将为管理员添加上载产品图像,并把它们存储到数据库中去的能力,以使这些图像能够显示在产品分类中。
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.
我们需要把两个新字段加到SportsStore.Domain项目的Products类,这两个字段对应于我们添加到数据库的列。所添加的内容在清单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.
在MVC框架为我们渲染编辑器时,我们不希望这两个新属性是可见的。于是,我们在ImageMimeType属性上使用了HiddenInput注解属性。我们不需要对ImageData属性做任何事情,因为框架不会为一个字节数组渲染一个编辑器。这一规则只对“简单”类型起作用,如int、string、DateTime等等。
■ 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.
小心:要确保你添加到Product类的属性名与你在数据库中所给定的新列严格匹配。
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.
我们已经创建了数据库中的新列和Product类中相应的属性。现在,我们必须更新实体框架的概念模型,以使这两者被适当地一起映射。这是一个快而容易的过程。打开SportsStore.Domain项目的Concrete/ORM文件夹中的SportsStore.edmx文件。你将看到Product类的当前的概念表示,因为实体框架是知道这个Product的,如图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
创建Upload用户接口元素
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).
我们的下一步是添加对处理文件上载的支持。这包括创建一个管理员可以用来上载图像的UI。修改Views/Admin/Edit.cshtml视图,以使它与清单9-32匹配(黑体部分)。
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" })) {
@Html.EditorForModel()
<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:
你也许还不知道,只有当HTML的form元素定义了一个值为multipart/form-data的enctype时,Web浏览器才会适当地上传文件。换句话说,要进行成功的上传,form元素必须看上去像这样:
<form action="/Admin/Edit" enctype="multipart/form-data" method="post"> ... </form>
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:
没有这个enctype属性,浏览器将只传递文件名,而不是它的内容,这对我们根本没用。为了确保enctype属性出现,我们必须使用Html.BeginForm辅助器的一个重载方法,使我们能够指定HTML属性,像这样:
@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.
还要注意到,如果被显示的Product有一个非空的ImageData属性值,我们添加了一个img元素,并把它的源设置为调用Product控制器的GetImage动作方法的结果。我们很快就会实现它。
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.
我们需要增强AdminController类中POST版本的Edit动作方法,以取得上传给我们的图像数据,并把它保存到数据库中。清单9-33显示了所需要的修改。
Listing 9-33. Handling Image Data in the AdminController Class
清单9-33. 在AdminController类中处理图像数据
[HttpPost] 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.
我们对Edit方法添加了一个新参数,MVC框架把它用于传递上载文件的数据。我们查看该参数的值是否为空;若非空,便把这些数据和该参数的MIME类型拷贝到Product对象,以便把它们保存到数据库。
■ 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.
注:你将需要更新你的单元测试,以反映出清单9-33中的新参数。提供一个null参数值便会使编译器得到满足。
Implementing the GetImage Action Method
实现GetImage动作方法
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.
在清单9-32中,我们添加了一个img元素,它的内容是通过GetImage动作方法获得的。我们打算实现它,以使我们能够显示包含在数据库中的图像。清单9-34显示了我们添加到ProductController类中的这个方法。
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.
此方法试图找到一个与参数指定的ID匹配的产品。当我们想把一个文件返回给客户端浏览器时,FileContentResult是从一个动作方法返回的,而实例是用controller基类的File方法创建的。我们将在第12章讨论你可以从动作方法返回的不同结果类型。
UNIT TEST: RETRIEVING IMAGES
单元测试:接收图像
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:
我们希望确保GetImage方法从存储库中返回了正确的MIME类型,并确保在请求一个不存在的产品ID时,没有返回数据。以下是我们创建的测试方法:
[TestMethod] 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.
当处理一个有效的产品ID时,我们会检查,从该动作方法得到了一个FileResult结果,并且该内容的类型与模仿数据的类型相匹配。FileResult类不让我们访问二进制的文件内容,因此我们必须对这个不太完美的测试感到满意。当请求一个非法的产品ID时,我们简单地进行检查,以确认其结果为空。
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.
管理员现在可以上载产品的图像了。你可以通过编辑一个产品自己试一下。图9-21显示了一个例子。
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.
所剩下的工作是在产品分类的产品描述旁边显示这个图像。编辑Views/Shared/ProductSummary.cshtml视图,以反映出清单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.
通过这些适当的修改,当客户浏览分类时,他们将看到图像,它是作为产品描述的一部分显示的,如图9-22所示。
Figure 9-22. Displaying product images
图9-22. 显示产品图像
Summary
小结
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框架每个组件的内部,以对它的能力给出完整指南。