spring boot websocket 实现

268 阅读4分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第18天,点击查看活动详情

前言

一般在java开发中如果需要使用长连接,比较成熟的方案是加入netty框架,来实现这个连接功能。但是对于一般的小项目,可能只有一两个点需要长连接,对性能有没有较高的要求,那么可以使用最原始的websocket,用原生的方式来实现这个功能。在spring中为我们提供了一个websocket的实现方法。

环境搭建

首先我们需要引入依赖

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

这个是websocket所需要的依赖。 然后我们需要创建一个配置类

@Configuration
public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
}

这个是为了暴露一个websocket的服务器端点。

@Slf4j
@Component
@ServerEndpoint(value = "/websocket/{userId}")
public class SelfWebSocketServer 

这里我们创建一个组件用于处理我们的websocket业务。@ServerEndpoint注解中声明了url参数用于暴露提供给前端的websocket地址。相当把当前的这个类定义为一个websocket服务器,客户端可以通过ws协议和它进行连接。

服务端业务处理

@Slf4j
@Component
@ServerEndpoint(value = "/websocket/{userId}")
public class SelfWebSocketServer {


    private static int onlineCount = 0;

    private static Map<String, Session> sessionMap = new ConcurrentHashMap<>();

    private Session session;

    private Long userId;

    private static final String PING = "ping";

    private static final String PONG = "pong";


    public static synchronized Integer getOnlineCount(){
        return SelfWebSocketServer.onlineCount;
    }

    private static synchronized void addOnlineCount(){
        SelfWebSocketServer.onlineCount++;
    }

    private static synchronized void subOnlineCount(){
        SelfWebSocketServer.onlineCount--;
    }

    @OnOpen
    public void onOpen(Session session,@PathParam("userId") Long userId){
        this.session = session;
        this.userId = userId;
        String user = String.valueOf(userId);
        if (sessionMap.containsKey(user)){
            sessionMap.put(user,session);
        }else {
            sessionMap.put(user,session);
            addOnlineCount();
        }
        log.info("socket add userId:[{}],online:[{}]",userId,onlineCount);
        //sendUserMessage("success", Collections.singletonList(userId));
    }

    @OnClose
    public void onClose(@PathParam("userId") Long userId){
        String user = String.valueOf(userId);
        if(sessionMap.containsKey(user)) {
            sessionMap.remove(user);
            subOnlineCount();
            log.info("socket remove userId:[{}],online:[{}]",user,onlineCount);
        }
    }

    @OnMessage
    public void onMessage(String message,@PathParam("userId")Long userId){
        String user = String.valueOf(userId);
        boolean containsKey = sessionMap.containsKey(user);
        if (containsKey){
            sendUserMessage(message.equals(PING)?PONG:message, Collections.singletonList(userId));
        }
    }


    public void sendUserMessage(String message, List<Long> userIdList){
        for (Long userId : userIdList) {
            String user = String.valueOf(userId);
            if (null != user && sessionMap.containsKey(user)){
                Session session = sessionMap.get(user);
                this.sendMessage(message,session);
                log.info("send websocket message,userId[{}],message[{}]",user,message);
            }else {
                log.warn("userId or websocket session null,user:[{}]",user);
            }
        }
    }

    public void sendMessage(String message, Session session){
        session.getAsyncRemote().sendText(message);
    }

    @OnError
    public void onError(Session session,Throwable throwable){
        log.error("websocket error, userId {[]},error,{[]}",this.userId,throwable.getMessage());
        throwable.printStackTrace();
    }

首先我们看到有四个注解分别是@OnOpen、@OnClose、@OnMessage、@OnError。 第一个OnOpen是用于websocket连接建立时候触发的,所有接入的连接我们都是用session来进行管理。然后我们用户每个用户的唯一标识id来和session绑定,那我们就可以通过这个id从存储中获取到session。

我们在这个建立连接的方法中添加这样的逻辑。使用一个map来管理会话,使用一个静态变量来统计在线人数。当会话中没有包含当前连接用户时,保存session,同时增加在线人数。如果会话中已经存在,则更新会话。

第二个OnClose是连接关闭时触发的,当我们的会话中包含这个用户时,移除它的session,同时减少在线人数。

第三个OnMessage是我们服务端和客户端通信的接口,主要是接受到客户端的消息,并且做出应答。因为如果服务端和客户端长时间没有数据交互,连接会关闭,因为一方会以为另一方已经挂掉了,所以断开连接。那么比较通用的做法是保持心跳连接,即在没有业务数据通信时,传输一些简单的标志保持心跳,也叫保活。 我这边定义是如果客户端发送了“ping”那么我就回复“pong”,如果是别的数据则按具体的数据做处理。

第四个是OnError,这个是连接异常的时候触发的,我们这里简单的打印错误信息即可。

客户端代码

这里还需要我们的客户端代码才可以完成一整个链路的调试。我们的客户端代码主要实现

  1. 能够和服务端连接通讯
  2. 能够做到心跳保持
  3. 能够做的断线重连
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>websocket</title>
  </head>
  <body>
    <div>
      <input id="userId" name="userId" type="text" placeholder="userId" />
    </div>
    <div>
      <button onclick="openSocket()">开启socket</button>
    </div>
    <div>
      <input id="content" />
      <button onclick="sendMsg()">发送消息</button>
    </div>
  </body>
  <script src="https://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js"></script>
  <script>
    var socket;
    const socketUrl = "ws://127.0.0.1:8082/web/websocket";
    var heartBeatTime = 10;
    var heartCheck = {
      timeout: heartBeatTime * 1000,
      timeoutObjnull,
      serverTimeoutObjnull,
      resetfunction () {
        clearTimeout(this.timeoutObj);
        clearTimeout(this.serverTimeoutObj);
        return this;
      },
      startfunction () {
        var self = this;
        this.timeoutObj = setTimeout(function () {
          socket.send("ping");
          self.serverTimeoutObj = setTimeout(function () {
            socket.close();
          }, this.timeout);
        }, this.timeout);
      },
    };
    function openSocket() {
      if (typeof WebSocket == "undefined") {
        console.log("不支持websocket");
      } else {
        console.log("支持websocket");
        var userId = document.getElementById("userId").value;
        var webSocketUrl = socketUrl + "/" + userId;
        console.log(socketUrl);
        socket = new WebSocket(webSocketUrl);
        initEventHandle();
      }
    }
    function sendMsg() {
      if (typeof WebSocket == "undefined") {
        console.log("不支持websocket");
      } else {
        console.log("支持websocket");
        var userId = document.getElementById("userId").value;
        var content = document.getElementById("content").value;
        var webSocketUrl = socketUrl + "/" + userId;
        console.log(socketUrl);
        socket.send(content);
      }
    }
    function initEventHandle() {
      socket.onclose = function () {
        console.log("websocket已断开");
        reconnect(socketUrl);
        console.log("websocket断线重连");
      };
      socket.onerror = function (err) {
        console.log(
          "websocket发生异常:" +
            err.code +
            " " +
            err.reason +
            " " +
            err.wasClean
        );
        reconnect(socketUrl);
        console.log("websocket断线重连");
      };
      socket.onopen = function () {
        socket.send("ping");
        heartCheck.reset().start();
      };
      socket.onmessage = function (msg) {
        console.log("接收到信息:" + msg);
        heartCheck.reset().start();
      };
    }
    function reconnect(socketUrl) {
      if (reconnect.lockReconnectreturn;
      reconnect.lockReconnect = true;
      setTimeout(function () {
        openSocket();
        reconnect.lockReconnect = false;
      }, 3000);
    }
  </script>
</html>

简单的介绍一下上面的代码。定义了心跳时间时10s。 首先用户输入id,客户端建立一个ws协议的连接,初始化连接的监听。分别对监听事件做出处理。 比如这里监听了默认的几个事件。从上往下是:

关闭事件,当客户端监听到连接关闭,就开始执行断线重连的方法。只要连接没有成功,就每隔3s尝试建立一次连接。

异常事件,如果连接通信中抛出了异常,那么也调用断线重连的方法。

打开事件和服务端建立了连接,首先向服务端发送一条消息,然后开始心跳机制。首先重置setTimeout方法的计时时间,然后每隔3s发送一个心跳包。这里是因为setTimeout只能执行一次,执行完之后定时器的计时已经达到了约定的时间,如果要重复调用方法需要使用clearTimeout方法来重置定时器,或者不使用setTimeout改用setInterval方法

消息事件,接受到服务端的消息,也开始心跳发送心跳包。

image.png

可以看到建立连接之后发送了心跳包。

2022-04-20 23:46:42.598  INFO 12152 --- [nio-8082-exec-7] c.z.s.websocket.SelfWebSocketServer      : send websocket message,userId[1],message[pong]

服务端接受了心跳包。

2022-04-20 23:47:24.111  INFO 12152 --- [io-8082-exec-10] c.z.s.websocket.SelfWebSocketServer      : send websocket message,userId[1],message[hello]

发送消息,服务端可以接受到客户端的消息

image.png

断线后进行了重连

image.png

以上就是spring boot websocket简单又快速的实现了,非常适合小型项目的开发。