【开发笔记】Spring + websocket 实现服务端推送消息(附几个坑)

Richard_Yi 2018年04月19日 126次浏览

背景

原先采用ajax轮询的方式,去更新推送信息。但是如果你目标的数据对于一次请求来说,只是一个很小的值,那采用轮询的方式就会很浪费带宽。

现在尝试用websocket实现产生推送信息时实时推送给前端。

Spring 4.0的一个最大更新是增加了websocket的支持。websocket提供了一个在web应用中的高效、双向的通讯,需要考虑到客户端(浏览器)和服务器之间的高频和低延时消息交换。一般的应用场景有:在线交易、游戏、协作、数据可视化等。

使用websocket需要考虑的浏览器的支持(IE<10不支持),目前主流的浏览器都能很好的支持websocket。

websocket协议中有一些子协议,可以从更高的层次实现编程模型,就像我们使用HTTP而不是TCP一样。这些子协议有STOMP,WAMP等。

基础知识

websocket与http

WebSocket是HTML5出的东西(协议),也就是说HTTP协议没有变化,或者说没关系,但HTTP是不支持持久连接的(长连接,循环连接的不算)

首先HTTP有 1.1 和 1.0 之说,也就是所谓的 keep-alive ,把多个HTTP请求合并为一个,但是 Websocket 其实是一个新协议,跟HTTP协议基本没有关系,只是为了兼容现有浏览器的握手规范而已,也就是说它是HTTP协议上的一种补充。

有交集,但是并不是全部。

另外Html5是指的一系列新的API,或者说新规范,新技术。Http协议本身只有1.0和1.1,而且跟Html本身没有直接关系,通俗来说,你可以用HTTP协议传输非Html数据。

websocket

HTTP的生命周期通过 Request 来界定,也就是一个 Request 一个 Response ,那么在 HTTP1.0 中,这次HTTP请求就结束了。

在HTTP1.1中进行了改进,使得有一个keep-alive,也就是说,在一个HTTP连接中,可以发送多个Request,接收多个Response。但是请记住 Request = Response , 在HTTP中永远是这样,也就是说一个request只能有一个response。而且这个response也是被动的,不能主动发起。

说了这么多,再来看下websocket。

首先Websocket是基于HTTP协议的,或者说借用了HTTP的协议来完成一部分握手。

首先我们来看个典型的 Websocket 握手(借用Wikipedia的。。)

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

熟悉HTTP的童鞋可能发现了,这段类似HTTP协议的握手请求中,多了几个东西。我会顺便讲解下作用。

Upgrade: websocket
Connection: Upgrade

这个就是Websocket的核心了,告诉 Apache 、 Nginx 等服务器:注意啦,我发起的是Websocket协议,快点帮我找到对应的助理处理~不是那个老土的HTTP。

Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

首先,Sec-WebSocket-Key 是一个 Base64 encode 的值,这个是浏览器随机生成的,告诉服务器:泥煤,不要忽悠窝,我要验证尼是不是真的是Websocket助理。

然后,Sec_WebSocket-Protocol 是一个用户定义的字符串,用来区分同URL下,不同的服务所需要的协议。简单理解:今晚我要服务A,别搞错啦~

最后, Sec-WebSocket-Version 是告诉服务器所使用的 Websocket Draft(协议版本),在最初的时候,Websocket协议还在 Draft 阶段,各种奇奇怪怪的协议都有,而且还有很多期奇奇怪怪不同的东西,什么Firefox和Chrome用的不是一个版本之类的,当初Websocket协议太多可是一个大难题。。不过现在还好,已经定下来啦~大家都使用的一个东西。

然后服务器会返回下列东西,表示已经接受到请求,成功建立Websocket啦!

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

这里开始就是HTTP最后负责的区域了,告诉客户,我已经成功切换协议啦~

`Upgrade: websocket
Connection: Upgrade`

依然是固定的,告诉客户端即将升级的是 Websocket 协议,而不是mozillasocket,lurnarsocket或者shitsocket。

然后, Sec-WebSocket-Accept这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key。 服务器:好啦好啦,知道啦,给你看我的ID CARD来证明行了吧。。

后面的, Sec-WebSocket-Protocol 则是表示最终使用的协议。

至此,HTTP已经完成它所有工作了,接下来就是完全按照Websocket协议进行了。具体的协议就不在这阐述了。

实战

服务端

maven 配置

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-messaging</artifactId>
            <version>4.0.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-websocket</artifactId>
            <version>4.0.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>4.0.5.RELEASE</version>
        </dependency>

拦截器实现

/**
 * @Author: Richard_yyf
 * @Description:
 * @Date: Created in 15:28 2018/2/24
 */
public class MyInterceptor implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler webSocketHandler, Map<String, Object> attributes) throws Exception {
        if (request instanceof ServletServerHttpRequest) {
            // 获取请求参数,首先我们要获取HttpServletRequest对象才能获取请求参数;当ServerHttpRequset的层次结构打开后其子类可以获取到我们想要的http对象,那么就简单了。
            // 我这里是把获取的请求数据绑定到session的map对象中(attributes)
            HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
            // 标记用户
            String uid = servletRequest.getParameter("uid");
            attributes.put("uid", uid);
            if(uid!=null){
                attributes.put("uid", uid);
            }else{
                return false;
            }
        }
        return true;
    }


    @Override
    public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) {
        
    }
}

Handler处理类实现

/**
 * @Author: Richard_yyf
 * @Description:
 * @Date: Created in 15:31 2018/2/24
 */

public class MySocketHandler implements WebSocketHandler {

    private static final Logger logger = Logger.getLogger(MySocketHandler.class);

    // websocket连接建立
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String uid = (String) session.getHandshakeAttributes().get("uid");
        logger.info("Websocket Connection established");
        logger.info(String.format("userId:%s", uid));
        logger.info(String.format("getLocalAddress:%s", session.getLocalAddress().toString()));
        logger.info(String.format("getUri:%s", session.getUri().toString()));
        session.sendMessage(new TextMessage("Server:connected OK!"));
        if (SocketSessionMap.socketSession.get(uid) == null) {
            SocketSessionMap.socketSession.put(uid, session);
        }
    }

    // 消息处理
    @Override
    public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {

    }

    // 消息传输错误
    @Override
    public void handleTransportError(WebSocketSession session, Throwable throwable) throws Exception {
        logger.error("Websocket Transport Error");
        if (session.isOpen()) {
            session.close();
        }
        removeSession(session);
    }

    // websocket 连接关闭
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
        // TODO Auto-generated method stub
        logger.info(String.format("Websocket:[userId=%s]已经关闭",(String) session.getHandshakeAttributes().get("uid")));
        removeSession(session);
    }

    // 移除Socket会话
    private void removeSession(WebSocketSession session) {
        Iterator<Map.Entry<String, WebSocketSession>> iter = SocketSessionMap.socketSession.entrySet().iterator();
        while (iter.hasNext()) {
            ConcurrentHashMap.Entry entry = iter.next();
            if (entry.getValue() == session) {
                SocketSessionMap.socketSession.remove(entry.getKey());
                logger.info(String.format("Socket会话已经移除:用户ID:%s", entry.getKey()));
            }
        }
    }

    @Override
    public boolean supportsPartialMessages() {
        return false;
    }

    /**
     * 给某个用户发送消息
     *
     * @param uid
     * @param message
     * @throws IOException
     */
    public void sendMessageToUser(String uid, TextMessage message)
            throws IOException {
        WebSocketSession session = SocketSessionMap.socketSession.get(uid);
        if (session != null && session.isOpen()) {
            session.sendMessage(message);
        }
    }

}

配置类实现

/**
 * @Author: Richard_yyf
 * @Description:
 * @Date: Created in 15:24 2018/2/24
 */
@Configuration
@EnableWebMvc
@EnableWebSocket
public class MySocketConfig extends WebMvcConfigurerAdapter implements WebSocketConfigurer {

    @Autowired
    private MySocketHandler mySocketHandler;

    @Autowired
    private MyInterceptor myInterceptor;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(mySocketHandler, "/websocket")
                .addInterceptors(myInterceptor);
    }

}

springmvc 配置

扫包

<context:component-scan base-package="com.fingard.rh.msp.socket" />

这里使用的是xml,也可以使用注解配置,具体就不细讲了。

    <bean id="mySocketHandler" class="com.fingard.rh.msp.socket.MySocketHandler"/>
    <!-- 握手接口/拦截器 -->
    <bean id="myInterceptor" class="com.fingard.rh.msp.socket.MyInterceptor"/>

    <websocket:handlers >
        <websocket:mapping path="/websocket" handler="mySocketHandler"/>
        <websocket:handshake-interceptors>
            <ref bean="myInterceptor"/>
        </websocket:handshake-interceptors>
    </websocket:handlers>

dispatcherServlet配置

<servlet>
        <servlet-name>springmvc</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:applicationContext-mvc.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>springmvc</servlet-name>
        <url-pattern>*.action</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>springmvc</servlet-name>
        <url-pattern>*.do</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>springmvc</servlet-name>
        <url-pattern>/websocket</url-pattern>
    </servlet-mapping>

<servlet>

前端(Client)

    //webScoket连接事件
    function socket() {
        try{
            var websocket;
            var url = "ws://host:port/项目名/websocket";
            var uid = $("#userId").val();
            if ('WebSocket' in window) {
                // websocket = new WebSocket(url+"?uid="+uid);
                websocket = new ReconnectingWebSocket(url+"?uid="+uid);
            } else {    // 如果程序不支持,使用轮询的方式
                // url = $("#sockjs").val();
                // websocket = new SockJS(url+"?uid="+uid);
                timerRefresh();
            }

            websocket.onopen = function(event) {
                console.log("WebSocket:已连接");
                console.log(event);
            };

            websocket.onmessage = function(event) {
                console.log("WebSocket:收到一条消息",event.data);
                show("您有新的消息!!!");
                setTimeout("closeDiv()",2000); // 最好设置成可以自己点掉的
                loadMail();
            };

            websocket.onerror = function(event) {
                console.log("WebSocket:发生错误 ");
                console.log(event);
            };
            websocket.onclose = function(event) {
                console.log("WebSocket:已关闭");
                console.log(event);
            }

        }catch(e){

        }
    }

注:使用reconnecting-websocket.js来实现websocket重连。只用把new WebSocket()替换成new ReconnectingWebSocket()即可。

几个坑

1、 跨域问题

经查阅官方文档springwebsocket 4.1.5版本前默认支持跨域访问,之后的版本默认不支持跨域,需要设置

.setAllowedOrigins("*") 

我demo的版本是默认支持跨域的,setAllowedOrigins()这个方法也是之后版本才有的。

2、 版本问题

Spring websocket 版本要和 Spring版本一致,不然会报错

3、 sockjs问题

sockjs是当浏览器不支持websocket的一种兼容性方案(轮询),但是在使用的时候有很多问题,比如在建立握手协议的时候会自动在原有的url上面加上/info的后缀和t的携带参数。这里我在浏览器不支持websocket时(少数情况),自己写了一个轮询。等之后调出来了sockjs,再添加内容吧。

image