Skip to content

springboot中websocket的使用

1. websocket是什么

WebSocket是一种网络传输协议,可在单个TCP连接上进行全双工通信,位于OSI模型应用层。WebSocket协议在2011年由IETF标准化为RFC 6455,后由RFC 7936补充规范。Web IDL中的WebSocket API由W3C标准化。

WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就可以建立持久性的连接,并进行双向数据传输。

websocket和socket一样吗

socket是什么,"Socket" 在计算机网络中是指一种端点,允许两个程序通过网络进行通信。具体来说,socket 是网络通信的基础,它可以在同一台计算机上运行的两个程序之间,也可以在不同计算机上运行的程序之间传输数据。

而websocket是用来让服务器和浏览器进行全双工通信的一个协议,他是通过http升级上来的协议。

所以就如同java 、 JavaScript一样,完全不是一回事。

2. 协议结构解析

WebSocket 是独立的、建立在TCP上的协议。 Websocket 通过 HTTP/1.1 协议的101状态码进行握手。

为了建立Websocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(Handshaking)。

1建立连接

一个典型的Websocket握手请求如下

客户端请求

shell
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服务端回应

shell
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

字段说明

  • Connection必须设置Upgrade,表示客户端希望连接升级。
  • Upgrade字段必须设置Websocket,表示希望升级到Websocket协议。
  • Sec-WebSocket-Key是随机的字符串,服务器端会用这些数据来构造出一个SHA-1的信息摘要。把“Sec-WebSocket-Key”加上一个特殊字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后计算SHA-1摘要,之后进行Base64编码,将结果做为“Sec-WebSocket-Accept”头的值,返回给客户端。如此操作,可以尽量避免普通HTTP请求被误认为Websocket协议。
  • Sec-WebSocket-Version 表示支持的Websocket版本。RFC6455要求使用的版本是13,之前草案的版本均应当弃用。
  • Origin字段是必须的。如果缺少origin字段,WebSocket服务器需要回复HTTP 403 状态码(禁止访问)。
  • 其他一些定义在HTTP协议中的字段,如Cookie等,也可以在Websocket中使用。

2 数据帧

在握手成功后,客户端和服务器可以开始交换数据,这些数据被封装在 WebSocket 帧中。每个 WebSocket 帧由以下几个部分组成:

  • 帧头 (Frame Header)

    • FIN (1 bit): 标志是否是消息的最后一个帧。
    • RSV1, RSV2, RSV3 (各 1 bit): 保留位,通常为 0,除非协商了扩展。
    • Opcode (4 bits): 指示帧的类型(例如文本帧、二进制帧、关闭连接、Ping、Pong)。
    • Mask (1 bit): 表示是否掩码(客户端发送的消息需要掩码)。
    • Payload length (7、7+16、7+64 bits): 负载数据的长度。
    • Masking Key (32 bits): 如果 Mask 位为 1,则包含一个掩码键,用于掩码数据。
  • 有效载荷 (Payload Data)

    • 实际要传输的数据,可能是文本数据、二进制数据、控制帧等。

数据帧结构示例:

  • 文本帧 (Text Frame): 用于传输文本数据。
mathematica
FIN | RSV1 | RSV2 | RSV3 | Opcode | Mask | Payload length | Masking Key | Payload Data
  • 二进制帧 (Binary Frame): 用于传输二进制数据。
mathematica
FIN | RSV1 | RSV2 | RSV3 | Opcode | Mask | Payload length | Masking Key | Payload Data
  • 控制帧 (Control Frames)
    • 关闭连接 (Close Frame):用于关闭连接。
    • Ping/Pong 帧:用于连接的心跳检测。
mathematica
FIN | RSV1 | RSV2 | RSV3 | Opcode | Mask | Payload length | Masking Key | Payload Data

3. 消息分片 (Message Fragmentation)

  • WebSocket 支持将一条消息分片成多个帧,以适应大型消息传输。
  • 第一帧包含 FIN 位设置为 0 和适当的 Opcode
  • 中间帧的 Opcode 设置为 0x0(继续帧)。
  • 最后一帧的 FIN 位设置为 1

4. 掩码 (Masking)

  • 由于 WebSocket 设计初衷,所有从客户端到服务器的消息必须使用掩码(即 Mask 位设置为 1)。
  • 掩码键用于掩码和解码消息数据。

5. 控制帧 (Control Frames)

  • 关闭帧 (Close Frame):用于优雅地关闭 WebSocket 连接。
  • Ping 帧:服务器或客户端发送 Ping 帧作为心跳检测。
  • Pong 帧:响应 Ping 帧,确认连接活跃。

6. 连接关闭 (Connection Close)

  • 任何一方可以通过发送关闭帧来关闭 WebSocket 连接。
  • 关闭帧可以包含关闭状态码和关闭原因。

示例:

js
socket.onclose = (event) => {
    console.log('WebSocket connection closed:', event);
    if (event.code === 1000) {
        console.log('Normal closure');
    } else {
        console.error('Abnormal closure, code:', event.code);
    }
};

总结

通过这些协议和机制,WebSocket 提供了一种高效的双向通信方式,适用于需要频繁和低延迟数据交换的应用场景。理解这些协议和数据交换机制有助于你更好地设计和实现 WebSocket 应用。

3. springboot下服务端实现

依赖引入

xml
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-websocket</artifactId>  
    <version>3.3.0</version>  
</dependency>

配置config

java
package com.zhaoyubin.learnspringboot.config;  
  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.web.servlet.FilterRegistrationBean;  
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.config.annotation.WebSocketConfigurer;  
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;  
import org.springframework.web.socket.server.standard.ServerEndpointExporter;  
import org.springframework.web.filter.CharacterEncodingFilter;  
  
@Configuration  
@EnableWebSocket  
public class WebSocketConfig implements WebSocketConfigurer {  
  
    @Autowired  
    private HttpAuthHandler2 httpAuthHandler;  
    @Autowired  
    private  WebSocketIntercepter webSocketIntercepter;  
  
    /**  
     * 配置字符编码  
     * @return  
     */  
    @Bean  
    public CharacterEncodingFilter characterEncodingFilter() {  
        CharacterEncodingFilter filter = new CharacterEncodingFilter();  
        filter.setEncoding("UTF-8");  
        filter.setForceEncoding(true);  
        return filter;  
    }  
  
    /**  
     * 这个配置是通过controller的类添加Endpoint注解实现的,这个是标准的使用websocket的方式,  两种方式可以同时存在,
     * @return  
     */  
    @Bean  
    public ServerEndpointExporter serverEndpointExporter() {  
        return new ServerEndpointExporter();  
    }  
  
    /**  
     * 注册 WebSocket 端点并指定其路径和允许的跨域请求。  
     * @param registry  
     */  
    @Override  
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {  
        registry.addHandler(httpAuthHandler,"/socket/*")  
                .addInterceptors(webSocketIntercepter)          //添加拦截器以处理握手请求和握手响应。  
                .setAllowedOrigins("*");  
    }  
}

实现HandshakeInterceptor

HandshakeInterceptor实现对http握手的处理,包括握手前和握手后,这里可以解析自定义header。

java
package com.zhaoyubin.learnspringboot.config;  
  
import org.apache.logging.log4j.util.Strings;  
import org.springframework.http.server.ServerHttpRequest;  
import org.springframework.http.server.ServerHttpResponse;  
import org.springframework.stereotype.Component;  
import org.springframework.web.socket.WebSocketHandler;  
import org.springframework.web.socket.server.HandshakeInterceptor;  
  
import java.util.Map;  
  
@Component  
public class WebSocketIntercepter  implements HandshakeInterceptor {  
    @Override  
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {  
        System.out.println("握手开始");  
        String hostName = request.getRemoteAddress().getHostName();  
        String sessionId = hostName+String.valueOf((int)(Math.random()*1000));  
        if (Strings.isNotBlank(sessionId)) {  
            // 放入属性域  
            attributes.put("session_id", sessionId);  
            System.out.println("用户 session_id " + sessionId + " 握手成功!");  
            return true;  
        }  
        System.out.println("用户登录已失效");  
        return false;  
    }  
  
    @Override  
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {  
        System.out.println("握手完成");  
    }  
}

实现TextWebSocketHandler,消息处理器

通过springboot的方式配置的,这个处理器在websocketconfig文件中配置生效。区别于传统的通过@ServerEndpoint注解的使用,但是这两种可以同时配置。

java
package com.zhaoyubin.learnspringboot.config;  
  
import org.springframework.stereotype.Component;  
import org.springframework.web.socket.CloseStatus;  
import org.springframework.web.socket.TextMessage;  
import org.springframework.web.socket.WebSocketSession;  
import org.springframework.web.socket.handler.TextWebSocketHandler;  
  
import java.time.LocalDateTime;  
  
@Component  
public class HttpAuthHandler extends TextWebSocketHandler {  
    /**  
     * socket 建立成功事件  
     *  
     * @param session  
     * @throws Exception  
     */    @Override  
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {  
        Object sessionId = session.getAttributes().get("session_id");  
        if (sessionId != null) {  
            // 用户连接成功,放入在线用户缓存  
            WsSessionManager.add(sessionId.toString(), session);  
        } else {  
            throw new RuntimeException("用户登录已经失效!");  
        }  
    }  
  
    /**  
     * 接收消息事件  
     *  
     * @param session  
     * @param message  
     * @throws Exception  
     */    @Override  
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {  
        // 获得客户端传来的消息  
        String payload = message.getPayload();  
        Object sessionId = session.getAttributes().get("session_id");  
        System.out.println("server 接收到 " + sessionId + " 发送的 " + payload);  
        session.sendMessage(new TextMessage("server 发送给 " + sessionId + " 消息 " + payload + " " + LocalDateTime.now().toString()));  
    }  
  
    /**  
     * socket 断开连接时  
     *  
     * @param session  
     * @param status  
     * @throws Exception  
     */    @Override  
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {  
        Object sessionId = session.getAttributes().get("session_id");  
        if (sessionId != null) {  
            // 用户退出,移除缓存  
            WsSessionManager.remove(sessionId.toString());  
        }  
    }  
  
}

传统的通过@ServerEndpoint的方式

通过ServerEndpoint注解的方式就和一个普通的Controller的使用方法一样。指定一个路径("/socket/test/{params}") ,前端通过js调用就可以访问了。

java
package com.zhaoyubin.learnspringboot.controller;  
  
  
import jakarta.websocket.OnClose;  
import jakarta.websocket.OnMessage;  
import jakarta.websocket.OnOpen;  
import jakarta.websocket.Session;  
import jakarta.websocket.server.PathParam;  
import jakarta.websocket.server.ServerEndpoint;  
import org.springframework.stereotype.Component;  
  
import java.util.ArrayList;  
import java.util.Map;  
import java.util.concurrent.ConcurrentHashMap;  
  
@Component  
@ServerEndpoint("/socket/test/{params}")  
public class SocketController {  
  
    //把session保存起来,方便针对不同的客户端发送消息  
    private static ConcurrentHashMap<String, ArrayList<Session>> map = new ConcurrentHashMap();  
  
    private static Session session;  
  
    @OnOpen  
    public void onOpen(Session session, @PathParam("params") String params) {  
  
        System.out.println("params = " + params);  
        System.out.println("open");  
        System.out.println(session.getId());  
        System.out.println(session.toString());  
        this.session = session;  
        ArrayList<Session> sessions = map.get(session.getId());  
        if (sessions == null) {  
            sessions = new ArrayList<>();  
        }  
        sessions.add(session);  
        map.put(params, sessions);  
    }  
  
  
    @OnClose  
    public void onClose(Session session) {  
  
        System.out.println("close");  
        System.out.println(session.toString());  
    }  
  
    @OnMessage  
    public void onMessage(String message) {  
        System.out.println(message);  
    }  
  
    /**  
     * 给所有的客户端发送消息  
     * @param message  
     */  
    public static void sendMessage(String message) {  
        session.getAsyncRemote().sendText(message);  
    }  
  
  
    /**  
     * 给指定的客户端发送消息  
     * @param key  
     * @param message  
     */  
    public static void sendMessage(String key,String message) {  
        ArrayList<Session> sessions = map.get(key);  
        if (sessions != null) {  
            for (Session session : sessions) {  
                session.getAsyncRemote().sendText(message);  
            }  
        }  
    }  
}

js客户端调用

WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。

使用 WebSocket() 构造函数来构造一个 WebSocket

构造函数

WebSocket(url[, protocols]) 返回一个 WebSocket 对象。 语法:

var aWebSocket = new WebSocket(url [, protocols]);

参数 url 要连接的 URL;这应该是 WebSocket 服务器将响应的 URL。

protocols 可选 一个协议字符串或者一个包含协议字符串的数组。这些字符串用于指定子协议,这样单个服务器可以实现多个 WebSocket 子协议(例如,你可能希望一台服务器能够根据指定的协议(protocol)处理不同类型的交互)。如果不指定协议字符串,则假定为空字符串。

常量

这些常量是websocket的状态的表示。 可以通过WebSocket中readyState属性来获取,每次发送消息前确认连接是打开的很重要。

ConstantValue
WebSocket.CONNECTING0
WebSocket.OPEN1
WebSocket.CLOSING2
WebSocket.CLOSED3

如何确认连接是打开的呢? 用法:

javascript
function sendMessage(content) {
    const message = {
        content: content,
        timestamp: new Date()
    };
    if (socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify(message));
    } else {
        pendingMessages.push(message);
        if (socket.readyState === WebSocket.CONNECTING) {
            console.log('WebSocket is connecting, message queued');
        } else {
            console.log('WebSocket is not open, message queued');
        }
    }
}

属性

WebSocket.binaryType 使用二进制的数据类型连接。

WebSocket.bufferedAmount 只读 未发送至服务器的字节数。

WebSocket.extensions 只读 服务器选择的扩展。

WebSocket.onclose 用于指定连接关闭后的回调函数。

WebSocket.onerror 用于指定连接失败后的回调函数。

WebSocket.onmessage 用于指定当从服务器接受到信息时的回调函数。

WebSocket.onopen 用于指定连接成功后的回调函数。

WebSocket.protocol 只读 服务器选择的下属协议。

WebSocket.readyState 只读 当前的链接状态。

WebSocket.url 只读 WebSocket 的绝对路径。

方法

WebSocket.close([code[, reason]]) 关闭当前链接。

WebSocket.send(data) 对要传输的数据进行排队。

事件

使用 addEventListener() 或将一个事件监听器赋值给本接口的 oneventname 属性,来监听下面的事件。

一个有四个事件:

  • close 当一个 WebSocket 连接被关闭时触发。 也可以通过 onclose 属性来设置。
  • error 当一个 WebSocket 连接因错误而关闭时触发,例如无法发送数据时。 也可以通过 onerror 属性来设置
  • message 当通过 WebSocket 收到数据时触发。 也可以通过 onmessage 属性来设置。
  • open 当一个 WebSocket 连接成功时触发。 也可以通过 onopen 属性来设置。

示例

js
// Create WebSocket connection.
const socket = new WebSocket("ws://localhost:8080");

// Connection opened
socket.addEventListener("open", function (event) {
  socket.send("Hello Server!");
});

// Listen for messages
socket.addEventListener("message", function (event) {
  console.log("Message from server ", event.data);
});

通过 addEventListener 设置事件和 onopen、onmessage、onclose、onerror的区别

拿onopen这个属性来说明:

onopen 是 WebSocket 对象的一个属性。你可以直接将一个函数赋值给这个属性来处理 WebSocket 连接打开时的事件。

写法

js
var exampleSocket = new WebSocket("ws://example.com/socket");

exampleSocket.onopen = function(event) {
  console.log("Connection opened!");
};
exampleSocket.onmessage = function (event) {
    console.log(event.data);
};
exampleSocket.onclose = function(event) {
  console.log("WebSocket is closed now.");
};
exampleSocket.onerror = function(event) {
  console.log("WebSocket is closed now.");
};

区别

  1. 多事件监听

    • onopen: 只能有一个事件处理函数。后赋值的函数会覆盖先前赋值的函数。
    • addEventListener: 可以添加多个事件处理函数,所有添加的处理函数都会被调用,不会相互覆盖。
  2. 一致性

    • addEventListener 是 DOM 事件处理的标准方法,适用于所有支持事件的对象(如 DOM 元素、WebSocket、XMLHttpRequest 等)。使用 addEventListener 可以使你的代码在处理事件时保持一致。

websocket 传递参数

通过 url

方法一: 通过@PathParam注解,js传参类似于restful风格 @ServerEndpoint("/socket/test/{params}")

@OnOpen
public void onOpen(Session session, @PathParam("params") String params) {

方法二: 此时就可以不用@PathParma参数了

前端请求携带参数

js
var exampleSocket = new WebSocket("ws://localhost:8080/socket/test?parmas=id001");

后台解析参数通过session

java
Map<String, String> pathParameters = session.getPathParameters();  
String params = pathParameters.get("params");

通过发送json

通过设置header

原生websocket存在的问题

  1. 不支持自动重连需要自己实现或者通过现有的库

通过websocket实现一个实时聊天系统

所涉及到的问题点

  • 如何存储对离线的人发送的消息。
  • 如何保证消息不丢失
  • 如何做到断线自动重连
  • 如何确认消息发送成功
  • 用户上线时如何接受到离线时的消息

参考

  1. ·https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket

最后更新于: