jueves, 17 de octubre de 2013

Binding de números y fechas “culture-sensitive” en ASP.NET MVC

Ahora que estoy metido de lleno en un desarrollo con ASP.NET MVC 4, pensé que sería buena idea leer un buen libro sobre el mismo. Mi elección fue Pro ASP.NET MVC 4. El libró me encantó y lo recomiendo.

En el capítulo “Model Binding” (obligado título para un capítulo de un libro que hable sobre ASP.NET MVC) me chocó mucho el siguiente texto (que cito literalmente)

“The DefaultModelBinder class use different culture settings to perform type conversions from different areas of the request data. The values that are obtained from URLs (the routing and query string data) are converted using culture-insensitive parsing, but values obtained from form data are converted taking cultureinto account.

The most common problem that this causes relates to DateTime values. Culture-insensitive dates are expected to be in the universal format yyyy-mm-dd. Form date values are expected to be in the format specified by the server. This means that a server set to the UK culture will expected dates to be in the form dd-mm-yyyy, whereas a server set to the US culture will expect the format mm-dd-yyyy, though in either case yyyy-mm-dd is acceptable too.

A date value won’t be converted if it isn’t in the right format. This means that we must make sure that all dates included in the URL are expressed in the universal format. We must also be careful when processing date values that users provide—the default binder assumes that the user will express dates in using the format of the server culture, something that unlikely to always happen in an MVC application that has international users.”

Como por suerte o por desgracia, todas las aplicaciones que desarrollamos en la empresa tiene que soportan internacionalización, el tema de formato de fechas y números es un tema que me preocupa especialmente.

El párrafo citado anterior viene a decir lo siguiente:

  • Los datos provenientes de la url (querystring y route values) son culture-insensitive. Es decir, esperan un formato determinado con independencia de la cultura establecida en el servidor.
  • Los datos provenientes del form son culture-sensitives. Esto es que sí tienen en cuenta la cultura del servidor.

Vamos a hacer unos pruebas para confirmar esto y verlo en directo. La cultura del servidor será es-ES (haciendo patria) y simplemente crearemos un acción como la siguiente:

public ActionResult Index(decimal? numero, DateTime? fecha)
{
    return View();
}

Ahora y con la ayuda de ScratchPad probaremos el binding de fechas y números.

POST http://localhost:6319/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
Host: localhost:6319

Numero=1,5&Fecha=31%2F12%2F2013

Todo perfecto, tanto 1,5 como 31/12/2013 son formatos válidos para es-ES.

Numero=1.5&Fecha=13%2F31%2F2013

Ninguna de los 2 parámetros cumple con el formato esperado, error.

Numero=1,5&Fecha=2013%2F12%2F31

Sorprendentemente, aunque form sea culture-sensitive, también acepta el formato yyyy-mm-dd para las fechas.

GET http://localhost:6319/?numero=1,5&fecha=31-12-2013 HTTP/1.1
Host: localhost:6319

Ninguna de los 2 parámetros cumple con el formato esperado, error.

GET http://localhost:6319/?numero=1.5&fecha=2013-12-31 HTTP/1.1
Host: localhost:6319

Ningún problema, especial atención el formato de la fecha: yyyy-mm-dd

GET http://localhost:6319/?numero=1.5&fecha=12-31-2013 HTTP/1.1
Host: localhost:6319

Otra sorpresa, aunque se suponía que el formato de fecha para url tenía que ser yyyy-mm-dd, aparentemente también acepta mm-dd-yyyy

Con esta situación está claro que bindear números y fechas y soportan internacionalización en tu aplicación se va a convertir en una pesadilla si utilizamos sólo y exclusivamente DefaultModelBinder. Es por ello, que resulta necesario crear unos binders personalizados para los tipos Decimal y DateTime.

El código es el siguiente:

using System;
using System.Globalization;
using System.Linq;
using System.Web.Mvc;

namespace WebApplication1
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value
= base.BindModel(controllerContext, bindingContext);
if (value != null)
{
return value;
}
if (controllerContext.HttpContext.Request.HttpMethod == "POST")
{
return null;
}
var valueProviderResult
= bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == null)
{
return null;
}
if (string.IsNullOrWhiteSpace(valueProviderResult.AttemptedValue))
{
return null;
}
var errors
= bindingContext.ModelState[bindingContext.ModelName].Errors;
if (errors.Any())
{
errors.Clear();
}
try
{
return (DateTime)valueProviderResult.ConvertTo(typeof(DateTime), CultureInfo.CurrentCulture);
}
catch (Exception ex)
{
bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex);
return null;
}
}

public class DecimalModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value
= base.BindModel(controllerContext, bindingContext);
if (value != null)
{
return value;
}
if (controllerContext.HttpContext.Request.HttpMethod == "POST")
{
return null;
}
var valueProviderResult
= bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == null)
{
return null;
}
if (string.IsNullOrWhiteSpace(valueProviderResult.AttemptedValue))
{
return null;
}
var errors
= bindingContext.ModelState[bindingContext.ModelName].Errors;
if (errors.Any())
{
errors.Clear();
}
try
{
return (Decimal)valueProviderResult.ConvertTo(typeof(Decimal), CultureInfo.CurrentCulture);
}
catch (Exception ex)
{
bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex);
return null;
}
}
}
}
Para hacerlo funcionar en MVC, también es necesario registrar nuestros nuevos y flamantes binders desde el global.asax:
ModelBinders.Binders.Add(typeof(DateTime), new DateTimeModelBinder());
ModelBinders.Binders.Add(typeof(DateTime?), new DateTimeModelBinder());
ModelBinders.Binders.Add(typeof(Decimal), new DecimalModelBinder());
ModelBinders.Binders.Add(typeof(Decimal?), new DecimalModelBinder());

Con esto, además del comportamiento predeterminado del DefaultModelBinder, ahora tanto url (querystring y route values) como form serán culture-sensitive.



Un saludo!

9 comentarios:

  1. Buenas! NO es buena idea tener formatos personalizados por cultura en la URL, a no ser que explícites en la URL la cultura a usar.
    La cultura del servidor en .NET se especifica de varias maneras, pero una forma es tener en cuenta la cabecera accept-language colocando en el, web.config:


    Esto tiene su razón de ser y es básicamente servir contenido en el idioma que el usuario tiene marcado como preferido en el navegador. De esta manera no tenemos que ofrecer un mecanismo de selección de idioma alternativo (si tienes el navegador configurado indicando que quieres contenido en-US vas a ver la web en inglés, mientras que si tienes al navegador configurado en es-ES la vas a ver en castellano).

    Y que tiene esto que ver con el que NO sea recomendable usar parámetros personalizados por cultura en la URL?
    Simple: Imagina un visitante que tiene el navegador configurado en inglés y visita t web a la url /offers/index?date=11/02/2014 para ver las ofertas del 02 de noviembre (pues el usa la cultura inglesa con MM/dd/yyyy). Encuentra esta página interesante, copia y pega el link y se lo manda a un colega...

    ... que tiene el navegador configurado en castellano y ve las ofertas del 11 de febrero!!!
    Esa es la principal razón por la que el DefaultModelBinder NO tiene en cuenta la cultura al formatear fechas de URL.

    En los posts estos problemas no se dan, simplemente porque no pueden "compartirse".

    Un saludo!
    edu (@eiximenis)

    ResponderEliminar
  2. Argh! Veo que el comentario ha quitado el código necesario en el web.config para configurar la cultura según el valor de la cabecera accept-languages:
    <globalization culture="auto" uiCulture="auto" />

    ResponderEliminar
  3. Hola Edu,
    Nunca lo había pensando así, pero tu ejemplo es "claro y contundente". He estado haciendo alguna prueba y creo que pillo lo que dices. La solución pasaría entonces por el siguiente escenario?
    Siempre que haga una petición get (sea cual sea la Culture en el cliente y/o servidor), las fechas deberían ir en el formato internacional (mm-dd-yyyy) que NO depende de ninguna cultura. Esto implica que el usuario escribiría en una caja de texto con su cultura (p. ej. 31/12/2013 en es-ES) y luego vía "javascritpt" habría que transformar esta fecha a internacional. ¿Es así, no? De este modo, compartir enlaces ya no es un problema. Da igual la cultura que el formato mm-dd-yyyy siempre se lo traga.
    Del tema de globalization en el web.config te contesto en unos minutos :-)

    ResponderEliminar
  4. Si, la idea es que las URLs deben ser "compartibles". Por lo tanto:
    1. O bien tienes una cultura fija en las URLs (opción de ASP.NET MVC)
    2. O bien tienes una cultura fija en el servidor

    El segundo punto tiene el "problema" de que entonces no estás haciendo caso de la cabecera Accept-Languages, lo que quiere decir que debes establecer algún otro mecanismo para que el usuario pueda seleccionar el idioma de la web (cookies, la cultura en la URL como hace la msdn (p.ej. msdn.microsoft.com/en-US/).

    Por supuesto, si tu web tiene un solo idioma y siempre vas a mostrar los datos en una cultura, entonces si que puedes utilizar tu mecanismo y establecer una cultura fija en el servidor.

    Saludos! :D

    ResponderEliminar
  5. Gracias Edu, al final entre el post, tus comentarios y el rato que nos estamos pegando viendo todo estoy yo y @MookieFumi, ahora está todo mucho más claro.
    Lo que te decía antes del globalización en el web.config en mi caso no puedo usarlo porque tengo controladas que culturas y uicultures concretas se pueden usar en la aplicación. Así, tengo una culture/uiculture por defecto + detección por accept-languages "sólo para los que yo quiero" + combo de selección de idioma de nuevo sólo para los permitidos.
    Muchos gracias por tus comentarios :)

    ResponderEliminar
  6. Hola a ambos,

    Estoy muy de acuerdo con lo que comenta Eduard, y en esa línea te había comentado yo por Twitter lo del idioma internacional...

    En concreto, yo lo comentaba porque con APIs REST cada vez es más común identificar recursos o listados con la propia URL y por tanto usar el idioma del navegador creo que es un poco peligroso. Voy a poner un ejemplo:

    Imaginad (siguiendo el ejemplo de Eduard) que yo estoy viendo el listado de ofertas del 11 de febrero (url/offers/index?date=11/02/2014), me gustan algunos artículos y decido mandarle a mi compañero de trabajo el link para que vea las ofertas. Él además es un programado como muchos que tiene la máquina en inglés porque así tienes menos problemas con las herramientas. ¿Qué verá él al pinchar sobre el link que le he enviado? Pues las ofertas del 2 de noviembre :-)

    Yo en estos casos soy partidario de intentar utilizar siempre el idioma internacional en las comunicaciones, y que luego cada capa cliente se lo traduzca como crea conveniente, aunque para ello tenga que utilizar alguna librería o demás.

    Hablo en general, porque luego siempre hay casos en que es mejor otra cosa

    Saludos,

    @XaviPaper

    ResponderEliminar
  7. Mmmm... tengo otra solución.
    DateTime.ToBinary -> DateTime.FromBinary

    http://1poquitodtodo.blogspot.com.es/2008/02/datetimetobinary.html

    ResponderEliminar
  8. Las fechas siempre en formato UTC y que cada cliente las interprete ;)

    ResponderEliminar
  9. Hola

    Xavi. Perdóname porque en twitter estaba algo espeso, de hecho según lo ha explicado aquí Eduard me he dado cuenta de que era más o menos lo que decías tú en twitter, lo dicho, a veces 140 caracteres se hacen cortos.
    Por otro lado, claramente la opción buena es querystring y culture insensitive en formato internacional (la que habéis explicado vosotros). Ahora bien, esto supone no hacer nunca un get en cliente sin antes pasar por un formateador que ponga la fecha en el formato internacional. Esto mismo pasaría con los números. Es decir, ya no enviaremos 1,5 sino 1.5. Esto implica otro "parser" más, ahora para números Además esto tienen las mismas implicaciones que la fecha a la hora de compartir enlaces. Si comparto offers/index?fromPrice=1,5toPrice=3,5 pues un americano verá precios desde 15 a 35 (en vez desde un mísero 1,5 a 3,5 que era lo que queríamos).
    La verdad es si la aplicación es privada y no está previsto compartir enlaces, otra posibilidad sería pasar de todo esto e ir a culture sensitive que ahorra "formatear y trabajar" con fechas en cliente.
    Lógicamente, si hablamos de algo público o rest, blanco y en botella, insensitive a muerte!

    Quijano. Pues muy interesante el enlace, yo trabajo con UTC así que mañana lo miraré bien, pero tiene muy buena pinta. Para pasar por URL no lo veo, no? Es binario y aunque fuera en Base64 la url quedaría un poco fea y nada friendly... digo yo :)

    Anónimo. ¿Podrías explicarte mejor? Lo digo porque no lo veo muy bien. UTC me da dolor de cabeza xD http://panicoenlaxbox.blogspot.com.es/2010/12/fechas-y-horas-utc.html

    Gracias a todos por comentar! Hoy he aprendido muchísimo.

    ResponderEliminar