ASP.NET Core: Creando un Chat con SignalR y Angular

isaacojeda

Isaac Ojeda

Posted on July 2, 2022

ASP.NET Core: Creando un Chat con SignalR y Angular

Introducción

En este post veremos cómo crear un chat con distintas salas utilizando ASP.NET SignalR y Angular.

El uso de SignalR tiene sus casos de uso en específico, pero su implementación suele ser muy sencilla y aun así tiene la posibilidad de escalar según como se requiera, apoyándose con otros servicios (como Azure).

Como siempre, te dejo el código fuente donde te recomiendo siempre que leas este artículo junto con el código fuente.

¿Qué es SignalR?

ASP.NET SignalR es una librería de para developers de ASP.NET que simplifica el proceso de agregar funcionalidad en tiempo real a una aplicación web.

¿Qué quiere decir? Significa que podemos agregar comunicación bidireccional entre nuestra aplicación web (C#) y los navegadores clientes (JavaScript).

SignalR puede ser usado para cualquier tipo de necesidad "real-time", y el ejemplo más común siempre será un Chat. Pero siempre que cuando un usuario necesite información actualizada y tenga que dar "click" en refresh, aquí SignalR sería una muy buena solución.

Yo he usado SignalR en proyectos IoT, donde se tiene un dashboard y es muy común estar actualizando la información con algún tipo de "polling". Esto lo hace ineficiente ya que será muy común preguntar por información nueva y se regresará la misma, por que el cliente no sabe cuándo se ha actualizado el backend, por lo que lo mejor es que el backend avise cuando hay información nueva (por eso comunicación bidireccional).

Con SignalR podemos hacer que desde C# podamos invocar eventos en el frontend (o sea, JavaScript) y también tenemos la posibilidad de hacer llamadas remotas. Es decir, que desde JavaScript podamos invocar una función en C#.

Lo mejor de todo es que SignalR automáticamente se hace cargo de los protocolos de comunicación y las conexiones, nosotros simplemente nos preocupamos en hacer nuestra aplicación y SignalR hará la mayoría.

SignalR hace todo esto por medio de Hubs, y los clientes se conectan a estos hubs para recibir eventos, ya sea provocados por otro cliente o por el mismo backend.

Para comenzar crearemos una API con un Hub para que nos ayude a distribuir mensajes de distintas salas de chat.

Proyecto API (el back-end)

Para crear un chat (sin persistencia) crearemos un proyecto en ASP.NET Core API (con visual studio o dotnet) y tendremos el clásico template con el WeatherForecastController (el cual, podemos borrar).

Hubs > ChatHub

Para comenzar, crearemos un Hub dentro de una carpeta Hubs llamado ChatHub.cs. Este será el concentrador que nos permitirá lanzar eventos con mensajes del chat que queremos hacer.



using Microsoft.AspNetCore.SignalR;

namespace Api.Hubs;

public class ChatHub : Hub
{
    public async Task JoinGroup(string groupName, string userName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);

        await Clients.Group(groupName).SendAsync("NewUser", $"{userName} entró al canal");
    }

    public async Task LeaveGroup(string groupName, string userName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
        await Clients.Group(groupName).SendAsync("LeftUser", $"{userName} salió del canal");
    }

    public async Task SendMessage(NewMessage message)
    {
        await Clients.Group(message.GroupName).SendAsync("NewMessage", message);
    }
}

public record NewMessage(string UserName, string Message, string GroupName);


Enter fullscreen mode Exit fullscreen mode

La idea de este chat es poder crear salas a las cuales un usuario se puede unir y cuando mandes un mensaje en una sala, solo les llegará el mensaje a aquellos usuarios que se metieron a la misma sala que tú. Super común esto en los chats de antes y es bien sencillo de hacer.

Explicación:

  • JoinGroup: La clase Hub hereda muchas propiedades, una de ellas es Group. Esta nos permite meter ConnectionId en un grupo para posteriormente poder lanzar eventos a esos grupos.
    • El Context.ConnectionId es el ID de la conexión que está haciendo la llamada, es decir, un cliente en Javascript. Estos en el front-end deberán de mandar a llamar el método remoto (RPC) JoinGroup para poder unirse a un grupo y empezar a recibir mensajes.
  • LeaveGroup: Este método pues al igual que el Join, queremos poder dejar salas por si queremos unirnos a otras.
  • SendMessage: SendMessage lo único que hace es mandar un mensaje al grupo en el que se está registrado.

Posteriormente, registramos el Hub para que este esté disponible al correr la aplicación:



// ... código omitido

app.UseAuthorization();
app.MapControllers();
app.MapHub<ChatHub>("/hubs/chat");

app.Run();



Enter fullscreen mode Exit fullscreen mode

Así ya queda registrado nuestro hub y los clientes pueden empezar a utilizarlo. Pero antes, tenemos un problema, tenemos pensado usar un cliente aparte (una aplicación de Angular) para que se conecte al Hub, pero este lo hará desde un Host distinto (y con javascript) por lo que tenemos que habilitar el CORS de la muerte.



// ...código omitido
app.UseCors(builder =>
{
    builder
        .AllowAnyHeader()
        .AllowAnyMethod()
        .AllowCredentials()
        .WithOrigins("https://localhost:7278", "https://localhost:44406");
});

app.UseAuthorization();

// código omitido...


Enter fullscreen mode Exit fullscreen mode

Los puertos que puse son los que se generaron al crear el proyecto de angular (cosa que haremos enseguida) así que tendrás que cambiarlos a los puertos que se te asignen automáticamente.

Proyecto Angular (el front-end)

Para este proyecto de angular utilizaremos también la plantilla de Visual Studio, que por default ya viene bien configurada (con navegación, bootstrap, etc).

chat.component

Con Angular CLI crearemos un componente llamado chat (tip: dentro de la carpeta ClientApp -> ng g c chat --skip-tests)

Necesitaremos también del paquete @microsoft/signalr para tener el cliente en javascript (dentro de ClientApp: npm install @microsoft/signalr --save.

Y queda de la siguiente forma:



import { Component, OnInit } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';

@Component({
  selector: 'app-chat',
  templateUrl: './chat.component.html',
  styleUrls: ['./chat.component.css']
})
export class ChatComponent implements OnInit {

  public userName = '';
  public groupName = '';
  public messageToSend = '';
  public joined = false;
  public conversation: NewMessage[] = [{
    message: 'Bienvenido',
    userName: 'Sistema'
  }];

  private connection: HubConnection;

  constructor() {
    this.connection = new HubConnectionBuilder()
      .withUrl('https://localhost:7048/hubs/chat')
      .build();

    this.connection.on("NewUser", message => this.newUser(message));
    this.connection.on("NewMessage", message => this.newMessage(message));
    this.connection.on("LeftUser", message => this.leftUser(message));
  }

  ngOnInit(): void {
    this.connection.start()
      .then(_ => {
        console.log('Connection Started');
      }).catch(error => {
        return console.error(error);
      });
  }

  public join() {
    this.connection.invoke('JoinGroup', this.groupName, this.userName)
      .then(_ => {
        this.joined = true;
      });
  }

  public sendMessage() {
    const newMessage: NewMessage = {
      message: this.messageToSend,
      userName: this.userName,
      groupName: this.groupName
    };

    this.connection.invoke('SendMessage', newMessage)
      .then(_ => this.messageToSend = '');
  }

  public leave() {
    this.connection.invoke('LeaveGroup', this.groupName, this.userName)
      .then(_ => this.joined = false);
  }

  private newUser(message: string) {
    console.log(message);
    this.conversation.push({
      userName: 'Sistema',
      message: message
    });
  }

  private newMessage(message: NewMessage) {
    console.log(message);
    this.conversation.push(message);
  }

  private leftUser(message: string) {
    console.log(message);
    this.conversation.push({
      userName: 'Sistema',
      message: message
    });
  }

}

interface NewMessage {
  userName: string;
  message: string;
  groupName?: string;
}



Enter fullscreen mode Exit fullscreen mode

Explicación:

  • constructor(): En el constructor inicializamos la conexión (pero no la empezamos) y asignamos los eventos que vamos a estar escuchando
  • ngOnInit(): Una vez que el componente se inicializó, comenzamos la conexión de SignalR
    • En este punto, al no estar en ningún grupo, no recibiremos ningún evento, pero técnicamente ya podríamos empezar a comunicarnos con el Hub
  • join(): Invocamos un método en el Hub para indicar nuestro nombre y el grupo al que queremos unirnos
  • sendMessage(): Al unirnos a un grupo, la UI se habilita para poder empezar a mandar mensajes a nuestro grupo, se hace de la misma forma, invocando un método remoto.
  • leave(): Simplemente indicamos que queremos dejar el grupo en el que anteriormente nos unimos.

Y la UI quedaría así:



<div *ngIf="!joined">
  <strong>Create a group</strong>
  <div class="form-group row mb-2">
    <label class="col-form-label col-md-3">Group name</label>
    <div class="col-md-9">
      <input type="text" class="form-control" name="groupName" [(ngModel)]="groupName" />
    </div>
  </div>
  <div class="form-group row mb-2">
    <label class="col-form-label col-md-3">User name</label>
    <div class="col-md-9">
      <input type="text" class="form-control" name="userName" [(ngModel)]="userName" />
    </div>
  </div>
  <div class="form-group row">
    <div class="col-md-9 offset-3">
      <button type="button" class="btn btn-primary" (click)="join()">
        Enter
      </button>
    </div>
  </div>
</div>

<div *ngIf="joined">
  <div id="chat">
    <div *ngFor="let message of conversation">
      <div><strong>{{message.userName}}:</strong> {{message.message}}</div>
    </div>
  </div>
  <input class="form-control mb-1" type="text" [(ngModel)]="messageToSend" name="messageToSend" />
  <button class="btn btn-primary" (click)="sendMessage()">Send</button>
  <button class="btn btn-secondary" (click)="leave()">Leave</button>
</div>


Enter fullscreen mode Exit fullscreen mode

Simplemente es un form dividido en dos partes: Para unirse a un canal y el chat, controlado con la propiedad joined y los ngIf* (digo, hay mejores formas, solo es un ejemplo).

El componente recién creado tenemos que agregarlo a las rutas (yo eliminé los otros componentes que la plantilla por default agregó)

Esto dentro de app.module.ts



  RouterModule.forRoot([
    { path: '', component: HomeComponent, pathMatch: 'full' },
    { path: 'chat', component: ChatComponent }
  ])


Enter fullscreen mode Exit fullscreen mode

Y en nav-menu actualicé la navegación:



  <ul class="navbar-nav flex-grow">
    <li class="nav-item" [routerLinkActive]="['link-active']" [routerLinkActiveOptions]="{ exact: true }">
      <a class="nav-link text-dark" [routerLink]="['/']">Home</a>
    </li>
    <li class="nav-item" [routerLinkActive]="['link-active']">
      <a class="nav-link text-dark" [routerLink]="['/chat']">Chat</a>
    </li>
  </ul>


Enter fullscreen mode Exit fullscreen mode

Y si lo corremos y abrimos varias ventanas, se verá así:
Image description

Conclusión

En ASP.NET Core es "nada" lo que se tiene que hacer para habilitar que una aplicación web ahora tenga "real-time".

Como se vio, solo se creó un Hub y básicamente ya teníamos un canal de comunicación en tiempo real con clientes JavaScript (y .NET si se quisiera).

SignalR ya tiene tiempo, funciona no solo con clientes JavaScript (lo he usado en clientes .NET) y permite escalar según se requiera con Azure, SQL Server o Redis.

Si tienes preguntas, siempre puedes encontrarme en @balunatic. Saludos.

Referencias

- Introduction to SignalR

💖 💪 🙅 🚩
isaacojeda
Isaac Ojeda

Posted on July 2, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related