Foro en Tiempo Real por CLI en Go: TCP, TLS y Cero Dependencias
Construye paso a paso un foro de chat en tiempo real 100% CLI en Go: TCP, TLS, broadcast sin duplicados por UUID y reconexión resiliente, cero dependencias.
Contenido
Un Foro, No un Chat Privado
Vamos a construir algo distinto a la típica app de notas: un foro comunal en tiempo real, por terminal. Cualquiera que se conecte ve todos los mensajes de todos. No hay salas privadas, no hay mensajes directos, no hay base de datos — cuando apagas el servidor, la conversación desaparece con él. Es, a propósito, tan simple como una plaza pública.
Pero “simple” en el resultado no significa “simple” en la construcción. Un foro en tiempo real bien hecho tiene que resolver problemas reales de sistemas concurrentes: ¿qué pasa si dos personas se conectan con el mismo nombre? ¿Qué pasa si un cliente es tan lento que bloquea a todos los demás? ¿Qué pasa si la red se cae a medias de un mensaje? ¿Cómo evitas que ese mismo mensaje se duplique si el cliente reintenta?
Esta guía responde cada una de esas preguntas con código, no con teoría. Al final tendrás un servidor (forumd) y un cliente de terminal (forum), comunicándose por TCP con TLS, sin un solo paquete externo — cero dependencias, toda la librería estándar de Go.
Qué Vamos a Construir
- Un foro general: todo mensaje se transmite (broadcast) a todos los conectados
- Cada mensaje lleva un UUID generado por el cliente — el servidor descarta duplicados
- Nombres de usuario únicos, resueltos automáticamente si hay colisión
- Transporte cifrado con TLS 1.3, verificación de certificado real (nada de
InsecureSkipVerify) - Cliente resiliente: reconexión automática con backoff exponencial y jitter
- Servidor resiliente: límite de tasa por cliente, desconexión de clientes lentos, heartbeats
- Cero persistencia — todo vive en memoria, todo se pierde al apagar el servidor
- Cero dependencias externas — sin
go.sum, solo librería estándar - 100% CLI: ni HTTP, ni navegador, ni WebSocket — sockets TCP puros
go version
# go version go1.27.1 linux/amd64 (o superior)
Arquitectura: Un Hub, Sin Mutex
La pieza central es el Hub: una única goroutine que es la única dueña del estado — la lista de clientes conectados, los nombres en uso, los IDs de mensajes ya vistos. Nadie más toca ese estado directamente. Todo el mundo le habla al Hub a través de canales.
Esto no es una elección estética. Es la aplicación literal del lema de Go: no comuniques compartiendo memoria, comparte memoria comunicando. Como solo una goroutine lee y escribe los mapas internos, no hace falta ni un sync.Mutex en todo el proyecto.
graph LR
subgraph "Clientes"
C1["forum CLI<br/>alice"]
C2["forum CLI<br/>bob"]
C3["forum CLI<br/>carol"]
end
subgraph "forumd"
L["Listener TLS"]
H["Hub<br/>una sola goroutine<br/>dueña de todo el estado"]
D1["conexión alice<br/>readPump / writePump"]
D2["conexión bob<br/>readPump / writePump"]
D3["conexión carol<br/>readPump / writePump"]
end
C1 <-->|"TCP + TLS"| L
C2 <-->|"TCP + TLS"| L
C3 <-->|"TCP + TLS"| L
L --> D1
L --> D2
L --> D3
D1 <-->|"canales"| H
D2 <-->|"canales"| H
D3 <-->|"canales"| H
Cada conexión entrante corre en su propia goroutine, dividida en dos mitades clásicas: readPump (lee del socket, valida, envía al Hub) y writePump (recibe del Hub, escribe al socket). El Hub es el único punto de encuentro entre todas ellas.
Preparando el Proyecto
mkdir forum && cd forum
go mod init forum
No vas a instalar nada más. En serio — nada de go get. Todo lo que necesitas ya está en la librería estándar: net, crypto/tls, bufio, encoding/json, context, log/slog.
mkdir -p cmd/forumd cmd/forum
mkdir -p internal/chat internal/server internal/client
mkdir -p certs
Árbol final:
forum/
├── cmd/
│ ├── forumd/
│ │ └── main.go
│ └── forum/
│ └── main.go
├── internal/
│ ├── chat/
│ │ ├── message.go
│ │ ├── id.go
│ │ └── message_test.go
│ ├── server/
│ │ ├── hub.go
│ │ ├── client.go
│ │ ├── limiter.go
│ │ └── hub_test.go
│ └── client/
│ └── session.go
├── certs/
│ ├── server.crt
│ └── server.key
├── go.mod
├── Dockerfile
├── docker-compose.yml
└── Makefile
Fíjate: no hay go.sum en este árbol. Sin dependencias externas, no hay nada que sumar.
Paso 1: El Protocolo — Mensajes por Línea
Antes de escribir un socket, define cómo hablan cliente y servidor entre sí. Vamos a usar JSON delimitado por saltos de línea (NDJSON): cada mensaje es un objeto JSON en una sola línea, terminado en \n. Es depurable a simple vista, no requiere un framing binario propio, y bufio.Scanner lo parte solo.
internal/chat/message.go
package chat
import (
"encoding/json"
"time"
)
type Kind string
const (
KindHello Kind = "hello"
KindWelcome Kind = "welcome"
KindMessage Kind = "message"
KindSystem Kind = "system"
KindPing Kind = "ping"
KindPong Kind = "pong"
)
const MaxBodyBytes = 2000
type Envelope struct {
Kind Kind `json:"kind"`
ID string `json:"id,omitempty"`
From string `json:"from,omitempty"`
Body string `json:"body,omitempty"`
SentAt time.Time `json:"sent_at"`
}
func Encode(e Envelope) ([]byte, error) {
b, err := json.Marshal(e)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
func Decode(line []byte) (Envelope, error) {
var e Envelope
err := json.Unmarshal(line, &e)
return e, err
}
Seis tipos de mensaje (Kind) cubren todo el protocolo: hello (el cliente se presenta), welcome (el servidor confirma el nombre asignado), message (chat real), system (avisos de entrada/salida), y ping/pong (el latido que mantiene viva la conexión).
internal/chat/id.go
package chat
import (
"crypto/rand"
"fmt"
)
func NewID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
Un UUID v4 real, generado con crypto/rand, en 12 líneas — sin traer github.com/google/uuid. Un UUID v4 son 16 bytes aleatorios con seis bits fijos (versión y variante); eso es literalmente todo lo que hace esta función.
internal/chat/message_test.go
package chat
import "testing"
func TestEncodeDecodeRoundTrip(t *testing.T) {
original := Envelope{Kind: KindMessage, ID: NewID(), From: "omar", Body: "hola foro"}
raw, err := Encode(original)
if err != nil {
t.Fatalf("Encode() error = %v", err)
}
decoded, err := Decode(raw)
if err != nil {
t.Fatalf("Decode() error = %v", err)
}
if decoded.ID != original.ID || decoded.Body != original.Body {
t.Fatalf("round trip mismatch: got %+v, want %+v", decoded, original)
}
}
func TestNewIDIsUnique(t *testing.T) {
seen := make(map[string]bool)
for range 1000 {
id := NewID()
if seen[id] {
t.Fatalf("duplicate ID generated: %s", id)
}
seen[id] = true
}
}
for range 1000 — Go moderno de verdad: iterar sobre un entero sin declarar variable de índice, disponible desde Go 1.22.
Paso 2: El Hub — El Corazón del Servidor
El Hub no sabe nada de TCP. No sabe nada de TLS. Solo conoce clientes abstractos con un nombre y un canal de salida. Esa separación es la que permite razonar sobre él sin pensar en sockets.
internal/server/hub.go
package server
import (
"fmt"
"log/slog"
"time"
"forum/internal/chat"
)
type joinRequest struct {
desired string
client *client
done chan struct{}
}
type Hub struct {
joins chan joinRequest
leaves chan *client
broadcast chan chat.Envelope
clients map[*client]struct{}
names map[string]struct{}
seen map[string]time.Time
}
func NewHub() *Hub {
return &Hub{
joins: make(chan joinRequest),
leaves: make(chan *client),
broadcast: make(chan chat.Envelope, 256),
clients: make(map[*client]struct{}),
names: make(map[string]struct{}),
seen: make(map[string]time.Time),
}
}
func (h *Hub) Join(desired string) *client {
c := &client{id: chat.NewID(), send: make(chan chat.Envelope, 16)}
req := joinRequest{desired: desired, client: c, done: make(chan struct{})}
h.joins <- req
<-req.done
return c
}
func (h *Hub) Leave(c *client) {
h.leaves <- c
}
func (h *Hub) Broadcast(env chat.Envelope) {
h.broadcast <- env
}
func (h *Hub) Run() {
cleanup := time.NewTicker(time.Minute)
defer cleanup.Stop()
for {
select {
case req := <-h.joins:
username := h.uniqueName(req.desired)
req.client.username = username
h.clients[req.client] = struct{}{}
h.names[username] = struct{}{}
close(req.done)
slog.Info("client joined", "username", username, "id", req.client.id)
h.deliver(chat.Envelope{
Kind: chat.KindSystem,
Body: username + " joined the forum",
SentAt: time.Now().UTC(),
})
case c := <-h.leaves:
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
delete(h.names, c.username)
close(c.send)
slog.Info("client left", "username", c.username, "id", c.id)
h.deliver(chat.Envelope{
Kind: chat.KindSystem,
Body: c.username + " left the forum",
SentAt: time.Now().UTC(),
})
}
case env := <-h.broadcast:
if env.ID != "" {
if _, dup := h.seen[env.ID]; dup {
continue
}
h.seen[env.ID] = time.Now()
}
h.deliver(env)
case now := <-cleanup.C:
for id, at := range h.seen {
if now.Sub(at) > 5*time.Minute {
delete(h.seen, id)
}
}
}
}
}
func (h *Hub) deliver(env chat.Envelope) {
for c := range h.clients {
select {
case c.send <- env:
default:
slog.Warn("dropping slow client", "username", c.username)
delete(h.clients, c)
delete(h.names, c.username)
close(c.send)
}
}
}
func (h *Hub) uniqueName(base string) string {
name := base
for n := 2; ; n++ {
if _, taken := h.names[name]; !taken {
return name
}
name = fmt.Sprintf("%s-%d", base, n)
}
}
Cuatro decisiones que sostienen todo lo demás:
Joines una petición, no una orden. Envía unjoinRequestcon un canaldoney espera. Eso obliga a que la asignación del nombre único ocurra dentro del Run() de la Hub, serializada, sin ninguna carrera posible aunque dos personas se conecten con el mismo nombre en el mismo microsegundo.- La deduplicación vive en el mismo
select. CadaEnvelopeque llega ah.broadcastse compara contrah.seenantes de repartirse. Si el ID ya se vio, se descarta silenciosamente — así un mensaje reenviado por error (o por un reintento del cliente) nunca llega dos veces. delivernunca bloquea. Elselectcon ramadefaultsignifica: si el canal de un cliente está lleno (16 mensajes en cola sin drenar), ese cliente se considera lento y se desconecta ahí mismo. Un solo cliente colgado no puede congelar la sala entera.- La limpieza de IDs vistos usa un
time.Tickeren el mismoselect. No hace falta una goroutine aparte para el housekeeping — es solo otro caso más en el mismo bucle.
close(req.done) y <-req.done forman una barrera de sincronización real: todo lo que el Hub escribió antes de cerrar ese canal (incluyendo req.client.username) es visible para quien hizo Join() en cuanto retorna. No hace falta ningún mutex para leer c.username después.
Paso 3: Por Conexión — readPump y writePump
Cada conexión aceptada obtiene su propio par de goroutines: una que lee, una que escribe. Nunca se cruzan directamente — se comunican con el Hub por canales, y entre ellas coordinan el cierre a través del canal send.
internal/server/limiter.go
package server
import "time"
type limiter struct {
tokens float64
max float64
rate float64
last time.Time
}
func newLimiter(maxTokens, perSecond float64) *limiter {
return &limiter{tokens: maxTokens, max: maxTokens, rate: perSecond, last: time.Now()}
}
func (l *limiter) allow() bool {
now := time.Now()
l.tokens = min(l.max, l.tokens+now.Sub(l.last).Seconds()*l.rate)
l.last = now
if l.tokens < 1 {
return false
}
l.tokens--
return true
}
Un token bucket de 15 líneas, sin sync.Mutex, porque cada instancia vive dentro de la goroutine de un único cliente — nadie más la toca.
internal/server/client.go
package server
import (
"bufio"
"net"
"strings"
"time"
"forum/internal/chat"
)
const (
readTimeout = 90 * time.Second
writeTimeout = 10 * time.Second
pingInterval = 30 * time.Second
)
type client struct {
id string
username string
conn net.Conn
send chan chat.Envelope
}
func Serve(conn net.Conn, hub *Hub) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 4096), chat.MaxBodyBytes*4)
conn.SetReadDeadline(time.Now().Add(readTimeout))
if !scanner.Scan() {
return
}
hello, err := chat.Decode(scanner.Bytes())
if err != nil || hello.Kind != chat.KindHello {
return
}
c := hub.Join(sanitizeUsername(hello.From))
c.conn = conn
c.send <- chat.Envelope{Kind: chat.KindWelcome, From: c.username}
writeDone := make(chan struct{})
go func() {
writePump(c)
close(writeDone)
}()
readPump(c, scanner, hub)
hub.Leave(c)
<-writeDone
}
func readPump(c *client, scanner *bufio.Scanner, hub *Hub) {
limit := newLimiter(5, 1)
for {
c.conn.SetReadDeadline(time.Now().Add(readTimeout))
if !scanner.Scan() {
return
}
env, err := chat.Decode(scanner.Bytes())
if err != nil || env.Kind != chat.KindMessage {
continue
}
if !limit.allow() {
continue
}
body := strings.TrimSpace(env.Body)
if body == "" || len(body) > chat.MaxBodyBytes {
continue
}
id := env.ID
if id == "" {
id = chat.NewID()
}
hub.Broadcast(chat.Envelope{
Kind: chat.KindMessage,
ID: id,
From: c.username,
Body: body,
SentAt: time.Now().UTC(),
})
}
}
func writePump(c *client) {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
defer c.conn.Close()
for {
select {
case env, ok := <-c.send:
if !ok {
return
}
if err := writeEnvelope(c.conn, env); err != nil {
return
}
case <-ticker.C:
if err := writeEnvelope(c.conn, chat.Envelope{Kind: chat.KindPing}); err != nil {
return
}
}
}
}
func writeEnvelope(conn net.Conn, env chat.Envelope) error {
b, err := chat.Encode(env)
if err != nil {
return err
}
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
_, err = conn.Write(b)
return err
}
func sanitizeUsername(raw string) string {
name := strings.TrimSpace(raw)
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
if len(name) > 32 {
name = name[:32]
}
if name == "" {
name = "anon-" + chat.NewID()[:8]
}
return name
}
Con calma, porque aquí está la parte más delicada del proyecto:
- Un solo
bufio.Scannerpara toda la conexión. Se crea una vez enServe, lee la línea de saludo, y ese mismoscanner— no uno nuevo — se pasa areadPump. Si crearas un segundo lector envolviendo el mismonet.Conn, perderías cualquier byte que el primero ya hubiera almacenado en su búfer interno. scanner.Buffer(..., chat.MaxBodyBytes*4)pone un techo duro. Sin esto, un cliente malicioso podría mandar una “línea” de gigabytes sin salto de línea y reventar la memoria del servidor antes de que tu validación de longitud alcance a rechazarla. Con el límite puesto,Scan()simplemente falla y la conexión se cierra.writePumpsiempre cierrac.conn, sin importar por qué salió — error de escritura, o el canalsendcerrado por el Hub al expulsar a un cliente lento. Cerrar la conexión desde ahí desbloquea inmediatamente elScan()quereadPumptiene pendiente en el otro lado.- El
if _, ok := h.clients[c]; okenHub.Leave(visto en el paso anterior) no es paranoia — es necesario. Si el Hub ya expulsó a un cliente lento desdedeliver, y luegoServellamahub.Leave(c)al terminar, esa verificación evita unclosedoble sobre un canal ya cerrado, que sí provocaría un pánico. - El heartbeat es puramente por actividad. El servidor no necesita procesar el contenido de un
pong— el simple hecho de queScan()retorne con éxito (sea lo que sea que haya llegado) reinicia elSetReadDeadlineen la siguiente vuelta del bucle. Un cliente que nunca escribe nada, ni siquiera unpong, se desconecta a los 90 segundos.
Nota también algo que el servidor no hace: nunca confía en el Kind que manda un cliente salvo message. Un cliente no puede falsificar un mensaje de system ni inyectar un ping falso — cualquier otro Kind que no sea message simplemente se ignora en readPump.
internal/server/hub_test.go
package server
import (
"testing"
"time"
"forum/internal/chat"
)
func TestHubDeduplicatesMessages(t *testing.T) {
hub := NewHub()
go hub.Run()
alice := hub.Join("alice")
defer hub.Leave(alice)
drain(t, alice.send)
msg := chat.Envelope{Kind: chat.KindMessage, ID: chat.NewID(), From: "bob", Body: "hola"}
hub.Broadcast(msg)
hub.Broadcast(msg)
first := recvMessage(t, alice.send)
if first.ID != msg.ID {
t.Fatalf("expected message %s, got %s", msg.ID, first.ID)
}
select {
case env := <-alice.send:
t.Fatalf("expected no duplicate, got %+v", env)
case <-time.After(200 * time.Millisecond):
}
}
func drain(t *testing.T, ch <-chan chat.Envelope) {
t.Helper()
select {
case <-ch:
case <-time.After(time.Second):
t.Fatal("timed out waiting for join system message")
}
}
func recvMessage(t *testing.T, ch <-chan chat.Envelope) chat.Envelope {
t.Helper()
select {
case env := <-ch:
return env
case <-time.After(time.Second):
t.Fatal("timed out waiting for message")
return chat.Envelope{}
}
}
Este test no es un adorno — es la prueba concreta de que “no se repiten” es verdad. hub.Broadcast(msg) se llama dos veces con exactamente el mismo ID, y el test falla si alice recibe el mensaje más de una vez.
Paso 4: Certificados TLS — Sin InsecureSkipVerify
“Seguro” significa cifrado en tránsito y verificación real del certificado — no saltarte la verificación porque el certificado es autofirmado. Genera un certificado autofirmado y confía explícitamente en él, en vez de desactivar la verificación por completo.
mkdir -p certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 365 -nodes \
-keyout certs/server.key -out certs/server.crt \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
El cliente va a cargar ese mismo server.crt como su única CA de confianza (RootCAs), y a exigir que el ServerName coincida — exactamente la misma verificación que harías contra un certificado emitido por una autoridad pública. InsecureSkipVerify: true existe como bandera para desarrollo, pero úsala y perdiste toda protección contra un atacante interceptando la conexión (machine-in-the-middle). Este proyecto la deja disponible detrás de un flag explícito — nunca como comportamiento por defecto.
Paso 5: forumd — El Servidor
cmd/forumd/main.go
package main
import (
"context"
"crypto/tls"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"forum/internal/server"
)
func main() {
addr := flag.String("addr", ":4000", "listen address")
certFile := flag.String("cert", "certs/server.crt", "TLS certificate path")
keyFile := flag.String("key", "certs/server.key", "TLS key path")
flag.Parse()
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
if err != nil {
slog.Error("load TLS certificate", "error", err)
os.Exit(1)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
}
listener, err := tls.Listen("tcp", *addr, tlsConfig)
if err != nil {
slog.Error("listen", "error", err)
os.Exit(1)
}
defer listener.Close()
hub := server.NewHub()
go hub.Run()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
listener.Close()
}()
slog.Info("forum listening", "addr", *addr)
for {
conn, err := listener.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("accept", "error", err)
continue
}
go server.Serve(conn, hub)
}
}
tls.Listen reemplaza a net.Listen sin cambiar nada del resto del flujo — cada conexión que sale de Accept() ya llega cifrada. Al recibir SIGTERM o Ctrl+C, cerrar el listener hace que Accept() retorne un error inmediatamente; como ctx.Err() ya no es nil en ese punto, el bucle sale limpio en vez de loguear un error falso.
Arranca el servidor:
go run ./cmd/forumd
Paso 6: forum — El Cliente CLI
El cliente tiene tres responsabilidades corriendo en paralelo: leer del socket e imprimir, leer de stdin y enviar, y reconectar solo cuando la red falla. La pieza clave para que esto no se vuelva un caos de goroutines es que stdin se lee una sola vez, para toda la vida del proceso — no una vez por intento de conexión. Así, lo que escribes durante un corte de red simplemente espera en un canal hasta que la reconexión se complete.
internal/client/session.go
package client
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"math/rand/v2"
"net"
"os"
"strings"
"time"
"forum/internal/chat"
)
type Config struct {
Addr string
Username string
TLSConfig *tls.Config
}
func Run(ctx context.Context, cfg Config) error {
send := make(chan chat.Envelope, 64)
go func() {
_ = scanStdin(ctx, send)
}()
backoff := time.Second
for {
if ctx.Err() != nil {
return ctx.Err()
}
start := time.Now()
err := connectAndChat(ctx, cfg, send)
if ctx.Err() != nil {
return ctx.Err()
}
if time.Since(start) > 5*time.Second {
backoff = time.Second
}
fmt.Fprintf(os.Stderr, "\nconnection lost (%v), retrying in %s...\n", err, backoff)
select {
case <-time.After(backoff + jitter()):
case <-ctx.Done():
return ctx.Err()
}
backoff = min(backoff*2, 30*time.Second)
}
}
func jitter() time.Duration {
return time.Duration(rand.Int64N(int64(500 * time.Millisecond)))
}
func connectAndChat(ctx context.Context, cfg Config, send chan chat.Envelope) error {
dialer := &net.Dialer{Timeout: 10 * time.Second}
rawConn, err := dialer.DialContext(ctx, "tcp", cfg.Addr)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
conn := tls.Client(rawConn, cfg.TLSConfig)
if err := conn.HandshakeContext(ctx); err != nil {
conn.Close()
return fmt.Errorf("tls handshake: %w", err)
}
defer conn.Close()
hello, err := chat.Encode(chat.Envelope{Kind: chat.KindHello, From: cfg.Username})
if err != nil {
return err
}
if _, err := conn.Write(hello); err != nil {
return fmt.Errorf("handshake write: %w", err)
}
connCtx, cancel := context.WithCancel(ctx)
defer cancel()
errs := make(chan error, 2)
go func() { errs <- readLoop(conn, send) }()
go func() { errs <- writeLoop(connCtx, conn, send) }()
err = <-errs
cancel()
return err
}
func readLoop(conn net.Conn, send chan<- chat.Envelope) error {
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 4096), chat.MaxBodyBytes*4)
for {
conn.SetReadDeadline(time.Now().Add(2 * time.Minute))
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return fmt.Errorf("read: %w", err)
}
return fmt.Errorf("read: connection closed")
}
env, err := chat.Decode(scanner.Bytes())
if err != nil {
continue
}
switch env.Kind {
case chat.KindWelcome:
fmt.Printf("\rconnected to the forum as %s\n> ", env.From)
case chat.KindPing:
select {
case send <- chat.Envelope{Kind: chat.KindPong}:
default:
}
case chat.KindMessage:
fmt.Printf("\r[%s] %s: %s\n> ", env.SentAt.Local().Format("15:04:05"), env.From, env.Body)
case chat.KindSystem:
fmt.Printf("\r*** %s\n> ", env.Body)
}
}
}
func writeLoop(ctx context.Context, conn net.Conn, send <-chan chat.Envelope) error {
for {
select {
case env := <-send:
b, err := chat.Encode(env)
if err != nil {
continue
}
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err := conn.Write(b); err != nil {
return fmt.Errorf("write: %w", err)
}
case <-ctx.Done():
return ctx.Err()
}
}
}
func scanStdin(ctx context.Context, send chan<- chat.Envelope) error {
fmt.Print("> ")
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
if ctx.Err() != nil {
return ctx.Err()
}
text := strings.TrimSpace(scanner.Text())
if text == "" {
fmt.Print("> ")
continue
}
if len(text) > chat.MaxBodyBytes {
fmt.Printf("message too long (max %d bytes), not sent\n> ", chat.MaxBodyBytes)
continue
}
send <- chat.Envelope{Kind: chat.KindMessage, ID: chat.NewID(), Body: text}
fmt.Print("> ")
}
return fmt.Errorf("stdin closed")
}
Lo que hace que la reconexión sea segura y no un desastre de goroutines colgadas:
sendse crea una sola vez, enRun, y vive para siempre.scanStdinlo escribe; cada intento de conexión, sin importar cuántas veces se reconecte, lo lee a través de unwriteLoopnuevo. Nunca hay dos goroutines leyendostdinal mismo tiempo, porquescanStdinsolo se lanza una vez.connCtxcancela al hermano cuando uno falla. SireadLoopmuere (la red se cayó),connectAndChatrecibe ese error, llamacancel(), ywriteLoopsale por su rama<-ctx.Done()en vez de quedarse esperando para siempre en un canal que ya nadie va a llenar con la urgencia debida.- El backoff se resetea solo si la conexión anterior duró más de cinco segundos. Así, una caída real de red no te deja reintentando cada 30 segundos para siempre una vez que la red vuelve — pero un servidor que rechaza la conexión instantáneamente sí activa el backoff completo, para no martillarlo.
- El jitter evita el efecto manada. Si el servidor se cae con veinte clientes conectados, sin jitter los veinte reintentarían exactamente en el mismo instante, una y otra vez. Con
500msde aleatoriedad sumados al backoff, se dispersan.
Hay una ventana de carrera minúscula y honesta: si escribes un mensaje justo en el instante en que la conexión muere, ese mensaje específico puede perderse en vez de reintentarse — el select dentro de writeLoop podría, en teoría, drenarlo del canal justo antes de que ctx.Done() se dispare. Arreglarlo de raíz requeriría un protocolo de confirmación (ACK) explícito por mensaje, que queda fuera del alcance de esta guía — y es, de hecho, el ejercicio que te propongo al final.
cmd/forum/main.go
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"flag"
"fmt"
"net"
"os"
"os/signal"
"os/user"
"syscall"
"forum/internal/client"
)
func main() {
addr := flag.String("addr", "localhost:4000", "server address")
username := flag.String("user", defaultUsername(), "your username in the forum")
caFile := flag.String("ca", "certs/server.crt", "trusted certificate (self-signed CA)")
insecure := flag.Bool("insecure-skip-verify", false, "skip TLS verification (development only)")
flag.Parse()
tlsConfig := &tls.Config{ServerName: hostOnly(*addr)}
if *insecure {
tlsConfig.InsecureSkipVerify = true
} else {
pem, err := os.ReadFile(*caFile)
if err != nil {
fmt.Fprintln(os.Stderr, "read CA file:", err)
os.Exit(1)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
fmt.Fprintln(os.Stderr, "invalid CA file")
os.Exit(1)
}
tlsConfig.RootCAs = pool
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err := client.Run(ctx, client.Config{
Addr: *addr,
Username: *username,
TLSConfig: tlsConfig,
})
if err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
func hostOnly(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
func defaultUsername() string {
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
return "anon"
}
defaultUsername usa os/user para adivinar tu nombre de usuario del sistema operativo — un detalle pequeño que evita que tengas que escribir -user=omar cada vez que pruebas.
Probándolo
Terminal 1 — el servidor:
go run ./cmd/forumd
Terminal 2 — primer cliente:
go run ./cmd/forum -user=alice
> connected to the forum as alice
> hola a todos
Terminal 3 — segundo cliente:
go run ./cmd/forum -user=bob
> connected to the forum as bob
> *** alice joined the forum
> [14:32:07] alice: hola a todos
> qué tal alice
De vuelta en la terminal de alice, verás llegar el mensaje de bob en tiempo real. Ahora prueba la resiliencia de verdad: mata el servidor con Ctrl+C en la Terminal 1. Ambos clientes van a imprimir connection lost, retrying in 1s..., y en cuanto vuelvas a levantar go run ./cmd/forumd, se reconectan solos — sin que tú toques nada en las terminales de los clientes.
Paso 7: Docker
Como no hay dependencias externas, ni siquiera hay go.sum que copiar.
Dockerfile
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/forumd ./cmd/forumd
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/forumd /forumd
COPY certs/server.crt certs/server.key /certs/
EXPOSE 4000
ENTRYPOINT ["/forumd", "-addr=:4000", "-cert=/certs/server.crt", "-key=/certs/server.key"]
docker-compose.yml
services:
forumd:
build: .
ports:
- "4000:4000"
restart: unless-stopped
Sin volúmenes. A propósito — no hay nada que persistir. Si el contenedor se reinicia, el foro empieza de cero, y eso es exactamente el comportamiento que pediste.
Makefile
certs:
mkdir -p certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 365 -nodes \
-keyout certs/server.key -out certs/server.crt \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
server:
go run ./cmd/forumd
client:
go run ./cmd/forum
test:
go test ./...
up:
docker compose up --build
down:
docker compose down
El cliente forum es una herramienta interactiva de terminal — normalmente la corres nativa en tu máquina, no dentro de Docker. El servidor sí es un buen candidato para contenerizar; el cliente vive donde vive el humano que escribe.
go test ./...
docker compose up --build
Límites Conocidos (Y Ejercicios Para Ti)
Esta guía es honesta sobre dónde se detiene a propósito:
- Sin entrega garantizada exacta. Hay una ventana de carrera minúscula donde un mensaje escrito justo cuando la conexión muere puede perderse. Ejercicio: agrega un
Kind: "ack"que el servidor devuelva por cada mensaje aceptado, y haz que el cliente reintente si no recibe el ACK en cierto tiempo — gracias alIDque ya generas, el reintento es seguro contra duplicados sin cambiar nada más. - El límite de tasa es silencioso. Si superas 5 mensajes por segundo, el exceso se descarta sin avisarte. Ejercicio: haz que el servidor responda con un
Envelopede tiposystemexplicando por qué se descartó. - Es un solo foro, sin salas. Ejercicio natural: agrega un campo
RoomalEnvelope, y haz queHub.deliversolo entregue a los clientes suscritos a esa sala — sigue sin hacer falta ningún mutex nuevo. - No hay persistencia, por diseño. Si algún día quisieras historial, la forma correcta de agregarlo sin romper la arquitectura es un suscriptor más del Hub — un cliente especial de solo lectura que escribe cada
Envelopea SQLite, igual que en la guía de la API de notas — sin tocar el broadcast en vivo.
Ese último punto es, quizás, la lección más importante de todo el proyecto: el Hub no sabe ni le importa quién está del otro lado de c.send. Podría ser un socket TCP, un test, o un escritor de base de datos. Esa es la ventaja real de mantener el estado en un solo lugar, hablado por canales.
Artículos relacionados
Por relevancia
Go Concurrente: Goroutines, Channels y Context - De Cero a Experto
Guía exhaustiva sobre programación concurrente en Go 1.25: goroutines, channels, context, patrones, race conditions, manejo de errores, y arquitectura profesional. Desde principiante absoluto hasta nivel experto con ejemplos prácticos.
Go Concurrency a Escala: Procesando 100,000 Registros Eficientemente con REST y DDD
Guía empresarial de pipelines de datos concurrentes en Go. Domina worker pools, diseño orientado a dominio, APIs REST, operaciones SFTP masivas e ingesta de datos fiscales de alto rendimiento.
SFTP con Go: Conexiones, Rendimiento y Estrategias de Búsqueda para Producción
Aprende a construir clientes SFTP en Go con pooling de conexiones, transferencias seguras, búsqueda eficiente de archivos y patrones production-ready. Incluye comandos CLI y mejores prácticas.
Go 1.27, Gin y SQLite: Notas con Arquitectura Hexagonal Paso a Paso
Guía paso a paso para construir una API REST de notas en Go 1.27, Gin, SQLite y Arquitectura Hexagonal + DDD. Código limpio, tests y Docker.