[SIGNALR] Hubs

Siguiendo con la serie dedicada a SignalR, en esta ocasión, vamos a ver como funcionan los hubs de signalr. Estos ofrecen un nivel de abstracción mucho mayor que las conexiones persistentes en lo relativo a la red y los protocolos. Los hubs nos permiten hacer llamadas directas desde el cliente hacia el servidor y viceversa, usando un esquema RPC bidireccional.

Para que desde el cliente se pueda invocar directamente un método del servidor, el enfoque que sigue signalr es similar a cuando necesitamos usar un servicio web y Visual Studio nos genera el proxy. SignalR genera un proxy javascript, pero este .js no es almacenado en nuestro proyecto, sino que se genera dinámicamente durante la carga de la página. Para ello, simplemente debemos incluir un script que apunte a la url “/signalr/js”.

Nota: Existe la posibilidad de generar el proxy de forma manual e incluir el .js en nuestro proyecto.

Nota: la url “/signalr/js” es la predeterminada, pero se puede personalizar.

Nota: Hay que tener el cuenta que el proxy generado usa una nomenclatura basada en “camelCase”, por lo que si él método del servidor se llama “EnviarMensaje“, el proxy crea el método como “enviarMensaje”.

En la siguiente imagen, se puede ver una captura del proxy generado

signalr_hubs_proxy_automatico

Cuando es el servidor el que quiere invocar a un método del cliente, entonces usa un protocolo propio en el que empaqueta los datos necesarios (nombre del método, parámetros…), y los envía de vuelta al cliente (PUSH), el cual tendrá que desempaquetarlo e invocar al método javascript correspondiente (Este proceso se realiza de forma transparente).

Nota: en este caso, no hay validación sobre el nombre del método cliente, es decir, si nos equivocamos al escribir el nombre, el paquete llegará al cliente pero no ocurrirá nada.

La estructura del paquete que envía el servidor al cliente sigue el patrón:

{
 "C": "d-B,2|F,2|G,3|H,0",
 "M": [
        {
          "H": "HubChatBasico",
          "M": "ProcesarMensaje",
          "A": "Hola Mundo"
        }
      ]
}

No vamos a entrar en los detalles sobre el formato del paquete, baste decir que incluye el nombre del nombre del método (ProcesarMensaje) y los parámetros (es este caso un mensaje de texto).

A continuación vamos a implementar el mismo ejemplo que el que realizamos en el articulo dedicado a las conexiones persistentes (un chat básico para el envió de mensajes).

Comenzamos creando la clase de inicio para OWIN (startup.cs), en la que especificamos la ruta de nuestro servicio

using Microsoft.AspNet.SignalR;
using Owin;

namespace HubChatBasico
{
    public static class Startup
    {
        public static void Configuration(IAppBuilder app)
        {
            app.MapSignalR("/chatbasico", new HubConfiguration() { });
        }
    }
}

En los Hubs no es necesario especificar la url, ya que por defecto la url para todos los Hubs es “/signalr”.

Nota: En las conexiones persistentes si era necesario especificar la url y la clase asociada.

En el siguiente fragmento de código podemos ver el código para usar la configuración por defecto

using Microsoft.AspNet.SignalR;
using Owin;

namespace HubChatBasico
{
    public static class Startup
    {
        public static void Configuration(IAppBuilder app)
        {
            app.MapSignalR();
        }
    }
}

El siguiente paso es crear una clase que derive de “Hub”

using System.Threading.Tasks;
using Microsoft.AspNet.SignalR;

namespace HubChatBasico.Hubs
{

    public class ChatBasicoHub : Hub
    {

    }
}

Antes de continuar con la implementación, hay que tener en cuenta que el ciclo de vida es similar a MVC, es decir, se crea una instancia al invocar un método del hub y esta es liberada al finalizar el método. Este detalle es muy importante para no guardar información en miembros a nivel de instancia, ya su contenido se perderá al finalizar la petición.

Nota: cuando se detecta una nueva conexion o desconexión, también se crea una nueva instancia para que podamos introducir la lógica que necesitemos.
Nota: En las conexiones persistentes, la conexión se podía mantener activa durante todo el ciclo de vida (dependiendo el transporte usado, pe. websocket).

A continuación vamos a añadir un método para enviar un mensaje como un objeto.

using System.Threading.Tasks;
using Microsoft.AspNet.SignalR;

namespace HubChatBasico.Hubs
{

    public class ChatBasicoHub : Hub
    {
        async public Task EnviarMensaje(MensajeChat mensaje)
        {
            await Clients.All.ProcesarMensaje(new MensajeChat() { Tipo = "Mensaje", Texto = "[" + Context.ConnectionId + "]: " + mensaje.Texto});
        }
    }
}

El código anterior usa el método “Clients.All” para enviar una mensaje a todos los clientes conectados.

Hay que tener en cuenta que cualquier método publico que implementemos, podrá ser invocado desde el cliente. Por ejemplo, el código cliente javascript necesario para invocar el método “EnviarMensaje” del Hub es:

     var mensaje = {};
     mensaje.Tipo = "Mensaje";
     mensaje.Texto = $scope.textoEnviar;
     $.connection.chatBasicoHub.server.enviarMensaje(mensaje);

Como podemos observar el código es muy simple, pero esto se debe a que cuando referenciamos la url del hub, realmente se genera dinamicamente un proxy .js. Este se encarga de realizar invocaciones remotas al estilo RPC. Observa como el nombre del Hub y el nombre del método usan una nomenclatura camelCase.

Nota: La determinación del método a invocar y la asignación de valores a los parámetros, usa un mecanismo similar al “model binder” de MVC.

Al igual que ocurría con las conexiones persistentes, también podemos controlar las acciones a realizar cuando se detecten nuevas conexiones o desconexiones.

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

namespace HubChatBasico.Hubs
{
    public class MensajeChat
    {
        public string Tipo { get; set; }
        public string Texto { get; set; }
    }

    public class ChatBasicoHub : Hub
    {
        async public override Task OnConnected()
        {
            await Clients.Caller.ProcesarMensaje(new MensajeChat() { Tipo = "Mensaje", Texto = "Bienvenido " + Context.ConnectionId });
            await Clients.Others.ProcesarMensaje(new MensajeChat() { Tipo = "Mensaje", Texto = "Nueva conexion con Id=" + Context.ConnectionId });
        }
        async public override Task OnDisconnected(bool stopCalled)
        {
            await Clients.Caller.ProcesarMensaje(new MensajeChat() { Tipo = "Mensaje", Texto = "Adios " + Context.ConnectionId });
            await Clients.Others.ProcesarMensaje(new MensajeChat() { Tipo = "Mensaje", Texto = "Cliente desconectado con Id=" + Context.ConnectionId });
        }

        async public Task EnviarMensaje(MensajeChat mensaje)
        {
            await Clients.All.ProcesarMensaje(new MensajeChat() { Tipo = "Mensaje", Texto = "[" + Context.ConnectionId + "]: " + mensaje.Texto});
        }
    }
}

Cuando se conecte un nuevo cliente, las operaciones realizadas son:

  • Se usa el método “Clients.Caller”, para invocar el método “ProcesarMensaje” del cliente conectado.
  • Se usa el método “Clients.Others”, para invocar el método “ProcesarMensaje” de todos los clientes conectados excepto del que realiza la conexión.

Cuando se desconecta un cliente, las operaciones realizadas son:

  • Se usa el método “Clients.Caller”, para invocar el método “ProcesarMensaje” del cliente desconectado.
  • Se usa el método “Clients.Others”, para invocar el método “ProcesarMensaje” de todos los clientes conectados exceptuando al clientes que se desconecta.

