jueves, 13 de diciembre de 2012

Caso real de internacionalización de una aplicación ASP.NET

Recientemente he tenido que desarrollar un módulo que trabaja con números de semana. El obtener el número de semana que corresponde a una fecha no es difícil. Sin embargo, obtener el primer y último día de un número de semana es algo más complicado. Además y a nivel de base de datos, trabajo con Sql Server y tengo la necesidad de calcular también números de semana desde T-SQL.

Para obtener el número de semana que corresponde a una fecha, podemos utilizar un método como el siguiente:

static DayOfWeek GetFirstDayOfWeek()

{

    return CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;

}

static int GetWeekOfYear(DateTime aDate)

{

    var firstDayOfWeek = GetFirstDayOfWeek();

    var weekOfYear = CultureInfo.CurrentCulture.Calendar.GetWeekOfYear(

        aDate, CalendarWeekRule.FirstDay, firstDayOfWeek);

    return weekOfYear;

}

Lo más relevante de este código es:

  • Con GetFirstDayOfWeek no se asume que el lunes será el día de comienzo de una semana, sino que se consulta el valor establecido por la cultura actual del subproceso. La cultura actual podría o no coincidir con la configuración regional del equipo, imagina una aplicación web donde según una selección del idioma se establece la cultura del subproceso.
  • Con el valor CalendarWeekRule.FirstDay se establece como se quiere calcular las semanas.

Realmente el enumerado CalendarWeekRule tiene 3 posibles valores, pero el más intuitivo y sencillo de comprender es FirstDay (aunque quizás y según los requerimientos de cada negocio podría no ser el más adecuado). Para ver el detalle de los 3 tipos de cálculo disponibles http://msdn.microsoft.com/en-us/library/system.globalization.calendarweekrule(v=vs.95).aspx. Si quieres cambiar el valor predeterminado de la propiedad CalendarWeekRule del objeto CultureInfo (no confundir con el parámetro del método GetWeekOfYear), tendrás que crear una nueva cultura a partir de la actual, especificar como se calcularán las semanas y después asignársela al subproceso actual (aunque insisto esto no es necesario pero si trabajas por ejemplo con Time Period Library for NET podría ser aconsejable http://www.codeproject.com/Articles/168662/Time-Period-Library-for-NET)

static void CreateCultureInfo(CalendarWeekRule calendarWeekRule, DayOfWeek firstDayOfWeek)

{

    var newCultureInfo = new CultureInfo(CultureInfo.CurrentCulture.Name);

    newCultureInfo.DateTimeFormat.CalendarWeekRule = calendarWeekRule;

    newCultureInfo.DateTimeFormat.FirstDayOfWeek = firstDayOfWeek;

    Thread.CurrentThread.CurrentCulture = newCultureInfo;

}

Además hay otro motivo importante por el que se ha escogido el valor FirstDay y es que la función DATEPART de T-SQL parece que funciona con este valor de forma predeterminada. Lo cierto es que aunque no haya podido confirmarlo oficialmente de esto, los ejemplos me dicen que es así porque los números de semana T-SQL coinciden con los de .NET (la verdad es que no se por que valor del enumerado anterior se rige el cálculo de SQL ni si tiene que ver algo el idioma de la instalación o la configuración regional del equipo…). Sinceramente, espero no tener que utilizar DATEPART con wk en ningún script de T-SQL.

SELECT DATEPART(wk,'20121231')

SELECT DATEPART(wk,'20130101')

SELECT DATEPART(wk,'20130107')

 

 

.NET

T-SQL

31/12/2012

54

54

01/01/2013

1

1

07/01/2013

2

2

Algo a tener muy en cuenta cuando se trabaja en T-SQL y con la función DATEPART es el valor del día marcado como inicio de la semana y que además viene determinado por el valor del idioma de la conexión (fíjate que nada tiene que ver con la configuración regional del equipo ni la cultura del subproceso).

Con DBCC USEROPTIONS se puede consultar estos valores y ver como en us_english el primer día de la semana es el domingo.

language

datefirst

Español

1

us_english

7

Para establecer el día de inicio de una semana tenemos varias posibilidades:

Una vez ya disponemos de cierta sincronización entre el cálculo que realiza .NET y el cálculo que realiza T-SQL, nuestro siguiente método nos ayudará a calcular el día de inicio y el día de fin de un número de semana (el código está prestado pero “entendido” y levemente “modificado” desde http://stackoverflow.com/questions/11309715/get-the-dates-for-getweekofyear-in-c). Puedo asegurar que funciona con CalendarWeekRule.FirstDay pero con el resto de valores del enumerado no lo he probado. También lo he probado con FirstFourDayWeek y funciona igualmente.

static DateTime GetFirstDateOfWeek(DateTime aDate)

{

    var firstDateOfWeek =

        aDate.AddDays(-((aDate.DayOfWeek - GetFirstDayOfWeek() + 7) % 7));

    if (firstDateOfWeek.Year != aDate.Year)

        firstDateOfWeek = new DateTime(aDate.Year, 1, 1);

    return firstDateOfWeek;

}

static DateTime GetLastDateOfWeek(DateTime aDate)

{

    var lastDateOfWeek =

        aDate.AddDays(((GetFirstDayOfWeek() - 1) - aDate.DayOfWeek + 7) % 7);

    if (lastDateOfWeek.Year != aDate.Year)

        lastDateOfWeek = new DateTime(aDate.Year, 12, 31);

    return lastDateOfWeek;

}

Hasta aquí llegó la teoría del cálculo de números de semana en ASP.NET y T-SQL, pero ahora es necesario hacer un ejercicio de reflexión para sopesar los pros y contras de esta solución en una aplicación ASP.NET con los siguientes requerimientos:

  • Una sola instancia de aplicación (modelo Saas).
  • Una base de datos por cliente.
  • Cada cliente tiene una Culture fija que no puede cambiar y que es la nativa al país de origen del cliente.
  • La aplicación tiene que soportar la selección por parte del usuario de las UICulture español e inglés, con independencia de la Culture establecida para el cliente.

Imaginemos que tenemos los siguientes clientes en la aplicación:

 

UICulture

Culture

USA

en o es

en-US

España

en o es

es-ES

Portugal

en o es

pt-PT

La UICulture es seleccionable por el usuario porque es indiferente para la aplicación en que idioma quiere visualizar la interfaz. A grandes rasgos, la elección de una UICulture u otra se traduce casi exclusivamente en la carga de determinados ficheros de recursos .resx.

La Culture tiene que ir fija por cada cliente porque determina el formato de fechas, números… y también como se comportan los cálculos con fechas y semanas. Aquí está claro que el usuario no puede cambiar la Culture porque, por ejemplo, no puedes pasar de ver € a ver $ sin ningún tipo de conversión. Tampoco puedes pasar de ver día/mes/año a mes/día/año sin que el usuario se lleve las manos a la cabeza. En definitiva, si vives en USA siempre trabajarás con $ y tus fechas siempre serán mes/día/año… otra cosa distinta es que quieras practicar el español y puedes cambiar la UICulture cuando te plazca.

Lógicamente, el establecer siempre la Culture y UICulture para cada recurso de tu aplicación, también conlleva que no importa en que idioma esté instalado tu servidor, nunca serás dependiente en este aspecto.

En este punto alguien podría pensar que ocurrirá si el cliente que hoy es Culture en-US quiera cambiar mañana a Culture es-ES (por alguna decisión política que escapa a mi entendimiento). Pues bien, tenemos un problema y los señores de soporte (léase programadores) tendrían que convertir muchos datos antes de poder hacer este cambio, pero esta es otra historía…

Otra punto importante e íntimamente ligado con la internacionalización de aplicaciones, es el uso de las fechas UTC.

En España tenemos un ejemplo de diferencia horaria con las Islas Canarias. En USA es el caos, jamás se me ocurriría pedir la hora a alguien por la calle… Con este variopinto escenario, las decisiones que hemos tomado al respecto de las fechas en la aplicación son las siguientes:

  • Cualquier usuario de la aplicación tendrá asociado un uso horario, que además podrá cambiar en función de donde se encuentre físicamente (sería ya la repera que por geolocalización le pudiéramos ubicar o proponerle un cambio, todo llegará…)
  • Cada vez que se quiere obtener la fecha actual del sistema, se llamará a un método que calculará la fecha a partir de la fecha UTC actual del servidor y aplicará el uso horario del usuario. Finalmente, será la fecha calculada la que se grabe en el típico campo CreatedDate.

Para permitir al usuario cambiar su uso horario, podemos darle un desplegable con una lista de opciones obtenidas desde System.TimeZoneInfo.GetSystemTimeZones

clip_image001

Asumiendo que el usuario está en Madrid, grabaremos un campo asociado con el valor “Romance Standard Time” que es la propiedad Id de la clase TimeZoneInfo. Después y cuando queramos obtener la fecha actual del usuario (que no del servidor) ejecutaremos el siguiente método:

static DateTime GetCurrentDateTime(string timeZoneInfoId)

{

    var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZoneInfoId);

    return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timeZoneInfo);

}

De esta forma siempre grabaremos en un campo del tipo CreatedDate la fecha actual del usuario según su uso horario y no según la hora del servidor.

Está claro que la internacionalización de aplicaciones es un mundo en sí mismo, pero por algún lado hay que empezar!

Un saludo!

martes, 20 de noviembre de 2012

Transformación de ficheros de configuración en ASP.NET

Aunque hace tiempo que conocía que se podía utilizar la transformación de los ficheros de configuración, es ahora cuando tengo una necesidad real de utilizar esta característica para intentar optimizar la publicación de aplicaciones web en entornos de hosting compartido.

Está claro que nuestro fichero web.config del entorno de desarrollo no es el mismo fichero web.config de producción o de cualquier otro entorno de publicación. Normalmente serán varias las secciones del web.config que tendremos que cambiar al publicar (appSettings, connectionStrings, customErrors, etc.). De igual forma está claro que no queremos (ni debemos) realizar estos cambios de forma manual cada vez que publiquemos en un entorno distinto al actual, por lo que una solución válida para automatizar esta tarea pasa por utilizar los ficheros de transformación.

Podemos crear un fichero de transformación para cada configuración de compilación (build configuration). Por defecto, cualquier proyecto incorpora las configuraciones Debug y Release, pero podemos crear cualquier otra configuración que necesitemos, por ejemplo: PreProducción, Producción, etc. Por cada configuración tendremos asociado a nuestro fichero web.config base un fichero con el nombre Web.NombreConfiguración.config. Cuando publiquemos y en función de la configuración activa, el fichero de transformación seleccionado aplicará los cambios solicitados al fichero web.config base y dará como resultado un nuevo fichero web.config con las transformaciones aplicadas.

Un ejemplo nos ayudará mejor a entenderlo.

Para crear una nueva configuración, Build > Configuration Manager… > New…

clip_image003

Después y en el menú contextual del fichero web.config, Add Config Tranform

clip_image004

Add Config Transform detectará que configuraciones de compilación no tienen un fichero de transformación y los agregará. Si estás utilizando VB.NET tendrás que activar “Show All Files” en la ventana “Solution Explorer” para ver estos ficheros.

clip_image005

Esta práctica no está limitada al directorio raíz de tu aplicación, sino que en cualquier carpeta que tenga un fichero web.config puedes aplicar la misma técnica.

clip_image006

También puedes borrar en cualquier momento un fichero de transformación y así y durante la publicación, simplemente no se realizará ninguna transformación.

En lo relativo a qué podemos hacer con las transformaciones, principalmente tendremos que trabajar con características principales:

  • Locator. Que nos ayudará a localizar el nodo donde aplicar una transformación.
  • Tranform. Servirá para indicar que tipo de transformación queremos realizar al nodo “localizado”.

La documentación sobre la sintaxis del espacio de nombres XML Document-Transform la puedes encontrar en la MSDN http://msdn.microsoft.com/en-us/library/dd465326(v=vs.100).aspx

Locator admite 3 posibilidades:

  • Condition(expresión XPath)
  • Match(Lista de atributos separada por comas)
  • XPath(expresión XPath)

Veamos algunos ejemplos:

Para empezar tenemos un fichero web.config con el siguiente contenido:

  <add

    name="CONEXIÓN"

    connectionString="CADENA_CONEXIÓN"

    providerName="PROVEEDOR" />

Con Locator="Condition(XPath expression)"

<?xml version="1.0" encoding="utf-8"?>

<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">

  <connectionStrings>

    <add

      name="NUEVO_NOMBRE"

      connectionString="NUEVA_CADENA_CONEXIÓN"

      providerName="NUEVO_PROVEEDOR"

      xdt:Locator="Condition(@name='CONEXION')"

      xdt:Transform="Replace"/>

  </connectionStrings>

</configuration>

Si quisiéramos ver ahora el resultado del fichero de transformación, la opción más sencilla sería publicar con la configuración “Debug”. Sin embargo, como a veces publicar puede ser algo tedioso (aunque sea en un directorio a disco), también podemos optar por llamar directamente por línea de comandos a MSBUILD con la siguiente sintaxis:
MSBuild “Ruta al fichero.vbproj|.csproj de nuestro proyecto” /t:TransformWebConfig /p:Configuration=NombreConfiguracion que nos volcará en el directorio \obj el resultado de la transformación (fijarse como además también ha trabajado con las subcarpetas).

clip_image007

Con Locator="Match(Lista de atributos separada por comas)"

  <connectionStrings>

    <add

      name="CONEXIÓN"

      connectionString="NUEVA_CADENA_CONEXIÓN"

      providerName="NUEVO_PROVEEDOR"

      xdt:Locator="Match(name)"

      xdt:Transform="Replace"/>

  </connectionStrings>

Si hablamos ahora de Transform, tenemos disponibles las siguientes operaciones:

  • Replace
    • Reemplaza el elemento encontrado.
    • En caso de encontrar varios, sólo reemplaza el primero.
  • Insert
    • Añade el elemento al final de la colección.
  • InsertBefore
    • Añade el elemento antes del elemento seleccionado según una expresión XPath.
  • InsertAfter
    • Igual que InsertBefore, pero añade el elemento después del elemento seleccionado.
  • Remove
    • Eliminar el elemento seleccionado.
    • En caso de encontrar varios, sólo reemplaza el primero.
  • RemoveAll
    • Elimina todos los elementos seleccionados.
  • RemoveAttributes
    • Elimina atributos del elemento seleccionado.
  • SetAttributes
    • Establece el valor de atributos de todos los elementos seleccionados.
    • Es similar a Replace, pero permite especificar que atributos serán reemplazados (mientras que Replace reemplaza el elemento entero). Además, SetAttributes no sólo reemplaza la primera ocurrencia, sino todas las encontradas.

Un ejemplo típico de un fichero de transformación para producción (release) podría ser aquel en que transformaremos los siguientes elementos:

  • appSettings
  • connectionStrings
  • compilation
  • customErrors
  • ELMAH
  • mailSettings

 

<?xml version="1.0" encoding="utf-8"?>

<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">

  <appSettings>

    <add

      key="Value"

      value="Value"

      xdt:Locator="Match(key)"

      xdt:Transform="Replace" />

  </appSettings>

  <connectionStrings xdt:Transform="Replace">

    <add

      name="Value"

      connectionString="Value"

      providerName="Value" />

  </connectionStrings>

  <system.web>

    <compilation xdt:Transform="RemoveAttributes(debug)" />

    <customErrors mode="RemoteOnly" xdt:Transform="SetAttributes(mode)">

    </customErrors>

  </system.web>

  <system.net>

    <mailSettings xdt:Transform="Replace">

      <smtp from=" Value ">

        <network

          host="Value"

          password="Value"

          userName="Value" />

      </smtp>

    </mailSettings>

  </system.net>

  <elmah>

    <security

      allowRemoteAccess="false"

      xdt:Transform="Replace" />

    <errorMail

      from="Value"

      to="Value"

      subject="Value"

      async="true"

      smtpPort="25"

      smtpServer="Value"

      userName="Value"

      password="Value"

      xdt:Transform="Replace">

    </errorMail>

  </elmah>

</configuration>

Las transformaciones realizadas han sido:

  • En appSettings hemos tenido que localizar primero con Match (porque asumimos que no queremos remplazar toda la sección sino sólo algún valor) y después se ha optado por Reemplazar (Replace). También se podría haber utilizado SetAttributes o incluso SetAttributes(value).
  • En connectionStrings se ha utilizado Replace pero sin ningún Locator. Con esto conseguimos remplazar la sección entera y no ha sido necesario utilizar Locator porque sólo hay una sección connectionStrings en un fichero web.config y por ello se localiza automáticamente.
  • En compilation se elimina el atributo debug con RemoveAttributes.
  • En customErrors simplemente se cambia el valor de mode con SetAttributes(mode).
  • Para mailSettings, elmah/security y elmah/errorMail se utilizar Replace sin Locator.

Espero que te haya quedado más o menos claro las posibilidades que ofrecen la transformación de ficheros de configuración y lo utilices de ahora en adelante, en detrimento del cambio manual que, insisto, ni nos gusta ni tampoco debemos.

POST ACTUALIZADO: Por cierto, si utilizas configSource para sacar a un fichero externo las cadenas de conexión o los settings de la aplicación, la transformación no los tendrá en cuenta, con lo cual tenemos varias opciones para solucionarlo. Una es utilizar distintos ficheros connectionStrings.config (p. ej. connectionStrings.Debug.config, connectionsString.Release.config y aplicar transformaciones al web.config para que apunte a uno u otro según configuración… cosa que no me gusta mucho, porque además de repetir código, no impide que en el directorio de publicación se escriban todos los ficheros, sean o no necesarios según la configuración activa). Otra es utilizar el addin de Visual Studio SlowCheetah que habilita la transformación de ficheros .config, no sólo al web.config sino a cualquier fichero de configuración.  Aunque la documentación de la herramienta es muy buena, también te dejo un enlace de un post de Scott Hanselman al respecto.

El único problema que me ha dado SlowCheetah ha sido un error durante la publicación del proyecto web porque no encontraba ciertos ficheros. Para solucionarlo he tenido que copiar todos los ficheros de la carpeta C:\Users\<TuUsuario>\AppData\Local\Microsoft\MSBuild\SlowCheetah\v1 a la carpeta v2.5.10. Entiendo que esto lo solucionará el autor en un siguiente release, seguro que sí!

POST ACTUALIZADO: Una mejor solución que copiar los ficheros parece modificar el fichero .csproj e indicar allí la ruta correcta a los ficheros:

<PropertyGroup>
  <SlowCheetahTargets Condition=" '$(SlowCheetahTargets)'=='' ">$(LOCALAPPDATA)\Microsoft\MSBuild\SlowCheetah\v2.5.10\SlowCheetah.Transforms.targets</SlowCheetahTargets>
</PropertyGroup>

POST ACTUALIZADO: Parece que hay un problema si se quiere reemplazar el nodo entero. Por ejemplo, algo como lo siguiente falla:

<connectionStrings xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform" xdt:Transform="Replace">

</connectionStrings>

Lanzado el siguiente error:

Could not write Destination file: Cannot insert the node in the specified location

Siendo así, en el caso de connectionStrings optaremos por tener distintos ficheros según configuración en vez de aplicar transformaciones. Donde sí tenemos que aplicar una transformación es en los ficheros web.config para que apunten a uno u otro fichero connectionString desde la propiedad configSource.

<connectionStrings configSource="connectionStrings.Release.config" xdt:Transform="Replace"></connectionStrings>

Esto conlleva por otro lado, que al publicar (en mi caso lo hago a disco) tengamos tanto el fichero connectionStrings.config (el que utilizamos en debug) como el fichero connectionStrings.Release.config (el que hemos creado para release). Como no queremos (no debemos) tener que eliminar a mano el fichero connectionStrings.config después de publicar, podemos solucionar esto incluyendo algo de código MSBuild en el fichero .pubxml que se creó en el directorio PublishProfiles debajo de Properties en nuestra aplicación web.

El código a incluir es el siguiente… y ojito que este código he tardado en escribirlo 3 horas, así que utilízalo sabiamente ;-)

<Target Name="panicoenlaxbox" AfterTargets="GatherAllFilesToPublish">
  <Delete Files="$(_PackageTempDir)\connectionStrings.config" />   
</Target>

POST ACTUALIZADO: Al volver mucho después a este post he visto que ahora también hay un paquete de Nuget SlowCheetah. Esto significa que ya no es necesario instalar el plugin en VS. Personalmente me parece ahora mejor opción porque así te olvidas de tener que estar copiando manualmente y en una ruta concreta los ficheros .targets en el servidor de integración continua, simplemente ahora son parte del proyecto.

image

Un saludo!