Introdução

Procurando uma forma de enviar dados para usuários usando Spring WebSocket e controlando quando enviar à todos ou quando enviar a apenas um usuário em específico, me deparei com muitos exemplos usando Spring Security. Spring Security não é algo ruim, completamente o opost, é algo muito útil e necessária em qualquer aplicação, but Spring exige um esforço que neste tipo de tarefa que estou fazendo é desnecessário já que Spring força o usuário a logar para poder gerenciar cada usuário mais facilmente para você. Anos atrás eu fiz a mesma coisa(Uma aplicação usando WebSocket) em uma monografia na faculdade usandoPlay framework 2.1 sem precisar adicionar nenhum tipo de artifício de segurança para controlar o usuário e eu não quis este tipo de configuração por aqui também. Após 2 semanas procurando eu finalmente achei uma thread em um fórum brasileiro ensinando como fazer de um jeito simples usando STOMP. Como essa busca consumira muito de meu tempo e era o único exemplo a funcionar que encontrei, decidi escrever essa postagem colocando um pouco mais de detalhes porque a explicação do fórum apenas cobria o problema que o usuário colocou e não como criar do zero você mesmo. Por isso nesta postagem tentarei ensinar como fazer um simples Webchat using Spring WebSocket com STOMP sem Spring Security.

Definição de WebSocket

Um WebSocket é uma conexão entre um usuário e um servidor que somente fecha quando o usuário pede or quando algo acontece ao servidor que o força a desconectar o usuário(seja isto problema de hardware, o usuário ser banido por fazer algo impróprio ou o servidor perder a conexão). Outra coisa, diferente de uma conexão normal HTTP que é mão única, ou seja, você pede algo e o servidor te responde usando a mesma conexão, em um WebSocket a conexão é de mão dupla, logo você tem uma conexão a uma sala cheia de usuários conectados ao mesmo lugar. Logo qualquer pedido que o servidor receba, todos os usuários conectados irão receber a resposta. Então para que serve o WebSocket? Para grandes transmissões de dados como vídeo e áudio, igual o Youtube, facilitando ao enviar pequenas partes do vídeo ao invés de enviar o video todo ao usuário. Em uma conexão normal vocẽ precisa enviar o vídeo todo para o usuário and quanto mais tempo para carregar no navegador ou qualquer aparelho usado, maior a chance the um 'timeout', sem mencionar quão pesado fica para manter a conexão aberta. mais informações aqui.

O problema ... e a solução

Espera ... Eu disse que existe uma 'sala de usuários' connectados ao servidor e qualquer pedido enviado todos os usuários receberão a resposta, então aonde 'controlando quando enviar à todos ou quando enviar a apenas um usuário especifico' entra? Bem... claro, isto é um programa, código de máquina, você pode controlar as conexões. cada conexão é vista como uma sessão pelo WebSocket, então você pode enviar dados específicos para um usuário específico usando STOPM. STOMP faz pelo WebSocket o que o REST fez pelo HTTP. Você especifica uma estrutura de 'urls' e as anexa a alguma classe or função e qualquer um que estiver conectao ao WebSocket pode se 'inscrever' a essas 'urls' e escutar logo outros usuários podem enviar dados a qualquer moment e somente quem estiver ouvindo uma rota especpifica poderá receber este dado.

Implementação

Para começar você precisa estar familiarizado com Java 7(ou mais novo), Spring Boot, WebSoket e STOMP. Muito obrigado o tutorial está terminado, você já sabe tudo o que vocẽ precisa.

Brincadeira =D (Parece uns certos sites por aí né bael...), mas sim para completar este tutorial você precisa no mínimo de conhecimento em Java, Spring e Maven ou Gradle(Apesar de eu usar os dois nesta demonstração, não irei explicar como eles funcionam e irei focar mais no Maven), tentarei facilitar explicando coisas de Spring com o máximo de detalhes posiveis, então se você nunca usou Spring não se preocupe, você ainda não saberá nada após esta postagem, digo, não se preocupe você saber o mínimo para configurar um projeto básico

Vamos dos início

Garanta ter Java e Maven( ou Gradle) instalados. Crie uma pasta (ou diretoria se fores de Portugal) (como spring_websocket_specific_user por exemplo)

Se estiver usando Maven crie um arquivo (Ou ficheiro amigo português) pom.xml na raíz de sua pasta e copie o seguinte:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.websocket.spring</groupId>
    <artifactId>spring_websocket_specific_user</artifactId>
    <version>0.1.0</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
    </parent>

    <dependencies>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.jayway.jsonpath</groupId>
            <artifactId>json-path</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Se estiver usando Gradle crie um arquivo build.gradle na raíz de sua pasta e copie o seguinte:

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:2.2.1.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

bootJar {
    baseName = 'spring_websocket_specific_user'
    version =  '0.1.0'
}

repositories {
    mavenCentral()
}

sourceCompatibility = 1.8
targetCompatibility = 1.8

dependencies {
    compile("org.springframework.boot:spring-boot-starter-websocket")
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

Estes arquivo irão gerencia as dependencias do Spring e baixar para você tudo o que irá precisar para que o Spring funcione. Agora crie 4 pastas uma dentro da outra: src, depois main depoir java e por fim websocket

Representação no Maven

Representação no Gradle

Criando nossa classe Main

Agora vamos criar a classe que irá iniciar nosso programa

Vá para /src/main/java/websocket e crie um arquivo de nome Main.java e adicione o seguinte:

package websocket;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Main {

  public static void main(String[] args) {
    SpringApplication.run(Main.class, args);
  }
}

Agora você pode rodar e testar se tudo está a funcionar

mvn spring-boot:run

or

gradle bootRun

Se tudo tiver corrido bem você irá ver algo similar a isso:

Vamos criar nosso WebSocket

Volte a pasta /src/main/java e crie os pacotes org.websocket.spring.config e org.websocket.spring.controller

PS: Antes de continuar, garanta que tudo o que fez até agora está correto e que você tem uma aplicação Spring funcionando. Você consegue saber se tudo está bem olhando na foto acima. Não tente fazer os próximos passos se nada está funcionando até este ponto. Caso contrário será difícil saber o que está errado e ficará pior quanto mais você continua.

Agora, continuando...

Crie um novo arquivo chamado WebSocketConfig.java em /src/main/java/org/websocket/spring/config e adicione o seguinte

package org.websocket.spring.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

  @Override
  public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/user", "/topic", "/queue");
    config.setApplicationDestinationPrefixes("/app");
  }

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/websocket-chat").withSockJS();
  }
}

Spring built-in support (Eu não soube como traduzir isso, perdoem-me)

STOMP é apenas um protocolo para nos facilitar enviar mensagens ao servidor. Antes do STOMP tínhamos que criar classes de dados cheias e enums para saber o tipo do request, processar no fluxo correto e retornar a resposta adequada poruq eso se tinha um endpoint para o WebSocket. Mas com o STOMP nós podemos criar 'urls internas' para ações semelhantes a applicações de Queue(como Rabbit MQ, Zero MQ, etc), com a diferença de que o RabbitMQ se não houver nenhum cliente conectado e alguma mensagem for enviada ele guarda a mensagem até alguém se conectar e consumir, enquanto que com o STOMP, se não houver ninguém conectado e uma mensagem for enviada essa mensagem jamais será consumida por ninguém. Baseado na url podemos anexar funções (como você faz com Urls REST). Para este propósito Spring tem algo muito legal para a gente, ele pré-define 3 tipos de URL: /topic, /queue e /user.

  • /topic- Ao criar qualquer endpoint que comece com essa raíz (como /topic/newMember ou /topic/disconnectedMember) Spring irá enviar as mensagens a qualquer usuário conectado ao WebSocket
  • /queue- Ao criar qualquer endpoint que comece com essa raíz (como /queue/register ou /queue/unregister) Spring irá enviar as mensagens somente ao usuário que pediu isto como uma conexão HTTP normal. Imagine que você quer se registrar em nosso chat e se você for elegível(tradução feia) você receberá uma lista com os outros usuários conectados mas os outros usuários já tem essa lista, então você não quer que eles recebam nada
  • /user- Ao criar qualque endpoint que comece com essa raíz (como /user/{username}/msg) Spring irá enviar as mensagens somente ao usuário entre chaves({username}). Observe que quando implementarmos as rotas /user nós não precisaremos do usuário {username}, isso foi apenas para ilustrar meu exemplo.

Explicação do arquivo de Configuração

Nós criamos uma classe de configuração Spring com a anotação @Configuration para configurar os endpoints do nosso WebSocket. Para enableSimpleBroker nós colocamos três 'Spring built-in helpers': /user, /queue, /topic porque precisaremos de todos eles. /user para redirecionarmos mensagens específicas do usuário no chat. /queue para registrar e desregistrar(?) nosso usuário e /topic para espalhar a palavra de que algum novo usuário entrou ou saiu da sala.

O /app na chamado do método setApplicationDestinationPrefixes é apenas um nome aleatório que você pode dar para segregar as rotas do WebSocket das rotas normais HTTP. Por último mas não menos importante: addEndpoint cirar a url do nosso WebSocket que passamos como parâmetro para conectar em nosso WebSocket no nosso exemplo você precisa usar a seguinte String: ws://localhost:9000/websocket-chat(apenas se você esitver usando uma aplicação de WebSocket de fora de nosso app, porque em nosso cliente interno em Javascript nós usaremos /websocket-chat).

Agora vamos criar a classe de dados onde iremos guardar a mensagem, quem a enviou e quem deve recebê-la. Apenas crie uma classe chamada WebSocketMessage e adicione o seguinte(Eu criei esta classe no pacote /src/main/java/org/websocket/spring/controller):

package org.websocket.spring.controller;

public class WebSocketMessage {
  public final String toWhom;
  public final String fromWho;
  public final String message;
  
  public WebSocketMessage(final String toWhom, final String fromWho, final String message){
    this.toWhom  = toWhom;
    this.fromWho = fromWho;
    this.message = message;
  }
}

Não há muito o que ser explicado aqui (Imagino eu), em uma situação do Mundo Real vocẽ iria criar o báscio hashCode, equals e toString, até mesmo getters e setter, eu prefiro a imutabilidade, mas isso é apenas um exemplo então não vamos nos atentar muito aos detalhes. Então vamos criar nosso Spring Controller. Apenas vá até /src/main/java/org/websocket/spring/controller novamente e crie o arquivo WebSocketController.java e adicione o seguinte:

package org.websocket.spring.controller;

import java.util.Set;
import java.util.HashSet;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

@Controller
public class WebSocketController {
  private final SimpMessagingTemplate simpMessagingTemplate;   //1
  private final Set<String> connectedUsers;     //2
  
  public WebSocketController(SimpMessagingTemplate simpMessagingTemplate){ 
    this.simpMessagingTemplate = simpMessagingTemplate; //1
    connectedUsers = new HashSet<>();  //2
  }
  
  @MessageMapping("/register")  //3
  @SendToUser("/queue/newMember")
  public Set<String> registerUser(String webChatUsername){
    if(!connectedUsers.contains(webChatUsername)) {
      connectedUsers.add(webChatUsername);
      simpMessagingTemplate.convertAndSend("/topic/newMember", webChatUsername); //4
      return connectedUsers;
    } else {
      return new HashSet<>();
    }
  }
  
  @MessageMapping("/unregister")  //5
  @SendTo("/topic/disconnectedUser")
  public String unregisterUser(String webChatUsername){
    connectedUsers.remove(webChatUsername);
    return webChatUsername;
  }

  @MessageMapping("/message")  //6
  public void greeting(WebSocketMessage message){
    simpMessagingTemplate.convertAndSendToUser(message.toWhom, "/msg", message);
  }
}

Eu coloquei comentário listando os pontos interessantes aqui:

  1. Começando pela versão 4( Eu acho) Spring não precisa mais @Autowired para injetar objetos se você os inicializar no construtor. Isso facilita muito criar componentes imutáveis ou pelo menos diminuir a mutabilidade das variáveis, então o que eu fiz foi injetar um objeto do tipo SimpMessagingTemplate. Este objeto irá nos ajudar a redirecionar nossas mensagens para o usuário correto.
  2. Para mostrar aos outros usuários quem está conectado no momento para que você não precise adivinhar o nome do usário que você quer enviar uma mensagem, eu criei um Set de Strings que irá guardar os nomes dos usuários conectados.
  3. Eu criei esta rota para registrar nosso usuário quando ele se conecta ao nosso WebSocket. Você verá na parte em Javascript que isto não ocorre automaticamente, vocẽ precisa enviar uma mensagem para a url /app/register logo após se conectar. A anotação @SendToUser("/queue/newMember") é o endpoint que nosso usuário irá se inscrever (ou escutar) para quando ele/ela se registrarem em nosso WebSocket a resposta(seja o que a função retornar, neste caso um Set the usuários conectados) irá ser enviada somente a ele/ela, porque se você observar, este é um endpoint /queue.
  4. Eu apenas checo se o usuário já existe em nossa 'Base de dados' e caso sim retorno um Set vazio(você irá entender por que na parte em JS). Caso contrário eu chamo simpMessagingTemplate.convertAndSend("/topic/newMember", webChatUsername); para avisar os outros usuparios que alguém acabou de entrar na sla e por fim retorno a lista com os usuários conectados para o novo usuário conectado. Observe que nós apenas enviamos a messagem para o endpoint /topic , então todos irão recebê- la.
  5. Aqui é o oposto do que a função anterior faz. Um usuário decidiu se desconectar, então o/a removemos de nossa 'Base de dados' e retornamos seu nome para o endpoint /topic para que o resto dos usuários saibam que ele/ela saiu. Veja que aqui ao invés de usar o objeto simp eu usei a anotação SendTo, ela tem o mesmo efeito que a função convertAndSend na classe SimpMessagingTemplate. A diferença está no fato que aqui temos a vantagem do compilador, porque nós dizemos o que queremos enviar ao endpoint na assinatura da função, enquanto que a função convertAndSend recebe como parâmetro um Object. Eu usei o formato diferente na função anterior por que não se pode enviar dados a dois endpoints usando um único retorno de função, então retornei ao /queueusando o mecanismo de retorno da linguagem e usei o método convertAndSend para o endpoint /topic.
  6. E aqui é onde a mágica acontence. Aqui recebemos a mensagem do usuário usando nossa classe WebSocketMessage e extraímos para quem esta mensagem deve ser entregue. Em teoria a anotação @SendToUser poderia fazer esse truque para nós e redirecionar a mensagem para o recipiente correto mas para conseguir isso eu aprendi em todas as minhas pesquisas que você precisa configurar Spring Security, assim o framework loga o usário e cria uma instancia de Principal para cada usuário logado e usa este objeto para redirecionar as mensagens para o usuário correto. Eu posso estar errado mas todas vezes que tentei sem Spring Security nenhuma funcionou. Então o que aquela postagem no fórum brasileiro que mencionei no início diz para fazer é ignorar esta anotação e usar o método convertAndSendToUser da classe SimpMessagingTemplate e como você verá se seguir o tutorial completo, isso funciona.

Agora temos de dizer ao Spring que nossas classes são componentes Spring e assim Spring deve instanciá-las para nós, então devemos modificar noss classe main um pouco para adicionar o escaneador de componentes do Spring e dizer quais pacotes que o Spring deve escanear e fazer sua mágica. Então adicione @ComponentScan({"org.websocket.spring"}) em nossa classe Main:

...
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan({"org.websocket.spring"})
public class Main {
...

Pronto, agora Spring irá instanciar nosso @Controller automaticamente fazendo nosso backend do WebSocket completo. Super simples não? Bem nós poderíamos terminar aqui, mas eu irei fazer um frontend simples para deixar isso ainda mais completo.

Frontend

Para o frontend eu irei ser ainda mais simples do que fui no backend. Não irei usar nenhum tipo de framework(React.js, Angular, jQuery, nada), apenas duas bibliotecas: SockJs-client e stomp-webscoket. O resto será puro javascript. SocksJs e Stomp-Websocket nos darão um jeito fácil de controlar nosso cliente WebSocket e o último adiciona STOMP, assim não precisamos manualmente fazer nada quando conectarmos ao nosso servidor de WebSocket. Para adicionar SockJs e Stomp-Websocket irei usar Webjars ao invés de baixar manualmente os arquivos js e colocá-los na nossa pasta resources. Então apenas adicione as duas dependências em nosso build file:

Maven

<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>webjars-locator-core</artifactId>
</dependency>
<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>sockjs-client</artifactId>
  <version>1.0.2</version>
</dependency>
<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>stomp-websocket</artifactId>
  <version>2.3.3</version>
</dependency>

Gradle

compile("org.webjars:webjars-locator-core")
compile("org.webjars:sockjs-client:1.0.2")
compile("org.webjars:stomp-websocket:2.3.3")

Se você nunca usou ou ouviu falar de Webjar, isto é apenas uma biblioteca javascript empacotada em um pacote Java jar, assim você pode gerenciá-los como dependências normais via maven, gradle ou qualquer outro. Quando adicionar as bibliotecas vocẽ ainda terá que adicioná- las do jeito antigo no HTML:

....
<script src="/webjar/sockjs-client/1.0.2/sock.min.js"/>
....

Como é irritante ter que trocar a versão da biblioteca no seu HTML toda vez que você mudar a versão no pom.xml eu adicionei a Webjar locator-core, ela nos ajuda a remover a verbosidade de adicionar versões cada vez que atualizamos a biblioteca. Mude seu HTML para o seguinte e não precisará mudar nunca mais:

....
<script src="/webjar/sockjs-client/sock.min.js"/>
....

Webjar não funciona apenas como Javascript mas com CSS também. Vou adicionar Bootstrap CSS não para deixar bonito mas para deixar mais alinhado e entendível. Entçao adicione mais uma dependência no seu build file:

Maven

<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>bootstrap</artifactId>
  <version>3.3.7</version>
</dependency>

Gradle

compile("org.webjars:bootstrap:3.3.7")

Irei criar somente o básico para enviar e receber mensagens e não irei explicar síntaxe de javascript ou detalhes de HTML. A única explicação que você terá nesta seção é quais objetos você deve instaciar, quais métodos usar e porque estamos usando estes assim você pode facilmente fazer modificações para se adequar a sua realidade. Então vamos criar nosso index.html dentro da pasta /src/main/resources/static e adicionar o seguinte:

<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Webchat WebSocket</title>
    <link href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
  
    <div id="main-content" class="container">
      <div class="row text-center">
        <h2>WebChat WebSocket</h2>
      </div>

      <br/>

      <div class="row text-center">
        <div class="col-md-4">
          <label for="webchat_username">Username:</label>
          <input type="text" id="webchat_username" placeholder="Put your username here..."/>
        </div>
        <div class="col-md-1">
          <input type="button" class="btn" id="webchat_connect" value="Connect"/>
        </div>
        <div class="col-md-1">
          <input type="button" class="btn" id="webchat_disconnect" value="Disconnect" disabled="true"/>
        </div>
      </div>

      <div class="row">
        <div class="row text-center"><h2>Connected Users List</h2></div>
        <div id="chat_user_list" class="row"></div>
      </div>
    
      <div id="chat_list" class="row"></div>
      
      <div id="alerts"></div>
    </div>
    
    <script src="/webjars/sockjs-client/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/stomp.min.js"></script>
    <script src="/js/index.js"></script>
  </body>
</html>

Agora vamos criar nosso arquivo Javascript dentro de index.js /src/main/resources/static/js e adicionar o seguinte:

(function(){
  function select(str){
    return document.querySelector(str);
  }
  
  function alertMessage(message, type){
    let alerts = select("#alerts");

    let el = document.createElement("p")
    el.innerHTML = message
    el.classList.add(type)
    alerts.append(el)
    setTimeout(() => alerts.innerHTML = '', 5000)
  }
  
  function drawChat(chatUsername){
    return `<div class="row text-center">\n  <h3>Chat with ${chatUsername}</h3>\n</div>\n<div id="chat_messages_${chatUsername}" class="row"></div>\n<br/>\n<div class="row">\n  <div class="col-md-4"><textarea id="chat_input_${chatUsername}"></textarea></div>\n  <div class="col-md-1"><input type="button" id="chat_send_to_${chatUsername}" value="Send"/></div>\n</div>`
  }

  function getChat(chatList, chatName){
    let chatRoom = chatList.querySelector(`#chat_${chatName}`)
    if(chatRoom === null){
      let el = document.createElement("div")
      el.id = `chat_${chatName}`
      el.innerHTML = drawChat(chatName)
      el.classList.add('row')
      chatList.append(el)
      return el;
    } else {
      return chatRoom
    }
  }

  function clickSendButton(chatRoom, toWhom, stompClient, username) {
    chatRoom.querySelector(`#chat_send_to_${toWhom}`).addEventListener('click', () => {
      let msgInput = chatRoom.querySelector(`#chat_input_${toWhom}`)
      let msg = msgInput.value;

      if (msg && msg !== '') {
        stompClientSendMessage(stompClient, '/app/message', JSON.stringify({
          toWhom: toWhom,
          fromWho: username,
          message: msg
        }))
        let messages = chatRoom.querySelector(`#chat_messages_${toWhom}`);
        messages.innerHTML += `<div class="row"><div class="col-md-1">Me:</div><div class="col-md-8">${msg}</div></div>`
        msgInput.value = ''
      } else {
        alertMessage(`Message to user [${toWhom}] cannot be empty !!!`, "bg-danger")
      }
    }, true)
  }
  
  function displayMessage(chatList, stompClient, username, {fromWho, message}){
    let chatRoom = getChat(chatList, fromWho);
    let messages = chatRoom.querySelector(`#chat_messages_${fromWho}`);
    messages.innerHTML += `<div class="row"><div class="col-md-1">${fromWho}:</div><div class="col-md-8">${message}</div></div>`

    clickSendButton(chatRoom, fromWho, stompClient, username)

  }

  function displayUserList(userList, chatList, username, stompClient){
    const lis = userList.length === 0 ? "It looks like you are the only one in the chat room !!!" : userList
        .reduce((acc, item) => `${acc}<li id="user_${item}"><a href="#chat_${item}">${item}</a></a></li>`, "")

    select("#chat_user_list").innerHTML = `<ul>${lis}</ul>`

    userList.forEach(item => select(`#chat_user_list #user_${item}`).addEventListener('click', () => {
      clickSendButton(getChat(chatList, item), item, stompClient, username);
    }, true))
  }
  
  function stompSubscribe(stompClient, endpoint, callback){ //8
    stompClient.subscribe(endpoint, callback)
    return stompClient
  }
  
  function stompClientSendMessage(stompClient, endpoint, message){ // 9
    stompClient.send(endpoint, {}, message)
    return stompClient
  }
  
  function disconnect(stompClient, username, connectBtn, disconnectBtn, clicked = false){
    connectBtn.disabled = false
    disconnectBtn.disabled = true
    if(clicked){
      stompClientSendMessage(stompClient, '/app/unregister', username)
    }
    stompClient.disconnect() //6-1
  }
  
  function connect(username){ //1-1
    return new Promise((resolve, reject) => {
      let stompClient = Stomp.over(new SockJS('/websocket-chat'))
      stompClient.connect({}, (frame) => resolve(stompClient))
    })
  }
  
  //To guarantee that our page is completely loaded before we execute anything
  window.addEventListener('load', function(event){
    let chatUsersList = [];
    let chatList = select("#chat_list");
    let connectButton = select("#webchat_connect");
    let disconnectButton = select("#webchat_disconnect");

    connectButton.addEventListener('click', () => {
      let username = select("#webchat_username").value;

      if(username == null || username === ''){
        alertMessage('Name cannot be empty!!!', 'bg-danger')
      } else {
        connect(username) //1
            .then((stompClient) => stompSubscribe(stompClient, '/user/queue/newMember', (data) => { //2
              chatUsersList = JSON.parse(data.body)
              if(chatUsersList.length > 0){
                displayUserList(chatUsersList.filter(x => x != username), chatList, username, stompClient)
              } else {
                alertMessage("Username already exists!!!", "bg-danger")
                disconnect(stompClient, username, connectButton, disconnectButton)
              }
            })).then((stompClient) => stompSubscribe(stompClient, '/topic/newMember', (data) => {  // 3
              chatUsersList.push(data.body);
              displayUserList(chatUsersList.filter(x => x != username), chatList, username, stompClient)
            })).then((stompClient) => stompClientSendMessage(stompClient, '/app/register', username)) // 4
            .then((stompClient) => stompSubscribe(stompClient, `/user/${username}/msg`, (data) => {
              displayMessage(chatList, stompClient, username, JSON.parse(data.body))
            }))
            .then((stompClient) => { //5
              connectButton.disabled = true;
              disconnectButton.disabled = false;
              disconnectButton.addEventListener('click', () => disconnect(stompClient, username, connectButton, disconnectButton, true), true); // 6
              return stompClient;
            }).then((stompClient) => stompSubscribe(stompClient, '/topic/disconnectedUser', (data) => { // 7
              const userWhoLeft = data.body;
              chatUsersList = chatUsersList.filter(x => x != userWhoLeft);
              displayUserList(chatUsersList.filter(x => x != username), chatList, username, stompClient);
              alertMessage(`User [${userWhoLeft}] left the chat room!!!`, "bg-success")
            }))
      }
      }, true)
  });
})();

Como previamente dito, não irei explicar detalhes de JS aqui, irei focar somente nas coisas de WebSocket, mas se você não entender onde começa não irá entender nada por aqui, logo nosso ponto de partida de tudo é window.addEventListener('load', function(event). Tudo acima disso é apenas declaração de funções. Então agora irei fazer o mesmo que fiz antes e colocar comentários mostrando coisas importantes usando números:

  1. Vamos do começo. Depois de checar se o nome do usuário é ou não válido nós conectamos em nosso WebSocket, se você olhar a função connect verá que é muito simples. Apenas passe a url do nosso servidor de WebSocket, aquela que configuramos na classe Java WebSocketConmfig: /websocket-chat.
  2. Agora iremos começar nos inscrevendo aos endpoints nos possiblitando receber mensagens. O primeiro endpoint que iremos nos inscrever é o newMember para sabermos se estamos registrados ou não. Lembra que eu disse que não é automático e devemos enviar uma mensagem para nos registrar? Pois então, antes de enviarmos a mensagem precisamos nos inscrever para o 'retorno' do endpoint, caso contrário a mensagem será enviada em um momento que não estaremos ouvindo. Outro ponto aqui, se olhar na função Java eu retorno uma lista vazia se o usuário já existir. Isso é apenas eu dizendo: 'Seu usuário já existe, tente outro'. Você pode fazer diferente, você pode criar uma classe que represente falha e sucesso por exemplo, mas o que você não pode é retornar 'null'. Eu tentei, não funciona, o servidor não manda a mensagem.
  3. Para que possamos continuar recebendo alertas quando outros usuários connectam nós nos inscrevemos na versão /topic do endpoint acima. Se você lembrar, o endpoint /queue/newMember irá nos retornar uma lista com todos os usuários conectados e o /topic somente o nome de quem se conectou. Outra diferença é que por usarmos /queue nós somente receberemos uma única vez a lista completa de usuário e para nos mantermos atualizados precisamos do /topic. Você pode notar que eu não checo quando nos conectamos para ignorar nosso nome, sinta- se livre para corrigir esse erro ^^
  4. Agora que nos inscrevemos no enpoint que irá nos dizer se estamos registrados na sala de chat ou não, podemos finalmente nos reigstrar. Então iremos enviar uma mensagem para /app/register e nos registrar.
  5. Aqui eu desabilito o botão de conectar para que você não reconecte acidentalmente e habilito o botão de desconectar.
  6. Aqui estou a adicionar a função de nos disconectar quando clicamos no botão de disconectar.
  7. Aqui é uma simples reação a quando alguém deixa o chat.
  8. Aqui você pode ver quão simples é se inscrever em um endpoint, você só precisa chamar o método subscribe do StompClient e pssar o endpoint que você quer se inscrever e o callback para reagir a suas mensagens.
  9. Aqui o mesmo para você ver o quão simples é enviar uma mensagem para um endpoint. Apenas chame o método senddo StompClient e passe o endpoint e a mensagem em si. Cheque se a mensagem precisa ser convertidade antes de ser enviada, como JSON.stringify ou qualquer outra coisa.

O resto é apenas eu tentando facilitar para você adaptar isso para quaisquer que seja sua necessidade. Execute e sinta-se livre para me xingar ou me cumprimentar quando eu adicionar comentários ao blog. Se quiser ver o código fonte por favor vá até esta URL.

Muitíssimo obrigado por seu tempo, espero ter ajudado de alguma forma, até mais.