Salta al contenuto principale

Comunicazione Real-Time in Go: TCP Socket, Message Framing e WebSocket

·8 minuti
Indice dei contenuti
TCP e un protocollo stream, non un protocollo a messaggi. Se leggi i byte in un buffer a dimensione fissa, troncherai silenziosamente i messaggi piu grandi di quel buffer. Hai bisogno del framing. Questo articolo copre il framing con prefisso di lunghezza, un server broadcast multi-client e il supporto WebSocket per i client browser.

La prima cosa che la maggior parte dei tutorial sui socket in Go sbaglia e il buffer. Leggere in make([]byte, 1024) non e orientato ai messaggi. Un messaggio di 1025 byte viene diviso in due letture. Un messaggio di 512 byte e uno di 300 byte possono arrivare in una singola chiamata Read. TCP e uno stream: devi aggiungere struttura sopra di esso.

Il problema del framing
#

Considera questo pattern comune dai tutorial:

buf := make([]byte, 1024)
n, err := conn.Read(buf)
msg := string(buf[:n])

Questo ha due bug:

  1. Se il mittente scrive 2000 byte, il ricevitore ottiene 1024 byte e scarta silenziosamente il resto.
  2. Se il mittente scrive due messaggi uno dopo l’altro, il ricevitore puo riceverli entrambi in una singola chiamata Read senza confine tra di essi.

Hai bisogno di un protocollo di framing. Il piu semplice e affidabile e un prefisso di lunghezza a 4 byte.

Framing con prefisso di lunghezza
#

Scrivi la lunghezza del messaggio come intero big-endian a 4 byte prima del corpo del messaggio. Il lettore legge esattamente 4 byte prima, apprende la dimensione del messaggio, poi legge esattamente quel numero di byte.

framing/framing.go
package framing

import (
	"encoding/binary"
	"fmt"
	"io"
	"net"
)

const maxMessageSize = 16 * 1024 * 1024 // 16 MB limite di sicurezza

// WriteMessage scrive un messaggio con prefisso di lunghezza su conn.
func WriteMessage(conn net.Conn, data []byte) error {
	if len(data) > maxMessageSize {
		return fmt.Errorf("message too large: %d bytes", len(data))
	}
	header := make([]byte, 4)
	binary.BigEndian.PutUint32(header, uint32(len(data)))
	if _, err := conn.Write(header); err != nil {
		return fmt.Errorf("write header: %w", err)
	}
	if _, err := conn.Write(data); err != nil {
		return fmt.Errorf("write body: %w", err)
	}
	return nil
}

// ReadMessage legge un messaggio con prefisso di lunghezza da conn.
// Blocca finche non e disponibile un messaggio completo.
func ReadMessage(conn net.Conn) ([]byte, error) {
	header := make([]byte, 4)
	if _, err := io.ReadFull(conn, header); err != nil {
		return nil, fmt.Errorf("read header: %w", err)
	}
	size := binary.BigEndian.Uint32(header)
	if size > maxMessageSize {
		return nil, fmt.Errorf("message size %d exceeds limit", size)
	}
	body := make([]byte, size)
	if _, err := io.ReadFull(conn, body); err != nil {
		return nil, fmt.Errorf("read body: %w", err)
	}
	return body, nil
}

io.ReadFull e fondamentale qui. Chiama Read in un loop finche non riempie esattamente il buffer, gestendo il caso in cui il kernel consegna i byte in piu chunk.

Nota

Il limite maxMessageSize protegge da un client malevolo o buggy che invia un prefisso di lunghezza da 4GB causando make([]byte, 4_000_000_000) e OOM del server. Valida sempre la dimensione prima di allocare.

Server TCP multi-client con broadcast
#

Un server degno della produzione gestisce ogni client nella propria goroutine e instrada i messaggi a tutti gli altri client connessi attraverso un canale broadcast centrale:

server/server.go
package server

import (
	"fmt"
	"log"
	"net"
	"sync"

	"github.com/yourname/chat/framing"
)

type Server struct {
	mu      sync.RWMutex
	clients map[net.Conn]struct{}
	bcast   chan []byte
}

func New() *Server {
	return &Server{
		clients: make(map[net.Conn]struct{}),
		bcast:   make(chan []byte, 256),
	}
}

