Trabajar con grupos en SignalR 1.x

por Patrick Fletcher, Tom FitzMacken

Advertencia

Esta documentación no se aplica a la última versión de SignalR. Eche un vistazo a SignalR de ASP.NET Core.

En este tema se describe cómo agregar usuarios a grupos y conservar la información de pertenencia a grupos.

Información general

Los grupos de SignalR proporcionan un método para difundir mensajes a subconjuntos especificados de clientes conectados. Un grupo puede tener cualquier número de clientes y un cliente puede ser miembro de cualquier número de grupos. No es necesario crear grupos explícitamente. En efecto, un grupo se crea automáticamente la primera vez que especifica su nombre en una llamada a Groups.Add y se elimina cuando se quita la última conexión de la pertenencia a ella. Para obtener una introducción al uso de grupos, consulte Administración de la pertenencia a grupos desde la clase Hub en la Guía del servidor de la API Hubs.

No hay ninguna API para obtener una lista de pertenencia a grupos o una lista de grupos. SignalR envía mensajes a clientes y grupos en función de una modelo pub/suby el servidor no mantiene listas de grupos o pertenencias a grupos. Esto ayuda a maximizar la escalabilidad, ya que cada vez que se agrega un nodo a una granja de servidores web, cualquier estado que SignalR mantiene debe propagarse al nuevo nodo.

Al agregar un usuario a un grupo mediante el método Groups.Add, el usuario recibe mensajes dirigidos a ese grupo durante la conexión actual, pero la pertenencia del usuario a ese grupo no se conserva más allá de la conexión actual. Si desea conservar permanentemente información sobre grupos y pertenencia a grupos, debe almacenar esos datos en un repositorio, como una base de datos o un almacenamiento de tablas de Azure. A continuación, cada vez que un usuario se conecta a la aplicación, se recupera del repositorio al que pertenece el usuario y se agrega manualmente ese usuario a esos grupos.

Al volver a conectarse después de una interrupción temporal, el usuario vuelve a unirse automáticamente a los grupos asignados previamente. Volver a unir automáticamente un grupo solo se aplica al volver a conectarse, no al establecer una nueva conexión. Se pasa un token firmado digitalmente desde el cliente que contiene la lista de grupos asignados previamente. Si desea comprobar si el usuario pertenece a los grupos solicitados, puede invalidar el comportamiento predeterminado.

Este tema incluye las siguientes secciones:

Adición y supresión de usuarios

Para agregar o quitar usuarios de un grupo, llame a los métodos Add o Remove y pase el identificador de conexión y el nombre del grupo del usuario como parámetros. No es necesario quitar manualmente un usuario de un grupo cuando finaliza la conexión.

En el ejemplo siguiente se muestran los métodos Groups.Add y Groups.Remove que se usan en los métodos Hub.

public class ContosoChatHub : Hub
{
    public Task JoinRoom(string roomName)
    {
        return Groups.Add(Context.ConnectionId, roomName);
    }

    public Task LeaveRoom(string roomName)
    {
        return Groups.Remove(Context.ConnectionId, roomName);
    }
}

Los métodos Groups.Add y Groups.Remove se ejecutan de forma asincrónica.

Si desea agregar un cliente a un grupo y enviar inmediatamente un mensaje al cliente mediante el grupo, debe asegurarse de que el método Groups.Add finaliza primero. En los ejemplos de código siguientes se muestra cómo hacerlo, uno mediante código que funciona en .NET 4.5 y otro mediante código que funciona en .NET 4.

Ejemplo asincrónico de .NET 4.5

public async Task JoinRoom(string roomName)
{
    await Groups.Add(Context.ConnectionId, roomName);
    Clients.Group(roomName).addChatMessage(Context.User.Identity.Name + " joined.");
}

Ejemplo asincrónico de .NET 4

public void JoinRoom(string roomName)
{
    (Groups.Add(Context.ConnectionId, roomName) as Task).ContinueWith(antecedent =>
      Clients.Group(roomName).addChatMessage(Context.User.Identity.Name + " joined."));
}

En general, no debe incluir await al llamar al método Groups.Remove porque es posible que el identificador de conexión que intenta quitar ya no esté disponible. En ese caso, TaskCanceledException se produce después de que se agote el tiempo de espera de la solicitud. Si la aplicación debe asegurarse de que el usuario se ha quitado del grupo antes de enviar un mensaje al grupo, puede agregar await antes de Groups.Remove y, a continuación, detectar la excepción TaskCanceledException que podría producirse.

Llamada a miembros de un grupo

Puede enviar mensajes a todos los miembros de un grupo o solo a miembros especificados del grupo, como se muestra en los ejemplos siguientes.

  • Todos los clientes conectados de un grupo especificado.

    Clients.Group(groupName).addChatMessage(name, message);
    
  • Todos los clientes conectados de un grupo especificado excepto los clientes especificados, identificados por el identificador de conexión.

    Clients.Group(groupName, connectionId1, connectionId2).addChatMessage(name, message);
    
  • Todos los clientes conectados de un grupo especificado excepto el cliente que realiza la llamada.

    Clients.OthersInGroup(groupName).addChatMessage(name, message);
    

Almacenamiento de la pertenencia a grupos en una base de datos

Los siguientes ejemplos muestran cómo conservar la información de grupo y de usuario en una base de datos. Puede usar cualquier tecnología de acceso a datos; sin embargo, el ejemplo siguiente muestra cómo definir modelos usando Entity Framework. Estos modelos de entidad corresponden a tablas y campos de la base de datos. Su estructura de datos podría variar considerablemente en función de los requisitos de su aplicación. En este ejemplo se incluye una clase denominada ConversationRoom que sería única para una aplicación que permite a los usuarios unirse a conversaciones sobre diferentes temas, como deportes o jardinería. En este ejemplo también se incluye una clase para las conexiones. La clase de conexión no es absolutamente necesaria para realizar el seguimiento de la pertenencia a grupos, pero suele formar parte de una solución sólida para realizar el seguimiento de los usuarios.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data.Entity;

namespace GroupsExample
{
    public class UserContext : DbContext
    {
        public DbSet<User> Users { get; set; }
        public DbSet<Connection> Connections { get; set; }
        public DbSet<ConversationRoom> Rooms { get; set; }
    }

    public class User
    {
        [Key]
        public string UserName { get; set; }
        public ICollection<Connection> Connections { get; set; }
        public virtual ICollection<ConversationRoom> Rooms { get; set; } 
    }

    public class Connection
    {
        public string ConnectionID { get; set; }
        public string UserAgent { get; set; }
        public bool Connected { get; set; }
    }

    public class ConversationRoom
    {
        [Key]
        public string RoomName { get; set; }
        public virtual ICollection<User> Users { get; set; }
    }
}

A continuación, en el centro de conectividad, puede recuperar la información de grupo y usuario de la base de datos y agregar manualmente el usuario a los grupos adecuados. El ejemplo no incluye código para realizar el seguimiento de las conexiones de usuario. En este ejemplo, la palabra clave await no se aplica antes de Groups.Add porque un mensaje no se envía inmediatamente a los miembros del grupo. Si desea enviar un mensaje a todos los miembros del grupo inmediatamente después de agregar el nuevo miembro, querrá aplicar la palabra clave await para asegurarse de que se ha completado la operación asincrónica.

using Microsoft.AspNet.SignalR;
using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;

namespace GroupsExample
{
    [Authorize]
    public class ChatHub : Hub
    {
        public override Task OnConnected()
        {
            using (var db = new UserContext())
            {
                // Retrieve user.
                var user = db.Users
                    .Include(u => u.Rooms)
                    .SingleOrDefault(u => u.UserName == Context.User.Identity.Name);

                // If user does not exist in database, must add.
                if (user == null)
                {
                    user = new User()
                    {
                        UserName = Context.User.Identity.Name
                    };
                    db.Users.Add(user);
                    db.SaveChanges();
                }
                else
                {
                    // Add to each assigned group.
                    foreach (var item in user.Rooms)
                    {
                        Groups.Add(Context.ConnectionId, item.RoomName);
                    }
                }
            }
            return base.OnConnected();
        }

        public void AddToRoom(string roomName)
        {
            using (var db = new UserContext())
            {
                // Retrieve room.
                var room = db.Rooms.Find(roomName);

                if (room != null)
                {
                    var user = new User() { UserName = Context.User.Identity.Name};
                    db.Users.Attach(user);

                    room.Users.Add(user);
                    db.SaveChanges();
                    Groups.Add(Context.ConnectionId, roomName);
                }
            }
        }

        public void RemoveFromRoom(string roomName)
        {
            using (var db = new UserContext())
            {
                // Retrieve room.
                var room = db.Rooms.Find(roomName);
                if (room != null)
                {
                    var user = new User() { UserName = Context.User.Identity.Name };
                    db.Users.Attach(user);

                    room.Users.Remove(user);
                    db.SaveChanges();
                    
                    Groups.Remove(Context.ConnectionId, roomName);
                }
            }
        }
    }
}

Almacenamiento de la pertenencia a grupos en Azure Table Storage

El uso de Azure Table Storage para almacenar información de grupo y usuario es similar al uso de una base de datos. En el ejemplo siguiente se muestra una entidad de tabla que almacena el nombre de usuario y el nombre del grupo.

using Microsoft.WindowsAzure.Storage.Table;
using System;

namespace GroupsExample
{
    public class UserGroupEntity : TableEntity
    {
        public UserGroupEntity() { }

        public UserGroupEntity(string userName, string groupName)
        {
            this.PartitionKey = userName;
            this.RowKey = groupName;
        }
    }
}

