上文创建的 MVC 应用程序非常基本,虽然其核心的功能能够工作,但应用程序本身却具有非常大的漏洞。下面我将建立核心功能并运行它以演示视图、模型、控制器是如何协作的,将基于基础的 MVC 应用程序创建更加健壮和实用的应用。
1. 配置路由
先前的测试默认页面没有包含任何我们感兴趣的内容,还需要在 URL 中加入 /Product,这非常不理想。MVC 框架使用 ASP.NET 路由把 URL 映射到控制器。在 Global.aspx 文件里,你可以看到 RegisterRotues 方法,它包含下列语句:
routes.MapRoute(
"Default", // 路由名称
"{controller}/{action}/{id}", // 带有参数的 URL
new { controller = "Home", action = "Index", id = UrlParameter.Optional } // 参数默认值
);
这条语句用 MapRoute 方法注册路由器,它为应用程序做 2 件重要的事:
- 设置请求的格式,以便呈现视图时,对 MVC 应用程序的回链具有“控制器/活动ID”的格式。例如,要查看 ProductID=7 的产品,那么 URL 应该为 /Product/details/7
- 最后一个参数非常重要,它指定了 URL 没有指定参数时要使用的默认值。这就是为什么请求 /Product 时会调用控制器方法 Index,通过 id = UrlParameter.Optional,声明如果请求没有提供记录的 ID,那就不要自动附加它们。
现在,我们希望 ProductController 作为默认值,因此这样修改 MapRoute 调用:
routes.MapRoute(
"Default", // 路由名称
"{controller}/{action}/{id}", // 带有参数的 URL
new { controller = "Product", action = "Index", id = UrlParameter.Optional } // 参数默认值
);
虽然控制器的类名是 ProductController,但在路由中指定的是 Product。保存更新,此时运行程序,浏览器仍将加载默认的 URL,但已经可以看到由控制器方法 Index 产生的数据库产品列表。之前,HomeController 类的设置以及 Views/Home 目录中的视图都已经不起作用,因此可以移除它们了。
路由还可以做更多的事。例如,要看到某个特定产品的细节,要使用形如 /Product/Details/1 的 URL,它会调用控制器的 Details 方法并选用 Product/Details.aspx 视图来显示 ProductID=1 的产品。如果省略了 ID,就会发生异常!
通过路由技术来避免这类问题,加入下列语句:
routes.MapRoute(
"DefaultDetails", // 路由名称
"{controller}/Details", // 带有参数的 URL
new { controller = "Product", action = "Index", id = UrlParameter.Optional } // 参数默认值
);
这个路由器被称作 DefaultDetails,它在接收到任何指向以 /Details 结尾的 URL 时(因此省略了 ID)起作用。路由把 URL 映射到 ProductController 类并把活动设置到 Index,它将生成产品记录的默认列表。
另外,这个新路由必须放在已有 Default 路由之前。这是因为路由根据它们的先后次序被应用(直到找到匹配项),默认路由将匹配接收到的任意 URL。
2. 增加错误处理
先前使用路由来消除某种错误,但这一技术只能应对一种问题。我们还需要一个通用的错误处理功能。MVC 支持方法过滤器,它让你以加注的方式改变控制器方法的行为。其中最有用的一个过滤器是 HandleError,它指定如何处理控制器方法的异常。使用 HandlerError 过滤器之前,要在 MVC 应用程序的 web.config 文件里启用自定义错误处理,添加下行至 web.config 配置节:
<customErrors mode="On"></customErrors>
下面是应用到控制器类的 HandlerError 过滤器,也就是说所有由控制器活动方法抛出的异常都将由自定义的错误处理策略处理:
namespace BasicMvcApplication.Controllers
{
[HandleError]
public class ProductController : Controller
{
当不加任何参数使用 HandlerError 过滤器时,过滤器覆盖的所有异常都使用 Views/Shared/Error.aspx 视图来显示。这个视图只显示非常简单的一句话,使用类似下列的 URL 来引发异常:
错误信息可以更具体。作为防范,通用的错误信息很有用,但你也可能希望给用户提供更详细的信息。在 Views/Shared 目录创建名为 NoSuchRecordError.aspx 的视图,内容如下:
<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<dynamic>" %>
<asp:Content ID="errorTitle" ContentPlaceHolderID="TitleContent" runat="server">
Error
</asp:Content>
<asp:Content ID="errorContent" ContentPlaceHolderID="MainContent" runat="server">
<h2>Sorry, you requested a record that doesn't exist.</h2>
</asp:Content>
它只是默认错误视图的一个变体,但至少告诉了用户被请求的产品在数据库中不存在的特定信息。现在对控制器方法 Details 应用 HandleError 过滤器:
[HandleError(View = "NoSuchRecordError")]
public ActionResult Details(int id)
还要修改备选的过滤器:
[HandleError(Order = 2)]
public class ProductController : Controller
Order=2 确保控制器级别的过滤器仅在更高层次没有具体的 HandlerError 时才被调用。但如果不设置 Order 的值,默认的错误视图会被优先使用。
还有一个问题在于,即使 Details 方法发生的是其他类型的错误,用户还是被告知请求了不存在的记录。其实,错误处理还可以更加具体。在 ProductController 类里创建一个异常,当找不到用户请求的记录时要用到它:
class NoSuchRecordException : Exception { }
然后修改 Details 方法,显式检查 LINQ 查询是否返回了一条记录,如果没有就抛出新的异常:
[HandleError(View = "NoSuchRecordError", // 其本质是指定错误视图针对哪种类型的异常
ExceptionType = typeof(NoSuchRecordException))]
public ActionResult Details(int id)
{
NorthwindEntities db = new NorthwindEntities();
var data = db.Products.Where(e => e.ProductID == id).Select(e => e);
if (data.Count() == 0)
{
throw new NoSuchRecordException();
}
else
{
return View(data.Single());
}
}
现在,控制器级别错误过滤器、具体活动错误过滤器、以及具体的异常都已经整合了,错误处理更为精确了。
3. 增加验证
由 VS 模板创建的默认 MVC 项目包括了一个用于用户验证的控制器,即 AccountController 类。在使用它之前,需要修改以使之不再引用之前删除了的 HomeController,需要如下修改,共有 3 处(默认的母板页包含了用户登录、登出、注册新帐号的链接)。这个修改让验证器在每个验证活动结束时把用户重定向到产品控制器。
将 return RedirectToAction("Index", "Home"); 替换为 return RedirectToAction("Index", "Product");
Authorize 过滤器让你能够控制哪些用户才能访问控制器方法,下面是应用到 Delete 方法的 Authorize 过滤器:
[Authorize]
public ActionResult Delete(int id)
现在,当用户删除产品时,首先会先检查 MVC 框架。如果用户没有登录,他将会被提醒输入用户名和密码或创建一个新帐号!
还可以在过滤器中指定用户名以执行更严格的约束。现在只允许用户 soot 访问 Delete 方法:
[Authorize(Users="soot")]
public ActionResult Delete(int id)
Authorize 也可以被应用到整个控制器类,和 HandlerError 极为相似,也有 Order 属性,工作机制也是相同的:
[Authorize(Order = 2)]
public class ProductController : Controller
这么做的话,表示整个控制器方法必须登陆后才能执行,并且 Delete 方法只有 soot 用户才可执行。
4. 增强数据存储访问
控制器的每个方法都创建数据模型的上下文的一个新实例并包含自己读写数据的逻辑,这显然不太可取。下面,我们增强并抽象对数据模型的访问,以减少代码的重复并提高连接的性能。为此,我们在 ProductController.cs 中创建一个 NorthwindAccessConsolidator 类:
class NorthwindAccessConsolidator
{
private NorthwindEntities db = new NorthwindEntities();
public IEnumerable<Products> ListProducts()
{
return db.Products;
}
public Products GetProduct(int id)
{
IEnumerable<Products> data = db.Products
.Where(e => e.ProductID == id)
.Select(e => e);
return data.Count() > 0 ? data.Single() : null;
}
public void DeleteProduct(int id)
{
Products prod = GetProduct(id);
if (prod!=null)
{
db.Products.DeleteObject(prod);
SaveChanges();
}
}
public void StoreNewProduct(Products prod)
{
db.Products.AddObject(prod);
SaveChanges();
}
public void SaveChanges() {
db.SaveChanges();
}
}
这个类唯一有意思的是 SaveChanges 方法是可以公共访问的。这么做是为了支持 Edit 活动方法要依赖的模型更新特性!
接着我们重构控制器类,让它使用新模型访问增强类:
public class ProductController : Controller
{
private NorthwindAccessConsolidator nwa = new NorthwindAccessConsolidator();
//
// GET: /Product/
public ActionResult Index()
{
return View(nwa.ListProducts());
}
//
// GET: /Product/Details/5
[HandleError(View = "NoSuchRecordError", // 其本质是指定错误视图针对哪种类型的异常
ExceptionType = typeof(NoSuchRecordException))]
public ActionResult Details(int id)
{
Products prod = nwa.GetProduct(id);
if (prod == null)
{
throw new NoSuchRecordException();
}
else
{
return View(prod);
}
}
// 这个 Create 方法的任务是创建一个新的 Product 实例并把它传递给 View 方法
// 这个 Create 方法当用户在 Index 视图上单击 “创建新项目” 时会被调用
// GET: /Product/Create
public ActionResult Create()
{
return View(new Products());
}
// 下面这个 Create 方法则是在用户填充了新产品的细节并提交表单时被调用
// 需要修改它的参数,使之接受 Products 实例
// 这是 MVC 中一个方便的特性,HTTP POST 操作被转换为控制器处理的数据类型
// POST: /Product/Create
[HttpPost]
public ActionResult Create(Products prod)
{
try
{
nwa.StoreNewProduct(prod);
return RedirectToAction("Index");
}
catch
{
return View();
}
}
// 和 Create 方法类似,Edit 和 Delete 功能也是成对出现的,操作中的模式是相同的
// 用户开始编辑或删除时调用一个方法,而完成操作并修改数据库时调用另一个方法
// GET: /Product/Edit/5
public ActionResult Edit(int id)
{
return View(nwa.GetProduct(id));
}
// 下面这个 Edit 方法除了传入用户编辑过的那个产品的 ID 外,
// 还传入一个包含描述产品信息的 名称/值 对的 FormCollection
// POST: /Product/Edit/5
[HttpPost]
public ActionResult Edit(int id, FormCollection collection)
{
try
{
Products prod = nwa.GetProduct(id);
if (prod != null)
{
UpdateModel(prod);
nwa.SaveChanges();
return RedirectToAction("Index");
}
else
{
throw new NoSuchRecordException();
}
}
catch
{
return View();
}
}
//
// GET: /Product/Delete/5
public ActionResult Delete(int id)
{
return View(nwa.GetProduct(id));
}
//
// POST: /Product/Delete/5
[HttpPost]
public ActionResult Delete(int id, FormCollection collection)
{
try
{
nwa.DeleteProduct(id);
return RedirectToAction("Index");
}
catch (Exception ex)
{
Console.WriteLine(ex);
return View();
}
}
}
5. 增加对外键约束的支持
目前的程序,创建一个新产品然后删除它是完全可行的。但如果试图删除一个已经存在的产品记录则会发生异常。这是因为在 Northwind Products 表和 Order_Details 表之间有一个外键约束。即必须先删除从表记录才能删除 Products 表中记录。为了解决这个问题,更新 NorthwindAccessConsolidator 类的 DeleteProduct 方法:
public void DeleteProduct(int id)
{
Products prod = GetProduct(id);
if (prod != null)
{
IEnumerable<Order_Details> ods = db.Order_Details
.Where(e => e.ProductID == id)
.Select(e => e);
foreach (Order_Details od in ods)
{
db.Order_Details.DeleteObject(od);
}
db.Products.DeleteObject(prod);
SaveChanges();
}
}