Spring boot 集群使用websocket长连接通讯

本文详细介绍了如何在Springboot项目中集成WebSocket实现长连接通信,从基础配置到集群环境下的session共享和连接数限制。通过MQ解决分布式session问题,利用Nginx配置支持WebSocket转发,并探讨了防止长时间连接的方法。
摘要由CSDN通过智能技术生成

引言

最近做了一个项目,涉及到玩家之间的交互。如果使用传统的http接口,客户端会频繁的请求服务端获取最新数据,最服务器会造成压力。这种场景正好符合websocket的使用场景,网上看了看demo感觉实现起来还行,就进了这个坑,给大家讲一下。

基础使用

创建一个基础的spring boot

现在市面上最常见的后端服务框架之一,spring boot。本次也是使用服务端spring boot + 客户端websocket 去实现,首先我们需要搭建一个spring boot 工程。我这里用的社区版idea,有条件的可以用专业版直接创建spring boot。创建步骤不想看的可以跳过这部分

Ctrl + N
创建一个Maven项目
填写项目标识
完成创建
生成代码结构如图
代码结构图

引入websocket需要的依赖

Pom文件依赖如图所示

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

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

    <groupId>com.wpx.demo</groupId>
    <artifactId>websocket-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
        <java.compile.version>1.8</java.compile.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>


    <dependencies>
        <!-- spring boot 启动核心依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- websocket start-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <!-- websocket end-->
    </dependencies>


    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

编写websocket服务代码

代码结构图
代码结构图
启动类Application代码

package com.wpx.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 启动类
 * @date 2021/1/12 16:55
 */
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

开启websocket服务配置类

package com.wpx.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * 开启websocket
 * @date 2021/1/12 18:52
 */
@Configuration
public class WebSocketConfig {
    /**
     * ServerEndpointExporter 作用
     *
     * 这个Bean会自动注册使用@ServerEndpoint注解声明的websocket endpoint
     *
     * @return
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

对外提供websocket服务代码

package com.wpx.demo.server;


import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/***
 * 长连接服务
 * @date: 2021-1-12 16:58:49
 */
@ServerEndpoint("/webSocket/{sid}")
@Component
public class WebsocketServer {
    //静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
    private static AtomicInteger onlineNum = new AtomicInteger();

    //concurrent包的线程安全Set,用来存放每个客户端对应的WebSocketServer对象。
    private static ConcurrentHashMap<String, Session> sessionPools = new ConcurrentHashMap<>();

    //发送消息
    public void sendMessage(Session session, String message) throws IOException {
        if(session != null){
            synchronized (session) {
//                System.out.println("发送数据:" + message);
                session.getBasicRemote().sendText(message);
            }
        }
    }
    //给指定用户发送信息
    public void sendInfo(String userName, String message){
        Session session = sessionPools.get(userName);
        try {
            sendMessage(session, message);
        }catch (Exception e){
            e.printStackTrace();
        }
    }

    //建立连接成功调用
    @OnOpen
    public void onOpen(Session session, @PathParam(value = "sid") String userName){
        sessionPools.put(userName, session);
        addOnlineCount();
        System.out.println(userName + "加入webSocket!当前人数为" + onlineNum);
        try {
            sendMessage(session, "欢迎" + userName + "加入连接!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //关闭连接时调用
    @OnClose
    public void onClose(@PathParam(value = "sid") String userName){
        sessionPools.remove(userName);
        subOnlineCount();
        System.out.println(userName + "断开webSocket连接!当前人数为" + onlineNum);
    }

    //收到客户端信息
    @OnMessage
    public void onMessage(String message) throws IOException{
        message = "客户端:" + message + ",已收到";
        System.out.println(message);
        for (Session session: sessionPools.values()) {
            try {
                sendMessage(session, message);
            } catch(Exception e){
                e.printStackTrace();
                continue;
            }
        }
    }

    //错误时调用
    @OnError
    public void onError(Session session, Throwable throwable){
        System.out.println("发生错误");
        throwable.printStackTrace();
    }

    public static void addOnlineCount(){
        onlineNum.incrementAndGet();
    }

    public static void subOnlineCount() {
        onlineNum.decrementAndGet();
    }
}

编写websocket客户端代码

编写一个html网页,用来与服务端交互

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket</title>

</head>
<body>
<h3>hello socket</h3>
<p>【userId】:<div><input id="userId" name="userId" type="text" value="10"></div>
<p>【toUserId】:<div><input id="toUserId" name="toUserId" type="text" value="20"></div>
<p>【contentText】:<div><input id="contentText" name="contentText" type="text" value="hello websocket"></div>
<p>操作:<div><button onclick="openSocket()">开启socket</button></div>
<p>【操作】:<div><button onclick="sendMessage()">发送消息</button></div>
</body>
<script>
    

    var socket;
    function openSocket() {
        if(typeof(WebSocket) == "undefined") {
            console.log("您的浏览器不支持WebSocket");
        }else{
            console.log("您的浏览器支持WebSocket");
            //实现化WebSocket对象,指定要连接的服务器地址与端口  建立连接
            var userId = document.getElementById('userId').value;
			var socketUrl="ws://127.0.0.1:8080/webSocket/"+userId;
            console.log(socketUrl);
            if(socket!=null){
                socket.close();
                socket=null;
            }
            socket = new WebSocket(socketUrl);
            //打开事件
            socket.onopen = function() {
                console.log("websocket已打开");
                //socket.send("这是来自客户端的消息" + location.href + new Date());
            };
            //获得消息事件
            socket.onmessage = function(msg) {
                var serverMsg = "收到服务端信息:" + msg.data;
                console.log(serverMsg);
                //发现消息进入    开始处理前端触发逻辑
            };
            //关闭事件
            socket.onclose = function() {
                console.log("websocket已关闭");
            };
            //发生了错误事件
            socket.onerror = function() {
                console.log("websocket发生了错误");
            }
        }
    }
    function sendMessage() {
        if(typeof(WebSocket) == "undefined") {
            console.log("您的浏览器不支持WebSocket");
        }else {
            // console.log("您的浏览器支持WebSocket");
            var toUserId = document.getElementById('toUserId').value;
            var contentText = document.getElementById('contentText').value;
            var msg = '{"toUserId":"'+toUserId+'","contentText":"'+contentText+'"}';
            console.log(msg);
            socket.send(msg);
        }
    }

    </script>
</html>

测试通过

启动我们的服务端,打开我们客户端的界面,按F12切换到Console控制台,点击开启socket,控制台输出如图,即表示测试通过

测试网页

集群使用

解决分布式session共享

Websocket通过连接注册的session保持会话,连接开启后,用户可以和节点双向通讯。
目前,大多数服务部署都是采用的集群部署。集群部署websocket就会出现一个问题,session共享。
用户开启连接后,session只会保存在当前节点中,如下图所示,如果用户1和用户2分别注册在了两个节点,用户1想给用户2发送信息,但因为用户2的session没有在SERVER 1中,SERVER 1无法向用户2发送信息。

集群websocket问题
如何实现跨节点的session通讯,就是我们在集群部署中要解决的大问题
网上搜了好多session共享的实现,大多都是通过redis 的pub/sub 实现。本着各司其职的思想,我们的项目中有MQ,所以,这里就用MQ的消息队列实现session共享通讯。
如下图所示,用户每次消息发送到服务端,服务端找不到用户2的session,转向发送到MQ,通过MQ的广播消费,实现跨节点的消息通讯

集群websocket解决方案
实现上述MQ消费,需要针对节点去创建队列名。每一个节点启动时创建自己的队列,服务销毁时将队列一并销毁

附MQ配置类


import org.springframework.amqp.core.Exchange;
import org.springframework.amqp.core.ExchangeBuilder;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;


/***
 * MQ基础配置类,声明交换机和rabbitAdmin
 * @date: 2021-1-12 16:58:49
 */
@Configuration
public class CommonRabbitMQConfig {
    /**
     * 发送的消息体转换json序列化存储到mq中
     *
     * @return
     */
    @Bean
    public MessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter();
    }


    @Bean
    public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory){
        RabbitAdmin rabbitAdmin = new RabbitAdmin(connectionFactory);
        rabbitAdmin.setAutoStartup(true);
        return rabbitAdmin;
    }
    /***
     * 声明交换机
     * 此交换机在没有被使用时会自动删除,内容全部遗弃,容器启动时会自动创建
     */
    @Bean(name = "websocketFanoutExchange")
    public Exchange topicExchange() {
        return ExchangeBuilder.fanoutExchange("WEBSOCKET_FANOUT_EXCHANGE"
).durable(true).build();
    }

}

声明队列的配置类,代码中的hostname根据自己的节点唯一标识注入


import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * rabbitMq config
 * 队列和交换机在没有消费者链接时会被删除,消息内容全部丢弃,消费者连接时会自动创建。
 * @date  2021-1-12 16:58:49
 * @version 1.0
 */
@Configuration
public class AnswerPkRabbitMQConfig {

    @Autowired
    RabbitAdmin rabbitAdmin;

    /**
     * hostname 机器名
     * 容器启动时会注入hostname
     */
    @Value("${hostname}")
    String hostname;

    /***
     * 声明队列
     */
    @Bean(name = "websocketQueue")
    public Queue websocketQueue(){
        return QueueBuilder.durable("WEBSOCKET_QUEUE_"+hostname).autoDelete().build();
    }

    /***
     * 队列绑定到交换机上
     */
    @Bean
    public Binding websocketFanoutExchangeBinding(@Qualifier("websocketQueue")Queue queue,
                                             @Qualifier("websocketFanoutExchange")Exchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with(MQConstants.WEBSOCKET_ROUTING_KEY).noargs();
    }

    @Bean
    public void createDeclare(){
        rabbitAdmin.declareQueue(websocketQueue());
    }
}

Mq发送和消费的代码不贴了,主要就是在操作session之前隔了一个mq,自行实现吧

集群连接数限制

在实际生产环境中,要防止websocket被连接撑爆节点服务。和http连接数限制一样,websocket也要控制长连接数量以保证服务的安全
可以通过过滤器实现websocket连接中的握手校验,当达到系统设置的阈值时,拒绝握手
以redis的incr实现连接数量的监控,当用户成功发起握手,触发 1.3 中的WebSocketServer中的OnOpen,对redis进行incr +1操作,当用户断开连接触发OnClose,对redis进程incr -1操作。在过滤器中判断redis值是否到达阈值来判断是否成功握手。

附过滤器代码,代码中的preHandle为判断redis值是否到达阈值,自行实现

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import sdk.common.util.JsonUtils;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 借过滤器实现对websocket的拦截
 * @date: 2020-12-15 17:21:40
 */
@Slf4j
@Component
public class BodyRequestWrapperFilter extends OncePerRequestFilter {

    @Autowired
    private RedisUtil redisUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        try {
            boolean b = preHandle(request, response);
            if(!b){
                return;
            }
        }catch (Exception e){
            log.error("doFilterInternal",e);
            responseError(response,"doFilterInternal"));
            return;
        }
        chain.doFilter(request, response);
    }
}

环境搭建

Nginx配置

如果你的项目用到了nginx,需要对nginx进行配置以支持websocket转发,配置如下表,在你的nginx配置中增加websocket的location

 location /webSocket/ {
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header  Connection "upgrade";
                proxy_pass http://ip:host/uri;
        }

如果你的项目中有F5,那么你的F5可能也需要支持websocket,去找你的网络人员需求支持吧

Gateway配置

如果你的项目中用到了gateway网关,那么你需要对你的gateway配置websocket转发以支持长连接,配置如下

spring.cloud.gateway.routes[0].id =websocket-demo
spring.cloud.gateway.routes[0].uri = lb:ws://websocket-demo
spring.cloud.gateway.routes[0].predicates[0] = Path=/webSocket/**
spring.cloud.gateway.routes[0].filters[0] = StripPrefix=0

蓝绿发布irules配置

如果你的项目中采用了irules蓝绿发布,那么irules规则也需要对websocket进行支持,针对传统http接口的irules规则不适用与websocket,我们采用url加标识,nginx做规则转发实现的蓝绿发布,时间紧我就不贴代码了

进阶

Session防止长时间连接

Spring这一套websocket中,可以对session设置最大空闲时间,即当此连接闲置过长的时候,服务端会断开连接

session.setMaxIdleTimeout(900000);//15分钟

但这个并不能一劳永逸,如果你的服务遭到了恶意攻击,并没有将连接空闲,且你的服务设计并没有超过一段时间之外的支持,那么你就需要监听你的连接,当达到你设置的阈值时,强制断开连接。你需要设计一个守护线程,定时监控你服务中的session,当达到你设置的阈值,调用session.close();方法对非法的超时连接进行关闭

  • 1
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值