WebSocket技术分享

为什么需要 WebSocket?

HTTP 的局限性

  • 无状态短连接:每次请求需要重新建立 TCP 连接(HTTP/1.1 的 Keep-Alive 只能有限复用)
  • 单向通信:必须由客户端发起请求,服务器才能响应(无法实现服务器主动推送)
  • 高开销:每次请求携带完整 HTTP 头部(Cookie、User-Agent 等冗余信息)
  • 实时性差:轮询(Polling)和长轮询(Long Polling)有显著延迟

传统实时方案缺陷

方案 延迟 服务器压力 适用场景 缺点分析
短轮询 极高 简单数据更新 无效请求多,资源浪费严重
长轮询 邮件通知 连接保持消耗资源
SSE 股票行情 仅支持服务器到客户端推送
WebSocket 极低 所有实时场景 实现复杂度较高

业务需求驱动

现代 Web 应用对实时性的要求越来越高:

  • 金融交易系统需要毫秒级行情推送
  • 在线协作工具需要实时同步用户操作
  • 物联网仪表盘需要实时展示设备状态

WebSocket 的诞生

历史演进

设计目标

  • 基于 TCP:可靠传输层保障
  • 兼容 HTTP:80/443 端口,通过 Upgrade 机制握手
  • 轻量级:帧头最小仅 2 字节
  • 全双工:突破 HTTP 单向限制、

WebSocket 是什么?

WebSocket 是基于 TCP 的一种新的应用层网络协议。它实现了浏览器与服务器全双工通信,即允许服务器主动发送信息给客户端。因此,在 WebSocket 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,客户端和服务器之间的数据交换变得更加简单。

WebSocket 的特点

  • 建立在 TCP 协议之上;
  • 与 HTTP 协议有着良好的兼容性:默认端口也是 80(ws) 和 443(wss,运行在 TLS 之上),并且握手阶段采用 HTTP 协议;
  • 较少的控制开销:连接创建后,ws 客户端、服务端进行数据交换时,协议控制的数据包头部较小,而 HTTP 协议每次通信都需要携带完整的头部;
  • 可以发送文本,也可以发送二进制数据;
  • 没有同源限制,客户端可以与任意服务器通信;
  • 协议标识符是 ws(如果加密,则为 wss),服务器网址就是 URL;
  • 支持扩展:ws 协议定义了扩展,用户可以扩展协议,或者实现自定义的子协议(比如支持自定义压缩算法等);

WebSocket 与 HTTP、TCP

HTTP、WebSocket 等协议都是处于 OSI 模型的最高层:应用层。而 IP 协议工作在网络层,TCP 协议工作在传输层。

HTTP、WebSocket 等应用层协议,都是基于 TCP 协议来传输数据的,因此其连接和断开,都要遵循 TCP 协议中的三次握手和四次挥手 ,只是在连接之后发送的内容不同,或者是断开的时间不同。

WebSocket 协议详解

入门例子

在正式介绍协议细节前,先来看一个简单的例子

服务端

pom.xml 中添加 WebSocket 依赖:

xml 复制代码
<dependency>  
   <groupId>org.springframework.boot</groupId>  
   <artifactId>spring-boot-starter-websocket</artifactId>  
   <version>3.4.5</version>
</dependency> 

代码如下,监听8080端口。当有新的连接请求到达时,打印日志,同时向客户端发送消息。当收到到来自客户端的消息时,同样打印日志。

创建 WebSocket 配置类

java 复制代码
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new MyWebSocketHandler(), "/ws")
                .setAllowedOrigins("*"); // 允许跨域
    }
}

创建 WebSocket 处理器

java 复制代码
public class MyWebSocketHandler extends TextWebSocketHandler {

    private final CopyOnWriteArrayList<WebSocketSession> sessions = new CopyOnWriteArrayList<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        sessions.add(session);
        System.out.println("新连接建立: " + session.getId());
        session.sendMessage(new TextMessage("欢迎连接到WebSocket服务器!"));
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("收到来自客户端 " + session.getId() + " 的消息: " + message.getPayload());
        // 可以在这里处理消息并回复
        session.sendMessage(new TextMessage("已收到你的消息: " + message.getPayload()));
    }
    @Override
    public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) throws Exception {
        sessions.remove(session);
        System.out.println("连接关闭: " + session.getId());
    }
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.err.println("连接 " + session.getId() + " 发生错误:");
        exception.printStackTrace();
    }
}

客户端

代码如下,向8080端口发起WebSocket连接。连接建立后,打印日志,同时向服务端发送消息。接收到来自服务端的消息后,同样打印日志。

javascript 复制代码
<!DOCTYPE html>
<html>
<head>
    <title>WebSocket 客户端</title>
</head>
<body>
    <script>
        // 创建WebSocket连接
        const socket = new WebSocket("ws://localhost:8080/ws");

        // 连接建立时
        socket.onopen = function(event) {
            console.log("连接已建立");
            // 向服务器发送消息
            socket.send("你好,服务器!");
        };

        // 接收到消息时
        socket.onmessage = function(event) {
            console.log("收到服务器消息: " + event.data);
        };

        // 连接关闭时
        socket.onclose = function(event) {
            console.log("连接已关闭");
        };

        // 发生错误时
        socket.onerror = function(error) {
            console.error("WebSocket错误: ", error);
        };
    </script>
</body>
</html>

运行结果

服务端输出:
javascript 复制代码
新连接建立: 123f164e-26cf-04d1-4ee3-4a91fac2a7f0
收到来自客户端 123f164e-26cf-04d1-4ee3-4a91fac2a7f0 的消息: 你好,服务

客户端输出:

javascript 复制代码
连接已建立
收到服务器消息: 欢迎连接到WebSocket服务器!
收到服务器消息: 已收到你的消息: 你好,服务器!

如何建立连接

在 WebSocket 开始通信之前,通信双方需要先进行握手,WebSocket复用了HTTP的握手通道。具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。

利用 HTTP 完成握手有什么好处呢?一是可以让 WebSocket 和 HTTP 基础设备兼容(运行在 80 端口 或 443 端口),二是可以复用 HTTP 的 Upgrade 机制,完成升级协议的协商过程。

客户端:申请协议升级

首先,客户端发起协议升级请求。可以看到,采用的是标准的HTTP报文格式,且只支持GET方法。

重点请求首部意义如下:

  • Connection: Upgrade:表示要升级协议
  • Upgrade: websocket:表示要升级到websocket协议。
  • Sec-WebSocket-Version: 13:表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。
  • Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。

服务端:响应协议升级

服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。

Sec-WebSocket-Accept的计算

Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。

计算公式为:

  1. 将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  2. 通过SHA1计算出摘要,并转成base64字符串。

验证下前面的返回结果:

java 复制代码
public class WebSocketAcceptCalculator {

    private static final String MAGIC_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

    public static String calculateAcceptKey(String secWebSocketKey) {
        try {
            // 1. 拼接 key 和 magic GUID
            String input = secWebSocketKey + MAGIC_GUID;
            
            // 2. 计算 SHA-1 哈希
            MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
            byte[] sha1Hash = sha1.digest(input.getBytes(StandardCharsets.UTF_8));
            
            // 3. Base64 编码
            return Base64.getEncoder().encodeToString(sha1Hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("SHA-1 algorithm not available", e);
        }
    }

    public static void main(String[] args) {
        String secWebSocketKey = "ZVdgW7a1Xa/MQb5tVybRfA==";
        String acceptKey = calculateAcceptKey(secWebSocketKey);
        System.out.println("Sec-WebSocket-Accept: " + acceptKey);

    }
}

输出:

java 复制代码
Sec-WebSocket-Accept: b3XPpwH2dyaFUWISfKSLl29dn7Y=

数据帧格式

客户端、服务端数据的交换,离不开数据帧格式的定义。因此,在实际讲解数据交换之前,我们先来看下WebSocket的数据帧格式。

WebSocket客户端、服务端通信的最小单位是帧(frame),由1个或多个帧组成一条完整的消息(message)。

  1. 发送端:将消息切割成多个帧,并发送给服务端;
  2. 接收端:接收消息帧,并将关联的帧重新组装成完整的消息;

数据帧格式概览

下面给出了WebSocket数据帧的统一格式。熟悉TCP/IP协议的同学对这样的图应该不陌生。

  1. 从左到右,单位是比特。比如FIN、RSV1各占据1比特,opcode占据4比特。
  2. 内容包括了标识、操作代码、掩码、数据、数据长度等。
lua 复制代码
  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued, if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                Masking-key, if MASK set to 1                  |
 +-------------------------------+-------------------------------+
 :                     Payload Data              :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

数据帧格式详解

  • FIN(1bit):如果是1,表示这是消息的最后一个分片,如果是0,表示不是消息的最后一个分片。
  • RSV1-3(各1bit):通常为0。当采用扩展时标志位可以非0
  • Opcode(4bit):操作代码,Opcode的值决定如何解析后续的数据载荷。可选的操作代码如下:
    • 0x0:延续帧, 表示本次数据传输采用了数据分片
    • 0x1:文本帧
    • 0x2:二进制帧
    • 0x8:连接断开
    • 0x9:ping操作
    • 0xA:pong操作
  • Mask(1bit):1表示有掩码(从客户端向服务端发送数据时,需要对数据进行掩码操作)
  • Payload length:数据载荷的长度,单位是字节。
    • 0-126: 直接表示长度
    • 126: 后2字节表示长度
    • 127: 后8字节表示长度
  • Masking-key:4字节(当Mask=1时存在)
  • Payload data:实际数据
掩码算法

掩码键(Masking-key)是由客户端挑选出来的32位的随机数。

算法:数据每个字节(i)与Masking-key的第j(j=i%4)个字节进行异或运算。

数据传递

一旦WebSocket客户端、服务端建立连接后,后续的操作都是基于数据帧的传递。

WebSocket根据opcode来区分操作的类型。比如0x8表示断开连接,0x0-0x2表示数据交互。

数据分片

WebSocket的每条消息可能被切分成多个数据帧。当WebSocket的接收方收到一个数据帧时,会根据FIN的值来判断,是否已经收到消息的最后一个数据帧。

FIN=1表示当前数据帧为消息的最后一个数据帧,此时接收方已经收到完整的消息,可以对消息进行处理。FIN=0,则接收方还需要继续监听接收其余的数据帧。

此外,opcode在数据交换的场景下,表示的是数据的类型。0x01表示文本,0x02表示二进制。而0x00比较特殊,表示延续帧(continuation frame),顾名思义,就是完整消息对应的数据帧还没接收完。

数据分片例子

客户端向服务端两次发送消息,服务端收到消息后回应客户端,这里主要看客户端往服务端发送的消息。

第一条消息

FIN=1, 表示是当前消息的最后一个数据帧。服务端收到当前数据帧后,可以处理消息。opcode=0x1,表示客户端发送的是文本类型。

第二条消息
  1. FIN=0,opcode=0x1,表示发送的是文本类型,且消息还没发送完成,还有后续的数据帧。
  2. FIN=0,opcode=0x0,表示消息还没发送完成,还有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。
  3. FIN=1,opcode=0x0,表示消息已经发送完成,没有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。服务端可以将关联的数据帧组装成完整的消息。
java 复制代码
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!

连接保持+心跳

WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。

但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用心跳来实现。

  • 发送方->接收方:ping
  • 接收方->发送方:pong

ping、pong的操作,对应的是WebSocket的两个控制帧,opcode分别是0x9、0xA。

Sec-WebSocket-Key/Accept

Sec-WebSocket-Key/Sec-WebSocket-Accept在主要作用在于提供基础的防护,减少恶意连接、意外连接。

作用大致归纳如下:

  1. 避免服务端收到非法的websocket连接(比如http客户端不小心请求连接websocket服务,此时服务端可以直接拒绝连接)
  2. 确保服务端理解websocket连接。因为ws握手阶段采用的是http协议,因此可能ws连接是被一个http服务器处理并返回的,此时客户端可以通过Sec-WebSocket-Key来确保服务端认识ws协议。(并非百分百保险,比如总是存在那么些无聊的http服务器,光处理Sec-WebSocket-Key,但并没有实现ws协议。。。)
  3. 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的。这样可以避免客户端发送ajax请求时,意外请求协议升级(websocket upgrade)
  4. 可以防止反向代理(不理解ws协议)返回错误的数据。比如反向代理前后收到两次ws连接的升级请求,反向代理把第一次请求的返回给cache住,然后第二次请求到来时直接把cache住的请求给返回(无意义的返回)。
  5. Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)。

WebSocket 扩展

WebSocket 扩展是通过 WebSocket 握手阶段 的 Sec-WebSocket-Extensions 头部进行协商的。客户端和服务端通过该头部声明支持的扩展,并最终协商出一个或多个共同支持的扩展用于后续通信。

扩展协商流程

客户端发起扩展支持声明

客户端在握手请求的 Sec-WebSocket-Extensions 头部中列出支持的扩展及其参数。例如:

http 复制代码
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits, x-extension-example
  • 此例中,客户端支持两个扩展:
    • permessage-deflate(WebSocket 压缩扩展),参数为 client_max_window_bits。
    • 自定义扩展 x-extension-example。
服务端响应确认扩展

服务端从客户端支持的扩展中选择一个或多个,通过响应头部的 Sec-WebSocket-Extensions 返回最终使用的扩展及参数:

http 复制代码
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15
  • 服务端确认使用 permessage-deflate 扩展,并设置参数 client_max_window_bits=15。
  • 未选择的扩展(如 x-extension-example)将被忽略。

扩展的使用方式

协商成功后,扩展会在 数据帧 的传输过程中生效。不同的扩展对数据帧的处理方式不同:

压缩扩展(如 permessage-deflate)
  • 客户端发送数据:先压缩数据,再通过 WebSocket 帧发送。
  • 服务端接收数据:解压数据后传递给应用层。
  • 双向生效:压缩和解压过程对双方透明。
自定义扩展

扩展可能修改帧的:

  • Payload 数据(如加密、压缩)。
  • 帧头字段(如扩展标志位)。

多个扩展按 Sec-WebSocket-Extensions 头部中的顺序依次处理(如先压缩再加密)。

若扩展协商失败,连接仍可建立,但需回退到无扩展模式。


安全性增强

启用WSS(WebSocket Secure)

场景 :防止中间人攻击,确保数据传输加密。
Spring Boot配置示例:

java 复制代码
# application.yml
server:
  port: 8443
  ssl:
    enabled: true
    key-store: classpath:keystore.p12
    key-store-password: yourpassword
    key-store-type: PKCS12

前端连接:

javascript 复制代码
const socket = new WebSocket("wss://yourdomain.com/ws");

防护措施

  • Origin校验:防止跨站劫持(CSWSH)
java 复制代码
@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myHandler(), "/ws")
                .setAllowedOrigins("https://trusted-domain.com"); // 严格限制来源
    }
}
  • 帧大小限制:防御DoS攻击
java 复制代码
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
    ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
    container.setMaxTextMessageBufferSize(8192); // 限制单帧8KB
    container.setMaxBinaryMessageBufferSize(8192);
    return container;
}

认证强化

  • JWT校验示例
java 复制代码
private void handleAuthMessage(WebSocketSession session, String token) {
    try {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(jwtSecret)
                .build()
                .parseClaimsJws(token)
                .getBody();
        
        if (claims.getExpiration().before(new Date())) {
            session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Token已过期"));
        }
    } catch (JwtException e) {
        session.close(CloseStatus.NOT_ACCEPTABLE.withReason("无效Token"));
    }
}

性能优化

压缩扩展(permessage-deflate)

服务端启用压缩:

java 复制代码
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
    ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
    container.setPerMessageDeflateEnabled(true); // 开启压缩
    container.setCompressionLevel(6); // 压缩级别(1-9)
    return container;
}

客户端请求头:

javascript 复制代码
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15

协议扩展实践

STOMP子协议

场景 :实现发布-订阅模式(如聊天室)。
服务端配置:

java 复制代码
@Configuration
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic"); // 内置Broker
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/stomp").setAllowedOrigins("*");
    }
}

客户端订阅:

javascript 复制代码
const stompClient = new StompJs.Client({ brokerURL: 'ws://localhost:8080/stomp' });
stompClient.subscribe('/topic/updates', (message) => {
    console.log("收到广播消息: " + message.body);
});

在项目中使用 WebSocket

WebSocketConfig

Spring WebSocket核心配置,负责 WebSocket 的端点注册、处理器映射和全局配置。

java 复制代码
@Configuration  // 标记为Spring配置类
@EnableWebSocket  // 启用Spring WebSocket功能
public class WebSocketConfig implements WebSocketConfigurer {

    /**
     * WebSocket处理器映射表
     * Key: WebSocket连接路径
     * Value: 对应的处理器实例
     */
    private final Map<String, TextWebSocketHandler> handlers;
    
    /**
     * WebSocket认证拦截器
     * 用于处理连接建立时的身份验证
     */
    private final WebSocketAuthInterceptor authInterceptor;

    /**
     * 构造函数(自动注入依赖)
     * @param handlerList 所有TextWebSocketHandler类型的Bean集合
     * @param authInterceptor 认证拦截器实例
     */
    @Autowired
    public WebSocketConfig(List<TextWebSocketHandler> handlerList,
                         WebSocketAuthInterceptor authInterceptor) {
        this.authInterceptor = authInterceptor;
        
        // 将处理器列表转换为路径->处理器的映射
        this.handlers = handlerList.stream()
            .collect(Collectors.toMap(
                // 键映射函数:根据处理器类型确定对应的WebSocket路径
                handler -> {
                    if (handler instanceof DataWebSocketHandler) {
                        
                        return "/ws/api";
                    }

                    return null;
                },
                // 值映射函数:直接使用处理器实例
                handler -> handler
            ));
    }

    /**
     * 注册WebSocket处理器
     * @param registry WebSocket处理器注册中心
     */
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 遍历所有已映射的处理器
        handlers.forEach((path, handler) -> 
            registry.addHandler(handler, path)  // 注册处理器和对应路径
                .addInterceptors(authInterceptor)  // 添加认证拦截器
                .setAllowedOriginPatterns("*")  // 允许所有来源跨域访问
        );
    }
}

DataWebSocketHandler

WebSocket业务核心组件,处理连接生命周期、用户认证、消息路由、主题订阅,并提供定时/即时数据推送能力

java 复制代码
@Component
@Slf4j
public class DataWebSocketHandler extends TextWebSocketHandler {

    // 定义有效的订阅主题集合
    private static final Set<String> VALID_TOPICS = Set.of("OVERVIEW");
    
    // 认证超时时间(秒)
    private static final long AUTH_TIMEOUT_SECONDS = 10;

    @Resource
    private WebSocketSessionManager sessionManager;
    @Resource
    private ObjectMapper objectMapper;

    // 消息处理器映射表(类型 -> 处理器)
    private final Map<String, MessageHandler> messageHandlers = new ConcurrentHashMap<>();
    
    // 定时任务执行器(用于认证超时控制)
    private final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1);

    // 消息处理函数式接口
    @FunctionalInterface
    private interface MessageHandler {
        void handle(WebSocketSession session, Map<String, Object> message) throws IOException;
    }

    /**
     * 初始化消息处理器
     */
    @PostConstruct
    public void init() {
        messageHandlers.put("AUTH", this::handleAuthMessage);       // 认证处理
        messageHandlers.put("SUBSCRIBE", this::handleSubscribeMessage); // 订阅处理
        messageHandlers.put("HEARTBEAT", this::handleHeartbeat);    // 心跳处理
    }

    /**
     * 新连接建立时触发
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        TraceNoUtils.newTraceNo();
        // 注册新会话
        sessionManager.registerSession(session);
        log.info("新WebSocket连接建立: sessionId={}", session.getId());
        
        // 设置认证超时任务
        scheduler.schedule(() -> {
            if (!sessionManager.isAuthenticated(session)) {
                try {
                    log.warn("连接 {} 未在 {} 秒内完成认证,即将关闭", session.getId(), AUTH_TIMEOUT_SECONDS);
                    session.close(CloseStatus.NOT_ACCEPTABLE);
                } catch (IOException e) {

                }
            }
        }, AUTH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
    }

    /**
     * 处理收到的文本消息
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
        try {
            // 解析JSON消息
            Map<String, Object> requestMap = objectMapper.readValue(
message.getPayload(),
 new TypeReference<>() {}
);

            // 根据消息类型路由到对应的处理器
            String type = (String) requestMap.get("type");
            MessageHandler handler = messageHandlers.get(type);


            handler.handle(session, requestMap);

        } catch (Exception e) {
            log.error("处理消息出错: {}", message.getPayload(), e);
            sendErrorResponse(session, "消息处理错误: " + e.getMessage());
            session.close(CloseStatus.SERVER_ERROR);
        }
    }

    /**
     * 处理认证消息
     */
    private void handleAuthMessage(WebSocketSession session, Map<String, Object> message) throws IOException {
        log.info("处理认证消息:sessionId={}, message={}", session.getId(), message);        
        // 检查重复认证
        if (sessionManager.isAuthenticated(session)) {
            sendErrorResponse(session, "已认证,请勿重复认证");
            return;
        }

        // Token验证
        String token = (String) message.get("token");
        if (StringUtils.isBlank(token)) {
            sendErrorResponse(session, "认证失败: token不能为空");
            session.close(CloseStatus.NOT_ACCEPTABLE);
            return;
        }

        // 认证会话
        sessionManager.authenticateSession(session, new UserInfo())));
        sendSuccessResponse(session, "认证成功", "AUTH");
    }

    /**
     * 处理订阅消息
     */
    private void handleSubscribeMessage(WebSocketSession session, Map<String, Object> message) throws IOException {
        log.info("处理订阅消息:sessionId={}, message={}", session.getId(), message);

        // 验证主题有效性
        String topic = (String) message.get("topic");
        if (!VALID_TOPICS.contains(topic)) {
            sendErrorResponse(session, "无效订阅主题: " + topic);
            return;
        }

        // 订阅主题
        sessionManager.subscribeTopic(session, topic);
        
        // 立即推送一次数据
        try {
            pushDataToSession(session, topic);
        } catch (Exception e) {
            log.error("订阅后立即推送数据失败,主题: {}, 会话ID: {}", topic, session.getId(), e);
        }
        
        sendSuccessResponse(session, "订阅成功: " + topic, "SUBSCRIBE");
    }

    /**
     * 处理心跳消息
     */
    private void handleHeartbeat(WebSocketSession session, Map<String, Object> message) {
        try {

            if (!session.isOpen()) {
                log.warn("会话 {} 已关闭,忽略心跳", session.getId());
                return;
            }
            
            // 更新活跃时间
            sessionManager.updateLastActiveTime(session);
            sendSuccessResponse(session, "心跳已接收", "HEARTBEAT");
        } catch (IOException e) {

        }
    }

    /**
     * 向会话推送主题数据
     */
    private void pushDataToSession(WebSocketSession session, String topic) throws Exception {
        if ("OVERVIEW".equals(topic)) {
            // 实际项目应替换为真实数据获取逻辑
            String jsonResponse = objectMapper.writeValueAsString("hello world");
            sessionManager.sendToSession(session, "OVERVIEW", jsonResponse);
            log.info("向会话 {} 推送数据成功", session.getId());
        }
    }

    /**
     * 定时推送数据(每分钟执行一次)
     */
    @Scheduled(fixedRateString = "${websocket.push.events.interval:60000}")
    public void pushOverviewData() {


        // 获取所有订阅者
        Set<WebSocketSession> subscribedSessions = sessionManager.getSubscribedSessions("OVERVIEW");
        if (subscribedSessions.isEmpty()) {
            log.info("当前没有订阅OVERVIEW的会话");
            return;
        }

        // 批量推送
        subscribedSessions.forEach(session -> {
            try {
                pushDataToSession(session, "OVERVIEW");
            } catch (Exception ignored) {
                
            }
        });
    }

}

WebSocketSessionManager

WebSocket会话全生命周期管理器,含认证、心跳、订阅和推送功能

java 复制代码
@Slf4j
@Component
public class WebSocketSessionManager {
    // 存储所有会话 (sessionId -> WebSocketSession)
    private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
    
    // 存储已认证的会话及对应的用户信息 (WebSocketSession -> UserInfo)
    private final Map<WebSocketSession, UserInfo> authenticatedSessions = new ConcurrentHashMap<>();
    
    // 存储每个会话的最后活跃时间 (WebSocketSession -> LastActiveTime)
    private final Map<WebSocketSession, LocalDateTime> lastActiveTimes = new ConcurrentHashMap<>();
    
    // 存储用户ID到会话的映射 (userId -> WebSocketSession)
    private final Map<String, WebSocketSession> userSessions = new ConcurrentHashMap<>();

    // 存储会话订阅的主题 (WebSocketSession -> Set<topic>)
    private final Map<WebSocketSession, Set<String>> sessionTopics = new ConcurrentHashMap<>();

    // 存储每个主题的参数 (WebSocketSession -> (topic -> (paramName -> paramValue)))
    private final Map<WebSocketSession, Map<String, Map<String, Object>>> topicParams = new ConcurrentHashMap<>();

    // 心跳超时时间(秒) - 建议通过@Value从配置读取
    private static final long HEARTBEAT_TIMEOUT = 120;

    /**
     * 注册新连接
     * @param session 新建立的WebSocket会话
     */
    public void registerSession(WebSocketSession session) {
        sessions.put(session.getId(), session);
        lastActiveTimes.put(session, LocalDateTime.now());
        log.info("注册新会话: {}", session.getId());
    }

    /**
     * 认证会话(实现单用户单连接)
     * @param session 待认证的会话
     * @param userInfo 用户信息
     */
    public void authenticateSession(WebSocketSession session, UserInfo userInfo) {
        // 移除该用户之前的会话(实现新连接踢掉旧连接)
        WebSocketSession previousSession = userSessions.put(userInfo.getPhone(), session);
        if (previousSession != null && !previousSession.getId().equals(session.getId())) {
            log.info("用户 {} 的新会话 {} 已建立,关闭旧会话 {}", 
                    userInfo.getPhone(), session.getId(), previousSession.getId());
            removeSession(previousSession);
            closeSessionQuietly(previousSession);
        }

        authenticatedSessions.put(session, userInfo);
        session.getAttributes().put("user", userInfo);
        log.info("会话认证成功: {}, 用户: {}", session.getId(), userInfo.getPhone());
    }

    /**
     * 更新会话活跃时间(心跳检测用)
     * @param session 需要更新的会话
     */
    public void updateLastActiveTime(WebSocketSession session) {
        lastActiveTimes.put(session, LocalDateTime.now());
        log.trace("会话 {} 的活跃时间已更新", session.getId());
    }

    /**
     * 移除会话(清理所有相关数据)
     * @param session 待移除的会话
     */
    public void removeSession(WebSocketSession session) {
        sessions.remove(session.getId());
        authenticatedSessions.remove(session);
        lastActiveTimes.remove(session);
        userSessions.values().remove(session);
        sessionTopics.remove(session);
        topicParams.remove(session);
        log.info("移除会话: {}", session.getId());
    }

    /**
     * 检查会话是否超时
     * @param session 待检查的会话
     * @param timeoutSeconds 超时阈值(秒)
     * @return 是否已超时
     */
    public boolean isSessionExpired(WebSocketSession session, long timeoutSeconds) {
        LocalDateTime lastActive = lastActiveTimes.get(session);
        if (lastActive == null) {
            return true; // 从未活跃过,视为超时
        }
        long inactiveSeconds = Duration.between(lastActive, LocalDateTime.now()).getSeconds();
        return inactiveSeconds > timeoutSeconds;
    }

    /**
     * 检查会话是否已认证
     * @param session 待检查的会话
     * @return 是否已认证
     */
    public boolean isAuthenticated(WebSocketSession session) {
        return authenticatedSessions.containsKey(session);
    }

    /**
     * 订阅主题
     * @param session 订阅会话
     * @param topic 主题名称
     */
    public void subscribeTopic(WebSocketSession session, String topic) {
        // 获取或创建会话的主题集合
        Set<String> topics = sessionTopics.computeIfAbsent(session, k -> ConcurrentHashMap.newKeySet());
        topics.add(topic);
        log.info("会话 {} 订阅主题: {}", session.getId(), topic);
    }

    /**
     * 检查会话是否订阅了指定主题
     * @param session 待检查的会话
     * @param topic 主题名称
     * @return 是否已订阅
     */
    public boolean isSubscribedToTopic(WebSocketSession session, String topic) {
        Set<String> topics = sessionTopics.get(session);
        return topics != null && topics.contains(topic);
    }

    /**
     * 获取主题参数
     * @param session 目标会话
     * @param topic 主题名称
     * @param paramName 参数名
     * @return 参数值
     */
    public Object getTopicParam(WebSocketSession session, String topic, String paramName) {
        Map<String, Map<String, Object>> sessionParams = topicParams.get(session);
        if (sessionParams != null) {
            Map<String, Object> topicParamMap = sessionParams.get(topic);
            if (topicParamMap != null) {
                return topicParamMap.get(paramName);
            }
        }
        return null;
    }

    /**
     * 定时检查心跳超时(每分钟执行一次)
     * 自动清理超时会话
     */
    @Scheduled(fixedRate = 60000)
    public void checkHeartbeatTimeout() {
        sessions.values().forEach(session -> {
            if (isSessionExpired(session, HEARTBEAT_TIMEOUT)) {
                removeSession(session);
                closeSessionQuietly(session);
            }
        });
    }

    /**
     * 安全关闭会话(忽略异常)
     * @param session 待关闭的会话
     */
    private void closeSessionQuietly(WebSocketSession session) {
        try {
            if (session.isOpen()) {
                session.close();
            }
        } catch (IOException e) {
            log.error("关闭会话失败", e);
        }
    }

    /**
     * 获取订阅了特定主题的所有活跃会话
     * @param topic 主题名称
     * @return 订阅该主题的会话集合
     */
    public Set<WebSocketSession> getSubscribedSessions(String topic) {
        return authenticatedSessions.keySet().stream()
                .filter(session -> session.isOpen() && isSubscribedToTopic(session, topic))
                .collect(Collectors.toSet());
    }

    /**
     * 向特定会话发送主题消息
     * @param session 目标会话
     * @param topic 主题名称
     * @param message 消息内容(JSON字符串)
     * @throws IOException 发送失败时抛出
     */
    public void sendToSession(WebSocketSession session, String topic, String message) throws IOException {
        if (session.isOpen() && isSubscribedToTopic(session, topic)) {
            String fullMessage = "{\"topic\":\"" + topic + "\",\"data\":" + message + "}";
            session.sendMessage(new TextMessage(fullMessage));
        }
    }
}

前端JS代码如下

javascript 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket 客户端</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }

        .container {
            display: flex;
            flex-direction: column;
            gap: 20px;
        }

        .control-panel {
            display: flex;
            gap: 10px;
            margin-bottom: 10px;
        }

        button {
            padding: 8px 16px;
            cursor: pointer;
        }

        button:disabled {
            cursor: not-allowed;
            opacity: 0.6;
        }

        textarea {
            width: 100%;
            min-height: 100px;
            padding: 8px;
            box-sizing: border-box;
        }

        .status {
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 10px;
        }

        .connected {
            background-color: #d4edda;
            color: #155724;
        }

        .disconnected {
            background-color: #f8d7da;
            color: #721c24;
        }

        .messages {
            border: 1px solid #ddd;
            padding: 10px;
            border-radius: 4px;
            max-height: 300px;
            overflow-y: auto;
            background-color: #f8f9fa;
        }

        .message {
            margin-bottom: 10px;
            padding: 8px;
            border-bottom: 1px solid #eee;
        }

        .message.incoming {
            background-color: #e2f0fd;
        }

        .message.outgoing {
            background-color: #f0f0f0;
        }

        .message.error {
            background-color: #fde8e8;
            color: #dc3545;
        }

        .timestamp {
            font-size: 0.8em;
            color: #6c757d;
        }

        .preset-buttons {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
            margin-bottom: 10px;
        }

        .data-display {
            border: 1px solid #ddd;
            padding: 10px;
            border-radius: 4px;
            margin-top: 10px;
            background-color: #f9f9f9;
        }

        .data-display h3 {
            margin-top: 0;
        }

        .data-display pre {
            background-color: #f5f5f5;
            padding: 10px;
            border-radius: 4px;
            overflow-x: auto;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>WebSocket 客户端 (推送模式)</h1>
        
        <div id="status" class="status disconnected">未连接</div>
        
        <div class="control-panel">
            <input type="text" id="wsUrl" placeholder="ws://192.168.54.25:8080/ws/api" style="flex-grow: 1;">
            <button id="connectBtn">连接</button>
            <button id="disconnectBtn" disabled>断开</button>
        </div>
        
        <div>
            <h3>认证信息</h3>
            <div class="preset-buttons">
                <button id="setAuthBtn">设置认证消息</button>
                <input type="text" id="tokenInput" placeholder="输入token" style="flex-grow: 1;">
            </div>
            <textarea id="authMessage" readonly></textarea>
        </div>
        
        <div>
            <h3>订阅管理</h3>
            <div class="preset-buttons">
                <button id="subscribeEventsBtn" disabled>订阅概览</button>
                <button id="unsubscribeBtn" disabled>取消所有订阅</button>
                <input type="text" id="dateTimeInput" placeholder="日期时间 (2025-06-20 14:30:00)">
            </div>
        </div>
        
        <div>
            <h3>消息记录</h3>
            <div class="messages" id="messageLog"></div>
        </div>
        
        <div class="data-display" id="eventsDataDisplay">
            <h3>概览数据</h3>
            <pre id="eventsData"></pre>
        </div>
    </div>

    <script>
        document.addEventListener('DOMContentLoaded', function() {
            // DOM元素
            const wsUrlInput = document.getElementById('wsUrl');
            const connectBtn = document.getElementById('connectBtn');
            const disconnectBtn = document.getElementById('disconnectBtn');
            const statusDiv = document.getElementById('status');
            const messageLog = document.getElementById('messageLog');
            const authMessage = document.getElementById('authMessage');
            const tokenInput = document.getElementById('tokenInput');
            const setAuthBtn = document.getElementById('setAuthBtn');
            const subscribeEventsBtn = document.getElementById('subscribeEventsBtn');
            const unsubscribeBtn = document.getElementById('unsubscribeBtn');
            const dateTimeInput = document.getElementById('dateTimeInput');
            const eventsDataDisplay = document.getElementById('eventsDataDisplay');
            const eventsData = document.getElementById('eventsData');
            
            // 订阅状态
            let isSubscribedEvents = false;
            let isSubscribedChart = false;
            
            // WebSocket变量
            let socket = null;
            let isAuthenticated = false;
            
            // 设置认证消息
            setAuthBtn.addEventListener('click', function() {
                const token = tokenInput.value.trim();
                if (token) {
                    authMessage.value = JSON.stringify({
                        type: "AUTH",
                        token: token
                    }, null, 2);
                } else {
                    alert('请输入token');
                }
            });
            
            // 订阅按钮事件
            subscribeEventsBtn.addEventListener('click', function() {
                if (!socket || socket.readyState !== WebSocket.OPEN) {
                    alert('WebSocket未连接');
                    return;
                }
                
                if (!isAuthenticated) {
                    alert('请先完成认证');
                    return;
                }
                
                const dateTime = dateTimeInput.value.trim() || new Date().toISOString().slice(0, 19).replace('T', ' ');
                const subscribeMsg = JSON.stringify({
                    type: "SUBSCRIBE",
                    topic: "OVERVIEW",
                    params: { dateTime: dateTime }
                });
                
                socket.send(subscribeMsg);
                logMessage('发送', subscribeMsg, 'outgoing');
                isSubscribedEvents = true;
                updateSubscribeButtons();
            });
            
            unsubscribeBtn.addEventListener('click', function() {
                if (!socket || socket.readyState !== WebSocket.OPEN) {
                    alert('WebSocket未连接');
                    return;
                }
                
                if (!isAuthenticated) {
                    alert('请先完成认证');
                    return;
                }
                
                if (isSubscribedEvents) {
                    const unsubscribeMsg = JSON.stringify({
                        type: "UNSUBSCRIBE",
                        topic: "OVERVIEW"
                    });
                    socket.send(unsubscribeMsg);
                    logMessage('发送', unsubscribeMsg, 'outgoing');
                    isSubscribedEvents = false;
                }
                
                if (isSubscribedChart) {
                    const unsubscribeMsg = JSON.stringify({
                        type: "UNSUBSCRIBE",
                        topic: "EVENTS_CHART"
                    });
                    socket.send(unsubscribeMsg);
                    logMessage('发送', unsubscribeMsg, 'outgoing');
                    isSubscribedChart = false;
                }
                
                updateSubscribeButtons();
            });
            
            // 连接WebSocket
            connectBtn.addEventListener('click', function() {
                const url = wsUrlInput.value.trim();
                if (!url) {
                    alert('请输入WebSocket URL');
                    return;
                }
                
                connectWebSocket(url);
            });
            
            // 断开连接
            disconnectBtn.addEventListener('click', function() {
                if (socket) {
                    socket.close();
                }
            });
            
            // 连接WebSocket函数
            function connectWebSocket(url) {
                // 如果已有连接,先关闭
                if (socket) {
                    socket.close();
                }
                
                // 重置订阅状态
                isSubscribedEvents = false;
                isSubscribedChart = false;
                updateSubscribeButtons();
                
                // 清空数据显示
                eventsData.textContent = '';
                
                // 更新UI状态
                updateStatus('连接中...', 'disconnected');
                connectBtn.disabled = true;
                disconnectBtn.disabled = false;
                
                // 创建WebSocket连接
                socket = new WebSocket(url);
                
                // 连接打开事件
                socket.onopen = function() {
                    updateStatus('已连接 (未认证)', 'connected');
                    logMessage('系统', 'WebSocket连接已建立', 'system');
                    
                    // 如果有认证消息,自动发送
                    if (authMessage.value.trim()) {
                        try {
                            const authMsg = JSON.parse(authMessage.value);
                            if (authMsg.type === "AUTH") {
                                socket.send(authMessage.value);
                                logMessage('发送', authMessage.value, 'outgoing');
                            }
                        } catch (e) {
                            logMessage('错误', '认证消息格式错误: ' + e.message, 'error');
                        }
                    }
                };
                
                // 收到消息事件
                socket.onmessage = function(event) {
                    try {
                        const data = JSON.parse(event.data);
                        logMessage('接收', JSON.stringify(data, null, 2), 'incoming');
                        
                        // 检查认证状态
                        if (data.status === "SUCCESS" && data.message === "认证成功") {
                            isAuthenticated = true;
                            updateStatus('已连接 (已认证)', 'connected');
                            updateSubscribeButtons();
                        }
                        
                        // 处理推送数据
                        if (data.topic === "OVERVIEW") {
                            // 概览数据
                            eventsData.textContent = JSON.stringify(data, null, 2);
                            eventsDataDisplay.style.display = 'block';
                        }
                    } catch (e) {
                        logMessage('接收', event.data, 'incoming');
                    }
                };
                
                // 错误事件
                socket.onerror = function(error) {
                    updateStatus('连接错误', 'disconnected');
                    logMessage('错误', 'WebSocket错误: ' + (error.message || '未知错误'), 'error');
                    connectBtn.disabled = false;
                    disconnectBtn.disabled = true;
                    updateSubscribeButtons();
                };
                
                // 连接关闭事件
                socket.onclose = function() {
                    updateStatus('已断开', 'disconnected');
                    logMessage('系统', 'WebSocket连接已关闭', 'system');
                    connectBtn.disabled = false;
                    disconnectBtn.disabled = true;
                    isAuthenticated = false;
                    socket = null;
                    updateSubscribeButtons();
                    
                    // 重置订阅状态
                    isSubscribedEvents = false;
                    isSubscribedChart = false;
                    
                    // 清空数据显示
                    eventsData.textContent = '';
                };
            }
            
            // 更新订阅按钮状态
            function updateSubscribeButtons() {
                if (!socket || socket.readyState !== WebSocket.OPEN || !isAuthenticated) {
                    subscribeEventsBtn.disabled = true;
                    unsubscribeBtn.disabled = true;
                    return;
                }
                
                subscribeEventsBtn.disabled = isSubscribedEvents;
                unsubscribeBtn.disabled = !(isSubscribedEvents || isSubscribedChart);
            }
            
            // 更新状态显示
            function updateStatus(text, className) {
                statusDiv.textContent = text;
                statusDiv.className = 'status ' + className;
            }
            
            // 记录消息到日志
            function logMessage(type, content, messageClass) {
                const now = new Date();
                const timestamp = now.toLocaleTimeString() + '.' + now.getMilliseconds().toString().padStart(3, '0');
                
                const messageDiv = document.createElement('div');
                messageDiv.className = 'message ' + messageClass;
                
                const headerDiv = document.createElement('div');
                headerDiv.innerHTML = `<strong>${type}</strong> <span class="timestamp">${timestamp}</span>`;
                
                const contentDiv = document.createElement('div');
                contentDiv.textContent = content;
                
                messageDiv.appendChild(headerDiv);
                messageDiv.appendChild(contentDiv);
                
                messageLog.appendChild(messageDiv);
                messageLog.scrollTop = messageLog.scrollHeight;
            }
        });
    </script>
</body>
</html>

下面是实际执行效果

目前存在的问题

分布式环境下websocket存在连接与服务器绑定问题

相关推荐
zizisuo2 小时前
为什么TCP设计中要设计ACK不重传?
网络·网络协议·tcp/ip
偶像你挑的噻2 小时前
Linux应用开发-17-套接字
linux·网络·stm32·嵌入式硬件
AI分享猿3 小时前
小白学规则编写:雷池 WAF 配置教程,用 Nginx 护住 WordPress 博客
java·网络·nginx
AORO20253 小时前
遨游科普:三防平板是指哪三防?有哪些应用场景?
大数据·网络·5g·智能手机·电脑·信息与通信
鸢尾掠地平3 小时前
DNS的正向、反向解析的服务配置知识点及实验
运维·服务器·网络
草莓熊Lotso3 小时前
C++ 方向 Web 自动化测试实战:以博客系统为例,从用例到报告全流程解析
前端·网络·c++·人工智能·后端·python·功能测试
GhostGuardian4 小时前
DNS报文结构全解析
网络·网络协议
宁雨桥4 小时前
WebSocket 完全指南:从原理到实战,搭建实时通信桥梁
网络·websocket·网络协议
xinxinhenmeihao4 小时前
爬虫导致IP被封号了如何解封?
爬虫·网络协议·tcp/ip