ASP.NET MVC – Custom model binders por propiedad

Muy buenas! En este post vamos a ver como habilitar un custom model binder para una propiedad en concreto de un viewmodel.

De serie es posible configurar un Custom Model Binder de dos maneras:

  1. Añadiéndolo a la colección Binders de la clase ModelBinders. Con esto conseguimos que todos los valores de un tipo concreto se enlacen usando nuestro ModelBinder.
  2. Usando el atributo [ModelBinder]. Con este atributo podemos especificar un Model Binder a usar para un viewmodel en concreto o para un parámetro en concreto de un controlador. Pero desgraciadamente no para una propiedad concreta de un viewmodel:

image

Por suerte conseguir usar un custom model binder para una propiedad en concreto de un viewmodel no es tan complicado 🙂

Lo primero es crearnos un atributo propio ya que el ModelBinderAttribute no podemos usarlo, así que vamos a ello:

Atributo [PropertyModelBinder]
  1. AttributeUsage(AttributeTargets.Property)]
  2.     public class PropertyModelBinderAttribute : CustomModelBinderAttribute
  3.     {
  4.         private readonly Type _typeToUse;
  5.  
  6.         public PropertyModelBinderAttribute(Type typeToUse)
  7.         {
  8.             _typeToUse = typeToUse;
  9.         }
  10.  
  11.         public override IModelBinder GetBinder()
  12.         {
  13.             var binder = DependencyResolver.Current.GetService(_typeToUse) as IModelBinder;
  14.             return binder ?? new DefaultModelBinder();
  15.         }
  16.     }

Una vez lo tenemos creado, lo aplicamos a nuestro viewmodel:

ViewModel de prueba
  1. public class SomeViewModel
  2. {
  3.     [PropertyModelBinder(typeof(RomanNumberModelBinder))]
  4.     [Range(1,21)]
  5.     public int Century { get; set; }
  6.     public string Name { get; set; }
  7. }

Ahora debemos implementar el custom model binder. En este caso he implementado un model binder que convierte cadenas en formato de número romano (tal como XX o VIII) al entero correspondiente.

Nota: El código de conversión lo he sacado de http://vadivel.blogspot.com.es/2011/09/how-to-convert-roman-numerals-to.html. Tan solo lo he corregido para usar un SortedDictionary y así asegurarme de que el orden por el que se itera sobre las claves es el esperado (en un Dictionary<,> el orden de iteración no está definido).

El código entero de mi ModelBinder es el siguiente:

RomanNumberModelBinder
  1. public class RomanNumberModelBinder : IModelBinder
  2. {
  3.     private static List<string> _romanTokens = new List<string>
  4.     {
  5.         "M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I",
  6.     };
  7.  
  8.     private class RomanKeyComparer : IComparer<string>
  9.     {
  10.         public int Compare(string x, string y)
  11.         {
  12.  
  13.             var idx = _romanTokens.IndexOf(x);
  14.             var idx2 = _romanTokens.IndexOf(y);
  15.             if (idx == idx2) return 0;
  16.             return idx < idx2 ? 1 : 1;
  17.  
  18.         }
  19.     }
  20.  
  21.     private static SortedDictionary<string, int> RomanNumbers =
  22.         new SortedDictionary<string, int>(new RomanKeyComparer());
  23.  
  24.     public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
  25.     {
  26.         if (bindingContext.ModelType != typeof (int)) return null;
  27.         var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).AttemptedValue;
  28.  
  29.         return RomanToInt(value);
  30.     }
  31.  
  32.     private static int RomanToInt(string roman)
  33.     {
  34.         if (!RomanNumbers.Any())
  35.         {
  36.             RomanNumbers.Add(_romanTokens[0], 1000);
  37.             RomanNumbers.Add(_romanTokens[1], 900);
  38.             RomanNumbers.Add(_romanTokens[2], 500);
  39.             RomanNumbers.Add(_romanTokens[3], 400);
  40.             RomanNumbers.Add(_romanTokens[4], 100);
  41.             RomanNumbers.Add(_romanTokens[5], 90);
  42.             RomanNumbers.Add(_romanTokens[6], 50);
  43.             RomanNumbers.Add(_romanTokens[7], 40);
  44.             RomanNumbers.Add(_romanTokens[8], 10);
  45.             RomanNumbers.Add(_romanTokens[9], 9);
  46.             RomanNumbers.Add(_romanTokens[10], 5);
  47.             RomanNumbers.Add(_romanTokens[11], 4);
  48.             RomanNumbers.Add(_romanTokens[12], 1);
  49.         }
  50.         int result = 0;
  51.  
  52.  
  53.         foreach (var pair in RomanNumbers)
  54.         {
  55.             while (roman.IndexOf(pair.Key.ToString()) == 0)
  56.             {
  57.                 result += int.Parse(pair.Value.ToString());
  58.                 roman = roman.Substring(pair.Key.ToString().Length);
  59.             }
  60.         }
  61.         return result;
  62.     }
  63. }

Teóricamente lo tenemos todo montado. Pero si lo probamos veremos que NO funciona. Para la prueba me he generado una pequeña vista como la siguiente:

Vista de prueba
  1. @model WebApplication1.Models.SomeViewModel
  2.  
  3. @using (Html.BeginForm())
  4. {
  5.     <p>
  6.         @Html.LabelFor(m => m.Name)
  7.         @Html.EditorFor(m => m.Name)
  8.     </p>
  9.     <p>
  10.         @Html.LabelFor(m => m.Century)
  11.         @Html.TextBoxFor(m => m.Century, new {placeholder = "In Romna numbers, like XX or XIX"})
  12.     </p>
  13.     
  14.     <input type="submit" value="send"/>
  15. }

Y luego las acciones correspondientes en el controlador para mostrar la vista y recibir los resultados. Si lo probáis veréis que nuestro RomanNumberModelBinder no se invoca 🙁

El “culpable” de que no funcione es el ModelBinder por defecto (DefaultModelBinder). Dado que el atributo [ModelBinder] origina no puede aplicarse a propiedades, el DefaultModelBinder no tiene en cuenta la posibilidad de que una propiedad en concreto use un model binder distinto. Así pues nos toca reescribir parte del DefaultModelBinder.

Para reescribir parte del ModelBinder lo más sencillo es heredar de él y redefinir el método que necesitemos. En este caso el método necesario es BindProperty que enlaza una propiedad. Veamos como queda el código:

DefaultModelBinderEx
  1. public class DefaultModelBinderEx : DefaultModelBinder
  2. {
  3.     protected override void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext,
  4.         PropertyDescriptor propertyDescriptor)
  5.     {
  6.  
  7.         var cmbattr = propertyDescriptor.Attributes.OfType<CustomModelBinderAttribute>().FirstOrDefault();
  8.         IModelBinder binder;
  9.         if (cmbattr != null && (binder = cmbattr.GetBinder()) != null)
  10.         {
  11.             var subPropertyName = DefaultModelBinder.CreateSubPropertyName(bindingContext.ModelName, propertyDescriptor.Name);
  12.             var obj = propertyDescriptor.GetValue(bindingContext.Model);
  13.             var modelMetadata = bindingContext.PropertyMetadata[propertyDescriptor.Name];
  14.             modelMetadata.Model = obj;
  15.             var bindingContext1 = new ModelBindingContext()
  16.             {
  17.                 ModelMetadata = modelMetadata,
  18.                 ModelName = subPropertyName,
  19.                 ModelState = bindingContext.ModelState,
  20.                 ValueProvider = bindingContext.ValueProvider
  21.             };
  22.             var propertyValue = this.GetPropertyValue(controllerContext, bindingContext1, propertyDescriptor, binder);
  23.             SetProperty(controllerContext, bindingContext, propertyDescriptor, propertyValue);
  24.             return;
  25.         }
  26.  
  27.         base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
  28.     }
  29. }

Vale… el código luce un “pelín” complicado, pero básicamente hace lo siguiente:

  1. Mira si la propiedad a enlazar está decorada con algún atributo que sea [ModelBinder] o derivado (en este caso será precisamente el [PropertyModelBinder]).
  2. Si dicha propiedad está enlazada obtiene el binder (mediante el método GetBinder() del atributo) y:
    1. Crea un “subcontexto” de binding, que contenga tan solo esta propiedad.
    2. Obtiene el valor a bindea, mediante la llamada a GetPropertyValue, un método definido en el propio DefaultModelBinder, pasándole el subcontexto de binding y el binder a usar.
    3. Llama a SetProperty (otro método de DefaultModelBinder) para establecer el valor de la propiedad
  3. En caso de que la propiedad NO esté decorada con ningún atributo [ModelBinder] o derivado, llama a base.BindProperty para procesarla de forma normal.

Nota: Este es el MÍNIMO código para que funcione, pero ¡ojo! no estamos tratando todas las casuísticas posibles 😉

Por supuesto para que funcione debemos decirle a ASP.NET MVC que el nuevo ModelBinder por defecto es nuestro DefaultModelBinderEx:

  1. ModelBinders.Binders.DefaultBinder = new DefaultModelBinderEx();

Y con esto ya tendríamos habilitados nuestros ModelBinders por propiedad!

Una nota final…

¿Y como tratar todas las casuísticas posibles que antes he dicho que nuestro método BindProperty no trataba y tener así un código “más completo”? Bien, pues la forma más sencilla y rápida es coger el código fuente de ASP.NET MVC y COPIAR TODO el método BindProperty del DefaultModelBinder en el DefaultModelBinderEx. Y luego una vez lo habéis echo localizad la línea:

  1. IModelBinder binder = this.Binders.GetBinder(propertyDescriptor.PropertyType);

Y modificadla por:

  1. var cmbattr = propertyDescriptor.Attributes.
  2.     OfType<CustomModelBinderAttribute>().
  3.     FirstOrDefault();
  4. IModelBinder binder = cmbattr != null
  5.     ? cmbattr.GetBinder()
  6.     : this.Binders.GetBinder(propertyDescriptor.PropertyType);

Y con esto ya tendréis el soporte completo. La verdad es una pena que el DefaultModelBinder no ofrezca un método virtual GetBinder(PropertyDescriptor) ya que si lo tuviese tan solo redefiniendo este método hubiese sido suficiente… Pero en fin… ¡esto es lo que hay!

Espero que os haya resultado interesante!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *