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
无需自定义消息传递协议和消息格式
与消息中间无缝衔接
内置心跳检测