websocket随笔

https://github.com/littlechaser/push-service

我们在浏览器调服务器的时候使用http连接通常就能实现,但是如果要服务器数据发生变化,需要主动推送给客户端(如订单的状态发送变化,由新建变成待支付,需要通知客户端去进行支付),那这时候http请求是不是就显得乏力呢?众所周知的轮询技术,不但会增加用户的流量消耗(移动端),并且如果客户端的数量比较大的话,轮询对服务器的压力也是非常巨大的,尽管有集群分散压力,那也不如寻找一种更好的技术来替代这种场景下的http请求。

这时候websocket的发挥空间就来了。websocket协议是基于TCP的一种新的网络协议。它实现了浏览器与服务器全双工通信——允许服务器主动发送信息给客户端。

以上的连接是本人在github上面写的一点东西,spring与websocket实现推送,支持集群的session共享,下面稍作讲解。

1.首先搭建springmvc环境,这个不做过多讲解。

2.然后基于springmvc环境进行整合

①.引入依赖包

<dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-websocket</artifactId>
      <version>${spring.version}</version>
</dependency>

②.写一个自定义的处理器TextWebSocketHandler,需要实现WebSocketHandler接口并重写其中的方法。

③.写一个握手拦截器,继承HttpSessionHandshakeInterceptor并重写其中的beforeHandshake和afterHandshake方法,这个拦截器一般是做一些特殊处理,比如从http请求中获取参数,并传入attributes这个map中,然后就可以在TextWebSocketHandler的方法中通过webSocketSession.getAttributes()取到相关的参数,具体见demo,demo也是如此实现的。

④.最后编写一个配置类TextWebSocketConfig,需要确保能被spring扫描到。

package com.allen.websocket;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket
public class TextWebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
        webSocketHandlerRegistry.addHandler(textWebSocketHandler(), "/websocket").addInterceptors(new UserHandshakeInterceptor()).setAllowedOrigins("*");
        webSocketHandlerRegistry.addHandler(textWebSocketHandler(), "/sockjs/websocket").addInterceptors(new UserHandshakeInterceptor()).setAllowedOrigins("*").withSockJS();
    }

    @Bean
    public TextWebSocketHandler textWebSocketHandler() {
        return new TextWebSocketHandler();
    }
}

注意setAllowedOrigins方法可以解决跨域问题,指定可以访问的ip或域名,配置为*是所有的客户端都可以访问。withSockJS是提供对sockjs的支持。UserHandshakeInterceptor是拦截用户的握手请求的。

至此,服务器的websocket已经完成,编写客户端的代码,如项目中的chat.html(WEB-INF/static/html/chat.html),编写完运行项目,打开http://localhost:8080/push-service/static/html/chat.html即可实现通信。再打开http://localhost:8080/push-service/push/test?username=allen,通过http请求给页面发一条消息,发现页面收到。再打开一个页面http://localhost:8080/push-service/static/html/chat2.html,通过http请求给页面发一条消息http://localhost:8080/push-service/push/test?username=all,发现打开的两个页面均收到消息。

至此,基于websocket实现的服务器主动推送消息给客户端的项目基本完成。

但是还有一个问题,眼尖的同学会发现,我们的session是需要服务端进行保存的,因为我们需要通过session发送消息。然而本项目我们是直接将session存在内存中的(map结构),单台服务器是可行的。

那么,如果服务器是集群模式的呢?这时候一个请求说要发消息给allen用户,allen用户的session保存在A服务器,但是发消息的请求被转发到了B服务器,B服务器并没有保存allen的session,这时候B服务器是没法发送消息给allen用户的。所以这里就存在了session共享的问题。

如何解决呢?通常的方法有如下

1.粘性session

粘性session是指Ngnix每次都将同一用户的所有请求转发至同一台服务器上,即将用户与服务器绑定。

2.服务器session复制

即每次session发生变化时,创建或者修改,就广播给所有集群中的服务器,使所有的服务器上的session相同。

3.session共享

缓存session,使用redis, memcached。

4.session持久化

将session存储至数据库中,像操作数据一样才做session。

其实,最简单的两种方案,就是方案一和方案三。方案一可以实现且不用改代码,但是也存在弊端,在此不做讨论,有兴趣的可以自行查阅资料搜一下粘性session。方案三在这里不可行,因为websocket的session没有实现序列化接口,无法序列化,所以无法使用redis之类的缓存工具。