Algunos de los principales métodos para invocar métodos en el cliente (desde el servidor) son:

  • Clients.AllExcept(connectionId1,connectionId1,…).NombreMetodoCliente(Parametros): Invoca el método en todos los clientes exceptuado aquellos cuyo id sea pasado como parámetro.
  • Clients.Client(connectionId).NombreMetodoCliente(Parametros): invoca el método del cliente cuyo id de conexión sea pasado como parámetro
  • Clients.Clients(connectionId1,connectionId2m…).NombreMetodoCliente(Parametros): invoca el método en los clientes cuyo ids de conexión sean pasado como parámetros
  • Clients.Group(nombreGrupo,connectionIdsExcluir).NombreMetodoCliente(Parametros): invoca el método de aquellos clientes que pertenezcan a un grupo. De forma opcional se pueden pasar parámetros adicionales para excluir id de conexion.

En el siguiente enlace se puede obtener más detalles sobre los diferentes métodos del hub.

En muchos casos nos puede interesar guardar información de estado en formato clave-valor. Por ejemplo, para guardar el nombre de usuario

La parte cliente

   $.connection.chatBasicoHub.server.state.NombreUsuario="ProgrammingApps";
   var mensaje = {};
   mensaje.Tipo = "Mensaje";
   mensaje.Texto = $scope.textoEnviar;
   $.connection.chatBasicoHub.server.enviarMensaje(mensaje);

usamos la propiedad “state” del proxy javascript.

Para recuperar los datos de estado en el servidor usamos el método “Clients.Caller.NOMBRE_PROPIEDAD”. Por ejemplo, para recuperar el valor de la variable “NombreUsuario”, debemos usar “Clients.Caller.NombreUsuario”.

        async public Task EnviarMensaje(MensajeChat mensaje)
        {
            await Clients.All.ProcesarMensaje(new MensajeChat() { Tipo = "Mensaje", Texto = "[" + Clients.Caller.NombreUsuario + "]: " + mensaje.Texto});
        }

Nota: para recuperar el valor de una variable de estado siempre debemos usar “Clients.Caller”.

El uso de las variables de estado puede ser muy interesante para reducir el número de parámetros comunes en los métodos (Usuario, Empresa, …), pero hay que tener en cuenta que la información de estado sera incluida en todas las comunicaciones. Por cuestiones de rendimiento no es conveniente abusar de las variables de estado.

A continuación vamos a implementar la parte cliente. SignalR ofrece la posibilidad de crear clientes basados en javascript, .net, Windows Phone… En este articulo vamos a ver la implementación para clientes de tipo javascript en aplicaciones web.

Antes de nada hay que añadir las referencias a los siguientes .js:

  • jQuery: se require la versión 1.6.4 o posterior. En nuestro ejemplo hemos usado la versión 1.10.2
  • SignalR: se requiere una versión 2.0 o posterior. En nuestro ejemplo hemos usado la versión 2.2.0.

Ambas librerias las hemos configurado usando 2 bundles de MVC.

            bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
                        "~/Scripts/jquery-{version}.js"));
            bundles.Add(new ScriptBundle("~/bundles/signalr").Include(
                      "~/Scripts/jquery.signalR-2.2.0.min.js"));

A continuación vamos a añadir la url necesaria para se genere el proxy del hub. Si no hemos cambiado la url por defecto (fichero de inicio owin app_start/startup.cs), debemos hacer referencia a:

<script src="/signalr/js"></script>

Nota: esta es la direccion comun para todos lo hubs.

Pero en nuestro ejemplo hemos usado la url “/chatbasico”, por lo que la url es:

<script src="/chatbasico/js"></script>

Nota: Observa como hemos añadido “/js” a la url del hub.

Cuando se realiza la primera petición a la url del proxy, se genera un script que contiene un proxy por cada hub implementado en el servidor y se almacena para sucesivas peticiones.

Nota: Existe la posibilidad de generar un proxy de forma manual, pero de momento nos vamos a centrar en el automático.

En este punto nuestro cliente, ya esta preparado para iniciar la conexión con el servidor.

<script>
    var app = angular.module("appHubChat", []);

    app.value('$', $);

    app.controller("ctrlHubChat", function ($scope, $http, $) {
        $scope.hub = null;
        $scope.conexion = null;

        $scope.iniciar = function () {
            $scope.hub = $.connection.chatBasicoHub;
            $scope.conexion = $.connection;

            $scope.conexion.logging = true;
            $scope.conexion.url = "/chatbasico";
            $scope.conexion.hub.start();
        };

        $scope.iniciar();
    });
</script>

En el código anterior hemos creado el método “iniciar”, el cual realiza las siguientes tareas:

  • En $.connection, se crea un objeto proxy por cada hub, usando su nombre en nomenclatura “camelCase”. En nuestro ejemplo, el proxy se genera como “$.connection.chatBasicoHub”, el cual hemos guardado en la variable “hub” del $scope.
  • El objeto $.connection lo hemos guardado en la variable “conexion” del $scope.
  • Sobre este objeto $.connection podemos configurar parámetros de comunicaciones tales como la url, activar/desactivar el logging, …
  • Por último, invocamos al método “$.connection.hub.start()”. Observa como el método “start” no ha sido invocado sobre un objeto proxy concreto, sino sobre la propiedad “$.connection.hub”. Esto es porque este método inicia el proceso de negociación para determinar que protocolo de transporte se puede usar en base a las características del cliente y del servidor.

Ahora vamos a implementar el método “procesarMensaje” en cliente, usando para ello la propiedad “client” del proxy.

            $scope.hub.client.procesarMensaje = function (mensaje) {
                $scope.$apply(function () {
                    $scope.mensajes.push(mensaje);
                });
            };

El código es muy simple, pues se limita a añadir un mensaje a un array.

Nota: este método podrá ser invocado desde el servidor usando por ejemplo “Clients.All.ProcesarMensaje“.

Por último vamos a incluir una función para invocar el método “enviarMensaje” del servidor (hub).

        $scope.enviar = function () {
            var mensaje = {};
            mensaje.Tipo = "Mensaje";
            mensaje.Texto = $scope.textoEnviar;
            $scope.hub.server.enviarMensaje(mensaje);
        }

En esta ocasión hemos usado la propiedad “server” del proxy.

El código completo de nuestro cliente javascript lo podemos ver en el siguiente fragmento de codigo

@{
    ViewBag.Title = "Index";
}
<div ng-app="appHubChat" ng-controller="ctrlHubChat">
    <div class="panel panel-primary">
        <div class="panel-heading">
            <h3 class="panel-title">Signalr Hubs - Mensajeria</h3>
        </div>
        <div class="panel-body">
            <div class="form-group">
                <label for="txtMensaje">Mensaje</label>
                <input type="text" class="form-control" name="txtMensaje" ng-model="textoEnviar" />
            </div>
            <div class="btn-group">
                <input type="button" name="btnEnviar" class="btn btn-primary" value="Enviar" ng-click="enviar()" />
            </div>
            <table class="table table-striped">
                <thead>
                    <tr>
                        <th>Mensaje</th>
                    </tr>
                </thead>
                <tbody>
                    <tr ng-repeat="msgObj in mensajes">
                        <td>{{msgObj.Texto}}</td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
</div>
<script src="/chatbasico/js"></script>
<script>
    var app = angular.module("appHubChat", []);

    app.value('$', $);

    app.controller("ctrlHubChat", function ($scope, $http, $) {
        $scope.mensajes = [];
        $scope.hub = null;
        $scope.conexion = null;
        $scope.textoEnviar = "";

        $scope.iniciar = function () {
            $scope.hub = $.connection.chatBasicoHub;
            $scope.conexion = $.connection;

            $scope.conexion.logging = true;
            $scope.conexion.url = "/chatbasico";
            $scope.conexion.hub.start();

            $scope.hub.client.procesarMensaje = function (mensaje) {
                $scope.$apply(function () {
                    $scope.mensajes.push(mensaje);
                });
            };
        };

        $scope.enviar = function () {
            var mensaje = {};
            mensaje.Tipo = "Mensaje";
            mensaje.Texto = $scope.textoEnviar;
            $scope.hub.server.enviarMensaje(mensaje);
        }

        $scope.iniciar();
    });

</script>

El funcionamiento es muy similar al ejemplo dedicado a las conexiones persistentes.

En la siguiente imagen se puede ver un ejemplo de uso

chatBasicoHub

En el siguiente enlace se puede descargar el proyecto de ejemplo usando en este articulo.