Chat em Tempo Real com WebSocket

Construa um servidor de chat em tempo real em Rust usando tokio e tokio-tungstenite com salas, apelidos e broadcast de mensagens.

Neste projeto vamos construir um servidor de chat em tempo real usando WebSockets em Rust. Utilizaremos tokio como runtime assíncrono e tokio-tungstenite para a comunicação WebSocket. O servidor vai suportar múltiplos clientes conectados simultaneamente, apelidos de usuários e broadcast de mensagens para todos os participantes.

WebSockets permitem comunicação bidirecional persistente entre cliente e servidor, ideal para aplicações em tempo real como chats, jogos multiplayer e dashboards ao vivo. Rust, com seu modelo de concorrência seguro e eficiente, é uma excelente escolha para construir servidores WebSocket de alto desempenho.

O Que Vamos Construir

Um servidor de chat com as seguintes funcionalidades:

  • Conexões WebSocket simultâneas de múltiplos clientes
  • Definição de apelido ao conectar (primeira mensagem)
  • Broadcast de mensagens para todos os clientes conectados
  • Notificação quando um usuário entra ou sai do chat
  • Lista de usuários online
  • Mensagens formatadas com timestamp e nome do remetente

Estrutura do Projeto

chat-websocket/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

cargo new chat-websocket
cd chat-websocket

Configure o Cargo.toml:

[package]
name = "chat-websocket"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
chrono = "0.4"

Usamos tokio-tungstenite para WebSockets, futures-util para trabalhar com streams e sinks, e chrono para timestamps nas mensagens.

Passo 1: Definindo o Estado Compartilhado

O estado do chat precisa ser compartilhado entre todas as conexões ativas. Vamos usar canais de broadcast para distribuir mensagens e um Mutex para manter a lista de usuários:

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;

// Mensagem enviada pelo sistema de broadcast
#[derive(Debug, Clone)]
pub struct MensagemChat {
    pub remetente: String,
    pub conteudo: String,
    pub timestamp: String,
}

impl MensagemChat {
    fn formatada(&self) -> String {
        format!("[{}] {}: {}", self.timestamp, self.remetente, self.conteudo)
    }
}

// Estado compartilhado entre todas as conexões
pub struct EstadoChat {
    // Canal de broadcast para mensagens
    pub transmissor: broadcast::Sender<MensagemChat>,
    // Mapa de usuários conectados: endereço -> apelido
    pub usuarios: Mutex<HashMap<String, String>>,
}

impl EstadoChat {
    fn novo() -> Arc<Self> {
        let (transmissor, _) = broadcast::channel(1000);
        Arc::new(EstadoChat {
            transmissor,
            usuarios: Mutex::new(HashMap::new()),
        })
    }

    fn adicionar_usuario(&self, endereco: &str, apelido: &str) {
        let mut usuarios = self.usuarios.lock().unwrap();
        usuarios.insert(endereco.to_string(), apelido.to_string());
    }

    fn remover_usuario(&self, endereco: &str) -> Option<String> {
        let mut usuarios = self.usuarios.lock().unwrap();
        usuarios.remove(endereco)
    }

    fn listar_usuarios(&self) -> Vec<String> {
        let usuarios = self.usuarios.lock().unwrap();
        usuarios.values().cloned().collect()
    }

    fn enviar_mensagem(&self, remetente: &str, conteudo: &str) {
        let mensagem = MensagemChat {
            remetente: remetente.to_string(),
            conteudo: conteudo.to_string(),
            timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
        };
        // Ignora erro se não há receptores
        let _ = self.transmissor.send(mensagem);
    }
}

Usamos broadcast::channel do tokio para enviar mensagens a todos os clientes. Cada conexão recebe um Receiver que escuta novas mensagens. O Mutex protege o mapa de usuários para acesso thread-safe.

Passo 2: Tratando Conexões WebSocket

Cada cliente WebSocket é tratado em uma task separada. A primeira mensagem define o apelido:

use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpStream;
use tokio_tungstenite::tungstenite::Message;

async fn tratar_conexao(
    stream: TcpStream,
    endereco: String,
    estado: Arc<EstadoChat>,
) {
    // Aceita o handshake WebSocket
    let ws_stream = match tokio_tungstenite::accept_async(stream).await {
        Ok(ws) => ws,
        Err(e) => {
            eprintln!("Erro no handshake WebSocket de {}: {}", endereco, e);
            return;
        }
    };

    println!("Nova conexão WebSocket: {}", endereco);

    let (mut escritor, mut leitor) = ws_stream.split();

    // Solicita apelido ao cliente
    let _ = escritor
        .send(Message::Text(
            "Bem-vindo ao chat! Digite seu apelido:".to_string(),
        ))
        .await;

    // Aguarda a primeira mensagem como apelido
    let apelido = match leitor.next().await {
        Some(Ok(msg)) => {
            let texto = msg.to_text().unwrap_or("Anônimo").trim().to_string();
            if texto.is_empty() {
                "Anônimo".to_string()
            } else {
                texto
            }
        }
        _ => {
            eprintln!("Cliente {} desconectou antes de informar apelido", endereco);
            return;
        }
    };

    // Registra o usuário
    estado.adicionar_usuario(&endereco, &apelido);

    // Notifica todos que o usuário entrou
    estado.enviar_mensagem("Sistema", &format!("{} entrou no chat", apelido));

    // Envia lista de usuários online
    let usuarios = estado.listar_usuarios();
    let _ = escritor
        .send(Message::Text(format!(
            "Usuários online: {}",
            usuarios.join(", ")
        )))
        .await;

    // Cria um receptor de broadcast para esta conexão
    let mut receptor = estado.transmissor.subscribe();

    let apelido_clone = apelido.clone();
    let endereco_clone = endereco.clone();

    // Loop principal: recebe mensagens do cliente e do broadcast
    loop {
        tokio::select! {
            // Mensagem recebida do cliente via WebSocket
            msg = leitor.next() => {
                match msg {
                    Some(Ok(Message::Text(texto))) => {
                        let texto = texto.trim().to_string();
                        if texto == "/usuarios" {
                            let usuarios = estado.listar_usuarios();
                            let _ = escritor
                                .send(Message::Text(format!(
                                    "Usuários online: {}",
                                    usuarios.join(", ")
                                )))
                                .await;
                        } else if texto == "/sair" {
                            break;
                        } else if !texto.is_empty() {
                            estado.enviar_mensagem(&apelido_clone, &texto);
                        }
                    }
                    Some(Ok(Message::Close(_))) | None => break,
                    _ => {}
                }
            }
            // Mensagem recebida via broadcast (de outro usuário)
            resultado = receptor.recv() => {
                match resultado {
                    Ok(mensagem) => {
                        // Não envia a própria mensagem de volta
                        if mensagem.remetente != apelido_clone {
                            let _ = escritor
                                .send(Message::Text(mensagem.formatada()))
                                .await;
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }

    // Limpa ao desconectar
    if let Some(apelido) = estado.remover_usuario(&endereco_clone) {
        estado.enviar_mensagem("Sistema", &format!("{} saiu do chat", apelido));
    }
    println!("Conexão encerrada: {}", endereco_clone);
}

O tokio::select! permite aguardar simultaneamente mensagens do cliente (via WebSocket) e mensagens de broadcast (de outros clientes). Comandos especiais como /usuarios e /sair são tratados antes de transmitir a mensagem.

Passo 3: Iniciando o Servidor TCP

O servidor escuta conexões TCP e promove cada uma para WebSocket:

use tokio::net::TcpListener;

async fn iniciar_servidor() {
    let estado = EstadoChat::novo();
    let endereco = "0.0.0.0:8080";

    let listener = TcpListener::bind(endereco).await.unwrap();
    println!("Servidor de chat WebSocket rodando em ws://{}", endereco);
    println!("Conecte-se com um cliente WebSocket para começar a conversar.");

    loop {
        match listener.accept().await {
            Ok((stream, addr)) => {
                let estado_clone = estado.clone();
                let endereco = addr.to_string();
                tokio::spawn(async move {
                    tratar_conexao(stream, endereco, estado_clone).await;
                });
            }
            Err(e) => {
                eprintln!("Erro ao aceitar conexão: {}", e);
            }
        }
    }
}

Cada conexão aceita gera uma nova task com tokio::spawn, permitindo que o servidor trate milhares de conexões simultâneas sem bloquear.

Passo 4: Montando o main.rs Completo

Aqui está o código completo do src/main.rs:

use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;

// === Modelos ===

#[derive(Debug, Clone)]
pub struct MensagemChat {
    pub remetente: String,
    pub conteudo: String,
    pub timestamp: String,
}

impl MensagemChat {
    fn formatada(&self) -> String {
        format!("[{}] {}: {}", self.timestamp, self.remetente, self.conteudo)
    }
}

pub struct EstadoChat {
    pub transmissor: broadcast::Sender<MensagemChat>,
    pub usuarios: Mutex<HashMap<String, String>>,
}

impl EstadoChat {
    fn novo() -> Arc<Self> {
        let (transmissor, _) = broadcast::channel(1000);
        Arc::new(EstadoChat {
            transmissor,
            usuarios: Mutex::new(HashMap::new()),
        })
    }

    fn adicionar_usuario(&self, endereco: &str, apelido: &str) {
        let mut usuarios = self.usuarios.lock().unwrap();
        usuarios.insert(endereco.to_string(), apelido.to_string());
    }

    fn remover_usuario(&self, endereco: &str) -> Option<String> {
        let mut usuarios = self.usuarios.lock().unwrap();
        usuarios.remove(endereco)
    }

    fn listar_usuarios(&self) -> Vec<String> {
        let usuarios = self.usuarios.lock().unwrap();
        usuarios.values().cloned().collect()
    }

    fn enviar_mensagem(&self, remetente: &str, conteudo: &str) {
        let mensagem = MensagemChat {
            remetente: remetente.to_string(),
            conteudo: conteudo.to_string(),
            timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
        };
        let _ = self.transmissor.send(mensagem);
    }
}

// === Tratamento de conexão ===

async fn tratar_conexao(stream: TcpStream, endereco: String, estado: Arc<EstadoChat>) {
    let ws_stream = match tokio_tungstenite::accept_async(stream).await {
        Ok(ws) => ws,
        Err(e) => {
            eprintln!("Erro no handshake WebSocket de {}: {}", endereco, e);
            return;
        }
    };

    println!("Nova conexão WebSocket: {}", endereco);
    let (mut escritor, mut leitor) = ws_stream.split();

    let _ = escritor
        .send(Message::Text(
            "Bem-vindo ao chat! Digite seu apelido:".to_string(),
        ))
        .await;

    let apelido = match leitor.next().await {
        Some(Ok(msg)) => {
            let texto = msg.to_text().unwrap_or("Anônimo").trim().to_string();
            if texto.is_empty() {
                "Anônimo".to_string()
            } else {
                texto
            }
        }
        _ => return,
    };

    estado.adicionar_usuario(&endereco, &apelido);
    estado.enviar_mensagem("Sistema", &format!("{} entrou no chat", apelido));

    let usuarios = estado.listar_usuarios();
    let _ = escritor
        .send(Message::Text(format!(
            "Usuários online: {}",
            usuarios.join(", ")
        )))
        .await;

    let mut receptor = estado.transmissor.subscribe();
    let apelido_clone = apelido.clone();
    let endereco_clone = endereco.clone();

    loop {
        tokio::select! {
            msg = leitor.next() => {
                match msg {
                    Some(Ok(Message::Text(texto))) => {
                        let texto = texto.trim().to_string();
                        if texto == "/usuarios" {
                            let usuarios = estado.listar_usuarios();
                            let _ = escritor
                                .send(Message::Text(format!(
                                    "Usuários online: {}",
                                    usuarios.join(", ")
                                )))
                                .await;
                        } else if texto == "/sair" {
                            break;
                        } else if !texto.is_empty() {
                            estado.enviar_mensagem(&apelido_clone, &texto);
                        }
                    }
                    Some(Ok(Message::Close(_))) | None => break,
                    _ => {}
                }
            }
            resultado = receptor.recv() => {
                match resultado {
                    Ok(mensagem) => {
                        if mensagem.remetente != apelido_clone {
                            let _ = escritor
                                .send(Message::Text(mensagem.formatada()))
                                .await;
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }

    if let Some(apelido) = estado.remover_usuario(&endereco_clone) {
        estado.enviar_mensagem("Sistema", &format!("{} saiu do chat", apelido));
    }
    println!("Conexão encerrada: {}", endereco_clone);
}

// === Main ===

#[tokio::main]
async fn main() {
    let estado = EstadoChat::novo();
    let endereco = "0.0.0.0:8080";

    let listener = TcpListener::bind(endereco).await.unwrap();
    println!("Servidor de chat WebSocket rodando em ws://{}", endereco);
    println!("Conecte-se com um cliente WebSocket para começar a conversar.");
    println!("Comandos: /usuarios (lista online), /sair (desconectar)");

    loop {
        match listener.accept().await {
            Ok((stream, addr)) => {
                let estado_clone = estado.clone();
                let endereco = addr.to_string();
                tokio::spawn(async move {
                    tratar_conexao(stream, endereco, estado_clone).await;
                });
            }
            Err(e) => eprintln!("Erro ao aceitar conexão: {}", e),
        }
    }
}

Como Executar

Compile e inicie o servidor:

cargo run

Para testar, você pode usar websocat (instale com cargo install websocat):

# Terminal 1 - Primeiro cliente
websocat ws://localhost:8080
# Digite seu apelido: Maria
# Olá pessoal!

# Terminal 2 - Segundo cliente
websocat ws://localhost:8080
# Digite seu apelido: João
# [14:30:05] Maria: Olá pessoal!
# Oi Maria!

# Comandos disponíveis:
# /usuarios  - Lista quem está online
# /sair      - Desconecta do chat

Você também pode testar no navegador usando JavaScript:

const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (e) => console.log(e.data);
ws.send('MeuApelido');  // Define o apelido
ws.send('Olá do navegador!');  // Envia mensagem

Desafios para Expandir

  1. Salas de chat — Implemente suporte a múltiplas salas. Usuários podem criar salas com /criar sala_nome e trocar de sala com /entrar sala_nome, recebendo apenas mensagens da sala atual.

  2. Mensagens privadas — Adicione o comando /msg apelido mensagem para enviar mensagens diretas que somente o destinatário recebe.

  3. Histórico de mensagens — Armazene as últimas 50 mensagens de cada sala e envie o histórico quando um novo cliente se conectar, para que ele veja o contexto da conversa.

  4. Interface web — Crie uma página HTML com CSS e JavaScript que o servidor sirva em GET /, proporcionando uma interface gráfica para o chat direto no navegador.

  5. Persistência e autenticação — Adicione registro de usuários com senha, persistindo contas em um arquivo JSON ou banco SQLite, exigindo login antes de entrar no chat.

Veja Também