Juegos sociales online con SignalR y Windows Azure (2 de 3)


Clouds
En el artículo anterior vimos cómo utilizar SignalR para crear servicios con un canal de comunicaciones abierto con el cliente. Recordad que es siempre el cliente el que establece la comunicación y luego el servidor utiliza la técnica más adecuada en cada caso para mantener el canal abierto.

El ejemplo, aunque con bastante código, no deja de ser un caso básico y con poca escalabilidad. Si queremos dar servicio a miles o millones de usuarios simultáneos, necesitaremos que nuestro servicio pueda crecer a lo ancho y no a lo alto. En lugar de aumentar potencia de CPU y RAM a una sola máquina, nos permite ir añadiendo más máquinas a medida que las necesitemos, que trabajarán en paralelo y nos permitirán un número de usuarios simultáneos sin límite.
Con Windows Azure podemos hacer esto y mucho más. Para que nuestra aplicación funcione en un entorno Cloud realizaremos algunos cambios en la misma:

  • Hasta ahora estábamos almacenando las partidas en memoria, en una aplicación en la nube no podemos utilizar esta técnica porque trabajaremos con múltiples instancias y cada una tiene su propia memoria. Necesitamos un lugar de almacenamiento que puedan compartir las diferentes instancias. En Azure tenemos diferentes posibilidades: Windows Azure SQL para datos relacionales, Windows Azure Table Storage para datos no relacionales (NoSQL), o Windows Azure Caching si necesitamos algo pequeño y muy rápido. En nuestro ejemplo usaremos las tablas de Windows Azure Storage.
  • Añadiremos un enlace al ServiceBus, de manera que cada vez que uno de los servidores necesite enviar información a todos los clientes conectados al servicio pueda avisar al resto de servidores del cluster para que también envíen esa información a sus clientes.

Qué necesito

Os recuerdo que para este ejercicio necesitamos:

Convertir un proyecto web en proyecto Azure

El primer paso es añadir a nuestra solución un proyecto de Azure que nos configurará el paquete de despliegue en la nube. Pulsamos el botón derecho sobre el proyecto y nos aparecerá la opción Add Windows Azure Cloud Service Project:

3er.11.AddCloudService

Si ejecutamos nuestra aplicación ahora, el Visual Studio arrancará el emulador de Windows Azure y nuestra aplicación se ejecutará en el entorno simulado. Al principio nos parecerá que funciona todo, para comprobar que en realidad nos va a fallar todo nos basta con configurar el rol para que se ejecuten dos instancias.

En el proyecto TresEnRaya.Azure, abrimos la carpeta Roles y hacemos doble-click en nuestro rol TresEnRaya, en la configuración podemos cambiar el número de instancias:
3er.11.increasinstances

Al incrementar el número de instancias, haremos que cada nueva conexión vaya a una máquina distinta, es decir, se irán balanceando las conexiones. Como os he comentado antes, las instancias no comparten memoria ni cpu, son instancias completamente independientes, incluso en el simulador. Con nuestro diseño de aplicación con listas en memoria nos encontramos con un problema importante: cada instancia tiene su lista de usuarios, solicitudes y partidas y no se ven entre ellas. Como podemos ver en la siguiente imagen, el botón para jugar contra «Manolo» debería estar en dos de los navegadores y sólo aparece en uno:
3er.11.ymanolo

Windows Azure Storage

Para almacenar las partidas y las solicitudes y que todas las instancias de nuestro servicio tengan acceso vamos a utilizar el Azure Table Service del Windows Azure Storage.
Utilizaremos el Storage en lugar de SQL Azure porque la sencillez de los datos nos lo permite y es un almacenamiento mucho más económico que el SQL.

