viernes, 16 de mayo de 2014

TempData basado en MongoDB en ASP.NET MVC

TempData es un forma útil de compartir datos en ASP.NET MVC entre distintas requests.La implementación por defecto que incorpora el framework está basada en Session y esto podría suponer problemas en escenarios cloud donde sería un cuello de botella si pretendemos escalar horizontalmente nuestra aplicación.

Haciendo honor a la promesa de que todo en ASP.NET MVC es flexible y configurable, podemos sustituir la implementación por defecto de TempData con nuestra propia solución.

Inicialmente trabajé con una implementación basada en cookies. La idea y el 85% del código está tomado del artículo de José María Aguilar en su post TempData sin variables de sesión en MVC.

El código del proveedor es el siguiente (lo pongo aquí porque incorpora algunas diferencias a raíz de los comentarios que se hicieron en el anterior post citado):

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Mvc;
 
namespace WebApplication1
{
    public class CookieTempDataProvider : ITempDataProvider
    {
        private const string CookieName = "TempData";
 
        public IDictionary<string, object> LoadTempData(ControllerContext controllerContext)
        {
            var cookie = controllerContext.HttpContext.Request.Cookies.Get(CookieName);
            if (cookie == null)
            {
                return null;
            }
            var bytes = Convert.FromBase64String(HttpUtility.UrlDecode(cookie.Value));
            var value = Encoding.Unicode.GetString(bytes);
            return Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string, object>>(value);
        }
 
        public void SaveTempData(ControllerContext controllerContext, IDictionary<string, object> values)
        {
            if (values != null && values.Any())
            {
                var serializedData = Newtonsoft.Json.JsonConvert.SerializeObject(values);
                var bytes = Encoding.Unicode.GetBytes(serializedData);
                var value = HttpUtility.UrlEncode(Convert.ToBase64String(bytes));
                var cookie = new HttpCookie(CookieName, value) { HttpOnly = true };
                controllerContext.HttpContext.Response.Cookies.Add(cookie);
            }
            else
            {
                var cookie = controllerContext.HttpContext.Request.Cookies[CookieName];
                if (cookie != null)
                {
                    cookie.Expires = DateTime.Now.AddDays(-1);
                    controllerContext.HttpContext.Response.Cookies.Set(cookie);
                }
            }
        }
    }
}
La implementación basada en cookies está bien y funciona, pero a medida que utilizo más TempData me da cierto reparo guardar determinados datos sensibles en cookies. Es cierto que podríamos fácilmente encriptar la cookie, pero también es cierto que la cookie tiene un tamaño máximo, un usuario podría no aceptarla, etc. Para solucionar esto (y sobre todo por marear un poco) me he decidido a crear un proveedor de TempData basado en MongoDB.

¿Qué no sabes que es MongoDB? Pues aquí tienes un tutorial magnífico de Rubén Fernández que te vendrá que ni al pelo.

El proveedor de TempData sobre MongoDB es el siguiente:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.Builders;
 
namespace Mss2.Presentation.WebClient.Infrastructure
{

    public class MongoDBTempDataDocument

    {

        public MongoDBTempDataDocument()

        {

            CreatedDate = DateTime.UtcNow;

        }

 

        public DateTime CreatedDate { get; set; }

        public ObjectId Id { get; set; }

        public string Key { get; set; }

        public Guid UniqueId { get; set; }

 

        [BsonSerializer(typeof(MongoDBCustomSerializer))]

        public object Value { get; set; }

    }

 
    public class MongoDBTempDataProvider : ITempDataProvider
    {
        private readonly string _collection;
        private readonly string _connectionString;
 
        public MongoDBTempDataProvider(string connectionString, string collection)
        {
            _connectionString = connectionString;
            _collection = collection;
        }
 
        public IDictionary<string, object> LoadTempData(ControllerContext controllerContext)
        {
            var uniqueId = GetUniqueId(controllerContext);
            if (uniqueId == null)
            {
                return null;
            }
            var collection = GetCollection();
            var query = Query<TempDataDocument>.EQ(p => p.UniqueId, uniqueId);

            SortByBuilder sort = SortBy.Ascending("CreatedDate");

            MongoCursor<MongoDBTempDataDocument> cursor = collection.Find(query).SetSortOrder(sort);

            var cursor = collection.Find(query);
            if (!cursor.Any())
            {
                return null;
            }
            var tempData = new Dictionary<string, object>();

            cursor.ToList().ForEach(item =>

            {

                if (tempData.ContainsKey(item.Key))

                {

                    tempData[item.Key] = item.Value;

                }

                else

                {

                    tempData.Add(item.Key, item.Value);

                }

            });

            return tempData;
        }
 
        public void SaveTempData(ControllerContext controllerContext, IDictionary<string, object> values)
        {
            var uniqueId = GetUniqueId(controllerContext);
            if (uniqueId == null)
            {
                return;
            }
            if (values.Any(p => p.Value.GetType().Name.Contains("AnonymousType")))
            {
                throw new ArgumentException("You cannot save a instance of anonymous type");
            }
            var collection = GetCollection();
            var query = Query<TempDataDocument>.EQ(p => p.UniqueId, uniqueId);
            collection.Remove(query);
            if (values != null && values.Any())
            {
                collection.InsertBatch(values.Select(p => new TempDataDocument()
                {
                    UniqueId = (Guid)uniqueId,
                    Key = p.Key,
                    Value = p.Value
                }));
            }
        }
 
        private MongoCollection<TempDataDocument> GetCollection()
        {
            var url = new MongoUrl(_connectionString);
            var client = new MongoClient(url);
            var server = client.GetServer();
            var database = server.GetDatabase(url.DatabaseName);
            var collection = database.GetCollection<TempDataDocument>(_collection);
            return collection;
        }
 
        private static Guid? GetUniqueId(ControllerContext controllerContext)
        {
            var cookie = controllerContext.HttpContext.Request.Cookies["UniqueId"];
            if (cookie != null)
            {
                return new Guid(cookie.Value);
            }
            return null;
        }
    }
}
Lo más reseñable es que el deserializador de MongoDB de serie falla al deserializar tipos anónimos, es por ello que se controla durante la inserción si el tipo es anónimo para lanzar una excepción, más información aquí.

Después de publicar este mismo post, el bueno de Luis Ruiz Pavón consiguió una solución al problema de no poder deserializar objetos anónimos y mejoró notoriamente el código, gracias!!
El código está en el post TempData basado en MongoDB (II) donde aparece ya todo esto resuelto y funcionado.

Después y para conseguir un valor único por usuario, yo he optado por guardar un Guid en una cookie durante el evento Application_BeginRequest, pero estoy abierto a cualquier otra recomendación para conseguir un valor único por usuario.

        private void SetUniqueIdCookie()
        {
            var cookie = Request.Cookies["UniqueId"];
            if (cookie == null)
            {
                var uniqueId = Guid.NewGuid();
                cookie = new HttpCookie("UniqueId")
                {
                    HttpOnly = true,
                    Value = uniqueId.ToString()
                };
                HttpContext.Current.Response.Cookies.Add(cookie);
            }
        }
 
        protected void Application_BeginRequest(object sender, EventArgs e)
        {
            SetUniqueIdCookie();
        }
Por cierto, aunque en el post no lo pongo por dejar el código lo más sencillo posible, en la implementación de producción tengo encriptada la cookie de valor único, así como la cookie completa que guarda TempData si opto por la implementación de TempData basada en cookies.

Y tampoco querría dejar de comentar que NO utilizo LINQ to Mongo ¿Por qué? Lee el post MongoDb y c#. Dos titanes en lucha constante de Pedro Hurtado y lo sabrás, básicamente la implementación de LINQ del driver de Mongo tiene algunas fugas importantes, toma nota!

Para ponerlo en funcionamiento, bastaría con agregar un par de settings en nuestro fichero web.config:
  <add key="MongoDB_ConnectionString" value="mongodb://192.168.1.23/aspnet"/>
  <add key="MongoDB_Collection" value="tempData"/>

Y registrar el proveedor (en mi caso estoy usando Unity como IoC container)
container.RegisterType<ITempDataProvider, MongoDBTempDataProvider>(
    new InjectionConstructor(
        ConfigurationManager.AppSettings["MongoDB_ConnectionString"],
        ConfigurationManager.AppSettings["MongoDB_Collection"]));
Un saludo!

2 comentarios:

  1. Muy buen post, Sergio, y gracias por la mención :)

    ResponderEliminar
  2. Hola José, gracias a ti por hacer un "inception" sobre el tema y abrir camino :) Gracias!

    ResponderEliminar