WebSocket 如何使用?
阅读了一篇2023年的帖子:Java 实现 WebSocket 的五种方式,算是开拓了自己的眼界吧。我对于其中提到的第一、三、四、五种方法都很陌生,只熟悉第二种:定义 WebSocket 端点,然后定义端点导出配置类。接下来只讨论这种方式。
这篇文章有一些错误:在第二种方法中,文章中添加的依赖项是 spring-boot-starter-websocket,最后使用的却是 javax.websocket。文中还提到:“这是 jdk 自带的”。
其实只有在 Java EE 中才内置相关 API,而且在2017年11月,Oracle 将 Java EE 的开发和维护交给了 Eclipse 基金会,标志着 Java EE 进入新阶段 —— Jakarta EE。并且在2020年9月,也就是从 Jakarta 9 开始,所有的 Java EE 包名都被更改为以 jakarta. 开头,以避免与 Java EE 的命名冲突,javax.websocket 包也被迁移到 jakarta.websocket 包。
引用 在 Spring Boot 中整合、使用 WebSocket 这篇文章里的一段话,写得很清晰。
在 Spring Boot 中使用 WebSocket 有 2 种方式。第 1 种是使用由 Jakarta EE 规范提供的 Api,也就是包 jakarta.websocket 下的接口。第 2 种是使用 spring 提供的支持,也就是 spring-websocket 模块。前者是一种独立于框架的技术规范,而后者是 Spring 生态系统的一部分,可以与其他 Spring 模块(如 Spring MVC、Spring Security)无缝集成,共享其配置和功能。
这里介绍使用 jakarta.websocket 包下的接口的方法。
本来正常的顺序是先介绍方法,再介绍工作流程,但为了方便理解,这里就把具体的流程与方法融合在一起进行介绍。
第一步:开发 ServerEndpoint 端点
有两种方法,第一种是实现规范所提供的各种接口,通过接口定义的回调方法来处理新的连接、客户端消息、连接断开等等事件。第二种是类似于 Spring MVC 中的 Controller 方法,使用注解 @ServerEndpoint(value = ""),来监听不同的 WebSocket 事件。
这里简易实现了第二种方法。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@ServerEndpoint(value = "/myWs")
@Component
public class WSServerEndpoint {
private static final Logger logger = LoggerFactory.getLogger(WSServerEndpoint.class);
private static final Map<String, Session> sessions = new ConcurrentHashMap<>(); // 服务器端点连接的所有客户端会话
private Session session; // 当前线程的客户端会话
private final Lock sendLock = new ReentrantLock();
/**
* 连接成功
*
* @param session
*/
@OnOpen
public void onOpen(Session session) {
this.session = session;
sessions.put(session.getId(), session);
logger.info("[websocket] 连接成功 id={}", session.getId());
logger.info("当前连接的客户端个数为:{} 个", sessions.size());
}
/**
* 连接关闭
*
*/
@OnClose
public void onClose() {
sessions.remove(this.session.getId());
logger.info("[websocket] 连接断开 id={}", this.session.getId());
}
/**
* 接收到消息
*
* @param message
*/
@OnMessage
public void onMessage(String message) throws IOException {
logger.info("[websocket] 接收到消息:id={}, message={}", this.session.getId(), message);
if (message.equals("bye")) {
this.session.close();
return;
}
String message2 = "收到消息:" + message + "来自会话:" + this.session.getId();
// sendMessageToAllClients(message2);
sendMessageToClient(this.session.getId(), message2);
}
public void sendMessageToAllClients(String message) {
for (Session clientSession : sessions.values()) {
sendLock.lock();
try {
clientSession.getAsyncRemote().sendText(message);
} catch (Exception e) {
logger.error("向客户端{}发送消息失败", clientSession.getId());
} finally {
sendLock.unlock();
}
}
}
public void sendMessageToClient(String id, String message) {
Session clientSession = sessions.get(id);
if (clientSession != null) {
sendLock.lock();
try {
clientSession.getAsyncRemote().sendText(message);
} catch (Exception e) {
logger.error("向客户端{}发送消息失败", clientSession.getId());
} finally {
sendLock.unlock();
}
}
}
}
我使用了一个 WebSocket在线测试工具 来作为客户端,方便测试我编写的 WebSocket 服务器端,界面如下:
我在服务地址中输入了类似于 HTTP 请求 URL 的地址:ws://localhost:8080/myWs,这里的“/myWs”就是 @ServerEndpoint() 中的值。
点击连接,观察控制台,发现浏览器发送了一个请求:
GET ws://localhost:8080/myWs HTTP/1.1 // 请求行,由方法、URL、协议版本组成
Host: localhost:8080 // 目标服务器的主机名和端口号
Connection: Upgrade // 请求的连接类型,Upgrade 表示客户端希望升级到一个不同的协议(即 WebSocket)。
Pragma: no-cache
Cache-Control: no-cache // Pragma 和 Cache-Control 指定不缓存该请求的响应。
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
// 客户端的软件和版本信息,通常是浏览器的标识。
Upgrade: websocket // 请求服务器将协议从 HTTP 升级到 WebSocket
Origin: https://wstool.js.org // 表示请求发起的源,通常用于跨源资源共享(CORS)验证。
Sec-WebSocket-Version: 13 // 表示客户端支持的 WebSocket 协议版本
Accept-Encoding: gzip, deflate, br, zstd // 指定客户端支持的内容编码方式,用于数据压缩。
Accept-Language: zh-CN,zh;q=0.9 // 指定客户端能够接受的语言类型及其优先级
Sec-WebSocket-Key: JGCS/6K3aXyCdq1jIP521A== // 随机生成的base64编码的密钥,用于握手过程中的服务器验证
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
// 可选的扩展项,用于协商 WebSocket 扩展功能,这里表示支持 permessage-deflate 扩展用于消息压缩。
服务器收到这个请求后会调用 @OnOpen 标注的方法,用于建立 Websocket 连接。并返回响应。这个方法中的参数 Session 用于表示当前的连接与会话,有着自己独立的 id。
然后客户端收到来自服务器端的响应
HTTP/1.1 101 // 101 状态码告诉客户端服务器已经同意升级到 WebSocket 协议。
Upgrade: websocket // 确认服务器已经将协议升级到 WebSocket。
Connection: upgrade // 表明连接类型是升级的,即客户端和服务器之间的协议已经从 HTTP 升级到 WebSocket。
Sec-WebSocket-Accept: C7Ia3Zhz0MA/xYca6u5eUJmDMnE=
// 包含服务器生成的密钥,用于验证客户端发送的 Sec-WebSocket-Key。这个密钥是通过对客户端的 Sec-WebSocket-Key 进行 SHA-1 哈希计算和 Base64 编码生成的,确保握手过程的安全性。
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15
// 可选的扩展项,服务器告知客户端其支持的扩展功能。这里表示服务器同意使用 permessage-deflate 扩展来进行消息压缩,并指定 client_max_window_bits 为 15(这是扩展的具体配置参数)。
Date: Thu, 08 Aug 2024 06:29:09 GMT
// 表示服务器响应的日期和时间,按照 GMT(格林威治标准时间)格式。
如果我在在线测试网站中点击发送消息,就会触发 @OnMessage 所标注的方法,方框中输入的消息也就是方法的参数。
服务器端也会显示收到消息:
网站中还有一个选项是“关闭连接”,我点击之后,在控制台中并没有看到客户端发送网络请求,但是建立连接时客户端会发送 HTTP 请求。为什么建立连接与关闭连接不一样呢?
查阅资料后发现,不同于建立连接,WebSocket 在关闭连接时并不会发送请求,而是发送一个帧(Frame),帧是 WebSocket 传输信息的单位,这个帧包括的信息有:关闭状态码以及可选的关闭原因。服务器端收到这个帧后,也会向客户端响应一个帧,表示同意关闭,并清理 WebSocket 会话,关闭任何相关的资源。客户端收到服务器端发送的响应帧后,也会清理 WebSocket 对象,移除相关的事件监听器。至此就关闭了连接,相当于 2次握手,没有发送 HTTP 请求。
在这个方法中我定义了这么一段:
if (message.equals("bye")) {
this.session.close();
return;
}
如果输入的消息是 "bye",就会调用 @OnClose 所标注的方法,来断开连接。这是服务器端主动关闭连接,也是以发送帧而不是发送 HTTP 请求的形式,具体来说只需要发送一个帧即可,客户端收到后不用给出响应,算是客户端关闭连接的一个子集。
一旦 WebSocket 连接建立成功,客户端和服务器可以开始通过 WebSocket 协议进行双向数据传输。数据传输是基于 WebSocket 帧的,这些帧可以包含文本、二进制数据或控制信息(例如关闭帧)。服务器使用 Session.getBasicRemote().sendText() 或 Session.getAsyncRemote().sendText() 发送消息。它们的区别是一个是同步,一个是异步,这里不加以讨论。
第二步:配置 ServerEndpointExporter
配置 ServerEndpointExporter 的方式非常简单, 只需要创建一个 ServerEndpointExporter Bean 即可, 它会去获取 Spring 上下文中所有的 Endpoint 实例, 完成 Endpoint 的注册过程, 并监听在 application.properties 的server.port 属性所指定的端口。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
@EnableWebSocket
public class WsConfig {
@Bean
public ServerEndpointExporter serverEndpoint() {
return new ServerEndpointExporter();
}
}
在 application.properties 中定义 server.port,也就是类似于 Spring MVC 中监听的端口
第三步:启动应用程序
WebSocket 是什么?
WebSocket 是一个应用层网络协议,相比于 HTTP 协议的请求-响应模式,WebSocket 协议将 HTTP 连接升级成了一个全双工的、持久的通信通道,以便客户端和服务器端可以实现双向的、持久的实时通信。
面试中遇到的问题
WebSocket 与 C++ 中的 Socket 有什么不同吗?
WebSocket 是一种基于 HTTP 协议的应用层协议,设计用于客户端和服务器之间的实时双向通信。
C++ Socket 提供了低层次的网络编程接口,允许开发者直接操作 TCP/IP 或 UDP/IP 协议,适合于需要精细控制和高性能的网络通信场景。
除了 WebSocket,要实现服务器端主动与客户端通信,还有哪些方式,相比 WebSocket 有哪些优缺点。
方法一:HTTP 轮询。客户端不断地向服务器端发送 HTTP 请求,一旦服务器端有响应,客户端就能在一定时间内收到来自服务器端的响应。缺点有两个,一个是消耗带宽,第二个是有延迟(每次发出请求的时间间隔)
方法二:HTTP 长轮询。客户端还是向服务器端发送 HTTP 请求,但是把响应超时时间设置得非常长,那样一旦服务器端有响应,客户端立马就能收到,不仅减少了带宽消耗,还降低了延迟。但是有一个问题,如果服务器端有大量数据要发给客户端,一个 HTTP 响应请求貌似装不够?
方法三:WebSocket。在客户端与服务器端建立一个全双工的连接,用于双方实时通信,也就是同一时间里,双方都可以主动向对方发送数据。
WebSocket 与 HTTP 中的 keep-alive 有什么区别?
keep-alive 是 HTTP 1.1 中引入一个新特性,是 Header 头 Connection 的一个值,用于建立一个 HTTP 连接之后,发送完一个请求,之后还能接着发送请求,不用重新建立连接损耗性能,但本质还是半双工的,还是单向的。
WebSocket 它不仅实现了 keep-alive 所引入的长连接,还实现了真正的全双工通信,也就是客户端和服务器端能同时地向对方发送请求。
在原理层面,介绍是如何建立 WebSocket 连接的?
其实在之前实践分析 HTTP 头部的时候已经历经了,自己动手做一遍远比记所谓的八股印象深刻
总的来说,是经过 TCP 三次握手 + HTTP 两次握手建立的。
首先经过 TCP 三次握手建立 HTTP 连接。之后进行两次 HTTP 握手:
第一次握手的时候,客户端在 HTTP 请求头中加入一些特殊的头部字段,比如 Connection:Upgrade、Upgrade:websocket、Sec-WebSocket-Accept(随机生成的一段 Base64 编码的字符串)
第二次握手,服务器端会返回一个 HTTP 响应,第一行的状态码是 101 Switching The Protocol,包含相同的 Connection、Upgrade 字段,Sec-WebSocket-Accept 字段中是对客户端发来的字符串使用哈希算法编码之后的的字符串,客户端也会对自己原本的字段值使用相同的哈希算法进行编码,比较是否相同。
反思
学习一个技术,首先要判断这个技术是偏理论性的还是偏实践性的。比如说本文介绍的 WebSocket 很明显是一门偏实践性的技术,学习偏实践性的技术,就应该先实践、后理论,先去用这个技术,在用的时候会产生产生一些疑惑,再带着这些疑惑去学习相关的理论。而不是一头扎进理论中,在”大脑中实践“(比如看别人实现的代码、问 ChatGPT、记八股,这样学效率很低、印象很浅)。
如果是偏理论性的技术,比如 MySQL,就可以适当地加大看书等理论学习的比重,当然这并不是说不需要去动手实操,比如在学 MySQL 的时候去 SELECT 一下系统数据表、看看参数、亲自执行 EXPLAIN 语句查看执行计划,这样会减少学习过程中的抽象感,会让印象更深刻。
这里探讨的是如何少做无用功,也就是明明花时间学了,为什么再次遇到还是不会,或者过一段时间之后就忘了,这就是学习效率低的体现。