Playground: Asp.Net Core SignalR

William Santos - Jul 11 '20 - - Dev Community

Olá!

Este é um post da sessão Playground, uma iniciativa para demonstrar, com pequenos tutoriais, tecnologias e ferramentas que entendo ter potencial para trazer ganhos aos seus projetos.

Apresentando o Asp.Net Core SignalR

O SignalR é uma biblioteca para comunicação em tempo real criada pela Microsoft que passou a integrar o Asp.Net Core a partir da versão 2.1. Esta biblioteca permite a troca de mensagens tanto do cliente para o servidor como do servidor para o cliente, e a razão que considero principal para seu uso é, exatamente, a possibilidade de notificar clientes sobre eventos ocorridos no servidor -- que é o caso que veremos neste artigo.

Para utilizar o SignalR você vai precisar de:

  • Um editor ou IDE (ex.: VSCode);
  • npm: para obter as dependências do SignalR para Javascript.

Importante! É possível criar clientes não apenas com Javascript. É possível usar Java ou .NET, como nos mostra a documentação.

Começando a Aplicação

A aplicação será um pequeno painel de cotação para ações de empresas brasileiras. Uma pequena amostra de empresas foi pré-selecionada por simplicidade.

Vamos começar criando a infraestrutura da aplicação. Ela será uma Web API, então vamos a partir do template do .NET Core, e remover a pasta Controllers e o arquivo WheaterForecast.cs

PS X:\code\playground-signalr> dotnet new webapi -o Playground.SignalR.Stocks
Enter fullscreen mode Exit fullscreen mode

Criando o Modelo

O modelo de nossa aplicação será bastante simples. Terá uma representação de nossa cotação, e um gerador de preços para simular o recebimento de uma mensagem de atualização.

Para começar, vamos criar nosso modelo de cotação. Crie uma pasta chamada Models na raíz do projeto, e um arquivo chamado Quote.cs com o seguinte conteúdo:

using System;

namespace Playground.SignalR.Stocks.Models
{
    public struct Quote
    {
        public string Symbol { get; private set; }
        public decimal Price { get; private set; }
        public DateTime Time { get; private set; }

        public static Quote Create(string symbol) => 
            new Quote { Symbol = symbol };