本例采用的是发布订阅的方式实现,即发送消息接口不是真正的发送消息,而是把消息广播出去,所有集群的节点订阅同一个topic,保证都能接收到广播。节点接收到广播后,去查询session,查到了就真正的对用户发送消息,没查到,则表明此用户没有连接到该服务器节点,无需处理。

具体实现是采用redis的发布订阅功能(其他的如rabbitmq,kafka也可实现),参考spring-redis.xml的配置以及其中的类。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:p="http://www.springframework.org/schema/p"
       xmlns:redis="http://www.springframework.org/schema/redis"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/redis http://www.springframework.org/schema/redis/spring-redis.xsd">
    <bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"
          p:maxTotal="100"
          p:maxIdle="10"
          p:maxWaitMillis="500"
          p:testWhileIdle="true"
          p:softMinEvictableIdleTimeMillis="600000"
          p:timeBetweenEvictionRunsMillis="1800000"/>
    <bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory"
          p:hostName="10.104.3.27"
          p:port="6379"
          p:password=""
          p:timeout="1000"
          p:poolConfig-ref="jedisPoolConfig"/>

    <bean id="fastJsonRedisConvertor" class="com.allen.redis.FastJsonRedisConvertor"/>

    <bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate"
          p:connectionFactory-ref="jedisConnectionFactory"
          p:defaultSerializer-ref="fastJsonRedisConvertor"/>

    <bean id="redisMessageListener" class="com.allen.redis.RedisMessageListener"/>

    <redis:listener-container connection-factory="jedisConnectionFactory">
        <redis:listener ref="redisMessageListener" topic="push-topic"/>
    </redis:listener-container>
</beans>
redisTemplate是发布者,fastJsonRedisConvertor是在发布之前对数据进行转换的转换器,发布时指定一个发布主题和内容即可。redisMessageListener是监听者,当监听的主题有消息时即可收到消息,收到消息进行相关处理即可。

由于客户端的行为是不可预测的,有的情况服务器对客户端的行为是无感知的(比如断网,或者崩溃了,客户端的连接断了),这时候服务器的连接还是保持无用的连接不会释放,如果这种情况发生的太多,就会出现内存溢出的现象。
为了防止这种现象的出现,我们做了个客户端心跳检测的线程,当然本例的写法这并不一定是比较好的实现方式,但却是一种思路。
心跳检测线程定时检测所有的session最后一次收到客户端回复的时间,超过一定时间逐出并关闭连接,同时给所有已连接的客户端发送ping消息,收到后服务器更新session的最后一次收到客户端回复的时间。详见HeartBeatExecutor相关代码


PS:本例还有个jsr303 的内容,不做过多详解,
写法是引入依赖
<dependency>
            <groupId>javax.validation</groupId>
            <artifactId>validation-api</artifactId>
            <version>${validation-api.version}</version>
</dependency>

springmvc配置文件配置

<mvc:annotation-driven validator="validator"/>
<bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean"
          p:providerClass="org.hibernate.validator.HibernateValidator"/>

java代码

DTO

package com.allen.dto;

import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Past;
import java.util.Date;

/**
 * @author yang_tao@<yangtao.letzgo.com.cn>
 * @version 1.0
 * @date 2018-04-10 9:16
 */
@Data
public class UserDTO {
    @NotBlank(message = "姓名不能为空")
    private String name;

    @NotNull(message = "年龄不能为空")
    @Range(min = 0, max = 120, message = "年龄必须在{min}和{max}之间")
    private Integer age;

    @NotNull(message = "性别不能为空")
    @Range(min = 0, max = 1, message = "性别必须在{min}和{max}之间")
    private Integer sex;

    @NotNull(message = "生日不能为空")
    @Past(message = "生日必须为当前时间之前的一个时间")
    private Date birthday;
}
@Data是lombok的注解,自动生成setter&getter方法,代码更简洁

controller接口

    /**
     * JSR 303 - Bean Validation
     */
    @RequestMapping(value = "/jsr303",
            method = RequestMethod.POST,
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseData jsr303(@RequestBody @Valid UserDTO userDTO) {
        return ResponseData.OK(userDTO);
    }
@Valid注解的作用是开启Bean Validation,DTO中的字段相关注解即可起到校验参数的作用
原文地址:https://www.cnblogs.com/xiao-tao/p/8761706.html