WebSocket com Go: Chat em Tempo Real

WebSocket permite comunicação bidirecional e persistente entre cliente e servidor. Diferente do HTTP tradicional (requisição-resposta), o WebSocket mantém uma conexão aberta, permitindo que ambos os lados enviem dados a qualquer momento. Go, com seu modelo de concorrência baseado em goroutines, é uma das melhores linguagens para construir servidores WebSocket de alto desempenho.

Neste tutorial, vamos construir um chat em tempo real completo, com salas, broadcast e gerenciamento de conexões.


Como Funciona o WebSocket

O protocolo WebSocket começa com um “upgrade” de uma conexão HTTP normal:

Cliente                              Servidor
  │                                     │
  │  GET /ws HTTP/1.1                   │
  │  Upgrade: websocket                 │
  │  Connection: Upgrade                │
  │────────────────────────────────────>│
  │                                     │
  │  HTTP/1.1 101 Switching Protocols   │
  │  Upgrade: websocket                 │
  │<────────────────────────────────────│
  │                                     │
  │  ══════ Conexão WebSocket ══════    │
  │  <──── mensagens bidirecionais ────>│
  │                                     │

Após o handshake, a conexão TCP permanece aberta e ambos os lados podem enviar e receber mensagens livremente.

Vantagens sobre HTTP polling:

  • Latência baixa (sem overhead de novas conexões)
  • Comunicação bidirecional real
  • Menos consumo de banda
  • Ideal para: chat, jogos, dashboards, notificações, colaboração em tempo real

Setup do Projeto

mkdir chat-go
cd chat-go
go mod init chat-go
go get github.com/gorilla/websocket

Estrutura do projeto:

chat-go/
├── main.go          # Ponto de entrada e servidor HTTP
├── hub.go           # Gerenciador central de conexões
├── client.go        # Representa um cliente conectado
├── templates/
│   └── index.html   # Interface do chat (frontend)
├── go.mod
└── go.sum

O Hub: Centro de Controle

O Hub gerencia todas as conexões ativas, distribui mensagens e controla as salas:

// hub.go
package main

import (
    "encoding/json"
    "log"
    "sync"
)

// Mensagem representa uma mensagem trocada no chat
type Mensagem struct {
    Tipo      string `json:"tipo"`      // "mensagem", "sistema", "entrada", "saida"
    Sala      string `json:"sala"`
    Usuario   string `json:"usuario"`
    Conteudo  string `json:"conteudo"`
    Timestamp string `json:"timestamp"`
}

// Hub gerencia todas as conexões e salas
type Hub struct {
    // Clientes registrados por sala
    salas map[string]map[*Client]bool

    // Canal para broadcast de mensagens
    broadcast chan Mensagem

    // Canal para registrar novos clientes
    registrar chan *Client

    // Canal para remover clientes desconectados
    desregistrar chan *Client

    // Mutex para acesso seguro às salas
    mu sync.RWMutex
}

// NovoHub cria e retorna um novo Hub
func NovoHub() *Hub {
    return &Hub{
        salas:        make(map[string]map[*Client]bool),
        broadcast:    make(chan Mensagem, 256),
        registrar:    make(chan *Client),
        desregistrar: make(chan *Client),
    }
}

// Executar inicia o loop principal do Hub
func (h *Hub) Executar() {
    for {
        select {
        case cliente := <-h.registrar:
            h.adicionarCliente(cliente)

        case cliente := <-h.desregistrar:
            h.removerCliente(cliente)

        case mensagem := <-h.broadcast:
            h.enviarParaSala(mensagem)
        }
    }
}

// adicionarCliente registra um novo cliente na sala
func (h *Hub) adicionarCliente(cliente *Client) {
    h.mu.Lock()
    defer h.mu.Unlock()

    sala := cliente.sala

    // Cria a sala se não existir
    if _, existe := h.salas[sala]; !existe {
        h.salas[sala] = make(map[*Client]bool)
        log.Printf("Sala '%s' criada", sala)
    }

    h.salas[sala][cliente] = true
    log.Printf("'%s' entrou na sala '%s' (%d usuários)",
        cliente.nome, sala, len(h.salas[sala]))

    // Notifica todos na sala sobre o novo usuário
    h.broadcast <- Mensagem{
        Tipo:     "entrada",
        Sala:     sala,
        Usuario:  "Sistema",
        Conteudo: cliente.nome + " entrou no chat",
    }
}

// removerCliente remove um cliente da sala
func (h *Hub) removerCliente(cliente *Client) {
    h.mu.Lock()
    defer h.mu.Unlock()

    sala := cliente.sala

    if clientes, existe := h.salas[sala]; existe {
        if _, ok := clientes[cliente]; ok {
            delete(clientes, cliente)
            close(cliente.enviar)

            log.Printf("'%s' saiu da sala '%s' (%d usuários restantes)",
                cliente.nome, sala, len(clientes))

            // Notifica os demais
            h.broadcast <- Mensagem{
                Tipo:     "saida",
                Sala:     sala,
                Usuario:  "Sistema",
                Conteudo: cliente.nome + " saiu do chat",
            }

            // Remove a sala se estiver vazia
            if len(clientes) == 0 {
                delete(h.salas, sala)
                log.Printf("Sala '%s' removida (vazia)", sala)
            }
        }
    }
}

// enviarParaSala envia uma mensagem para todos os clientes de uma sala
func (h *Hub) enviarParaSala(msg Mensagem) {
    h.mu.RLock()
    defer h.mu.RUnlock()

    clientes, existe := h.salas[msg.Sala]
    if !existe {
        return
    }

    // Serializa a mensagem uma vez
    dados, err := json.Marshal(msg)
    if err != nil {
        log.Printf("Erro ao serializar mensagem: %v", err)
        return
    }

    // Envia para cada cliente na sala
    for cliente := range clientes {
        select {
        case cliente.enviar <- dados:
            // Mensagem enviada com sucesso
        default:
            // Buffer cheio — cliente provavelmente travou
            log.Printf("Buffer cheio para '%s', desconectando", cliente.nome)
            close(cliente.enviar)
            delete(clientes, cliente)
        }
    }
}

// ListarSalas retorna informações sobre todas as salas ativas
func (h *Hub) ListarSalas() map[string]int {
    h.mu.RLock()
    defer h.mu.RUnlock()

    resultado := make(map[string]int)
    for sala, clientes := range h.salas {
        resultado[sala] = len(clientes)
    }
    return resultado
}

O Client: Representando Cada Conexão

Cada cliente conectado tem duas goroutines: uma para ler mensagens e outra para escrever:

// client.go
package main

import (
    "encoding/json"
    "log"
    "time"

    "github.com/gorilla/websocket"
)

const (
    // Tempo máximo para escrever uma mensagem
    tempoEscrita = 10 * time.Second

    // Tempo máximo entre pongs do cliente
    tempoPong = 60 * time.Second

    // Intervalo de envio de pings
    intervaloPing = (tempoPong * 9) / 10

    // Tamanho máximo da mensagem (64KB)
    tamanhoMaxMensagem = 64 * 1024
)

// Client representa um usuário conectado via WebSocket
type Client struct {
    hub    *Hub
    conn   *websocket.Conn
    enviar chan []byte // Canal de mensagens para enviar ao cliente
    nome   string
    sala   string
}

// LerMensagens lê mensagens do WebSocket e envia para o Hub
// Cada cliente tem uma goroutine dedicada para leitura
func (c *Client) LerMensagens() {
    defer func() {
        c.hub.desregistrar <- c
        c.conn.Close()
    }()

    // Configurações de leitura
    c.conn.SetReadLimit(tamanhoMaxMensagem)
    c.conn.SetReadDeadline(time.Now().Add(tempoPong))

    // Quando receber um pong, renova o deadline
    c.conn.SetPongHandler(func(string) error {
        c.conn.SetReadDeadline(time.Now().Add(tempoPong))
        return nil
    })

    for {
        _, dados, err := c.conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err,
                websocket.CloseGoingAway,
                websocket.CloseNormalClosure) {
                log.Printf("Erro de leitura de '%s': %v", c.nome, err)
            }
            break
        }

        // Decodifica a mensagem recebida
        var msg Mensagem
        if err := json.Unmarshal(dados, &msg); err != nil {
            log.Printf("Mensagem inválida de '%s': %v", c.nome, err)
            continue
        }

        // Preenche campos do servidor
        msg.Usuario = c.nome
        msg.Sala = c.sala
        msg.Timestamp = time.Now().Format("15:04:05")

        // Envia para o hub processar
        c.hub.broadcast <- msg
    }
}

// EscreverMensagens envia mensagens do canal para o WebSocket
// Cada cliente tem uma goroutine dedicada para escrita
func (c *Client) EscreverMensagens() {
    // Ticker para enviar pings periódicos
    ticker := time.NewTicker(intervaloPing)
    defer func() {
        ticker.Stop()
        c.conn.Close()
    }()

    for {
        select {
        case mensagem, ok := <-c.enviar:
            // Define deadline para escrita
            c.conn.SetWriteDeadline(time.Now().Add(tempoEscrita))

            if !ok {
                // Hub fechou o canal — enviar close frame
                c.conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }

            // Abre um writer para a mensagem
            w, err := c.conn.NextWriter(websocket.TextMessage)
            if err != nil {
                return
            }
            w.Write(mensagem)

            // Aproveita para enviar mensagens enfileiradas
            n := len(c.enviar)
            for i := 0; i < n; i++ {
                w.Write([]byte{'\n'})
                w.Write(<-c.enviar)
            }

            if err := w.Close(); err != nil {
                return
            }

        case <-ticker.C:
            // Envia ping para manter a conexão viva
            c.conn.SetWriteDeadline(time.Now().Add(tempoEscrita))
            if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
                return
            }
        }
    }
}

O Servidor: Juntando Tudo

// main.go
package main

import (
    "encoding/json"
    "html/template"
    "log"
    "net/http"
    "strings"

    "github.com/gorilla/websocket"
)

// Configuração do upgrader (HTTP -> WebSocket)
var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    // Em produção, valide a origem!
    CheckOrigin: func(r *http.Request) bool {
        return true // Aceita qualquer origem (apenas para desenvolvimento)
    },
}

var hub *Hub

func main() {
    hub = NovoHub()
    go hub.Executar() // Inicia o Hub em background

    // Página principal do chat
    http.HandleFunc("/", paginaInicial)

    // Endpoint WebSocket
    http.HandleFunc("/ws", handleWebSocket)

    // API: listar salas ativas
    http.HandleFunc("/api/salas", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(hub.ListarSalas())
    })

    porta := ":8080"
    log.Printf("Chat iniciado em http://localhost%s", porta)
    if err := http.ListenAndServe(porta, nil); err != nil {
        log.Fatal("Erro ao iniciar servidor:", err)
    }
}

// handleWebSocket trata novas conexões WebSocket
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
    // Extrai parâmetros da query string
    nome := r.URL.Query().Get("nome")
    if nome == "" {
        nome = "Anônimo"
    }

    sala := r.URL.Query().Get("sala")
    if sala == "" {
        sala = "geral"
    }

    // Sanitização básica
    nome = strings.TrimSpace(nome)
    sala = strings.TrimSpace(sala)

    // Faz o upgrade de HTTP para WebSocket
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Printf("Erro no upgrade WebSocket: %v", err)
        return
    }

    // Cria um novo cliente
    cliente := &Client{
        hub:    hub,
        conn:   conn,
        enviar: make(chan []byte, 256),
        nome:   nome,
        sala:   sala,
    }

    // Registra o cliente no hub
    hub.registrar <- cliente

    // Inicia as goroutines de leitura e escrita
    go cliente.EscreverMensagens()
    go cliente.LerMensagens()
}

// paginaInicial serve o HTML do chat
func paginaInicial(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" {
        http.NotFound(w, r)
        return
    }

    tmpl, err := template.ParseFiles("templates/index.html")
    if err != nil {
        http.Error(w, "Erro ao carregar página", http.StatusInternalServerError)
        return
    }
    tmpl.Execute(w, nil)
}

Frontend: Interface do Chat

<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Chat Go WebSocket</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }

        body {
            font-family: 'Segoe UI', Tahoma, sans-serif;
            background: #0d1117;
            color: #c9d1d9;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }

        /* Cabeçalho */
        .header {
            background: #161b22;
            padding: 16px 24px;
            border-bottom: 1px solid #30363d;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .header h1 {
            color: #58a6ff;
            font-size: 1.2rem;
        }

        .sala-info {
            color: #8b949e;
            font-size: 0.9rem;
        }

        /* Formulário de entrada */
        .login-form {
            display: flex;
            gap: 12px;
            padding: 20px;
            background: #161b22;
            justify-content: center;
            align-items: center;
            flex-wrap: wrap;
        }

        .login-form input {
            padding: 10px 16px;
            border-radius: 6px;
            border: 1px solid #30363d;
            background: #0d1117;
            color: #c9d1d9;
            font-size: 0.95rem;
        }

        .login-form button {
            padding: 10px 24px;
            background: #238636;
            color: #fff;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 0.95rem;
        }

        .login-form button:hover { background: #2ea043; }

        /* Área de mensagens */
        .mensagens {
            flex: 1;
            overflow-y: auto;
            padding: 16px 24px;
        }

        .mensagem {
            margin-bottom: 12px;
            padding: 10px 14px;
            border-radius: 8px;
            background: #161b22;
            border-left: 3px solid #30363d;
        }

        .mensagem.sistema {
            background: #1c2128;
            border-left-color: #58a6ff;
            color: #8b949e;
            font-style: italic;
        }

        .mensagem .autor {
            color: #58a6ff;
            font-weight: 600;
            margin-right: 8px;
        }

        .mensagem .hora {
            color: #484f58;
            font-size: 0.8rem;
        }

        .mensagem .texto { margin-top: 4px; }

        /* Barra de input */
        .input-bar {
            display: flex;
            padding: 16px 24px;
            background: #161b22;
            border-top: 1px solid #30363d;
            gap: 12px;
        }

        .input-bar input {
            flex: 1;
            padding: 12px 16px;
            border-radius: 6px;
            border: 1px solid #30363d;
            background: #0d1117;
            color: #c9d1d9;
            font-size: 1rem;
        }

        .input-bar button {
            padding: 12px 24px;
            background: #238636;
            color: #fff;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 1rem;
        }

        .hidden { display: none !important; }
    </style>
</head>
<body>
    <div class="header">
        <h1>Chat Go + WebSocket</h1>
        <span class="sala-info" id="salaInfo">Desconectado</span>
    </div>

    <!-- Formulário de login -->
    <div class="login-form" id="loginForm">
        <input type="text" id="inputNome" placeholder="Seu nome" value="">
        <input type="text" id="inputSala" placeholder="Nome da sala" value="geral">
        <button onclick="conectar()">Entrar no Chat</button>
    </div>

    <!-- Área de mensagens -->
    <div class="mensagens hidden" id="mensagens"></div>

    <!-- Barra de input -->
    <div class="input-bar hidden" id="inputBar">
        <input type="text" id="inputMensagem" placeholder="Digite sua mensagem..."
               onkeydown="if(event.key==='Enter') enviar()">
        <button onclick="enviar()">Enviar</button>
    </div>

    <script>
        let ws = null;
        let nomeUsuario = '';

        function conectar() {
            nomeUsuario = document.getElementById('inputNome').value.trim() || 'Anônimo';
            const sala = document.getElementById('inputSala').value.trim() || 'geral';

            // Monta a URL do WebSocket
            const protocolo = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const url = `${protocolo}//${window.location.host}/ws?nome=${encodeURIComponent(nomeUsuario)}&sala=${encodeURIComponent(sala)}`;

            ws = new WebSocket(url);

            ws.onopen = function() {
                // Mostra a interface do chat
                document.getElementById('loginForm').classList.add('hidden');
                document.getElementById('mensagens').classList.remove('hidden');
                document.getElementById('inputBar').classList.remove('hidden');
                document.getElementById('salaInfo').textContent = `Sala: ${sala} | Usuário: ${nomeUsuario}`;
                document.getElementById('inputMensagem').focus();
                adicionarSistema('Conectado ao chat!');
            };

            ws.onmessage = function(evento) {
                // Pode receber múltiplas mensagens separadas por \n
                const linhas = evento.data.split('\n');
                linhas.forEach(linha => {
                    try {
                        const msg = JSON.parse(linha);
                        exibirMensagem(msg);
                    } catch (e) {
                        console.error('Erro ao parsear mensagem:', e);
                    }
                });
            };

            ws.onclose = function() {
                adicionarSistema('Conexão encerrada. Recarregue a página para reconectar.');
            };

            ws.onerror = function(erro) {
                console.error('Erro WebSocket:', erro);
                adicionarSistema('Erro na conexão.');
            };
        }

        function enviar() {
            const input = document.getElementById('inputMensagem');
            const texto = input.value.trim();

            if (!texto || !ws || ws.readyState !== WebSocket.OPEN) return;

            // Envia a mensagem como JSON
            ws.send(JSON.stringify({
                tipo: 'mensagem',
                conteudo: texto
            }));

            input.value = '';
            input.focus();
        }

        function exibirMensagem(msg) {
            const container = document.getElementById('mensagens');
            const div = document.createElement('div');

            if (msg.tipo === 'entrada' || msg.tipo === 'saida' || msg.tipo === 'sistema') {
                div.className = 'mensagem sistema';
                div.innerHTML = `<span class="texto">${msg.conteudo}</span>`;
            } else {
                div.className = 'mensagem';
                div.innerHTML = `
                    <span class="autor">${msg.usuario}</span>
                    <span class="hora">${msg.timestamp || ''}</span>
                    <div class="texto">${escapeHtml(msg.conteudo)}</div>
                `;
            }

            container.appendChild(div);
            container.scrollTop = container.scrollHeight;
        }

        function adicionarSistema(texto) {
            exibirMensagem({ tipo: 'sistema', conteudo: texto });
        }

        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }
    </script>
</body>
</html>

Executando o Chat

# Inicie o servidor
go run .

# Abra no navegador
# http://localhost:8080

# Abra múltiplas abas para simular vários usuários
# Cada aba pode entrar com um nome diferente na mesma sala

Ping/Pong: Mantendo a Conexão Viva

O mecanismo de ping/pong é essencial para detectar conexões mortas. Já implementamos isso no client.go, mas vale entender como funciona:

// O servidor envia PING a cada ~54 segundos
// (90% do tempo de pong, que é 60s)
case <-ticker.C:
    c.conn.SetWriteDeadline(time.Now().Add(tempoEscrita))
    if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
        return // Conexão morta
    }

// O cliente responde automaticamente com PONG (o navegador faz isso)
// Quando recebemos o PONG, renovamos o deadline de leitura
c.conn.SetPongHandler(func(string) error {
    c.conn.SetReadDeadline(time.Now().Add(tempoPong))
    return nil
})

Se o PONG não chegar dentro de 60 segundos, o ReadMessage() retorna erro e o cliente e desconectado. Isso garante que conexões zumbis sejam limpas automaticamente.


Reconexão Automática no Cliente

Para uma experiência robusta, implemente reconexão automática no JavaScript:

// Estratégia de reconexão com backoff exponencial
let tentativasReconexao = 0;
const maxTentativas = 5;

function reconectar() {
    if (tentativasReconexao >= maxTentativas) {
        adicionarSistema('Não foi possível reconectar. Recarregue a página.');
        return;
    }

    tentativasReconexao++;

    // Backoff exponencial: 1s, 2s, 4s, 8s, 16s
    const delay = Math.pow(2, tentativasReconexao - 1) * 1000;

    adicionarSistema(`Reconectando em ${delay / 1000}s... (tentativa ${tentativasReconexao}/${maxTentativas})`);

    setTimeout(() => {
        conectar();
        // Se conectar com sucesso, reseta o contador
        ws.onopen = function() {
            tentativasReconexao = 0;
            // ... restante do onopen
        };
    }, delay);
}

// Adicione no ws.onclose:
ws.onclose = function(evento) {
    if (!evento.wasClean) {
        reconectar();
    }
};

Escalando: Considerações para Produção

1. Limite de Conexões Simultâneas

// Limite global de conexões
var (
    conexoesAtivas int64
    maxConexoes    int64 = 10000
)

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
    // Verifica limite
    if atomic.LoadInt64(&conexoesAtivas) >= maxConexoes {
        http.Error(w, "Servidor lotado", http.StatusServiceUnavailable)
        return
    }

    atomic.AddInt64(&conexoesAtivas, 1)
    defer atomic.AddInt64(&conexoesAtivas, -1)

    // ... restante do handler
}

2. Compressão de Mensagens

// Habilite compressão para economizar banda
upgrader := websocket.Upgrader{
    ReadBufferSize:    1024,
    WriteBufferSize:   1024,
    EnableCompression: true,
}

3. Rate Limiting por Cliente

// Limite de mensagens por segundo por cliente
func (c *Client) LerMensagens() {
    // Permite 10 mensagens por segundo com burst de 5
    limiter := rate.NewLimiter(10, 5)

    for {
        _, dados, err := c.conn.ReadMessage()
        if err != nil {
            break
        }

        // Verifica rate limit
        if !limiter.Allow() {
            // Envia aviso ao cliente
            aviso := Mensagem{
                Tipo:     "sistema",
                Conteudo: "Muitas mensagens! Aguarde um momento.",
            }
            dados, _ := json.Marshal(aviso)
            c.enviar <- dados
            continue
        }

        // Processa a mensagem normalmente...
    }
}

4. Múltiplos Servidores com Redis Pub/Sub

Para escalar horizontalmente, use Redis como broker entre instâncias:

// Em produção com múltiplos servidores:
// Servidor 1 <──> Redis Pub/Sub <──> Servidor 2
//
// Cada servidor publica mensagens no Redis
// e consome mensagens de outros servidores

import "github.com/redis/go-redis/v9"

func (h *Hub) PublicarNoRedis(msg Mensagem) {
    dados, _ := json.Marshal(msg)
    rdb.Publish(ctx, "chat:"+msg.Sala, dados)
}

func (h *Hub) ConsumirDoRedis() {
    sub := rdb.Subscribe(ctx, "chat:*")
    for msg := range sub.Channel() {
        var mensagem Mensagem
        json.Unmarshal([]byte(msg.Payload), &mensagem)
        h.enviarParaSala(mensagem)
    }
}

Conclusão

WebSocket com Go é uma combinação natural, graças ao modelo de concorrência da linguagem. Com goroutines baratas e channels eficientes, Go consegue gerenciar milhares de conexões simultâneas com baixo consumo de recursos.

Neste tutorial, construímos:

  • Servidor WebSocket completo com gorilla/websocket
  • Hub central para gerenciar conexões e salas
  • Client com goroutines de leitura e escrita independentes
  • Ping/pong para manter conexões vivas e detectar desconexões
  • Frontend funcional com HTML, CSS e JavaScript
  • Estratégias de produção: rate limiting, compressão, reconexão e escalabilidade

O padrão Hub + Client que usamos aqui e o mesmo adotado por muitas aplicações de chat em produção. Com Go, um servidor modesto consegue facilmente suportar 10.000+ conexões simultâneas consumindo menos de 100MB de RAM.