Chat TCP em Zig — Tutorial Passo a Passo

Chat TCP em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um servidor de chat TCP que permite múltiplos clientes se conectarem e trocarem mensagens em tempo real. Este é um projeto fundamental para entender programação de redes em Zig, incluindo sockets, polling de I/O e gerenciamento de conexões.

O Que Vamos Construir

Nosso chat vai:

  • Aceitar múltiplas conexões TCP simultâneas
  • Fazer broadcast de mensagens para todos os clientes conectados
  • Suportar nomes de usuário e mensagens formatadas
  • Comandos especiais (/nome, /lista, /sair)
  • Usar poll para I/O não-bloqueante sem threads

Por Que Este Projeto?

Programação de rede é onde Zig realmente brilha. A stdlib fornece wrappers seguros sobre sockets POSIX, e o sistema de erros garante que nunca ignoremos falhas de rede silenciosamente. O modelo de I/O com poll é eficiente e não requer o overhead de threads.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir chat-tcp
cd chat-tcp
zig init

Passo 2: Gerenciamento de Clientes

const std = @import("std");
const net = std.net;
const posix = std.posix;
const io = std.io;
const mem = std.mem;

const MAX_CLIENTES = 64;
const TAMANHO_BUF = 1024;

/// Representa um cliente conectado ao chat.
/// Cada cliente tem um socket, um nome e um buffer de leitura.
const Cliente = struct {
    socket: posix.socket_t,
    nome: [32]u8,
    nome_len: usize,
    ativo: bool,
    buf: [TAMANHO_BUF]u8,

    pub fn nomeStr(self: *const Cliente) []const u8 {
        return self.nome[0..self.nome_len];
    }

    pub fn enviar(self: *const Cliente, msg: []const u8) void {
        _ = posix.write(self.socket, msg) catch {};
    }
};

/// Gerenciador de todos os clientes conectados.
/// Usa um array fixo para evitar alocação dinâmica.
/// A posição no array é o "ID" do cliente.
const GerenciadorClientes = struct {
    clientes: [MAX_CLIENTES]Cliente,
    quantidade: usize,

    const Self = @This();

    pub fn init() Self {
        var g = Self{
            .clientes = undefined,
            .quantidade = 0,
        };
        for (&g.clientes) |*c| {
            c.ativo = false;
        }
        return g;
    }

    /// Adiciona um novo cliente. Retorna o índice ou null se lotado.
    pub fn adicionar(self: *Self, socket: posix.socket_t) ?usize {
        for (self.clientes, 0..) |*c, i| {
            if (!c.ativo) {
                c.* = .{
                    .socket = socket,
                    .nome = undefined,
                    .nome_len = 0,
                    .ativo = true,
                    .buf = undefined,
                };
                // Nome padrão: "Anon_X"
                const nome_padrao = std.fmt.bufPrint(&c.nome, "Anon_{d}", .{i}) catch "Anon";
                c.nome_len = nome_padrao.len;
                self.quantidade += 1;
                return i;
            }
        }
        return null;
    }

    /// Remove um cliente pelo índice.
    pub fn remover(self: *Self, idx: usize) void {
        if (idx < MAX_CLIENTES and self.clientes[idx].ativo) {
            posix.close(self.clientes[idx].socket);
            self.clientes[idx].ativo = false;
            self.quantidade -= 1;
        }
    }

    /// Envia uma mensagem para todos os clientes exceto o remetente.
    pub fn broadcast(self: *Self, msg: []const u8, exceto: ?usize) void {
        for (self.clientes, 0..) |*c, i| {
            if (c.ativo) {
                if (exceto) |ex| {
                    if (i == ex) continue;
                }
                c.enviar(msg);
            }
        }
    }

    /// Retorna lista de nomes dos clientes ativos.
    pub fn listarNomes(self: *const Self, buf: []u8) []const u8 {
        var pos: usize = 0;
        for (self.clientes) |c| {
            if (c.ativo) {
                const nome = c.nomeStr();
                if (pos + nome.len + 2 < buf.len) {
                    @memcpy(buf[pos .. pos + nome.len], nome);
                    pos += nome.len;
                    buf[pos] = ',';
                    buf[pos + 1] = ' ';
                    pos += 2;
                }
            }
        }
        if (pos >= 2) pos -= 2; // remove última vírgula
        return buf[0..pos];
    }
};

Decisão de design: Usamos um array fixo de clientes em vez de ArrayList. Isso elimina alocação dinâmica e torna o gerenciamento de memória trivial. O limite de 64 clientes é razoável para um chat simples.

Passo 3: Processamento de Comandos

