websocket方案调研及实践

webscoket方案调研及实践

一、使用场景

1、考试管理端需要给特定考试用户单独暂停考试、继续考试、加时、减时的操作,当管理端执行了上述的某个操作,需要实时的通知到正在考试的用户那里。

2、社交聊天、弹幕、多玩家游戏、协同编辑、股票基金实时报价、体育实况更新、视频会议/聊天、智能家居等需要高实时的场景

二、方案调研

1、Ajax短轮询

短轮询:客户端定时向服务器发送Ajax请求,服务器接到请求后马上返回响应信息并关闭连接。
优点:后端程序编写比较容易。
缺点:请求中有大半是无用,浪费带宽和服务器资源。

2、long-polling长轮询

长轮询:客户端向服务器发送Ajax请求,服务器接到请求后hold住连接,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求。
优点:在无消息的情况下不会频繁的请求,耗费资源小。
缺点:服务器hold连接会消耗资源,返回数据顺序无保证,难于管理维护。

3、iframe长连接

长连接:在页面里嵌入一个隐蔵iframe,将这个隐蔵iframe的src属性设为对一个长连接的请求或是采用xhr请求,服务器端就能源源不断地往客户端输入数据。
优点:消息即时到达,不发无用请求;管理起来也相对方便。
缺点:服务器维护一个长连接会增加开销,不同浏览器会有加载问题。

4、XHR-streaming

XHR流:服务端使用分块传输编码(Chunked transfer encoding)的HTTP传输机制进行响应,并且服务器端不终止HTTP响应流,让HTTP始终处于持久连接状态,当有数据需要发送给客户端时再进行写入数据。
优点:通过XHR-Streaming,可以允许服务端连续地发送消息,无需每次响应后再去建立一个连接。
缺点:XHR-streaming连接的时间越长,浏览器会占用过多内存,sockjs默认只允许每个xhr-streaming连接输出128kb数据,超过这个大小时会关闭输出流,让浏览器重新发起请求。

5、Websocket

websocket:Webscoket是Web浏览器和服务器之间的一种全双工通信协议.一旦Web客户端与服务器建立起连接,之后的全部数据通信都通过这个连接进行。通信过程中,可互相发送JSON、XML、HTML或图片等任意格式的数据。
优点:复用长连接,全双工通信,支持服务器推送消息
缺点:服务器维护一个长连接会增加开销,不同浏览器会支持程度不一

5.1 实现原理

Websocket是应用层第七层上的一个应用层协议,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。

WebSocket 交互以 HTTP 请求开始,该请求使用 HTTP“Upgrade”header头升级或在本例中切换到 WebSocket 协议

GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade	
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080

http升级成websocket请求返回的是状态码101,而不是200

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp

成功握手后,HTTP 升级请求下的 TCP 套接字保持打开状态,以便客户端和服务器继续发送和接收消息。

参考

https://segmentfault.com/a/1190000019697463

三、实现方案(Websocket)

​ 在java层面实现Webocket主要有spring、netty两个方向,由于我们系统使用的是spring系列,所以采用spring的Websocket实现方案。

​ 在spring的Websocket实现中,又可以细分为3种:

1、基于java原生注解:

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

@Configuration
@EnableWebSocket // 开启websocket
public class WebSocketConfig {

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

@ServerEndpoint("/myWs") // 声明websocket端点
@Component
public class WsServerEndpoint {

    private static Map<String, Session> onlineUserCache = new HashMap<>();
    /**
     * 连接成功
     *
     * @param session
     */
    @OnOpen
    public void onOpen(Session session) {
        System.out.println("连接成功");
    }
    /**
     * 连接关闭
     *
     * @param session
     */
    @OnClose
    public void onClose(Session session) {
        System.out.println("连接关闭");
    }
    /**
     * 接收到消息
     *
     * @param text
     */
    @OnMessage
    public String onMsg(String text) throws IOException {
        return "servet 发送:" + text;
    }
}

​ WsServerEndpoint类下的几个注解需要注意一下,首先是他们的包都在 javax.websocket 下。并不是 spring 提供的,而 jdk 自带的,下面是他们的具体作用。

  1. @ServerEndpoint通过这个 spring boot 就可以知道你暴露出去的 ws 应用的路径,有点类似我们经常用的@RequestMapping。比如你的启动端口是8080,而这个注解的值是ws,那我们就可以通过 ws://127.0.0.1:8080/ws 来连接你的应用
  2. @OnOpen当 websocket 建立连接成功后会触发这个注解修饰的方法,注意它有一个 Session 参数
  3. @OnClose当 websocket 建立的连接断开后会触发这个注解修饰的方法,注意它有一个 Session 参数
  4. @OnMessage当客户端发送消息到服务端时,会触发这个注解修改的方法,它有一个 String 入参表明客户端传入的值
  5. @OnError当 websocket 建立连接时出现异常会触发这个注解修饰的方法,注意它有一个 Session 参数

​ 另外一点就是服务端如何发送消息给客户端,服务端发送消息必须通过上面说的 Session 类,通常是在@OnOpen 方法中,当连接成功后把 session 存入 Map 的 value,key 是与 session 对应的用户标识,当要发送的时候通过 key 获得 session 再发送,这里可以通过 session.getBasicRemote().sendText()来对客户端发送消息

2、spring提供的WebSocket API

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

public class MyHandler extends TextWebSocketHandler {
    // 建立连接成功事件
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        Object token = session.getAttributes().get("token");
        if (token != null) {
            // 用户连接成功,放入在线用户缓存
            WsSessionManager.add(token.toString(), session);
        } else {
            throw new RuntimeException("用户登录已经失效!");
        }
    }

    // 接收消息事件
    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) {
        // ...
    }
    
    // 断开连接时
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        Object token = session.getAttributes().get("token");
        if (token != null) {
            // 用户退出,移除缓存
            WsSessionManager.remove(token.toString());
        }
    }
}

MyHandler通过继承 TextWebSocketHandler 类并覆盖相应方法,可以对 websocket 的事件进行处理,这里可以同原生注解的那几个注解连起来看.

  1. afterConnectionEstablished 方法是在 socket 连接成功后被触发,同原生注解里的 @OnOpen 功能
  2. afterConnectionClosed 方法是在 socket 连接关闭后被触发,同原生注解里的 @OnClose 功能
  3. handleTextMessage 方法是在客户端发送信息时触发,同原生注解里的 @OnMessage 功能、
public class WsSessionManager {
    /**
     * 保存连接 session 的地方
     */
    private static ConcurrentHashMap<String, List<WebSocketSession>> SESSION_POOL = new ConcurrentHashMap<>();

    /**
     * 添加 session
     *
     * @param key
     */
    public static void add(String key, WebSocketSession session) {
        // 添加 session
        SESSION_POOL.put(key, session);
    }

    /**
     * 删除 session,会返回删除的 session
     *
     * @param key
     * @return
     */
    public static WebSocketSession remove(String key) {
        // 删除 session
        return SESSION_POOL.remove(key);
    }

    /**
     * 删除并同步关闭连接
     *
     * @param key
     */
    public static void removeAndClose(String key) {
        WebSocketSession session = remove(key);
        if (session != null) {
            try {
                // 关闭连接
                session.close();
            } catch (IOException e) {
                // todo: 关闭出现异常处理
                e.printStackTrace();
            }
        }
    }

    /**
     * 获得 session
     *
     * @param key
     * @return
     */
    public static WebSocketSession get(String key) {
        // 获得 session
        return SESSION_POOL.get(key);
    }
}

​ 这里简单通过 ConcurrentHashMap 来实现了一个 session 池,用来保存已经登录的 web socket 的 session。前面提过,服务端发送消息给客户端必须要通过这个 session。

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myHandler(),       "/myHandler").setAllowedOrigins("http://mydomain.com");
    }

    @Bean
    public WebSocketHandler myHandler() {
        return new MyHandler();
    }
}

​ 通过实现 WebSocketConfigurer 类并覆盖相应的方法进行 websocket 的配置。我们主要覆盖 registerWebSocketHandlers 这个方法。通过向 WebSocketHandlerRegistry 设置不同参数来进行配置。其中 addHandler 方法添加我们上面的写的 ws 的 handler 处理类,第二个参数是你暴露出的 ws 路径。setAllowedOrigins("*") 这个是关闭跨域校验,方便本地调试,线上推荐打开。

3、基于STOMP消息协议实现

​ 首先需要对SockJS、StompJS以及跟WebSocket三者做简要的说明。

3.1、SockJS

​ SockJS是一个JavaScript库,为了应对许多浏览器不支持WebSocket协议的问题,设计了备选SockJS 。SockJS 是 WebSocket 技术的一种模拟。SockJS会尽可能对应 WebSocket API,但如果WebSocket 技术不可用的话,会自动降为轮询的方式。还提供了心跳检测的机制。

3.2、StompJS

​ STOMP—— Simple Text Oriented Message Protocol——面向消息的简单文本协议。
SockJS 为 WebSocket 提供了 备选方案。 STOMP协议,采用消息订阅的机制,为浏览器 和 server 间的 通信增加适当的消息语义。

3.3、WebSocket、SockJs、STOMP三者关系

​ WebSocket 是底层协议,SockJS 是WebSocket 的备选方案,是一种兼容实现,而 STOMP 是基于 WebSocket(SockJS)的上层协议。

3.4、代码实现

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

import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/portfolio").withSockJS();  // 1
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.setApplicationDestinationPrefixes("/app");  // 2
        config.enableSimpleBroker("/topic", "/queue");  // 3
    }
}

  1. "/portfolio"是 WebSocket(或 SockJS)客户端需要连接到 WebSocket 握手的端点的 HTTP URL
  2. 以“/app”开头的STOMP消息将被路由到@Controller 类中的@MessageMapping 方法中
  3. 使用内置的消息代理进行订阅和广播;将以“/topic”或“/queue”开头的消息路由到代理
@Controller
public class WSController {

    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;

    @MessageMapping("/hello")
    @SendTo("/topic/hello")
    public ResponseMessage hello(RequestMessage requestMessage) {
        System.out.println("接收消息:" + requestMessage);
        return new ResponseMessage("服务端接收到你发的:" + requestMessage);
    }

    @GetMapping("/sendMsgByUser")
    public @ResponseBody
    Object sendMsgByUser(String token, String msg) {
        simpMessagingTemplate.convertAndSendToUser(token, "/msg", msg);
        return "success";
    }
}

​ 通过 @MessageMapping 来暴露节点路径,有点类似 @RequestMapping。注意这里虽然写的是 hello ,但是我们客户端调用的真正地址是 /app/hello。 因为我们在上面的 config 里配置了registry.setApplicationDestinationPrefixes("/app")

@SendTo这个注解会把返回值的内容发送给订阅了 /topic/hello 的客户端,与之类似的还有一个@SendToUser 只不过他是发送给用户端一对一通信的。这两个注解一般是应答时响应的,如果服务端主动发送消息可以通过 simpMessagingTemplate类的convertAndSend方法。

3.5、 消息流向

​ 对于客户端来说,既可以发送指定的消息请求"/app/a",又可以订阅某一个消息主题"/topic/a"。消息订阅的会被路由到消息代理SimpleBroker中,指定的消息请求会被@MessageMapper路由到指定的方法中,之后再根据特定的主题订阅到消息代理SimpleBroker中。最后再通过消息代码返回消息到对应的客户端。

4、问题

4.1 Websocket连接鉴权问题

​ 前2种可以通过添加握手过程的拦截器,在进行握手前,通过获取url传参,进行鉴权;STOMP实现方案可以通过获取header中的参数来进行鉴权。

4.2 分布式问题

​ 前2种方案,跟客户端的交互都需要通过Session进行,并且需要在各个JVM中维护自己的Session池,在分布式环境中,跟消息服务A连接的用户没办法发送消息给跟消息服务B连接的用户。

​ 而Stomp可以通过外部消息中间件MessageBroker的接入解决分布式问题。

4.3 浏览器Websocket协议兼容问题

​ 前2种方案只能通过Websocket协议进行握手,当客户端所在浏览器不支持WebSocket协议时,需要再实现一套轮询的方案来实现客户端与服务端的交互问题。

​ 而SockJS可以实现当浏览器不支持WebSocket协议时,会自动降为轮询的方式进行交互。

5、消息推送负载均衡方案

​ 上述不管哪种实现方案,都避不开分布式问题。解决分布式问题一般引入第三方中间件,在这里我们可以引入rocketMq、redis、rabbitMq等。消息推送服务都订阅到中间件,业务系统通过发布到中间件,进而让各个连接到消息推送服务的客户端能够收到消息。

四、方案实践

1、方案选型

​ 这里我们选基于STOMP消息协议实现的Websocket方案,原因有如下几点:

1、前后端采用消息订阅消费机制,无须在各个JVM中维护各个SESSION池;
2、采用STOMP协议通信,更容易与中间件结合,解决分布式连接问题(浏览器A连接服务A,浏览器B连接服务B,A没法跟B通信)
3、前端实现采用的是SockJS,能够检测各个浏览器能否支持Websocket协议,不支持的话,会自己降级成XHR- streaming、iframe的实现方式
4、通过SockJS发起的Websocket连接,可以在header中添加参数,来实现鉴权,不然只能通过跟在url后的参数进行鉴权

2、具体实现

2.1 引入websocket依赖

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

2.2 添加Websocket消息代理配置

package com.learnfuture.elearning.exam.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

import javax.annotation.Resource;

/**
 * @author huangyizeng
 * @description Websocket 消息代理配置
 * @date 2021/8/22
 **/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Resource
    private SocketChanelInterceptor socketChanelInterceptor;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry){
        //客户端连接端点
        registry.addEndpoint("/exam/websocket")
                .setAllowedOrigins("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic","/queue/", "/exchange/");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(socketChanelInterceptor);
    }

}

package com.learnfuture.elearning.exam.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * @author huangyizeng
 * @description
 * @date 2021/8/22
 **/
@Slf4j
@Component
public class SocketChanelInterceptor implements ChannelInterceptor {

    /**
     * 实际消息发送到频道之前调用
     */
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {

        StompHeaderAccessor accessor =
                MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        //1、判断是否首次连接
        if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
            List<String> nativeHeader = accessor.getNativeHeader("Authorization");
            System.out.println(nativeHeader);
        }
        String jwtToken = accessor.getFirstNativeHeader("token");
        return message;
    }
}

WebSocketConfig主要是配置Websocket的开放端点,使能SockJS连接端点,并且配置Spring自带的消息代理的各个过滤器前缀/topic、/queue、/exchange 等。这里的消息代理是作为浏览器的消息代理,是浏览器跟websocket服务端的订阅及消费关系。

​ 接着配置Socket的通道拦截器SocketChanelInterceptor,与Websocket端点建立连接以及后续通过该通道接收消息,都会经过该拦截器。在连接/发送消息的时候,可以通过添加header头,来实现websocket的鉴权。

2.3 编写Websocket的业务消费者

/**
 * @author huangyizeng
 * @description 考试用户消息消费者
 * @date 2021/8/26
 **/
@Slf4j
@Component
public class SendToExamUserConsumer{

    @Resource
    private SimpMessagingTemplate template;

    @Value("${rocketmq.consumer.group.message.sendToExamUser}")
    private String consumerGroup;

    @Value("${rocketmq.name-server}")
    private String nameServer;

    @Value("${rocketmq.topic.message}")
    private String topic;

    @Value("${rocketmq.topic.message.tag.sendToExamUser}")
    private String selectorExpression;

    @PostConstruct
    public void init() {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(consumerGroup);

        consumer.setNamesrvAddr(nameServer);
        consumer.setMessageModel(MessageModel.BROADCASTING);
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        try {
            consumer.subscribe(topic, selectorExpression);
        } catch (MQClientException e) {
            e.printStackTrace();
        }

        //设置一个Listener,主要进行消息的逻辑处理
        consumer.registerMessageListener(new MessageListenerConcurrently() {

            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
                                                            ConsumeConcurrentlyContext context) {
                try {
                    for (MessageExt messageExt : msgs) {
                        String messageBody = new String(messageExt.getBody());
                        JSONObject jsonObject = JSONObject.parseObject(messageBody);
                        WebsocketMsgResp websocketMsgResp = jsonObject.toJavaObject(WebsocketMsgResp.class);

                        String destination = "/queue/examUserDetailId_" + websocketMsgResp.getExamUserDetailId();
                        template.convertAndSend(destination, websocketMsgResp);

                        log.info("考试用户消息消费成功, message={}", messageBody);
                    }
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                } catch (Exception e) {
                    log.error("考试用户消息消费失败, message={}", msgs, e);
                    throw new ServiceException(ApiCode.FAILURE, "考试用户消息消费失败", e);
                }
            }
        });

        // 调用start()方法启动consumer
        try {
            consumer.start();
        } catch (MQClientException e) {
            log.error("SendToExamUserConsumer 启动失败", e);
        }
    }
}

​ 这里主要是定义了一个业务的RocketMQ消费者,消息模式设为广播模式,这样当websocket服务集群化部署时,只要有一条消息生产过来,那每一个websocket服务都能够消费到。避免了分布式下Websocket的消息推送问题。

2.4 js代码实现

<script src="/js/websocket.js"></script>
<script src="/js/jquery.min.js"></script>
<script src="/js/sockjs.min.js"></script>
<script src="/js/stomp.min.js"></script>

function connect(url) {
   var host = window.location.host; // 带有端口号
   userId =  GetQueryString("userId");
   var socket = new SockJS("http://localhost:9000/api/exam/websocket?access_token=4c819e0f-a0a0-448a-8f79-9b9a538f5837", null, {timeout : 10000});
   stompClient = Stomp.over(socket);
   stompClient.connect({"Authorization" : "Bearer 5807a5eb-dbf1-4ac7-8c40-8aeaa25ccf65"}, function (frame) {
      writeToScreen("connected: " + frame);
      stompClient.subscribe("/queue/examUserDetailId_" + userId, function (response) {
         writeToScreen(response.body);
      });

      }, function (error) {
      }
   )
}

​ 引入相关js包后,新建SockJS对象后,调用stompClient.connect发起websocket服务连接,通过stompClient.subscribe发起跟websocket服务的消息订阅,后续websocket服务发送到具体topic下的消息,都会发送到对应的浏览器那里。

3、技术难点

3.1 网关鉴权

​ 现在系统架构中,所有需要鉴权的接口都会在gateway中进行鉴权,并且通过在header中,添加对应的Authrization属性来传参通过OAuth2的鉴权,而websocket的info接口(比如/api/exam/websocket 是端点,那么在发起websocket连接之前,会先发送一个/api/exam/websocket/info接口查看当前websocket服务是否存在)是没办法将token放在header进行调用的,只能通过/info?token=xxx 的形式来发起。

​ 而OAuth默认是先从Header中获取token,接着再从url中的参数获取token。此时我们只能通过url传递token过去,但是OAuth默认是不允许从url进行获取的,所以需要手动设置成允许。

​ 优先从header中获取,其次是从参数中获取。

OAuth2AuthenticationProcessingFilter.doFilter

​ 设置成允许从参数中获取token

// token转换器
ServerBearerTokenAuthenticationConverter converter = new ServerBearerTokenAuthenticationConverter();
converter.setAllowUriQueryParameter(true); // 设置成允许从参数中获取token

3.2 前端联调问题

3.2.1 WebSocket is closed before the connection is established.

​ 主要是由于前端本地webpack的代理没有开启ws 协议的支持。

3.2.2 Error during WebSocket handshake: Unexpected response code: 400

​ 前端经过nginx代理,而nginx 不支持http请求的升级,而Websocket是首先发送一个http请求,然后将该请求升级成Websocket请求,所以需要添加支持配置:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

4、存在的问题

4.1 websocket 的高并发性能还未进行测试

posted @ 2021-10-18 13:48  寻找的路上  阅读(1131)  评论(0编辑  收藏  举报