• 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 的高并发性能还未进行测试

  • 相关阅读:
    Grails批改默认启动端口
    基于注解的SpringMVC简单介绍
    JSP、Servlet中的相对路径和绝对路径
    jsp相对路径绝对路径
    idea如何设置注释作者信息
    alt+4 打开控制台
    idea常用快捷键
    解决Error running 'index.jsp : Address localhost:1099 is already in use的方法
    演示事物所需表
    关于jdbc的面试题
  • 原文地址:https://www.cnblogs.com/process-h/p/15410269.html
Copyright © 2020-2023  润新知