WebSocket ile Real-Time Uygulama
Giriş — Canlı Chat Neden Zor?
WhatsApp'ı açıyorsun, mesaj yazıyorsun, karşı taraf anında görüyor. Basit görünür. Ama arka planda: mesajın doğru kişiye ulaşması, online/offline durumu, bağlantı koptuğunda ne olacağı, binlerce eşzamanlı kullanıcıyı yönetmek, güvenlik, rate limiting... İşler hızla karmaşıklaşır.
Önceki derste WebSocket temellerini ve basit bir echo server'ı gördük. Bu derste işleri ciddiye alıyoruz: gerçek bir chat uygulaması, user-specific mesajlar, session tracking, güvenlik ve scaling. Production-ready bir real-time uygulama nasıl yapılır, adım adım göreceğiz.
1. Chat Room Uygulaması: Step-by-Step
Proje Yapısı
src/main/java/com/example/chat/
├── ChatApplication.java
├── config/
│ └── WebSocketConfig.java
├── controller/
│ └── ChatController.java
├── model/
│ ├── ChatMessage.java
│ └── ChatRoom.java
├── service/
│ └── OnlineUserService.java
└── listener/
└── WebSocketEventListener.java
src/main/resources/
├── static/
│ └── index.html
└── application.propertiesStep 1: Model Sınıfları
// ChatMessage.java
public class ChatMessage {
public enum MessageType {
CHAT, // Normal mesaj
JOIN, // Kullanıcı katıldı
LEAVE, // Kullanıcı ayrıldı
TYPING, // Yazıyor... göstergesi
SYSTEM // Sistem mesajı
}
private MessageType type;
private String content;
private String sender;
private String roomId;
private long timestamp;
public ChatMessage() {
this.timestamp = System.currentTimeMillis();
}
public ChatMessage(MessageType type, String content, String sender) {
this();
this.type = type;
this.content = content;
this.sender = sender;
}
// Getter ve setter'lar
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; }
public String getRoomId() { return roomId; }
public void setRoomId(String roomId) { this.roomId = roomId; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}Step 2: WebSocket Konfigürasyonu
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue")
.setHeartbeatValue(new long[]{10000, 10000})
.setTaskScheduler(heartbeatScheduler());
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user"); // User-specific mesajlar için
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.setMessageSizeLimit(64 * 1024); // 64KB max mesaj
registry.setSendBufferSizeLimit(512 * 1024); // 512KB send buffer
registry.setSendTimeLimit(20_000); // 20s send timeout
}
@Bean
public TaskScheduler heartbeatScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
scheduler.setThreadNamePrefix("ws-heartbeat-");
scheduler.initialize();
return scheduler;
}
}Step 3: Online Kullanıcı Servisi
@Service
public class OnlineUserService {
// sessionId → username mapping
private final Map<String, String> sessionUsers = new ConcurrentHashMap<>();
// roomId → Set<username> mapping
private final Map<String, Set<String>> roomUsers = new ConcurrentHashMap<>();
public void userConnected(String sessionId, String username) {
sessionUsers.put(sessionId, username);
}
public void userJoinedRoom(String username, String roomId) {
roomUsers.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet())
.add(username);
}
public String userDisconnected(String sessionId) {
String username = sessionUsers.remove(sessionId);
if (username != null) {
// Tüm odalardan çıkar
roomUsers.values().forEach(users -> users.remove(username));
}
return username;
}
public Set<String> getUsersInRoom(String roomId) {
return roomUsers.getOrDefault(roomId, Collections.emptySet());
}
public int getOnlineCount() {
return sessionUsers.size();
}
public boolean isOnline(String username) {
return sessionUsers.containsValue(username);
}
}Step 4: Event Listener
@Component
public class WebSocketEventListener {
private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);
private final SimpMessagingTemplate messagingTemplate;
private final OnlineUserService onlineUserService;
public WebSocketEventListener(SimpMessagingTemplate messagingTemplate,
OnlineUserService onlineUserService) {
this.messagingTemplate = messagingTemplate;
this.onlineUserService = onlineUserService;
}
@EventListener
public void handleSessionConnected(SessionConnectedEvent event) {
StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = headers.getSessionId();
logger.info("Yeni bağlantı: sessionId={}", sessionId);
}
@EventListener
public void handleSessionDisconnect(SessionDisconnectEvent event) {
StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = headers.getSessionId();
String username = onlineUserService.userDisconnected(sessionId);
if (username != null) {
logger.info("Kullanıcı ayrıldı: {}", username);
ChatMessage leaveMessage = new ChatMessage(
ChatMessage.MessageType.LEAVE,
username + " sohbetten ayrıldı",
username
);
// Herkese bildir
messagingTemplate.convertAndSend("/topic/chat.public", leaveMessage);
// Güncel online listesini gönder
messagingTemplate.convertAndSend("/topic/online-users",
onlineUserService.getUsersInRoom("public"));
}
}
}Step 5: Chat Controller
@Controller
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
private final OnlineUserService onlineUserService;
public ChatController(SimpMessagingTemplate messagingTemplate,
OnlineUserService onlineUserService) {
this.messagingTemplate = messagingTemplate;
this.onlineUserService = onlineUserService;
}
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/chat.public")
public ChatMessage sendMessage(ChatMessage message) {
message.setTimestamp(System.currentTimeMillis());
return message;
}
@MessageMapping("/chat.join")
@SendTo("/topic/chat.public")
public ChatMessage joinChat(ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {
// Session'a username'i kaydet
String sessionId = headerAccessor.getSessionId();
headerAccessor.getSessionAttributes().put("username", message.getSender());
onlineUserService.userConnected(sessionId, message.getSender());
onlineUserService.userJoinedRoom(message.getSender(), "public");
message.setType(ChatMessage.MessageType.JOIN);
message.setContent(message.getSender() + " sohbete katıldı!");
// Online kullanıcı listesini güncelle
messagingTemplate.convertAndSend("/topic/online-users",
onlineUserService.getUsersInRoom("public"));
return message;
}
@MessageMapping("/chat.typing")
public void typing(ChatMessage message) {
message.setType(ChatMessage.MessageType.TYPING);
// Yazan kişi hariç herkese gönder?
// Basitlik için herkese gönderiyoruz, client kendi adını filtreler
messagingTemplate.convertAndSend("/topic/chat.typing", message);
}
// Belirli bir odaya mesaj gönderme
@MessageMapping("/chat.room.{roomId}")
public void sendToRoom(@DestinationVariable String roomId,
ChatMessage message) {
message.setRoomId(roomId);
message.setTimestamp(System.currentTimeMillis());
messagingTemplate.convertAndSend("/topic/chat.room." + roomId, message);
}
}2. User-Specific Mesaj: @SendToUser
Bazen mesajı herkese değil, tek bir kullanıcıya göndermek istersin. Özel mesaj, hata bildirimi, kişisel bildirim gibi.
@SendToUser Annotation
@Controller
public class PrivateMessageController {
@MessageMapping("/private.send")
@SendToUser("/queue/private-messages")
public ChatMessage handlePrivateMessage(ChatMessage message, Principal principal) {
// Bu mesaj SADECE mesajı gönderen kullanıcıya döner
// Principal üzerinden authenticated user bilgisi gelir
message.setContent("Server aldı: " + message.getContent());
return message;
}
}Client tarafında subscribe:
// /user prefix'i Spring tarafından otomatik eklenir
stompClient.subscribe('/user/queue/private-messages', function(message) {
console.log('Özel mesaj:', JSON.parse(message.body));
});💡 İpucu:
@SendToUserkullandığında, Spring otomatik olarak mesajı gönderen kullanıcının session'ına yönlendirir. Destination/user/queue/private-messagesolur ama Spring bunu arka planda/queue/private-messages-user{sessionId}gibi bir şeye çevirir. Client sadece/user/queue/private-messages'a subscribe olur.
SimpMessagingTemplate ile Hedefli Mesaj
Gerçek bir özel mesaj sistemi için convertAndSendToUser() kullanılır:
@Controller
public class DirectMessageController {
private final SimpMessagingTemplate messagingTemplate;
public DirectMessageController(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
@MessageMapping("/dm.send")
public void sendDirectMessage(DirectMessage dm, Principal sender) {
dm.setSender(sender.getName());
dm.setTimestamp(System.currentTimeMillis());
// Alıcıya gönder
messagingTemplate.convertAndSendToUser(
dm.getRecipient(), // Hedef kullanıcı adı
"/queue/direct-messages", // Destination
dm // Payload
);
// Gönderene de kopyasını gönder (kendi chat penceresinde görsün)
messagingTemplate.convertAndSendToUser(
sender.getName(),
"/queue/direct-messages",
dm
);
}
}// DirectMessage model
public class DirectMessage {
private String sender;
private String recipient;
private String content;
private long timestamp;
// Constructor, getter, setter...
public DirectMessage() {}
public String getSender() { return sender; }
public void setSender(String sender) { this.sender = sender; }
public String getRecipient() { return recipient; }
public void setRecipient(String recipient) { this.recipient = recipient; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}⚠️ Dikkat:
convertAndSendToUser()çalışması için kullanıcının authenticated olması gerekir. Spring Security entegrasyonu olmadan, Principal null gelir ve mesaj iletilemez. Basit kullanım için session attribute'larıyla workaround yapılabilir ama production'da mutlaka authentication kullan.
Principal Olmadan User Mapping (Workaround)
Authentication yoksa, STOMP handshake sırasında username'i yakalayıp session'a bağlayabilirsin:
@Configuration
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
// Client bağlanırken gönderdiği header'dan username al
String username = accessor.getFirstNativeHeader("username");
if (username != null) {
accessor.setUser(new SimplePrincipal(username));
}
}
return message;
}
});
}
}
// Basit Principal implementasyonu
public class SimplePrincipal implements Principal {
private final String name;
public SimplePrincipal(String name) { this.name = name; }
@Override
public String getName() { return name; }
}Client tarafı:
stompClient.connect(
{ 'username': 'Ali' }, // Native header olarak username gönder
function(frame) {
console.log('Bağlandı:', frame);
}
);3. Session Tracking: Kim Online?
Detaylı Session Bilgisi
@Component
public class SessionRegistry {
private final Map<String, SessionInfo> sessions = new ConcurrentHashMap<>();
public static class SessionInfo {
private final String sessionId;
private final String username;
private final Instant connectedAt;
private Instant lastActivityAt;
public SessionInfo(String sessionId, String username) {
this.sessionId = sessionId;
this.username = username;
this.connectedAt = Instant.now();
this.lastActivityAt = Instant.now();
}
public void updateActivity() {
this.lastActivityAt = Instant.now();
}
// Getter'lar
public String getSessionId() { return sessionId; }
public String getUsername() { return username; }
public Instant getConnectedAt() { return connectedAt; }
public Instant getLastActivityAt() { return lastActivityAt; }
}
@EventListener
public void onConnect(SessionConnectedEvent event) {
StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = headers.getSessionId();
// Username'i al (Principal veya native header'dan)
String username = "anonymous";
if (headers.getUser() != null) {
username = headers.getUser().getName();
}
sessions.put(sessionId, new SessionInfo(sessionId, username));
}
@EventListener
public void onDisconnect(SessionDisconnectEvent event) {
StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
sessions.remove(headers.getSessionId());
}
public Collection<SessionInfo> getAllSessions() {
return sessions.values();
}
public Optional<SessionInfo> getSession(String sessionId) {
return Optional.ofNullable(sessions.get(sessionId));
}
public List<String> getOnlineUsernames() {
return sessions.values().stream()
.map(SessionInfo::getUsername)
.distinct()
.collect(Collectors.toList());
}
}Online Kullanıcı Listesini REST ile Sunma
@RestController
@RequestMapping("/api")
public class UserStatusController {
private final SessionRegistry sessionRegistry;
private final SimpMessagingTemplate messagingTemplate;
public UserStatusController(SessionRegistry sessionRegistry,
SimpMessagingTemplate messagingTemplate) {
this.sessionRegistry = sessionRegistry;
this.messagingTemplate = messagingTemplate;
}
@GetMapping("/online-users")
public ResponseEntity<List<String>> getOnlineUsers() {
return ResponseEntity.ok(sessionRegistry.getOnlineUsernames());
}
@GetMapping("/online-count")
public ResponseEntity<Map<String, Integer>> getOnlineCount() {
return ResponseEntity.ok(
Map.of("count", sessionRegistry.getAllSessions().size())
);
}
}Periyodik Online Liste Broadcast
Online kullanıcı listesini belirli aralıklarla broadcast etmek, disconnect event'lerin kaçırılmasına karşı güvence sağlar:
@Component
public class OnlineBroadcaster {
private final SessionRegistry sessionRegistry;
private final SimpMessagingTemplate messagingTemplate;
public OnlineBroadcaster(SessionRegistry sessionRegistry,
SimpMessagingTemplate messagingTemplate) {
this.sessionRegistry = sessionRegistry;
this.messagingTemplate = messagingTemplate;
}
@Scheduled(fixedRate = 30_000) // Her 30 saniyede
public void broadcastOnlineUsers() {
List<String> onlineUsers = sessionRegistry.getOnlineUsernames();
messagingTemplate.convertAndSend("/topic/online-users", onlineUsers);
}
}💡 İpucu:
@Scheduledkullanmak için main class'a@EnableSchedulingeklemeyi unutma. Ve scheduled task'lar ile WebSocket push'u birleştirmek çok güçlü bir pattern — heartbeat kaçırılan session'ları da temizleyebilirsin.
4. Heartbeat Konfigürasyonu
Heartbeat, bağlantının canlı olduğunu doğrulayan "ping-pong" mekanizmasıdır.
Server Tarafı
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue")
.setHeartbeatValue(new long[]{10000, 10000});
// ↑ ↑
// server→client client→server
// (ms cinsinden)
config.setApplicationDestinationPrefixes("/app");
}İlk değer: Server'ın client'a heartbeat gönderme aralığı (0 = devre dışı) İkinci değer: Server'ın client'tan heartbeat bekleme aralığı (0 = beklemiyor)
Client Tarafı (STOMP.js)
const stompClient = Stomp.over(socket);
// Client heartbeat ayarları
stompClient.heartbeat.outgoing = 10000; // 10 saniyede bir client → server
stompClient.heartbeat.incoming = 10000; // 10 saniyede bir server → client bekle
// Heartbeat negotiation:
// Client ve server kendi değerlerini gönderir.
// Kullanılacak değer = MAX(client, server)
// Yani client 5s, server 10s derse → 10s kullanılır.Heartbeat Değer Seçimi
| Senaryo | Önerilen Değer | Neden? |
|---|---|---|
| Mobil uygulama | 25-30 saniye | Pil tasarrufu, mobil ağ trafiği |
| Desktop web | 10-15 saniye | Hızlı disconnect tespiti |
| LAN içi uygulama | 5-10 saniye | Düşük latency, güvenilir ağ |
| Yüksek trafikli | 15-20 saniye | Server yükünü dengeleme |
⚠️ Dikkat: Heartbeat değerlerini 0 yaparsanız heartbeat tamamen devre dışı kalır. Bu durumda ölü bağlantıları tespit edemezsiniz. TCP keepalive'a güvenmek zorunda kalırsınız ki bu proxy arkasında çalışmayabilir.
5. Hata Yönetimi
@MessageExceptionHandler
REST'teki @ExceptionHandler gibi, WebSocket mesaj işleme sırasında oluşan hataları yakalar:
@Controller
public class ChatController {
@MessageMapping("/chat.send")
@SendTo("/topic/chat.public")
public ChatMessage sendMessage(ChatMessage message) {
if (message.getContent() == null || message.getContent().isBlank()) {
throw new IllegalArgumentException("Mesaj içeriği boş olamaz!");
}
if (message.getContent().length() > 500) {
throw new IllegalArgumentException("Mesaj 500 karakterden uzun olamaz!");
}
return message;
}
// Bu controller'daki tüm @MessageMapping hatalarını yakalar
@MessageExceptionHandler
@SendToUser("/queue/errors") // Hatayı SADECE mesajı gönderen kullanıcıya bildir
public ErrorResponse handleException(Exception ex) {
return new ErrorResponse("HATA", ex.getMessage());
}
// Spesifik exception tipi
@MessageExceptionHandler(IllegalArgumentException.class)
@SendToUser("/queue/errors")
public ErrorResponse handleValidation(IllegalArgumentException ex) {
return new ErrorResponse("VALIDATION_ERROR", ex.getMessage());
}
}// ErrorResponse model
public class ErrorResponse {
private String code;
private String message;
private long timestamp;
public ErrorResponse(String code, String message) {
this.code = code;
this.message = message;
this.timestamp = System.currentTimeMillis();
}
public String getCode() { return code; }
public String getMessage() { return message; }
public long getTimestamp() { return timestamp; }
}Client tarafında hata mesajlarını dinleme:
// Hata mesajlarını dinle
stompClient.subscribe('/user/queue/errors', function(message) {
const error = JSON.parse(message.body);
console.error('Server hatası:', error.code, error.message);
showErrorNotification(error.message);
});Global Exception Handler
Tüm controller'lar için geçerli bir global handler:
@ControllerAdvice
public class WebSocketExceptionHandler {
private static final Logger logger =
LoggerFactory.getLogger(WebSocketExceptionHandler.class);
@MessageExceptionHandler(Exception.class)
@SendToUser("/queue/errors")
public ErrorResponse handleAll(Exception ex) {
logger.error("WebSocket mesaj işleme hatası", ex);
return new ErrorResponse("INTERNAL_ERROR",
"Bir hata oluştu. Lütfen tekrar deneyin.");
}
@MessageExceptionHandler(AccessDeniedException.class)
@SendToUser("/queue/errors")
public ErrorResponse handleAccessDenied(AccessDeniedException ex) {
return new ErrorResponse("ACCESS_DENIED", "Bu işlem için yetkiniz yok.");
}
}💡 İpucu: Client'a gönderilen hata mesajlarında stack trace veya detaylı internal bilgi paylaşma. Kullanıcıya "Bir hata oluştu" yaz, detayı server log'unda tut. Güvenlik açısından kritik.
6. WebSocket + Spring Security
Authentication: Kim Bağlanıyor?
WebSocket handshake normal bir HTTP isteğidir. Dolayısıyla Spring Security'nin HTTP security chain'i burada devreye girer.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf
// WebSocket endpoint'leri için CSRF devre dışı
// SockJS, WebSocket handshake HTTP isteği yapar
.ignoringRequestMatchers("/ws/**")
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/ws/**").permitAll() // WebSocket endpoint
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("*"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}JWT Token ile WebSocket Authentication
Modern uygulamalarda JWT yaygın. WebSocket'te JWT'yi STOMP CONNECT frame'inde gönderirsin:
@Configuration
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {
private final JwtTokenProvider jwtTokenProvider;
public WebSocketSecurityConfig(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
if (jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUsername(token);
List<GrantedAuthority> authorities =
jwtTokenProvider.getAuthorities(token);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
username, null, authorities);
accessor.setUser(auth);
} else {
throw new MessageDeliveryException("Geçersiz token");
}
}
}
return message;
}
});
}
}Client tarafı:
const token = localStorage.getItem('jwt_token');
stompClient.connect(
{ 'Authorization': 'Bearer ' + token },
function(frame) {
console.log('Authenticated bağlantı:', frame);
},
function(error) {
console.error('Auth hatası:', error);
// Token expired ise login sayfasına yönlendir
window.location.href = '/login';
}
);Message-Level Authorization
Belirli destination'lara erişimi kısıtlama:
@Configuration
@EnableWebSocketSecurity
public class WebSocketAuthorizationConfig {
@Bean
public AuthorizationManager<Message<?>> messageAuthorizationManager(
MessageMatcherDelegatingAuthorizationManager.Builder messages) {
messages
.simpDestMatchers("/app/admin.**").hasRole("ADMIN")
.simpSubscribeDestMatchers("/topic/admin.**").hasRole("ADMIN")
.simpDestMatchers("/app/**").authenticated()
.simpSubscribeDestMatchers("/topic/**").authenticated()
.simpSubscribeDestMatchers("/user/**").authenticated()
.anyMessage().denyAll();
return messages.build();
}
}⚠️ Dikkat: WebSocket bağlantısı bir kez kurulduktan sonra, HTTP session cookie veya token'ın süresi dolsa bile bağlantı açık kalır. Token expiry kontrolünü heartbeat veya mesaj interceptor'ında yapmalısın. Aksi halde expired token'lı kullanıcılar mesaj göndermeye devam edebilir.
CSRF Koruması
// SockJS kullanan WebSocket endpoint'leri için CSRF sorunlu olabilir.
// SockJS'in info endpoint'i GET isteği yapar — CSRF token gerekmez.
// Ama CONNECT frame'i POST olabilir.
// En yaygın çözüm: WebSocket endpoint'lerini CSRF'ten muaf tut
csrf.ignoringRequestMatchers("/ws/**")
// Alternatif: CSRF token'ı handshake header'ında gönder
// Bu daha güvenli ama implementasyonu daha karmaşık7. Rate Limiting: Flood Koruması
Bir kullanıcı saniyede 100 mesaj gönderirse ne olur? Diğer kullanıcıların deneyimi çöker. Rate limiting şart.
Interceptor ile Rate Limiting
@Component
public class RateLimitInterceptor implements ChannelInterceptor {
// sessionId → son mesaj zamanları
private final Map<String, Deque<Long>> messageTimestamps = new ConcurrentHashMap<>();
private static final int MAX_MESSAGES = 10; // Max mesaj sayısı
private static final long TIME_WINDOW_MS = 10000; // Zaman penceresi (10 saniye)
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.SEND.equals(accessor.getCommand())) {
String sessionId = accessor.getSessionId();
if (!isAllowed(sessionId)) {
throw new MessageDeliveryException(
"Rate limit aşıldı! " + MAX_MESSAGES + " mesaj / " +
(TIME_WINDOW_MS / 1000) + " saniye sınırını aştınız."
);
}
}
return message;
}
private boolean isAllowed(String sessionId) {
long now = System.currentTimeMillis();
Deque<Long> timestamps = messageTimestamps.computeIfAbsent(
sessionId, k -> new ConcurrentLinkedDeque<>());
// Eski timestamp'ları temizle
while (!timestamps.isEmpty() &&
timestamps.peekFirst() < now - TIME_WINDOW_MS) {
timestamps.pollFirst();
}
if (timestamps.size() >= MAX_MESSAGES) {
return false;
}
timestamps.addLast(now);
return true;
}
}Interceptor'ı konfigürasyona ekleme:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final RateLimitInterceptor rateLimitInterceptor;
public WebSocketConfig(RateLimitInterceptor rateLimitInterceptor) {
this.rateLimitInterceptor = rateLimitInterceptor;
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(rateLimitInterceptor);
}
// ... diğer konfigürasyonlar
}Rate Limit Hatalarını Client'a Bildirme
// Rate limit hatası ChannelInterceptor'da fırlatılır.
// Bu hata @MessageExceptionHandler'a düşmez çünkü
// interceptor, controller'dan önce çalışır.
// Çözüm: ErrorHandler tanımla
@Configuration
public class WebSocketErrorConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(rateLimitInterceptor);
registration.taskExecutor()
.corePoolSize(4)
.maxPoolSize(8);
}
}💡 İpucu: Rate limiting'i session bazlı değil, kullanıcı bazlı yapmak daha güvenli. Bir kullanıcı birden fazla tab açarak session bazlı rate limit'i bypass edebilir. Username veya IP bazlı limit daha etkili.
8. Scaling: Birden Fazla Sunucu
Problem: Sticky Session
Tek sunucuyla çalışırken her şey güzel. Ama load balancer arkasında iki sunucu varsa:
Client A ──→ Server 1 (WebSocket bağlantısı burada)
Client B ──→ Server 2 (WebSocket bağlantısı burada)
Client A mesaj gönderir → Server 1 alır → /topic/chat'e broadcast eder
→ Ama Client B Server 2'de! Server 1'deki broadcast'ı göremez!Çözüm 1: External Broker (RabbitMQ)
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq.internal")
.setRelayPort(61613)
.setClientLogin("guest")
.setClientPasscode("guest")
.setSystemLogin("guest")
.setSystemPasscode("guest")
.setSystemHeartbeatSendInterval(10000)
.setSystemHeartbeatReceiveInterval(10000);
config.setApplicationDestinationPrefixes("/app");
}Artık mesaj akışı:
Client A → Server 1 → RabbitMQ → Server 1 (Client A'ya)
→ Server 2 (Client B'ye)RabbitMQ tüm instance'lar arasında mesaj iletimini sağlar.
Çözüm 2: Redis Pub/Sub
RabbitMQ yerine Redis kullanmak da mümkün, ama Spring'in built-in STOMP relay'i Redis desteklemez. Custom implementasyon gerekir:
@Component
public class RedisMessageBridge {
private final SimpMessagingTemplate messagingTemplate;
private final RedisTemplate<String, String> redisTemplate;
@PostConstruct
public void init() {
// Redis'ten gelen mesajları dinle ve WebSocket'e yönlendir
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.addMessageListener(
(message, pattern) -> {
String channel = new String(message.getChannel());
String body = new String(message.getBody());
messagingTemplate.convertAndSend(channel, body);
},
new PatternTopic("/topic/*")
);
}
// Mesaj geldiğinde hem local broadcast hem Redis publish
public void broadcastMessage(String destination, Object message) {
// Local
messagingTemplate.convertAndSend(destination, message);
// Redis → diğer instance'lar
redisTemplate.convertAndSend(destination,
new ObjectMapper().writeValueAsString(message));
}
}Load Balancer Konfigürasyonu
WebSocket için load balancer'da sticky session veya connection upgrade desteği olmalı:
# Nginx WebSocket proxy konfigürasyonu
upstream websocket_backend {
# ip_hash ile sticky session — aynı IP hep aynı sunucuya
ip_hash;
server backend1:8080;
server backend2:8080;
}
server {
listen 80;
location /ws {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# WebSocket timeout — varsayılan 60s çok düşük
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}⚠️ Dikkat: Nginx
proxy_read_timeoutvarsayılanı 60 saniyedir. WebSocket bağlantısı 60 saniye boyunca mesaj göndermezse (heartbeat dahil) bağlantı kesilir. Production'da bu değeri en az 1 saat yapın ve heartbeat'i 60 saniyeden kısa tutun.
9. Frontend: Minimal Chat Client
İşte tüm özellikleri birleştiren tam bir frontend:
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spring Boot Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; }
.container {
max-width: 800px; margin: 20px auto; padding: 20px;
display: flex; flex-direction: column; height: 95vh;
}
/* Login ekranı */
.login-panel {
text-align: center; padding: 60px 20px;
background: #16213e; border-radius: 12px;
}
.login-panel h1 { margin-bottom: 20px; font-size: 2em; }
.login-panel input {
padding: 12px 20px; font-size: 16px; border: none;
border-radius: 8px; background: #0f3460; color: #fff;
width: 250px; text-align: center;
}
.login-panel button {
padding: 12px 30px; font-size: 16px; border: none;
border-radius: 8px; background: #e94560; color: #fff;
cursor: pointer; margin-left: 10px;
}
/* Chat ekranı */
.chat-panel { display: none; flex: 1; flex-direction: column; }
.chat-header {
background: #16213e; padding: 15px 20px; border-radius: 12px 12px 0 0;
display: flex; justify-content: space-between; align-items: center;
}
.online-count {
background: #27ae60; padding: 4px 12px; border-radius: 20px; font-size: 0.9em;
}
/* Mesaj listesi */
.messages {
flex: 1; overflow-y: auto; padding: 20px;
background: #16213e; display: flex; flex-direction: column;
}
.message {
max-width: 70%; padding: 10px 15px; margin: 5px 0;
border-radius: 12px; word-wrap: break-word;
}
.message.mine {
background: #e94560; align-self: flex-end; border-radius: 12px 12px 4px 12px;
}
.message.other {
background: #0f3460; align-self: flex-start; border-radius: 12px 12px 12px 4px;
}
.message.system {
background: transparent; align-self: center; color: #888;
font-style: italic; font-size: 0.85em;
}
.message .sender { font-weight: bold; font-size: 0.8em; color: #f0d9b5; }
.message .time { font-size: 0.7em; color: rgba(255,255,255,0.5); margin-top: 4px; }
/* Typing indicator */
.typing-indicator {
padding: 5px 20px; font-style: italic; color: #888; font-size: 0.85em;
min-height: 25px;
}
/* Mesaj gönderme */
.input-area {
display: flex; padding: 15px; background: #16213e;
border-radius: 0 0 12px 12px;
}
.input-area input {
flex: 1; padding: 12px 20px; border: none; border-radius: 8px;
background: #0f3460; color: #fff; font-size: 15px;
}
.input-area button {
padding: 12px 25px; border: none; border-radius: 8px;
background: #e94560; color: #fff; cursor: pointer;
margin-left: 10px; font-size: 15px;
}
.input-area button:hover { background: #c0392b; }
</style>
</head>
<body>
<div class="container">
<!-- Login -->
<div class="login-panel" id="loginPanel">
<h1>💬 Spring Chat</h1>
<p style="color: #888; margin-bottom: 20px;">Sohbete katılmak için adını gir</p>
<input type="text" id="usernameInput" placeholder="Adın"
onkeypress="if(event.key==='Enter') joinChat()">
<button onclick="joinChat()">Katıl</button>
</div>
<!-- Chat -->
<div class="chat-panel" id="chatPanel">
<div class="chat-header">
<h2>💬 Genel Sohbet</h2>
<span class="online-count" id="onlineCount">0 online</span>
</div>
<div class="messages" id="messagesDiv"></div>
<div class="typing-indicator" id="typingIndicator"></div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="Mesajını yaz..."
onkeypress="handleKeyPress(event)" oninput="handleTyping()">
<button onclick="sendMessage()">Gönder ➤</button>
</div>
</div>
</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;
let username = null;
let typingTimeout = null;
function joinChat() {
username = document.getElementById('usernameInput').value.trim();
if (!username) return;
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.debug = null;
stompClient.connect({ username: username }, function(frame) {
// Chat panelini göster
document.getElementById('loginPanel').style.display = 'none';
document.getElementById('chatPanel').style.display = 'flex';
// Mesajları dinle
stompClient.subscribe('/topic/chat.public', onMessageReceived);
// Online kullanıcıları dinle
stompClient.subscribe('/topic/online-users', onOnlineUsersUpdate);
// Typing indicator
stompClient.subscribe('/topic/chat.typing', onTypingReceived);
// Hata mesajları
stompClient.subscribe('/user/queue/errors', onErrorReceived);
// Katılma mesajı gönder
stompClient.send('/app/chat.join', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));
document.getElementById('messageInput').focus();
}, function(error) {
alert('Bağlantı hatası: ' + error);
});
}
function sendMessage() {
const input = document.getElementById('messageInput');
const content = input.value.trim();
if (!content || !stompClient) return;
stompClient.send('/app/chat.sendMessage', {}, JSON.stringify({
sender: username,
content: content,
type: 'CHAT'
}));
input.value = '';
}
function handleKeyPress(event) {
if (event.key === 'Enter') sendMessage();
}
function handleTyping() {
if (typingTimeout) clearTimeout(typingTimeout);
stompClient.send('/app/chat.typing', {}, JSON.stringify({
sender: username
}));
typingTimeout = setTimeout(() => {
// Typing durduğunda göstergeyi kaldırmak için
// boş typing event gönderilebilir
}, 2000);
}
function onMessageReceived(payload) {
const msg = JSON.parse(payload.body);
const div = document.getElementById('messagesDiv');
const msgEl = document.createElement('div');
const time = new Date(msg.timestamp).toLocaleTimeString('tr-TR',
{ hour: '2-digit', minute: '2-digit' });
if (msg.type === 'JOIN' || msg.type === 'LEAVE') {
msgEl.className = 'message system';
msgEl.textContent = msg.content;
} else {
const isMine = msg.sender === username;
msgEl.className = 'message ' + (isMine ? 'mine' : 'other');
msgEl.innerHTML =
(isMine ? '' : '<div class="sender">' + msg.sender + '</div>') +
'<div>' + escapeHtml(msg.content) + '</div>' +
'<div class="time">' + time + '</div>';
}
div.appendChild(msgEl);
div.scrollTop = div.scrollHeight;
}
function onOnlineUsersUpdate(payload) {
const users = JSON.parse(payload.body);
document.getElementById('onlineCount').textContent =
users.length + ' online';
}
function onTypingReceived(payload) {
const msg = JSON.parse(payload.body);
if (msg.sender !== username) {
const indicator = document.getElementById('typingIndicator');
indicator.textContent = msg.sender + ' yazıyor...';
setTimeout(() => { indicator.textContent = ''; }, 2000);
}
}
function onErrorReceived(payload) {
const error = JSON.parse(payload.body);
console.error('Error:', error);
alert('Hata: ' + error.message);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>10. Production Checklist
Gerçek bir chat uygulamasını production'a almadan önce kontrol listesi:
Güvenlik
[ ] WebSocket endpoint'te authentication var mı?
[ ] Message-level authorization tanımlı mı?
[ ] Input validation (XSS koruması) yapılıyor mu?
[ ] Rate limiting aktif mi?
[ ] CORS doğru yapılandırılmış mı?
[ ] WSS (TLS) kullanılıyor mu?
Performans
[ ] External broker (RabbitMQ/ActiveMQ) kuruldu mu?
[ ] Message size limit ayarlandı mı?
[ ] Heartbeat optimize edildi mi?
[ ] Connection pooling yapılandırıldı mı?
[ ] Thread pool boyutları ayarlandı mı?
Monitoring
[ ] WebSocket connection sayısı metric olarak toplanıyor mu?
[ ] Mesaj throughput izleniyor mu?
[ ] Error rate takip ediliyor mu?
[ ] Disconnect nedenleri loglanıyor mu?
Resilience
[ ] Client reconnect stratejisi var mı?
[ ] Server restart'ta graceful shutdown yapılıyor mu?
[ ] Dead connection cleanup mekanizması var mı?
[ ] Circuit breaker pattern uygulandı mı?
Spring Boot Actuator ile Monitoring
# application.properties
management.endpoints.web.exposure.include=health,metrics,websocket// Custom WebSocket metric'leri
@Component
public class WebSocketMetrics {
private final MeterRegistry meterRegistry;
private final AtomicInteger activeConnections;
public WebSocketMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.activeConnections = meterRegistry.gauge(
"websocket.connections.active", new AtomicInteger(0));
}
@EventListener
public void onConnect(SessionConnectedEvent event) {
activeConnections.incrementAndGet();
meterRegistry.counter("websocket.connections.total").increment();
}
@EventListener
public void onDisconnect(SessionDisconnectEvent event) {
activeConnections.decrementAndGet();
meterRegistry.counter("websocket.disconnections.total").increment();
}
}11. Client Reconnect Stratejisi
Bağlantı koptuğunda otomatik yeniden bağlanma:
let stompClient = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 10;
function connect() {
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.debug = null;
stompClient.connect({},
function(frame) {
// Başarılı bağlantı
reconnectAttempts = 0;
console.log('Bağlandı!');
subscribeToTopics();
},
function(error) {
// Bağlantı koptu — yeniden dene
console.error('Bağlantı koptu:', error);
reconnect();
}
);
}
function reconnect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error('Maksimum deneme sayısına ulaşıldı');
showErrorMessage('Bağlantı kurulamıyor. Sayfayı yenileyin.');
return;
}
reconnectAttempts++;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 30000);
console.log(`Yeniden bağlanma denemesi ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} ` +
`(${delay/1000}s sonra)`);
showSystemMessage(`Bağlantı koptu. ${delay/1000}s sonra tekrar deneniyor...`);
setTimeout(connect, delay);
}
function subscribeToTopics() {
stompClient.subscribe('/topic/chat.public', onMessageReceived);
stompClient.subscribe('/user/queue/errors', onErrorReceived);
stompClient.subscribe('/topic/online-users', onOnlineUsersUpdate);
// Yeniden bağlandıysa, tekrar join gönder
if (reconnectAttempts > 0) {
stompClient.send('/app/chat.join', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));
}
}💡 İpucu: Exponential backoff çok önemli. Sunucu çöktüğünde tüm client'lar aynı anda reconnect denerse "thundering herd" problemi oluşur — sunucu ayağa kalkmaya çalışırken binlerce bağlantı isteğiyle boğulur. Backoff + jitter (rastgele gecikme ekleme) bu sorunu çözer.
Jitter Ekleme
function reconnect() {
const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 30000);
// Rastgele jitter: ±%25
const jitter = baseDelay * 0.25 * (Math.random() * 2 - 1);
const delay = baseDelay + jitter;
setTimeout(connect, delay);
}Yaygın Hatalar ve Çözümleri
1. "User destination not resolved"
User destination "/user/queue/messages" not resolvedNeden: convertAndSendToUser() çağrıldı ama hedef kullanıcı connected değil veya Principal ayarlanmamış.
Çözüm: CONNECT interceptor'ında Principal set ettiğinden emin ol. Kullanıcının bağlı olduğunu kontrol et.
2. Mesajlar Farklı Tab'larda Çoğalıyor
Aynı kullanıcı 3 tab açtıysa, /user/queue/... mesajları 3 tab'a da gider. Bu genellikle istenen davranış ama bazen sorun olabilir.
Çözüm: Session-specific destination kullan veya client tarafında deduplication yap.
3. Memory Leak — Session Map Büyüyor
// YANLIŞ — disconnect event'i kaçırılırsa entry kalıcı olur
private final Map<String, String> sessions = new HashMap<>();
// DOĞRU — TTL ile otomatik temizleme
// Guava Cache veya Caffeine kullan
private final Cache<String, String> sessions = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofHours(1))
.build();4. Cross-Origin SockJS Hatası
Blocked by CORS: /ws/infoÇözüm:
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("http://localhost:*", "https://myapp.com")
.withSockJS();setAllowedOrigins("*") SockJS ile çalışmaz! setAllowedOriginPatterns("*") kullan.
Özet
Chat uygulaması, WebSocket + STOMP + Spring Boot ile step-by-step kurulur — Model → Config → Service → Controller → Frontend akışı
User-specific mesajlar için
@SendToUserveyaSimpMessagingTemplate.convertAndSendToUser()kullanılır — Principal (authentication) şartSession tracking ile online kullanıcı listesi tutulur —
SessionConnectedEventveSessionDisconnectEventdinlenir,ConcurrentHashMapile thread-safe yönetilirSpring Security entegrasyonu STOMP CONNECT interceptor'ında yapılır — JWT token header'da gönderilir, message-level authorization destination pattern'larıyla tanımlanır
Rate limiting ChannelInterceptor ile implementte edilir — sliding window veya token bucket algoritması, flood saldırılarına karşı korur
Scaling için external broker (RabbitMQ) veya Redis Pub/Sub kullanılır — sticky session + Nginx WebSocket proxy konfigürasyonu, client tarafında exponential backoff ile reconnect stratejisi
AI Asistan
Sorularını yanıtlamaya hazır