/// Processa um comando especial (começa com /).
fn processarComando(
    gerenciador: *GerenciadorClientes,
    idx: usize,
    msg: []const u8,
) void {
    const trimmed = mem.trim(u8, msg, " \t\r\n");

    if (mem.startsWith(u8, trimmed, "/nome ")) {
        const novo_nome = mem.trim(u8, trimmed[6..], " ");
        if (novo_nome.len > 0 and novo_nome.len <= 31) {
            var buf_msg: [128]u8 = undefined;
            const aviso = std.fmt.bufPrint(&buf_msg, "* {s} agora se chama {s}\n", .{
                gerenciador.clientes[idx].nomeStr(), novo_nome,
            }) catch return;

            @memcpy(gerenciador.clientes[idx].nome[0..novo_nome.len], novo_nome);
            gerenciador.clientes[idx].nome_len = novo_nome.len;

            gerenciador.broadcast(aviso, null);
        } else {
            gerenciador.clientes[idx].enviar("* Nome invalido (1-31 caracteres)\n");
        }
    } else if (mem.eql(u8, trimmed, "/lista")) {
        var buf_lista: [1024]u8 = undefined;
        const lista = gerenciador.listarNomes(&buf_lista);
        var buf_msg: [1100]u8 = undefined;
        const msg_lista = std.fmt.bufPrint(&buf_msg, "* Usuarios online: {s}\n", .{lista}) catch return;
        gerenciador.clientes[idx].enviar(msg_lista);
    } else if (mem.eql(u8, trimmed, "/sair")) {
        var buf_msg: [128]u8 = undefined;
        const aviso = std.fmt.bufPrint(&buf_msg, "* {s} saiu do chat\n", .{
            gerenciador.clientes[idx].nomeStr(),
        }) catch return;
        gerenciador.broadcast(aviso, idx);
        gerenciador.remover(idx);
    } else if (mem.eql(u8, trimmed, "/ajuda")) {
        gerenciador.clientes[idx].enviar(
            \\* Comandos disponiveis:
            \\*   /nome <novo_nome> - Alterar nome
            \\*   /lista           - Ver usuarios online
            \\*   /sair            - Sair do chat
            \\*   /ajuda           - Esta mensagem
            \\
        );
    } else {
        gerenciador.clientes[idx].enviar("* Comando desconhecido. Use /ajuda\n");
    }
}

Passo 4: Servidor Principal com Poll

/// Configura o servidor TCP.
fn criarServidor(porta: u16) !posix.socket_t {
    const endereco = net.Address.initIp4(.{ 0, 0, 0, 0 }, porta);
    const socket = try posix.socket(posix.AF.INET, posix.SOCK.STREAM, 0);
    errdefer posix.close(socket);

    // Permite reusar o endereço (útil durante desenvolvimento)
    const optval: i32 = 1;
    try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.REUSEADDR, std.mem.asBytes(&optval));

    try posix.bind(socket, &endereco.any, endereco.getOsSockLen());
    try posix.listen(socket, 10);

    return socket;
}

pub fn main() !void {
    const stdout = io.getStdOut().writer();
    const porta: u16 = 8080;

    const servidor_socket = try criarServidor(porta);
    defer posix.close(servidor_socket);

    try stdout.print(
        \\
        \\  ==========================================
        \\     CHAT TCP - Servidor Zig
        \\  ==========================================
        \\  Escutando na porta {d}
        \\  Conecte com: nc localhost {d}
        \\  ou: telnet localhost {d}
        \\
    , .{ porta, porta, porta });

    var gerenciador = GerenciadorClientes.init();

    // Array de poll file descriptors
    // Posição 0 = socket do servidor (para aceitar conexões)
    // Posições 1+ = sockets dos clientes (para receber dados)
    var pollfds: [MAX_CLIENTES + 1]posix.pollfd = undefined;
    pollfds[0] = .{
        .fd = servidor_socket,
        .events = posix.POLL.IN,
        .revents = 0,
    };

    // Inicializa o resto como inválido
    for (pollfds[1..]) |*pfd| {
        pfd.fd = -1;
        pfd.events = 0;
        pfd.revents = 0;
    }

    while (true) {
        // Atualiza pollfds com os sockets ativos
        for (gerenciador.clientes, 0..) |c, i| {
            if (c.ativo) {
                pollfds[i + 1] = .{
                    .fd = c.socket,
                    .events = posix.POLL.IN,
                    .revents = 0,
                };
            } else {
                pollfds[i + 1].fd = -1;
            }
        }

        // Espera por atividade em qualquer socket
        _ = posix.poll(&pollfds, -1) catch continue;

        // Verificar novas conexões
        if (pollfds[0].revents & posix.POLL.IN != 0) {
            const cliente_socket = posix.accept(servidor_socket, null, null) catch continue;

            if (gerenciador.adicionar(cliente_socket)) |idx| {
                const nome = gerenciador.clientes[idx].nomeStr();
                try stdout.print("  [+] {s} conectou\n", .{nome});

                // Boas-vindas
                var buf_msg: [256]u8 = undefined;
                const boas_vindas = std.fmt.bufPrint(&buf_msg,
                    \\* Bem-vindo ao Chat Zig! Voce e {s}
                    \\* Use /nome para mudar, /ajuda para comandos
                    \\
                , .{nome}) catch continue;
                gerenciador.clientes[idx].enviar(boas_vindas);

                // Avisa outros
                const aviso = std.fmt.bufPrint(&buf_msg, "* {s} entrou no chat\n", .{nome}) catch continue;
                gerenciador.broadcast(aviso, idx);
            } else {
                // Servidor lotado
                _ = posix.write(cliente_socket, "* Servidor lotado. Tente mais tarde.\n") catch {};
                posix.close(cliente_socket);
            }
        }

        // Verificar mensagens dos clientes
        for (gerenciador.clientes, 0..) |*c, i| {
            if (!c.ativo) continue;

            if (pollfds[i + 1].revents & posix.POLL.IN != 0) {
                const bytes_lidos = posix.read(c.socket, &c.buf) catch 0;

                if (bytes_lidos == 0) {
                    // Cliente desconectou
                    var buf_msg: [128]u8 = undefined;
                    const aviso = std.fmt.bufPrint(&buf_msg, "* {s} desconectou\n", .{c.nomeStr()}) catch continue;
                    try stdout.print("  [-] {s} desconectou\n", .{c.nomeStr()});
                    gerenciador.remover(i);
                    gerenciador.broadcast(aviso, null);
                } else {
                    const msg = c.buf[0..bytes_lidos];
                    const trimmed = mem.trim(u8, msg, " \t\r\n");

                    if (trimmed.len > 0 and trimmed[0] == '/') {
                        processarComando(&gerenciador, i, trimmed);
                    } else if (trimmed.len > 0) {
                        // Broadcast mensagem normal
                        var buf_msg: [TAMANHO_BUF + 64]u8 = undefined;
                        const formatada = std.fmt.bufPrint(&buf_msg, "[{s}] {s}\n", .{
                            c.nomeStr(), trimmed,
                        }) catch continue;
                        gerenciador.broadcast(formatada, i);
                        try stdout.print("  {s}", .{formatada});
                    }
                }
            }
        }
    }
}

Passo 5: Cliente Simples

Para testar, você pode usar nc (netcat) ou telnet, mas aqui está um cliente básico em Zig:

// cliente.zig - compilar separadamente
const std = @import("std");
const net = std.net;
const posix = std.posix;

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    const stdin = std.io.getStdIn();

    const endereco = net.Address.initIp4(.{ 127, 0, 0, 1 }, 8080);
    const socket = try posix.socket(posix.AF.INET, posix.SOCK.STREAM, 0);
    defer posix.close(socket);

    try posix.connect(socket, &endereco.any, endereco.getOsSockLen());
    try stdout.print("Conectado! Digite mensagens (CTRL+C para sair):\n", .{});

    var pollfds = [_]posix.pollfd{
        .{ .fd = socket, .events = posix.POLL.IN, .revents = 0 },
        .{ .fd = stdin.handle, .events = posix.POLL.IN, .revents = 0 },
    };

    var buf: [1024]u8 = undefined;

    while (true) {
        _ = posix.poll(&pollfds, -1) catch continue;

        // Dados do servidor
        if (pollfds[0].revents & posix.POLL.IN != 0) {
            const n = posix.read(socket, &buf) catch break;
            if (n == 0) break;
            try stdout.print("{s}", .{buf[0..n]});
        }

        // Entrada do teclado
        if (pollfds[1].revents & posix.POLL.IN != 0) {
            const n = posix.read(stdin.handle, &buf) catch break;
            if (n == 0) break;
            _ = posix.write(socket, buf[0..n]) catch break;
        }
    }

    try stdout.print("Desconectado.\n", .{});
}

Testes

test "gerenciador - adicionar e remover" {
    var g = GerenciadorClientes.init();
    // Não podemos testar com sockets reais em testes unitários,
    // mas podemos verificar a lógica do gerenciador
    try std.testing.expectEqual(@as(usize, 0), g.quantidade);
}

test "listar nomes vazio" {
    var g = GerenciadorClientes.init();
    var buf: [1024]u8 = undefined;
    const lista = g.listarNomes(&buf);
    try std.testing.expectEqual(@as(usize, 0), lista.len);
}

Compilando e Executando

# Terminal 1: Servidor
zig build run

# Terminal 2: Cliente
nc localhost 8080

# Terminal 3: Outro cliente
nc localhost 8080

Conceitos Aprendidos

  • Programação com sockets TCP em Zig
  • I/O multiplexado com poll
  • Gerenciamento de múltiplas conexões simultâneas
  • Broadcast de mensagens
  • Processamento de comandos com prefixo

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.