domingo, 29 de marzo de 2015

ASP.NET. Localizando Data Annotations (y III) - Personalizar Atributos de Validación

Este artículo es continuación de:
ASP.NET. Localizando Data Annotations (I) - Archivos de recursos
ASP.NET. Localizando Data Annotations (II) - Utilizar una plantilla T4 para conectar a cualquier origen

Ya hemos visto cómo adaptar nuestros textos de los atributos de validación al idioma del usuario a través de archivos de recursos o a través de una plantilla de texto T4, mediante la cual conseguíamos independizar la gestión de nuestros textos del origen de datos utilizado. Sin embargo esta última solución también tiene sus pegas.

Por un lado están los problemas inherentes a las plantillas de texto T4. Hemos creado una plantilla T4 que, compilándose en tiempo de diseño, generaba una nueva clase. Es código que genera código, código al fin y al cabo. Pero código difícil de mantener, depurar y testear.

Por otro lado, lo que estamos haciendo en realidad, es crear una clase con información redundante para poder emular el funcionamiento de los archivos de recursos. Esto nos obliga a tener sincronizada nuestra nueva clase con el origen de datos de los textos. Cuando añadamos o eliminemos textos de nuestro origen de datos deberemos recompilar nuestra aplicación y, lo que es más importante, para compilar nuestra aplicación deberemos tener nuestro entorno de desarrollo conectado a un origen de datos con los textos completamente actualizados.



No estoy diciendo con esto que las vistas hasta ahora no sean soluciones válidas. Yo mismo las he utilizado en mis proyectos. En muchos casos estas "pegas" son perfectamente asumibles. Sin embargo, existen muchas situaciones en las que estas "pegas" se convierten en problemas importantes, bien por las características de la aplicación, del método de trabajo utilizado por el equipo de desarrollo, por los problemas añadidos que puede tener en la generación automática de builds, etc.

 Voy a mostrar, por tanto, una nueva posibilidad que nos permita solventar estos problemas.

Creando Atributos Personalizados


La idea es la de crear nuestros propios atributos de validación que gestionen los textos a mostrar, de forma que evitemos la clase accesoria que generábamos a partir de la plantilla de texto.

Para mostrar la solución voy a continuar utilizando el mismo proyecto LocalizeDataAnnotations que hemos utilizado en los artículos anteriores.

Para empezar vamos a hacer un pequeño cambio en el método Recuperar de la clase TextosBBDD para que, en caso de no encontrar el texto buscado, devuelva null (Nothing en VB). De forma que podamos comprobar más fácilmente si la recuperación del texto ha sido posible.

        public static string Recuperar(string textoID)
        {
            if (_textos == null)
            {
                _textos = new DataTable();
                _textos.ReadXml(HttpContext.Current.Server.MapPath("~/Content/Textos.xml"));
            }
            string idioma = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName;
            if (idioma != "en") idioma = "es";
            DataRow row = _textos.Rows.Find(new object[] { textoID, idioma });
            if (row != null)
                return row["Texto"].ToString();
            else
                return null;
        }
    Public Shared Function Recuperar(textoID As String) As String
        If _textos Is Nothing Then
            _textos = New DataTable()
            _textos.ReadXml(HttpContext.Current.Server.MapPath("~/Content/Textos.xml"))
        End If
        Dim idioma As String = Thread.CurrentThread.CurrentCulture.TwoLetterISOLanguageName
        If idioma <> "en" Then idioma = "es"
        Dim row As DataRow = _textos.Rows.Find(New Object() {textoID, idioma})
        If row IsNot Nothing Then
            Return row("Texto").ToString()
        Else
            Return Nothing
        End If
    End Function

Empezaremos personalizando el atributo DisplayNameAttribute. Aunque éste no es propiamente un atributo de validación es el que nos permitirá indicar en nuestro modelo el texto a utilizar como nombre del campo. El atributo devuelve el texto a mostrar a través de la propiedad de sólo lectura DisplayName. Por lo tanto voy a crear un nuevo atributo CustomDisplayAttribute en la carpeta Infrastructure que herede del atributo DisplayNameAttribute, y sobreescribiré la propiedad DisplayName para que devuelva el texto a mostrar de nuestro origen de datos alternativo a través de la clase TextosBBDD.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Web;

