Asp.Net MVC 2 - 将模型属性绑定到不同名称的值

51

更新(2016年9月21日) - 感谢Digbyswift评论指出此解决方案在MVC5中仍然有效。

更新(2012年4月30日) - 注意,如果您通过搜索等方式偶然发现这个问题,那么接受的答案不是我最终采用的方法,但我仍将其接受,因为它在某些情况下可能有效。我的回答包含了我使用的最终解决方案,它可重用,并适用于任何项目。

它也被确认可以在MVC框架的v3和v4中工作。

我有以下模型类型(类的名称和属性名称已更改以保护其身份):

public class MyExampleModel
{
  public string[] LongPropertyName { get; set; }
}

这个属性绑定到了一组(>150)复选框,每个复选框的输入名称当然是LongPropertyName

表单以HTTP GET方式提交到URL,如果用户选择其中三个复选框,URL将拥有查询字符串?LongPropertyName=a&LongPropertyName=b&LongPropertyName=c

那么大的问题是,如果我选择所有复选框(甚至只是超过一半!),我就会超过IIS请求过滤器强制执行的最大查询字符串长度!

我不想扩展它-所以我想要一种方法来缩小这个查询字符串(我知道我可以切换到POST-但即使如此,我仍然希望尽量减少客户端发送的数据中的冗余信息)。

我想做的是将LongPropertyName绑定为简单的'L',这样查询字符串就会变成?L=a&L=b&L=c,但不更改代码中的属性名称。

所涉及的类型已经有一个自定义模型绑定器(派生自DefaultModelBinder),但它附加到其基类-因此我不想在派生类中放置代码。当前所有属性绑定都由标准的DefaultModelBinder逻辑执行,我知道它使用System.ComponentModel中的TypeDescriptors和Property Descriptors等。

我有点希望可能会有一个可以应用于属性的属性-有吗?还是我应该考虑实现ICustomTypeDescriptor


1
嘿,我最初来到这里是在思考如何解决 asp.net core 的完全相同的问题。事情已经改变了,所以我打开了一个不同的 SO 问题。链接在这里。 - Nathan Cooper
@NathanCooper 很酷 :) - Andras Zoltan
@AndrasZoltan:非常感谢你的好问题和回答。我认为你自己的答案应该是被接受的答案……这对像我这样遇到这篇文章并想快速找到正确答案的人来说会更少混淆。 - Hooman Bahreini
5个回答

86
作为对michaelalm答案和请求的回应,这是我最终所做的。我保留了原始答案,主要是出于礼貌,因为Nathan建议的其中一种解决方案可以工作。
此输出是一个替换DefaultModelBinder类的结果,您可以全局注册它(从而允许所有模型类型受益于别名),或者选择性地继承它以用于自定义模型绑定器。
一切都始于可预测的:
/// <summary>
/// Allows you to create aliases that can be used for model properties at
/// model binding time (i.e. when data comes in from a request).
/// 
/// The type needs to be using the DefaultModelBinderEx model binder in 
/// order for this to work.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
public class BindAliasAttribute : Attribute
{
  public BindAliasAttribute(string alias)
  {
    //ommitted: parameter checking
    Alias = alias;
  }
  public string Alias { get; private set; }
}

然后我们得到了这个类:

internal sealed class AliasedPropertyDescriptor : PropertyDescriptor
{
  public PropertyDescriptor Inner { get; private set; }

  public AliasedPropertyDescriptor(string alias, PropertyDescriptor inner)
    : base(alias, null)
  {
    Inner = inner;
  }

  public override bool CanResetValue(object component)
  {
    return Inner.CanResetValue(component);
  }

  public override Type ComponentType
  {
    get { return Inner.ComponentType; }
  }

  public override object GetValue(object component)
  {
    return Inner.GetValue(component);
  }

  public override bool IsReadOnly
  {
    get { return Inner.IsReadOnly; }
  }

  public override Type PropertyType
  {
    get { return Inner.PropertyType; }
  }

