ASP.NET MVC: Como recuperar un dato de una cookie para cada petición…

EEhhhmm… bueno, no se me ocurre un título mejor. Este post nace gracias a un tweet de Lluis Franco. En el tweet Lluís preguntaba dónde guardar la cultura de una aplicación MVC si no se podía poner en la URL. Después de varios tweets comentando algunas cosillas yo he respondido diciendo que veía dos opciones: o en una cookie o en la base de datos. Una de las cosas que más me gustan de HTTP es que es simple: no hay muchas maneras de pasar estado entre cliente y servidor 😉

En este post vamos a ver como podemos solucionar fácilmente el problema asumiendo que se guarda la cultura del usuario en una cookie.

De mi tweet, la parte importante es la segunda: independizar a los controladores de donde esta la cultura de la aplicación. Ya lo he comentado en varios posts: evitad acceder desde los controladores a objetos que dependen de HTTP: sesión, aplicación, cache y… cookies.

En un post anterior ya comenté como usar un value provider para hacer binding de datos de una cookie a un controlador. Esa es una buena solución si los datos de la cookie se usan en algunas pocas acciones de un controlador. Pero ese no es nuestro caso ahora: ahora queremos que la cultura se establezca siempre, para todas las acciones de todos los controladores.

La solución en este caso pasa por un Route Handler nuevo. Los route handlers son los objetos que se encargan de procesar las rutas (crear los controladores y cederles el control). Son pues objetos de bajo nivel. Cuando la tabla de rutas enruta una URL para ser procesada por una ruta concreta, se usa el RouteHandler asociado a dicha ruta para crear toda la infrastructura que MVC necesita para procesar la petición.

Recordad que la tabla de rutas se define en Global.asax y que por defecto tiene el siguiente código:

public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
"Default", // Route name
"{controller}/{action}/{id}", // URL with parameters
new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
);
}

Aquí no estamos especificando ningún route handler, por lo que se usará el que tiene MVC por defecto… Pero como (casi) todo en MVC lo podemos cambiar 🙂

En lugar de usar el método MapRoute (que por si alguien no lo sabe es un método de extensión) podemos crear un objeto Route y añadirlo directamente a la tabla de rutas. El constructor de Route tiene un parámetro que es de tipo IRouteHandler y que es el route handler para esta ruta. Así que puedo transformar la tabla de rutas anterior en esta:

public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.Add("Default", new Route("{controller}/{action}/{id}",
new CultureRouteHandler())
{
Defaults = new RouteValueDictionary(
new
{
controller = "Home", action = "Index", id = UrlParameter.Optional
})
});
}

Ambas son equivalentes, salvo que esta usará un objeto de tipo CultureRouteHandler para procesar las peticiones.

Ahora vamos a ver como es el CultureRouteHandler:

public class CultureRouteHandler : MvcRouteHandler
{
protected override IHttpHandler GetHttpHandler(System.Web.Routing.RequestContext requestContext)
{
var cultureCookieVal = GetCultureFromCookie(requestContext.HttpContext.Request.Cookies);
var culture = new CultureInfo(cultureCookieVal);
requestContext.RouteData.Values.Add("culture", cultureCookieVal);
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;
return base.GetHttpHandler(requestContext);
}

private string GetCultureFromCookie(HttpCookieCollection cookies)
{
var retValue = "ca-ES";
if (cookies.AllKeys.Contains("userculture"))
{
retValue = cookies["userculture"].Value;
}
return retValue;
}
}

En este caso derivo de MvcRouteHandler (como casi siempre en MVC es mucho más sencillo derivar de alguna clase base que implementar la interfaz entera), y en el método GetHttpHandler lo que hago es llamar al método de la clase base pero antes:

  1. Recupero el valor de la cookie de cultura
  2. Guardo este valor en el route data con el nombre culture (por si alguien quiere consultarlo)
  3. Creo un CultureInfo a partir de los datos de la cookie y establezco la cultura del thread actual a este valor: así cualquier mecanismo que tenga de “localización” debería funcionar igualmente.

Finalmente para probar el tema me he creado un pequeño controlador:

public class HomeController : Controller
{
[OutputCache(NoStore = true, Location = OutputCacheLocation.None)]
public ActionResult Index()
{
return View();
}

public ActionResult SetCookie(string id)
{
if (!string.IsNullOrEmpty(id))
{
this.ControllerContext.HttpContext.Response.Cookies.Add(new HttpCookie("userculture", id));
}
return RedirectToAction("Index");
}
}

La acción /Home/Index simplemente retorna una vista. La acción /Home/SetCookie/id establece la cookie de cultura (se supone que el id es válido, algo así como /Home/SetCookie/es-ES p.ej.).

La vista que devuelve /Home/Index simplemente muestra la cultura actual:

<p>
Cultura actual: @System.Threading.Thread.CurrentThread.CurrentUICulture.ToString();
</p>

Bonus track: Y si quiero que algún controlador reciba la cultura actual como parámetro de alguna de sus acciones?

Bien, recordad que hemos hecho que el route handler guardase el valor de cultura en los route values. MVC tiene un value provider que permite realizar bindings desde los route values hacia los controladores. Guardábamos el valor con el nombre “culture” así que nos basta con:

public ActionResult Foo(string culture)
{
// Código...
}

El parámetro culture tiene el valor de la cultura.

Si quieres saber exactamente cómo reciben los datos los controladores, hace algún tiempecillo escribí un post al respecto.

De esa manera conseguimos lo que yo decía en mi tweet: agnostizar los controladores de dónde se guarda la cultura!

Un saludo!

12 comentarios sobre “ASP.NET MVC: Como recuperar un dato de una cookie para cada petición…”

  1. @Edu: Eres un fucking crack del MVC! 1000 gracias… 🙂

    Pregunta: Para validar si a alguien con ganas de tocar los webs le da por meter una cultura incorrecta (/Home/SetCookie/en-US666), que te parece si hacemos esto?

    private static bool isValidCulture(string id)
    {
    CultureInfo[] cultures = CultureInfo.GetCultures
    (CultureTypes.SpecificCultures);
    return cultures.Any(p => p.Name == id);
    }

    Y luego en el SetCookie hacemos esto:

    if (!string.IsNullOrEmpty(id) && isValidCulture(id))

    ¿Lo meterías aquí o en otro lado?
    Asias!

  2. @Lluís 😉

    A mi, donde propones me parece bien… 🙂
    De todos modos esa acción /SetCookie la puse a modo de demo…

    Y, por supuesto, siempre puedes hacer un método de extensión sobre string para validar que sea una representación válida de CultureInfo, y discutimos en que namespce ponerlo :p

    Por cierto que, mientras me zampaba una hamburguesa para comer, estaba meditando que igual con un action filter global de MVC3 se puede conseguir el mismo resultado, con la ventaja de no tener ni que modificar la tabla de rutas. Cuando pueda haré un par de pruebas y ya publicaré algo al respecto… 🙂

    Saludos!!! 😉

  3. Jajajaaa… 😉
    Tranquilo… la verdad es que ese tema ya salió en la «conversación» por twitter 😉
    Mi pregunta:
    http://twitter.com/eiximenis/status/25846977392545792
    y la respuesta de Lluís:
    http://twitter.com/lluisfranco/status/25847553077547009

    Un saludo y gracias por comentar!! 😉

    PD: Mira que es coñazo obtener «las conversaciones» que se han hecho por twitter… ahí hay espacio para un servicio que haga esto… mmmmm….. 🙂

  4. Hola tito @Alex!
    Recuerdas el ejemplo que me pasaste? Resulta que al final el ‘consejo de sabios’ decidió que NO se podía poner la cultura en la url… y ya les puedes hablar de SEO y demases, que te miran con cara de ‘mira tio, yo pago y tu lo haces y te callas’ :-/
    En fin, gracias a los dos! 😛

  5. @Edu: Creo que estaría bien hacer un post para discutir lo del namespace y… coñe! ¿has visto pasar un gato negro dos veces? :-S

  6. Hola señor,
    Estoy usando su ejemplo y el valor de la cultura no persiste entre sesión y sesión. Hay algun modo de persistir el valor de la cultura para que el usuario no tenga que fijarla siempre que llega a la página web?
    Muchas gracias por sus artículos!

  7. @Alberto,
    El tema está en que la cookie que creo en mi ejemplo no es persistente (por lo que vive sólo mientras no se cierra la ventana del navegador).
    Para hacer que la cookie sea persistente debes establecer su propiedad .Expires al tiempo que ha de durar… Algo como:
    myCookie.Expires = DateTime.Now.AddDays(1);
    Con eso la cookie duraría un día.

    Aquí tienes más información: http://ashrafur.wordpress.com/2008/01/11/persistent-and-non-persistent-cookies-in-aspnet/

    Recuerda que el usuario puede borrar las cookies cuando quiera, así que no puedes asumir que siempre va a estar.

    Un saludo!

  8. Me gusta!

    No obstante, la lógica de leer esta cookie para establecer la cultura me parece màs adecuada en un ActionFilter registrado en GlobalFilters o en los controladores que lo necesiten mediante un atributo, que en un RouteHandler.

    Parece que en el pipeline mvc hay muchos puntos de extensibilidad y cada uno tiene su rol y su cometido concreto (SRP). El del RouteHandler es interpretar las URL y pasarlos al diccionario routedata, ni más ni menos. Extender el RouteHandler me parece menos intuitivo y mantenible que un IActionFilter.

    Es simplemente un punto de vista. Muy buen post en cualquier caso.

  9. @Germán
    Me alegro de que te guste, y al respecto de lo que dices en tu comentario, tienes toda la razón. Seguramente el mejor sitio donde poner este código es en un Action Filter.
    Y de hecho, esta alternativa (Filtro Global) es la que exploro en otro post (http://geeks.ms/blogs/etomas/archive/2011/01/19/asp-net-mvc-como-recuperar-un-dato-de-una-cookie-para-cada-petici-243-n-una-alternativa-191-igual.aspx).
    A mi también me parece más elegante la alternativa del filtro global.

    La razón por la cual lo hice en primera instancia con un route handler fue muy simple: ni pensé en el filtro global… luego de publicar el post, mientras me zampaba una hamburguesa caí en lo del filtro y hice el siguiente post! Ya ves… 😀

    Te agradezco mucho tu comentario!
    Un saludo!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *