websocket还不会?不行,我必须教一教你!

154 阅读7分钟

前言

主管让我做一个实时推送的需求,我直接汗如雨下,赶快到网上搜索相关内容。

实时推送?那最基本的就是websocket啦!

本文系我个人看了十余篇博客整理而成,属于最基本的websocket讲解,适合没学过websocket的Javaer阅读。

丰富一下你的表情包叭🤪_7_佳佳子_来自小红书网页版.webp

什么是websocket,为什么使用webSocket

WebSocket 是 HTML5 开始提供的一种在单个 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 出现之前,如果我们想实现实时通信,比较常采用的方式是 Ajax 轮询,即在特定时间间隔(比如每秒)由浏览器发出请求,服务器返回最新的数据。这样子的轮询方式有什么缺陷呢?

  • HTTP 请求一般包含的头部信息比较多,其中有效的数据可能只占很小的一部分,导致带宽浪费;
  • 服务器被动接收浏览器的请求然后响应,数据没有更新时仍然要接收并处理请求,导致服务器 CPU 占用

WebSocket 的出现可以对应解决上述问题:

  • WebSocket 的头部信息少,通常只有 2Bytes 左右,能节省带宽;
  • WebSocket 支持服务端主动推送消息,更好地支持实时通信;

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

在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。

img

如何使用webSocket

websocket有两种方式:编程式;注解式。

本文会采用注解式,其他可参考文章www.zifangsky.cn/1355.html

简单描述

对于前端而言,websocket使用大体如下

<script>
    let ws = new WebSocket("ws://localhost/chat")
    //建立连接时使用
    ws.onopen = function(){
    };
    
    ws.onmessage = function(evt){
    //通过evt.data 可以获取到服务器发送的数据
    };
    
    //关闭时使用
    ws.onclose = function(){};
    
</script>

对于javaer而言,大体使用如下

@ServerEndpoint("/chat")
@Component
public class Endpoint{
    @OnOpen
    public void onOpen(Session session){
    }
    @OnMessage
    public void onMessage(String message){
​
    }
    @OnMesssage
    public void onclose(){
​
    }
}

是不是巨简单!

丰富一下你的表情包叭🤪_1_佳佳子_来自小红书网页版.webp

Javaer搭建

引入依赖

springboot包含了了相关集成

先引入父依赖

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.11</version>
    </parent>

然后是下面的

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

代码原型

有了依赖后,则开始拓展后端代码

添加Endpoint

使用 @ServerEndpoint 注解,将此类标为Endpoint,

什么是Endpoint?相当于将此类作为服务端的一端,value是表示当前和前端对应的是哪一个路径

使用@Component将此类交于IOC管理。

@ServerEndpoint("/chat")
@Component
public class Endpoint{
    @OnOpen
    public void onOpen(Session session){
        //连接时,会调用此方法
    }
    @OnMessage
    public void onMessage(String message){
        //发送消息时,会调用此方法
    }
    @OnMesssage
    public void onclose(){
        //关闭时,会调用此方法
    }
}

编写配置类

此配置类的作用是:扫描包含@ServerEndPoint注解的类

/**
 * 开启WebSocket支持
 */
@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
} 
​

拓展

拓展onOpen方法(无非是做一些初始化操作,以及连接时的处理)。

所以我们目前要做的是,

  1. 添加一个map,用来存储websocketSession的对应关系。将session和对应的客户端进行绑定,这样的在多个客户端发起请求时,我们就知道是哪一个客户端进行处理。
  2. 添加一个字段标识当前连接数。
 private static final ConcurrentHashMap<String, Session> manager = new ConcurrentHashMap<String, Session>();//key是客户端唯一标识;value是sessionprivate static final Integer onlineCount = 0;

唯一标识(可不用添加)

那么key可以是什么?可以是用户的token或者用户名等。

那么如果是token,该如何获取?

可以新建一个配置类,并且在ServerEndpoint注解中引入对应的配置类

配置类如下

/**
 * @description: 获取session信息
 * @author: hjw
 * @create: 2023-11-19 16:53
 **/
public class GetHttpSessionConfigurator extends ServerEndpointConfig.Configurator{
    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
        HttpSession httpSession = (HttpSession)request.getHttpSession();
        //在握手时把httpSession放入配置中,当onOpen时,即可调用放进去的信息
        sec.getUserProperties().put(HttpSession.class.getName(), httpSession);
        super.modifyHandshake(sec, request, response);
    }
}

所以可以将建立连接和断开连接时进行处理

其中@ServerEndpoint注解上要加上指定的配置类,这样此Endpoint在建立连接时就会调用指定的配置类

@ServerEndpoint(value = "/chat", configurator = GetHttpSessionConfigurator.class)
@Component
public class Endpoint{
    private static ConcurrentHashMap<String, Session> manager = new ConcurrentHashMap<String, Session>();
    private HttpSession httpSession;
    private static final Integer onlineCount = 0;
    @OnOpen
    public void onOpen(Session session, EndpointConfig config){
        //连接时,会调用此方法
        this.httpSession =(HttpSession) config.getUserProperties().get(HttpSession.class.getName());
        String token = (String) this.httpSession.getAttribute("token");
        manager.put(token, session);
    }
    @OnMessage
    public void onMessage(String message){
        //发送消息时,会调用此方法
    }
    @OnClose
    public void onclose(){
        //关闭时,会调用此方法
        String token = (String)this.httpSession.getAttribute("token");
        manager.remove(token);
    }
}

添加发送逻辑

可以在此类中加入一些处理逻辑,比如发给谁,单发还是群发。

**/****
 * @description: 聊天webSocket
 * @author: hjw
 * @create: 2023-11-19 16:51
 **/
@ServerEndpoint(value = "/chat", configurator = GetHttpSessionConfigurator.class)
@Component
public class Endpoint {
    private static ConcurrentHashMap<String, Session> manager = new ConcurrentHashMap<String, Session>();
    private static Integer onlineCount;
    private HttpSession httpSession;
​
​
    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        //连接时,会调用此方法
        this.httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
        String token = (String) this.httpSession.getAttribute("token");
        manager.put(token, session);
        addOnlineCount();
    }
​
    @OnClose
    public void onclose() {
        //关闭时,会调用此方法
        String token = (String) this.httpSession.getAttribute("token");
        manager.remove(token);
        subOnlineCount();
    }
​
​
    /**
     * 收到客户端消息后调用的方法
     *
     * @param message 客户端发送过来的消息*/
    @OnMessage
    public void onMessage(String message, Session session) throws IOException {
        //群发消息
        for (Session value : manager.values()) {
            session.getBasicRemote().sendText("你好呀");
        }
    }
​
    /**
     *
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        error.printStackTrace();
        subOnlineCount();
    }
    
    /**
     * 实现服务器主动推送
     */
    public static void sendMessage(Session session, String message) throws IOException {
        session.getBasicRemote().sendText(message);
    }
​
​
    /**
     * 群发自定义消息
     * */
    public static void sendInfo(String message,@PathParam("token") String token) throws IOException {
​
        manager.forEach((k,v) ->{
            try {
                //这里可以设定只推送给这个sid的,为null则全部推送
                if(token==null) {
                    sendMessage(v, message);
                }else if(k.equals(token)){
                    sendMessage(v, message);
                }
            } catch (IOException e) {
                e.getStackTrace();
            }
        });
​
    }
​
    public static synchronized int getOnlineCount() {
        return onlineCount;
    }
​
    public static synchronized void addOnlineCount() {
        endpoint.onlineCount++;
    }
​
    public static synchronized void subOnlineCount() {
        endpoint.onlineCount--;
    }
}
​

测试

由于没有做登录页面

所以需要将上述的token获取都注释掉,使用静态的key

//注释掉获取token
    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        //连接时,会调用此方法
        //this.httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
       // String token = (String) this.httpSession.getAttribute("token");
        manager.put("token", session);
        addOnlineCount();
    }
//注释掉获取token
public class GetHttpSessionConfigurator extends ServerEndpointConfig.Configurator{
    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
//        HttpSession httpSession = (HttpSession)request.getHttpSession();
//        //在握手时把httpSession放入配置中,当onOpen时,即可调用放进去的信息
//        sec.getUserProperties().put("token", httpSession);
        super.modifyHandshake(sec, request, response);
    }
}

测试的前端页面采用在线测试网站websocket.jsonin.com/

测试方式

  1. 填写Endpoint路径
  2. 点击连接,右方提示已连接则连接成功
  3. 点击发送,测试onMessage

image-20231119184246110.png

业务代码测试

测试的controller代码

@RestController
@RequestMapping("/test")
public class TestController {
    @Autowired
    private Endpoint endpoint;
    @RequestMapping(value = "/sendUser",method = RequestMethod.POST)
    public void sendUser(String token) throws IOException {
        //业务代码。。。 用token获取到session,然后发送消息
        Session session = Endpoint.manager.get("token");
        endpoint.sendMessage(session, "业务执行完毕,我要推送一些东西给你");
    }
    //使用测试
}

使用postman等工具,向testController发起请求

image-20231119182448830.png 可以在测试网站上,看到testController发起请求后,推送的内容。

image-20231119184306429.png

Session共享问题

如果项目是分布式环境,登录的用户被Nginx的反向代理分配到多个不同服务器,那么在其中一个服务器建立了WebSocket连接的用户如何给在另外一个服务器上建立了WebSocket连接的用户发送消息呢

使用redis替代map来存储session

可参考文章:juejin.cn/post/684490…

结束!

参考

juejin.cn/post/684490…

juejin.cn/post/684490…

juejin.cn/post/699575…

juejin.cn/post/723220…

juejin.cn/post/708602…