premik.pl

Jak efektywnie pracować z WebSocketami w aplikacjach webowych

Standardowy HTTP działa jak wizyta na poczcie – przychodzisz, składasz prośbę, czekasz na odpowiedź i wychodzisz. Świetne do pobierania stron i wysyłania formularzy. Fatalne do czatu, gdzie chcesz widzieć wiadomości w ułamku sekundy. WebSocket zmienia ten model fundamentalnie – otwiera stałe, dwukierunkowe połączenie, przez które serwer może wysyłać dane do klienta w dowolnym momencie, bez czekania na pytanie.

Czym są WebSockety i kiedy ich używać?

WebSocket to protokół komunikacyjny działający na warstwie TCP, który po nawiązaniu połączenia utrzymuje je otwarte przez cały czas sesji. Zarówno klient jak i serwer mogą wysyłać dane w dowolnym momencie – bez konieczności inicjowania każdej wymiany przez klienta.

Połączenie WebSocket zaczyna się od standardowego żądania HTTP z nagłówkiem Upgrade: websocket. Serwer akceptuje upgrade, protokół przełącza się z HTTP na WebSocket i od tej chwili kanał jest otwarty. Ten proces nazywa się WebSocket handshake.

Kiedy WebSocket jest właściwym wyborem:

Czat i komunikatory – każda wiadomość musi dotrzeć natychmiast do wszystkich uczestników bez odpytywania serwera co sekundę. Aplikacje tradingowe i dashboardy finansowe – kursy walut i ceny akcji zmieniają się w milisekundach. Gry wieloosobowe – pozycje graczy, zdarzenia, wynik muszą być synchronizowane w czasie rzeczywistym. Kolaboratywne edytory tekstu jak Google Docs – zmiany jednego użytkownika muszą natychmiast pojawiać się u pozostałych. Powiadomienia push w aplikacjach webowych – serwer informuje klienta o zdarzeniu bez czekania na żądanie.

Kiedy WebSocket nie jest potrzebny:

Jeśli dane zmieniają się rzadko lub klient inicjuje każdą wymianę – klasyczny HTTP jest prostszy i wystarczający. Dla jednostronnego strumienia danych z serwera do klienta (np. aktualizacje statusu, powiadomienia) prostszą alternatywą są Server-Sent Events (SSE) – jednokierunkowy strumień oparty na HTTP, który nie wymaga bibliotek i jest łatwiejszy w implementacji.

Implementacja WebSocket – klient i serwer

Przeglądarka ma wbudowane API WebSocket – nie potrzebujesz żadnych bibliotek po stronie klienta do podstawowej implementacji.

Klient (JavaScript w przeglądarce):

// Nawiązanie połączenia
const ws = new WebSocket('wss://api.example.com/ws')

// Zdarzenia połączenia
ws.addEventListener('open', () => {
  console.log('Połączono z serwerem')
  ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }))
})

// Odbieranie wiadomości
ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data)
  handleMessage(data)
})

// Obsługa błędów
ws.addEventListener('error', (error) => {
  console.error('WebSocket error:', error)
})

// Zamknięcie połączenia
ws.addEventListener('close', (event) => {
  console.log(`Połączenie zamknięte: ${event.code} ${event.reason}`)
  if (!event.wasClean) {
    scheduleReconnect() // Automatyczne ponowne połączenie
  }
})

// Wysyłanie wiadomości
function sendMessage(content) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'message', content }))
  }
}

Serwer (Node.js z biblioteką ws)

const WebSocket = require('ws')
const http = require('http')

const server = http.createServer()
const wss = new WebSocket.Server({ server })

// Mapa połączeń z metadanymi
const clients = new Map()

wss.on('connection', (ws, req) => {
  const clientId = generateId()
  clients.set(ws, { id: clientId, authenticated: false })

  console.log(`Nowe połączenie: ${clientId}`)

  ws.on('message', (data) => {
    try {
      const message = JSON.parse(data)
      handleMessage(ws, message)
    } catch (error) {
      ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }))
    }
  })

  ws.on('close', () => {
    clients.delete(ws)
    console.log(`Połączenie zamknięte: ${clientId}`)
  })

  ws.on('error', (error) => {
    console.error(`Błąd klienta ${clientId}:`, error)
  })
})

// Broadcast do wszystkich połączonych klientów
function broadcast(message, excludeClient = null) {
  const data = JSON.stringify(message)
  wss.clients.forEach((client) => {
    if (client !== excludeClient && client.readyState === WebSocket.OPEN) {
      client.send(data)
    }
  })
}

server.listen(3000)

Protokół wiadomości – jak strukturyzować dane

WebSocket to tylko kanał transportowy – nie definiuje formatu przesyłanych danych. Musisz sam zaprojektować protokół wiadomości, który będzie używany przez klienta i serwer.

Najpopularniejszym podejściem jest protokół oparty na typach wiadomości – każda wiadomość JSON ma pole type określające jej rodzaj i pole payload z danymi:

// Przykładowe typy wiadomości w aplikacji czatu
const MESSAGE_TYPES = {
  AUTH: 'auth',
  AUTH_SUCCESS: 'auth_success',
  AUTH_ERROR: 'auth_error',
  CHAT_MESSAGE: 'chat_message',
  USER_JOINED: 'user_joined',
  USER_LEFT: 'user_left',
  PING: 'ping',
  PONG: 'pong'
}

// Przykład obsługi wiadomości po stronie serwera
function handleMessage(ws, message) {
  const client = clients.get(ws)

  switch (message.type) {
    case MESSAGE_TYPES.AUTH:
      const user = verifyToken(message.token)
      if (user) {
        clients.set(ws, { ...client, authenticated: true, user })
        ws.send(JSON.stringify({ type: MESSAGE_TYPES.AUTH_SUCCESS, user }))
        broadcast({ type: MESSAGE_TYPES.USER_JOINED, user }, ws)
      } else {
        ws.send(JSON.stringify({ type: MESSAGE_TYPES.AUTH_ERROR }))
        ws.close(1008, 'Unauthorized')
      }
      break

    case MESSAGE_TYPES.CHAT_MESSAGE:
      if (!client.authenticated) {
        ws.close(1008, 'Unauthorized')
        return
      }
      broadcast({
        type: MESSAGE_TYPES.CHAT_MESSAGE,
        content: sanitize(message.content),
        user: client.user,
        timestamp: Date.now()
      })
      break

    case MESSAGE_TYPES.PING:
      ws.send(JSON.stringify({ type: MESSAGE_TYPES.PONG }))
      break
  }
}

Socket.IO to biblioteka, która buduje warstwę abstrakcji nad WebSocket i dodaje wiele użytecznych funkcji: automatyczne ponowne łączenie, pokoje (rooms), przestrzenie nazw (namespaces), fallback do long-polling gdy WebSocket jest niedostępny i wbudowany system zdarzeń. Warto jej używać w projektach produkcyjnych zamiast surowego WebSocket API:

// Socket.IO - serwer
const io = require('socket.io')(server)

io.on('connection', (socket) => {
  socket.on('join-room', (roomId) => {
    socket.join(roomId)
    socket.to(roomId).emit('user-joined', { userId: socket.id })
  })

  socket.on('chat-message', ({ roomId, content }) => {
    io.to(roomId).emit('chat-message', {
      content,
      userId: socket.id,
      timestamp: Date.now()
    })
  })
})

// Socket.IO - klient
const socket = io('https://api.example.com')
socket.emit('join-room', 'room-123')
socket.on('chat-message', (message) => renderMessage(message))

Kluczowe wyzwania produkcyjne

Surowa implementacja WebSocket działa świetnie w środowisku deweloperskim. Produkcja stawia dodatkowe wymagania, o których warto wiedzieć zanim wdrożysz rozwiązanie na żywo.

Automatyczne ponowne łączenie – połączenia WebSocket zrywają się. Sieć jest zawodna, serwery się restartują, użytkownicy tracą zasięg. Klient musi automatycznie próbować ponownie nawiązać połączenie z wykładniczym opóźnieniem (exponential backoff) żeby nie zalewać serwera żądaniami:

class ReconnectingWebSocket {
  constructor(url) {
    this.url = url
    this.reconnectDelay = 1000
    this.maxReconnectDelay = 30000
    this.connect()
  }

  connect() {
    this.ws = new WebSocket(this.url)
    this.ws.addEventListener('open', () => {
      this.reconnectDelay = 1000 // Reset opóźnienia po udanym połączeniu
    })
    this.ws.addEventListener('close', () => {
      setTimeout(() => this.connect(), this.reconnectDelay)
      this.reconnectDelay = Math.min(
        this.reconnectDelay * 2,
        this.maxReconnectDelay
      )
    })
  }
}

Heartbeat / Ping-Pong – niektóre proxy i load balancery zamykają nieaktywne połączenia po kilkudziesięciu sekundach ciszy. Heartbeat to regularne wysyłanie wiadomości ping/pong (co 30-60 sekund) żeby utrzymać połączenie przy życiu i wykryć zerwane połączenia:

// Serwer - wykrywanie martwych połączeń
function setupHeartbeat(wss) {
  const interval = setInterval(() => {
    wss.clients.forEach((ws) => {
      if (!ws.isAlive) {
        ws.terminate()
        return
      }
      ws.isAlive = false
      ws.ping()
    })
  }, 30000)

  wss.on('connection', (ws) => {
    ws.isAlive = true
    ws.on('pong', () => { ws.isAlive = true })
  })
}

Skalowanie horyzontalne – WebSocket to stanowe połączenie. Gdy skalujesz aplikację na wiele instancji serwera, klient połączony z instancją A nie może bezpośrednio komunikować się z klientem połączonym z instancją B. Rozwiązaniem jest broker wiadomości – Redis Pub/Sub lub Apache Kafka – przez który wszystkie instancje serwera wymieniają wiadomości:

// Redis Pub/Sub dla WebSocket broadcasting w wielu instancjach
const redis = require('ioredis')
const publisher = new redis()
const subscriber = new redis()

// Subskrypcja do kanału Redis
subscriber.subscribe('websocket-messages')
subscriber.on('message', (channel, message) => {
  // Rozgłoś do wszystkich lokalnych klientów WebSocket
  wss.clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message)
    }
  })
})

// Publikowanie wiadomości przez Redis (broadcastuje do wszystkich instancji)
function broadcastToAll(message) {
  publisher.publish('websocket-messages', JSON.stringify(message))
}

Bezpieczeństwo – WebSocket nie ma wbudowanego mechanizmu autoryzacji. Użyj wss:// (WebSocket przez TLS) zamiast ws:// w produkcji. Weryfikuj token JWT lub ciasteczko sesji przy nawiązaniu połączenia. Waliduj i sanityzuj każdą przychodzącą wiadomość. Implementuj rate limiting na poziomie połączeń i wiadomości.

Testowanie i debugowanie

Chrome DevTools ma wbudowaną obsługę WebSocket – w zakładce Network filtruj po „WS” żeby zobaczyć połączenia WebSocket, a po kliknięciu w połączenie – pełną historię wysłanych i odebranych wiadomości z timestampami.

Testowanie serwera przez narzędzie websocat (CLI) lub Postman (obsługuje WebSocket od wersji 8.5):

# websocat - testowanie z linii poleceń
websocat ws://localhost:3000
# Wpisz wiadomość JSON i naciśnij Enter
{"type": "ping"}

Testy jednostkowe dla logiki WebSocket najlepiej pisać mockując połączenie:

// Jest + mock WebSocket
const { WebSocket, Server } = require('mock-socket')

test('obsługuje wiadomość auth', async () => {
  const server = new Server('ws://localhost:3000')
  const client = new WebSocket('ws://localhost:3000')

  server.on('connection', (socket) => {
    socket.on('message', (data) => {
      const msg = JSON.parse(data)
      if (msg.type === 'auth') {
        socket.send(JSON.stringify({ type: 'auth_success' }))
      }
    })
  })

  client.send(JSON.stringify({ type: 'auth', token: 'valid-token' }))
  // ... asercje
})

WebSockety to technologia, która przy właściwym użyciu otwiera zupełnie nowe możliwości interaktywności aplikacji webowych. Kluczem jest dobry protokół wiadomości, solidna obsługa ponownego łączenia i świadomość wyzwań skalowania zanim trafisz na nie w produkcji. Jeśli masz pytania dotyczące konkretnej implementacji lub architektury czasu rzeczywistego – napisz przez formularz kontaktowy.

Zobacz powiązane wpisy