基于SpringBoot WebSocket的一套消息推送系统

1、需求

需要实现业务系统实时发送任务消息至移动客户端,由于原单体架构基于socket的消息推送复杂难以在链接建立时获取客户信息、并发差难以实现分布式负载均衡。考虑采用Springboot-WebSocket+Kafka+Redis构建分布式高可用消息推送系统。

2、思路

整体思路如图所示,左边为WebSocket构建基本发送协议,根据业务系统产生的消息统一写入Kafka消息通知Topic,采用SpringBoot-WebSocket 消费Kafka中消息并发送到客户端:

消息发送的目标为客户端连接时传入的clientId,同时将ClientId写入外部Redis缓存作为当前在线用户,并检查Redis中是否存在此用户离线信息,如有则读取离线信息并发送;采用HashMap在单个WS推动程序中维护clientId-WsSession——当用户连入时保存其ClientId及其对应的WebSocketSession,当业务系统产生响应消息时,通过查询Redis中clientId Set判断是否用户当前在线,如在线取出其WebSocketSession发送对应信息,如不在线通过Redis存储离线信息(Key:clientId+Msg.timestamp,Value:离线信息,Expire:7Days);当用户退出或发生错误时,移除Redis中用户Set对应的clientId,并删除HashMap中的Session键值对。

关于分布式,本文目前采用Redis外部缓存用户信息——当多个WebSocket实例运行时,可采用不同的Kafka Consumer GroupId以达到同时消费信息(同一条消息各实例都会消费到),由于用户对应的WebSocket Session只能存在于一个实例当中并且系统不知道是哪一个实例(这部分也可通过编写代理路由实现clientId对应唯一确认的实例),因此当有消息流入后,首先判断Redis中客户是否在线,如不在线写入key-value的离线信息(多个实例都会写入,由于是同一条信息因此最终redis也只存在一条),如用户在线,每个实例都会尝试从HashMap中取当前用户的WSSession,此时当然只会有一个实例获取到,然后发送信息。

更好的做法是当用户连接websocket时通过外部存储保存其当前连接指向那一台实例,保存clientId——WebSocketInstanceId,当消息流入后,根据clientId判断由哪一个实例消费信息(相应的KafkaCosnumer要做对应的路由处理),这样可以避免多个实例重复消费同一条消息。

3、Demo

1)Nginx实现WebSocket代理及负载均衡

Nginx较高版本开始支持WebSocket代理,下载Nginx后只需修改conf在http配置块中增加如下内容:

	#增加WebSocket代理设置
	map $http_upgrade $connection_upgrade{
		default upgrade;
		'' close;
	}
	
	
	upstream ws {
		server localhost:8888 weight=1; //websocket运行实例
		server localhost:9999 weight=1;
	}
	
	server{
		listen 8080;
		location /ws {
			proxy_pass http://ws;
			proxy_http_version 1.1;
			proxy_connect_timeout 5s; // 连接超时
			proxy_read_timeout 120s; // 超过此时间断开连接——采用1min的心跳保持连接
			proxy_send_timeout 15s;
			proxy_set_header Upgrade $http_upgrade;
			proxy_set_header Connection $connection_upgrade;
			
		}
	}

2)SpringBoot-WebSocket

kafka部分建立好对应的Topic并写入测试信息,此部分不赘述。

a) Pom文件主要部分如下:

    <groupId>com.test</groupId>
    <artifactId>WebSocketService</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.0.RELEASE</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <swagger2.version>2.7.0</swagger2.version>
        <beanutils.version>1.9.3</beanutils.version>
        <lang3.version>3.3.2</lang3.version>
        <start-class>com.test.Application</start-class>
        <skipTests>true</skipTests>
        <directory>./target</directory>
    </properties>

    <dependencies>
        <!--springBoot 启动jar-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <artifactId>jackson-databind</artifactId>
                    <groupId>com.fasterxml.jackson.core</groupId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <!--替换Tomcat Web容器为Undertow-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-undertow</artifactId>
            <version>2.0.0.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

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

        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>${swagger2.version}</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>${swagger2.version}</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.16.20</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <!--kafka 依赖-->
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.54</version>
        </dependency>
    </dependencies>

 </project>

b) 配置部分,WebSocketConfig如下,Kafka Consumer及Redis连接池的配置限于篇幅就不贴了,网上相关资料比较多。

@Slf4j
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Autowired
    private MyHandShake handShake;
    @Autowired
    private MyHandler handler;

    /**
     * 实现 WebSocketConfigurer 接口,重写 registerWebSocketHandlers 方法,这是一个核心实现方法,配置 websocket 入口,允许访问的域、注册 Handler、SockJs 支持和拦截器。
     * <p>
     * registry.addHandler()注册和路由的功能,当客户端发起 websocket 连接,把 /path 交给对应的 handler 处理,而不实现具体的业务逻辑,可以理解为收集和任务分发中心。
     * <p>
     * addInterceptors,顾名思义就是为 handler 添加拦截器,可以在调用 handler 前后加入我们自己的逻辑代码。
     * <p>
     * setAllowedOrigins(String[] domains),允许指定的域名或 IP (含端口号)建立长连接,如果只允许自家域名访问,这里轻松设置。如果不限时使用”*”号,如果指定了域名,则必须要以 http 或 https 开头。
     * @param registry
     */
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry){
        //部分 支持websocket 的访问链接,允许跨域
        registry.addHandler(handler,"/ws")
                .addInterceptors(handShake).setAllowedOrigins("*");
        //部分 不支持websocket的访问链接,允许跨域
        registry.addHandler(handler,"/sockjs/ws")
                .addInterceptors(handShake).setAllowedOrigins("*").withSockJS();
    }

}

c)编写请求处理类,Handler:

@Slf4j
@Service
public class MyHandShake implements HandshakeInterceptor {

    @Autowired
    private IJedisService jedisService;
    /**
     * 通过请求的WS路径参数:ws://ip:port/ws?parameter=xxx
     * 获取用户信息
     * (通过http session获取用户信息——需要在请求ws之前通过登录接口,保存服务器生成session,并共享session至ws服务)
     * @param request
     * @param response
     * @param webSocketHandler
     * @param attributes
     * @return
     * @throws Exception
     */
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler webSocketHandler,
                                   Map<String,Object> attributes) throws Exception{
        HttpServletRequest servletRequest = null;
        if(request instanceof ServletServerHttpRequest){
            //Transfer WebSocketServer Request to HttpServletRequest to Get Session
            servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
        }
        //Get User Identity from Session And Put it To ParamMap
        String userName = (String) servletRequest.getParameter(WebSocketConstants.ATTRI_USERID);  //ATTRI_USERID为自定义的ws连接参数字符串,比如 ws://localhost:8888/ws?userid=XXX 中的userid是也
        //TODO 判断userId是否允许连接websocket  if userName in Database 整体架构内,鉴权由网关层处理
//        if(jedisService.isUserSetContainsUser(USERS_KEY,userName)){
//            log.error("|----- 同一用户重复请求连接! -----|");
//            return false;
//        }
        attributes.put(WebSocketConstants.ATTRI_USERID,userName);
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request,ServerHttpResponse response,WebSocketHandler webSocketHandler,Exception e){

    }
}

d)实现WebSocketSession发送的逻辑:

其中WebSocketConstant为常量Integer类型用于判断消息类型——0:心跳,1:业务消息。

LogFormatStringConstants.PREFIX_FORMAT 及LogFormatStringConstants.SUFFIX_FORMAT为自定义的特殊字符串用来标识比较重要的log行用。WebSocketMsg为封装的简单消息类,结构如下:

{
    "from": "消息来源",
    "msg": "消息内容",
    "timestamp":消息产生的时间戳,
    "to": "发送目标,与客户端的ATTR_USERID对应",
    "type": 消息类型,0—心跳,1—业务消息
}

@Slf4j
@Service
public class MyHandler implements WebSocketHandler {

    @Autowired
    private IJedisService iJedisService;

    /**
     * Static list to store exist websocket sessions
     */
    private final static Map<String,WebSocketSession> USER_SESSION_MAP = Collections.synchronizedMap(new HashMap<>());

    /**
     * Things to do after clients connected
     * @param webSocketSession
     * @throws Exception
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception{
        String clientId = (String) webSocketSession.getAttributes().get(WebSocketConstants.ATTRI_USERID);
        log.info(LogFormatStringConstants.PREFIX_FORMAT+"Connected Successfully. User: "+clientId+LogFormatStringConstants.SUFFIX_FORMAT);

        if(USER_SESSION_MAP.containsKey(clientId)){
            WebSocketSession session = USER_SESSION_MAP.get(clientId);
            if(null != session && session.isOpen()){
                WebSocketMsg msg = new WebSocketMsg();
                msg.setType(WebSocketConstants.KICKED);
                msg.setFrom("server");
                msg.setTo(clientId);
                msg.setMsg("用户已在其他设备登录,本次连接终止");
                msg.setTimestamp(System.currentTimeMillis());
                session.sendMessage(new TextMessage(msg.toString()));
                log.info("发送消息至用户:"+clientId + ", "+msg.toString());
                session.close();
                USER_SESSION_MAP.remove(clientId);
            }
        }

        iJedisService.saveUserSet(clientId);
        USER_SESSION_MAP.put(clientId,webSocketSession);

        if(iJedisService.isExistOfflineMsg(clientId)){
            try{
                WebSocketSession session = USER_SESSION_MAP.get(clientId);
                if(null != session && session.isOpen()){
                    for(String msg : iJedisService.getOfflineMsg(clientId)){
                        session.sendMessage(new TextMessage(msg));
                        log.info("发送消息至用户:"+clientId + ", "+msg);
                    }
                }
            }catch (Exception e){
                e.printStackTrace();
            }finally {
                iJedisService.delOfflineMsg(clientId);
            }
        }
    }

    /**
     * Logic when new messages arrived
     * @param webSocketSession
     * @param message
     * @throws Exception
     */
    @Override
    public void handleMessage(WebSocketSession webSocketSession, WebSocketMessage<?> message) throws Exception{
        log.info(LogFormatStringConstants.PREFIX_FORMAT+"Handling Messages to be sent"+LogFormatStringConstants.SUFFIX_FORMAT);
        //Parse socket message to JSONObject
        JSONObject msg = JSON.parseObject(message.getPayload().toString());
        log.info(LogFormatStringConstants.PREFIX_FORMAT+"Got Message :{}"+LogFormatStringConstants.SUFFIX_FORMAT,msg);
        sendMessage(msg);
    }

    /**
     * Message send logic
     * @param object
     */
    public void sendMessage(JSONObject object){
        JSONObject result = new JSONObject();
        try{
            WebSocketMsg webSocketMsg = WebSocketMsg.parseFromJson(object.toJSONString());
            if(webSocketMsg.getType() == WebSocketConstants.HEART_BEAT && webSocketMsg.getMsg().equals("ping")){
                String clientId = webSocketMsg.getFrom();
                WebSocketMsg response = new WebSocketMsg();
                response.setType(WebSocketConstants.HEART_BEAT);
                response.setFrom("server");
                response.setTo(clientId);
                response.setMsg("pong");
                response.setTimestamp(System.currentTimeMillis());
                if(iJedisService.getUserSet().contains(clientId)){
                    WebSocketSession session = USER_SESSION_MAP.get(clientId);
                    if(null != session && session.isOpen()){
                        session.sendMessage(new TextMessage(response.toString()));
                    }
                }
            }else if(webSocketMsg.getType() == WebSocketConstants.MESSAGE){
                String clientIds = webSocketMsg.getTo();
                for(String clientId : GlobalUtils.parseStringToList(clientIds)){
                    if(null !=iJedisService.getUserSet() && iJedisService.getUserSet().contains(clientId)){
                        WebSocketSession session = USER_SESSION_MAP.get(clientId);
                        if(null != session && session.isOpen()){
                            session.sendMessage(new TextMessage(webSocketMsg.toString()));
                            log.info("Sent Msg to Natural User: {}, msg: {}",
                                    clientId,webSocketMsg.toString());
                        }
                    }else {
                        iJedisService.saveOfflineMsg(clientId,webSocketMsg.getTimestamp(),webSocketMsg.toString());
                        log.info("Save Offline Msg to Natural User: {}, msg: {}",
                                clientId,webSocketMsg.toString());
                    }
                }
            }
        }catch (IOException e){
            e.printStackTrace();
        }
    }

    /**
     * Close connection when error occurs
     * @param webSocketSession
     * @param throwable
     * @throws Exception
     */
    @Override
    public void handleTransportError(WebSocketSession webSocketSession, Throwable throwable) throws Exception{
        String clientId = (String) webSocketSession.getAttributes().get(WebSocketConstants.ATTRI_USERID);
        if(webSocketSession.isOpen()){
            webSocketSession.close();
        }
        log.info(LogFormatStringConstants.PREFIX_FORMAT+"Connecting error, closing connection"+LogFormatStringConstants.PREFIX_FORMAT);
        USER_SESSION_MAP.remove(clientId);
        iJedisService.removeUser(clientId);
    }

    /**
     * Close connection normally
     * @param webSocketSession
     * @param closeStatus
     * @throws Exception
     */
    @Override
    public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus closeStatus) throws Exception{
        String client = (String) webSocketSession.getAttributes().get(WebSocketConstants.ATTRI_USERID);
        log.info(LogFormatStringConstants.PREFIX_FORMAT+"Connection closed: "+closeStatus.toString()+LogFormatStringConstants.PREFIX_FORMAT);
        USER_SESSION_MAP.remove(client);
        iJedisService.removeUser(client);
    }

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

}

e) kafka 消费者代码中引入sendMessage方法即可实现消费topic消息发送至相应用户:

@Slf4j
@Component
public class MsgConsumer {
    @Autowired
    private MyHandler handler;

    @KafkaListener(topicPattern = "${kafka.topic.testTopic}",containerFactory = "kafkaListenerContainerFactory")
    public void consumeAndPushToWS(ConsumerRecord<?,?> record){
        String data = "";
        try{
            Optional<?> kafkaMessage = Optional.ofNullable(record.value());
            if(kafkaMessage.isPresent()){
                Object message = kafkaMessage.get();
                data = message.toString();
                JSONObject jsonData = JSON.parseObject(data);
                handler.sendMessage(jsonData);
            }
        }catch (Exception e){
            e.printStackTrace();
        }
     }

}

服务端整体实现就到此结束了,相应的客户端为保持连接,需定时(2min内)发送给服务端心跳,上述代码中简单定义了很简单的心跳协议——ping、pong . 

原创不易转载请说明谢谢。

================================================================================================

  • 0
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值