1. 整体流程
客户端发起http请求,请求Netty服务器进行WebSocket连接,服务器接收后请求后进行注册信道并登记客户端IP地址,如此一来就建立了WebSocket通讯连接。
上面的论述可以得出,我们可以比较Http和WebSocket两者之间的关系和区别
都是基于TCP协议的两种通信协议;
两者不相同但是WebSocket又依赖于Http,毕竟建立WebSocket通讯首次请求需要使用Http请求;
Http请求只能是客户端对服务器进行发送消息,服务器是被动的;而WebSocket则允许双方可以任由一方主动发送消息给另外一方,而且能够长时间连接,只要任何一方不断开的话。
基于上述WebSocket的特点,可以实现在线设备的消息推送
2. Springboot整合WebSocket
- 2.1 依赖添加
io.netty
netty-all
cn.hutool
hutool-all
5.4.6
com.alibaba
fastjson
1.2.74
org.springframework.boot
spring-boot-starter-aop
- 2.2 WebSocket信道处理实现,处理通讯的业务逻辑
package com.ryan.websocketpush.netty.handler;
import com.alibaba.fastjson.JSONObject;
import com.ryan.websocketpush.netty.global.ChannelSupervise;
import com.ryan.websocketpush.netty.service.TcpDispacher;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.websocketx.*;
import io.netty.util.CharsetUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Date;
import static io.netty.handler.codec.http.HttpUtil.isKeepAlive;
@Component
@ChannelHandler.Sharable
public class NioWebSocketHandler extends SimpleChannelInboundHandler
- 2.3 连接通信信道的初始化注册
package com.ryan.websocketpush.netty.handler;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class NioWebSocketChannelInitializer extends ChannelInitializer {
@Autowired
private NioWebSocketHandler socketHandler ;
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast("logging",new LoggingHandler("DEBUG")); //设置log监听器,并且日志级别为debug,方便观察运行流程
ch.pipeline().addLast("http-codec",new HttpServerCodec()); //设置解码器
ch.pipeline().addLast("aggregator",new HttpObjectAggregator(65536)); //聚合器,使用websocket会用到
ch.pipeline().addLast("http-chunked",new ChunkedWriteHandler()); //用于大数据的分区传输
ch.pipeline().addLast("handler", socketHandler); //自定义的业务handler
}
}
- 2.4 注册完信道Channel后,要对信道进行通过全局管理起来
package com.ryan.websocketpush.netty.global;
import cn.hutool.core.collection.CollUtil;
import com.ryan.websocketpush.utils.JsonUtils2;
import io.netty.channel.Channel;
import io.netty.channel.ChannelId;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.concurrent.GlobalEventExecutor;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 频道管理
*/
public class ChannelSupervise {
/**
* 频道集群管理
*/
private static ChannelGroup GlobalGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
/**
* 频道管理 【channel.id().asShortText():channel.id()】
*/
private static ConcurrentMap ChannelMap = new ConcurrentHashMap();
/**
* (动作码:频道)映射
*/
private static ConcurrentHashMap
- 2.5 配置好Netty服务器之后,然后创建一新的线程进行Netty服务器的
package com.ryan.websocketpush.netty.service;
import com.ryan.websocketpush.netty.handler.NioWebSocketChannelInitializer;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
@Component
public class NioWebSocketServer {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private NioWebSocketChannelInitializer channelInitializer;
public void init(Integer webSocketPort) {
logger.info("正在启动websocket服务器");
NioEventLoopGroup boss = new NioEventLoopGroup();
NioEventLoopGroup work = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, work);
bootstrap.channel(NioServerSocketChannel.class);
bootstrap.childHandler(channelInitializer);
Channel channel = bootstrap.bind(webSocketPort).sync().channel();
logger.info("webSocket服务器启动成功:" + channel);
logger.info("webSocket端口号:" + webSocketPort);
channel.closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
logger.info("运行出错:" + e);
} finally {
boss.shutdownGracefully();
work.shutdownGracefully();
logger.info("websocket服务器已关闭");
}
}
}
创建线程启动Netty服务器
package com.ryan.websocketpush.netty.mainThread;
import com.ryan.websocketpush.netty.service.NioWebSocketServer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Netty服务线程启动
*
*/
@Component
@Slf4j
public class NettyServerThread implements CommandLineRunner {
@Autowired
private NioWebSocketServer nettyServer;
@Value("${webSokcet.port}")
private Integer webSocketPort;
@Override
public void run(String... args) throws Exception {
log.warn("websocket服务器准备启动! 端口为 : " + webSocketPort);
nettyServer.init(webSocketPort);
}
}
也许这里,有些疑问为什么需要创建线程来启动Netty服务器,这里尝试一下如果没有创建线程,直接在Main主线程启动,看看会有什么问题?这个问题留着后面再去说明
在配置文件中进行WebSocket的端口配置
webSokcet.port=8090
上面的步骤进行完成了一个Netty服务器的初始化启动和端口监听,这时候会有疑问了,消息推送功能还没有实现。所以下面就是我们如何去实现消息处理逻辑
-
2.6 编写自定义注解
-
WebSocket动作码注解
package com.ryan.websocketpush.annotation; import java.lang.annotation.*; /** * * socket 动作码 注解 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented public @interface ActionCode { int value(); }
-
WebSocket消息响应体注解
package com.ryan.websocketpush.annotation; import java.lang.annotation.*; /** * socket 返回 数据 注解 * 自动解析为 json 并返回 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface SocketResponseBody { }
-
-
2.7 消息动作码定义类
package com.ryan.websocketpush.netty.constants; /** * socket消息状态码定义类 * ---------------------------------------------- */ public interface ActionCodeConstants { /** * 广播消息 */ int PUSH_MSG = 1024; /** * 指定用户消息 */ int SPECIAL_MSG = 1025; }
通过定义不同的消息状态可以实现不同类型的消息推送,这里我们暂且只定义一个广播的消息状态码。何为广播,就是只要你客户端请求接入Netty服务器,就给推送消息,人人有份。
-
2.8 下面是真正消息业务处理逻辑实现类了
-
创建消息处理接口,往后不同状态码的消息处理类都要实现这个接口,通过doAction()方法实现不同消息业务逻辑实现,这里简单实现一个点对点订阅推送
package com.ryan.websocketpush.netty.interf; import io.netty.channel.ChannelHandlerContext; public interface IActionSocketService { Object doAction(ChannelHandlerContext context, String message); }
-
点对点消息实现类
package com.ryan.websocketpush.service.impl; import com.alibaba.fastjson.JSONObject; import com.ryan.websocketpush.annotation.ActionCode; import com.ryan.websocketpush.netty.constants.ActionCodeConstants; import com.ryan.websocketpush.netty.global.ChannelSupervise; import com.ryan.websocketpush.netty.interf.IActionSocketService; import io.netty.channel.ChannelHandlerContext; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service @Slf4j @ActionCode(ActionCodeConstants.SPECIAL_MSG) public class PointToPointMsgServiceImpl implements IActionSocketService { @Override public Object doAction(ChannelHandlerContext context, String message) { log.info("收到终端请求 === " + message); JSONObject parseJson = JSONObject.parseObject(message); Integer actionCode = parseJson.getInteger("action"); ChannelSupervise.removeChannel(context.channel()); //去除广播推送 ChannelSupervise.addChannelByActionCode(context.channel(), actionCode); return "发送指定用户信息"; } }
也许会有怎么识别出那个状态码对应哪个业务逻辑代码实现?这时候上面所定义的@ActionCode自定义注解就起到作用了
-
提取一个请求里面的状态码,并对应到消息实现类,也就是WebSocket请求结构体
package com.ryan.websocketpush.netty.bean; import cn.hutool.json.JSONUtil; import lombok.Data; @Data public class BaseSocketRequest { private Integer action; private String ip; /** * 获取对应请求的bean * * @param json * @return 对应的bean */ public static BaseSocketRequest toBean(String json){ if(JSONUtil.isJson(json)){ BaseSocketRequest socketRequest = JSONUtil.toBean(json, BaseSocketRequest.class); if (socketRequest != null && socketRequest.getAction() != null){ return socketRequest; } } return null; } }
-
-
2.9 如何去返回最后的结果?这里通过切面编程的方式去统一的返回结果
-
自定义WebSocketResponseBody注解
package com.ryan.websocketpush.annotation; import java.lang.annotation.*; /** * socket 返回 数据 注解 * 自动解析为 json 并返回 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface SocketResponseBody { }
-
切面编程
package com.ryan.websocketpush.netty.aspect; import com.ryan.websocketpush.utils.JsonUtils2; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component; @Aspect @Component public class SocketResponseBodyAspect { @Pointcut("@annotation(com.ryan.websocketpush.annotation.SocketResponseBody)") public void pointCut() { } @Around("pointCut()") public Object around(ProceedingJoinPoint point) throws Throwable { /** * 获取返回结果 */ Object proceed = point.proceed(); /** * 获取方法参数 */ Object[] args = point.getArgs(); if (args.length > 0) { if (args[0] instanceof ChannelHandlerContext){ TextWebSocketFrame tws = new TextWebSocketFrame( JsonUtils2.writeValue(proceed)); ChannelHandlerContext context = (ChannelHandlerContext) args[0]; context.channel().writeAndFlush(tws); //通过信道发送消息 } } return proceed; } }
-
-
2.10 定时任务模拟消息推送
package com.ryan.websocketpush.scheduler; import com.ryan.websocketpush.netty.constants.ActionCodeConstants; import com.ryan.websocketpush.netty.global.ChannelSupervise; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** * 消息推送 */ @Component public class PushTask { @Scheduled(cron = "0/5 * * * * ? ") public void pushAll() { TextWebSocketFrame textWebSocketFrame = new TextWebSocketFrame("服务器5秒广播消息"); ChannelSupervise.send2All(textWebSocketFrame); } @Scheduled(cron = "0/10 * * * * ? ") public void pushAllByActionCode() { TextWebSocketFrame textWebSocketFrame = new TextWebSocketFrame("服务器10秒指定用户消息"); ChannelSupervise.send2AllAction(ActionCodeConstants.SPECIAL_MSG, textWebSocketFrame); } }
-
2.11 测试结果
- 广播消息,只要客户端接入就可以收到服务器端发送过来的消息
-
上面提到为什么要开启线程进行Netty服务器的启动,这里就来验证一次,如果不开启线程看看会是怎样的结果
- 注释代码
@Autowired private NioWebSocketServer nettyServer; @Value("${webSokcet.port}") private Integer webSocketPort; @Override public void run(String... args) throws Exception { log.warn("websocket服务器准备启动! 端口为 : " + webSocketPort); //nettyServer.init(webSocketPort); }
- 在Main主线程中开启Netty
@Autowired private NioWebSocketChannelInitializer channelInitializer; @Value("${webSokcet.port}") private Integer webSocketPort; @Bean public void init() { logger.info("正在启动websocket服务器"); NioEventLoopGroup boss = new NioEventLoopGroup(); NioEventLoopGroup work = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(boss, work); bootstrap.channel(NioServerSocketChannel.class); bootstrap.childHandler(channelInitializer); Channel channel = bootstrap.bind(webSocketPort).sync().channel(); logger.info("webSocket服务器启动成功:" + channel); logger.info("webSocket端口号:" + webSocketPort); channel.closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); logger.info("运行出错:" + e); } finally { boss.shutdownGracefully(); work.shutdownGracefully(); logger.info("websocket服务器已关闭"); } }
-
连接上后,并没有接收到广播信息;并且发送信息会出现如下这种情况
-
分析项目启动控制台日志打印
-
总结
不用线程进行启动的时候,定时器和WebSocket处理逻辑类没有初始化,是因为Netty启动后,主线程一直阻塞监听,导致后面的定时器和MsgImplement没有注入到Spring容器中。