1. 什么是WebSocket?
WebSocket是一种在单个TCP连接上进行全双工通信的协议。它允许客户端和服务器之间的实时、双向数据传输。与传统的HTTP请求/响应模型相比,WebSocket更加高效,因为它在初次握手后,连接保持打开状态,可以不断传输数据。
WebSocket的基本工作原理
- 握手阶段:客户端通过HTTP请求发起WebSocket连接请求,服务器响应并同意协议升级。
- 数据传输阶段:握手完成后,客户端和服务器可以通过这个持久连接进行双向数据传输。
- 连接关闭:客户端或服务器可以随时关闭连接。
2. 环境准备
2.1 拉取 RabbitMQ 镜像
使用以下命令从 Docker Hub 拉取 RabbitMQ 镜像:
docker pull bitnami/rabbitmq
2.2. 启动容器
使用以下命令从 Docker Hub 拉取 RabbitMQ 镜像并运行容器,同时设置远程访问的用户名和密码。
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 -p 61613:61613 bitnami/rabbitmq:latest
此命令做了以下事情:
-d
:以后台模式运行容器。--name rabbitmq
:为容器指定名称为rabbitmq
。-p 5672:5672
:将容器的 5672 端口映射到主机的 5672 端口,用于 AMQP 协议。-p 15672:15672
:将容器的 15672 端口映射到主机的 15672 端口,用于 RabbitMQ 管理插件(Web UI)。p 61613:61613
:STOMP 协议端口。rabbitmq:management
:使用包含管理插件的 RabbitMQ 镜像。
2.3 验证 RabbitMQ 容器是否启动成功
运行以下命令查看 RabbitMQ 容器的状态:
docker ps
你应该能够看到名为 rabbitmq
的容器正在运行。
2.4 添加新管理员用户并启用 STOMP 插件
执行以下命令以进入运行中的容器,并添加新的管理员用户以及启用 STOMP 插件:
# 进入 RabbitMQ 容器
docker exec -it rabbitmq bash# 添加新的管理员用户
rabbitmqctl add_user admin 123456
rabbitmqctl set_user_tags admin administrator
rabbitmqctl set_permissions -p / admin ".*" ".*" ".*"# 启用 STOMP 插件
rabbitmq-plugins enable rabbitmq_stomp# 退出容器
exit
2.5 访问 RabbitMQ 管理界面
打开浏览器,访问 http://192.168.186.77:15672
,切换IP为你自己的IP,使用你在运行容器时设置的用户名和密码登录。
登录成功页面:
3. 项目代码
3.1 项目结构
3.2 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.3.2</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>org.example</groupId><artifactId>websocket</artifactId><version>0.0.1-SNAPSHOT</version><properties><java.version>17</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.33</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId></dependency><dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId></dependency><dependency><groupId>javax.xml.bind</groupId><artifactId>jaxb-api</artifactId><version>2.3.1</version></dependency><dependency><groupId>org.glassfish.jaxb</groupId><artifactId>jaxb-runtime</artifactId><version>2.3.1</version></dependency><dependency><groupId>javax.activation</groupId><artifactId>javax.activation-api</artifactId><version>1.2.0</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><excludes><exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude></excludes></configuration></plugin></plugins></build></project>
3.3 application.yml
spring:datasource:url: jdbc:mysql://localhost:3306/websocket_demousername: rootpassword: 123456driver-class-name: com.mysql.cj.jdbc.Driverjpa:hibernate:ddl-auto: updateshow-sql: trueopen-in-view: falserabbitmq:host: 192.168.186.77port: 5672username: adminpassword: 123456
stomp:port: 61613
3.4 WebsocketApplication.java
package org.example;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
public class WebsocketApplication {public static void main(String[] args) {SpringApplication.run(WebsocketApplication.class, args);}}
3.5 User.java
package org.example.model;import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;import java.util.HashSet;
import java.util.Set;@Entity
@Getter
@Setter
public class User {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;@Column(unique = true)private String username;private String password;private String name;private String imageUrl;@ManyToMany@JoinTable(name = "friends",joinColumns = @JoinColumn(name = "user_id"),inverseJoinColumns = @JoinColumn(name = "friend_id"))@JsonIgnoreprivate Set<User> friends = new HashSet<>();
}
3.6 ChatMessage.java
package org.example.model;
import lombok.Data;@Data
public class ChatMessage {public enum MessageType {CHAT,JOIN,LEAVE}private MessageType type;private String content;private String sender;private String senderName;private String recipient; // 用于一对一消息private long timestamp;
}
3.7 UserRepository.java
package org.example.repository;import org.example.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;@Repository
public interface UserRepository extends JpaRepository<User, Long> {User findByUsername(String username);
}
3.8 UserService.java
package org.example.service;import org.example.model.User;
import org.example.repository.UserRepository;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import java.util.Set;@Service
public class UserService {@Autowiredprivate UserRepository userRepository;@Transactionalpublic void register(User user) {userRepository.save(user);}@Transactional(readOnly = true)public User login(String username, String password) {User user = userRepository.findByUsername(username);if (user != null && user.getPassword().equals(password)) {return user;}return null;}@Transactionalpublic void addFriend(String userName, String friendName) {User user = userRepository.findByUsername(userName);User friend = userRepository.findByUsername(friendName);user.getFriends().add(friend);friend.getFriends().add(user); // 双向关系userRepository.save(friend); // 保存友谊关系userRepository.save(user);}@Transactional(readOnly = true)public Set<User> getFriends(String username) {User user = userRepository.findByUsername(username);Hibernate.initialize(user.getFriends()); // 手动初始化,防止懒加载return user.getFriends();}
}
3.9 RabbitMQService.java
package org.example.service;import org.example.listener.ChatMessageListener;
import org.example.model.ChatMessage;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.listener.MessageListenerContainer;
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
import org.springframework.amqp.rabbit.listener.adapter.MessageListenerAdapter;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;@Service
public class RabbitMQService {// RabbitAdmin 用于管理 AMQP 资源,如队列、交换机和绑定private final RabbitAdmin rabbitAdmin;// 定义 TopicExchange,支持基于路由键模式的消息路由private final TopicExchange exchange;// ConnectionFactory 用于配置和管理与 RabbitMQ 的连接private final ConnectionFactory connectionFactory;// 消息监听器,用于处理接收到的消息private final ChatMessageListener chatMessageListener;// 消息转换器,将消息转换为 JSON 格式和从 JSON 格式转换private final Jackson2JsonMessageConverter messageConverter;// 使用构造函数注入所需的依赖项@Autowiredpublic RabbitMQService(RabbitAdmin rabbitAdmin, TopicExchange exchange,ConnectionFactory connectionFactory,ChatMessageListener chatMessageListener,Jackson2JsonMessageConverter messageConverter) {this.rabbitAdmin = rabbitAdmin;this.exchange = exchange;this.connectionFactory = connectionFactory;this.chatMessageListener = chatMessageListener;this.messageConverter = messageConverter;}/*** 动态创建私有队列。* @param channelName 队列名称的一部分,通常是私聊双方的标识。*/public void createPrivateQueue(String channelName) {// 构建队列的完整名称,例如 "private-queue.admin-guest"String queueName = "private-queue." + channelName;// 创建一个非持久化(不在磁盘上存储)的队列Queue queue = new Queue(queueName, false);// 声明队列,使其在 RabbitMQ 中存在rabbitAdmin.declareQueue(queue);// 将队列绑定到指定的交换机,并使用指定的路由键Binding binding = BindingBuilder.bind(queue).to(exchange).with(channelName);rabbitAdmin.declareBinding(binding);// 创建监听器容器,以便监听此私有队列中的消息MessageListenerContainer container = createMessageListenerContainer(queueName);container.start(); // 启动监听器容器,开始监听消息}/*** 创建监听器容器,用于监听指定队列的消息。* @param queueName 要监听的队列的名称。* @return 配置好的 MessageListenerContainer 实例。*/private MessageListenerContainer createMessageListenerContainer(String queueName) {// 创建一个简单的消息监听器容器SimpleMessageListenerContainer container = new SimpleMessageListenerContainer();// 设置 RabbitMQ 的连接工厂container.setConnectionFactory(connectionFactory);// 设置要监听的队列名称container.setQueueNames(queueName);// 使用 MessageListenerAdapter 将消息传递给 ChatMessageListenerMessageListenerAdapter adapter = new MessageListenerAdapter(chatMessageListener, "receiveMessage");// 设置消息转换器为 JSON 转换器adapter.setMessageConverter(messageConverter);// 将适配器设置为消息监听器container.setMessageListener(adapter);return container; // 返回配置好的容器}/*** 使用 @RabbitListener 注解监听 "public-queue" 队列中的消息。* 当队列中有消息时,会自动调用此方法。* @param chatMessage 接收到的聊天消息。*/@RabbitListener(queues = "public-queue")public void receivePublicMessage(ChatMessage chatMessage) {// 调用消息监听器的 receiveMessage 方法处理消息chatMessageListener.receiveMessage(chatMessage);}
}
3.10 ChatMessageListener.java
package org.example.listener;import org.example.model.ChatMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;@Component
public class ChatMessageListener {// SimpMessagingTemplate 是一个 Spring 提供的工具类,用于在 WebSocket 上发送消息private final SimpMessagingTemplate messagingTemplate;// 构造函数注入 SimpMessagingTemplate,用于后续发送消息public ChatMessageListener(SimpMessagingTemplate messagingTemplate) {this.messagingTemplate = messagingTemplate;}/*** 接收消息的方法,这个方法会被 RabbitMQ 的消息监听器调用。* @param chatMessage 从队列中接收到的 ChatMessage 对象。*/public void receiveMessage(ChatMessage chatMessage) {// 根据消息的接收者生成唯一的目的地频道名称String destination = chatMessage.getRecipient() != null? "/queue/private-" + chatMessage.getRecipient() + "-" + chatMessage.getSender() // 私人消息的通道: "/topic/public"; // 公共消息的通道// 使用 SimpMessagingTemplate 将消息发送到生成的通道中messagingTemplate.convertAndSend(destination, chatMessage);}
}
3.11 UserController.java
package org.example.controller;import org.example.model.User;
import org.example.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;import java.util.Set;@RestController
@RequestMapping("/api")
public class UserController {@Autowiredprivate UserService userService;@PostMapping("/register")public String register(@RequestBody User user) {userService.register(user);return "User registered successfully";}@PostMapping("/login")public User login(@RequestBody User user) {User existingUser = userService.login(user.getUsername(), user.getPassword());if (existingUser != null) {return existingUser;} else {throw new RuntimeException("Invalid username or password");}}@PostMapping("/addFriend")public String addFriend(@RequestParam String username, @RequestParam String friendname) {userService.addFriend(username, friendname);return "Friend added successfully";}@GetMapping("/friends/{username}")public Set<User> getFriends(@PathVariable String username) {return userService.getFriends(username);}
}
3.12 ChatController.java
package org.example.controller;import org.example.model.ChatMessage;
import org.example.service.RabbitMQService;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;import java.util.Objects;@Controller
public class ChatController {private final RabbitTemplate rabbitTemplate;private final RabbitMQService rabbitMQService;public ChatController(RabbitTemplate rabbitTemplate, RabbitMQService rabbitMQService) {this.rabbitTemplate = rabbitTemplate;this.rabbitMQService = rabbitMQService;}@MessageMapping("/public-channel")public void handleMessage(@Payload ChatMessage chatMessage,SimpMessageHeaderAccessor headerAccessor) {if (chatMessage.getType() == ChatMessage.MessageType.JOIN) {// 将用户名添加到WebSocket会话中Objects.requireNonNull(headerAccessor.getSessionAttributes()).put("username", chatMessage.getSender());} else if (chatMessage.getType() == ChatMessage.MessageType.LEAVE) {// 从WebSocket会话中移除用户名Objects.requireNonNull(headerAccessor.getSessionAttributes()).remove("username");}// 广播公共消息rabbitTemplate.convertAndSend("chat-exchange", "public", chatMessage);}@MessageMapping("/private-channel")public void handlePrivateMessage(@Payload ChatMessage chatMessage,SimpMessageHeaderAccessor headerAccessor) {if (chatMessage.getType() == ChatMessage.MessageType.JOIN) {// 将用户名添加到WebSocket会话中Objects.requireNonNull(headerAccessor.getSessionAttributes()).put("username", chatMessage.getSender());} else if (chatMessage.getType() == ChatMessage.MessageType.LEAVE) {// 从WebSocket会话中移除用户名Objects.requireNonNull(headerAccessor.getSessionAttributes()).remove("username");}String channel=chatMessage.getRecipient()+"-"+chatMessage.getSender();// 确保私人队列存在rabbitMQService.createPrivateQueue(channel);// 发送私人消息到私人频道rabbitTemplate.convertAndSend("chat-exchange",channel, chatMessage);}
}
3.13 WebSocketConfig.java
package org.example.config;import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
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 {// 从配置文件中注入 RabbitMQ 主机地址@Value("${spring.rabbitmq.host}")private String host;// 从配置文件中注入 RabbitMQ 用户名@Value("${spring.rabbitmq.username}")private String username;// 从配置文件中注入 RabbitMQ 密码@Value("${spring.rabbitmq.password}")private String password;// 从配置文件中注入 STOMP 端口@Value("${stomp.port}")private int port;/*** 配置消息代理,用于处理 STOMP 消息。* @param config 消息代理注册表。*/@Overridepublic void configureMessageBroker(MessageBrokerRegistry config) {// 配置 STOMP 代理中继config.enableStompBrokerRelay("/topic", "/queue").setRelayHost(host) // 配置 RabbitMQ 主机地址.setRelayPort(port) // 配置 STOMP 端口.setClientLogin(username) // 配置 STOMP 客户端登录用户名.setClientPasscode(password) // 配置 STOMP 客户端登录密码.setSystemLogin(username) // 配置 STOMP 系统登录用户名.setSystemPasscode(password); // 配置 STOMP 系统登录密码// 配置应用前缀,用于识别发送给应用程序的消息config.setApplicationDestinationPrefixes("/app");}/*** 注册 STOMP 端点并配置 SockJS 回退选项。* @param registry STOMP 端点注册表。*/@Overridepublic void registerStompEndpoints(StompEndpointRegistry registry) {// 注册一个 WebSocket 端点,并设置允许的跨域请求registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();}/*** 创建并配置一个公共队列的 Bean。* @return 公共队列的实例。*/@Beanpublic Queue publicQueue() {// 创建一个非持久化的队列return new Queue("public-queue", false);}/*** 创建并配置一个 TopicExchange 的 Bean。* @return TopicExchange 的实例。*/@Beanpublic TopicExchange exchange() {// 创建一个 TopicExchange,用于基于路由键模式的消息路由return new TopicExchange("chat-exchange");}/*** 绑定公共队列到交换机,并指定路由键。* @param publicQueue 要绑定的队列。* @param exchange 要绑定到的交换机。* @return 队列与交换机绑定的实例。*/@Beanpublic Binding bindingPublicQueue(Queue publicQueue, TopicExchange exchange) {// 使用 "public" 作为路由键将队列绑定到交换机return BindingBuilder.bind(publicQueue).to(exchange).with("public");}/*** 创建并配置 RabbitAdmin,用于管理 RabbitMQ 资源。* @param connectionFactory RabbitMQ 的连接工厂。* @return RabbitAdmin 的实例。*/@Beanpublic RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {// 使用给定的连接工厂创建 RabbitAdminreturn new RabbitAdmin(connectionFactory);}/*** 创建并配置 RabbitTemplate,用于发送和接收消息。* @param connectionFactory RabbitMQ 的连接工厂。* @param messageConverter 消息转换器,用于 JSON 消息的转换。* @return RabbitTemplate 的实例。*/@Beanpublic RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, Jackson2JsonMessageConverter messageConverter) {RabbitTemplate template = new RabbitTemplate(connectionFactory);// 设置消息转换器为 Jackson2JsonMessageConvertertemplate.setMessageConverter(messageConverter);return template;}/*** 创建并配置 Jackson2JsonMessageConverter,用于将消息转换为 JSON 格式。* @param objectMapper 用于 JSON 转换的 ObjectMapper 实例。* @return Jackson2JsonMessageConverter 的实例。*/@Beanpublic Jackson2JsonMessageConverter jackson2JsonMessageConverter(ObjectMapper objectMapper) {// 使用给定的 ObjectMapper 创建 Jackson2JsonMessageConverterreturn new Jackson2JsonMessageConverter(objectMapper);}
}
说明:
-
消息代理(Message Broker):
消息代理是一个中介,负责将消息从一个地方路由到另一个地方。在 WebSocket 中,它通常负责接收来自客户端的消息,并将它们发送到一个或多个目的地(如特定的客户端或订阅特定频道的所有客户端)。
-
enableSimpleBroker("/topic")
:config.enableSimpleBroker("/topic");
这行代码启用了一个简单的内存消息代理,并指定了它的目的地前缀为
/topic
。这意味着所有以/topic
开头的消息目的地都会被这个内存消息代理处理。简单消息代理:
- 简单消息代理是 Spring 内置的一种轻量级消息代理,适用于基本的消息传递需求。
- 它通常用于开发和测试环境,在生产环境中,您可能会使用更复杂的消息代理(如 RabbitMQ 或 ActiveMQ)。
/topic
前缀:- 消息目的地的前缀。所有订阅以
/topic
开头的目的地的客户端都将接收发送到这些目的地的消息。 - 例如,客户端订阅了
/topic/public
,那么发送到/topic/public
的消息将被这个客户端接收。
-
setApplicationDestinationPrefixes("/app")
:config.setApplicationDestinationPrefixes("/app");
这行代码指定了应用程序消息的发送路径前缀为
/app
。这意味着所有以/app
开头的消息路径都会被路由到带有@MessageMapping
注解的方法中。应用程序目的地前缀:
- 用于区分应用程序内部的消息路径。
- 当客户端发送消息到服务器时,消息路径会以
/app
开头,这样 Spring 框架可以将这些消息路由到适当的处理方法中。
例如,客户端发送消息到
/app/public-channel
,该消息将被路由到ChatController
类中带有@MessageMapping("/public-channel")
注解的方法。 -
STOMP 端点:
STOMP 是一种简单的基于文本的协议,用于在 WebSocket 之上定义消息格式和路由规则。通过 STOMP,客户端和服务器之间可以进行更复杂的消息传递,如订阅、发送和接收消息等。
registry.addEndpoint("/ws")
:registry.addEndpoint("/ws");
这行代码定义了一个 WebSocket 端点,客户端将通过这个端点连接到 WebSocket 服务器。端点的路径为
/ws
。端点(Endpoint):
- 端点是客户端连接 WebSocket 服务器的 URL 路径。在这个例子中,客户端会连接到
ws://<your-server>/ws
。 - 客户端通过这个端点与服务器建立 WebSocket 连接,并使用 STOMP 协议进行通信。
- 端点是客户端连接 WebSocket 服务器的 URL 路径。在这个例子中,客户端会连接到
-
setAllowedOrigins("*")
:setAllowedOrigins("*");
这行代码允许来自任何源的请求连接到这个 WebSocket 端点。
*
表示允许所有域名进行跨域请求。跨域请求(CORS):
- 跨域资源共享(CORS)是指浏览器允许来自不同域的请求访问资源。
- 在生产环境中,通常会限制允许的域名,以提高安全性。这里使用
*
是为了方便开发和测试。
-
withSockJS()
:withSockJS();
这行代码启用了 SockJS 支持。SockJS 是一个 JavaScript 库,它提供了对 WebSocket 的回退支持,使得在 WebSocket 不可用的情况下,客户端仍然可以与服务器进行通信。
SockJS:
- SockJS 提供了一组传输协议,如 XHR、iframe、JSONP 等,以确保在不同浏览器和网络环境下的兼容性。
- 当原生 WebSocket 不可用时(如浏览器不支持或防火墙阻止),SockJS 会自动回退到其他传输方式,确保连接的可靠性。
3.14 index.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Vue Chat Application</title><style>body {font-family: Arial, sans-serif;background-color: #f4f4f4;display: flex;justify-content: center;align-items: center;height: 100vh;margin: 0;}#app {width: 90%;max-width: 900px;background: #fff;box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);border-radius: 10px;overflow: hidden;display: flex;flex-direction: column;height: 100%;}#login-container {padding: 20px;display: flex;flex-direction: column;align-items: center;justify-content: center;background-color: #f8f8f8;}#login-container input {margin-bottom: 10px;padding: 10px;width: 80%;border: 1px solid #ccc;border-radius: 5px;}#login-container button {padding: 10px;width: 80%;border: none;border-radius: 5px;background-color: #007bff;color: white;font-size: 16px;cursor: pointer;}#chat-container {display: flex;flex: 1;overflow: hidden;}#friend-list {width: 220px;border-right: 1px solid #ccc;background-color: #f1f1f1;padding: 10px;overflow-y: auto;}#friend-list h3 {margin-top: 0;margin-bottom: 10px;font-size: 1.2em;}#friend-list div {padding: 12px;cursor: pointer;margin-bottom: 8px;border-radius: 5px;background-color: #e9e9e9;font-size: 1em;}#friend-list div:hover {background-color: #d8d8d8;}#friend-input-container {position: relative;margin-bottom: 15px;}#friend-input-container input {width: 100%;padding: 8px;padding-right: 50px; /* 为按钮预留空间 */border: 1px solid #ccc;border-radius: 5px;font-size: 1em;box-sizing: border-box;}#friend-input-container button {position: absolute;right: 0;top: 0;height: 100%;padding: 8px 12px;border: none;border-radius: 0 5px 5px 0;background-color: #28a745;color: white;cursor: pointer;font-size: 1em;}#chat-box {flex: 1;display: flex;flex-direction: column;padding: 10px;background-color: #fff;overflow-y: auto;}#chat-title {font-size: 20px;font-weight: bold;margin-bottom: 10px;}#chat-content {flex: 1;overflow-y: auto;padding-bottom: 10px;display: flex;flex-direction: column;}.message {display: inline-block;flex-direction: column;margin-bottom: 10px;max-width: 70%;min-width: 50px;border-radius: 10px;word-wrap: break-word;position: relative;padding: 10px 10px 20px 10px;vertical-align: top;font-size: 1em;}.message-username {font-weight: bold;margin-bottom: 5px;color: #555;}.message-content {font-size: 14px;}.message-info {font-size: 12px;color: #888;text-align: right;margin-top: 5px;position: absolute;right: 10px;bottom: 5px;line-height: 1;}.received-message {align-self: flex-start;background-color: #f1f1f1;color: #333;}.sent-message {align-self: flex-end;background-color: #afff78;color: white;}#message-input-container {display: flex;padding: 10px;background-color: #f8f8f8;border-top: 1px solid #ccc;}#message-input {flex: 1;padding: 10px;border: 1px solid #ccc;border-radius: 5px;margin-right: 10px;}#send-button {padding: 10px 20px;border: none;border-radius: 5px;background-color: #007bff;color: white;cursor: pointer;}</style><script src="https://cdn.jsdelivr.net/npm/vue@2"></script><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>
</head>
<body>
<div id="app"><div v-if="!isLoggedIn" id="login-container"><h2>Login</h2><input type="text" v-model="username" placeholder="Username"><input type="password" v-model="password" placeholder="Password"><button @click="login">Login</button></div><div v-else id="chat-container"><div id="friend-list"><h3>Friends</h3><div id="friend-input-container"><input type="text" v-model="newFriendUsername" placeholder="Add friend by username"><button @click="addFriend">Add</button></div><div @click="subscribeToPublicChannel">Public Channel</div><div v-for="friend in friends" :key="friend.username" @click="selectPrivateChat(friend.username)">{{ friend.username }}</div></div><div id="chat-box"><div id="chat-title">{{ chatTitle }}</div><div id="chat-content"><div v-for="message in currentMessages" :key="message.timestamp" :class="['message', message.sender === username ? 'sent-message' : 'received-message']"><!-- 公共频道显示用户名 --><div v-if="currentChannel === 'public'" class="message-username"><strong>{{ message.sender }}</strong></div><div class="message-content">{{ message.content }}</div><div class="message-info">{{ formatTimestamp(message.timestamp) }}</div></div></div></div></div><div v-if="isLoggedIn" id="message-input-container"><input type="text" v-model="messageInput" id="message-input" placeholder="Enter your message"><button @click="sendMessage" id="send-button">Send</button></div>
</div><script>new Vue({el: '#app',data: {username: '',password: '',isLoggedIn: false,friends: [],publicMessages: [],privateChats: {}, // 存储所有私聊消息的对象messageInput: '',newFriendUsername: '', // 用于添加好友的用户名输入socket: null,stompClient: null,currentRecipient: null,currentSubscription: {}, // 存储所有频道的订阅对象currentChannel: 'public' // 'public' or 'private'},computed: {currentMessages() {if (this.currentChannel === 'public') {return this.publicMessages;} else if (this.currentRecipient && this.privateChats[this.currentRecipient]) {return this.privateChats[this.currentRecipient];} else {return [];}},chatTitle() {return this.currentChannel === 'public'? 'Public Channel': `Private Chat with ${this.currentRecipient}`;}},methods: {// 登录成功后建立WebSocket连接async login() {const response = await fetch('/api/login', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ username: this.username, password: this.password })});if (response.ok) {const user = await response.json();this.username = user.username;this.isLoggedIn = true;this.loadFriends();this.connect(); // 登录成功后立即连接WebSocket} else {alert('Invalid username or password');}},async addFriend() {if (this.newFriendUsername) {try {const response = await fetch('/api/addFriend?username='+this.username+"&friendname="+this.newFriendUsername, {method: 'POST',headers: {'Content-Type': 'application/json'}});if (response.ok) {alert("Friend added successfully!");this.loadFriends(); // 添加成功后重新加载好友列表this.newFriendUsername = ''; // 清空输入框} else {alert("Failed to add friend.");}} catch (error) {console.error("Error adding friend:", error);}} else {alert("Please enter a username.");}},connect() {this.socket = new SockJS('/ws');this.stompClient = Stomp.over(this.socket);this.stompClient.connect({}, () => {console.log('WebSocket connected');// 不在这里立即订阅频道,只是建立连接});this.stompClient.onclose = () => {console.log('WebSocket closed');};this.stompClient.onerror = (error) => {console.log('WebSocket error:', error);};},async loadFriends() {const response = await fetch(`/api/friends/${this.username}`, {method: 'GET',headers: {'Content-Type': 'application/json'}});if (response.ok) {this.friends = await response.json();}},sendSystemMessage(content) {const systemMessage = {sender: 'System',recipient: null,content: `${this.username} ${content}`,type: 'CHAT',timestamp: new Date().getTime()};const destination = `/app/public-channel`;this.stompClient.send(destination, {}, JSON.stringify(systemMessage));},// 当用户点击公共频道时,建立公共频道的订阅subscribeToPublicChannel() {if (!this.currentSubscription.public) {this.currentSubscription.public = this.stompClient.subscribe('/topic/public', (message) => {const chatMessage = JSON.parse(message.body);this.publicMessages.push(chatMessage);this.scrollToBottom();});// 向公共频道发送加入消息this.sendSystemMessage('has joined the public channel.');}this.currentChannel = 'public';this.currentRecipient = null;},// 当用户点击私人聊天时,建立相应的私人频道订阅selectPrivateChat(friendUsername) {if (this.currentRecipient === friendUsername && this.currentChannel === 'private') {return;}// 当用户从公共频道切换到私人聊天时,发送离开公共频道的系统消息if (this.currentChannel === 'public') {this.sendSystemMessage('has left the public channel.');}this.currentRecipient = friendUsername;this.currentChannel = 'private';if (!this.privateChats[friendUsername]) {this.$set(this.privateChats, friendUsername, []);}if (!this.currentSubscription[friendUsername]) {this.currentSubscription[friendUsername] = this.stompClient.subscribe(`/queue/private-${this.username}-${friendUsername}`, (message) => {const chatMessage = JSON.parse(message.body);this.privateChats[friendUsername].push(chatMessage);this.scrollToBottom();});}},sendPublicMessage() {const chatMessage = {sender: this.username,recipient: null,content: this.messageInput,type: 'CHAT',timestamp: new Date().getTime()};const destination = `/app/public-channel`;this.stompClient.send(destination, {}, JSON.stringify(chatMessage));this.messageInput = '';},sendPrivateMessage() {const chatMessage = {sender: this.username,recipient: this.currentRecipient,content: this.messageInput,type: 'CHAT',timestamp: new Date().getTime()};const destination = `/app/private-channel`;this.stompClient.send(destination, {}, JSON.stringify(chatMessage));this.privateChats[this.currentRecipient].push(chatMessage);this.scrollToBottom();this.messageInput = '';},sendMessage() {if (this.currentChannel === 'public') {this.sendPublicMessage();} else {this.sendPrivateMessage();}},scrollToBottom() {this.$nextTick(() => {const chatContent = this.$el.querySelector("#chat-content");chatContent.scrollTop = chatContent.scrollHeight;});},formatTimestamp(timestamp) {const date = new Date(timestamp);const hours = date.getHours().toString().padStart(2, '0');const minutes = date.getMinutes().toString().padStart(2, '0');return `${hours}:${minutes}`;}}});
</script>
</body>
</html>
4. 测试验证
4.1 前提准备
说明:手动创建三个用户:user1,user2,user3 进行模拟一对一对话和公共对话。
4.2 用户登录
说明:将用户user1,user2,user3分别登录不同的窗口,user1先登录,添加好友后再登录user2和user3。
4.3 登录后的界面
4.4 添加好友
说明:输入用户名user2点Add按钮,即可完成添加,下图是添加两个用户的结果。
说明:添加完后可以登录user2和user3了。
4.5 公共频道
说明:登录成功的时候,鼠标点击一下Public Channel加入公共通道。
4.6 私聊频道
4.6.1 user1和user2
4.6.2 user1和user3
4.6.3 MQ队列
4.6.4 MQ交换机
4.6.5 MQ连接
5. 总结
5.1. 系统架构
前端(Vue.js):通过 WebSocket 连接到 Spring Boot 提供的WebSocket 端点。使用 STOMP 协议与后端进行消息传递,支持订阅和发送消息功能。用户登录后,可选择加入公共频道或私人聊天,发送和接收实时消息。
后端(Spring Boot):配置 WebSocket 端点,允许客户端使用 STOMP 协议进行连接。配置 RabbitMQ 作为消息代理,用于处理和分发消息。处理来自客户端的消息,将其发布到相应的 RabbitMQ 队列,并通过 STOMP 通知相关订阅者。监听 RabbitMQ 队列中的消息,将其通过 WebSocket 推送给订阅该队列的客户端。
5.2 工作流程
1.客户端连接
用户通过 WebSocket 端点连接到服务器。
连接建立后,客户端可以订阅公共频道或私人聊天的消息队列。
2. 消息发布
用户在前端界面中输入消息并发送。
消息通过 WebSocket 传递到服务器端,并被发布到对应的 RabbitMQ 队列(如公共频道或私人队列)。
3. 消息分发
RabbitMQ 将消息路由到正确的队列。
服务器端监听器监听这些队列中的消息,并通过 WebSocket 将消息推送给已订阅该队列的客户端。
4. 实时更新
订阅的客户端接收到消息,并实时更新前端界面,显示新消息。