Bootstrap

springboot +websocket 不同的实现方案

序言

介绍了 netty方式 stomp方式netty-socketio等方式使用websocket协议
文章结尾还有在线客服案例

1.spring-boot-starter-websocket 方式

1.1引入依赖

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

1.2配置类

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
	private final HttpAuthHandler httpAuthHandler;
	private final CustomInterceptor customInterceptor;
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		registry
				.addHandler(httpAuthHandler, "testWs")
				.addInterceptors(customInterceptor)
				.setAllowedOrigins("*");
	}
	  @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxSessionIdleTimeout(3600000L);
        return container;
    }
}

1.3 WsSessionManager

@Slf4j
public class WsSessionManager {
	/**
	 * 保存连接 session 的地方
	 */
	private static ConcurrentHashMap<String, WebSocketSession> SESSION_POOL = new ConcurrentHashMap<>();

	/**
	 * 添加 session
	 *
	 * @param key
	 */
	public static void add(String key, WebSocketSession session) {
		// 添加 session
		SESSION_POOL.put(key, session);
	}

	/**
	 * 删除 session,会返回删除的 session
	 *
	 * @param key
	 * @return
	 */
	public static WebSocketSession remove(String key) {
		// 删除 session
		return SESSION_POOL.remove(key);
	}

	/**
	 * 删除并同步关闭连接
	 *
	 * @param key
	 */
	public static void removeAndClose(String key) {
		WebSocketSession session = remove(key);
		if (session != null) {
			try {
				// 关闭连接
				session.close();
			} catch (IOException e) {
				// todo: 关闭出现异常处理
				e.printStackTrace();
			}
		}
	}

	/**
	 * 获得 session
	 *
	 * @param key
	 * @return
	 */
	public static WebSocketSession get(String key) {
		// 获得 session
		return SESSION_POOL.get(key);
	}
}

1.4HttpAuthHandler

@Component
public class HttpAuthHandler extends TextWebSocketHandler {
	/**
	 * socket 建立成功事件
	 *
	 * @param session
	 * @throws Exception
	 */
	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		Object token = session.getAttributes().get("token");
		if (token != null) {
			// 用户连接成功,放入在线用户缓存
			WsSessionManager.add(token.toString(), session);
		} else {
			throw new RuntimeException("用户登录已经失效!");
		}
	}

	/**
	 * 接收消息事件
	 *
	 * @param session
	 * @param message
	 * @throws Exception
	 */
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
		// 获得客户端传来的消息
		String payload = message.getPayload();
		Object token = session.getAttributes().get("token");
		System.out.println("server 接收到 " + token + " 发送的 " + payload);
		session.sendMessage(new TextMessage("server 发送给 " + token + " 消息 " + payload + " " + LocalDateTime.now().toString()));
	}

	/**
	 * socket 断开连接时
	 *
	 * @param session
	 * @param status
	 * @throws Exception
	 */
	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		Object token = session.getAttributes().get("token");
		if (token != null) {
			// 用户退出,移除缓存
			WsSessionManager.remove(token.toString());
		}
	}
}

1.5CustomInterceptor

@Component
public class CustomInterceptor implements HandshakeInterceptor {
	/**
	 * 握手前
	 *
	 * @param request
	 * @param response
	 * @param wsHandler
	 * @param attributes
	 * @return
	 * @throws Exception
	 */
	@Override
	public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
		System.out.println("握手开始");

		// 获得请求参数
		Map<String, String> paramMap =  HttpUtil.decodeParamMap(request.getURI().getQuery(), CharsetUtil.charset("utf-8"));
		String uid = paramMap.get("token");
		if (StrUtil.isNotBlank(uid)) {
			// 放入属性域
			attributes.put("token", uid);
			System.out.println("用户 token " + uid + " 握手成功!");
			return true;
		}
		System.out.println("用户登录已失效");
		return false;
	}

	/**
	 * 握手后
	 *
	 * @param request
	 * @param response
	 * @param wsHandler
	 * @param exception
	 */
	@Override
	public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
		System.out.println("握手完成");
	}
}

1.6 示例

在这里插入图片描述

2.io.netty.netty-all 方式

2.1netty介绍

  • netty定义

Netty是 一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。
官网:https://netty.io/wiki/user-guide-for-4.x.html
更多样例
https://github.com/netty/netty/tree/4.1/example/src/main/java/io/netty/example

  • 1.2netty优点
  • API使用简单,学习成本低。
  • 功能强大,内置了多种解码编码器,支持多种协议。
  • 性能高,对比其他主流的NIO框架,Netty的性能最优。
  • 社区活跃,发现BUG会及时修复,迭代版本周期短,不断加入新的功能。
  • Dubbo、Elasticsearch都采用了Netty,质量得到验证。
  • 1.3逻辑图
    在这里插入图片描述

BossGroup 和 WorkerGroup:

bossGroup 和 workerGroup 是两个线程池, 它们默认线程数为 CPU 核心数乘以 2 bossGroup
用于接收客户端传过来的请求,接收到请求后将后续操作交由 workerGroup 处理

Selector(选择器):

检测多个通道上是否有事件的发生
TaskQueue
(任务队列):

上面的任务都是在当前的 NioEventLoop ( 反应器 Reactor 线程 ) 中的任务队列中排队执行 ,
在其它线程中也可以调度本线程的 Channel 通道与该线程对应的客户端进行数据读写
Channel:

Channel 是框架自己定义的一个通道接口, Netty 实现的客户端 NIO 套接字通道是 NioSocketChannel
提供的服务器端 NIO 套接字通道是 NioServerSocketChannel
当服务端和客户端建立一个新的连接时, 一个新的 Channel 将被创建,同时它会被自动地分配到它专属的 ChannelPipeline :
ChannelPipeline
是一个拦截流经 Channel 的入站和出站事件的 ChannelHandler 实例链,并定义了用于在该链上传播入站和出站事件流的 API
ChannelHandler:

分为 ChannelInBoundHandler 和 ChannelOutboundHandler 两种 如果一个入站 IO
事件被触发,这个事件会从第一个开始依次通过 ChannelPipeline中的 ChannelInBoundHandler,先添加的先执行。
若是一个出站 I/O 事件,则会从最后一个开始依次通过 ChannelPipeline 中的
ChannelOutboundHandler,后添加的先执行,然后通过调用在 ChannelHandlerContext
中定义的事件传播方法传递给最近的 ChannelHandler。 在 ChannelPipeline 传播事件时,它会测试
ChannelPipeline 中的下一个 ChannelHandler 的类型是否和事件的运动方向相匹配。
如果某个ChannelHandler不能处理则会跳过,并将事件传递到下一个ChannelHandler,直到它找到和该事件所期望的方向相匹配的为止。

2.2整合

  • 2.1maven依赖
<dependency>
			<groupId>io.netty</groupId>
			<artifactId>netty-all</artifactId>
		</dependency>
  • 2.2配置类
/**
 * @Author: GZ
 * @CreateTime: 2023-03-20  11:51
 * @Description: 
 * @Version: 1.0
 */
@Component
public class WebSocketChannelConfig  extends ChannelInitializer<Channel> {
    @Resource
    private  WebSocketFrameHandler webSocketFrameHandler;
    @Override
    protected void initChannel(Channel channel) throws Exception {
        ChannelPipeline pipeline = channel.pipeline();
        // netty中http协议的编解码
        pipeline.addLast(new HttpServerCodec());
        //最大内容长度
        pipeline.addLast(new HttpObjectAggregator(65536));
        //压缩
        pipeline.addLast(new WebSocketServerCompressionHandler());
        //协议
        pipeline.addLast(new WebSocketServerProtocolHandler("/ws", null, true));
        //主要逻辑
        pipeline.addLast(webSocketFrameHandler);
    }
}
  • 2.3WebSocketServer
package com.insound.commontest.component;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketServerCompressionHandler;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

/**
 * @Author: GZ
 * @CreateTime: 2023-03-20  10:45
 * @Description: WebSocketServer
 * @Version: 1.0
 */
@Component
@Slf4j
public class WebSocketServer {

    @Autowired
    private static WebSocketChannelConfig webSocketChannelConfig;

    @PostConstruct
    private void init() {
        bind(8084);
    }

    public static void bind(int port) {
        //老板线程,用于接受线程
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        //工人线程,用于处理任务
        EventLoopGroup workerGroup = new NioEventLoopGroup(1);
        try {
            //创建netty启动类
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            //设置线程组
            serverBootstrap.group(bossGroup, workerGroup)
            //设置通道非阻塞IO
            .channel(NioServerSocketChannel.class)
            //设置日志
            .handler(new LoggingHandler(LogLevel.INFO))
            .childHandler(webSocketChannelConfig);
            //服务器异步创建绑定通道
            ChannelFuture channelFuture = serverBootstrap.bind(port).sync();
            log.info("-----netty服务器端启动成功-----");
            //关闭服务器通道
            channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
            log.error("WebSocketServer is error",e);

        } finally {
            //释放线程池资源
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}
  • 2.4WebSocketFrameHandler
@Component
@Slf4j
public class WebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {


    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
        log.info("接受消息");
    }
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        log.info("建立连接");
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        log.info("断开连接");
    }
}

3.netty-socketio 方式

3.1依赖

<dependency>
            <groupId>com.corundumstudio.socketio</groupId>
            <artifactId>netty-socketio</artifactId>
            <version>${netty-socketio}</version>
        </dependency>

3.2配置

package com.gz.im.backend.config;

import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.annotation.SpringAnnotationScanner;
import com.gz.im.backend.exception.InstantMessageExceptionListener;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author GuoZhong
 * @description netty socketIo配置类
 * @date 2023/3/26 22:31
 */
@Configuration
public class NettySocketIoConfig {

	@Value("${socketIo.host}")
	private String host;

	@Value("${socketIo.port}")
	private Integer port;

	@Value("${socketIo.bossCount}")
	private int bossCount;

	@Value("${socketIo.workCount}")
	private int workCount;

	@Value("${socketIo.allowCustomRequests}")
	private boolean allowCustomRequests;

	@Value("${socketIo.upgradeTimeout}")
	private int upgradeTimeout;

	@Value("${socketIo.pingTimeout}")
	private int pingTimeout;

	@Value("${socketIo.pingInterval}")
	private int pingInterval;

	@Value("${socketIo.maxFramePayloadLength}")
	private int maxFramePayloadLength;

	@Value("${socketIo.maxHttpContentLength}")
	private int maxHttpContentLength;


	@Bean
	public SocketIOServer socketIOServer() {

		com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
		// 开启Socket端口复用 解决对此重启服务时,netty端口被占用问题
		com.corundumstudio.socketio.SocketConfig socketConfig = new com.corundumstudio.socketio.SocketConfig();
		socketConfig.setReuseAddress(true);
		socketConfig.setTcpNoDelay(true);
		socketConfig.setSoLinger(0);
		config.setSocketConfig(socketConfig);
		//主机地址
		config.setHostname(host);
		//端口
		config.setPort(port);
		//老板线程
		config.setBossThreads(bossCount);
		//工作线程
		config.setWorkerThreads(workCount);
		//允许为不同于 socket.io 协议的自定义请求提供服务
		config.setAllowCustomRequests(allowCustomRequests);
		//异常监听
		config.setExceptionListener(new InstantMessageExceptionListener());
		config.setUpgradeTimeout(upgradeTimeout);
		config.setPingTimeout(pingTimeout);
		config.setPingInterval(pingInterval);
		config.setMaxHttpContentLength(maxHttpContentLength);
		config.setMaxFramePayloadLength(maxFramePayloadLength);
		return  new SocketIOServer(config);
	}
    /**
     * 扫描socket 注解类
     */
	@Bean
	public SpringAnnotationScanner springAnnotationScanner() {
		return new SpringAnnotationScanner(socketIOServer());
	}


}

3.2 application.yml

server:
  port: 8081

socketIo:
  # host主机
  host:  localhost
  # 端口
  port: 8082
  # 内容长度
  maxFramePayloadLength: 1048576
  # http请求长度
  maxHttpContentLength: 1048576
  # 老板线程
  bossCount: 1
  # 工作线程
  workCount: 100
  # 自定义请求 允许为不同于 socket.io 协议的自定义请求提供服务
  allowCustomRequests: true
  # 协议升级超时
  upgradeTimeout: 1000000
  # ping超时
  pingTimeout: 6000000
  # ping间隔
  pingInterval: 25000

3.3SocketIoServer

package com.gz.im.backend.component;
import com.corundumstudio.socketio.SocketIOServer;
import com.gz.im.backend.component.handler.ImEventHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;


/**
 * @author socket服务初始化与销毁
 */

@Component
@RequiredArgsConstructor
public class SocketIoServer  {
    private final SocketIOServer server;

  
	@PostConstruct
	public void init()  {
		server.addNamespace("/client").addListeners(new ImEventHandler());
		server.start();
	}
	@PreDestroy
	public void destroy() {
		server.stop();
	}
}

3.4ImEventHandler

package com.gz.im.backend.component.handler;


import cn.hutool.json.JSONUtil;
import com.corundumstudio.socketio.AckRequest;
import com.corundumstudio.socketio.SocketIOClient;
import com.corundumstudio.socketio.annotation.OnConnect;
import com.corundumstudio.socketio.annotation.OnDisconnect;
import com.corundumstudio.socketio.annotation.OnEvent;
import com.gz.im.backend.pojo.MessageRequest;
import com.gz.im.backend.pojo.User;
import com.gz.im.backend.pojo.enums.TurnAgentTypeEnum;
import lombok.extern.slf4j.Slf4j;


/**
 * @author GuoZhong
 * @description 客户端nettySocket 处理类
 * @date 2023/3/26 22:50
 */

@Slf4j
public class ImEventHandler {

	private final ImEventProxy proxy;
	public ImEventHandler(ImEventProxy proxy) {
		this.proxy = proxy;
	}
    /**
     * 客户端连接
     */
	@OnConnect
	public void onConnect(SocketIOClient client) {
		log.info("im is connect");
	


	}
	/**
	 * 客户端发生消息
	 */
	@OnEvent("chat")
	public void chat(SocketIOClient client, AckRequest ackRequest, MessageRequest data) {
		log.info("客户端发来的数据:{}",data);
	
	}


	/**
	 * 客户端断开
	 */
	@OnDisconnect
	public void onDisconnect(SocketIOClient client) {
		log.info("im is disconnect");
		

	}


}

3.5示例

在这里插入图片描述

4.stomp方式

官网连接
https://docs.spring.io/spring-framework/docs/5.3.26/reference/html/web.html#websocket-stomp

在这里插入图片描述

在这里插入图片描述

4.1WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/websocket").setAllowedOrigins("*")
                .setHandshakeHandler(new CustomHandshakeHandler())
                //用于浏览器兼容
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {

        //以 /app 开头的 STOMP 消息被路由到 @Controller 类中的 @MessageMapping 方法
        config.setApplicationDestinationPrefixes("/app");

        //topic 代表广播消息
        //queue代表 一对一消息
        config.enableSimpleBroker("/topic", "/queue");

    }

}

4.2CustomHandshakeHandler

package com.insound.commontest.component;

import com.insound.commontest.pojo.StompPrincipal;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;

import java.security.Principal;
import java.util.Map;


/**
 * @Author: GZ
 * @CreateTime: 2023-03-23  13:50
 * @Description: TODO
 * @Version: 1.0
 */

public class CustomHandshakeHandler extends DefaultHandshakeHandler {

/**
     * 重写定义用户信息方法
     *
     * @param request    握手请求对象
     * @param wsHandler  WebSocket管理器,用于管理信息
     * @param attributes 用于传递WebSocket会话的握手属性
     * @return StompPrincipal 自定义用户信息
     */

    @Override
    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
        //获取客户端主机名称
        String hostName = request.getRemoteAddress().getHostName();
        //获取客户端主机IP地址
        String hostAddress = request.getRemoteAddress().getAddress().getHostAddress();
        //StompPrincipal(name = hostName, publicName = hostAddress)
        return new StompPrincipal(hostName, hostAddress);
    }

}

4.3 controller

   @Autowired
    public SimpMessagingTemplate template;

    /**
     * 广播
     *
     * @param msg
     */
    @ResponseBody
    @RequestMapping("/pushToAll")
    public void subscribe( @RequestBody String msg) {

        template.convertAndSend("/topic/all", msg);
    }

5.区别

  • netty-socketio
    性能高
    跨平台
    兼容所有浏览器
    内置心跳检测
  • springboot-websocket
    操作方便
  • netty
    性能高
  • stomp
    无需自定义消息传递协议和消息格式
    与消息中间无缝衔接
    内置心跳检测

6.在线客服案例

https://gitee.com/GZ-jelly/gz-im

;