namespace LocalizeDataAnnotations.Infrastructure
{

    public class CustomDisplayAttribute : DisplayNameAttribute
    {

        public CustomDisplayAttribute(): base() { }

        public CustomDisplayAttribute(string displayName): base(displayName){ }

        public override string DisplayName
        {
            get
            {
                string displayName = TextosBBDD.Recuperar(base.DisplayName);
                return displayName ?? base.DisplayName;
            }
        }
        
    }
}
Imports System.ComponentModel

Public Class CustomDisplayAttribute
    Inherits DisplayNameAttribute

    Public Sub New()
        MyBase.New()
    End Sub

    Public Sub New(displayName As String)
        MyBase.New(displayName)
    End Sub

    Public Overrides ReadOnly Property DisplayName As String
        Get
            Dim name As String = TextosBBDD.Recuperar(MyBase.DisplayName)
            Return If(name, MyBase.DisplayName)
        End Get
    End Property

End Class

Además del DisplayNameAttribute, tengo que personalizar los dos atributos de validación que venimos utilizando: RequiredAttribute y StringLengthAttribute. Éstos sí son atributos de validación del namespace System.ComponentModel.DataAnnotations y, como todos los atributos de validación de este namespace, heredan de la clase ValidationAttribute.

Esta clase ValidationAttribute tiene un método FormatErrorMessage que es el responsable de devolver el mensaje de error a mostrar. Por lo tanto en ambos casos crearemos una nueva clase que herede del atributo original y sobreescriba el método FormatErrorMessage para que devuelva el mensaje a mostrar a través de la clase TextosBBDD.

Primero crearemos una nueva clase CustomRequiredAttribute en la carpeta Infrastructure que herede de la clase RequiredAttribute.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Web;

namespace LocalizeDataAnnotations.Infrastructure
{
    public class CustomRequiredAttribute: RequiredAttribute
    {

        public override string FormatErrorMessage(string name)
        {
            string errorMessage = null;
            if (!string.IsNullOrEmpty(base.ErrorMessageResourceName))
                errorMessage = TextosBBDD.Recuperar(base.ErrorMessageResourceName);

            return errorMessage ?? base.FormatErrorMessage(name);
        }

    }
}
Imports System.ComponentModel.DataAnnotations

Public Class CustomRequiredAttribute
    Inherits RequiredAttribute

    Public Overrides Function FormatErrorMessage(name As String) As String
        Dim errorMessage As String = Nothing
        If (Not String.IsNullOrEmpty(MyBase.ErrorMessageResourceName)) Then
            errorMessage = TextosBBDD.Recuperar(MyBase.ErrorMessageResourceName)
        End If
        Return If(errorMessage, MyBase.FormatErrorMessage(name))
    End Function

End Class



De la misma forma voy a crear una clase CustomStringLengthAttribute que herede de StringLengthAttribute.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Web;

namespace LocalizeDataAnnotations.Infrastructure
{
    public class CustomStringLengthAttribute: StringLengthAttribute
    {

        public CustomStringLengthAttribute(int maximumLength) : base(maximumLength) { }

        public override string FormatErrorMessage(string name)
        {
            string errorMessage = null;
            if (!string.IsNullOrEmpty(base.ErrorMessageResourceName))
                errorMessage = TextosBBDD.Recuperar(base.ErrorMessageResourceName);

            return errorMessage ?? base.FormatErrorMessage(name);
        }

    }
}
Imports System.ComponentModel.DataAnnotations

