WebSockets en Go: servidor y cliente con gorilla/websocket y nhooyr.io/websocket

WebSockets permiten comunicación bidireccional persistente sobre una conexión HTTP actualizada. Go tiene dos librerías consolidadas para esto: gorilla/websocket, la más veterana y completa, y nhooyr.io/websocket, más moderna y con soporte nativo para context.Context.

Servidor básico con gorilla/websocket

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin: func(r *http.Request) bool {
        return true // en producción valida el origen
    },
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("error de upgrade:", err)
        return
    }
    defer conn.Close()

    for {
        mt, msg, err := conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
                log.Println("conexión cerrada inesperadamente:", err)
            }
            break
        }
        log.Printf("recibido: %s", msg)
        if err := conn.WriteMessage(mt, msg); err != nil { // eco
            break
        }
    }
}

func main() {
    http.HandleFunc("/ws", wsHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Hub para broadcast a múltiples clientes

El patrón hub centraliza la gestión de conexiones y el envío de mensajes a todos los clientes conectados. Es el núcleo de cualquier chat o sala de eventos en tiempo real:

type Hub struct {
    clients    map[*websocket.Conn]bool
    broadcast  chan []byte
    register   chan *websocket.Conn
    unregister chan *websocket.Conn
}

func newHub() *Hub {
    return &Hub{
        clients:    make(map[*websocket.Conn]bool),
        broadcast:  make(chan []byte, 256),
        register:   make(chan *websocket.Conn),
        unregister: make(chan *websocket.Conn),
    }
}

func (h *Hub) run() {
    for {
        select {
        case conn := <-h.register:
            h.clients[conn] = true
        case conn := <-h.unregister:
            if _, ok := h.clients[conn]; ok {
                delete(h.clients, conn)
                conn.Close()
            }
        case msg := <-h.broadcast:
            for conn := range h.clients {
                if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
                    conn.Close()
                    delete(h.clients, conn)
                }
            }
        }
    }
}

// En el handler: registra la conexión y lee mensajes
func wsHandlerHub(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, _ := upgrader.Upgrade(w, r, nil)
    hub.register <- conn
    defer func() { hub.unregister <- conn }()
    for {
        _, msg, err := conn.ReadMessage()
        if err != nil { break }
        hub.broadcast <- msg
    }
}

Reconexión con backoff exponencial en el cliente

func conectarConRetry(url string) {
    espera := time.Second
    for {
        conn, _, err := websocket.DefaultDialer.Dial(url, nil)
        if err != nil {
            log.Printf("error de conexión: %v; reintentando en %s", err, espera)
            time.Sleep(espera)
            espera = min(espera*2, 30*time.Second) // cap en 30s
            continue
        }
        espera = time.Second // reset al reconectar
        log.Println("conectado")
        manejarConexion(conn)
        conn.Close()
    }
}

func min(a, b time.Duration) time.Duration {
    if a < b { return a }
    return b
}

Keepalive con ping/pong

Las conexiones WebSocket ociosas pueden ser cortadas por proxies o firewalls. El mecanismo ping/pong mantiene la conexión viva y detecta desconexiones reales:

const pingPeriod = 30 * time.Second
const pongWait   = 60 * time.Second

func keepAlive(conn *websocket.Conn) {
    conn.SetReadDeadline(time.Now().Add(pongWait))
    conn.SetPongHandler(func(string) error {
        conn.SetReadDeadline(time.Now().Add(pongWait))
        return nil
    })

    ticker := time.NewTicker(pingPeriod)
    defer ticker.Stop()
    for range ticker.C {
        if err := conn.WriteControl(
            websocket.PingMessage, nil, time.Now().Add(10*time.Second),
        ); err != nil {
            return
        }
    }
}

nhooyr.io/websocket: alternativa con context nativo

nhooyr.io/websocket simplifica el API y trata la cancelación como ciudadano de primera clase:

import "nhooyr.io/websocket"

http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
    conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
        OriginPatterns: []string{"localhost:*"},
    })
    if err != nil {
        return
    }
    defer conn.CloseNow()

    ctx := r.Context()
    for {
        typ, data, err := conn.Read(ctx)
        if err != nil {
            break
        }
        if err := conn.Write(ctx, typ, data); err != nil {
            break
        }
    }
})

Antipatrón: escrituras concurrentes sin sincronización

El error más frecuente con gorilla/websocket es llamar a WriteMessage desde varias goroutines al mismo tiempo. La documentación es explícita: solo una goroutine puede escribir a la vez. La solución es usar un mutex o, mejor, canalizar todos los envíos por una goroutine dedicada:

// MAL: dos goroutines escriben concurrentemente
go func() { conn.WriteMessage(websocket.TextMessage, msg1) }()
go func() { conn.WriteMessage(websocket.TextMessage, msg2) }()

// BIEN: canal de salida + goroutine escritora única
send := make(chan []byte, 64)
go func() {
    for msg := range send {
        conn.WriteMessage(websocket.TextMessage, msg)
    }
}()
send <- msg1
send <- msg2

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP