انتقل إلى المحتوى الرئيسي

الفصل 5: WebSockets والاتصال في الوقت الحقيقي

أهداف الفصل​

  • فهم بروتوكول WebSocket
  • إعداد Spring WebSocket مع STOMP
  • تنفيذ نظام مراسلة في الوقت الحقيقي
  • إدارة مصادقة WebSocket
  • إنشاء تطبيقات الدردشة والإشعارات

1. مقدمة في WebSockets​

1.1 HTTP مقابل WebSocket​

HTTP (طلب-استجابة):
Client ──GET──> Server
Client <──200── Server
Client ──GET──> Server
Client <──200── Server
(يتم إغلاق الاتصال بعد كل تبادل)

WebSocket (ثنائي الاتجاه):
Client ──Handshake──> Server
<──Upgrade────
←───────────→ (اتصال مستمر)
←───────────→ (رسائل ثنائية الاتجاه)

1.2 متى تستخدم WebSocket​

حالة الاستخدامالحل
الدردشة في الوقت الحقيقيWebSocket
إشعارات الدفعWebSocket
الألعاب متعددة اللاعبينWebSocket
التداول في الوقت الحقيقيWebSocket
لوحات التحكم الحيةWebSocket
البيانات المتكررة (polling)WebSocket
API REST القياسيHTTP

1.3 بروتوكول STOMP​

يوفر STOMP (Simple Text Oriented Messaging Protocol) طبقة تجريد على WebSocket:

STOMP Frame:
COMMAND
header1:value1
header2:value2

Body^@

الأوامر الرئيسية:

  • CONNECT - إنشاء اتصال
  • SUBSCRIBE - الاشتراك في وجهة
  • SEND - إرسال رسالة
  • MESSAGE - استقبال رسالة
  • DISCONNECT - إغلاق الاتصال

2. إعداد Spring WebSocket​

2.1 تبعيات Maven​

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

<!-- من أجل SockJS fallback -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.5.1</version>
</dependency>

<!-- من أجل STOMP -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.4</version>
</dependency>
</dependencies>

2.2 إعداد WebSocket​

package com.example.websocket.config;

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 registry) {
// بادئة للوجهات التي يرسلها الخادم إلى العملاء
// /topic = بث لجميع المشتركين
// /queue = رسالة خاصة لمستخدم واحد
registry.enableSimpleBroker("/topic", "/queue");

// بادئة للوجهات التي يرسلها العملاء إلى الخادم
registry.setApplicationDestinationPrefixes("/app");

// بادئة للرسائل الخاصة لمستخدم معين
registry.setUserDestinationPrefix("/user");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// نقطة نهاية WebSocket مع SockJS fallback
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000", "http://localhost:8080")
.withSockJS();
}
}

3. وحدة التحكم WebSocket​

3.1 كائنات نقل الرسائل (DTOs)​

package com.example.websocket.dto;

public class ChatMessage {

private String sender;
private String content;
private String timestamp;
private MessageType type;

public enum MessageType {
CHAT, JOIN, LEAVE
}

public ChatMessage() {}

public ChatMessage(String sender, String content, MessageType type) {
this.sender = sender;
this.content = content;
this.type = type;
this.timestamp = java.time.LocalDateTime.now().toString();
}

// Getters و Setters
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; }

public String getTimestamp() { return timestamp; }
public void setTimestamp(String timestamp) { this.timestamp = timestamp; }

public MessageType getType() { return type; }
public void setType(MessageType type) { this.type = type; }
}

3.2 وحدة التحكم​

package com.example.websocket.controller;

import com.example.websocket.dto.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;

@Controller
public class ChatController {

private static final Logger log = LoggerFactory.getLogger(ChatController.class);

// العميل يرسل إلى /app/chat.sendMessage
// الخادم يبث إلى /topic/public
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage message) {
log.info("Message received: {} from {}", message.getContent(), message.getSender());
return message;
}

// إدارة وصول المستخدم
@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {

// تخزين اسم المستخدم في جلسة WebSocket
headerAccessor.getSessionAttributes().put("username", message.getSender());

log.info("User joined: {}", message.getSender());

message.setType(ChatMessage.MessageType.JOIN);
return message;
}
}

3.3 إرسال الرسائل من خدمة​

package com.example.websocket.service;

import com.example.websocket.dto.ChatMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

@Service
public class NotificationService {

private final SimpMessagingTemplate messagingTemplate;

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

// بث لجميع المشتركين في /topic/notifications
public void sendBroadcastNotification(String message) {
ChatMessage notification = new ChatMessage(
"System",
message,
ChatMessage.MessageType.CHAT
);

messagingTemplate.convertAndSend("/topic/notifications", notification);
}

// رسالة خاصة لمستخدم معين
public void sendPrivateMessage(String username, String message) {
ChatMessage privateMessage = new ChatMessage(
"System",
message,
ChatMessage.MessageType.CHAT
);

// إرسال إلى /user/{username}/queue/private
messagingTemplate.convertAndSendToUser(
username,
"/queue/private",
privateMessage
);
}

// إرسال إلى وجهة محددة
public void sendToRoom(String roomId, ChatMessage message) {
messagingTemplate.convertAndSend("/topic/room/" + roomId, message);
}
}

4. مستمعو الأحداث​

4.1 إدارة الاتصالات/الانفصالات​

package com.example.websocket.listener;

import com.example.websocket.dto.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

@Component
public class WebSocketEventListener {

private static final Logger log = LoggerFactory.getLogger(WebSocketEventListener.class);

private final SimpMessageSendingOperations messagingTemplate;

public WebSocketEventListener(SimpMessageSendingOperations messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}

@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
log.info("New WebSocket connection established");
}

@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

String username = (String) headerAccessor.getSessionAttributes().get("username");

if (username != null) {
log.info("User disconnected: {}", username);

ChatMessage leaveMessage = new ChatMessage(
username,
username + " has left the chat",
ChatMessage.MessageType.LEAVE
);

messagingTemplate.convertAndSend("/topic/public", leaveMessage);
}
}
}

5. عميل JavaScript​

5.1 قالب HTML​

<!DOCTYPE html>
<html>
<head>
<title>WebSocket Chat</title>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<style>
.chat-container { max-width: 600px; margin: 0 auto; }
.messages { height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; }
.message { padding: 5px; margin: 5px 0; }
.join-message { color: green; }
.leave-message { color: red; }
input[type="text"] { width: 70%; padding: 10px; }
button { padding: 10px 20px; }
</style>
</head>
<body>
<div class="chat-container">
<div id="login-page">
<input type="text" id="username" placeholder="Enter your name">
<button onclick="connect()">Join Chat</button>
</div>

<div id="chat-page" style="display: none;">
<div class="messages" id="messages"></div>
<input type="text" id="message" placeholder="Type a message...">
<button onclick="sendMessage()">Send</button>
</div>
</div>

<script>
let stompClient = null;
let username = null;

function connect() {
username = document.getElementById('username').value.trim();
if (!username) return;

// اتصال SockJS
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);

stompClient.connect({}, onConnected, onError);
}

function onConnected() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('chat-page').style.display = 'block';

// الاشتراك في الموضوع العام
stompClient.subscribe('/topic/public', onMessageReceived);

// الاشتراك في الرسائل الخاصة
stompClient.subscribe('/user/queue/private', onPrivateMessage);

// إعلان الوصول
stompClient.send('/app/chat.addUser', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));
}

function onError(error) {
console.error('WebSocket error:', error);
alert('Could not connect to WebSocket server');
}

function sendMessage() {
const messageInput = document.getElementById('message');
const content = messageInput.value.trim();

if (content && stompClient) {
stompClient.send('/app/chat.sendMessage', {}, JSON.stringify({
sender: username,
content: content,
type: 'CHAT'
}));

messageInput.value = '';
}
}

function onMessageReceived(payload) {
const message = JSON.parse(payload.body);
displayMessage(message);
}

function onPrivateMessage(payload) {
const message = JSON.parse(payload.body);
message.content = '[Private] ' + message.content;
displayMessage(message);
}

function displayMessage(message) {
const messagesDiv = document.getElementById('messages');
const messageElement = document.createElement('div');
messageElement.className = 'message';

if (message.type === 'JOIN') {
messageElement.className += ' join-message';
messageElement.textContent = message.sender + ' joined the chat';
} else if (message.type === 'LEAVE') {
messageElement.className += ' leave-message';
messageElement.textContent = message.sender + ' left the chat';
} else {
messageElement.textContent = message.sender + ': ' + message.content;
}

messagesDiv.appendChild(messageElement);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

// إرسال مع Enter
document.getElementById('message').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>

6. مصادقة WebSocket​

6.1 معترض القناة​

package com.example.websocket.config;

import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

@Component
public class AuthChannelInterceptor implements ChannelInterceptor {

private final JwtService jwtService;

public AuthChannelInterceptor(JwtService jwtService) {
this.jwtService = jwtService;
}

@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 (jwtService.validateToken(token)) {
String username = jwtService.extractUsername(token);

UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
username, null, Collections.emptyList()
);

accessor.setUser(auth);
}
}
}

return message;
}
}

6.2 تسجيل المعترض​

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final AuthChannelInterceptor authInterceptor;

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authInterceptor);
}

// ... إعدادات أخرى
}

7. الغرف والقنوات​

7.1 وحدة التحكم مع الغرف​

@Controller
public class RoomController {

@MessageMapping("/room/{roomId}/send")
@SendTo("/topic/room/{roomId}")
public ChatMessage sendToRoom(
@DestinationVariable String roomId,
@Payload ChatMessage message) {

log.info("Message to room {}: {}", roomId, message.getContent());
return message;
}

@MessageMapping("/room/{roomId}/join")
@SendTo("/topic/room/{roomId}")
public ChatMessage joinRoom(
@DestinationVariable String roomId,
@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {

// تخزين الغرفة في الجلسة
headerAccessor.getSessionAttributes().put("currentRoom", roomId);

message.setType(ChatMessage.MessageType.JOIN);
message.setContent(message.getSender() + " joined room " + roomId);

return message;
}
}

7.2 عميل JavaScript - الغرف​

function joinRoom(roomId) {
// إلغاء الاشتراك من الغرفة القديمة
if (currentRoomSubscription) {
currentRoomSubscription.unsubscribe();
}

// الاشتراك في الغرفة الجديدة
currentRoomSubscription = stompClient.subscribe(
'/topic/room/' + roomId,
onRoomMessage
);

// إعلان الوصول
stompClient.send('/app/room/' + roomId + '/join', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));

currentRoom = roomId;
}

function sendToRoom(content) {
stompClient.send('/app/room/' + currentRoom + '/send', {}, JSON.stringify({
sender: username,
content: content,
type: 'CHAT'
}));
}

8. الملخص​

الهيكلية​

Client (Browser)
|
| SockJS / WebSocket
|
Spring WebSocket
|
├── بروتوكول STOMP
|
├── وسيط الرسائل (/topic, /queue)
|
└── @MessageMapping Controllers

بادئات الوجهة​

بادئةالاستخدام
/appمن العميل إلى الخادم
/topicبث (1-to-many)
/queueنقطة إلى نقطة (1-to-1)
/userرسائل خاصة

أفضل الممارسات​

  1. استخدام SockJS للتوافق
  2. تنفيذ المصادقة
  3. إدارة الانفصالات بشكل صحيح
  4. تحديد حجم الرسائل
  5. مراقبة الاتصالات النشطة
  6. استخدام الغرف للتوسع

الانتقال إلى الأعمال التطبيقية

العودة إلى الوحدة 14