← Kursa Dön
📄 Text · 25 min

WebSocket Temelleri

Giriş — Neden WebSocket?

Bir borsa uygulaması düşün. Hisse fiyatları saniyede birkaç kez değişiyor. Kullanıcı ekranında her zaman güncel fiyatı görmek istiyor. REST API ile bunu nasıl yaparsın? Her 500 milisaniyede bir GET /api/prices isteği atarsın — buna polling denir. Ve bu korkunç derecede verimsiz.

Her HTTP isteğinde header'lar, cookie'ler, authentication token'lar... hepsi tekrar tekrar gönderilir. Sunucu her seferinde yeni bir bağlantı açar, cevabı verir, bağlantıyı kapatır. Veri değişmemiş olsa bile bu döngü devam eder. 10.000 kullanıcı düşün — saniyede 20.000 HTTP isteği, çoğu boşuna.

İşte WebSocket tam da bu sorunu çözmek için var. Tek bir bağlantı aç, açık tut, veri olduğunda hemen gönder. Bu kadar basit. Bu derste HTTP ve WebSocket arasındaki farkı, Spring Boot'ta WebSocket nasıl kurulur, STOMP protokolü nedir ve bir echo server nasıl yazılır öğreneceğiz.


1. HTTP vs WebSocket: Temel Fark

HTTP: Request-Response Modeli

HTTP, half-duplex bir protokoldür. Client bir istek gönderir, server bir cevap verir. Bu kadar. Server, client'a kendi isteğiyle mesaj gönderemez. Her iletişim client'ın başlatmasını gerektirir.

Bunu bir telsiz gibi düşün — bir kişi konuşurken diğeri dinler. Aynı anda iki yönlü iletişim yok.

Client → Server: GET /api/messages
Server → Client: 200 OK [{"id": 1, "text": "Merhaba"}]
// Bağlantı kapandı. Yeni mesaj geldi mi? Tekrar sor.
Client → Server: GET /api/messages?after=1
Server → Client: 200 OK []  // Boş — veri yok ama istek yaptın

WebSocket: Full-Duplex İletişim

WebSocket, full-duplex bir protokoldür. Bağlantı bir kez kurulur ve iki taraf da istedikleri zaman mesaj gönderebilir. Telefon görüşmesi gibi — iki taraf da aynı anda konuşabilir.

Client ↔ Server: Bağlantı kuruldu (tek seferlik)
Server → Client: Yeni mesaj geldi!
Client → Server: Bu mesajı beğendim
Server → Client: Başka bir güncelleme var
// Bağlantı hâlâ açık...

WebSocket Handshake: HTTP'den Upgrade

WebSocket bağlantısı aslında bir HTTP isteğiyle başlar. Client, server'a "Hey, WebSocket'e geçelim mi?" diye sorar. Buna upgrade handshake denir.

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Server kabul ederse:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

101 Switching Protocols — bu noktadan sonra artık HTTP konuşulmaz. Bağlantı WebSocket'e dönüşür ve TCP soketi açık kalır.

💡 İpucu: WebSocket, HTTP port'larını kullanır (80 ve 443). Yani ws:// port 80, wss:// port 443. Ekstra firewall kuralı gerekmez — bu çok büyük avantaj.

Karşılaştırma Tablosu

ÖzellikHTTP (REST)WebSocket
İletişim yönüTek yön (client → server)Çift yön (full-duplex)
Bağlantı süresiHer istekte aç/kapatSürekli açık
OverheadHer istekte header'larİlk handshake sonrası minimal
Real-timePolling/Long-polling gerekirNative push
ProtokolHTTP/1.1, HTTP/2ws://, wss://
StateStatelessStateful
CachingKolay (HTTP cache)Cache yok
Load balancingBasit (round-robin)Sticky session gerekebilir

2. Ne Zaman WebSocket, Ne Zaman REST?

Bu soru çok önemli. Her yere WebSocket koymak yanlış olduğu gibi, her yerde REST kullanmak da yetersiz kalabilir.

Karar Matrisi

SenaryoÖneriNeden?
CRUD işlemleri (user, product)RESTStandard request-response yeterli
Real-time chatWebSocketİki yönlü anlık iletişim şart
Dashboard'da canlı metrikSSE veya WebSocketTek yönlü push yeterliyse SSE
Online oyunWebSocketDüşük latency, iki yönlü
Dosya uploadREST (multipart)WebSocket binary için optimize değil
Bildirim sistemiWebSocket veya SSEPush gerekli
Form submitRESTTek seferlik işlem
Borsa fiyatlarıWebSocketYüksek frekanslı güncelleme
Ödeme işlemiRESTGüvenlik, idempotency önemli
Collaborative editing (Google Docs)WebSocketSürekli, çift yönlü senkronizasyon

Basit Karar Ağacı

Veri server'dan client'a push edilecek mi?
├── Hayır → REST kullan
└── Evet
    ├── Client da server'a veri gönderiyor mu?
    │   ├── Hayır → SSE kullan (daha basit)
    │   └── Evet → WebSocket kullan
    └── Güncelleme frekansı?
        ├── Düşük (dakikada 1-2) → Long polling yeterli
        └── Yüksek (saniyede birkaç) → WebSocket şart

⚠️ Dikkat: WebSocket stateful bir bağlantıdır. Her açık bağlantı sunucuda memory tüketir. 100.000 eşzamanlı bağlantı planlıyorsan, capacity planning yap. REST'te böyle bir sorun yok çünkü bağlantılar hemen kapanır.


3. STOMP Protokolü

Raw WebSocket'in Problemi

WebSocket sadece bir transport protokolüdür. Mesajın formatı, routing'i, subscription mekanizması — bunların hiçbiri tanımlı değil. Raw WebSocket'te şunu yaparsın:

// Client
socket.send("Merhaba dünya");

// Server tarafında ne geldi? String mi? JSON mı? Kime gidecek?
// Hepsini sen parse etmelisin.

Bu, TCP üzerinde kendi protokolünü yazmaya benzer. Yapılabilir ama neden tekeri yeniden icat edesin?

STOMP: Simple Text Oriented Messaging Protocol

STOMP, WebSocket üzerinde çalışan bir messaging protokolüdür. HTTP'nin request/response modeli gibi, STOMP'un da frame yapısı vardır.

STOMP frame'i şöyle görünür:

COMMAND
header1:value1
header2:value2

Body^@

Örnek — bir mesaj gönderme:

SEND
destination:/app/chat
content-type:application/json

{"sender":"Ali","content":"Merhaba"}^@

Örnek — bir topic'e subscribe olma:

SUBSCRIBE
id:sub-0
destination:/topic/messages

^@

STOMP'un Sağladıkları

  1. Destination-based routing: Mesajlar /topic/... veya /queue/... gibi hedeflere gönderilir

  2. Subscribe mekanizması: Client hangi mesajları almak istediğini belirtir

  3. Content-type: Mesajın formatı header'da belirtilir

  4. Acknowledgment: Mesajın alındığı onaylanabilir

  5. Error handling: Server, client'a hata frame'i gönderebilir

STOMP Destination Modeli

/app/...    → Application destination (Controller'a gider)
/topic/...  → Broadcast (tüm subscriber'lara)
/queue/...  → Point-to-point (tek kullanıcıya)
/user/...   → User-specific mesaj

💡 İpucu: /topic publish-subscribe modelidir (bir mesaj → N alıcı). /queue point-to-point modelidir (bir mesaj → 1 alıcı). Chat room /topic, özel mesaj /queue veya /user kullanır.


4. SockJS Fallback

Problem: Her Tarayıcı WebSocket Desteklemiyor mu?

Modern tarayıcıların hepsi WebSocket destekler. Ama gerçek dünyada sorun tarayıcı değil, ağ altyapısıdır. Bazı kurumsal proxy'ler, firewall'lar ve load balancer'lar WebSocket upgrade'ini engelleyebilir.

SockJS Ne Yapar?

SockJS, WebSocket çalışmadığında otomatik olarak alternatif transport mekanizmalarına geçer:

  1. WebSocket — Önce dener (en iyi seçenek)

  2. XHR Streaming — WebSocket yoksa, uzun süren HTTP bağlantısı

  3. XHR Polling — Streaming de yoksa, klasik polling

  4. EventSource — Server-Sent Events

  5. iframe-based transports — Son çare

Client tarafında hiçbir şey değiştirmene gerek yok. SockJS otomatik olarak en iyi transport'u seçer.

// SockJS kullanımı — WebSocket yerine SockJS objesi oluşturursun
const socket = new SockJS('/ws');
// Gerisini SockJS halleder

SockJS Info Endpoint

SockJS, bağlantı kurmadan önce server'a bir info isteği atar:

GET /ws/info HTTP/1.1

Server, desteklediği transport'ları döner. Client buna göre karar verir.

⚠️ Dikkat: SockJS kullanıyorsan, WebSocket endpoint URL'in sonuna /websocket otomatik eklenir. Yani /ws endpoint'in aslında /ws/websocket, /ws/xhr_streaming, /ws/xhr gibi alt URL'lere sahip olur. CORS ayarlarında bunu hesaba kat.


5. Spring WebSocket Konfigürasyonu

Bağımlılık

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

Temel Konfigürasyon

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // Simple in-memory broker — /topic ve /queue prefix'li destination'lar
        config.enableSimpleBroker("/topic", "/queue");
        
        // Application destination prefix — Controller'a yönlendirme
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // WebSocket endpoint — client buraya bağlanır
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")  // CORS
                .withSockJS();                   // SockJS fallback aktif
    }
}

Bu konfigürasyonun ne yaptığını adım adım açıklayalım:

`@EnableWebSocketMessageBroker` — WebSocket message broker'ı aktifleştirir. Bu annotation olmadan hiçbir şey çalışmaz.

`enableSimpleBroker("/topic", "/queue")` — Spring'in built-in memory broker'ını etkinleştirir. /topic/... ve /queue/... ile başlayan destination'lar doğrudan client'lara iletilir.

`setApplicationDestinationPrefixes("/app")`/app/... ile başlayan mesajlar @MessageMapping annotated controller method'larına yönlendirilir.

`addEndpoint("/ws")` — Client'ların bağlanacağı WebSocket endpoint. SockJS ile birlikte /ws/** altında birçok alt endpoint oluşur.

Mesaj Akış Diyagramı

Client                    Spring                     Clients
  |                         |                           |
  |-- SEND /app/chat ------>|                           |
  |                    @MessageMapping("/chat")         |
  |                    Controller method çalışır        |
  |                         |                           |
  |                    @SendTo("/topic/messages")       |
  |                         |                           |
  |<-- MESSAGE -------------|---------- MESSAGE ------->|
  |    /topic/messages      |          /topic/messages  |

6. Message Broker: Simple vs External

Simple Broker

Spring'in built-in broker'ıdır. Herhangi bir ek bağımlılık gerektirmez. Küçük uygulamalar için ideal.

config.enableSimpleBroker("/topic", "/queue");

Avantajları:

  • Sıfır konfigürasyon, hemen çalışır

  • Ek infrastructure yok

  • Development için mükemmel

Dezavantajları:

  • Sadece memory'de çalışır — sunucu restart'ta tüm subscription'lar kaybolur

  • Cluster desteği yok — birden fazla instance'ta çalışmaz

  • Message persistence yok

  • Gelişmiş routing yok

External Broker (RabbitMQ / ActiveMQ)

Production ortamında birden fazla sunucu instance'ın varsa, external broker şart.

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
    // External RabbitMQ broker
    config.enableStompBrokerRelay("/topic", "/queue")
          .setRelayHost("localhost")
          .setRelayPort(61613)         // STOMP port
          .setClientLogin("guest")
          .setClientPasscode("guest")
          .setSystemLogin("guest")
          .setSystemPasscode("guest");
    
    config.setApplicationDestinationPrefixes("/app");
}

Ek bağımlılık:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>

External broker ile:

Instance 1 ──┐
              ├──→ RabbitMQ ──→ Tüm subscribe olan client'lar
Instance 2 ──┘

Mesaj hangi instance'a gelirse gelsin, RabbitMQ üzerinden tüm instance'lardaki client'lara ulaşır.

💡 İpucu: Development'ta simple broker, production'da external broker kullan. @Profile ile kolayca ayırabilirsin. Konfigürasyon dışında uygulama kodun değişmez.

Ne Zaman Hangisi?

DurumSimple BrokerExternal Broker
Tek sunucu, az kullanıcıOverkill
Multiple instance, load balanced
Mesaj persistence gerekli
Development / POCGereksiz
Production, yüksek trafikRiskli

7. Controller: @MessageMapping ve @SendTo

WebSocket mesajlarını handle eden controller, REST controller'a çok benzer. Ama @RestController yerine mesaj odaklı annotation'lar kullanılır.

Basit Bir Controller

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;

@Controller
public class ChatController {

    @MessageMapping("/chat.send")          // /app/chat.send'e gelen mesajları yakala
    @SendTo("/topic/public")               // Sonucu /topic/public'e broadcast et
    public ChatMessage sendMessage(ChatMessage message) {
        return message;  // Gelen mesajı olduğu gibi herkese gönder
    }

    @MessageMapping("/chat.join")
    @SendTo("/topic/public")
    public ChatMessage addUser(ChatMessage message) {
        message.setType(MessageType.JOIN);
        return message;
    }
}

Model Sınıfları

public class ChatMessage {
    private MessageType type;
    private String content;
    private String sender;
    
    public enum MessageType {
        CHAT, JOIN, LEAVE
    }
    
    // Constructor, getter, setter
    public ChatMessage() {}
    
    public ChatMessage(MessageType type, String content, String sender) {
        this.type = type;
        this.content = content;
        this.sender = sender;
    }
    
    public MessageType getType() { return type; }
    public void setType(MessageType type) { this.type = type; }
    
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    
    public String getSender() { return sender; }
    public void setSender(String sender) { this.sender = sender; }
}

@MessageMapping Detayları

@MessageMapping tıpkı @RequestMapping gibi çalışır ama HTTP değil STOMP mesajları için.

// Basit mapping
@MessageMapping("/hello")           // /app/hello'ya gelen mesaj
public void handleHello(String greeting) {
    // ...
}

// Path variable
@MessageMapping("/chat/{roomId}")   // /app/chat/room1
public void handleRoom(@DestinationVariable String roomId, 
                        ChatMessage message) {
    // roomId = "room1"
}

// Header erişimi
@MessageMapping("/message")
public void handleWithHeaders(@Header("simpSessionId") String sessionId,
                               @Payload ChatMessage message) {
    System.out.println("Session: " + sessionId);
}

@SendTo vs SimpMessagingTemplate

@SendTo basit senaryolar için güzel. Ama bazen runtime'da hedefi belirlemek gerekir. O zaman SimpMessagingTemplate kullanılır.

@Controller
public class NotificationController {

    private final SimpMessagingTemplate messagingTemplate;

    public NotificationController(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    @MessageMapping("/notify")
    public void sendNotification(NotificationMessage message) {
        // Dinamik hedef belirleme
        String destination = "/topic/notifications." + message.getChannel();
        messagingTemplate.convertAndSend(destination, message);
    }
    
    // REST endpoint'ten de WebSocket mesajı gönderebilirsin!
    // Bu çok güçlü bir pattern.
    @PostMapping("/api/broadcast")
    public ResponseEntity<Void> broadcastFromRest(@RequestBody String message) {
        messagingTemplate.convertAndSend("/topic/public", message);
        return ResponseEntity.ok().build();
    }
}

💡 İpucu: SimpMessagingTemplate ile REST API'den de WebSocket client'larına mesaj push edebilirsin. Örneğin bir sipariş oluşturulduğunda, REST endpoint'i siparişi kaydeder ve WebSocket ile dashboard'a anlık bildirim gönderir. İki dünyanın en iyisi!


8. JavaScript Client: SockJS + STOMP.js

Bağımlılıklar (CDN veya npm)

<!-- CDN ile -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
# veya npm ile
npm install sockjs-client stompjs

Temel Bağlantı

// 1. SockJS üzerinden WebSocket bağlantısı oluştur
const socket = new SockJS('http://localhost:8080/ws');

// 2. STOMP client oluştur
const stompClient = Stomp.over(socket);

// 3. Bağlan
stompClient.connect({}, function(frame) {
    console.log('Bağlandı: ' + frame);
    
    // 4. Bir topic'e subscribe ol
    stompClient.subscribe('/topic/public', function(message) {
        const chatMessage = JSON.parse(message.body);
        console.log('Mesaj geldi:', chatMessage);
        displayMessage(chatMessage);
    });
    
    // 5. Katılma mesajı gönder
    stompClient.send('/app/chat.join', {}, JSON.stringify({
        sender: 'Ali',
        type: 'JOIN'
    }));
});

// Mesaj gönderme fonksiyonu
function sendMessage(content) {
    stompClient.send('/app/chat.send', {}, JSON.stringify({
        sender: 'Ali',
        content: content,
        type: 'CHAT'
    }));
}

// Bağlantıyı kapatma
function disconnect() {
    if (stompClient !== null) {
        stompClient.disconnect(function() {
            console.log('Bağlantı kesildi');
        });
    }
}

Bağlantı Parametreleri ve Header'lar

// Authentication header'ları ile bağlanma
const headers = {
    'Authorization': 'Bearer ' + token,
    'X-Custom-Header': 'value'
};

stompClient.connect(headers, 
    function(frame) {
        // Başarılı bağlantı
        console.log('Bağlandı:', frame);
    },
    function(error) {
        // Hata durumu
        console.error('Bağlantı hatası:', error);
        // Yeniden bağlanma denemesi
        setTimeout(reconnect, 5000);
    }
);

Debug Modunu Kapatma

// STOMP.js varsayılan olarak tüm frame'leri console'a yazar.
// Production'da kapat:
stompClient.debug = null;

// Veya kendi log fonksiyonunu yaz:
stompClient.debug = function(str) {
    if (str.indexOf('PING') === -1) {  // Heartbeat loglarını filtrele
        console.log(str);
    }
};

⚠️ Dikkat: STOMP.js'in debug fonksiyonunu production'da mutlaka kapatın veya filtreyin. Varsayılan haliyle her STOMP frame'ini console'a yazdırır — bu hem performans kaybına hem de güvenlik açığına yol açar (token'lar loglara düşebilir).


9. Tam Örnek: Echo Server

Şimdi tüm parçaları birleştirelim. Basit bir echo server yapalım: kullanıcı mesaj gönderir, server mesajı timestamp ekleyerek herkese broadcast eder.

Proje Yapısı

src/
├── main/
│   ├── java/com/example/websocket/
│   │   ├── WebSocketApplication.java
│   │   ├── config/
│   │   │   └── WebSocketConfig.java
│   │   ├── controller/
│   │   │   └── MessageController.java
│   │   └── model/
│   │       ├── IncomingMessage.java
│   │       └── OutgoingMessage.java
│   └── resources/
│       ├── static/
│       │   └── index.html
│       └── application.properties

Model Sınıfları

// IncomingMessage.java — Client'tan gelen mesaj
public class IncomingMessage {
    private String sender;
    private String content;
    
    public IncomingMessage() {}
    
    public String getSender() { return sender; }
    public void setSender(String sender) { this.sender = sender; }
    
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
}
// OutgoingMessage.java — Client'a gönderilen mesaj
public class OutgoingMessage {
    private String sender;
    private String content;
    private String timestamp;
    
    public OutgoingMessage(String sender, String content, String timestamp) {
        this.sender = sender;
        this.content = content;
        this.timestamp = timestamp;
    }
    
    public String getSender() { return sender; }
    public String getContent() { return content; }
    public String getTimestamp() { return timestamp; }
}

Konfigürasyon

// WebSocketConfig.java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
}

Controller

// MessageController.java
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Controller
public class MessageController {

    private static final DateTimeFormatter FORMATTER = 
        DateTimeFormatter.ofPattern("HH:mm:ss");

    @MessageMapping("/echo")
    @SendTo("/topic/messages")
    public OutgoingMessage echo(IncomingMessage message) {
        String timestamp = LocalDateTime.now().format(FORMATTER);
        return new OutgoingMessage(
            message.getSender(),
            message.getContent(),
            timestamp
        );
    }
}

Frontend (index.html)

<!DOCTYPE html>
<html lang="tr">
<head>
    <meta charset="UTF-8">
    <title>WebSocket Echo Server</title>
    <style>
        body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; }
        #messages { border: 1px solid #ccc; height: 300px; overflow-y: scroll; padding: 10px; }
        .message { margin: 5px 0; padding: 5px; background: #f0f0f0; border-radius: 4px; }
        .timestamp { color: #888; font-size: 0.8em; }
        .controls { margin: 10px 0; }
        input, button { padding: 8px; margin: 2px; }
        #messageInput { width: 70%; }
        .status { padding: 5px 10px; border-radius: 4px; display: inline-block; }
        .connected { background: #d4edda; color: #155724; }
        .disconnected { background: #f8d7da; color: #721c24; }
    </style>
</head>
<body>
    <h1>🔌 WebSocket Echo Server</h1>
    
    <div class="controls">
        <input type="text" id="nameInput" placeholder="Adınız" value="Kullanıcı">
        <button onclick="connect()">Bağlan</button>
        <button onclick="disconnect()">Kes</button>
        <span id="status" class="status disconnected">Bağlı değil</span>
    </div>
    
    <div id="messages"></div>
    
    <div class="controls">
        <input type="text" id="messageInput" placeholder="Mesajınız..." 
               onkeypress="if(event.key==='Enter') sendMessage()">
        <button onclick="sendMessage()">Gönder</button>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
    <script>
        let stompClient = null;
        
        function connect() {
            const socket = new SockJS('/ws');
            stompClient = Stomp.over(socket);
            stompClient.debug = null; // Debug loglarını kapat
            
            stompClient.connect({}, function(frame) {
                document.getElementById('status').textContent = 'Bağlı ✓';
                document.getElementById('status').className = 'status connected';
                
                stompClient.subscribe('/topic/messages', function(message) {
                    const msg = JSON.parse(message.body);
                    showMessage(msg);
                });
                
                addSystemMessage('Bağlantı kuruldu!');
            }, function(error) {
                document.getElementById('status').textContent = 'Hata ✗';
                document.getElementById('status').className = 'status disconnected';
                addSystemMessage('Bağlantı hatası: ' + error);
            });
        }
        
        function disconnect() {
            if (stompClient) {
                stompClient.disconnect(function() {
                    document.getElementById('status').textContent = 'Bağlı değil';
                    document.getElementById('status').className = 'status disconnected';
                    addSystemMessage('Bağlantı kesildi.');
                });
            }
        }
        
        function sendMessage() {
            const input = document.getElementById('messageInput');
            const name = document.getElementById('nameInput').value;
            
            if (stompClient && input.value.trim()) {
                stompClient.send('/app/echo', {}, JSON.stringify({
                    sender: name,
                    content: input.value.trim()
                }));
                input.value = '';
            }
        }
        
        function showMessage(msg) {
            const div = document.getElementById('messages');
            div.innerHTML += '<div class="message"><strong>' + msg.sender + 
                ':</strong> ' + msg.content + 
                ' <span class="timestamp">' + msg.timestamp + '</span></div>';
            div.scrollTop = div.scrollHeight;
        }
        
        function addSystemMessage(text) {
            const div = document.getElementById('messages');
            div.innerHTML += '<div class="message" style="background:#e3f2fd;font-style:italic;">' 
                + text + '</div>';
            div.scrollTop = div.scrollHeight;
        }
    </script>
</body>
</html>

Bu uygulamayı çalıştırmak için:

# Spring Boot uygulamasını başlat
mvn spring-boot:run

# Tarayıcıda aç
# http://localhost:8080
# İki farklı tab aç, ikisinden de mesaj gönder, iki tarafta da gör.

10. Connection Lifecycle

WebSocket bağlantısının yaşam döngüsünü takip etmek, özellikle online kullanıcı listesi veya kaynak yönetimi için kritiktir.

Spring Events

Spring, WebSocket bağlantı olayları için event'ler yayınlar:

import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectEvent;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;

@Component
public class WebSocketEventListener {

    @EventListener
    public void handleSessionConnect(SessionConnectEvent event) {
        // Client bağlantı isteği gönderdi (henüz tamamlanmadı)
        StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = headers.getSessionId();
        System.out.println("Bağlantı isteği: " + sessionId);
    }

    @EventListener
    public void handleSessionConnected(SessionConnectedEvent event) {
        // Bağlantı başarıyla kuruldu
        StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
        System.out.println("Bağlandı: " + headers.getSessionId());
    }

    @EventListener
    public void handleSessionDisconnect(SessionDisconnectEvent event) {
        // Bağlantı koptu
        StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = headers.getSessionId();
        System.out.println("Ayrıldı: " + sessionId);
    }

    @EventListener
    public void handleSubscribe(SessionSubscribeEvent event) {
        // Client bir destination'a subscribe oldu
        StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
        System.out.println("Subscribe: " + headers.getDestination());
    }
}

Event Sırası

1. SessionConnectEvent      → Client bağlantı isteği gönderdi
2. SessionConnectedEvent    → Server bağlantıyı kabul etti
3. SessionSubscribeEvent    → Client bir topic'e subscribe oldu (birden fazla olabilir)
4. ... (mesajlaşma devam eder) ...
5. SessionDisconnectEvent   → Bağlantı koptu (client kapattı veya timeout)

Online Kullanıcı Takibi

@Component
public class WebSocketSessionTracker {

    // Thread-safe set — çoklu bağlantıda sorun çıkmasın
    private final Set<String> onlineSessions = ConcurrentHashMap.newKeySet();
    private final Map<String, String> sessionUserMap = new ConcurrentHashMap<>();

    @EventListener
    public void handleConnect(SessionConnectedEvent event) {
        StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = headers.getSessionId();
        onlineSessions.add(sessionId);
        
        // Native header'lardan kullanıcı adını al (client bağlanırken gönderir)
        // Veya Spring Security'den Principal al
        // headers.getUser() != null ise → headers.getUser().getName()
    }

    @EventListener
    public void handleDisconnect(SessionDisconnectEvent event) {
        StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = headers.getSessionId();
        onlineSessions.remove(sessionId);
        sessionUserMap.remove(sessionId);
    }

    public int getOnlineCount() {
        return onlineSessions.size();
    }

    public Set<String> getOnlineSessions() {
        return Collections.unmodifiableSet(onlineSessions);
    }
}

⚠️ Dikkat: SessionDisconnectEvent her zaman güvenilir değildir. Network kesintisinde client düzgün kapamayabilir. Bu durumda heartbeat timeout'u tetiklenir ve event gecikmeli gelebilir. Online kullanıcı sayısı %100 doğru olmayabilir — bunu kullanıcıya gösterirken göz önünde bulundur.

Heartbeat Ayarları

Heartbeat, bağlantının hâlâ canlı olduğunu doğrular:

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic", "/queue")
          .setHeartbeatValue(new long[]{10000, 10000})  // server→client, client→server (ms)
          .setTaskScheduler(taskScheduler());             // Heartbeat scheduler
    
    config.setApplicationDestinationPrefixes("/app");
}

@Bean
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(1);
    scheduler.setThreadNamePrefix("ws-heartbeat-");
    scheduler.initialize();
    return scheduler;
}

Client tarafında:

// STOMP.js heartbeat konfigürasyonu
stompClient.heartbeat.outgoing = 10000; // Her 10 saniyede client → server
stompClient.heartbeat.incoming = 10000; // Her 10 saniyede server → client bekle

💡 İpucu: Heartbeat değerlerini çok düşük tutma (1-2 saniye) — gereksiz trafik oluşturur. Çok yüksek tutma (60+ saniye) — ölü bağlantılar geç tespit edilir. 10-20 saniye genelde ideal.


11. WebSocket URL Pattern'leri ve Routing

Destination Prefix Stratejisi

İyi bir destination naming convention, büyüyen projede hayat kurtarır:

/topic/public                    → Herkese açık broadcast
/topic/room.{roomId}             → Belirli bir oda
/topic/notifications.{userId}    → Kullanıcıya özel bildirim
/queue/errors                    → Hata mesajları
/user/queue/messages             → Kişiye özel mesaj (Spring yönetir)

Birden Fazla Endpoint

Farklı amaçlar için farklı endpoint'ler tanımlayabilirsin:

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    // Ana WebSocket endpoint — SockJS ile
    registry.addEndpoint("/ws")
            .setAllowedOriginPatterns("*")
            .withSockJS();
    
    // Mobil client'lar için — SockJS olmadan (native WebSocket)
    registry.addEndpoint("/ws-native")
            .setAllowedOriginPatterns("*");
    // Mobil SDK'lar genellikle native WebSocket destekler, SockJS gereksiz
}

Message Size Limiti

@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
    registry.setMessageSizeLimit(128 * 1024);      // Max mesaj boyutu: 128KB
    registry.setSendBufferSizeLimit(512 * 1024);    // Send buffer: 512KB
    registry.setSendTimeLimit(20 * 1000);            // Send timeout: 20 saniye
}

⚠️ Dikkat: Varsayılan mesaj boyutu limiti 64KB'dır. Büyük payload'lar gönderiyorsan (örneğin base64 encoded resim) bu limiti artırmalısın. Ama çok yüksek tutma — malicious client'lar büyük mesajlarla sunucuyu zorlayabilir.


12. Hata Ayıklama (Debugging)

WebSocket debugging REST'ten farklıdır. Birkaç ipucu:

Spring Logging

# application.properties
logging.level.org.springframework.web.socket=DEBUG
logging.level.org.springframework.messaging=DEBUG

# STOMP frame'lerini görmek için
logging.level.org.springframework.web.socket.messaging=TRACE

Browser DevTools

Chrome DevTools → Network sekmesi → WS filtresi ile WebSocket frame'lerini görebilirsin. Her giden ve gelen STOMP frame'ini inceleyebilirsin.

Postman WebSocket

Postman'in WebSocket desteği var. ws://localhost:8080/ws/websocket adresine bağlanıp STOMP frame'lerini manuel gönderebilirsin:

CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000

\0
SUBSCRIBE
id:sub-0
destination:/topic/messages

\0
SEND
destination:/app/echo
content-type:application/json

{"sender":"Test","content":"Merhaba"}
\0

💡 İpucu: \0 (null character) her STOMP frame'inin sonunda olmalıdır. Postman'de bu ^@ veya \u0000 olarak girilir. Bunu unutmak en yaygın debugging hatasıdır.


13. application.properties Ayarları

# Server port
server.port=8080

# WebSocket mesaj boyutu (byte)
spring.websocket.max-text-message-size=131072
spring.websocket.max-binary-message-size=131072

# SockJS ayarları
spring.websocket.sockjs.heartbeat-time=25000
spring.websocket.sockjs.disconnect-delay=5000
spring.websocket.sockjs.stream-bytes-limit=131072

# Session timeout (dakika)
server.servlet.session.timeout=30m

Yaygın Hatalar ve Çözümleri

1. CORS Hatası

Access to XMLHttpRequest at 'http://localhost:8080/ws/info' 
from origin 'http://localhost:3000' has been blocked by CORS policy

Çözüm:

registry.addEndpoint("/ws")
        .setAllowedOriginPatterns("http://localhost:3000")  // Spesifik origin
        .withSockJS();

2. 404 — Endpoint Bulunamadı

SockJS kullanıyorsan, client URL'i /ws olmalı, /ws/websocket değil. SockJS kendi alt URL'lerini yönetir.

// Doğru
const socket = new SockJS('/ws');

// Yanlış (SockJS kullanıyorsan)
const socket = new WebSocket('ws://localhost:8080/ws');

3. Mesaj İletilmiyor

Destination prefix'lerini kontrol et:

// Konfigürasyon
config.setApplicationDestinationPrefixes("/app");
config.enableSimpleBroker("/topic");

// Controller — /app prefix'i otomatik eklenir
@MessageMapping("/chat")  // → /app/chat'e gelen mesajlar

// Client — /app prefix'ini SEN eklemelisin
stompClient.send('/app/chat', {}, JSON.stringify(message));

// Subscribe — broker prefix'i ile
stompClient.subscribe('/topic/messages', callback);

4. Jackson Serialization Hatası

Could not write JSON: No serializer found for class...

Çözüm: Model sınıflarında getter method'ları olmalı. Veya @JsonAutoDetect kullan. Lombok @Data en kolay çözüm.


Özet

  • WebSocket, HTTP'nin aksine full-duplex iletişim sağlar — server da client'a istediği zaman mesaj gönderebilir

  • STOMP protokolü, raw WebSocket üzerine routing, subscription ve message format ekler — kendi protokolünü yazma

  • SockJS, WebSocket çalışmayan ortamlarda otomatik olarak fallback transport'lara geçer

  • Spring'de @EnableWebSocketMessageBroker + WebSocketMessageBrokerConfigurer ile dakikalar içinde WebSocket kurulumu yapılır

  • Simple broker development için yeterli, external broker (RabbitMQ) production ve multi-instance için şart

  • @MessageMapping + @SendTo ile mesaj handle etmek, REST controller yazmak kadar kolay — SimpMessagingTemplate ile runtime'da dinamik hedef belirlenebilir