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.