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

TP14.3: تطبيق دردشة باستخدام WebSocket

أهداف التطبيق العملي​

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

المدة المقدرة: 1 ساعة و30 دقيقة


الهندسة المعمارية​

Browser 1 ----+
|
Browser 2 ----+---> [WebSocket Server] ---> /topic/public
| |
Browser 3 ----+ +---> /user/{id}/queue/private

الجزء 1: التكوين (15 دقيقة)​

1.1 التبعيات​

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.5.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.4</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>5.3.2</version>
</dependency>
</dependencies>

1.2 تكوين WebSocket​

package com.example.chat.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) {
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}

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

الجزء 2: DTOs (10 دقائق)​

package com.example.chat.dto;

public class ChatMessage {

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

public enum MessageType {
CHAT, JOIN, LEAVE, PRIVATE
}

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 et 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: وحدة تحكم WebSocket (15 دقيقة)​

package com.example.chat.controller;

import com.example.chat.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.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

@Controller
public class ChatController {

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

private final SimpMessagingTemplate messagingTemplate;

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

@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage message) {
log.info("Message from {}: {}", message.getSender(), message.getContent());
message.setTimestamp(java.time.LocalDateTime.now().toString());
return message;
}

@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {
headerAccessor.getSessionAttributes().put("username", message.getSender());
log.info("User joined: {}", message.getSender());

message.setType(ChatMessage.MessageType.JOIN);
message.setContent(message.getSender() + " joined the chat");
return message;
}

@MessageMapping("/chat.privateMessage")
public void sendPrivateMessage(@Payload PrivateMessage message) {
log.info("Private message from {} to {}", message.getSender(), message.getRecipient());

ChatMessage chatMessage = new ChatMessage(
message.getSender(),
message.getContent(),
ChatMessage.MessageType.PRIVATE
);

messagingTemplate.convertAndSendToUser(
message.getRecipient(),
"/queue/private",
chatMessage
);
}

public static class PrivateMessage {
private String sender;
private String recipient;
private String content;

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; }
}
}

الجزء 4: مستمع الأحداث (10 دقائق)​

package com.example.chat.listener;

import com.example.chat.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.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 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 message = new ChatMessage(
username,
username + " left the chat",
ChatMessage.MessageType.LEAVE
);

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

الجزء 5: صفحة HTML (20 دقيقة)​

إنشاء src/main/resources/static/index.html:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket Chat</title>
<link rel="stylesheet" href="/webjars/bootstrap/5.3.2/css/bootstrap.min.css">
<style>
.chat-container { max-width: 800px; margin: 50px auto; }
.messages { height: 400px; overflow-y: auto; border: 1px solid #ddd;
padding: 15px; background: #f9f9f9; }
.message { padding: 8px 12px; margin: 5px 0; border-radius: 5px; }
.chat-message { background: #e3f2fd; }
.join-message { background: #c8e6c9; color: #2e7d32; }
.leave-message { background: #ffcdd2; color: #c62828; }
.private-message { background: #fff3e0; border-left: 3px solid #ff9800; }
.sender { font-weight: bold; }
.timestamp { font-size: 0.8em; color: #666; }
</style>
</head>
<body>
<div class="chat-container">
<h2 class="text-center mb-4">WebSocket Chat</h2>

<!-- Login -->
<div id="login-page" class="card p-4">
<div class="mb-3">
<input type="text" id="username" class="form-control"
placeholder="Enter your username">
</div>
<button class="btn btn-primary w-100" onclick="connect()">
Join Chat
</button>
</div>

<!-- Chat -->
<div id="chat-page" style="display: none;">
<div class="messages" id="messages"></div>

<div class="mt-3">
<div class="input-group">
<input type="text" id="message" class="form-control"
placeholder="Type a message...">
<button class="btn btn-primary" onclick="sendMessage()">
Send
</button>
</div>
</div>

<div class="mt-3">
<small class="text-muted">
Connected as: <strong id="current-user"></strong>
</small>
</div>
</div>
</div>

<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<script>
let stompClient = null;
let username = null;

function connect() {
username = document.getElementById('username').value.trim();
if (!username) {
alert('Please enter a username');
return;
}

const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);

// Disable debug logs
stompClient.debug = null;

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

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

// Subscribe to public topic
stompClient.subscribe('/topic/public', onMessageReceived);

// Subscribe to private messages
stompClient.subscribe('/user/queue/private', onPrivateMessage);

// Announce joining
stompClient.send('/app/chat.addUser', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));
}

function onError(error) {
console.error('WebSocket error:', error);
alert('Could not connect to 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.type = 'PRIVATE';
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.innerHTML = message.content;
} else if (message.type === 'LEAVE') {
messageElement.className += ' leave-message';
messageElement.innerHTML = message.content;
} else if (message.type === 'PRIVATE') {
messageElement.className += ' private-message';
messageElement.innerHTML =
'<span class="sender">[Private] ' + message.sender + ':</span> ' +
message.content;
} else {
messageElement.className += ' chat-message';
messageElement.innerHTML =
'<span class="sender">' + message.sender + ':</span> ' +
message.content;
}

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

// Send on Enter
document.getElementById('message').addEventListener('keypress', function(e) {
if (e.key === 'Enter') sendMessage();
});

document.getElementById('username').addEventListener('keypress', function(e) {
if (e.key === 'Enter') connect();
});
</script>
</body>
</html>

الجزء 6: الاختبارات (15 دقيقة)​

6.1 اختبار الدردشة​

  1. تشغيل التطبيق
  2. فتح http://localhost:8080 في 2-3 متصفحات
  3. إدخال أسماء مختلفة
  4. إرسال رسائل

6.2 الملاحظة​

  • الرسائل تُبث لجميع المستخدمين
  • إشعارات الاتصال/الانفصال
  • تحديث في الوقت الحقيقي

قائمة التحقق من التحقق​

  • تظهر صفحة الدردشة
  • اتصال WebSocket ناجح
  • الرسائل تُبث لجميع المستخدمين
  • إشعارات JOIN/LEAVE تعمل
  • الواجهة تُحدث في الوقت الحقيقي

TP14.4: المهام المجدولة

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