Public Class CustomStringLengthAttribute
    Inherits StringLengthAttribute

    Public Sub New(maximumLength As Integer)
        MyBase.New(maximumLength)
    End Sub

    Public Overrides Function FormatErrorMessage(name As String) As String
        Dim errorMessage As String = Nothing
        If (Not String.IsNullOrEmpty(MyBase.ErrorMessageResourceName)) Then
            errorMessage = TextosBBDD.Recuperar(MyBase.ErrorMessageResourceName)
        End If
        Return If(errorMessage, MyBase.FormatErrorMessage(name))
    End Function

End Class

Para probar el nuevo sistema voy a crear una nueva clase PersonaCustomAttr en la carpeta Models que implemente la interfaz IPersona. En la clase PersonaCustomAttr voy a definir los mismos atributos que en las clases PersonaResource y PersonaBBDD pero utilizando los nuevos atributos personalizados.

using LocalizeDataAnnotations.Infrastructure;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace LocalizeDataAnnotations.Models
{
    public class PersonaCustomAttr : IPersona
    {
        [CustomRequired(ErrorMessageResourceName = "NombreObligatorio")]
        [CustomDisplay("Nombre")]
        public string Nombre { get; set; }

        [CustomDisplay("Apellido")]
        public string Apellido { get; set; }

        [CustomDisplay("Ciudad")]
        [CustomStringLength(20, MinimumLength = 4, ErrorMessageResourceName = "CiudadErrorLongitud")]
        public string Ciudad { get; set; }

        [CustomDisplay("Email")]
        public string Email { get; set; }
    }
}
Imports System.ComponentModel.DataAnnotations

Public Class PersonaCustomAttr
    Implements IPersona

    <CustomRequired(ErrorMessageResourceName:="NombreObligatorio")> _
    <CustomDisplay("Nombre")> _
    Public Property Nombre As String Implements IPersona.Nombre

    <CustomDisplay("Apellido")> _
    Public Property Apellido As String Implements IPersona.Apellido

    <CustomDisplay("Ciudad")> _
    <CustomStringLength(20, MinimumLength:=4, ErrorMessageResourceName:="CiudadErrorLongitud")> _
    Public Property Ciudad As String Implements IPersona.Ciudad

    <CustomDisplay("Email")> _
    Public Property Email As String Implements IPersona.Email

End Class

Para probar el ejemplo únicamente nos faltaría añadir una nueva acción CustomAttr al controlador HomeController que se encargue de enviar una instancia de la clase PersonaCustomAttr a la vista Index.

        public ViewResult CustomAttr(PersonaCustomAttr persona)
        {
            return View("Index", persona);
        }
        Function CustomAttr(persona As PersonaCustomAttr) As ViewResult
            Return View("Index", persona)
        End Function

De esta forma si accedemos a la ruta http://<rutapublicacion>/Home/CustomAttr obtendremos el mismo resultado que en nuestros ejemplos anteriores.

Resultado del formulario en español

Resultado del formulario en inglés
Y con esto doy por finalizada esta serie de artículos.

Evidentemente las soluciones descritas no son las únicas posibles, son simplemente las que hoy por hoy suelo tener en cuenta a la hora de abordar esta cuestión en mis proyectos. He visto soluciones de otros desarrolladores personalizando el proveedor de metadatos del modelo (DataAnnotationsModelMetadataProvider). Yo mismo le he dado vueltas a un par de implementaciones que lo hacían. Sin embargo, ni las soluciones de terceros, ni las soluciones que se me han ocurrido a mi, han acabado de convencerme.

Por supuesto que el hecho de que a mi no me hayan convencido otras soluciones no quiere decir que no vayan a convencerte a ti. Incluso te animaría a echarle un vistazo esas implementaciones porque, aunque no decidas utilizarlas, resultan muy enriquecedoras a la hora de comprender como trabaja ASP.NET MVC con los modelos.

Y, por descontado, siempre existe la opción de que decidas desarrollar tu propia solución al problema.

El código completo de los ejemplos mostrados a lo largo de los 3 artículos, tanto en C# como en VB.Net, puede descargarse de:

Códigos de muestra - Ejemplos MSDN

No hay comentarios:

Publicar un comentario