ASP.NET MVC 处理 Null 模型
一种处理 ASP.NET MVC 视图中 Null 模型的建议解决方案。
引言
你是否曾遇到过一个由多个领域模型组成的视图模型?例如,一个包含 Person
对象和 Address
对象的 Personnel
视图模型。你将其绑定到一个用于管理目的的视图,例如更改人员姓名或地址。那么,如果其中一个领域模型可以为 null
会怎么样?出于某种原因,一个人可能没有地址,或者你只需要在系统中输入他们的姓名而不填写地址。但同时,你必须显示地址的视图,因为管理员用户可以随时输入地址。拥有可能为 null
的模型会在 ASP.NET MVC 中造成一些复杂性。
我发现自己遇到了一个具体情况,也许其他人也遇到过?我假设你对 ASP.NET MVC 非常熟悉。那么,让我来描述一下场景。我有一个管理员屏幕,我在其中编辑 Personnel
的信息。有输入元素用于名字、姓氏、街道地址等。那么,假设我有两个领域模型如下。(代码显示得非常简化。)
public class Person
{
public int ID {get;set;}
public string Name{get;set;}
public int? AddressID {get;set;}
}
public class Address
{
public int ID{get;set;}
public string Street {get;set;}
}
假设我有一个视图模型如下
public class Personnel
{
public Person Person{get;set;}
public Address Address{get;set;}
}
请注意 Person
和 Address
之间存在一对一的关系,并且 Person.AddressId
是可空的。我不会展示代码,但假设存在某种机制(实体框架或其他)通过 ID
检索 Personnel
对象的一个实例。关键是要意识到 Personnel
的 Address
属性可能为 null
,因为并非所有人员都输入了他们的地址信息,所以数据库中不会有对应的记录。我们还假设,如果 Address
不为 null
,那么你使用的持久化机制将在 ID = 0
时在数据库中插入一条记录,否则将执行更新。这里情况变得有点奇怪。UI 或视图仍然需要显示地址模型的输入元素,因为管理员用户随时可以输入此人的地址,或者如果他们不打算提供地址信息,也可以留空。因此,Address 可能为 null
,但页面上的 UI 输入元素仍然需要存在。我有一个用于地址类型的局部视图,看起来像这样
@Html.HiddenFor(m => m.ID)
@Html.EditorFor(m => m.Street)
假设我们将所有这些放入一个控制器和视图中,并请求一个没有地址的员工的人员管理页面的 URL。它可能看起来像这样
问题
好的,这有什么问题?如果我查看页面的源代码,并查看 Address
部分的标记,我会看到这个
<input value name="Address.ID" type="hidden">
隐藏输入的值是 null
,或者实际上是空字符串。因为 Address
最初是 null
,并且没有什么可以放入 value 属性中,所以这是应该的。
那么,也许管理员用户将名字更改为“Stan
”,而不对地址做任何更改,然后点击保存。假设保存提交表单,并发布到像这样的控制器
[HttpPost]
public ActionResult EditPersonnel(Personnel model){
if (ModelState.IsValid)
{
model.Save() // or whatever
}
return View(model);
}
我们终于来到了问题所在。上面的代码有什么问题?model.Save()
永远不会被调用,我们的信息也永远不会持久化到数据库,为什么?因为 ModelState.IsValid
永远会是 false
。发生了什么?问题是由于 MVC 默认模型绑定器的工作方式引起的。当模型绑定器尝试从请求的已发布数据中反序列化对象时,它必须能够为一个值类型属性(如 int
、short
、bool
等)找到一个有效值。没有例外。在这种情况下,就是 Address.ID
。不幸的是,请求数据对于 Address.ID
的值是 ""(空字符串),因为这就是隐藏输入元素的值。如果你像我在 Chrome 中那样检查网络请求,就可以看到这一点。
MVC 不会将类型的值放入 input 字段的 value 属性中。Address.ID
是一个整数,所以如果 Address
属性为 null
,你可能认为 MVC 的 HtmlHelper.HiddenFor
会将其放入整数的默认值,即 0
,但事实并非如此。因此,因为模型绑定器无法将 "" 转换为 0 来设置 Address.ID
,它会在 ModelState
的错误集合中添加一个条目,IsValid
将始终为 false
,人员模型将永远不会保存。当然,这仅在你希望使用验证时发生。如果你不使用验证,你将不会遇到这个问题,但谁又不使用验证呢?我假设你希望使用 Microsoft 构建的系统。使用数据注解,并让 DefaultModelBinder
自动处理一切非常方便。
解决方案?
- 最直接的解决问题的方法是修改
Address.ID
的类型为Nullable<int>
。如果模型中的属性是可空的,那么它就不需要被绑定,一个空字符串可以转换为一个null
整数,并且不会注册错误。我选择不使用这种技术,因为它会扭曲领域模型。ID
是Address
在数据库中的主键,所以它不能为null
。但这或许是一个有趣的解决方案。我的意思是,如果你创建一个新的地址,ID
将会是0
,直到它被保存到数据库并填充新的ID
。为什么它不能是null
而不是0
呢。 - 有人可能会说 UI 不应该这样。如果地址可以为
null
,那么地址的输入字段就不应该存在,直到用户点击“添加新地址”按钮或类似的操作。这也许是一个很好的做法,但是Person
和Address
的关系是一对一的,在我看来,当只能添加一个新地址时,有一个“添加新地址”按钮会显得很奇怪。此外,需求文档可能坚持要求我们像这样显示。 - 还有人可能会说,如果
Address
是null
,那么在将其传递给视图之前,将其赋值为一个新的Address
实例。if(p.Address == null){p.Address = new Address();}
我想这可以接受,但它没有考虑到管理员用户可能根本不填写地址字段,这表明
Person
没有地址。如果用户保存,是的,请求中的所有数据都是有效的,并且模型绑定器不会产生错误,但数据库中将插入一个空的Address
记录。也许在某些情况下这是可以接受的。 - 如果我们首先阻止导致问题的初始条件,即空字符串无法转换为整数
Address.ID
,该怎么办?一种方法是不提供Address.ID
的输入元素。如果请求不包含Address.ID
的条目,那么绑定器就不会尝试将该值转换为整数。所以我们可以创建一个HtmlHelper
扩展方法,它模仿HiddenFor
的功能。我想能够检查模型是否为null
,如果是,则不创建任何标记。它可能看起来像这样public static MvcHtmlString HiddenForDefault<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression) { ModelMetadata metadata = ModelMetadata.FromLambdaExpression <TModel, TProperty>(expression, htmlHelper.ViewData); if (!metadata.ModelType.IsValueType) { throw new Exception ("Don't use this for reference types which don't need a value"); } if (metadata.Model == null) { return new MvcHtmlString("") ; } else return htmlHelper.HiddenFor(expression); }
然后我们可以将我们的地址视图更改为这样
@Html.HiddenForDefault(m => m.ID)
现在,如果
Address
是null
,那么对于Address.ID
的输入元素将不会创建任何标记。需要注意的是,DefaultModelBinder
仍然会在Personnel
对象上创建一个Address
实例,因为Street
输入元素存在。在绑定完成后,如果它看到任何名称为“Address.whatever
”的请求键,它将创建一个新的Address
对象并将其放入Personnel.Address
。如果没有找到键,那么Personnel.Address
将为null
。所以现在,因为绑定器不再尝试将空string
放入整数Address.ID
,所以不会注册任何错误,验证也会通过。太棒了!
**当我提到请求键时,我指的是DefaultModelBinder
查找传入信息的集合中的键值:Request.Form
、URL、Request.QueryString
等等。 - 因此,我们现在能够通过验证,但仍然存在一个问题。绑定器正在创建一个
Address
对象的实例,这意味着在保存时,数据库中将插入一条空的记录。这就像问题 #3 引起的那样。有什么办法可以解决这个问题吗?是的。在 ASP.NET MVC 中,你可以创建自己的自定义模型绑定器。我们可以创建一个能够识别Address
允许为null
并且会相应调整的绑定器。要创建自定义绑定器,我创建一个继承自System.Web.Mvc.DefaultModelBinder
的类。public class PersonnelModelBinder:DefaultModelBinder { public override object BindModel (ControllerContext controllerContext, ModelBindingContext bindingContext) { Personnel p = base.BindModel(controllerContext, bindingContext) as Personnel; } }
现在我们需要让 MVC 系统知道,任何时候它试图反序列化
Personnel
类型时,都应该使用PersonnelModelBinder
。我们可以通过在网站的 global.asax 文件的Application_Start
中添加以下代码来做到这一点。System.Web.Mvc.ModelBinders.Binders.Add(typeof(Personnel), new PersonnelModelBinder());
在
PersonnelModelBinder
中,我重写了BindModel
方法,当你想从请求数据中反序列化对象时,MVC 系统会调用此方法。我们仍然希望使用基类的函数,通过调用base.BindModel
。这是因为DefaultModelBinder
完成了从请求中提取数据的所有复杂工作,并使用反射来决定如何创建对象并将适当的值放入该对象的属性中。我们绝对不想自己编写那段代码。因此,在执行完上述代码后,我们现在将在变量
p
中拥有一个反序列化的Personnel
模型。请记住,即使请求数据中没有Address.ID
,绑定器仍然成功创建了一个Address
对象,并且ID
将为0
。根据上面的代码,p.Address.ID
将是0
。所以现在我们需要确定我们逻辑的判断依据,即Address
对象应该保留还是被设置为null
。- 如果
Address.ID == 0
并且Address
的所有其他字段都为空/null/0,那么用户无意创建地址。所以我们必须将p.Address
设置为null
; - 如果
Address.ID == 0
但其中一个字段有值,那么用户有意创建地址,所以我们不能将p.Address
设置为null
;
请记住,我们假设如果
Address
为null
,你使用的任何持久化技术都将忽略Address
并且不会保存它,否则如果不是null
,它将保存它。
所以,将PersonnelModelBinder
更改为使用此逻辑可能如下所示public class Peronnel:BaseModelBinder { public override object BindModel (ControllerContext controllerContext, ModelBindingContext bindingContext) { Personnel p = base.BindModel(controllerContext, bindingContext) as Personnel; if( p.Address.ID == 0 && p.Address.Street == "" ) { p.Address = null; } return p; } }
因此,在实现此逻辑后,我们将拥有我们想要的行为。如果
Address
对象的所有属性都为空(零、空字符串、null),这意味着用户不想创建地址,所以我们将Address
设置为null
,这样就不会保存空的记录。如果Address
的任何属性有值,那么我们什么也不做,持久化机制就会保存它。 - 如果
- 所以,有了解决方案 #4 和 #5,你必须将它们结合起来才能起作用。也许有人不喜欢这种方法。你必须知道在任何地方使用
HiddenForDefault
,并实现它,只要Model
可能为null
,然后你还必须有一个自定义模型绑定器。也许所有这些都应该在一个地方处理会更好。以下是我们如何修改PersonnelModelBinder
来处理所有事情,这样我们就不用使用HiddenForDefault
了。public override object BindModel( ControllerContext controllerContext, ModelBindingContext bindingContext) { Personnel p = base.BindModel(controllerContext, bindingContext) as Personnel; if( p.Address.ID == 0 && p.Address.Street == "" ) { p.Address = null; foreach (var k in bindingContext.ModelState.Keys) { if (k.Contains("Address.")) { bindingContext.ModelState[k].Errors.Clear(); } } } else if(p.Address.AddressID == 0) { bindingContext.ModelState["Address.AddressID"].Errors.Clear(); } return p; }
那么,现在我们假设我们不再使用
HiddenForDefault
,而只使用PersonnelModelBinder
。那么这个绑定器在做什么?首先,它进行与之前相同的检查,以确定用户是否无意创建地址,并且与之前一样,它会将Address
设置为null
。但现在它需要适应这样一个事实:由于我们没有使用HiddenForDefault
,ModelState
中会出现错误。这可以通过简单地清空任何键包含“Address
”文本的ModelState
中的错误集合来完成。这是应用于来自Address
对象的所有属性的前缀。添加了另一个条件来适应用户输入了信息到输入框中的情况,目的是创建一个新地址。Address
Id 不会被用户创建,因此仍然是空字符串,仍然会产生错误。我们只需要清除那个特定的错误,而不是所有的错误,因为用户可能输入了无效的值,例如街道的“?????+++****”,这是无效的。当然,我们不会将Address
设置为null
,因为我们希望它在那里以便保存。
这些就是我为处理我描述的场景而寻找的解决方案。我认为 #6 是最好的解决方案,因为它在一个地方处理了所有问题,但你可能更喜欢使用其他解决方案之一。