WebSocket 一次性通关

1,429 阅读11分钟

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 语句查看执行计划,这样会减少学习过程中的抽象感,会让印象更深刻。

这里探讨的是如何少做无用功,也就是明明花时间学了,为什么再次遇到还是不会,或者过一段时间之后就忘了,这就是学习效率低的体现。