Guia de Implementação de WebSocket com Spring Boot e JavaScript Puro

Parte 1: Servidor com Spring Boot

Para impleemntar WebSocket no Spring Boot, baseado na especificação JSR-356, utilize os componentes centrais: a anotação @ServerEndpoint para definir endpoints, ServerEndpointExporter para registro automático e a classe Session para gerenciar conexões.

1. Adicionar Dependência

Inclua no arquivo pom.xml a dependência do starter WebSocket:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2. Classe de Configuração

Para aplicações com Tomcat embutido, registre um bean para exportar endpoints:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WsConfig {

    @Bean
    public ServerEndpointExporter wsEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

3. Implementação do Endpoint

Crie uma classe para lidar com eventos de conexão e mensagens. Este exemplo inclui gerenciamento de usuários e envio de mensagens em grupo:

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

@Slf4j
@Component
@ServerEndpoint("/comms/{uid}")
public class WsEndpoint {

    private static final CopyOnWriteArraySet<WsEndpoint> activeEndpoints = new CopyOnWriteArraySet<>();
    private static final ConcurrentHashMap<String, Session> uidSessionMap = new ConcurrentHashMap<>();
    private Session currentSession;
    private String uid;

    @OnOpen
    public void handleOpen(Session session, @PathParam("uid") String uid) {
        this.currentSession = session;
        this.uid = uid;
        activeEndpoints.add(this);
        uidSessionMap.put(uid, session);
        log.info("Conexão estabelecida: " + uid + ", total ativo: " + activeEndpoints.size());
        try {
            transmitMessage("Bem-vindo! Seu ID é: " + uid);
        } catch (IOException ex) {
            log.error("Falha ao enviar mensagem de boas-vindas", ex);
        }
    }

    @OnClose
    public void handleClose() {
        activeEndpoints.remove(this);
        if (uid != null) {
            uidSessionMap.remove(uid);
        }
        log.info("Conexão encerrada. Usuários online: " + activeEndpoints.size());
    }

    @OnMessage
    public void handleMessage(String message, Session session) {
        log.info("Mensagem de " + uid + ": " + message);
        try {
            broadcast("Usuário " + uid + " disse: " + message);
        } catch (IOException ex) {
            log.error("Falha no broadcast", ex);
        }
    }

    @OnError
    public void handleError(Session session, Throwable error) {
        log.error("Erro na sessão", error);
    }

    private void transmitMessage(String msg) throws IOException {
        if (currentSession != null && currentSession.isOpen()) {
            currentSession.getBasicRemote().sendText(msg);
        }
    }

    public static void broadcast(String msg) throws IOException {
        for (WsEndpoint ep : activeEndpoints) {
            synchronized (ep) {
                if (ep.currentSession != null && ep.currentSession.isOpen()) {
                    ep.currentSession.getBasicRemote().sendText(msg);
                }
            }
        }
    }

    public static void sendToUser(String msg, String targetUid) throws IOException {
        Session target = uidSessionMap.get(targetUid);
        if (target != null && target.isOpen()) {
            target.getBasicRemote().sendText(msg);
        } else {
            log.warn("Usuário offline ou sessão inválida: " + targetUid);
        }
    }
}

Pontos importantes: CopyOnWriteArraySet garante segurança em ambiente multi-thread; @PathParam captura parâmetros da URL; a classe Session é essencial para envio de dados via sendText().

Parte 2: Cliente com JavaScript Puro

O cliente utiliza a API WebSocket nativa do navegador, sem dependências externas.

Exemplo HTML/JS Básico

Crie um arquivo index.html com a seguinte estrutura:

<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <title>Teste de WebSocket Nativo</title>
    <style>
        #registro { width: 600px; height: 400px; border: 1px solid #ddd; overflow-y: auto; padding: 10px; font-family: monospace; }
        .msg-recebida { color: green; }
        .msg-enviada { color: blue; }
        .info { color: #666; font-style: italic; }
    </style>
</head>
<body>
    <h2>Demo de Chat com WebSocket</h2>
    <div>
        ID do Usuário: <input type="text" id="campoUid" value="usuario_001">
        <button onclick="iniciarConexao()">Conectar</button>
        <button onclick="encerrarConexao()" disabled id="btnDesconectar">Desconectar</button>
    </div>
    <br>
    <div>
        <input type="text" id="campoMsg" placeholder="Digite sua mensagem..." style="width: 400px;">
        <button onclick="enviarMensagem()" id="btnEnviar" disabled>Enviar</button>
        <button onclick="enviarPrivado()">Enviar Privado</button>
    </div>
    <br>
    <div id="registro"></div>

    <script>
        let socket = null;
        const registroDiv = document.getElementById('registro');

        function registrar(msg, classe = 'info') {
            const p = document.createElement('div');
            p.className = classe;
            const horario = new Date().toLocaleTimeString();
            p.innerText = `[${horario}] ${msg}`;
            registroDiv.appendChild(p);
            registroDiv.scrollTop = registroDiv.scrollHeight;
        }

        function iniciarConexao() {
            const uid = document.getElementById('campoUid').value;
            if (!uid) { alert("Insira um ID de usuário"); return; }

            const urlWs = `ws://localhost:8080/comms/${uid}`;
            registrar(`Conectando a ${urlWs}...`);
            socket = new WebSocket(urlWs);

            socket.onopen = function() {
                registrar('Conexão bem-sucedida!', 'info');
                document.getElementById('btnDesconectar').disabled = false;
                document.getElementById('btnEnviar').disabled = false;
            };

            socket.onmessage = function(evento) {
                registrar('Recebido: ' + evento.data, 'msg-recebida');
                try {
                    const dados = JSON.parse(evento.data);
                    console.log('Dados parseados:', dados);
                } catch (e) { /* Tratar se não for JSON */ }
            };

            socket.onclose = function() {
                registrar('Conexão fechada', 'info');
                document.getElementById('btnDesconectar').disabled = true;
                document.getElementById('btnEnviar').disabled = true;
                socket = null;
            };

            socket.onerror = function(erro) {
                registrar('Erro: ' + erro, 'info');
            };
        }

        function encerrarConexao() {
            if (socket) socket.close();
        }

        function enviarMensagem() {
            if (socket && socket.readyState === WebSocket.OPEN) {
                const campo = document.getElementById('campoMsg');
                const texto = campo.value;
                if (!texto) return;

                socket.send(texto);
                registrar('Enviado: ' + texto, 'msg-enviada');
                campo.value = '';
            } else {
                registrar('Conexão não ativa', 'info');
            }
        }

        function enviarPrivado() {
            if (socket && socket.readyState === WebSocket.OPEN) {
                const alvo = prompt("ID do destinatário:");
                const conteudo = prompt("Mensagem:");
                if (alvo && conteudo) {
                    const payload = JSON.stringify({
                        tipo: 'PRIVADO',
                        destinatario: alvo,
                        mensagem: conteudo
                    });
                    socket.send(payload);
                    registrar(`Privado para ${alvo}: ${conteudo}`, 'msg-enviada');
                }
            }
        }

        window.onbeforeunload = function() {
            if (socket) socket.close();
        };
    </script>
</body>
</html>

Parte 3: Integração e Soluções Comuns

Teste e Depuração

Para testar: inicie a aplicação Spring Boot, abra o index.html em dois navegadores, insira IDs diferentes e envie mensagens. Observe os logs no servidor para verificar a comunicação.

Problemas de CORS (Cross-Origin)

Se o frontend e backend estiverem em domínios diferentes, o hansdhake do WebSocket pode falhar. Soluções incluem configurar CORS no Spring Boot via ServerEndpointConfig ou usar um proxy reverso no Nginx. Exemplo de configuração no Nginx:

location /comms/ {
    proxy_pass http://backend_server;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400;
}

Considerações para Produção

  • WSS Criptografado: Em produção, utilize wss:// com SSL configurado no Nginx.
  • Ambientes em Cluster: A implementação acima é para único servidor. Para múltiplos nós, integre um sistema de mensagens como Redis Pub/Sub para sincronizar mensagens entre instâncias.
  • Keep-Alive: Implemente lógica de ping/pong na aplicação para detectar desconexões inativas.

Reconexão Automática no Cliente

Adicione uma estratégia de reconexão com backoff exponencial no JavaScript:

let tentativasReconexao = 0;
function tentarReconectar() {
    tentativasReconexao++;
    const delay = Math.min(1000 * tentativasReconexao, 30000);
    setTimeout(() => {
        registrar('Tentando reconexão...');
        iniciarConexao();
    }, delay);
}

// Modificar o handler onclose
socket.onclose = function() {
    registrar('Conexão perdida, reconectando...', 'info');
    tentarReconectar();
};

Tags: WebSocket Spring Boot javascript JSR-356 ServerEndpoint

Publicado em 6-4 17:37 por Thomas