WebSocket是一种在单个TCP连接上进行全双工通信的协议,其设计的目的是在Web浏览器和Web服务器之间进行实时通信(实时Web)
WebSocket协议的优点包括:
-
更高效的网络利用率:与HTTP相比,WebSocket的握手只需要一次,之后客户端和服务器端可以直接交换数据
-
实时性更高:WebSocket的双向通信能够实现实时通信,无需等待客户端或服务器端的响应
-
更少的通信量和延迟:WebSocket可以发送二进制数据,而HTTP只能发送文本数据,并且WebSocket的消息头比HTTP更小
项目内容
1.WebSocketConfig
表示这是一个配置类,可以定义 Spring Bean
Spring 会扫描该类并将其中定义的 @Bean
方法返回的对象注册到应用上下文中
@Bean
方法:
serverEndpointExporter
方法用来创建并注册一个 ServerEndpointExporter
实例
ServerEndpointExporter
是 Spring 提供的一个类,用于自动注册基于 Java 标准的 WebSocket 端点(由 @ServerEndpoint
注解标注的类)
它负责将 @ServerEndpoint
注解标记的 WebSocket 类注册到容器中
ServerEndpointExporter
的作用:
当应用运行在 Spring Boot 容器中时,ServerEndpointExporter
会扫描所有带有@ServerEndpoint
注解的类,并将其注册为 WebSocket 端点,适用于嵌入式的 Servlet 容器(如 Tomcat),如果使用的是独立的 Servlet 容器(如外部的Tomcat),则不需要配置 ServerEndpointExporter
java
package com.qcby.chatroom1117.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
2.ChatController
获取在线用户列表:
- 调用
WebSocketServer.getWebSocketSet()
获取所有在线用户 - 如果用户的
sid
不是"admin"
,则添加到返回列表中
管理员发送消息:
- 使用
@RequestParam
获取请求中的sid
(目标用户 ID)和message
(消息内容) - 调用
WebSocketServer.sendInfo
向指定用户发送消息
java
package com.qcby.chatroom1117.controller;
import com.qcby.chatroom1117.server.WebSocketServer;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
/**
* 获取在线用户列表,不包含管理员
*/
@GetMapping("/online-users")
public List<String> getOnlineUsers() {
List<String> sidList = new ArrayList<>();
for (WebSocketServer server : WebSocketServer.getWebSocketSet()) {
//排除管理员
if (!server.getSid().equals("admin")) {
sidList.add(server.getSid());
}
}
return sidList;
}
/**
* 管理员发送消息给指定用户
*/
@PostMapping("/send")
public void sendMessageToUser(@RequestParam String sid, @RequestParam String message) throws IOException {
WebSocketServer.sendInfo(message, sid);
}
}
3.WebSocketServer
@OnOpen
: 客户端连接时执行的操作,维护连接集合并记录用户的sid
@OnClose
: 客户端断开时从集合中移除,更新在线用户数@OnMessage
: 接收客户端消息,解析后发送到指定用户sendMessage
: 服务端向客户端单独发送消息sendInfo
: 群发或向指定客户端发送消息getOnlineCount
: 获取当前在线连接数addOnlineCount
&subOnlineCount
: 管理在线人数的计数@OnError
: 捕获 WebSocket 连接中的异常,记录日志以便排查
java
package com.qcby.chatroom1117.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* WebSocket 服务端
*/
@Component
@Slf4j
@Service
@ServerEndpoint("/api/websocket/{sid}")
public class WebSocketServer {
//当前在线连接数
private static int onlineCount = 0;
//存放每个客户端对应的 WebSocketServer 对象
private static final CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<>();
//用户信息
private Session session;
//当前用户的 sid
private String sid = "";
//JSON解析工具
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
this.session = session;
this.sid = sid;
webSocketSet.add(this); //加入集合
addOnlineCount(); //在线数加1
try {
sendMessage("conn_success");
log.info("有新窗口开始监听: " + sid + ", 当前在线人数为: " + getOnlineCount());
} catch (IOException e) {
log.error("WebSocket IO Exception", e);
}
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
webSocketSet.remove(this); //从集合中删除
subOnlineCount(); //在线数减1
log.info("释放的 sid 为:" + sid);
log.info("有一连接关闭!当前在线人数为 " + getOnlineCount());
}
/**
* 收到客户端消息后调用的方法
*/
@OnMessage
public void onMessage(String message, Session session) {
log.info("收到来自窗口 " + sid + " 的信息: " + message);
//解析消息中的 targetSid
String targetSid;
String msgContent;
try {
Map<String, String> messageMap = objectMapper.readValue(message, Map.class);
targetSid = messageMap.get("targetSid");
msgContent = messageMap.get("message");
} catch (IOException e) {
log.error("消息解析失败", e);
return;
}
//构造消息
Map<String, String> responseMap = new HashMap<>();
responseMap.put("sourceSid", sid);
responseMap.put("message", msgContent);
String jsonResponse;
try {
jsonResponse = objectMapper.writeValueAsString(responseMap);
} catch (IOException e) {
log.error("JSON 序列化失败", e);
return;
}
//按 targetSid 发送消息
for (WebSocketServer item : webSocketSet) {
try {
if (targetSid.equals(item.sid)) {
item.sendMessage(jsonResponse);
break; //找到目标用户后不再继续发送
}
} catch (IOException e) {
log.error("消息发送失败", e);
}
}
}
/**
* 判断是否是管理员
*/
private boolean isAdmin(String sid) {
return "admin_sid".equals(sid);
}
/**
* 发生错误时调用的方法
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("发生错误", error);
}
/**
* 实现服务器主动推送
*/
public void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
}
/**
* 群发自定义消息
*/
public static void sendInfo(String message, @PathParam("sid") String sid) throws IOException {
log.info("推送消息到窗口 " + sid + ",推送内容: " + message);
for (WebSocketServer item : webSocketSet) {
try {
if (sid == null) {
item.sendMessage(message); //推送给所有人
} else if (item.sid.equals(sid)) {
item.sendMessage(message); //推送给指定 sid
}
} catch (IOException e) {
log.error("推送消息失败", e);
}
}
}
public static synchronized int getOnlineCount() {
return onlineCount;
}
public static synchronized void addOnlineCount() {
WebSocketServer.onlineCount++;
}
public static synchronized void subOnlineCount() {
WebSocketServer.onlineCount--;
}
public static CopyOnWriteArraySet<WebSocketServer> getWebSocketSet() {
return webSocketSet;
}
public String getSid() {
return this.sid;
}
}
4.admin页面
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理员端 - 聊天窗口</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
display: flex;
height: 100vh;
margin: 0;
background-color: #f4f7fc;
color: #333;
}
/* 左侧在线用户列表 */
#onlineUsersContainer {
width: 250px;
padding: 20px;
background-color: #fff;
border-right: 1px solid #ddd;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
#onlineUsers {
list-style-type: none;
padding: 0;
margin-top: 20px;
}
#onlineUsers li {
padding: 10px;
cursor: pointer;
border-radius: 5px;
transition: background-color 0.3s ease;
}
#onlineUsers li:hover {
background-color: #e9f1fe;
}
#onlineUsers li.selected {
background-color: #d0e7fe;
}
/* 右侧聊天窗口 */
#chatBox {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
background-color: #fff;
}
#messages {
border: 1px solid #ddd;
height: 500px;
overflow-y: scroll;
margin-bottom: 20px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 10px;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1);
}
.message {
padding: 10px;
margin: 8px 0;
border-radius: 10px;
max-width: 80%;
line-height: 1.6;
word-wrap: break-word;
}
.message-right {
background-color: #dcf8c6;
text-align: right;
margin-left: auto;
}
.message-left {
background-color: #f1f0f0;
text-align: left;
margin-right: auto;
}
#messageInput {
width: 80%;
padding: 12px;
border-radius: 25px;
border: 1px solid #ccc;
margin-right: 10px;
font-size: 16px;
transition: border-color 0.3s ease;
}
#messageInput:focus {
border-color: #007bff;
outline: none;
}
button {
padding: 12px 20px;
border-radius: 25px;
border: 1px solid #007bff;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #0056b3;
}
h3 {
font-size: 18px;
color: #333;
margin-bottom: 20px;
}
#onlineUsers li.unread {
font-weight: bold;
color: red;
}
@media (max-width: 768px) {
#onlineUsersContainer {
width: 100%;
padding: 15px;
}
#chatBox {
padding: 15px;
}
#messageInput {
width: calc(100% - 100px);
}
button {
width: 80px;
}
}
</style>
</head>
<body>
<div id="onlineUsersContainer">
<h3>在线用户</h3>
<ul id="onlineUsers"></ul>
</div>
<div id="chatBox">
<h3>聊天窗口</h3>
<div id="messages"></div>
<div style="display: flex;">
<input id="messageInput" type="text" placeholder="请输入消息">
<button onclick="sendMessage()">发送</button>
</div>
</div>
<script>
let websocket;
const sid = "admin";
let currentUserSid = null; //当前聊天对象的sid
let chatHistory = {}; //用于存储每个用户的聊天记录
//页面加载时初始化
window.onload = () => {
connectWebSocket();
getOnlineUsers(); //页面加载时刷新在线用户列表
restoreSelectedUser(); //恢复选中的用户
};
function connectWebSocket() {
websocket = new WebSocket("ws://localhost:8080/api/websocket/" + sid);
websocket.onopen = () => {
console.log("连接成功,管理员ID:" + sid);
};
websocket.onmessage = (event) => {
try {
let data;
if (event.data.startsWith("{") && event.data.endsWith("}")) {
data = JSON.parse(event.data); // 如果是有效的 JSON 格式,进行解析
} else {
// 如果是无效的 JSON(比如 "conn_success" 这样的字符串),进行处理
console.log("接收到非 JSON 消息:", event.data);
return;
}
const { sourceSid, message } = data;
if (sourceSid) {
//初始化聊天记录存储
if (!chatHistory[sourceSid]) {
chatHistory[sourceSid] = [];
}
//存储对方的消息
chatHistory[sourceSid].push({ sender: 'left', message });
//如果消息来源是当前聊天对象,更新聊天窗口
if (sourceSid === currentUserSid) {
displayMessages();
} else {
//消息来源不是当前对象,提示未读消息
notifyUnreadMessage(sourceSid);
}
}
} catch (e) {
console.error("解析消息失败", e);
}
};
websocket.onclose = () => {
console.log("连接关闭");
};
websocket.onerror = (error) => {
console.error("WebSocket发生错误", error);
};
}
function notifyUnreadMessage(userSid) {
const userListItems = document.querySelectorAll("#onlineUsers li");
userListItems.forEach(item => {
if (item.textContent === userSid) {
item.classList.add("unread"); //添加未读消息样式
}
});
}
//清除未读消息提示
function clearUnreadMessage(userSid) {
const userListItems = document.querySelectorAll("#onlineUsers li");
userListItems.forEach(item => {
if (item.textContent === userSid) {
item.classList.remove("unread");
}
});
}
function sendMessage() {
const message = document.getElementById("messageInput").value;
if (!currentUserSid) {
alert("请选择一个用户进行聊天!");
return;
}
if (message.trim() !== "") {
websocket.send(JSON.stringify({ targetSid: currentUserSid, message }));
chatHistory[currentUserSid] = chatHistory[currentUserSid] || [];
chatHistory[currentUserSid].push({ sender: 'right', message });
document.getElementById("messageInput").value = '';
displayMessages();
}
}
//显示当前用户的聊天记录
function displayMessages() {
const messagesDiv = document.getElementById("messages");
messagesDiv.innerHTML = "";
if (currentUserSid && chatHistory[currentUserSid]) {
chatHistory[currentUserSid].forEach(msg => {
const messageDiv = document.createElement("div");
messageDiv.classList.add("message", msg.sender === 'right' ? "message-right" : "message-left");
messageDiv.textContent = msg.message;
messagesDiv.appendChild(messageDiv);
});
}
scrollToBottom();
}
function scrollToBottom() {
const messagesDiv = document.getElementById("messages");
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
//获取在线用户列表(不包括管理员)
function getOnlineUsers() {
fetch("/api/chat/online-users")
.then(response => response.json())
.then(users => {
const userList = document.getElementById("onlineUsers");
userList.innerHTML = "";
users.forEach(user => {
if (user !== "admin") {
const li = document.createElement("li");
li.textContent = user;
li.onclick = () => selectUser(user, li);
userList.appendChild(li);
}
});
});
}
//选择用户进行聊天
function selectUser(user, liElement) {
//清除所有选中状态
const userListItems = document.querySelectorAll("#onlineUsers li");
userListItems.forEach(item => item.classList.remove("selected"));
//高亮显示当前选中的用户
liElement.classList.add("selected");
if (currentUserSid !== user) {
currentUserSid = user;
//清除未读消息提示
clearUnreadMessage(user);
//显示与该用户的聊天记录
displayMessages();
//保存当前选中的用户
localStorage.setItem('selectedUserSid', user);
}
scrollToBottom();
}
//恢复选中的用户
function restoreSelectedUser() {
const savedUserSid = localStorage.getItem('selectedUserSid');
if (savedUserSid) {
currentUserSid = savedUserSid;
const userListItems = document.querySelectorAll("#onlineUsers li");
userListItems.forEach(item => {
if (item.textContent === savedUserSid) {
item.classList.add("selected");
}
});
displayMessages();
}
}
</script>
</body>
</html>
5.user页面
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>用户端 - 聊天窗口</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #f0f4f8;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
#chatBox {
position: fixed;
bottom: 10px;
right: 10px;
width: 400px;
height: 500px;
background-color: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background: linear-gradient(to top right, #f9f9f9, #e9eff7);
display: flex;
flex-direction: column;
max-height: 80vh;
}
#chatBox h3 {
font-size: 20px;
margin-bottom: 15px;
color: #333;
text-align: center;
}
#messages {
flex: 1;
border: 1px solid #ddd;
padding: 15px;
overflow-y: auto;
background-color: #f9f9f9;
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
color: #333;
line-height: 1.5;
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.1);
}
.message {
padding: 10px;
margin: 5px 0;
border-radius: 8px;
max-width: 80%;
word-wrap: break-word;
}
.message-right {
background-color: #dcf8c6;
text-align: right;
margin-left: auto;
}
.message-left {
background-color: #f1f0f0;
text-align: left;
margin-right: auto;
}
#inputWrapper {
display: flex;
width: 100%;
}
#messageInput {
width: calc(100% - 80px);
padding: 12px;
border-radius: 25px;
border: 1px solid #ccc;
margin-right: 10px;
font-size: 16px;
transition: border-color 0.3s ease;
}
#messageInput:focus {
border-color: #007bff;
outline: none;
}
button {
padding: 12px 20px;
border-radius: 25px;
border: 1px solid #007bff;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s ease;
width: 60px;
display: inline-flex;
align-items: center;
justify-content: center;
}
button:hover {
background-color: #0056b3;
}
@media (max-width: 768px) {
#chatBox {
width: 100%;
bottom: 20px;
padding: 15px;
}
#messageInput {
width: calc(100% - 100px);
}
button {
width: 70px;
padding: 10px;
}
}
</style>
<script>
let websocket;
const sid = Math.random().toString(36).substring(2, 15); //用户端的sid
const isAdmin = false; //这是用户端,管理员标识为false
function connectWebSocket() {
websocket = new WebSocket("ws://localhost:8080/api/websocket/" + sid);
websocket.onopen = () => {
console.log("连接成功,用户ID:" + sid);
document.getElementById("messages").innerHTML += `<div class="message-left">连接成功,您的ID是:${sid}</div>`;
};
websocket.onmessage = (event) => {
try {
let data;
// 检查消息是否是有效的 JSON
if (event.data && event.data.startsWith("{")) {
data = JSON.parse(event.data);
const { targetSid, message, sourceSid } = data;
// 确保消息是发送给当前用户的
if (sourceSid === "admin" || targetSid === sid) {
document.getElementById("messages").innerHTML += `<div class="message-left">${message}</div>`;
scrollToBottom();
}
} else {
// 如果不是 JSON 格式,可以直接处理其他类型的消息
document.getElementById("messages").innerHTML += `<div class="message-left">${event.data}</div>`;
scrollToBottom();
}
} catch (e) {
console.error("解析消息失败", e);
}
};
websocket.onclose = () => {
console.log("连接关闭");
};
websocket.onerror = (error) => {
console.error("WebSocket发生错误", error);
};
}
function sendMessage() {
const message = document.getElementById("messageInput").value;
const targetSid = "admin"; //目标为管理员
if (message.trim() !== "") {
websocket.send(JSON.stringify({ targetSid, message }));
document.getElementById("messages").innerHTML += `<div class="message-right">${message}</div>`;
document.getElementById("messageInput").value = '';
scrollToBottom();
}
}
function scrollToBottom() {
const messagesDiv = document.getElementById("messages");
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
connectWebSocket();
</script>
</head>
<body>
<div id="chatBox">
<h3>用户聊天窗口</h3>
<div id="messages"></div>
<div id="inputWrapper">
<input id="messageInput" type="text" placeholder="请输入消息">
<button onclick="sendMessage()">发送</button>
</div>
</div>
</body>
</html>
项目部署
1.准备云服务器
2.在服务器上安装jdk
(1)yum install -y java-1.8.0-openjdk-devel.x86_64
(2)
输入java -version
查看已安装的jdk
版本
3.在服务器上安装tomcat
(1)sudo wget https://archive.apache.org/dist/tomcat/tomcat-9/v9.0.75/bin/apache-tomcat-9.0.75.tar.gz
(2)解压后进入到文件目录,启动
3.修改项目
(1)修改pom文件
添加打包方式:
添加tomcat和websocket依赖:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope> <!-- 提示该依赖已由外部服务器提供 -->
</dependency>
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
</dependency>
添加插件:
XML
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</plugin>
(2)修改启动类
java
package com.qcby.chatroom1117;
import javafx.application.Application;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
public class ChatRoom1117Application extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(ChatRoom1117Application.class, args);
}
@Override //这个表示使用外部的tomcat容器
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
// 注意这里要指向原先用main方法执行的启动类
return builder.sources(ChatRoom1117Application.class);
}
}
(3)修改前端代码
4.打包
先执行clean,再执行install
5.上传war包到tomcat文件夹的webapp目录下
6.重新启动tomcat,访问
用户端 - 聊天窗口http://47.96.252.224:8080/chatroom1117-0.0.1-SNAPSHOT/user
管理员端 - 聊天窗口http://47.96.252.224:8080/chatroom1117-0.0.1-SNAPSHOT/admin
至此,部署完成