miércoles, 25 de marzo de 2015

ASP.NET. Localizando Data Annotations (II) - Utilizar una plantilla T4 para conectar a cualquier origen

Este artículo es continuación de:
ASP.NET. Localizando Data Annotations (I) - Archivos de recursos

En la última entrada mostré cómo utilizar archivos de recursos para mostrar los mensajes de los atributos del namespace Data Annotations en función del idioma del usuario.

Pero ¿qué sucede si tenemos los textos de nuestra aplicación en otro lugar, por ejemplo en una base de datos?

En este ejemplo mostraré cómo podemos utilizar una plantilla de texto T4 para obtener los textos a partir de cualquier origen de datos.



Obteniendo los textos desde cualquier origen de datos


Para el ejemplo utilizaré como origen de datos un fichero xml que contendrá un DataTable serializado con los textos de la aplicación. Este DataTable estará formado por 3 campos:

  • Id: Identificador del texto
  • Idioma: Identificador de idioma
  • Texto
Contenido del DataTable Textos

El fichero xml lo añadiré a la carpeta Content del proyecto como Textos.xml.

A continuación necesitaremos un sistema para recuperar el texto requerido por la aplicación de nuestro origen de datos. Para ello añadiré una nueva carpeta Infrastructure al proyecto en la que crearé una nueva clase TextosBBDD. Esta nueva clase tendrá un único método Recuperar que recibe como parámetro el identificador de un texto y devuelve el texto correspondiente según la referencia cultural actual. Si deseas utilizar tu propio origen de datos para ejecutar el ejemplo (por ejemplo una base de datos) únicamente necesitarás modificar el código de este método.

using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Web;

namespace LocalizeDataAnnotations.Infrastructure
{
    public static class TextosBBDD
    {

        private static DataTable _textos;

        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 "Texto no encontrado";
        }

    }
}
Imports System.Threading

Public NotInheritable Class TextosBBDD

    Private Shared _textos As DataTable

    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 "Texto no encontrado"
        End If
    End Function

End Class

