【IT168 技术文档】熟悉ASP.NET的朋友都知道,ASP.NET框架中针对表单中常用控件引入了一套完整的并支持可定制的校验组件。然而,在目前最新版本的ASP.NET MVC框架中却仍然没有引入对于表单控件相关的校验组件的内置支持。但是另一方面,针对Web页面表单中典型的输入控件实施必要的校验在今天网络安全第一位的形势下显得尤其重要和必要。在本篇中,我们将探讨如何在构建ASP.NET MVC论坛应用程序的过程中把服务器端表单校验功能添加到程序中。
一、 创建表单校验框架
因为ASP.NET MVC框架并没有为表单控件校验提供内置的支持;所以,如果你想实现在把数据提交到一个数据库之前校验表单数据,那么你必须你自己动手编写校验逻辑。在这一节,我将描述一下如何创建一个定制的表单校验框架。
注意,我在这一节中将要描述的校验框架的设计中整合了ASP.NET MVC框架Preview 5中新引入的ViewData.ModelState属性,从而使相应的校验操作显得更加平滑。
这个校验框架的设计充分利用了C#语言属性(Attribute)语法的优点。你可以使用Validator系列属性来修饰你想校验的属性。具体地说,该校验框架包括了下列属性:
?
RequiredValidator—当一个属性没有一个值时,使用这个属性可以显示一个校验错误消息。
? TypeValidator—当一个属性类型不正确时,使用这个属性显示一个校验错误消息。
? RegularExpressionValidator—当一个属性不匹配一个指定的正规表达式时,使用这个属性显示一个校验错误消息。
? LengthValidator—当一个属性的值太长时,使用这个属性显示一个校验错误消息。
? EmailValidator—当一个属性所描述的是一个无效的电子邮件地址时,使用这个属性显示一个校验错误消息。
? USCurrencyValidator—当一个属性所描述的是一个无效的美国货币数值时,使用这个属性显示一个校验错误消息。
下面,让我们来看一个如何使用这些属性的例子。设想你正在创建一个产品目录应用程序,并且你想对表单进行校验,以便你能够正确地输入产品信息。
接下来的列表1展示了上面创建一个新产品的视图相应的页面实现代码。
列表1—Views\Home\Create.aspx
<head runat="server">
<title>Create New Product</title>
<style type="text/css">
.input-validation-error
{
border: solid 2px red;
}
</style>
</head>
<body>
<div>
<form method="post" action="/Home/Create">
<label for="name">Product Name:</label>
<br />
<%= Html.TextBox("name") %>
<%= Html.ValidationMessage("name") %>
<br /><br />
<label for="price">Product Price:</label>
<br />
<%= Html.TextBox("price") %>
<%= Html.ValidationMessage("price") %>
<br /><br />
<label for="description">Product Description:</label>
<br />
<%= Html.TextArea("description") %>
<%= Html.ValidationMessage("description") %>
<br /><br />
<label for="saleStartDate">Sale Start Date:</label>
<br />
<%= Html.TextBox("saleStartDate") %>
<%= Html.ValidationMessage("saleStartDate") %>
<br /><br />
<label for="saleEndDate">Sale End Date:</label>
<br />
<%= Html.TextBox("saleEndDate") %>
<%= Html.ValidationMessage("saleEndDate") %>
<br /><br />
<input type="submit" value="Add Product" />
</form>
</div>
</body>
</html>
关于列表1中的视图有两点你应该特别注意。首先注意的是,每一个输入域都与一个Html.ValidationMessage()辅助函数相关联。这个辅助函数包括已经内置于目前最新版本(Preview 5)的ASP.NET MVC框架中,它用于显示一个校验错误消息。
其次请注意,视图中包括了一个层叠样式表,它定义了一个名字为input-validation-errorCSSr CSS类。当Html.TextBox()辅助函数生成一个含有无效值的输入域时,这个CSS类被自动地添加到该输入域。在列表1中的视图中,校验发生错误的输入域都使用一个红色方框框出。
创建一个新产品的视图将从Home控制器中返回。列表2中提供了该Home控制器的完整代码。
列表2–Controllers\HomeController.cs
using System.Globalization;
using System.Linq;
using System.Web.Mvc;
using LinqToSqlExtensions;
using Microsoft.Web.Mvc;
using MvcFakes;
using MvcValidation;
using MvcValidationWebsite.Models;
namespace MvcValidationWebsite.Controllers
{
[HandleError]
public class HomeController : Controller
{
private IDataContext _dataContext;
private ITable<Product> _products;
public HomeController()
{
_dataContext = new DataContextWrapper("conProductsDB", "~/Models/ProductsDB.xml");
_products = _dataContext.GetTable<Product>();
}
public ActionResult Index()
{
return View("Index", _products.ToList());
}
[AcceptVerbs("GET")]
public ActionResult Create()
{
return View("Create");
}
[AcceptVerbs("POST")]
public ActionResult Create(FormCollection form)
{
// Perform validation
Validation.Validate<Product>(ViewData.ModelState, form);
if (!ViewData.ModelState.IsValid)
return View("Create", form);
// Create product
var product = new Product();
product.Name = form["name"];
product.Price = Decimal.Parse(form["price"], NumberStyles.Currency);
product.Description = form["description"];
if (!String.IsNullOrEmpty(form["saleStartDate"]))
product.SaleStartDate = DateTime.Parse(form["saleStartDate"]);
if (!String.IsNullOrEmpty(form["saleEndDate"]))
product.SaleEndDate = DateTime.Parse(form["saleEndDate"]);
// Insert product into database
_dataContext.Insert(product);
// Redirect
return RedirectToAction("Index");
}
}
}
注意,上面的这个Home控制器中定义了两个Create()行为。在实现一个HTTP GET操作时调用第一个Create()行为。换句话说,当首次显示建一个新产品的表单时调用第一个Create()行为。
接下来请注意,仅当实现一个HTTP POST操作时才调用第二个Create()行为。即当产品表单被寄送到服务器时就调用这个Create()行为。这第二个Create()方法负责校验表单数据;如果不存在校验错误,即把新的产品添加到服务器端数据库中。
具体地说,上面的校验是借助于下列三行代码实现的:
if (!ViewData.ModelState.IsValid)
return View("Create", form);
这个Validation.Validate()方法对传递到控制器行为的form参数进行校验。当遇到校验错误时,即把该错误添加到ModelState。如果存在任何校验错误,该ModelState.IsValid属性将返回值False并且再次显示Create视图。当再次显示Create视图时,用户输入的所有的值也都重新显示(注意:上面这个form变量是被作为ViewData传递到Create视图中的)。
请注意,你必须指定如何在模型中校验表单中的域(因为我们所设计的校验是模型驱动型的)。列表3中提供了此Product类的实现代码。
列表3–Models\Product.cs
using MvcValidation;
using System.Web.Mvc;
using Microsoft.Web.Mvc;
using MvcValidationWebsite.Validators;
namespace MvcValidationWebsite.Models
{
[CustomValidator(typeof(ProductValidator))]
public class Product
{
public int Id { get; set; }
[RequiredValidator("Product name is required.")]
public string Name { get; set; }
[RequiredValidator("Product price is required.")]
[USCurrencyValidator("Product price is not a valid currency amount.")]
public decimal Price { get; set; }
[RequiredValidator("Product description is required")]
[LengthValidator(50, "Description too long.")]
public string Description { get; set; }
[TypeValidator(typeof(DateTime), "Sale start date must be a valid date.")]
public DateTime? SaleStartDate { get; set; }
[TypeValidator(typeof(DateTime), "Sale end date must be a valid date.")]
public DateTime? SaleEndDate { get; set; }
}
}
注意,上面的校验器属性被应用到Product属性上。例如,Name属性就被标记以RequiredValidator属性。由相应的校验器指定的错误消息最终将被显示到页面视图中。
值得注意的,有一个校验器属性需要多作一些解释。CustomValidator被应用到了类定义的前面,而不是一个类的某个属性上。此外,该CustomValidator校验器还支持你执行更复杂的校验逻辑—其中有可能涉及到多个属性。
例如,Product类就使用了两个属性,一个是StartSaleDate,另一个是EndSaleDate属性;它们共同描述了产品待销售期间的时间段。注意,EndSaleDate属性应该总是位于StartSaleDate属性之后。列表3中的CustomValidator用于强行实施这个校验逻辑。
注意,当你把一个CustomValidator添加到一个类上时,你需要指定一个对象的类型。例如,在列表3中,CustomValidator就指向一个ProductValidator类。这个类中包含了实现定制校验的相关逻辑。列表4提供了该ProductValidator类的实现代码。
列表4–Validators\ProductValidator.cs
using System.Web.Mvc;
using Microsoft.Web.Mvc;
using MvcValidation;
namespace MvcValidationWebsite.Validators
{
public class ProductValidator : ICustomValidator
{
#region ICustomValidator Members
public void Validate(ModelStateDictionary modelState, FormCollection form)
{
// Don't bother with custom validation when attribute validation failed
if (modelState.IsValid)
{
string strSaleStartDate = form["saleStartDate"];
string strSaleEndDate = form["saleEndDate"];
// Verify that either both or neither saleStartDate and saleEndDate have values
if (!String.IsNullOrEmpty(strSaleStartDate) && String.IsNullOrEmpty(strSaleEndDate))
modelState.AddModelError("saleEndDate", strSaleEndDate, "sale end date must have a value when sale start date has a value.");
if (String.IsNullOrEmpty(strSaleStartDate) && !String.IsNullOrEmpty(strSaleEndDate))
modelState.AddModelError("saleStartDate", strSaleStartDate, "sale start date must have a value when sale end date has a value.");
// Verify that saleEndDate > saleStartDate
if (!String.IsNullOrEmpty(strSaleStartDate) && !String.IsNullOrEmpty(strSaleEndDate))
{
DateTime saleStartDate = DateTime.Parse(strSaleStartDate);
DateTime saleEndDate = DateTime.Parse(strSaleEndDate);
if (saleEndDate <= saleStartDate)
{
modelState.AddModelError("saleStartDate", strSaleStartDate, "sale start date must be before end date.");
modelState.AddModelError("saleEndDate", strSaleEndDate, "sale start date must be before end date.");
}
}
}
}
#endregion
}
}
注意,上面这个ProductValidator实现了接口ICustomValidator。这个接口有一个要求你必须实现的方法,名字为Validate()。
列表4中的ProductValidator类首先检查当把一个日期提供给StartSaleDate或EndSaleDate属性时,这两个属性即都使用这一个日期相关内容预以赋值。仅指定一个开始销售日期而不指定终止日期实在没有多大意义。
接下来,ProductValidator校验器可以确保EndSaleDate的值大于StartSaleDate的值。如果存在一个错误,那么,一个新的错误消息即被添加到ModelState中以描述该错误。只要视图中包括一个HTML.ValidationMessage()调用(其中包括了校验错误键),那么该校验错误消息即可被显示。
你可以利用一个定制校验器执行任何复杂的校验任务。例如,如果你需要执行一个数据库查询确保一个唯一的值,那么你可以在这个定制的校验器类中执行该数据库查询。
二、 测试校验框架
因为论坛应用程序中使用了前面创建的校验框架,所以需要对它进行单元测试,就像该论坛应用程序中的任何其它部分一样。你可以下载本文相应的工程源码,其中即包括了本文所创建的校验器。此外,这个方案还包括了一个针对该校验器的测试工程。该测试工程中包括了33个测试,用于验证针对不同的表单域值校验器都能够正确工作
例如,列表5中的测试类包含了所有针对校验器LengthValidator的单元测试。其中,这个LengthValidator校验器分别使用一个空字符串、一个超过最大长度的字符串、一个等于最大长度的字符串和一个小于最大长度的字符串进行相应的字段校验。
列表5–LengthValidatorTests.cs
using System.Text;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MvcValidation;
namespace MvcValidationTests
{
[TestClass]
public class LengthValidatorTests
{
[TestMethod]
public void LengthValidatorEmptyIsValid()
{
// Arrange
var validator = new LengthValidatorAttribute(2);
// Act
var result = validator.Validate(String.Empty);
// Assert
Assert.IsTrue(result);
}
[TestMethod]
public void LengthValidatorOverMaximum()
{
// Arrange
var validator = new LengthValidatorAttribute(2);
// Act
var result = validator.Validate("abc");
// Assert
Assert.IsFalse(result);
}
[TestMethod]
public void LengthValidatorEqualMaximum()
{
// Arrange
var validator = new LengthValidatorAttribute(2);
// Act
var result = validator.Validate("ab");
// Assert
Assert.IsTrue(result);
}
[TestMethod]
public void LengthValidatorUnderMaximum()
{
// Arrange
var validator = new LengthValidatorAttribute(2);
// Act
var result = validator.Validate("a");
// Assert
Assert.IsTrue(result);
}
}
}
三、 片刻思考
乍看起来,你可能感觉有些奇怪:我们花费了如此巨大的努力去避免在我们的模型类中添加LINQ to SQL属性,而现在我们又把校验器属性添加到相同的类上,为什么能够成功地添加校验器属性却无法添加LINQ to SQL属性呢?
这基于两个重要的考虑。首先,我们在未来有可能需要进一步更改数据访问技术。例如,我可能需要把LINQ to SQL修改为基于Microsoft Entity Framework或NHibernate的数据访问技术。当有可能在未来需要改变数据访问技术时,我不想把一个特定的数据访问技术硬编码进我们的模型类中。
然而,在未来我却不可能再去改变处理表单校验的方式。我不想以后再改变表单校验框架。因此,在模型类上添加校验器属性不会影响我们以后可能想更改的其他内容。
在此,存在两个密切相关的软件设计原则要求我们必须加以考虑:单一责任原则(Single Responsibility Principle)和封装变化原则(Encapsulate What Varies Principle)。这两个原则都警告我们,当我们想改变软件时,我们应该竭尽全力去把以后可能变更的软件部分与我们的代码的其他部分隔离开来。
如果我们喜欢的话,我们可以把校验器属性与我们的代码的其他部分隔离开来。也就是说,我们能够把Product类再细化为两个类:Product类和ProductValidationModel类。我们不需要改动Product类以便在其中添加校验。我们完全可以把所有的校验器添加到另一个完全相同的ProductValidationModel类上。当我们调用Validation.Validate()方法时,我们可以把ProductValidationModel类而不是把Product类传递到这个方法。
但是,请注意在此论坛应用程序中我不想把这个Product类再拆分成两个子类,因为这样似乎导致过于复杂(Needless Complexity)。我的确不希望再改变校验框架,因此再作额外的工作并无多大意义。
四、 在论坛应用程序中添加表单校验支持
现在,既然我们有了一个简单的校验框架,那么接下来,我们便可以修改该论坛应用程序以利用上述校验技术。首先,我们想确保一个用户在没有提供一个消息主题和消息正文内容的情况下不能够把一个新的消息寄送到论坛中
因此,我们需要创建能够表达这一意图的单元测试。列表6中包含了两个新的单元测试。其中,第一个单元测试用于验证在没有提供一个消息主题时校验会失败;第二个单元测试用于验证当没有提供一个消息正文内容消时校验也会失败。
列表6–Controllers\ForumControllerTest.cs
public void EmptySubjectFailsValidation()
{
// Arrange
var controller = new ForumController(_repository);
// Act
var form = new FormCollection();
form.Add("author", "Stephen");
form.Add("subject", String.Empty);
form.Add("body", "Body of new thread");
var result = (ViewResult)controller.Create(form);
// Assert
Assert.IsFalse(result.ViewData.ModelState.IsValid);
}
[TestMethod]
public void EmptyBodyFailsValidation()
{
// Arrange
var controller = new ForumController(_repository);
// Act
var form = new FormCollection();
form.Add("author", "Stephen");
form.Add("subject", "New Message");
form.Add("body", String.Empty);
var result = (ViewResult)controller.Create(form);
// Assert
Assert.IsFalse(result.ViewData.ModelState.IsValid);
}
为了顺利通过这些新的单元测试,我们需要修改我们的论坛控制器。该新版本的论坛控制器包含在列表7中。
列表7–Controllers\ForumController.cs
using System.Web.Mvc;
using MvcForums.Models;
using Microsoft.Web.Mvc;
using MvcValidation;
namespace MvcForums.Controllers
{
public class ForumController : Controller
{
private IForumRepository _repository;
public ForumController()
: this(new ForumRepository())
{ }
public ForumController(IForumRepository repository)
{
_repository = repository;
}
public ActionResult Index()
{
ViewData.Model = _repository.SelectThreads();
return View("Index");
}
[AcceptVerbs("GET")]
public ActionResult Create()
{
return View("Create");
}
[AcceptVerbs("Post")]
public ActionResult Create(FormCollection form)
{
// Validate
Validation.Validate<Message>(ViewData.ModelState, form);
if (!ViewData.ModelState.IsValid)
return View("Create", form);
// Create new message
var messageToCreate = new Message();
if (!String.IsNullOrEmpty(form["parentThreadId"]))
messageToCreate.ParentThreadId = int.Parse(form["parentThreadId"]);
if (!String.IsNullOrEmpty(form["parentMessageId"]))
messageToCreate.ParentMessageId = int.Parse(form["parentMessageId"]);
messageToCreate.Author = form["author"];
messageToCreate.Subject = form["subject"];
messageToCreate.Body = form["body"];
messageToCreate.EntryDate = DateTime.Now;
// Add to database
_repository.AddMessage(messageToCreate);
// Redirect
return RedirectToAction("Index");
}
public ActionResult Thread(int threadId)
{
ViewData.Model = _repository.SelectMessages(threadId);
return View("Thread");
}
}
}
注意,现在该论坛控制器拥有两个Create()方法。其中,第一个Create()方法用于显示创建一个新的论坛消息的表单,当把表单数据寄送到服务器端时调用第二个Create()方法。
其中,第二个Create()方法利用了校验类对表单数据进行校验。如果校验失败,则重新显示Create视图。
最后,我们需要修改的是Message类。列表8中提供了修改过的Message类的完整代码。
列表8–Models\Message.cs
using MvcValidation;
namespace MvcForums.Models
{
public class Message
{
public Message()
{ }
public Message(int id, int? parentThreadId, int? parentMessageId, string author, string subject, string body)
{
this.Id = id;
this.ParentThreadId = parentThreadId;
this.ParentMessageId = parentMessageId;
this.Author = author;
this.Subject = subject;
this.Body = body;
}
public int Id { get; set; }
public int? ParentThreadId { get; set; }
public int? ParentMessageId { get; set; }
public string Author { get; set; }
[RequiredValidator("You must enter a message subject.")]
public string Subject { get; set; }
[RequiredValidator("You must enter a message body.")]
public string Body { get; set; }
public DateTime EntryDate { get; set; }
}
}
注意,两个RequiredValidator属性已经被添加到列表8中的Message类中。但是,对该类并没作其它的改变。
对论坛应用程序作以上所有的这些修改后,新的校验单元测试已能够顺利通过。至此,我们已经成功地把校验器添加到论坛应用程序中。
五、 总结
在本篇中,我们将已经成功地把基本的表单校验支持添加到此论坛应用程序上。我们创建了一组校验属性,它们可以应用于我们的模型类上。最后请注意在本篇中,我们实现的是基于服务器端的校验逻辑。在本系列的下一篇中,我想重点讨论如何解决客户端校验逻辑的问题。