让我们看一个简单的表单提交场景,往产品数据库中加一个新产品:
上面的页面是在用户访问我们应用的“/Products/Create”URL时返回的,该网页的的HTML表单标识如下:
上面的标识是标准的HTML,在<form>元素中,我们有2个<input type="text"/>文本框,然后在表单的下部有一个HTML提交按钮,点击该按钮,会导致包含该按钮的表单将表单输入提交到服务器,该表单会向由它的“action”属性(在这里是“/Products/Save”)表示的URL提交内容。
使用先前的“第四个预览版”,我们也许会使用象下面这样的ProductsController类来实现上面的场景,在其中实现2个action方法,“Create”和“Save”:
上面的“Create” action方法负责返回显示了初始空白表单的HTML视图,而“Save” action方法则负责处理表单提交回服务器后的场景。ASP.NET MVC框架会自动地将“ProductName”和“UnitPrice”表单提交值映射到Save方法的同名参数上。 Save action方法然后使用LINQ to SQL创建一个新的Product对象,将用户提交的值赋予它的ProductName和UnitPrice属性, 然后试图将新产品保存到数据库中。如果产品保存成功的话,用户就会被重新定向到一个“/ProductsAdded”URL,该URL会显示一个成功消息。如果有错的话,我们会重新显示“Create” HTML视图,这样用户可以修正问题,再试着提交。
然后我们可以实现一个象下面这样的“Create” HTML 视图模板,该模板与上面的ProductsController协作产生适当的HTML。注意下面,我们使用了Html.TextBox辅助方法来生成<input type="text"/>元素,同时该方法自动地使用我们传递给视图的Product模型产品的适当属性值来填充这些文本框的值:
第五个预览版中的表单提交方面的改进
上面的代码在先前的“第四个预览版”下工作,在“第五个预览版”中仍将继续工作。但“第五个预览版”添加了额外的特性,允许我们将表单场景做得更完善。
这些新的特性包括:
- 发布单个action URL,但根据HTTP动词做不同的分派的能力
- 模型绑定器(Model Binders),将允许从表单输入值构建出丰富的参数对象,然后传递给action方法
- 新的辅助方法,允许将提交进来的表单输入值映射到action方法中的现有模型对象实例上
- 对处理输入和验证错误的支持的改进(例如,在表单重新显示给用户时,自动高亮显示有问题的域(field),保留用户输入的表单值)
我将用本贴子剩下的篇幅对这些特性进行仔细讨论。
[AcceptVerbs] 和 [ActionName] 特性
在上面的例子中,我们用2个action方法,“Create” 和 “Save”,实现了添加产品的场景。象这样将实现进行分割的一个动机在于,它使得我们的Controller代码既干净又易读。
但在这个场景中使用2个action方法的坏处在于,我们最终在网站上发布了2个URL,"/Products/Create" 和 "/Products/Save"。这在因为输入错误,需要重新显示HTML表单的场景时,会变得有点问题,因为在出错的场景下,重新显示的表单的URL会变成“/Products/Save”,而不是“/Products/Create”(因为“Save”是表单提交的目标URL)。如果用户将这重新显示的页面收藏的话,或者将URL拷贝/粘贴 email给朋友,他们保存的是错的URL,而且在之后再来访问时,非常可能出错。发布2个URL也会给某些搜索引擎造成问题,如果它们爬你的网站,试图从你的action属性自动连到下一个网页的话。
绕过这些问题的一个方法是,发布一个单一的“/Products/Create” URL,然后取决于请求是GET还是POST而使用不同的服务器逻辑。一个在其他web MVC框架中过去常用的方法是,在action方法中有个巨大的if/else语句,根据不同动词,执行不同分支:
但上面这个方法的坏处是,它使得action方法的实现难读,而且难以测试。
ASP.NET MVC第五个预览版现在提供了一个更好的选项来处理这个场景。你可以创建action方法的重载实现,使用一个新的[AcceptVerbs]特性,让 ASP.NET MVC过滤这些方法是如何分派的。例如,在下面,我们可以声明2个 Create action方法,一个会在GET场景下调用,另一个会在POST场景下调用:
这个方法避免了在action方法中使用巨大的“if/else”语句的需要,使得你的action逻辑结构更干净,还摒除了为测试这2个不同场景需要模拟Request对象的必要性。
你现在还可以使用一个新的[ActionName] 特性,以允许你的controller类的实现方法的名称与发布的URL有所不同。例如,在你的controller类中不是拥有2个重载的Create方法,你可以把在POST情形下调用的方法命名为“Save”,然后象这样给这个方法加一个[ActionName]特性:
在上面,我们的方法(Create和Save)的签名跟一开始的表单提交例子中的方法一模一样,但其区别在于,我们现在只发布了一个单一的URL,/Products/Create,且会基于进来的HTTP的动词自动地变换处理逻辑(而且对浏览器收藏夹和搜索引擎比较友好)。
模型绑定器(Model Binders)
在上面的例子中,处理表单提交的Controller action方法的签名接受一个String和一个Decimal参数,然后action方法创建一个新的Product对象,赋之以这些输入值,然后试图将其插入到数据库中:
第五个预览版中可以使得这个场景更干净的一个新功能是对“模型绑定器(Model Binders)”的支持,模型绑定器提供了一种方法,能将进来的HTTP输入逆序列化成复杂的类型,然后作为参数传递给Controller的action方法。它们还提供了对处理输入异常的支持,方便了在错误发生时表单的重新显示(而不要求用户重新输入数据,后文对此有详述)。
例如,使用模型绑定器支持,我们可以对上面的action方法进行重构,让它接受一个Product对象作为方法参数,象这样:
这使得代码变得更加简明干净,还允许我们避免在多个controller类/action方法中重复表单分析的代码,允许我们坚守DRY原则:别重复自己(don't repeat yourself)。
注册模型绑定器
ASP.NET MVC中的模型绑定器是实现了IModelBinder接口的类,可以用来帮助管理类型到输入参数的绑定。模型绑定器可以编来针对一个特定的对象类型,也可以用来处理一堆类型。IModelBinder接口允许你独立于web服务器或任何特定的controller实现来单元测试绑定器。
在一个ASP.NET MVC应用中, 模型绑定器可以在4个不同的层次上注册,于你如何使用它们,提供了巨大的灵活性:
1) ASP.NET MVC会先看action方法的参数上是否声明了模型绑定器的特性,例如,我们可以象下面这样,通过使用特性来注解产品参数,以表示我们想使用假想的“Bind”绑定器(注意我们是如何通过特性的一个参数来表示只应该绑定2个属性):
注: 第五个预览版目前还没有象上面这样的内置[Bind]特性 (虽然我们在考虑在将来将其加为ASP.NET MVC的一个内置功能),但实现象上面这样的[Bind]特性所需的所有框架级基础设施在第五个预览版中都已经实现了。开源的MVCContrib项目也有一个你今天就可以使用的类似的DataBind特性。
2) 如果action方法参数上没有绑定器特性,ASP.NET MVC就会在传递给action方法的参数的类型上看是否注册有绑定器特性。例如,我们可以通过在Product部分类上加象下面这样的代码,为我们的LINQ to SQL "Product"对象注册一个显式的“ProductBinder”绑定器:
3) ASP.NET MVC还支持在应用启动时,使用ModelBinders.Binders集合注册绑定器的能力。这在你想要使用由第三方编写的类型(因为你无法加特性)时,或者你不想直接在你的模型对象上加绑定器特性,尤其有用。下面的代码例子演示了如何在global.asax中在应用启动时注册2个特定类型的绑定器:
4) 除了注册特定类型的全局绑定器外,你还可以使用ModelBinders.DefaultBinder属性来注册一个默认的绑定器,该绑定器将在没找到特定类型的绑定器时使用。MVCFutures程序集中(是当前MVC预览版中默认引用的)包含了一个ComplexModelBinder实现,它根据进来的表单提交的名称/数值,使用反射来设置对象的属性。你可以使用下面的代码来注册它,将它当作作为Controller action方法参数传递的复杂类型的后备绑定器:
注: MVC开发团队计划在下一个预览版中对IModelBinder接口做进一步的调整 (因为他们最近发现了必须对IModelBinder接口做一些改动的几个场景)。所以,如果你用第五个预览版开发了定制的模型绑定器的话,预期在下一个版本出来时需要做些调整(也许不是什么很大的变动,就是注意一下,我们知道它的方法的一些参数会有变动)。
UpdateModel和TryUpdateModel方法
上面的模型绑定器支持在你想要生成新的对象,将它们作为参数传递给controller action方法的场景下非常有用。但还有些场景,你想要能够把输入值绑定到在action方法中创建或获取的现有的对象实例上。例如,在对数据库中现有的产品的编辑场景中,你也许想要在action方法中,先使用ORM从数据库中取得一个现有的产品对象,然后将新的输入值绑定到这个获取的产品实例上,然后将变动保存回数据库。
第五个预览版在Controller基类中加了2个新的方法,UpdateModel() 和 TryUpdateModel(),来帮助促成这个场景。两者都允许你将一个现有的对象实例作为第一个参数传进去,然后作为第二个参数,你传入你想要使用表单提交值来更新的属性列表。 例如,下面,我使用LINQ to SQL取得了一个Product对象,然后使用UpdateModel方法来用表单数据更新产品的名称和价格属性。
UpdateModel方法会试图更新你列出的所有的属性(即使在更新列表前面某个属性时出了错)。如果它在更新一个属性时遇上了错误(例如,你对类型是Decimal的UnitPrice属性输入了假的字符串数据),它会在一个新的ModelState集合中存储一个抛出的异常对象,以及原先的表单提交值,这个集合是在第五个预览版中新加的。我们将在稍后讨论这个新的ModelState集合,但简单地说,在出错需要重新显示表单时,它方便我们用用户输入的值自动填充表单,让用户来修改。
在尝试更新所有列出的属性后,UpdateModel会抛出一个异常,如果其中一个失败了的话。TryUpdateModel的工作原理是一样的,除了不是抛出一个异常,而是返回一个true/false布尔值,表示更新过程中是否有错外。你可以任意选择与你的错误处理偏好最相合的方法。
Product编辑例子
要看一个使用UpdateModel方法的实际例子,让我们实现一个简单的产品编辑表单。我们将使用/Products/Edit/{ProductId}的URL格式来表示我们要编辑哪个产品。例如,在下面,URL是/Products/Edit/4,意味着我们将编辑那个ProductId是4的产品:
用户可以改变产品名称或单价,然后点击“Save”按钮,点击之后,我们的提交action方法将更新数据库,如果成功的话,将给用户显示“产品更新完毕!”的消息:
我们可以使用下面的2个Controller action方法来实现上面的功能。注意,我们使用了[AcceptVerbs]特性来区别显示初始表单的Edit action方法,和处理表单提交的另一个Edit action方法:
在上面,我们的POST action方法使用了LINQ to SQL从数据库中取得我们要编辑的产品对象的实例,然后使用UpdateModel试图使用表单提交值来更新产品的ProductName 和 UnitPrice值,然后它调用了LINQ to SQL datacontext的SubmitChanges()将更新保存回数据库中。如果成功的话,我们将一个成功的信息字符串保存到TempData集合中,然后用客户端重新定向将用户重新定向到GET action方法(将导致重新显示刚保存好的产品,以及表示更新成功了的TempData信息字符串)。如果出错的话,无论是表单提交值的问题,还是数据库更新的问题,一个异常会被抛出,然后在catch块中被捕捉住,然后我们会重新显示表单视图,让用户作修改。
你也许会疑惑,成功后的这个重新定向是怎么回事?为什么不就地重新显示表单以及成功信息呢?客户端的重新定向的理由是为了确保,如果用户在成功点击保存按钮后点击刷新按钮的话,他们不会重新提交表单,得到象这样一个浏览器提示:
做一个重新定向回到action方法的GET版本,确保用户点击刷新按钮会重新装载页面,而不是重新提交。这个方法被称作“Post/Redirect/Get”(即PRG)模式。Tim Barcz 在这里有一篇很棒的文章,对其在ASP.NET MVC中的应用有更详细的讨论。
上面的2个controller action方法是处理编辑和更新产品对象所需的全部实现。下面是跟上面的Controller相关的“Edit”视图:
有用的小技巧: 在过去,一旦你开始往URL中添加参数(例如,/Products/Edit/4),你必须在视图中编写代码来更新form的action属性,以包括提交URL中的参数。第五个预览版中包括了一个方便的Html.Form()辅助方法。Html.Form()有许多重载的版本,允许你指定诸多参数选项。我们加了一个新的不接受参数的Html.Form()重载方法,会输出与当前请求一样的URL。
例如,如果显示上面视图的Controller的URL是 “/Products/Edit/5”,象上面那样调用Html.Form(),作为标识输出的一部分,会自动输出<form action="/Products/Edit/5" method="post">。如果显示上面视图的Controller的URL是“/Products/Edit/55”,象上面那样调用Html.Form(),作为标识输出的一部分,会自动输出<form action="/Products/Edit/55" method="post">。这提供了一个很巧妙的方法来避免编写任何定制的代码来构建URL或包括参数。
单元测试和UpdateModel
在这个星期的第五个预览版中,UpdateModel方法总是从Request对象的Form提交集合中取得数值的,这意味着,要测试上面的表单提交action方法,你需要在你的单元测试里模拟Request对象。
在下一个MVC版本中,我们还将加一个重载的UpdateModel方法,它将允许你传入一个你自己的数值集合为它所用。例如,我们将能够使用第五个预览版中的新的FormCollection类型(有一个ModelBuilder对象会自动地使用所有的表单提交值来填充它),将它作为参数传给UpdateModel方法,象这样:
使用象上面这样的方法将允许我们不用使用任何模拟,就可以单元测试我们的表单提交场景。下面是我们可以编写的一个单元测试的例程,用来测试一个POST场景用新的数值成功地更新,然后重新定向到我们action方法的GET版本。 注意,为单元测试我们的controller的所有功能,我们不需要模拟任何东西(也不依赖任何特别的辅助方法):
处理错误场景 - 重新显示带错误消息的表单
在处理表单提交场景时,一个需要注意的重要事情是处理出错条件。这些情形包括,用户提交了错误的输入(例如,对类型是Decimal的单价,输入了字符串,而不是数字),以及输入格式是合法的,但应用后面的业务规则不允许某些东西得到创建/更新/保存(例如,对停卖了的产品的新的订购)。
如果用户在填表时犯了错,表单应该重新显示,并输出清楚明确的错误信息,引导用户修正输入。表单还应该保留用户当初输入的数据,这样,他们就不用手工重填了。这个过程应该重复多次,直到表单成功完成为止。
ASP.NET MVC中的基本的表单错误处理和输入验证
在上面的产品编辑例程中,我们在Controller或View中都没怎么编写错误处理代码。我们的Edit提交action方法只简单地用了一个try/catch错误处理块将UpdateModel()输入映射调用以及数据库保存SubmitChanges()调用围了起来。如果错误发生的话,controller将一个输出消息保存到TempData集合中,然后返回/重新显示我们的编辑视图:
在ASP.NET MVC以前的版本中,上面的代码提供不了很好的用户体验(因为有错的话,它不会高亮显示问题所在,也不会保留用户输入)。
但在第五个预览版中,你会发现,只用上面的代码,出错时,你现在能得到一个不错的用户体验。特别地,你会发现,当我们的编辑视图因为输入错误重新显示时,它会高亮显示所有有问题的输入控件,同时保留它们的输入值,让我们来修正:
你也许会问,单价文本框是如何用红色高亮显示自己,知道输出原先输入的用户值的呢?
第五个预览版引进了一个新的“ModelState”集合,该集合是在显示时,作为ViewData的一部分,从Controller传递给View的。 ModelState集合给Controllers提供了一个方式,来表示传给View的模型对象或模型属性中有错存在,允许指定一个描述问题所在的对人类友好的错误消息,以及由用户输入的原始数据。
开发人员可以在Controller action方法中,明确地编写代码往ModelState集合中添加个项。 ASP.NET MVC 的模型绑定器和UpdateModel()辅助方法也会在遇上输入错误时自动地默认填充这个集合。因为在上面的Edit action方法中,我们使用了UpdateModel()辅助方法,在它试图将单价文本框的“gfgff23.02”输入映射到Product.UnitPrice(类型是Decimal)属性时失败了的时候,它会自动地往ModelState集合中加一项。
View中的Html辅助方法在默认情形下,在显示输出时,现在会查看ModelState集合。如果它们正显示的一项有错存在的话,它们现在会将原先输入的用户数据显示出来,同时会将一个CSS错误类显示到HTML输入控件元素上去。例如,在上面的“Edit”视图中,我们使用了 Html.TextBox() 辅助方法来显示我们Product对象的UnitPrice:
在上面的出错场景下,视图被显示时,Html.TextBox()会查看ViewData.ModelState集合,看我们的Product对象的“UnitPrice”属性是否有问题,当它看到有问题时,它会将原先输入的用户数据"gfgff23.02"输出,同时将一个css类加到它输出的<input type="textbox"/>上去:
你可以将出错css类的外观定制成你想要的任何外观,在新的ASP.NET MVC项目中创建的样式表中输入控件元素默认的出错CSS规则如下所示:
添加额外的验证消息
内置的HTML表单辅助方法对有问题的输入域提供了基本的视觉识别外观。现在让我们来往页面上再加一些更具描述性的出错信息,具体做法是,我们可以使用第五个预览版中这个新的Html.ValidationMessage()辅助方法,这个方法会输出与一个给定Model或Model属性相关联的ModelState集合中的错误信息。
例如,我们可以更新我们的视图,在文本框的右方使用Html.ValidationMessage()辅助方法,象这样
当页面因错而显示时,错误消息就会显示在有问题的控件域之后:
Html.ValidationMessage() 方法还有一个重载的版本,接受第二个参数,允许视图指定要显示的替换文字:
常见的一个用例是,在输入控件域后面输出 * 字符,然后将新的Html.ValidationSummary()辅助方法(也是第五个预览版中新加的)加到页面的顶部,来列出所有的错误消息:
Html.ValidationSummary()辅助方法然后就会用<ul><li></ul>显示出ModelState集合中的所有错误消息,以及 * 和 红边框会表示每个有问题的输入控件元素:
注意,我们完全不用改动ProductsController类就取得了如此效果。
支持业务规则的验证
象上面那样支持输入验证场景是很有用的,但对大多数应用来说是不够的。在大多数场景下,你还想要能够执行业务规则,让你应用的用户界面能与它们干净地结合起来。
ASP.NET MVC支持任何的数据层抽象(无论是基于ORM与否),允许你对你的领域模型,以及相关联的规则/限制进行任意的结构化。象模型绑定器, UpdateModel辅助方法,以及所有的错误显示和验证消息支持这样的功能,都是特意设计的,这样你可以在你的MVC应用中使用你想要的任何首选数据访问故事(包括LINQ to SQL, LINQ to Entities, NHibernate, SubSonic, CSLA.NET, LLBLGen Pro, WilsonORMapper, DataSets, ActiveRecord等等)。
往LINQ to SQL实体中添加业务规则
在上面的例子中,我一直在使用LINQ to SQL 来定义我的Product实体和进行数据访问操作。迄今为止,在我的Product实体上使用的唯一领域规则/验证是那些由LINQ to SQL从SQL Server元数据(可空性,数据类型和长度等等)中推断出来的那些规则,这会捉住象上面那样的场景(我们试图给一个Decimal赋一个有问题的输入)。但是,它们无法构型那些使用SQL元数据难以声明的业务问题。例如,在一个产品停售之后,不能允许它的reorder(重订购)水平大于0,或者不允许一个产品以小于供应商的价格出售,等等。对这样的场景,我们需要在我们的模型中添加代码来表达和结合这些业务规则。
添加这些业务规则逻辑的错误地方是应用的UI层,在那里加这些规则不好,有很多理由。特别值得一提的是,几乎肯定会导致代码的重复,因为你最终会将这些规则从一个UI拷贝到另一个UI,从一个表单到另一个表单。除了费时外,这么做,在你改变业务规则逻辑时,如果你忘了在所有的地方做更新的话,有很大的几率会导致缺陷。
一个融合这些业务规则的好得多的地方是你的模型或领域层,那么做的话,不管是什么类型的用户界面或表单或服务,这些规则都可以被使用和执行。对规则的变动只要做一次,不用重复任何逻辑就可在任何地方使用。
我们有几个模式和方法可用,来将丰富的业务规则集成到上面我们一直在用的Product模型对象中去:我们可以在对象中定义这些规则,也可在对象外面定义这些规则。我们可以使用声明式规则,一个可重用的规则引擎框架,或者命令式代码(imperative code)。要点是,ASP.NET MVC允许我们使用任何一种方法或者所有这些方法(没有一堆功能要求你总是采用一种方式做事,你拥有对它们进行变通的灵活性,MVC功能的扩展性之强大,几乎可以与任何东西相结合)。
在本贴子中,我将使用一个比较简单的规则的方法。首先,我将象下面这样定义一个“RuleViolation”类,我们可以用它来捕捉住我们模型中正被违反的业务规则的信息。这个类会呈现一个ErrorMessage字符串属性,内含错误的细节,以及呈现与之相关联的造成规则违反的关键属性名称和属性值:
(注:为简单起见,我将只储存一个属性, 在更复杂的应用中,这也许会是一个列表,这样就可以指定多个属性。)
然后我将定义一个IRuleEntity接口,内含单个方法, GetRuleViolations(),将返回实体中所有业务规则违反的列表:
然后我可以让我的Product类实现这个接口,为保持这个例子简单起见,我将把规则定义和估算逻辑内嵌在方法之中。有更好的模式你可以用来促成可重用的规则,以及处理更复杂的规则。如果这个例程变大的话,我需要重构这个方法,这样,规则以及它们的估算将被定义在其它地方,但目前为保持简明,我们将象下面这样估算三个业务规则:
我们的应用现在可以查询Product(或任何其他IRuleEntity)实例来检查它目前的验证状态,以及取回RuleViolation对象,这些对象可用来帮助显示一个可以引导应用的用户修正这些违反的用户界面。它还允许我们独立于应用的用户界面来轻松地单元测试我们的业务规则。
在这个特定的例子里,我将选择确保,我们的Product对象在不合法的状态下是不能保存到数据库中去的(意味着在Product对象可保存到数据库之前,所有的RuleViolations都必须被修正)。在LINQ to SQL中,我们可以这么做,在Product部分类中,加一个OnValidate部分方法。这个方法会在数据库持久化时,被LINQ to SQL自动调用。在下面我将调用我们在上面添加的GetRuleViolations()方法,如果有未解决的错误,我将抛出一个异常。这会中止事务,防止数据库被更新:
那样,除了有个友好的辅助方法允许我们从一个Product中取出RuleViolations外,我们还确保在数据库被更新之前那些RuleViolations必须被修正。
将上述规则集成进ASP.NET MVC用户界面中
一旦象上面那样实现了业务规则,呈示RuleViolations后,将其集成进我们的ASP.NET MVC例程则是比较容易的事情。
因为我们往Product类中加了OnValidate部分方法,调用northwind.SubmitChanges()会抛出一个异常,如果我们试图保存的Product对象中有任何业余规则验证问题的话。这个异常会终止任何数据库事务,在下面的catch块中被捕捉住:
我们要往我们的错误catch块中加的的一行额外代码是调用定义在下面的UpdateModelStateWithViolations()辅助方法的逻辑。这个方法从一个实体中取出所有的规则违反的列表,然后将适当的模型错误(包括对实体对象上导致错误的属性的引用)更新ModelState集合:
完成之后,我们可以重新运行我们的应用。现在,除了看到与输入格式相关的错误信息外,ASP.NET MVC的验证辅助方法还将显示我们的业务规则的违反情形。
例如,我们可以将单价设成小于$1,将Reorder水平设成-1(从输入格式的角度来看,这2个值都是合法的,但两者都违反了业务规则)。当我们这么做,点击保存按钮时,我们将看到Html.ValidationSummary()列表中的错误信息,以及相关的文本框被特别标出了:
我们的业务规则可以跨越多个Product属性,例如,你也许注意到了,我在上面加了一条规则说,如果产品被中止的话,reorder水平不能大于0:
在这整个业务规则过程中,对我们的“Edit”视图模板要做的唯一变动是往文件中再加2个Product属性(Reorder和Discontinued):
现在我们可以往我们的Product实体中添加任何数目的额外业务验证规则,而且为使得界面支持它们,我们不需要更新Edit视图,也不要更新ProductsController。
我们还可以独立于我们的Controller和View,对我们的模型和业务规则进行单独的单元测试。我们也可以独立于我们的Controller, Views 和 Models对URL路径选择规则进行单独的单元测试。我们可以独立于我们的Views单独单元测试我们的Controller。本博客贴子展示的所有场景都不要求使用任何模拟(mocking)或替补(stubbing)就支持单元测试,最终的结果是极其容易测试的应用,而且支持很好的TDD工作流程。