  public override void ResetValue(object component)
  {
    Inner.ResetValue(component);
  }

  public override void SetValue(object component, object value)
  {
    Inner.SetValue(component, value);
  }

  public override bool ShouldSerializeValue(object component)
  {
    return Inner.ShouldSerializeValue(component);
  }
}

这个代理了一个“正式”的PropertyDescriptor,通常由DefaultModelBinder找到,但它将其名称呈现为别名。

接下来是新的模型绑定器类:

已更新,采用@jsabrooke的建议以下

public class DefaultModelBinderEx : DefaultModelBinder
{
  protected override System.ComponentModel.PropertyDescriptorCollection
    GetModelProperties(ControllerContext controllerContext, 
                      ModelBindingContext bindingContext)
  {
    var toReturn = base.GetModelProperties(controllerContext, bindingContext);

    List<PropertyDescriptor> additional = new List<PropertyDescriptor>();

    //now look for any aliasable properties in here
    foreach (var p in 
      this.GetTypeDescriptor(controllerContext, bindingContext)
      .GetProperties().Cast<PropertyDescriptor>())
    {
      foreach (var attr in p.Attributes.OfType<BindAliasAttribute>())
      {
        additional.Add(new AliasedPropertyDescriptor(attr.Alias, p));

        if (bindingContext.PropertyMetadata.ContainsKey(p.Name)
            && !string.Equals(p.Name, attr.Alias, StringComparison.OrdinalIgnoreCase)))
        {
            bindingContext.PropertyMetadata.Add(
                attr.Alias,
                bindingContext.PropertyMetadata[p.Name]);
        }
      }
    }

    return new PropertyDescriptorCollection
      (toReturn.Cast<PropertyDescriptor>().Concat(additional).ToArray());
  }
}

然后,从技术上讲,就是这样了。您现在可以使用此SO中发布的解决方案将DefaultModelBinderEx类注册为默认值:更改asp.net MVC中的默认模型绑定程序,或者您可以将其用作自己模型绑定程序的基础。

选择绑定程序启动方式后,只需将其应用于模型类型,如下所示:

public class TestModelType
{
    [BindAlias("LPN")]
    //and you can add multiple aliases
    [BindAlias("L")]
    //.. ad infinitum
    public string LongPropertyName { get; set; }
}

我选择这段代码的原因是因为我想要一个既可以与自定义类型描述符一起工作,又可以与任何类型一起工作的东西。同样,我希望仍然使用值提供程序系统来获取模型属性值。所以我改变了DefaultModelBinder在开始绑定时看到的元数据。这是一个稍微冗长一些的方法 - 但从概念上讲,它在元数据级别上正好做到了你想要的。

一个可能有趣但有点烦人的副作用是,如果ValueProvider包含多个别名或别名和属性名称的值,那么只会使用检索到的值中的一个。不过只是使用object进行操作时很难想出一种类型安全合并它们所有的方式。不过这类似于在表单提交和查询字符串中都提供值的情况 - 我不确定MVC在这种情况下会做什么,但我认为这并不是推荐做法。

另一个问题是,当然不能创建一个等于另一个别名或实际属性名称的别名。

通常情况下,我喜欢使用CustomModelBinderAttribute类来应用我的模型绑定器。唯一的问题是,如果您需要从模型类型派生并更改其绑定行为,则可能会出现问题 - 因为CustomModelBinderAttribute在MVC执行的属性搜索中是继承的。

在我的情况下,这还好,我正在开发一个新的网站框架,并能够使用其他机制将新的可扩展性推入我的基础绑定器以满足这些新类型;但对于每个人来说都不是这样的情况。


你如何生成考虑到BindAlias属性的外部URL? - hival
2
非常好,谢谢。这真的应该是框架中默认的一部分。 - mellodev
感谢这个解决方案,正好符合我的需求,我能够扩展我们已经使用的自定义第三方绑定器来支持它。 - stuisme
如果您对在此解决方案中使用类似强类型帮助程序的TextBoxFor感兴趣,请查看我的答案 - Yusuf Uzun
1
嘿@AndrasZoltan,感谢您的回答。我今天大部分时间都在尝试在ApiController中实现相同的功能(请参见下面的我的答案),看起来已经快10年了,但我很想听听您对此更新的想法。 - Alex C
显示剩余3条评论

20
你可以使用BindAttribute 来实现这个功能。
public ActionResult Submit([Bind(Prefix = "L")] string[] longPropertyName) {

}

更新

由于'longPropertyName'参数是模型对象的一部分,而不是控制器操作的独立参数,因此您有另外几个选择。

您可以将模型和属性保持为操作的独立参数,然后在操作方法中手动合并数据。

public ActionResult Submit(MyModel myModel, [Bind(Prefix = "L")] string[] longPropertyName) {
    if(myModel != null) {
        myModel.LongPropertyName = longPropertyName;
    }
}

另一种选择是实现自定义模型绑定器,手动执行参数值分配(如上所述),但这很可能是过度设计了。如果您感兴趣,这里有一个示例:Flags Enumeration Model Binder


1
啊,是的 - 但那是一个操作方法的参数 - 这是一个操作方法参数的属性... - Andras Zoltan
1
BindAttribute放在属性上是不可能的,因为它只能在类或参数声明上有效。虽然这本来是完美的解决方案 - 正是我正在寻找的那种东西。 - Andras Zoltan
@AndrasZoltan 哦,确实是这样。我的错误,我不确定那个。手动组合模型参数和longPropertyName参数可能是您最快的选择。自定义模型绑定器可能是“最干净”的选项。 - Nathan Taylor
1
最后,我通过在类型上注入一个基类来扩展已经存在的模型绑定器,并将其与DefaultModelBinder之间添加了一个基类,该基类从应用于要“别名化”的任何属性的属性中添加额外的PropertyDescriptor。同时,它还将该属性的PropertyMetaData克隆回BindingContext,以便当模型绑定逻辑开始通过别名读取/写入值时,它可以正常工作。现在,我可以将其重用于任何其他类型 - 总共只有大约50行代码 - 哦,我的q字符串又合法了! - Andras Zoltan

5
希望您能像Andras一样提供一个类似的解决方案,希望您也能发表您的答案。 控制器方法。
public class MyPropertyBinder : DefaultModelBinder
{
    protected override void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor)
    {
        base.BindProperty(controllerContext, bindingContext, propertyDescriptor);

        for (int i = 0; i < propertyDescriptor.Attributes.Count; i++)
        {
            if (propertyDescriptor.Attributes[i].GetType() == typeof(BindingNameAttribute))
            {                    
                // set property value.
                propertyDescriptor.SetValue(bindingContext.Model, controllerContext.HttpContext.Request.Form[(propertyDescriptor.Attributes[i] as BindingNameAttribute).Name]);
                break;
            }
        }
    }
}

属性

public class BindingNameAttribute : Attribute
{
    public string Name { get; set; }

    public BindingNameAttribute()
    {

    }
}

视图模型

public class EmployeeViewModel
{                    

    [BindingName(Name = "txtName")]
    public string TestProperty
    {
        get;
        set;
    }
}

然后在控制器中使用 Binder。
[HttpPost]
public ActionResult SaveEmployee(int Id, [ModelBinder(typeof(MyPropertyBinder))] EmployeeViewModel viewModel)
{
        // do stuff here
}

txtName表单值应设置为TestProperty。


是的,它很相似。我会在不久的将来发布我的答案,以便我们进行比较和对照 :) - Andras Zoltan
我已经发布了我的代码。我的版本不与Request.Form绑定,因为它向模型的元数据添加属性,以便所有其他绑定机制仍然可以使用(值提供程序等)。它适用于所有类型,并且可以用作全局绑定器的替代品,也可以作为自定义绑定器的基础。 - Andras Zoltan

3
这可能只是 Andras Zoltan 回答的短评,但我没有足够的声望,很抱歉。
感谢解决方案,我刚使用它,它仍然非常好用!然而,我的一些属性有一个别名,名称相同,但大小写不同,例如:
[BindAlias("signature")]
public string Signature { get; set; }

当自定义模型绑定器尝试将别名添加到 PropertyMetadata 字典时,这些会抛出错误,因为它们的主要属性名称版本已经被基础模型绑定器添加,并且模型绑定对大小写不敏感。

为了解决这个问题,只需进行不区分大小写的检查 -

替换

if (bindingContext.PropertyMetadata.ContainsKey(p.Name))

使用

if (bindingContext.PropertyMetadata.ContainsKey(p.Name)
    && !string.Equals(p.Name, attr.Alias, StringComparison.OrdinalIgnoreCase))

好的建议 - 我注意到你建议将此作为编辑提交,但不幸的是,它被以2:1的投票结果拒绝了。我已经批准了这个编辑并链接到了这个答案,这样人们就可以看到它来自于你。 - Andras Zoltan

1

所以我花了大部分时间来弄清楚为什么我无法让它工作。由于我是从System.Web.Http.ApiController进行调用,结果发现不能像上面提到的那样使用DefaultPropertyBinder解决方案,而必须使用IModelBinder类。

我编写的类来替换@AndreasZoltan上面写的基础工作如下:

using System.Reflection;
using System.Web;
using System.Web.Http.Controllers;
using System.Web.Http.ModelBinding;
using QueryStringAlias.Attributes;

namespace QueryStringAlias.ModelBinders
{
    public class AliasModelBinder : IModelBinder
    {
        private bool TryAdd(PropertyInfo pi, NameValueCollection nvc, string key, ref object model)
        {
            if (nvc[key] != null)
            {
                try
                {
                    pi.SetValue(model, Convert.ChangeType(nvc[key], pi.PropertyType));
                    return true;
                }
                catch (Exception e)
                {
                    Debug.WriteLine($"Skipped: {pi.Name}\nReason: {e.Message}");
                }
            }
            return false;
        }

        public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
        {
            Type bt = bindingContext.ModelType;
            object model = Activator.CreateInstance(bt);
            string QueryBody = actionContext.Request.Content.ReadAsStringAsync().Result;
            NameValueCollection nvc = HttpUtility.ParseQueryString(QueryBody);

            foreach (PropertyInfo pi in bt.GetProperties())
            {
                if (TryAdd(pi, nvc, pi.Name, ref model))
                {
                    continue;
                };
                foreach (BindAliasAttribute cad in pi.GetCustomAttributes<BindAliasAttribute>())
                {
                    if (TryAdd(pi, nvc, cad.Alias, ref model))
                    {
                        break;
                    }
                }
            }
            bindingContext.Model = model;
            return true;
        }
    }
}

为确保这是作为WebAPI调用的一部分运行,您还必须将 config.BindParameter(typeof(TestModelType), new AliasModelBinder()); 添加到 WebApiConfig 的注册部分中。如果您正在使用此方法,则还必须从方法签名中删除 [FromBody]
    [HttpPost]
    [Route("mytestendpoint")]
    [System.Web.Mvc.ValidateAntiForgeryToken]
    public async Task<MyApiCallResult> Signup(TestModelType tmt) // note that [FromBody] does not appear in the signature
    {
        // code happens here
    }

请注意,此工作基于上面的答案,使用QueryStringAlias示例。
目前,如果TestModelType具有复杂嵌套类型,这可能会失败。理想情况下还有其他几个要点:
- 坚韧地处理复杂嵌套类型 - 在类上启用属性来激活IModelBuilder,而不是在注册中 - 使相同的IModelBuilder在控制器和ApiControllers中均可工作
但目前为止,我对自己的需求感到满意。希望有人能发现这篇文章有用。

很高兴为ApiController和新堆栈拥有一个新的解决方案 :) - Andras Zoltan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接