65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET MVC 处理 Null 模型

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (16投票s)

2013年6月9日

CPOL

11分钟阅读

viewsIcon

196282

一种处理 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;}
}   

请注意 PersonAddress 之间存在一对一的关系,并且 Person.AddressId 是可空的。我不会展示代码,但假设存在某种机制(实体框架或其他)通过 ID 检索 Personnel 对象的一个实例。关键是要意识到 PersonnelAddress 属性可能为 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 默认模型绑定器的工作方式引起的。当模型绑定器尝试从请求的已发布数据中反序列化对象时,它必须能够为一个值类型属性(如 intshortbool 等)找到一个有效值。没有例外。在这种情况下,就是 Address.ID。不幸的是,请求数据对于 Address.ID 的值是 ""(空字符串),因为这就是隐藏输入元素的值。如果你像我在 Chrome 中那样检查网络请求,就可以看到这一点。

MVC 不会将类型的值放入 input 字段的 value 属性中。Address.ID 是一个整数,所以如果 Address 属性为 null,你可能认为 MVC 的 HtmlHelper.HiddenFor 会将其放入整数的默认值,即 0,但事实并非如此。因此,因为模型绑定器无法将 "" 转换为 0 来设置 Address.ID,它会在 ModelState 的错误集合中添加一个条目,IsValid 将始终为 false,人员模型将永远不会保存。当然,这仅在你希望使用验证时发生。如果你不使用验证,你将不会遇到这个问题,但谁又不使用验证呢?我假设你希望使用 Microsoft 构建的系统。使用数据注解,并让 DefaultModelBinder 自动处理一切非常方便。

解决方案?

  1. 最直接的解决问题的方法是修改 Address.ID 的类型为 Nullable<int>。如果模型中的属性是可空的,那么它就不需要被绑定,一个空字符串可以转换为一个 null 整数,并且不会注册错误。我选择不使用这种技术,因为它会扭曲领域模型。IDAddress 在数据库中的主键,所以它不能为 null。但这或许是一个有趣的解决方案。我的意思是,如果你创建一个新的地址,ID 将会是 0,直到它被保存到数据库并填充新的 ID。为什么它不能是 null 而不是 0 呢。
  2. 有人可能会说 UI 不应该这样。如果地址可以为 null,那么地址的输入字段就不应该存在,直到用户点击“添加新地址”按钮或类似的操作。这也许是一个很好的做法,但是 PersonAddress 的关系是一对一的,在我看来,当只能添加一个新地址时,有一个“添加新地址”按钮会显得很奇怪。此外,需求文档可能坚持要求我们像这样显示。
  3. 还有人可能会说,如果 Addressnull,那么在将其传递给视图之前,将其赋值为一个新的 Address 实例。
    if(p.Address == null){p.Address = new Address();}   

    我想这可以接受,但它没有考虑到管理员用户可能根本不填写地址字段,这表明 Person 没有地址。如果用户保存,是的,请求中的所有数据都是有效的,并且模型绑定器不会产生错误,但数据库中将插入一个空的 Address 记录。也许在某些情况下这是可以接受的。

  4. 如果我们首先阻止导致问题的初始条件,即空字符串无法转换为整数 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) 

    现在,如果 Addressnull,那么对于 Address.ID 的输入元素将不会创建任何标记。需要注意的是,DefaultModelBinder 仍然会在 Personnel 对象上创建一个 Address 实例,因为 Street 输入元素存在。在绑定完成后,如果它看到任何名称为“Address.whatever”的请求键,它将创建一个新的 Address 对象并将其放入 Personnel.Address。如果没有找到键,那么 Personnel.Address 将为 null。所以现在,因为绑定器不再尝试将空 string 放入整数 Address.ID,所以不会注册任何错误,验证也会通过。太棒了!
    **当我提到请求键时,我指的是 DefaultModelBinder 查找传入信息的集合中的键值:Request.Form、URL、Request.QueryString 等等。

  5. 因此,我们现在能够通过验证,但仍然存在一个问题。绑定器正在创建一个 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

    1. 如果 Address.ID == 0 并且 Address 的所有其他字段都为空/null/0,那么用户无意创建地址。所以我们必须将 p.Address 设置为 null
    2. 如果 Address.ID == 0 但其中一个字段有值,那么用户有意创建地址,所以我们不能将 p.Address 设置为 null

    请记住,我们假设如果 Addressnull,你使用的任何持久化技术都将忽略 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 的任何属性有值,那么我们什么也不做,持久化机制就会保存它。

  6. 所以,有了解决方案 #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。但现在它需要适应这样一个事实:由于我们没有使用 HiddenForDefaultModelState 中会出现错误。这可以通过简单地清空任何键包含“Address”文本的 ModelState 中的错误集合来完成。这是应用于来自 Address 对象的所有属性的前缀。添加了另一个条件来适应用户输入了信息到输入框中的情况,目的是创建一个新地址。Address Id 不会被用户创建,因此仍然是空字符串,仍然会产生错误。我们只需要清除那个特定的错误,而不是所有的错误,因为用户可能输入了无效的值,例如街道的“?????+++****”,这是无效的。当然,我们不会将 Address 设置为 null,因为我们希望它在那里以便保存。

这些就是我为处理我描述的场景而寻找的解决方案。我认为 #6 是最好的解决方案,因为它在一个地方处理了所有问题,但你可能更喜欢使用其他解决方案之一。

© . All rights reserved.