ASP.NET MVC - ModelMetadata basada en convenciones y recursos

Cuando trabajamos con mvc, posiblemente tengamos nuestros models plagados de strings para definir nombres, mensajes de validación, nombres de recursos y posiblemente estos se repitan en varios models. Este ejemplo intenta mostrar una forma de resolverlo mediante convenciones y recursos.

Metadata basada en DataAnnotacion básica

Partiendo del siguiente ejemplo podemos observar varios strings hardcode

public class RegisterModel {
    [Required(ErrorMessage = "The User name is required")]
    [Display(Name = "User name")]
    public string UserName { get; set; }

    [Required(ErrorMessage = "The Email address is required")]
    [DataType(DataType.EmailAddress)]
    [Display(Name = "Email address")]
    public string Email { get; set; }

    [Required(ErrorMessage = "The Password is required")]
    [ValidatePasswordLength]
    [DataType(DataType.Password)]
    [Display(Name = "Password")]
    public string Password { get; set; }

    [DataType(DataType.Password)]
    [Display(Name = "Confirm password")]
    [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
    public string ConfirmPassword { get; set; }
}

uno de los problemas que plantea esto es la internacionalización

Metadata basada en recursos

Para solucionar la internacionalización es bien conocido el uso de recursos, para esto indicamos los textos como recursos:
Screenshot
y nuestro model debería ser algo parecido a:

public class RegisterModel
{
    [Required(ErrorMessageResourceType = typeof(Resources), ErrorMessageResourceName = "UserNameRequired")]
    [Display(ResourceType = typeof(Resources), Name = "UserName")]
    public string UserName { get; set; }

    [Required(ErrorMessageResourceType = typeof(Resources), ErrorMessageResourceName = "EMailRequired")]
    [DataType(DataType.EmailAddress)]
    [Display(ResourceType = typeof(Resources), Name = "EMail")]
    public string Email { get; set; }

    [Required(ErrorMessageResourceType = typeof(Resources), ErrorMessageResourceName = "PasswordRequired")]
    [ValidatePasswordLength]
    [DataType(DataType.Password)]
    [Display(ResourceType = typeof(Resources), Name = "Password")]
    public string Password { get; set; }

    [DataType(DataType.Password)]
    [Display(ResourceType = typeof(Resources), Name = "ConfirmPassword")]
    [Compare("Password", ErrorMessageResourceType = typeof(Resources), ErrorMessageResourceName = "ConfirmPasswordRequired")]
    public string ConfirmPassword { get; set; }
}

con esto resolvimos la internacionalización, ahora tenemos los mensajes en un archivo de recursos, pero seguimos teniendo muchos strings hardcode que ahora hasta parecen seguir un determinado patrón. Además, si tuviésemos otro model con la propiedad Email o UserName tendríamos que volver a definir todos los recursos.

Metadata basada en convenciones

La propuesta de “Metadata basada en convenciones” intenta buscar el nuestro model se parezca a algo como lo siguiente:

public class RegisterModel
{
    [Required]
    public string UserName { get; set; }

    [Required]
    [DataType(DataType.EmailAddress)]
    public string Email { get; set; }

    [Required]
    [ValidatePasswordLength]
    [DataType(DataType.Password)]
    public string Password { get; set; }

    [DataType(DataType.Password)]
    [Compare("Password")]
    public string ConfirmPassword { get; set; }
}
y esto se logra implementando un custom ModelMetadataProvider como el siguiente:
public class DataAnnotationAndResourcesModelMetadataProvider : DataAnnotationsModelMetadataProvider
{
    private List<Attribute> _attributeList;

    protected override ModelMetadata CreateMetadata(IEnumerable<Attribute> attributes, Type containerType, Func<object> modelAccessor, Type modelType, string propertyName)
    {
        _attributeList = new List<Attribute>(attributes);

        var modelMetadata = base.CreateMetadata(attributes, containerType, modelAccessor, modelType, propertyName);

        if (propertyName != null)
        {
            if (string.IsNullOrWhiteSpace(modelMetadata.DisplayName))
                modelMetadata.DisplayName = SearchResource(propertyName);

            if (string.IsNullOrWhiteSpace(modelMetadata.Description))
                modelMetadata.Description = SearchResource(propertyName + "Description");

            var validators = _attributeList.OfType<ValidationAttribute>();
            foreach (var validator in validators)
            {
                if (string.IsNullOrWhiteSpace(validator.ErrorMessage) &&
                    string.IsNullOrWhiteSpace(validator.ErrorMessageResourceName))
                {
                    var resourceName = propertyName + validator.GetType().Name;
                    if (resourceName.EndsWith("Attribute"))
                        resourceName = resourceName.Substring(0, resourceName.Length - 9);
                    var resourceType = validator.ErrorMessageResourceType ?? typeof(Resources);
                    var prop = resourceType.GetProperty(resourceName);
                    if (prop != null)
                    {
                        validator.ErrorMessageResourceType = resourceType;
                        validator.ErrorMessageResourceName = resourceName;
                    }
                }
            }
        }

        return modelMetadata;
    }

    private static string SearchResource(string resourceName)
    {
        string displayName = null;
        var resourceType = typeof(Resources);
        var prop = resourceType.GetProperty(resourceName);
        if (prop != null)
        {
            var value = prop.GetValue(resourceType, null);
            displayName = value != null ? value.ToString() : resourceName;
        }
        return displayName;
    }
}
Para que mvc utilice este provider, debemos indicárselo en ModelMetadataProviders.Current, por ejemplo en el Application_Start de Global.asax:
ModelMetadataProviders.Current = new DataAnnotationAndResourcesModelMetadataProvider();
Este ModelMetadataProvider primero crea la metadata de la forma tradicionar (basándose en DataAnnotation), si esta no indica los nombres, mensajes, etc. se basa en convenciones de nombres para buscar en los recursos:
  • Si no está indicado el DisplayName, busca en los Resources uno que tenga el mismo nombre de la propiedad.
  • Si no está indicada la Description, busca en los Resources uno que tenga el mismo nombre de la propiedad y que termine en “Description”.
  • Para cada ValidationAttribute busca entre los Resources uno que comience con el nombre de la propiedad y termine con el nombre del attribute, por ejemplo: UserNameRequired para la propiedad UserName que tiene indicada la validación Required.

Y si tenemos otros models con las propiedades UserName, EMail, etc. Los recursos serán reutilizados cuando sea necesario si necesidad de definirlo.

Nota: con esto resolvemos nombres, mensajes, etc. Es decir los textos de comunicación con el usuario, no definimos comportamiento, para esto último seguimos utilizando DataAnnotations.
Una implementación real (y mas completa) de esto se puede ver en el proyecto que estamos desarrollando en la comunidad alt.net hispano para la gestión administrativa de eventos haciendo clic aquí.