Una explicación rápida: las tablas de Windows Azure son listas organizadas por clave, pueden contener hasta 252 valores en cada registro y tienen un límite de 100TB. Los puntos clave de las tablas en Azure que vamos a encontrarnos durante el desarrollo de esta aplicación son:

  • Los elementos se identifican mediante una clave compuesta por dos elementos PartitionKey y RowKey.
  • La información está agrupada por PartitionKey, de tal manera que recuperar múltiples registros de una misma PartitionKey es muy rápido.
  • Realizar consultas que impliquen diferentes PartitionKey o buscar por otras propiedades que no formen parte de la clave penaliza el rendimiento
  • No existen las relaciones entre tablas, es decir, no podremos hacer consultas cruzadas, olvidad lo que sabéis de SQL y las reglas de normalización de tablas. Tendremos que trabajar de otra manera, seguramente repitiendo datos organizados de formas distintas en múltiples tablas.

Definición de las tablas

En C# las tablas se pueden definir directamente desde clases, podremos utilizar las mismas clases que ya teníamos, heredando de la clase TableEntity:

public class Jugador:TableEntity
{
    public Jugador()
    {
    }

    public Jugador(string pais, string nombre, string id)
    {
        PartitionKey = pais;
        RowKey = nombre;
        Id = id;
    }

    public string Pais { get { return PartitionKey; } }
    public string Nombre { get { return RowKey; } }
    public string Id { get; set; }
}

Al transformar la clase necesitamos añadir un constructor por defecto y convertimos los campos país y nombre en la PartitionKey y RowKey respectivamente.
Así podremos realizar consultas sobre todos los jugadores de un mismo país sin perder rendimiento.
El problema que nos encontraremos será encontrar el jugador por Id de conexión, algo que hacemos bastante dentro de la clase DatosPartida. Como una consulta por Id nos penalizará el rendimiento, lo que haremos será crear otra tabla para poder buscar por Id.

public class JugadorPorId : TableEntity
{
    public JugadorPorId() { 
    }

    public JugadorPorId(string pais, string nombre, string id)
    {
        PartitionKey = pais;
        RowKey = id;
        Nombre = nombre;
    }

    public string Pais { get { return PartitionKey; } }
    public string Nombre { get; set; }
    public string Id { get { return RowKey; } }
}

El caso de las partidas es más complicado, pues los jugadores vienen de otra tabla y no podemos guardar en las tablas árboles de objetos, tienen que ser objetos bastante planos. En nuestro caso vamos a modificar un poco la forma en que se guardan esas propiedades en los métodos WriteEntity y ReadEntity

public class Partida:TableEntity
{
    public const int Dimension = 3;
    public Partida()
    {
    }

    public Partida(string pais, string id)
    {
        PartitionKey = pais;
        RowKey = id;
        _tablero = new char[Dimension * Dimension];
        for (int i = 0; i < _tablero.Length; i++)
        {
            _tablero[i] = ' ';
        }
    }

    public string Pais { get { return PartitionKey; } }
    public string Id { get { return RowKey; } }
    public Jugador Jugador1 { get; set; }
    public Jugador Jugador2 { get; set; }
    public int Turno { get; set; }

    public override IDictionary<string, EntityProperty> WriteEntity(OperationContext operationContext)
    {
        var context= base.WriteEntity(operationContext);
        context.Remove("Jugador1");
        context.Add("Jugador1Nombre", EntityProperty.GeneratePropertyForString(Jugador1.Nombre));
        context.Add("Jugador1Id", EntityProperty.GeneratePropertyForString(Jugador1.Id));
        context.Remove("Jugador2");
        context.Add("Jugador2Nombre", EntityProperty.GeneratePropertyForString( Jugador2.Nombre));
        context.Add("Jugador2Id", EntityProperty.GeneratePropertyForString(Jugador2.Id));
        return context;
    }
    public override void ReadEntity(IDictionary<string, EntityProperty> properties, 
        OperationContext operationContext)
    {
        Jugador1 = new Jugador {
            PartitionKey= properties["Pais"].StringValue,
            RowKey = properties["Jugador1Nombre"].StringValue ,
            Id = properties["Jugador1Id"].StringValue
        };
        Jugador2 = new Jugador {
            PartitionKey= properties["Pais"].StringValue,
            RowKey = properties["Jugador2Nombre"].StringValue ,
            Id = properties["Jugador2Id"].StringValue
        };
        base.ReadEntity(properties, operationContext);
    }

(no pongo el resto de código pues es igual al código del capítulo anterior).

Creación de las tablas en Azure

Hemos creado las entidades por código y las tablas las vamos a crear igual. Modificaremos la clase DatosPartida que ya teníamos definida, para que al arrancar cree las tablas si es que no existen.

public class DatosPartida
{
    static CloudTable _solicitudes;
    static CloudTable _partidas;
    static CloudTable _jugadores;
    static CloudTable _jugadoresPorId;

    static DatosPartida()
    {
        // Retrieve the storage account from the connection string.
        CloudStorageAccount storageAccount = CloudStorageAccount.Parse(
            CloudConfigurationManager.GetSetting("StorageConnectionString"));

        // Create the table client.
        CloudTableClient tableClient = storageAccount.CreateCloudTableClient();

        // Create the table if it doesn't exist.
        _solicitudes = tableClient.GetTableReference("Solicitudes");
        _solicitudes.CreateIfNotExists();

        _partidas = tableClient.GetTableReference("Partidas");
        _partidas.CreateIfNotExists();

        _jugadores = tableClient.GetTableReference("Jugadores");
        _jugadores.CreateIfNotExists();

        _jugadoresPorId = tableClient.GetTableReference("JugadoresPorId");
        _jugadoresPorId.CreateIfNotExists();
    }

En el constructor estático estamos conectando al servicio de tablas mediante una cadena de conexión que he puesto en la configuración del rol. Para definirla hacemos doble click sobre el rol:
3er.12.webrole
Y en la sección Settings podremos definir nuestro valor de configuración. Por ahora utilizaremos el emulador local del storage.
3er.13.storage
A partir de este punto, modificamos los métodos de acceso que utilizábamos antes sobre listas para que accedan a las tablas. Como veréis he procurado no consultar las tablas sin una PartitionKey:

     public DatosPartida()
     {
     }

     public Jugador NuevoJugador(string pais, string nombre, string id)
     {
         var jugador = new Jugador(pais, nombre, id);
         TableOperation insertJugador = TableOperation.InsertOrReplace(jugador);
         _jugadores.Execute(insertJugador);

         TableOperation insertJugadorId = TableOperation.InsertOrReplace(new JugadorPorId(id, pais, nombre));
         _jugadoresPorId.Execute(insertJugadorId);
         return jugador;
     }

     public Jugador NuevaSolicitud(Jugador jugador)
     {
         var solicitud = new Jugador(jugador.Pais, jugador.Nombre, jugador.Id);
         TableOperation operation = TableOperation.InsertOrReplace(solicitud);
         _solicitudes.Execute(operation);
         return solicitud;
     }

     public Jugador ObtenerSolicitud(string pais, string nombre)
     {
         var op = TableOperation.Retrieve<Jugador>(pais, nombre);
         var result = _solicitudes.Execute(op);
         var solicitud = result.Result as Jugador;
         return solicitud;
     }

     public bool BorrarSolicitud(Jugador solicitud)
     {
         var op = TableOperation.Delete(solicitud);
         var result = _solicitudes.Execute(op);
         return result.HttpStatusCode == 204;
     }

     public Partida EmpezarPartida(string pais, Jugador jugador1, Jugador jugador2)
     {
         var partida = new Partida(pais, Guid.NewGuid().ToString())
         {
             Jugador1 = jugador1,
             Jugador2 = jugador2
         };
         var empiezaPartidaOp = TableOperation.InsertOrReplace(partida);
         _partidas.Execute(empiezaPartidaOp);
         return partida;
     }

     public Jugador ObtenerJugador(string pais, string id)
     {
         var op = TableOperation.Retrieve<JugadorPorId>(pais, id);
         var jugadorxid = _jugadoresPorId.Execute(op).Result as JugadorPorId;
         if (jugadorxid != null)
         {
             op = TableOperation.Retrieve<Jugador>(pais, jugadorxid.Nombre);
             return _jugadores.Execute(op).Result as Jugador;
         }
         return null;
     }

