一、前言
在传统的 HTTP 通信中,客户端发起请求,服务器给出响应,一次通信就此结束。这种模式对于静态页面展示完全够用,但对于需要实时推送的场景------如在线聊天、实时通知、股票行情、设备状态监控------就力不从心了。
早期的解决方案是轮询:客户端每隔几秒就发一次请求问服务器"有新消息吗",效率低下且浪费资源。WebSocket 的出现彻底改变了这一局面。
二、WebSocket 是什么
WebSocket 是一种在单个 TCP 连接上进行全双工通信的网络协议,由 HTML5 规范引入,RFC 6455 正式定义。
2.1 与 HTTP 的核心区别
| 对比项 | HTTP | WebSocket |
|---|---|---|
| 通信方向 | 单向(客户端请求,服务器响应) | 双向(任意一方均可主动发送) |
| 连接状态 | 无状态,一问一答后断开 | 有状态,连接建立后持续保持 |
| 协议头开销 | 每次请求都携带完整 Header | 握手一次后,后续帧头极小(2~10字节) |
| 适用场景 | 普通页面请求 | 实时推送、聊天、游戏 |
2.2 握手过程
WebSocket 复用了 HTTP 的握手机制,通过一次 HTTP 请求完成协议升级:
客户端发送升级请求:
GET /ws/chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务器返回:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
握手成功后,协议从 HTTP 升级为 WebSocket,后续通信不再经过 HTTP 层,直接在 TCP 连接上传输 WebSocket 帧。
三、Spring Boot 集成 WebSocket
Spring Boot 提供了两种方式使用 WebSocket:
- 方式一 :基于 Java EE 标准的
@ServerEndpoint(本文重点介绍) - 方式二 :基于 Spring 的
WebSocketHandler
3.1 添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
3.2 注册配置类
使用内置 Tomcat 时,需要手动注册 ServerEndpointExporter,让 Spring 能扫描到 @ServerEndpoint 注解:
@Configuration
public class WebSocketConfig {
/**
* 使用内置 Tomcat 时必须注入此 Bean
* 若使用外部 Tomcat 部署,则不需要,容器会自行管理
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3.3 编写 WebSocket 端点
@Component
@ServerEndpoint("/ws/chat/{userId}")
public class ChatWebSocket {
private static final Logger log = LoggerFactory.getLogger(ChatWebSocket.class);
// 存储所有在线连接,key=userId,value=WebSocket会话
private static final ConcurrentHashMap<String, Session> SESSIONS = new ConcurrentHashMap<>();
/**
* 连接建立时触发
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
SESSIONS.put(userId, session);
log.info("用户 {} 上线,当前在线人数:{}", userId, SESSIONS.size());
}
/**
* 收到客户端消息时触发
*/
@OnMessage
public void onMessage(String message, @PathParam("userId") String userId) {
log.info("收到用户 {} 的消息:{}", userId, message);
// 解析消息,转发给目标用户
handleMessage(userId, message);
}
/**
* 连接关闭时触发
*/
@OnClose
public void onClose(@PathParam("userId") String userId) {
SESSIONS.remove(userId);
log.info("用户 {} 下线,当前在线人数:{}", userId, SESSIONS.size());
}
/**
* 发生异常时触发
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("WebSocket 发生异常:{}", error.getMessage());
}
/**
* 向指定用户发送消息
*/
public static void sendToUser(String userId, String message) {
Session session = SESSIONS.get(userId);
if (session != null && session.isOpen()) {
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("向用户 {} 发送消息失败:{}", userId, e.getMessage());
}
}
}
/**
* 广播消息给所有在线用户
*/
public static void broadcast(String message) {
SESSIONS.forEach((userId, session) -> {
if (session.isOpen()) {
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("广播消息失败,userId={}:{}", userId, e.getMessage());
}
}
});
}
/**
* 处理消息逻辑(示例:解析JSON转发)
*/
private void handleMessage(String fromUserId, String message) {
try {
// 假设消息格式为 JSON:{"toUserId":"xxx","content":"hello"}
JSONObject json = JSONObject.parseObject(message);
String toUserId = json.getString("toUserId");
String content = json.getString("content");
JSONObject response = new JSONObject();
response.put("fromUserId", fromUserId);
response.put("content", content);
response.put("time", System.currentTimeMillis());
sendToUser(toUserId, response.toJSONString());
} catch (Exception e) {
log.error("消息处理失败:{}", e.getMessage());
}
}
}
3.4 在业务代码中主动推送
WebSocket 不只能被动接收消息,也可以在任意业务逻辑中主动向客户端推送:
@Service
public class OrderService {
public void completeOrder(Long orderId, Long userId) {
// 处理订单完成逻辑...
// 订单完成后,主动推送通知给用户
JSONObject notify = new JSONObject();
notify.put("type", "ORDER_COMPLETE");
notify.put("orderId", orderId);
notify.put("message", "您的订单已完成,请及时查看");
ChatWebSocket.sendToUser(String.valueOf(userId), notify.toJSONString());
}
}
四、前端对接
<!DOCTYPE html>
<html>
<body>
<script>
const userId = "user_001";
const ws = new WebSocket(`ws://localhost:8080/ws/chat/${userId}`);
// 连接建立
ws.onopen = function () {
console.log("WebSocket 连接成功");
ws.send(JSON.stringify({
toUserId: "user_002",
content: "你好!"
}));
};
// 收到消息
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log(`收到来自 ${data.fromUserId} 的消息:${data.content}`);
};
// 连接关闭
ws.onclose = function () {
console.log("WebSocket 连接已关闭");
};
// 发生错误
ws.onerror = function (error) {
console.error("WebSocket 错误:", error);
};
</script>
</body>
</html>
五、生产环境注意事项
5.1 @ServerEndpoint 无法注入 Spring Bean 的问题
@ServerEndpoint 的实例由 Tomcat 管理,不是 Spring Bean,所以直接用 @Autowired 注入会失败:
@ServerEndpoint("/ws/chat/{userId}")
public class ChatWebSocket {
@Autowired
private UserService userService; // ❌ 注入失败,值为 null
}
解决方案:通过静态变量 + ApplicationContext 获取
@ServerEndpoint("/ws/chat/{userId}")
public class ChatWebSocket implements ApplicationContextAware {
private static UserService userService;
@Override
public void setApplicationContext(ApplicationContext context) {
userService = context.getBean(UserService.class);
}
}
或者更简洁地,在配置类里提前拿到:
@Component
public class WebSocketBeanFactory implements ApplicationContextAware {
private static ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
context = applicationContext;
}
public static <T> T getBean(Class<T> clazz) {
return context.getBean(clazz);
}
}
// 在 WebSocket 端点里使用
UserService userService = WebSocketBeanFactory.getBean(UserService.class);
5.2 集群部署下的消息广播问题
单机部署时,所有连接都在同一个 JVM 里,ConcurrentHashMap 存储会话没问题。但集群部署时,用户 A 连接在节点1,用户 B 连接在节点2,节点1无法直接找到用户 B 的 Session。
解决方案:引入消息中间件(如 RabbitMQ / Redis Pub-Sub)
节点1收到消息
↓
发布到 MQ / Redis Channel
↓
所有节点订阅并消费消息
↓
各节点检查自己管理的 Session 里有没有目标用户
↓
有则发送,无则忽略
5.3 连接心跳保活
长时间无数据交换时,防火墙或负载均衡器可能会断开连接,需要定期发送心跳:
// 前端每 30 秒发送一次心跳
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "PING" }));
}
}, 30000);
// 后端收到心跳回复
@OnMessage
public void onMessage(String message, Session session) {
JSONObject json = JSONObject.parseObject(message);
if ("PING".equals(json.getString("type"))) {
try {
session.getBasicRemote().sendText("{\"type\":\"PONG\"}");
} catch (IOException e) {
log.error("心跳回复失败");
}
}
}
5.4 连接断开重连
网络抖动时前端需要自动重连:
function createWebSocket(userId) {
const ws = new WebSocket(`ws://localhost:8080/ws/chat/${userId}`);
ws.onclose = function () {
console.log("连接断开,3秒后重连...");
setTimeout(() => createWebSocket(userId), 3000);
};
return ws;
}
六、完整流程总结
1. 引入 spring-boot-starter-websocket 依赖
↓
2. 注册 ServerEndpointExporter Bean(内置Tomcat必须)
↓
3. 编写 @ServerEndpoint 端点类
实现 @OnOpen / @OnMessage / @OnClose / @OnError
↓
4. 用 ConcurrentHashMap 管理所有在线 Session
↓
5. 业务代码调用静态方法主动推送消息
↓
6. 前端用 new WebSocket(url) 建立连接
通过 onmessage 接收,ws.send() 发送
七、适用场景总结
| 场景 | 说明 |
|---|---|
| 在线聊天 | 用户之间实时发送消息 |
| 实时通知 | 订单完成、充值到账、审批结果 |
| 设备监控 | 硬件设备实时上报状态数据 |
| 协同编辑 | 多人同时编辑同一文档 |
| 行情推送 | 股票、加密货币实时价格 |
| 在线游戏 | 多人游戏实时同步状态 |
WebSocket 适合高频、双向、实时 的通信场景。如果只是服务器单向推送(如消息通知),也可以考虑更轻量的 SSE(Server-Sent Events);如果实时性要求不高,简单的短轮询也能满足需求。选择合适的技术,比盲目使用 WebSocket 更重要。