func (s *Server) Run(addr string) error {
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return fmt.Errorf("listen: %w", err)
	}
	defer ln.Close()
	log.Printf("TCP server in ascolto su %s", addr)

	go s.broadcaster()

	for {
		conn, err := ln.Accept()
		if err != nil {
			log.Printf("accept: %v", err)
			continue
		}
		s.addClient(conn)
		go s.handleConn(conn)
	}
}

func (s *Server) broadcaster() {
	for msg := range s.bcast {
		s.mu.RLock()
		for conn := range s.clients {
			// Imposta un write deadline in modo che un client lento non blocchi il broadcast.
			_ = conn.SetWriteDeadline(timeoutFrom(2))
			if err := framing.WriteMessage(conn, msg); err != nil {
				log.Printf("broadcast write: %v", err)
			}
		}
		s.mu.RUnlock()
	}
}

func (s *Server) handleConn(conn net.Conn) {
	defer func() {
		s.removeClient(conn)
		conn.Close()
	}()

	for {
		// Read deadline: disconnetti i client inattivi dopo 60s.
		_ = conn.SetReadDeadline(timeoutFrom(60))

		msg, err := framing.ReadMessage(conn)
		if err != nil {
			log.Printf("read from %s: %v", conn.RemoteAddr(), err)
			return
		}
		s.bcast <- msg
	}
}

func (s *Server) addClient(conn net.Conn) {
	s.mu.Lock()
	s.clients[conn] = struct{}{}
	s.mu.Unlock()
	log.Printf("client connesso: %s (totale: %d)", conn.RemoteAddr(), s.clientCount())
}

func (s *Server) removeClient(conn net.Conn) {
	s.mu.Lock()
	delete(s.clients, conn)
	s.mu.Unlock()
	log.Printf("client disconnesso: %s (totale: %d)", conn.RemoteAddr(), s.clientCount())
}

func (s *Server) clientCount() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return len(s.clients)
}
Avviso

Il loop di broadcast mantiene un read lock mentre scrive a tutti i client. Se un client e lento, blocca il broadcast per tutti gli altri fino al write deadline (2 secondi). Per sistemi ad alto throughput, dai a ogni client un canale di scrittura dedicato e una goroutine in modo che un client lento venga eliminato senza bloccare gli altri.

Server WebSocket con gorilla/websocket
#

I client browser non possono aprire connessioni TCP raw. WebSocket gira su HTTP e funziona in ogni browser. Usa gorilla/websocket:

wsserver/wsserver.go
package wsserver

import (
	"log"
	"net/http"
	"sync"
	"time"

	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		return true // Restringi in produzione: controlla r.Header.Get("Origin")
	},
}

type Hub struct {
	mu      sync.RWMutex
	clients map[*websocket.Conn]struct{}
	bcast   chan []byte
}

func NewHub() *Hub {
	h := &Hub{
		clients: make(map[*websocket.Conn]struct{}),
		bcast:   make(chan []byte, 256),
	}
	go h.broadcaster()
	return h
}

func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Printf("upgrade: %v", err)
		return
	}
	h.addClient(conn)
	defer func() {
		h.removeClient(conn)
		conn.Close()
	}()

	conn.SetReadLimit(16 * 1024 * 1024)
	conn.SetReadDeadline(time.Now().Add(60 * time.Second))
	conn.SetPongHandler(func(_ string) error {
		conn.SetReadDeadline(time.Now().Add(60 * time.Second))
		return nil
	})

	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
				log.Printf("ws read error: %v", err)
			}
			return
		}
		h.bcast <- msg
	}
}

func (h *Hub) broadcaster() {
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case msg := <-h.bcast:
			h.mu.RLock()
			for conn := range h.clients {
				conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
				if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
					log.Printf("ws write: %v", err)
				}
			}
			h.mu.RUnlock()
		case <-ticker.C:
			// Invia ping a tutti i client per mantenere le connessioni attive.
			h.mu.RLock()
			for conn := range h.clients {
				conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
				_ = conn.WriteMessage(websocket.PingMessage, nil)
			}
			h.mu.RUnlock()
		}
	}
}

func (h *Hub) addClient(conn *websocket.Conn) {
	h.mu.Lock()
	h.clients[conn] = struct{}{}
	h.mu.Unlock()
}

func (h *Hub) removeClient(conn *websocket.Conn) {
	h.mu.Lock()
	delete(h.clients, conn)
	h.mu.Unlock()
}

Confronto TCP vs WebSocket
#

Latenza: Minima. Nessun overhead di framing HTTP.