En el centro de conectividad, se recuperan los grupos asignados cuando el usuario se conecta.

using Microsoft.AspNet.SignalR;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.WindowsAzure.Storage.Table;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure;

namespace GroupsExample
{
    [Authorize]
    public class ChatHub : Hub
    {
        public override Task OnConnected()
        {
            string userName = Context.User.Identity.Name;

            var table = GetRoomTable();
            table.CreateIfNotExists();
            var query = new TableQuery<UserGroupEntity>()
                .Where(TableQuery.GenerateFilterCondition(
                "PartitionKey", QueryComparisons.Equal, userName));
            
            foreach (var entity in table.ExecuteQuery(query))
            {
                Groups.Add(Context.ConnectionId, entity.RowKey);
            }

            return base.OnConnected();
        }

        public Task AddToRoom(string roomName)
        {
            string userName = Context.User.Identity.Name;

            var table = GetRoomTable();

            var insertOperation = TableOperation.InsertOrReplace(
                new UserGroupEntity(userName, roomName));
            table.Execute(insertOperation);

            return Groups.Add(Context.ConnectionId, roomName);
        }

        public Task RemoveFromRoom(string roomName)
        {
            string userName = Context.User.Identity.Name;

            var table = GetRoomTable();

            var retrieveOperation = TableOperation.Retrieve<UserGroupEntity>(
                userName, roomName);
            var retrievedResult = table.Execute(retrieveOperation);

            var deleteEntity = (UserGroupEntity)retrievedResult.Result;

            if (deleteEntity != null)
            {
                var deleteOperation = TableOperation.Delete(deleteEntity);
                table.Execute(deleteOperation);
            }

            return Groups.Remove(Context.ConnectionId, roomName);
        }

       private CloudTable GetRoomTable()
        {
            var storageAccount =
                CloudStorageAccount.Parse(
                CloudConfigurationManager.GetSetting("StorageConnectionString"));
            var tableClient = storageAccount.CreateCloudTableClient();
            return tableClient.GetTableReference("room");
        }
    }
}

Comprobación de la pertenencia a grupos al volver a conectarse

De manera predeterminada, la aplicación SignalR reasignará automáticamente a un usuario a los grupos adecuados cuando se vuelva a conectar tras una interrupción temporal, como cuando se pierde una conexión y se vuelve a establecer antes de que se agote el tiempo de conexión. La información del grupo del usuario se pasa en un token al volver a conectarse y ese token se comprueba en el servidor. Para obtener información sobre el proceso de comprobación para volver a unir usuarios a grupos, consulte Volver a unir grupos al volver a conectarse.

En general, debe usar el comportamiento predeterminado de volver a unir automáticamente grupos en la reconexión. Los grupos de SignalR no están diseñados como mecanismo de seguridad para restringir el acceso a datos confidenciales. Sin embargo, si la aplicación debe comprobar la pertenencia a grupos de un usuario al volver a conectarse, puede invalidar el comportamiento predeterminado. Cambiar el comportamiento predeterminado puede agregar una carga a la base de datos porque la pertenencia a grupos de un usuario debe recuperarse para cada reconexión en lugar de solo cuando el usuario se conecta.

Si debe comprobar la pertenencia a grupos en la reconexión, cree un nuevo módulo de canalización del centro de conectividad que devuelva una lista de grupos asignados, como se muestra a continuación.

using Microsoft.AspNet.SignalR;
using Microsoft.AspNet.SignalR.Hubs;
using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;

namespace GroupsExample
{
    public class RejoingGroupPipelineModule : HubPipelineModule
    {
        public override Func<HubDescriptor, IRequest, IList<string>, IList<string>> 
            BuildRejoiningGroups(Func<HubDescriptor, IRequest, IList<string>, IList<string>> 
            rejoiningGroups)
        {
            rejoiningGroups = (hb, r, l) => 
            {
                List<string> assignedRooms = new List<string>();
                using (var db = new UserContext())
                {
                    var user = db.Users.Include(u => u.Rooms)
                        .Single(u => u.UserName == r.User.Identity.Name);
                    foreach (var item in user.Rooms)
                    {
                        assignedRooms.Add(item.RoomName);
                    }
                }
                return assignedRooms;
            };

            return rejoiningGroups;
        }
    }
}

A continuación, agregue ese módulo a la canalización del centro de conectividad, como se resalta a continuación.

public class Global : HttpApplication
{
    void Application_Start(object sender, EventArgs e)
    {
        // Code that runs on application startup
        BundleConfig.RegisterBundles(BundleTable.Bundles);
        AuthConfig.RegisterOpenAuth();
        RouteConfig.RegisterRoutes(RouteTable.Routes);
        RouteTable.Routes.MapHubs();
        GlobalHost.HubPipeline.AddModule(new RejoingGroupPipelineModule());
    }
}