En la entrada anterior (ASP.NET. Localizando Data Annotations (I) - Archivos de recursos) vimos cómo podíamos indicar el texto a utilizar en los atributos de las propiedades de nuestro modelo a través de las propiedades de los atributos Name/ResourceType o ErrorMessageResourceName/ErrorMessageResourceType. Esto resulta posible porque los archivos de recursos se compilan generando una clase con una propiedad estática (Static en C# o Shared en VB) para cada entrada del archivo. De esta forma lo que realmente estamos indicando es el nombre de la clase a utilizar (ResourceType o ErrorMessageResourceType) y el nombre de la propiedad a partir de la cual recuperar el texto (Name o ErrorMessageResourceName).

Si abrimos la clase PersonaResource hacemos click con el botón derecho del ratón sobre una referencia a Textos y seleccionamos al opción Ver la definición podemos ver el código de la clase generada a partir de los archivos de recursos:



Por lo tanto podríamos crearnos una clase que exponga estas propiedades y utilizarla de la misma forma que los archivos de recursos.



Para verlo voy a crear en la carpeta Infrastructure una nueva clase TextosLoader con una propiedad estática para cada una de las cadenas de la aplicación. Cada propiedad devolverá el texto correspondiente a través del método Recuperar de la clase TextosBBDD.

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

namespace LocalizeDataAnnotations.Infrastructure
{
    public static class TextosLoader
    {

        public static string Nombre
        {
            get { return TextosBBDD.Recuperar("Nombre"); }
        }

        public static string Apellido
        {
            get { return TextosBBDD.Recuperar("Apellido"); }
        }

        public static string Ciudad
        {
            get { return TextosBBDD.Recuperar("Ciudad"); }
        }

        public static string Email
        {
            get { return TextosBBDD.Recuperar("Email"); }
        }


        public static string NombreObligatorio
        {
            get { return TextosBBDD.Recuperar("NombreObligatorio"); }
        }

        public static string CiudadErrorLongitud
        {
            get { return TextosBBDD.Recuperar("CiudadErrorLongitud"); }
        }

    }
}
Public NotInheritable Class TextosLoader

    Public Shared ReadOnly Property Nombre() As String
        Get
            Return TextosBBDD.Recuperar("Nombre")
        End Get
    End Property

    Public Shared ReadOnly Property Apellido() As String
        Get
            Return TextosBBDD.Recuperar("Apellido")
        End Get
    End Property

    Public Shared ReadOnly Property Ciudad() As String
        Get
            Return TextosBBDD.Recuperar("Ciudad")
        End Get
    End Property

    Public Shared ReadOnly Property Email() As String
        Get
            Return TextosBBDD.Recuperar("Email")
        End Get
    End Property

    Public Shared ReadOnly Property NombreObligatorio() As String
        Get
            Return TextosBBDD.Recuperar("NombreObligatorio")
        End Get
    End Property

    Public Shared ReadOnly Property CiudadErrorLongitud() As String
        Get
            Return TextosBBDD.Recuperar("CiudadErrorLongitud")
        End Get
    End Property

End Class

Para probar el nuevo sistema voy a crearme una nueva clase PersonaBBDD en la carpeta Models del proyecto y que implemente la interfaz IPersona. He decorado las propiedades de esta nueva clase con los mismos atributos que en la clase PersonaResource pero utilizando como "tipo de recurso" (ResourceType/ErrorMessageResourceType) la nueva clase TextosLoader:

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

namespace LocalizeDataAnnotations.Models
{
    public class PersonaBBDD: IPersona
    {
        [Required(ErrorMessageResourceName = "NombreObligatorio"
            , ErrorMessageResourceType = typeof(TextosLoader))]
        [Display(Name = "Nombre", ResourceType = typeof(TextosLoader))]
        public string Nombre { get; set; }

        [Display(Name = "Apellido", ResourceType = typeof(TextosLoader))]
        public string Apellido { get; set; }

        [Display(Name = "Ciudad", ResourceType = typeof(TextosLoader))]
        [StringLength(20, MinimumLength = 4, ErrorMessageResourceName = "CiudadErrorLongitud"
            , ErrorMessageResourceType = typeof(TextosLoader))]
        public string Ciudad { get; set; }

        [Display(Name = "Email", ResourceType = typeof(TextosLoader))]
        public string Email { get; set; }

    }
}
Imports System.ComponentModel.DataAnnotations

Public Class PersonaBBDD
    Implements IPersona

    <Required(ErrorMessageResourceName:="NombreObligatorio" _
        , ErrorMessageResourceType:=GetType(TextosLoader))> _
    <Display(Name:="Nombre", ResourceType:=GetType(TextosLoader))> _
    Public Property Nombre As String Implements IPersona.Nombre

    <Display(Name:="Apellido", ResourceType:=GetType(TextosLoader))> _
    Public Property Apellido As String Implements IPersona.Apellido

    <Display(Name:="Ciudad", ResourceType:=GetType(TextosLoader))> _
    <StringLength(20, MinimumLength:=4, ErrorMessageResourceName:="CiudadErrorLongitud" _
        , ErrorMessageResourceType:=GetType(TextosLoader))> _
    Public Property Ciudad As String Implements IPersona.Ciudad

    <Display(Name:="Email", ResourceType:=GetType(TextosLoader))> _
    Public Property Email As String Implements IPersona.Email

End Class

Finalmente he añadido una nueva acción BBDD al controlador HomeController que devuelve una instancia de la clase PersonaBBDD a la vista Index:

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

Ahora, si arrancamos la aplicación y accedemos a la ruta http://<rutapublicacion>/Home/BBDD podremos ver que obtenemos el mismo resultado que en el ejemplo en el que utilizábamos archivos de recursos:


Formulario de prueba

Si cambiamos la referencia cultural en el archivo Web.config para que utilice una con idioma inglés, podremos ver que los textos cambian para mostrarse en inglés.


Supongo que a estas alturas todos os estaréis haciendo la misma pregunta: "¿y cada vez que quiera añadir o eliminar un texto de mi aplicación tengo que modificar la clase TextosLoader para añadir o eliminar la correspondiente propiedad?"

Pues hay buenas y malas noticias. Las malas: la respuesta a la pregunta es "Sí", cada vez que se añada o elimina un texto habrá que modificar la clase y volver a compilar la aplicación. Las buenas: como veremos a continuación existen formas de automatizar este proceso.

Generando la clase a partir de una plantilla de texto T4


Lo que voy a hacer a continuación es automatizar la generación de la clase TextosLoader a través de una plantilla de texto T4.

Según la propia definición de Microsoft en MSDN:
En Visual Studio, una plantilla de texto T4 es una combinación de bloques de texto y lógica de control que puede generar un archivo de texto. La lógica de control se escribe como fragmentos de código de programa en Visual C# o Visual Basic. El archivo generado puede ser texto de cualquier tipo, como una página web o un archivo de recursos o código fuente del programa en cualquier lenguaje.

Es decir: archivos con código que, al ejecutarse, generan como salida un archivo. Nada nuevo. Pero las plantillas T4 tienen una particularidad que las hace muy útiles: pueden compilarse y ejecutarse en tiempo de diseño. De esta forma podemos crear plantillas que generen archivos de código que estarán inmediatamente disponibles para su uso dentro de nuestro proyecto.

No voy a profundizar en las posibilidades y sintaxis de las plantillas T4 porque es un tema que daría para una serie de artículos por sí sólo (quizás más adelante). Simplemente quiere mostrar un ejemplo para ver cómo se pueden utilizar para generar una clase que tengamos siempre actualizada con los textos de nuestra aplicación.

Así que voy a eliminar la clase TextosLoader de la carpeta Infrastructure y añadiré una nueva Plantilla de Texto TextosLoader.tt:

En el código de nuestra plantilla T4 simplemente cargaremos un DataTable a partir del fichero Textos.xml, obtendremos todos los textos con idioma "es" (para no tener claves repetidas) y generaremos una propiedad por cada entrada que recuperará el valor a través del método Recuperar de la clase TextosBBDD.

<#@ template debug="false" hostspecific="true" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Data" #>
<#@ assembly name="System.Xml" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Data" #>
<#@ output extension=".cs" #>

namespace LocalizeDataAnnotations.Infrastructure
{

 public static class TextosLoader
 {

 <#
  string rutaArchivo = Host.ResolveAssemblyReference("$(ProjectDir)") + "Content\\Textos.xml";
  DataTable tablaTextos = new DataTable();
  tablaTextos.ReadXml(rutaArchivo);
  DataRow[] textos = tablaTextos.Select("Idioma='es'");
  foreach (DataRow row in textos)
  {
   WriteLine(String.Format("\t\tpublic static string {0}", row["Id"]));
   WriteLine("\t\t{");
   WriteLine(String.Format("\t\t\tget {{ return TextosBBDD.Recuperar(\"{0}\"); }}", row["Id"]));
   WriteLine("\t\t}");
  }
 #>

 }

}
<#@ template debug="false" hostspecific="true" language="VB" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Data" #>
<#@ assembly name="System.Xml" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Data" #>
<#@ output extension=".vb" #>

Public NotInheritable Class TextosLoader

<#
 Dim rutaArchivo As String = Host.ResolveAssemblyReference("$(ProjectDir)") + "Content\Textos.xml"
 Dim tablaTextos As DataTable = New DataTable()
 tablaTextos.ReadXml(rutaArchivo)
 Dim textos As DataRow() = tablaTextos.Select("Idioma='es'")
 For Each row As DataRow In textos
  WriteLine(String.Format(" Public Shared ReadOnly Property {0}() As String", row("Id")))
  WriteLine("  Get")
  WriteLine(String.Format("   Return TextosBBDD.Recuperar(""{0}"")", row("Id")))
  WriteLine("  End Get")
  WriteLine(" End Property")
 Next
#>

End Class

Como antes si abrimos la clase PersonaBBDD hacemos click con el botón derecho del ratón sobre una referencia a TextosLoader y seleccionamos al opción Ver la definición podemos ver el código de la clase generada.


Si accedemos a la ruta http://<rutapublicacion>/Home/BBDD y modificamos la cultura de usuario a través del Web.config podremos ver que nuestra aplicación sigue mostrando los textos correctamente.

De esta forma solucionamos el problema de tener que modificar la clase TextosLoader cada vez que añadimos o eliminamos un texto. Sin embargo seguimos teniendo la necesidad de volver a compilar nuestra aplicación. En muchas aplicaciones puede que esto no sea mayor problema pero existen situaciones en las que puede serlo.

En los próximas entradas veremos otras alternativas que subsanen este problema.


Continúa con:
ASP.NET. Localizando Data Annotations (y III) - Personalizar Atributos de Validación

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