Overhead del protocollo: Minimo. Solo i tuoi byte di framing.

Supporto browser: Nessuno. I browser non possono aprire socket TCP raw.

Casi d’uso: Server di gioco, comunicazione inter-servizio, protocolli custom, dispositivi IoT.

Framing: Lo implementi tu (length-prefix, delimitatore, o varint protobuf).

TLS: Lo gestisci con crypto/tls.

Latenza: Leggermente piu alta. Handshake di upgrade HTTP al momento della connessione, framing WebSocket su ogni messaggio (~6 byte di overhead per messaggio).

Overhead del protocollo: Piccolo ma non nullo per frame.

Supporto browser: Completo. Supportato in ogni browser moderno dal 2011.

Casi d’uso: App di chat, dashboard in tempo reale, strumenti collaborativi, qualsiasi funzionalita real-time rivolta al browser.

Framing: Built-in. gorilla/websocket gestisce automaticamente i confini dei messaggi.

TLS: Gestito dal server HTTP (usa ListenAndServeTLS o un reverse proxy).

Opzioni di message framing
#

flowchart TD
    Q1{Quali sono i tuoi vincoli?}
    Q1 -->|Messaggi di testo semplici| Delim["Basato su delimitatore\n(newline \\n per messaggio)"]
    Q1 -->|Binario o lunghezza variabile| LenPfx["Length-prefix\n(header 4 byte + body)"]
    Q1 -->|Gia usi protobuf| Varint["Framing varint protobuf\n(lunghezza auto-descrittiva)"]
    Delim -->|Svantaggio| D1["Il delimitatore deve essere\nescapato nel contenuto del messaggio"]
    LenPfx -->|Svantaggio| L1["Dimensione massima del messaggio fissa\n(impostata in fase di design)"]
    Varint -->|Svantaggio| V1["Richiede la dipendenza\nda protobuf"]

Considerazioni per la produzione
#

Limiti di connessione. Il sistema operativo limita i file descriptor aperti per processo. Su Linux il default e 1024. Per un server di chat con migliaia di client, imposta ulimit -n 65536 e configura fs.file-max in /etc/sysctl.conf. Ogni goroutine per una connessione usa anche ~8KB di stack per default.

Read e write deadline. Impostali sempre. conn.SetReadDeadline disconnette i client che smettono di inviare (rilevando connessioni half-open). conn.SetWriteDeadline impedisce a un client lento di bloccare la goroutine di broadcast.

Ping/pong keepalive. I pacchetti TCP keepalive a livello OS possono impiegare minuti per rilevare una connessione caduta. I ping a livello applicativo (frame ping WebSocket, o un messaggio custom in TCP) rilevano la disconnessione molto piu velocemente.

Shutdown graceful. Ascolta SIGTERM, smetti di accettare nuove connessioni, svuota il canale broadcast, poi chiudi tutte le connessioni. sync.WaitGroup tiene traccia delle goroutine in esecuzione.

**Nessun framing.** Leggere con un buffer a dimensione fissa tronca o unisce silenziosamente i messaggi. Usa sempre un protocollo di framing -- il length-prefix e l'approccio corretto piu semplice. **Nessun read o write deadline.** Un client che smette di rispondere mantiene la sua goroutine e il file descriptor attivi indefinitamente. Imposta sempre deadline con `SetReadDeadline` e `SetWriteDeadline`. **Broadcast bloccante.** Se il loop di broadcast scrive ai client sequenzialmente senza write deadline, un client lento blocca tutti gli altri. Aggiungi write deadline per scrittura, o dai a ogni client un canale con buffer e una goroutine di scrittura dedicata. **Una goroutine per tutte le connessioni.** Gestire tutte le connessioni in una singola goroutine con un loop select non scala. La goroutine-per-connessione e idiomatic Go e scala bene a migliaia di client concorrenti. **Ignorare io.ReadFull.** Usare un semplice `conn.Read` in uno slice non garantisce che il buffer sia riempito. TCP puo consegnare dati in chunk. Usa `io.ReadFull` per letture di dimensione esatta.

Quando usare socket, NATS, o gRPC
#

EsigenzaRaccomandazione
Client browserWebSocket
Messaggistica inter-servizio, pub/sub, fan-outNATS o Kafka
RPC tipizzato tra servizigRPC
Protocollo binario raw, controllo totaleTCP con framing custom
Gioco o simulazione a bassa latenzaUDP con il tuo layer di affidabilita

Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati