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
<?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
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/websocket_demo
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
open-in-view: false
rabbitmq:
host: 192.168.186.77
port: 5672
username: admin
password: 123456
stomp:
port: 61613
3.4 WebsocketApplication.java
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
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")
)
@JsonIgnore
private Set<User> friends = new HashSet<>();
}
3.6 ChatMessage.java
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
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
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 {
@Autowired
private UserRepository userRepository;
@Transactional
public 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;
}
@Transactional
public 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
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;
// 使用构造函数注入所需的依赖项
@Autowired
public 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 将消息传递给 ChatMessageListener
MessageListenerAdapter 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
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
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 {
@Autowired
private 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
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
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 消息代理注册表。
*/
@Override
public 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 端点注册表。
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 注册一个 WebSocket 端点,并设置允许的跨域请求
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
}
/**
* 创建并配置一个公共队列的 Bean。
* @return 公共队列的实例。
*/
@Bean
public Queue publicQueue() {
// 创建一个非持久化的队列
return new Queue("public-queue", false);
}
/**
* 创建并配置一个 TopicExchange 的 Bean。
* @return TopicExchange 的实例。
*/
@Bean
public TopicExchange exchange() {
// 创建一个 TopicExchange,用于基于路由键模式的消息路由
return new TopicExchange("chat-exchange");
}
/**
* 绑定公共队列到交换机,并指定路由键。
* @param publicQueue 要绑定的队列。
* @param exchange 要绑定到的交换机。
* @return 队列与交换机绑定的实例。
*/
@Bean
public Binding bindingPublicQueue(Queue publicQueue, TopicExchange exchange) {
// 使用 "public" 作为路由键将队列绑定到交换机
return BindingBuilder.bind(publicQueue).to(exchange).with("public");
}
/**
* 创建并配置 RabbitAdmin,用于管理 RabbitMQ 资源。
* @param connectionFactory RabbitMQ 的连接工厂。
* @return RabbitAdmin 的实例。
*/
@Bean
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
// 使用给定的连接工厂创建 RabbitAdmin
return new RabbitAdmin(connectionFactory);
}
/**
* 创建并配置 RabbitTemplate,用于发送和接收消息。
* @param connectionFactory RabbitMQ 的连接工厂。
* @param messageConverter 消息转换器,用于 JSON 消息的转换。
* @return RabbitTemplate 的实例。
*/
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, Jackson2JsonMessageConverter messageConverter) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
// 设置消息转换器为 Jackson2JsonMessageConverter
template.setMessageConverter(messageConverter);
return template;
}
/**
* 创建并配置 Jackson2JsonMessageConverter,用于将消息转换为 JSON 格式。
* @param objectMapper 用于 JSON 转换的 ObjectMapper 实例。
* @return Jackson2JsonMessageConverter 的实例。
*/
@Bean
public Jackson2JsonMessageConverter jackson2JsonMessageConverter(ObjectMapper objectMapper) {
// 使用给定的 ObjectMapper 创建 Jackson2JsonMessageConverter
return new Jackson2JsonMessageConverter(objectMapper);
}
}
说明:
-
消息代理(Message Broker):
消息代理是一个中介,负责将消息从一个地方路由到另一个地方。在 WebSocket 中,它通常负责接收来自客户端的消息,并将它们发送到一个或多个目的地(如特定的客户端或订阅特定频道的所有客户端)。
-
enableSimpleBroker("/topic")
:javaconfig.enableSimpleBroker("/topic");
这行代码启用了一个简单的内存消息代理,并指定了它的目的地前缀为
/topic
。这意味着所有以/topic
开头的消息目的地都会被这个内存消息代理处理。简单消息代理:
- 简单消息代理是 Spring 内置的一种轻量级消息代理,适用于基本的消息传递需求。
- 它通常用于开发和测试环境,在生产环境中,您可能会使用更复杂的消息代理(如 RabbitMQ 或 ActiveMQ)。
/topic
前缀:- 消息目的地的前缀。所有订阅以
/topic
开头的目的地的客户端都将接收发送到这些目的地的消息。 - 例如,客户端订阅了
/topic/public
,那么发送到/topic/public
的消息将被这个客户端接收。
-
setApplicationDestinationPrefixes("/app")
:javaconfig.setApplicationDestinationPrefixes("/app");
这行代码指定了应用程序消息的发送路径前缀为
/app
。这意味着所有以/app
开头的消息路径都会被路由到带有@MessageMapping
注解的方法中。应用程序目的地前缀:
- 用于区分应用程序内部的消息路径。
- 当客户端发送消息到服务器时,消息路径会以
/app
开头,这样 Spring 框架可以将这些消息路由到适当的处理方法中。
例如,客户端发送消息到
/app/public-channel
,该消息将被路由到ChatController
类中带有@MessageMapping("/public-channel")
注解的方法。 -
STOMP 端点:
STOMP 是一种简单的基于文本的协议,用于在 WebSocket 之上定义消息格式和路由规则。通过 STOMP,客户端和服务器之间可以进行更复杂的消息传递,如订阅、发送和接收消息等。
registry.addEndpoint("/ws")
:javaregistry.addEndpoint("/ws");
这行代码定义了一个 WebSocket 端点,客户端将通过这个端点连接到 WebSocket 服务器。端点的路径为
/ws
。端点(Endpoint):
- 端点是客户端连接 WebSocket 服务器的 URL 路径。在这个例子中,客户端会连接到
ws://<your-server>/ws
。 - 客户端通过这个端点与服务器建立 WebSocket 连接,并使用 STOMP 协议进行通信。
- 端点是客户端连接 WebSocket 服务器的 URL 路径。在这个例子中,客户端会连接到
-
setAllowedOrigins("*")
:javasetAllowedOrigins("*");
这行代码允许来自任何源的请求连接到这个 WebSocket 端点。
*
表示允许所有域名进行跨域请求。跨域请求(CORS):
- 跨域资源共享(CORS)是指浏览器允许来自不同域的请求访问资源。
- 在生产环境中,通常会限制允许的域名,以提高安全性。这里使用
*
是为了方便开发和测试。
-
withSockJS()
:javawithSockJS();
这行代码启用了 SockJS 支持。SockJS 是一个 JavaScript 库,它提供了对 WebSocket 的回退支持,使得在 WebSocket 不可用的情况下,客户端仍然可以与服务器进行通信。
SockJS:
- SockJS 提供了一组传输协议,如 XHR、iframe、JSONP 等,以确保在不同浏览器和网络环境下的兼容性。
- 当原生 WebSocket 不可用时(如浏览器不支持或防火墙阻止),SockJS 会自动回退到其他传输方式,确保连接的可靠性。
3.14 index.html
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 端点连接到服务器。
连接建立后,客户端可以订阅公共频道或私人聊天的消息队列。
- 消息发布
用户在前端界面中输入消息并发送。
消息通过 WebSocket 传递到服务器端,并被发布到对应的 RabbitMQ 队列(如公共频道或私人队列)。
- 消息分发
RabbitMQ 将消息路由到正确的队列。
服务器端监听器监听这些队列中的消息,并通过 WebSocket 将消息推送给已订阅该队列的客户端。
- 实时更新
订阅的客户端接收到消息,并实时更新前端界面,显示新消息。