     public Partida ObtenerPartida(string pais, string id)
     {
         var op = TableOperation.Retrieve<Partida>(pais, id);
         return _partidas.Execute(op).Result as Partida;
     }

     public void GuardarMovimiento(Partida partida)
     {
         var replaceOp = TableOperation.Replace(partida);
         _partidas.Execute(replaceOp);
     }

     public IEnumerable<Jugador> ListaDisponibles(string pais)
     {
         var solicitudQuery = new TableQuery<Jugador>().Where(
            TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, pais));
         return _solicitudes.ExecuteQuery<Jugador>(solicitudQuery);
     }
 }

ServiceBus y SignalR

Si ejecutamos ahora la aplicación, parece que funciona™ pero tiene un gran fallo que podremos comprobar aumentando el número de instancias. Como vimos al principio del post, las diferentes instancias no se hablan entre sí, lo que provoca que si tenemos dos clientes de nuestra aplicación y cada uno está conectado a una instancia diferente, nuestra aplicación no funcionará, o sólo lo hará a medias.
Windows Azure tiene un mecanismo para resolver esto, el Service Bus, que nos permite crear suscripciones a la información agrupadas por «temas». De esta manera, cuando algo cambie en una instancia podemos avisar a todas las otras.
Por suerte, SignalR implementa esta funcionalidad con el ServiceBus, así que sólo tendremos que crear una cuenta de ServiceBus y conectarla a nuestra aplicación, SignalR se encargará de gestionar los canales.

El ServiceBus no tiene emulador. En la versión anterior podíamos instalarlo en local, pero todavía no han publicado la nueva, así que para poder utilizarlo, incluso en local, tendremos que crear uno en una cuenta de Azure. El coste del ServiceBus es muy pequeño (en la fecha de publicación del artículo €0,0075 al mes por cada 10.000 mensajes) así que no nos vamos a arruinar por hacer unas pruebas.
Si tenéis alguna cuenta MSDN os entrará dentro de los recursos gratuitos que tenéis. Si no es así, podéis crear una cuenta de evaluación gratuita durante un mes: http://www.windowsazure.com/es-es/pricing/free-trial/

En nuestro portal de Azure creamos un espacio de nombres para nuestra aplicación, yo he sido muy original y lo he llamado tresenraya:
3er.20.servbus
Recordad ponerlo en una región que esté cerca de vuestros usuarios, pues es muy conveniente que todos los servicios que vamos a usar estén en la misma región, evita tráfico innecesario.
Una vez esté activo abrimos la información de la conexión y la copiamos:

3er.23.signalrsbconect
Esta información de conexión la guardaremos otra vez en las propiedades del rol:

3er.21.servbusconn

Para poder utilizar el ServiceBus necesitaremos importar con nuget el paquete de Microsoft.AspNet.SignalR.ServiceBus:
3er.22.signalrsb

Una vez instalada la librería para el ServiceBus, sólo nos queda avisar a SignalR que debe utilizarlo, así en el Application_Start de Global.asax.cs indicaremos a SignalR qué debe hacer:

protected void Application_Start(object sender, EventArgs e)
{
    // Register the default hubs route: ~/signalr
    RouteTable.Routes.MapHubs();

    var sbConnectionString = CloudConfigurationManager.GetSetting("ServiceBusConnectionString");
    GlobalHost.DependencyResolver.UseServiceBus(sbConnectionString, "TresEnRaya");
}

Resumen

En este capítulo hemos modificado la aplicación de juego para que funcionara bien dentro de un entorno cloud. El mayor trabajo ha sido cambiar el sistema de almacenamiento, pues el Hub de SignalR no lo hemos tocado y sólo hemos tenido que añadir dos líneas de código para que SignalR funcione correctamente con múltiples instancias.
En el próximo artículo crearemos la aplicación cliente en Windows 8. Como ya hemos hecho lo difícil, os prometo que la app de Windows 8 será coser y cantar.

Descarga el código del ejemplo de Codeplex

Deja un comentario