1、为什么使用websocket

前端和后端的交互模式最常见的就是前端发数据请求,从后端拿到数据后展示到页面中。如果前端不做操作,后端不能主动向前端推送数据,这恰恰就是http协议的缺陷。但在我们平常开发中,常遇到客户端需要实时获取服务端信息,做到客户端与服务端互通有无,通过http协议实现(轮询)存在一定延时性,且会造成资源的很大浪费,websocket却能完美实现。恰巧最近有项目需求,就做了一定研究,特此记录。

2、简介

websocket是一种在单个TCP连接上进行全双工通信的协议,该协议兼容我们常用的浏览器。例如Chrome、 Firefox、IE等。它可以使客户端和服务端双向数据传输更加简单快捷,并且在TCP连接进行一次握手后,就可以持久性连接,同时允许服务端对客户端推送数据。外加传统模式的协议一般HTTP请求可能会包含较长的头部,但真正有效的可能只有小部分,从而就占用了很多资源和带宽。因此WebSocket协议不仅可以实时通讯,支持扩展;也可以压缩节省服务器资源和带宽。 WS协议和WSS协议两个均是WebSocket协议的SCHEM,两者一个是非安全的,一个是安全的。也是统一的资源标志符。就好比HTTP协议和HTTPS协议的差别。非安全的没有证书,安全的需要SSL证书。(SSL是Netscape所研发,用来保障网络中数据传输的安全性,主要是运用数据加密的技术,能够避免数据在传输过程被不被窃取或者监听。)其中WSS表示在TLS之上的WebSocket。WS一般默认是80端口,而WSS默认是443端口,大多数网站用的就是80和433端口。

3、特点

(1)建立在 TCP 协议之上,服务器端的实现比较容易。

(2)与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

(3)数据格式比较轻量,性能开销小,通信高效。

(4)可以发送文本,也可以发送二进制数据。

(5)没有同源限制,客户端可以与任意服务器通信。

(6)协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。

4、与http的关系

(1)相同点

都是一样基于TCP的,都是可靠性传输协议;都是应用层协议。

(2)不同点

WebSocket是双向通信协议,模拟Socket协议,可以双向发送或接受信息,HTTP是单向的;WebSocket是需要浏览器和服务器握手进行建立连接的,而http是浏览器发起向服务器的连接,服务器预先并不知道这个连接。

5、代码实现

(1)服务端

@ServerEndpoint("/websocket/sendMessage")
public class WebSocketUtil {

    private static Logger logger = Logger.getLogger(WebSocketUtil.class);

    /**
     * 静态变量,用来记录当前在线连接数。
     */

    private static volatile int onlineCount = 0;
    /**
     * concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
     */
    private static CopyOnWriteArraySet<WebSocketUtil> webSocketSet = new CopyOnWriteArraySet<WebSocketUtil>();
    /**
     * 与某个客户端的连接会话,需要通过它来给客户端发送数据
     */

    private Session session;
    /**
     * 用户编码
     */

    private String userCode;

    /**
     * 连接建立成功调用的方法
     *
     * @param session 可选的参数。session为与某个客户端的连接会话,需要通过它来给客户端发送数据
     */

    @OnOpen
    public void onOpen(Session session) {
        String userCode = (String) session.getQueryString();
        this.session = session;
        this.userCode = userCode;
        //加入set中
        webSocketSet.add(this);
        //在线数加1
        addOnlineCount();
        logger.info("有新连接加入" + userCode + "!当前在线客户端数为" + getOnlineCount());
    }

    /**
     * 连接关闭调用的方法
     */

    @OnClose
    public void onClose() {
        // 从set中删除
        webSocketSet.remove(this);
        // 在线数减1
        subOnlineCount();
        logger.info("有一连接关闭!当前在线客户端数为" + getOnlineCount());
    }

    /**
     * 接收消息消息
     *
     * @param txt     接受服务端消息体内容,必填字段(msg:消息内容,type:消息类型),选填字段(to:需发送的用户,默认像所有人发送)
     * @param session
     */

    @OnMessage
    public void onMessage(String txt, Session session) {
        // 向客户端发送消息
        JSONObject jsonTo = JSONObject.fromObject(txt);
        String to = jsonTo.containsKey("to") ? jsonTo.getString("to") : "All";
        String content = jsonTo.containsKey("content") ? jsonTo.getString("content") : "";
        String type = jsonTo.containsKey("type") ? jsonTo.getString("type") : null;
        WebSocketMsgVo vo = new WebSocketMsgVo();
        vo.setContent(content);
        vo.setTo(to);
        vo.setType(type);
        sendMessage(vo);
        if(!"testLink".equals(type)){
            // 非测试消息打印
            logger.info("onMessage: " + txt);
        }
    }

    /**
     * 发生错误时调用
     *
     * @param session
     * @param error
     */

    @OnError
    public void onError(Session session, Throwable error) {
        logger.error("发生错误", error);
    }


    /**
     * 发送消息到客户端(
     *
     * @param message 消息内容
     */

    public static void sendMessage(WebSocketMsgVo message) {
        if (StringUtil.isNotBlank(message.getType())) {
            // 必须指定发送消息的类型
            JSONObject json = JSONObject.fromObject(message);
            if (("ALL").equals(message.getTo())) {
                // 群发
                for (WebSocketUtil item : webSocketSet) {
                    item.session.getAsyncRemote().sendText(json.toString());
                }
            } else {
                // 发给指定用户
                for (WebSocketUtil item : webSocketSet) {
                    if (StringUtil.isNotBlank(item.userCode) && item.userCode.equals(message.getTo())) {
                        item.session.getAsyncRemote().sendText(json.toString());
                    }
                }
            }
        }
    }

    public static synchronized int getOnlineCount() {
        return onlineCount;
    }

    public static synchronized void addOnlineCount() {
        onlineCount++;
    }

    public static synchronized void subOnlineCount() {
        onlineCount--;
    }

}

2、客户端

data () {
  return {
    websocketUrl: 'ws://localhost:8090/portal-admin/websocket/sendMessage',
    // websocket请求地址
    socket: ""
  }
},
mounted () {
  // 初始化
  this.websocketInit()
},
methods: {
  // websocket初始化
  websocketInit () {
    if(typeof(WebSocket) === "undefined"){
      this.$message.warning('您的浏览器不支持socket')
    }else{
      // 实例化socket
      this.socket = new WebSocket(this.websocketUrl + '?80512179')
      // 监听socket连接
      this.socket.onopen = this.websocketOpen
      // 监听socket错误信息
      this.socket.onerror = this.websocketError
      // 监听socket消息
      this.socket.onmessage = this.websocketGetMessage
    }
  },
  websocketOpen: function () {
    console.log("socket连接成功")
  },
  websocketError: function () {
    console.log("连接错误")
  },
  websocketGetMessage: function (msg) {
    // 执行具体业务逻辑
    var json = JSON.parse(msg.data)
    if(json.type == 'portalMsg'){
      this.getUnReadNum()
    }
  },
  // 向服务端发送消息
  websocketSend: function (params) {
    this.socket.send(JSON.stringify(params), '80512179')
  },
  websocketClose: function () {
    console.log("socket已经关闭")
  }
},
destroyed () {
  // 销毁监听
  this.socket.onclose = this.websocketClose()
}

到此,一种基于Tomcat的实现方式就完成了,需要注意的是,该实现要求tomcat的版本为tomcat7.x,本文示例使用的是tomcat8。当然服务端实现方式肯定不止当前这一种,比如基于spring的实现,其中的实现请读者自行尝试。

6、问题解决

在尝试使用过程中也遇到各种各样的问题,但这也正是开发的乐趣,享受发现问题与解决问题的过程,其中主要问题点如下:

  1. 问题一,连接中断

目前我们的项目大多数是负载均衡,通过nginx做负载,如此就存在一个巨大的隐患。因为nginx一般都存在一个超时时间,一定时间内没有交互就会断开连接。

若依微服务 定时任务 nacos如何配置 若依websocket_客户端

为此我们引入心跳包,维持连接的有效性,首先定义心跳数据代码:

var heartCheck = { 
  timeout: 60000,//60s 
  timeoutObj: null, 
  reset: function(){ 
    clearInterval(this.timeoutObj); 
    this.start();
  }, 
  start: function(){ 
    this.timeoutObj = setInterval(function(){ 
      if(websocket.readyState==1){ 
        websocket.send("HeartBeat");
      } 
    }, this.timeout) 
  }
};

其次,在 websocket onopen 事件上执行 heartCheck.start(),表示连接成功后开始发送心跳包(每隔 60s 发送一次);在 onmessage 事件执行 heartCheck.reset() ,收到数据时,重置发送心跳定时程序。

websocketOpen: function () {
  console.log("socket连接成功")
  heartCheck.start();
},
websocketGetMessage: function (msg) {
  heartCheck.reset();
  // 执行具体业务逻辑
  var json = JSON.parse(msg.data)
  if(json.type == 'portalMsg'){
    this.getUnReadNum()
  }
},

最后,关闭重连。一般情况下,如网络故障,服务器故障等发生时(一般情况 onclose 事件的 evnt.code=1006),故障时我们都会设置重连。但一些特殊情况下我们可以指定其中 evnt.code = 4500 返回的代码,来判断不需要,不需要重连。

websocketClose: function () {
  console.log("socket已经关闭")
  if (evnt.code != 4500) { 
    //4500为指定不需重连的编码
    reconnect();//重连 
  }
}

(2)问题2

WebSocket connection to 'ws://angelapi.bluemoon.com.cn/portal-admin/websocket/sendMessage' failed: Error during WebSocket handshake: Unexpected response code: 200

翻译后就是 WebSocket握手过程中出错:意外响应代码:200

主要原因就是过滤器,因为项目的配置文件与代码是分开部署的,测试环境没有配置过滤url,导致websocket的请求会被拦截,而不会到达websocket的链接处,就会报上面的错。添加配置后使得URL不被拦截,问题就就解决了。

(3)问题3

Mixed Content: The page at '*****' was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://*****'. This request has been blocked; this endpoint must be available over WSS.

遇到这个问题不得不说一下 ws与wss:

WebSocket可以使用 ws 或 wss 来作为统一资源标志符,类似于 HTTP 或 HTTPS。其中 ,wss 表示在 TLS 之上的 WebSocket,相当于 HTTPS。默认情况下,WebSocket的 ws 协议基于Http的 80 端口;当运行在TLS之上时,wss 协议默认是基于Http的 443 端口。说白了,wss 就是 ws 基于 SSL 的安全传输,与 HTTPS 一样样的道理。所以,如果你的网站是 HTTPS 协议的,那你就不能使用 ws:// 了,浏览器会 block 掉连接,和 HTTPS 下不允许 HTTP 请求一样。

遇到这个问题只需在前端建立连接的时候针对http与https多做一步处理即可:

若依微服务 定时任务 nacos如何配置 若依websocket_json_02

(4)问题4

WebSocket connection to ‘wss://{ip}:{port}/‘ failed: Error in connection establishment: net::ERR_SSL_PROTOCOL_ERROR

在Http的情况下,客户端用ip+port的形式来连接服务端,是不会出现什么问题。但是在更改成Https后,若还是以这种方式连接服务端,浏览器就会报 SSL 协议错误,这很明显就是证书的问题。如果这时候还用 IP + 端口号 的方式连接 WebSocket ,根本就没有证书存在的(即使我们在Nginx配置了SSL证书,但这种方式其实是不会走Nginx代理的)。所以建议我们还是域名访问。

(5)问题5

WebSocket connection to ‘wss://{域名}/‘ failed: Error during WebSocket handshake: Unexpected response code: 400

看到这个错误信息后,确定这是服务端返回的400响应。既然可以请求到服务端,就说明客户端这边是没有问题的,那么问题最可能出在客户端和服务端之间。由于中间层使用了Nginx做转发,所以导致服务端无法知道这是一个合法的WebSocket请求。于是立刻请求了IDC同事的帮助,做了处理后问题得到解决。自己也查找了网上资料,表示在Nginx配置文件加入以下配置,并能解决这个问题,不过没有实践过

server {
location / {
proxy_pass http://localhost:{port};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

(6)问题6

其实到此websocket单机部署已经成功了,但因为我们一般生产都是多节点多服务器部署,如此会出现连接在ws node1的用户收不到node2上的消息,而且websocket的session是无法共享的,加上session是有序无法存入到redis缓存中,这又进一步增加了多节点实现的难度。通过一定的资料查找,redis的订阅与发布模式可以很好的解决这一问题,但因各种原因导致目前还没有进行代码实现,待继续努力。