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ınWebSocket: 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: 13Server 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
| Özellik | HTTP (REST) | WebSocket |
|---|---|---|
| İletişim yönü | Tek yön (client → server) | Çift yön (full-duplex) |
| Bağlantı süresi | Her istekte aç/kapat | Sürekli açık |
| Overhead | Her istekte header'lar | İlk handshake sonrası minimal |
| Real-time | Polling/Long-polling gerekir | Native push |
| Protokol | HTTP/1.1, HTTP/2 | ws://, wss:// |
| State | Stateless | Stateful |
| Caching | Kolay (HTTP cache) | Cache yok |
| Load balancing | Basit (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 | Öneri | Neden? |
|---|---|---|
| CRUD işlemleri (user, product) | REST | Standard request-response yeterli |
| Real-time chat | WebSocket | İki yönlü anlık iletişim şart |
| Dashboard'da canlı metrik | SSE veya WebSocket | Tek yönlü push yeterliyse SSE |
| Online oyun | WebSocket | Düşük latency, iki yönlü |
| Dosya upload | REST (multipart) | WebSocket binary için optimize değil |
| Bildirim sistemi | WebSocket veya SSE | Push gerekli |
| Form submit | REST | Tek seferlik işlem |
| Borsa fiyatları | WebSocket | Yüksek frekanslı güncelleme |
| Ödeme işlemi | REST | Güvenlik, idempotency önemli |
| Collaborative editing (Google Docs) | WebSocket | Sü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ı
Destination-based routing: Mesajlar
/topic/...veya/queue/...gibi hedeflere gönderilirSubscribe mekanizması: Client hangi mesajları almak istediğini belirtir
Content-type: Mesajın formatı header'da belirtilir
Acknowledgment: Mesajın alındığı onaylanabilir
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:
/topicpublish-subscribe modelidir (bir mesaj → N alıcı)./queuepoint-to-point modelidir (bir mesaj → 1 alıcı). Chat room/topic, özel mesaj/queueveya/userkullanı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:
WebSocket — Önce dener (en iyi seçenek)
XHR Streaming — WebSocket yoksa, uzun süren HTTP bağlantısı
XHR Polling — Streaming de yoksa, klasik polling
EventSource — Server-Sent Events
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 hallederSockJS Info Endpoint
SockJS, bağlantı kurmadan önce server'a bir info isteği atar:
GET /ws/info HTTP/1.1Server, desteklediği transport'ları döner. Client buna göre karar verir.
⚠️ Dikkat: SockJS kullanıyorsan, WebSocket endpoint URL'in sonuna
/websocketotomatik eklenir. Yani/wsendpoint'in aslında/ws/websocket,/ws/xhr_streaming,/ws/xhrgibi 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.
@Profileile kolayca ayırabilirsin. Konfigürasyon dışında uygulama kodun değişmez.
Ne Zaman Hangisi?
| Durum | Simple Broker | External Broker |
|---|---|---|
| Tek sunucu, az kullanıcı | ✅ | Overkill |
| Multiple instance, load balanced | ❌ | ✅ |
| Mesaj persistence gerekli | ❌ | ✅ |
| Development / POC | ✅ | Gereksiz |
| Production, yüksek trafik | Riskli | ✅ |
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:
SimpMessagingTemplateile 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 stompjsTemel 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
debugfonksiyonunu 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.propertiesModel 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:
SessionDisconnectEventher 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=TRACEBrowser 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
\0SUBSCRIBE
id:sub-0
destination:/topic/messages
\0SEND
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\u0000olarak 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=30mYaygı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+WebSocketMessageBrokerConfigurerile dakikalar içinde WebSocket kurulumu yapılırSimple broker development için yeterli, external broker (RabbitMQ) production ve multi-instance için şart
@MessageMapping+@SendToile mesaj handle etmek, REST controller yazmak kadar kolay —SimpMessagingTemplateile runtime'da dinamik hedef belirlenebilir
AI Asistan
Sorularını yanıtlamaya hazır