Spring boot 集群使用websocket长连接通讯教程
- 引言
- 基础使用
- 创建一个基础的spring boot
- 引入websocket需要的依赖
- 编写websocket服务代码
- 编写websocket客户端代码
- 测试通过
- 集群使用
- 解决分布式session共享
- 集群连接数限制
- 环境搭建
- Nginx配置
- Gateway配置
- 蓝绿发布irules配置
- 进阶
- Session防止长时间连接
引言
最近做了一个项目,涉及到玩家之间的交互。如果使用传统的http接口,客户端会频繁的请求服务端获取最新数据,最服务器会造成压力。这种场景正好符合websocket的使用场景,网上看了看demo感觉实现起来还行,就进了这个坑,给大家讲一下。
基础使用
创建一个基础的spring boot
现在市面上最常见的后端服务框架之一,spring boot。本次也是使用服务端spring boot + 客户端websocket 去实现,首先我们需要搭建一个spring boot 工程。我这里用的社区版idea,有条件的可以用专业版直接创建spring boot。创建步骤不想看的可以跳过这部分
生成代码结构如图
引入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发送信息。
如何实现跨节点的session通讯,就是我们在集群部署中要解决的大问题
网上搜了好多session共享的实现,大多都是通过redis 的pub/sub 实现。本着各司其职的思想,我们的项目中有MQ,所以,这里就用MQ的消息队列实现session共享通讯。
如下图所示,用户每次消息发送到服务端,服务端找不到用户2的session,转向发送到MQ,通过MQ的广播消费,实现跨节点的消息通讯
实现上述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();方法对非法的超时连接进行关闭