        public void Update(decimal price)
        {
            Price = price;
            Time = DateTime.Now;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Em seguida, na mesma pasta Models crie o arquivo QuotePriceGenerator.cs, e adicione o seguinte conteúdo:

using System;

namespace Playground.SignalR.Stocks.Models
{
    public class QuotePriceGenerator
    {
        private const int MinimumPrice = 10;
        private const int MaximumPrice = 30;
        private const int PriceTreshold = 35;
        private readonly Random _random = new Random();

        public decimal Generate(decimal previousPrice)
        {
            var modifier = (decimal)_random.NextDouble();

            if(previousPrice == 0)
                return _random.Next(MinimumPrice, MaximumPrice) + modifier;

            var updatedPrice = previousPrice + ((modifier > 0.6m ? modifier : modifier * -1) / 100);

            if(updatedPrice > PriceTreshold)
                return MaximumPrice + modifier;

            if(updatedPrice < MinimumPrice)
                return MinimumPrice + modifier;

            return updatedPrice;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

O código acima gera um novo preço a partir do anterior, com uma variação positiva ou negativa a depender do resultado de uma randomização. Além disso, caso o preço fique acima de uma margem máxima, ou abaixo de uma margem mínima, é ajustado para não oscilar descontroladamente.

Hub: O Protagonista

O Hub é a principal implementação do SignalR, sendo a interface de comunicação entre o cliente o servidor. É nele que usualmente definimos os métodos pelos quais o servidor receberá mensagens, e por quais ele deve enviar.

Para criarmos o hub da nossa aplicação, vamos criar a pasta Hubs na raíz do projeto, e adicionar o arquivo QuoteHub.cs com o seguinte conteúdo:

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

namespace Playground.SignalR.Stocks.Hubs
{
    public class QuoteHub : Hub<IQuoteHub>
    {
        public async Task ChangeSubscription(string oldSymbol, string newSymbol)
        {
            if(!string.IsNullOrEmpty(oldSymbol))
                await Groups.RemoveFromGroupAsync(Context.ConnectionId, oldSymbol);

            await Groups.AddToGroupAsync(Context.ConnectionId, newSymbol);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Dentro do Hub existem outras estruturas que permitem gerenciar a distribuição das mensagens. Uma delas é o Grupo. Grupos são como dicionários, possuem um nome e podem ter adicionado ou removido o ID da conexão com o SignalR, ID este que é semelhante ao SessionId a que estamos acostumados no Asp.Net Core. Quando adicionamos uma conexão a um grupo, qualquer mensagem enviada a este alcançará essa conexão.

No método ChangeSubscription vemos que o código de negociação da ação newSymbol servirá como nome de grupo. Ou seja, todos os clientes que tiverem interesse em receber a atualização da cotação desta ação serão notificados quando ela for atualizada.

Nota: Este método foi definido com dois códigos, oldSymbol e newSymbol, pois o front-end contará com um campo do tipo select para promover a troca da cotação acompanhada.

Repare que na declaração da classe, QuoteHub herda de Hub com a interface IQuoteHub como tipo genérico. Herdar de Hub é semelhante a herdar de ControllerBase em uma Web API. E esta interface adicionada tem uma função bem específica: permitir que os métodos nela especificados para envio de mensagens sejam implementados automaticamente pelo SignalR. É isso mesmo! Nada de implementação manual. É trabalho poupado e tempo ganho!

E aqui temos o código dela. Ainda na pasta Hubs, vamos criar o arquivo IQuoteHub.cs e adicionar o seguinte conteúdo:

using System.Threading.Tasks;
using Playground.SignalR.Stocks.Models;

namespace Playground.SignalR.Stocks.Hubs
{
    public interface IQuoteHub
    {
        Task SendQuote(Quote quote);
    }
}
Enter fullscreen mode Exit fullscreen mode

Agora que temos o Hub para receber as solicitações de acompanhamento de cotações, com uma interface que define o método de envio, vamos criar o processo que atualizará as cotações disponíveis.

Atualizando as Cotações

Para atualizarmos as cotações, vamos utilizar um Background Service do Asp.Net Core. Para isso, na raíz do projeto, vamos criar a pasta Workers, e adicionar o arquivo QuoteWorker.cs com o seguinte conteúdo:

using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using Playground.SignalR.Stocks.Hubs;
using Playground.SignalR.Stocks.Models;

namespace Playground.SignalR.Stocks.Workers
{
    public class QuoteWorker : BackgroundService
    {
        private readonly Quote[] _quotes = { Quote.Create("PETR4"), 
                                            Quote.Create("VALE3"), 
                                            Quote.Create("ITUB4"), 
                                            Quote.Create("BBDC4"), 
                                            Quote.Create("BBAS3") };
        private readonly IHubContext<QuoteHub, IQuoteHub> _hub;
        private readonly QuotePriceGenerator _priceGenerator;

        public QuoteWorker(IHubContext<QuoteHub, IQuoteHub> hub, QuotePriceGenerator priceGenerator)
        {
            _hub = hub;
            _priceGenerator = priceGenerator;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while(!stoppingToken.IsCancellationRequested)
            {
                foreach(Quote quote in _quotes)
                {
                    quote.Update(_priceGenerator.Generate(quote.Price));

                    await _hub.Clients.Group(quote.Symbol).SendQuote(quote);
                }

                await Task.Delay(1000, stoppingToken);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Aqui nós temos todos os nossos componentes em ação. Este BackgroundService vai se encarregar de, a cada segundo, atualizar o preço das cotações previamente cadastradas e enviá-las aos clientes que pertençam ao grupo destas cotações.

Nota: Aqui temos mais um detalhe interessante. Repare que no construtor de QuoteWorker é recebido como argumento uma instância do tipo IHubContext<QuoteHub, IQuoteHub>. Essa instância é quem dá acesso ao nosso Hub, e é deste modo que se torna possível enviar mensagens a partir de outras classes além dele. E, como definimos nosso método de envio de mensagens na interface IQuoteHub, ela é também usada como tipo genérico.

Com o back-end quase pronto, é hora de atacar o front!

O Front-end

Para o front-end, vamos usar uma Razor Page que conterá o painel onde a cotação será exibida. Na raíz do projeto, adicione a pasta Pages, e em seguida crie um arquivo chamado Index.cshtml com o seguinte conteúdo:

@page

<div>
    <div>
        <select id="selectSymbols">
            <option value="">Selecione um ativo</option>
            <option value="PETR4">PETR4</option>
            <option value="VALE3">VALE3</option>
            <option value="ITUB4">ITUB4</option>
            <option value="BBDC4">BBDC4</option>
            <option value="BBAS3">BBAS3</option>
        </select>
    </div>

    <div style="margin-top:20px;">
        <div>
            Cotação para: <span id="spanSymbol"></span>
        </div>
        <div>
            Ultimo Preço: <span id="spanPrice"></span>
        </div>
        <div>
            Última atualização: <span id="spanTime"></span>
        </div>
    </div>

    <div style="margin-top:20px;">
        <div>
            <span id="spanError"></span>
        </div>
    </div>
</div>

<script src="~/js/libs/signalr.min.js"></script>
<script src="~/js/libs/msgpack5.min.js"></script>
<script src="~/js/libs/signalr-protocol-msgpack.min.js"></script>
<script src="~/js/quotes.js"></script>
Enter fullscreen mode Exit fullscreen mode

Aqui nós temos um campo para selecionar a cotação que vamos acompanhar, um painel para exibí-la, e um campo para informação de eventuais erros. Além disso, temos alguns scripts com as dependências do SignalR e nossa lógica para realizar a comunicação com o servidor.

Repare que, entre essas dependências, além do cliente do SignalR temos mais duas bibliotecas: msgpack5 e signalr-protocol-msgpack. Essas bibliotecas server para instruir o cliente SignalR a utilizar o protocolo MessagePack, que é um protocolo binário, para serializar os dados para a troca de mensagens. Ou seja, além de podermos trocar mensagens com o servidor, podemos melhorar o desempenho desta troca utilizando um formato mais leve!

É claro que, para tornar isto possível, o servidor também precisa saber que este formato será utilizado. Mas isso será visto à frente, quando chegarmos aos toques finais da aplicação.

Para inserirmos essas dependências no projeto, precisamos executar os seguintes comandos do npm na CLI:

PS X:\code\playground-signalr> npm init -y
PS X:\code\playground-signalr> npm install @microsoft/signalr-protocol-msgpack
Enter fullscreen mode Exit fullscreen mode

Estes comandos vão criar, acima da raíz do projeto, a pasta node-modules de onde extrairemos o que precisamos.

Para continuarmos, vamos utilizar a hospedagem de arquivos estáticos do Asp.Net Core.

Na pasta raiz do projeto, crie a pasta wwwroot\js\libs, e cole o arquivo signalr.min.js que está na pasta node_modules\@microsoft\signalr\dist\browser.
Em seguida, cole o arquivo signalr-protocol-msgpack.min.js que está na pasta node_modules\@microsoft\signalr-protocol-msgpack\dist\browser.
E, por fim, o arquivo msgpack5.min.js que está na pasta node_modules\msgpack5\dist.

Para fecharmos nosso front-end, vamos criar o arquivo quotes.js em wwwroot\js com o seguinte conteúdo:

"use strict";

(function() 
{
    var quoteConn = new signalR.HubConnectionBuilder()
                               .withUrl("/quoteHub")
                               .withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
                               .build();
    quoteConn.serverTimeoutInMilliseconds = 30000;

    var selectSymbols = document.querySelector('#selectSymbols');

    var currentSymbol = '';
    selectSymbols.disabled = true;
    selectSymbols.addEventListener("focus", function(event) 
                 {
                    currentSymbol = event.target.value;
                 });
    selectSymbols.addEventListener("change", function(event) 
                 {
                    quoteConn.invoke("ChangeSubscription", currentSymbol, event.target.value)
                             .catch(function(error) 
                             {
                                console.error(error.toString());
                                spanError.innerHTML = 'Falha ao registrar seu pedido de atualização de cotações';
                             });

                    currentSymbol = selectSymbols.value;
                });

    var spanSymbol = document.querySelector('#spanSymbol');
    var spanTime = document.querySelector('#spanTime');
    var spanPrice = document.querySelector('#spanPrice');
    var spanError = document.querySelector('#spanError');

    quoteConn.on("SendQuote", function (quote) 
    {
        spanSymbol.innerHTML = quote.Symbol;
        spanPrice.innerHTML = parseFloat(quote.Price).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', mininumFractionDigits: 2, maximumFractionDigits: 2 });
        spanTime.innerHTML = quote.Time.toLocaleTimeString('pt-BR');
    });

    quoteConn.start()
             .then(function ()
              {
                selectSymbols.disabled = false;
              })
              .catch(function (error) 
              {
                spanError.innerHTML = 'Falha ao iniciar conexão com o servidor. Aperte F5.';
              });

    quoteConn.onclose(function(error)
    {
      spanError.innerHTML = 'Conexão com o servidor perdida. Aperte F5.';
    });

})();
Enter fullscreen mode Exit fullscreen mode

Neste código instanciamos nossa conexão com o Hub informando o caminho /quoteHub como endpoint, e habilitando nosso select tão logo a conexão seja estabelecida. Ao mesmo tempo, adicionamos eventos ao nosso select para invocar o método ChangeSubscription no servidor para escolher a cotação que acompanharemos. E, também, criamos um manipulador de evento para as mensagens recebidas pelo método SendQuote de IQuoteHub para podermos exibir nossa cotação na tela.

Toques Finais

Agora precisamos apenas informar à nossa aplicação quais recursos do Asp.Net Core vamos utilizar. No arquivo do projeto, vamos adicionar a biblioteca do MessagePack, para usarmos este formato no servidor:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="3.1.0" />
  </ItemGroup>

</Project>
Enter fullscreen mode Exit fullscreen mode

Agora, no arquivo Startup.cs vamos adicionar os recursos que desejamos utilizar. Para simplificar, basta colar o seguinte conteúdo:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Playground.SignalR.Stocks.Hubs;
using Playground.SignalR.Stocks.Models;
using Playground.SignalR.Stocks.Workers;

namespace Playground.SignalR.Stocks
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<QuoteHub>()
                    .AddSingleton<QuotePriceGenerator>()
                    .AddHostedService<QuoteWorker>()
                    .AddSignalR()
                    .AddMessagePackProtocol();

            services.AddRazorPages();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapHub<QuoteHub>("/quoteHub");

                endpoints.MapRazorPages();
            });
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

No código acima informamos ao Asp.Net Core que utilizaremos nosso Hub, para que ele possa ser injetado via IHubContext em nosso Background Service, o atualizador de cotações pelo mesmo motivo, bem como o próprio Background Service (QuoteWorker). Também adicionamos o suporte ao SignalR, ao MessagePack, e às Razor Pages.

Também informamos, em Configure, que usaremos arquivos estáticos e que devemos mapear nosso Hub para o endereço /quoteHub que é o endpoint presente em nosso cliente Javascript.

It's Alive!

Se tudo correu bem, devemos ter o seguinte resultado em nossa tela:

Alt Text

Importante! É possível que, ao carregar sua aplicação, seja apresentada uma mensagem de conexão insegura. Caso isso aconteça, não se preocupe. Apenas execute os seguintes comandos na CLI para gerar novos certificados:

dotnet dev-certs https --clean
dotnet dev-certs https --trust

E assim temos nossa primeira aplicação comunicando em tempo real com seus clientes, e utilizando um protocolo que torna essa comunicação mais leve!

Para ver um exemplo funcional, segue uma versão hospedada no Azure App Service.

E para ter acesso ao código-fonte da aplicação, acesse meu GitHub.

Feedkback

Seu feedback é muito importante para que eu conheça meus acertos, erros, e como posso melhorar de modo geral. Se tiver gostado do artigo, me deixe saber pelos indicadores e mande um comentário se restar alguma dúvida. Respondo assim que puder!

Até a próxima!

Referências:

Introdução ao SignalR

Asp.Net Core SignalR Javascript client

Usar o protocolo Hub MessagePack no SignalR para ASP.NET Core

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .