WebSocket详解 + 搭配SpringMVC使用

消息推送

HTTP请求只能在客户端发起请求后,服务端再返回消息,而不能在客户端未发起请求时服务端主动推送消息给客户端

轮询方式

客户端定时向服务端发送ajax请求,服务器接收到请求后马上返回消息并关闭连接

优点:后端程序编写比较简单

缺点:TCP的建立和关闭操作浪费时间和宽带,请求中有大半是无用,浪费带宽和服务器资源

长轮询

客户端向服务器发送ajax请求,服务器接到请求后hold住连接,客户端直到收到新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求

优点:在无消息的情况下不会频繁的请求,耗费资源小

缺点:服务器hold连接会消耗资源,返回数据顺序无保证,难于管理维护

Websocket协议

WebSocket协议是基于TCP 的一种新的网络协议。它实现了浏览器与服务器的全双工(full-duplex)通信,即允许服务器主动发送信息给客户端。因此,在WebSocket中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。同时它兼容了HTTP协议,默认使用HTTP的80端口和HTTPS的443端口,同时使用HTTP header进行协议升级

优点:

1、使用的资源更少:其头部更小

2、实时性更强:服务端可以通过连接主动向客户端推送消息

3、有状态:开启链接之后可以不用每次都携带状态信息

缺点:少部分浏览器不支持

示例:社交聊天(微信、QQ)、弹幕、多玩家玩游戏、协同编辑、股票基金实时报价、体育实况更新、视屏会议/聊天、基于位置的应用、在线教育等高实时行的场景

WebSocket流程

1、浏览器、服务器建立TCP连接,三次握手

2、TCP连接成功后,浏览器通过HTTP协议向服务器传送WebSocket支持的版本号等信息

3、连接成功后,双方通过TCP通道进行数据传输,不需要HTTP协议

注意: WebSocket简历连接需要先通过一个HTTP请求进行和服务端协商更新协议

(此图片来源于网络)

Spring MVC中实现WebSocket

添加websocket库

xml 复制代码
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

通过继承TextWebSocketHandler等实现websocket逻辑

1、编写websocket类,继承TextWebSocketHandler


如果是文本数据传输的话,可以选择继承TextWebSocketHandler,如果是二进制数据传输的话,可以选择继承BinaryWebSocketHandler(这里以继承TextWebSocketHandler为例子)

2、重写TextWebSocketHandler中的方法

我们一般重写图中标红的方法

afterConnectionEstablished :建立连接之后调用此方法(可以用来提示此用户是否上线)

handleTextMessage : 接收到客户端信息后调用此方法 (可以主动给客户端发送消息)

afterConnectionClosed :关闭连接之后调用此方法(可以用来提示此用户已经下线)

示例:

java 复制代码
@Service
public class EchoService extends TextWebSocketHandler {

    private final Logger logger = LoggerFactory.getLogger("EchoService");

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        logger.info("[websocket_extend]建立连接:" + session.getId());
        Object httpSessionId = session.getAttributes().get("httpSessionId");
        logger.info("[httpSessionId_websocket] :" + httpSessionId);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        session.sendMessage(message);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        logger.info("[websocket_extend]连接关闭:" + session.getId() + "status:" + status.getReason());
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        logger.info("[websocket_extend]出现错误:" + session.getId() + " " + exception.getMessage());
    }
}

3、将此类注册为websocket节点(注意加上@EnableWebSocket注解)

新建一个类名为WebSocketConfig,实现WebSocketConfigurer接口,重写WebSocketConfigure接口中的registerWebSocketHandlers方法

less 复制代码
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private EchoService echoService;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(echoService, "/echo/channel")
        // 配置此可以通过websocketSession拿到HttpSession中的内容
        .addInterceptors(new HttpSessionHandshakeInterceptor())
        // 设置域白名单
        .setAllowedOrigins("*");
    }

}

为什么要设置Interceptors?

从下图可以看到,addInterceptors()的参数类型为HandshakeInterceptor,通过名字HandshakeInterceptor(握手拦截器)可以猜测到,其发生在websocket协议与服务器握手的途中,可以拦截到用于升级协议的http请求,通过此拦截器来对webSocketSession做一些处理(最常用的就是拦截到握手时的http协议,获取到HttpSession,将HttpSession中的内容放置于webSocketSession中(注意,HttpSession和WebSocketSession是完全不一样的东西,大家可以试一下打印HttpSession的sessionId和WebSocketSession的sessionId,做一下对比))

点进去可以发现,HandshakeInterceptor是一个接口,里面有两个方法需要实现,分别是beforeHandshake和afterHandshake

接下来可以看看,springframework中实现HandshakeInterceptor接口的类都有哪些?从下图可以看到,实现HandshakeInterceptor的类有HttpSessionHandshakeInterceptor和OriginHandhsakeInterceptor

我们先来看OriginHandshakeInterceptor(其中WebUtils.isSameOrigin是判断其是否同源):

kotlin 复制代码
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        if (!WebUtils.isSameOrigin(request) && this.corsConfiguration.checkOrigin(request.getHeaders().getOrigin()) == null) {
            response.setStatusCode(HttpStatus.FORBIDDEN);
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Handshake request rejected, Origin header value " + request.getHeaders().getOrigin() + " not allowed");
            }

            return false;
        } else {
            return true;
        }
    }
ini 复制代码
public static boolean isSameOrigin(HttpRequest request) {
        HttpHeaders headers = request.getHeaders();
        String origin = headers.getOrigin();
        if (origin == null) {
            return true;
        } else {
            String scheme;
            String host;
            int port;
            if (request instanceof ServletServerHttpRequest) {
                ServletServerHttpRequest servletServerHttpRequest = (ServletServerHttpRequest)request;
                HttpServletRequest servletRequest = servletServerHttpRequest.getServletRequest();
                scheme = servletRequest.getScheme();
                host = servletRequest.getServerName();
                port = servletRequest.getServerPort();
            } else {
                URI uri = request.getURI();
                scheme = uri.getScheme();
                host = uri.getHost();
                port = uri.getPort();
            }

            UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build();
            return ObjectUtils.nullSafeEquals(scheme, originUrl.getScheme()) && ObjectUtils.nullSafeEquals(host, originUrl.getHost()) && getPort(scheme, port) == getPort(originUrl.getScheme(), originUrl.getPort());
        }
    }

可以看到,其本质上还是在看 请求升级的http协议 和 其头部的 origin字段中的 协议、域名、以及端口是否一致,设置此Interceptor,即限制了只有同源请求才能升级成webSocket协议

注意:如果没有指明使用的Interceptor,则默认使用OriginHandshakeInterceptor

接下来我们来看HttpSessionHandshakeInterceptor,我们主要关注beforeHandshake和getSession这两个方法

typescript 复制代码
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        HttpSession session = this.getSession(request);
        if (session != null) {
            if (this.isCopyHttpSessionId()) {
                attributes.put("HTTP.SESSION.ID", session.getId());
            }

            Enumeration<String> names = session.getAttributeNames();

            while(true) {
                String name;
                do {
                    if (!names.hasMoreElements()) {
                        return true;
                    }

                    name = (String)names.nextElement();
                } while(!this.isCopyAllAttributes() && !this.getAttributeNames().contains(name));

                attributes.put(name, session.getAttribute(name));
            }
        } else {
            return true;
        }
    }

    @Nullable
    private HttpSession getSession(ServerHttpRequest request) {
        if (request instanceof ServletServerHttpRequest serverRequest) {
            return serverRequest.getServletRequest().getSession(this.isCreateSession());
        } else {
            return null;
        }
    }

从上述源码中我们可以看到,其会从HttpServletRequest中拿到其HttpSession的内容,再把HttpSession的内容都放到WebSocketSeesion中,从而达到当开启WebSocketSession的时候,也能拿到之前存储在HttpSession中的信息

PS:BeforeHandshake()方法中的其中一个参数类型为ServerHttpRequest,在getSession方法中,其会先去判断ServletHttpRequest request是否为ServletServerHttpRequest,若是就将其强转为ServletServerHttpRequest,再通过getServletRequest()方法,拿到HttpServletRequest(其中ServletServerHttpRequest是ServletHttpRequest的子类)

思考一个问题:那如果我们使用redis来存储数据,websocket可以拿到数据并存储在WebSocketSession里吗?

当然可以!

根据上面的说法,其实我们只需要自己实现一个类,然后实现HandshakeInterceptor接口,参考HttpSessionHandshakeInterceptor中beforeHandshake的逻辑就行,不过是将从HttpSession里拿数据换成了从redis里拿数据(记得配置redis的依赖以及其相关的地址和端口和密码)

代码示例:

typescript 复制代码
@Component
public class RedisInterceptor implements HandshakeInterceptor {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        HttpServletRequest r = null;
        if (request instanceof ServletServerHttpRequest serverHttpRequest) {
            r = serverHttpRequest.getServletRequest();
        } else {
            return true;
        }
        String token = "";
        Cookie[] cookies = r.getCookies();
        for(Cookie cookie : cookies) {
            if(cookie.getName().equals("Token")) {
                token = cookie.getValue();
            }
        }
        if(!StringUtils.hasLength(token)) {
            return false;
        }
        String s = redisTemplate.opsForValue().get(token);
        attributes.put("Token", s);
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {

    }
}

然后把WebSocketConfig中的Interceptor换成我们自己写的RedisInterceptor就好了

typescript 复制代码
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(echoService, "/echo/channel")
                // 配置此可以通过websocketSession拿到HttpSession中的内容
//                .addInterceptors(new HttpSessionHandshakeInterceptor())
                .addInterceptors(redisInterceptor)
                .setAllowedOrigins("*");
    }

为什么要设置setAllowOrigins?

根据上面的分析,我们知道,由于websocket默认使用同源拦截策略,,。而setAllowOrigins()就是在设置服务器白名单(注意,这里的origin都在指"源",也就是协议+域名/ip+端口的形式,ex:"http://127.0.0.1:8080")

设置为"*",则表示允许所有的源

为什么WebSocket握手的Http拿不到原来Http的Cookie?

我在使用WebSocket的时候,发生了一个问题,就是WebSocket握手Http拿不到原来Http的Cookie,就像下图,可以看到,在升级协议之前,是可以拿到Cookie中的JSESSIONID的

但是握手Http却带不上Cookie字段,为啥呢?

找了许久,发现是前端页面链接websocket的时候使用的是localhost而不是127.0.0.1

从而导致Host变成localhost:8080

而Cookie的Domain为127.0.0.1导致触发了Cookie的同源策略,从而导致拿不到Cookie,这点大家要格外注意一下

相关推荐
我要学编程(ಥ_ಥ)1 小时前
滑动窗口算法专题(1)
java·数据结构·算法·leetcode
niceffking1 小时前
JVM 一个对象是否已经死亡?
java·jvm·算法
真的很上进1 小时前
【Git必看系列】—— Git巨好用的神器之git stash篇
java·前端·javascript·数据结构·git·react.js
科研小白_d.s2 小时前
intellij-idea创建html项目
java·html·intellij-idea
林太白2 小时前
❤Node09-用户信息token认证
数据库·后端·mysql·node.js
XXXJessie2 小时前
c++249多态
java·c++·servlet
喝旺仔la2 小时前
VSCode的使用
java·开发语言·javascript
骆晨学长2 小时前
基于Springboot的助学金管理系统设计与实现
java·spring boot·后端
尘浮生2 小时前
Java项目实战II基于Java+Spring Boot+MySQL的大型商场应急预案管理系统(源码+数据库+文档)
java·开发语言·数据库·spring boot·spring·maven·intellij-idea
蒙娜丽宁2 小时前
深入理解Go语言中的接口定义与使用
开发语言·后端·golang·go