Netty整合WebSocket的使用
初学Netty,记录下学习的过程,各位请多多关照哦!
Netty 是一个基于 JAVA NIO 类库的异步通信框架,它的架构特点就是:异步非阻塞、基于事件驱动、高性能、高可靠性和高可定制性。
netty主要分为如下几大部分
-
构建Netty 服务端
-
构建Netty 客户端
-
利用protobuf定义消息格式
-
服务端空闲检测
-
客户端发送心跳包与断线重连
写代码之前呢我们需要先引进依赖
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.42.Final</version> </dependency>
如有需要请前往gitee下载
1.构建netty服务
package com.serene.im.config;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* netty 服务
*
* @author serene
* @date 2021/3/18 15:55
*/
@Component
public class NettyServer {
private static final Logger log = LoggerFactory.getLogger(NettyServer.class);
/**
* netty端口
*/
private static int port = 8080;
private static class SingletionWSServer {
static final NettyServer instance = new NettyServer();
}
public static NettyServer getInstance() {
return SingletionWSServer.instance;
}
private EventLoopGroup mainGroup;
private EventLoopGroup subGroup;
private ServerBootstrap server;
private ChannelFuture future;
/**
* 处理客户端请求
*/
public NettyServer() {
System.out.println("2.进入NettyServer");
mainGroup = new NioEventLoopGroup();
subGroup = new NioEventLoopGroup();
server = new ServerBootstrap();
server.group(mainGroup, subGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new NettyChannelInitializer());
}
/**
* 启动netty
*/
public void start() {
this.future = server.bind(port);
log.info("netty server server 启动完毕... port = " + port);
System.out.println("future-> " + future.isSuccess());
System.out.println("3.启动netty服务");
}
}
2.管道初始化(netty初始化器)
package com.serene.im.config;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
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.stream.ChunkedWriteHandler;
import io.netty.handler.timeout.IdleStateHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 管道初始化
*/
public class NettyChannelInitializer extends ChannelInitializer<SocketChannel> {
private static final Logger log = LoggerFactory.getLogger(NettyChannelInitializer.class);
@Override
protected void initChannel(SocketChannel ch) throws Exception {
System.out.println("4.管道初始化");
log.info(" 管道初始化...... ");
ChannelPipeline pipeline = ch.pipeline();
// websocket 基于http协议,所以要有http 编解码器
pipeline.addLast("HttpServerCodec", new HttpServerCodec());
// 对写大数据流的支持
pipeline.addLast(new ChunkedWriteHandler());
// 对httpMessage进行聚合,聚合成FullHttpRequest或FullHttpResponse
// 几乎在netty中的编程,都会使用到此hanler
pipeline.addLast(new HttpObjectAggregator(1024 * 64));
// 增加心跳支持 start
// 针对客户端,如果在1分钟时没有向服务端发送读写心跳(ALL),则主动断开
// 如果是读空闲或者写空闲,不处理
pipeline.addLast(new IdleStateHandler(8, 10, 12));
// 自定义的空闲状态检测 处理消息的handler
//pipeline.addLast(new NettyHeartBeatHandler());
/**
* websocket 服务器处理的协议,用于指定给客户端连接访问的路由 : /ws
* 本handler会帮你处理一些繁重的复杂的事
* 会帮你处理握手动作: handshaking(close, ping, pong) ping + pong = 心跳
* 对于websocket来讲,都是以frames进行传输的,不同的数据类型对应的frames也不同
*/
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
System.out.println("5.进入websocket");
// 自定义的wshandler 处理消息的handler
pipeline.addLast(new NettyWsChannelInboundHandler());
System.out.println("7.进入处理消息的handler类");
// 自定义 http
pipeline.addLast(new NettyHttpChannelInboundHandler());
System.out.println("自定义http 的Handler");
}
}
3.然后你就根据自己的业务逻辑写自己需要的handler,下面是处理聊天消息的handler
package com.serene.im.config;
import com.serene.im.SpringBeanUtil;
import com.serene.im.entity.ChatMsg;
import com.serene.im.entity.DataContent;
import com.serene.im.enums.MsgActionEnum;
import com.serene.im.pojo.im.ImChatMsgLogs;
import com.serene.im.service.im.ImChatMsgLogsService;
import com.serene.im.utils.JsonUtils;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
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 org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 处理消息的handler
* TextWebSocketFrame: 在netty中,是用于为websocket专门处理文本的对象,frame是消息的载体
* SimpleChannelInboundHandler: 对于请求来讲 ,相当于 【入站,入境】
*
* @author serene
* @date 2021/3/18 15:55
*/
public class NettyWsChannelInboundHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private static final Logger log = LoggerFactory.getLogger(NettyWsChannelInboundHandler.class);
/**
* 用于记录和管理所有客户端的channle
*/
public static ChannelGroup users = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
/**
* 从channel缓冲区读数据
*
* @param ctx
* @param msg
* @throws Exception
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
System.out.println("8.从channel缓冲区读数据");
// 获得 channel
Channel currentChannel = ctx.channel();
//获取客户端所传输的消息
String content = msg.text();
log.info("接收消息: {} ", content);
dealWithToChatWith(currentChannel, content);
}
/**
* 处理聊天数据
*/
protected void dealWithToChatWith(Channel currentChannel, String content) throws Exception {
System.out.println("9.进入获取客户端发来的消息");
// 1. 获取客户端发来的消息
DataContent dataContent = JsonUtils.jsonToPojo(content, DataContent.class);
Integer action = dataContent.getAction();
/**
* 2. 判断消息类型,根据不同的类型来处理不同的业务
*/
//type=1:第一次(或重连)初始化连接
if (action == MsgActionEnum.CONNECT.type) {
System.out.println("进入初始化连接");
// 2.1 当websocket 第一次open的时候,初始化channel,把用的channel和userid关联起来
String sendId = dataContent.getChatMsg().getSendId();
UserChannelRel.put(sendId, currentChannel);
UserChannelRel.output();
//type=2:聊天消息
} else if (action == MsgActionEnum.CHAT.type) {
System.out.println("进入好友聊天消息 type=" + MsgActionEnum.CHAT.type);
// 2.2 聊天类型的消息,把聊天记录保存到数据库,同时标记消息的签收状态[未签收]
ImChatMsgLogsService imChatMsgLogsService = (ImChatMsgLogsService) SpringBeanUtil.getBean("imChatMsgLogsServiceImpl");
ChatMsg chatMsg = dataContent.getChatMsg();
// 保存消息到数据库,并且标记为 未签收
ImChatMsgLogs logs = new ImChatMsgLogs();
logs.setMainUserId(chatMsg.getMainUserId());
logs.setUserId(chatMsg.getUserId());
logs.setSendId(chatMsg.getSendId());
logs.setReceiveId(chatMsg.getReceiveId());
logs.setMsgContent(chatMsg.getMsgContent());
logs.setToType(1);
Integer msgId = imChatMsgLogsService.saveWebMsgLogs(logs);
chatMsg.setMsgId(msgId.toString());
// 消息发送时间
chatMsg.setSendTime(new Date());
DataContent dataContentMsg = new DataContent();
dataContentMsg.setChatMsg(chatMsg);
// 给自己发送成功消息
Channel sendIdChannel = UserChannelRel.get(chatMsg.getSendId());
sendIdChannel.writeAndFlush(new TextWebSocketFrame(JsonUtils.objectToJson(dataContent)));
// 发送消息 从全局用户Channel关系中获取接受方的channel
Channel receiverChannel = UserChannelRel.get(chatMsg.getReceiveId());
if (receiverChannel == null) {
// TODO channel为空代表用户离线,推送消息(JPush,个推,小米推送) 添加离线消息记录
log.info(" 用户离线1 ... receiverChannel 是 null");
imChatMsgLogsService.updateOfflineStatusTwo(msgId);
} else {
// 当receiverChannel不为空的时候,从ChannelGroup去查找对应的channel是否存在
Channel findChannel = users.find(receiverChannel.id());
if (findChannel != null) {
// 用户在线
receiverChannel.writeAndFlush(new TextWebSocketFrame(JsonUtils.objectToJson(dataContent)));
} else {
// 用户离线 TODO 推送消息 添加离线消息记录
log.info(" 用户离线2 ... findChannel 是 null");
imChatMsgLogsService.updateOfflineStatusTwo(msgId);
}
}
//type=3:消息签收
} else if (action == MsgActionEnum.SIGNED.type) {
System.out.println("进入消息签收");
log.info(" 消息通知..... ");
// 扩展字段在signed类型的消息中,代表需要去签收的消息id,逗号间隔
String msgIdsStr = dataContent.getExtand();
String msgIds[] = msgIdsStr.split(",");
List<String> msgIdList = new ArrayList<>();
for (String mid : msgIds) {
if (StringUtils.isNotBlank(mid)) {
msgIdList.add(mid);
}
}
if (msgIdList != null && !msgIdList.isEmpty() && msgIdList.size() > 0) {
// 2.3 签收消息类型,针对具体的消息进行签收,修改数据库中对应消息的签收状态[已签收]
// 批量签收
ImChatMsgLogsService imChatMsgLogsService = (ImChatMsgLogsService) SpringBeanUtil.getBean("imChatMsgLogsServiceImpl");
imChatMsgLogsService.updateMsgReadStatusOne(msgIdList);
}
//type=4:客户端保持心跳
} else if (action == MsgActionEnum.KEEPALIVE.type) {
System.out.println("客户端保持心跳");
// 2.4 心跳类型的消息
log.info("收到来自channel为[" + currentChannel + "]的心跳包...");
//type=6:好友申请
} else if (action == MsgActionEnum.FRIEND_REQUEST.type) {
// 好友申请
ChatMsg chatMsg = dataContent.getChatMsg();
String sendId = chatMsg.getSendId();
String receiveId = chatMsg.getReceiveId();
log.info("sendId = " + sendId + ".... 好友申请.... receiveId =" + receiveId);
//type=7:群消息
} else if (action == MsgActionEnum.GROUP_MSG.type) {
System.out.println("进入群消息");
//群消息发送
ImChatMsgLogsService imChatMsgLogsService = (ImChatMsgLogsService) SpringBeanUtil.getBean("imChatMsgLogsServiceImpl");
ChatMsg chatMsg = dataContent.getChatMsg();
// 保存消息到数据库,并且标记为 未签收
ImChatMsgLogs logs = new ImChatMsgLogs();
logs.setMainUserId(chatMsg.getMainUserId());
logs.setUserId(chatMsg.getUserId());
logs.setSendId(chatMsg.getSendId());
logs.setGroupInfoId(1);
logs.setMsgContent(chatMsg.getMsgContent());
logs.setToType(2);
Integer msgId = imChatMsgLogsService.saveWebMsgLogs(logs);
chatMsg.setMsgId(msgId.toString());
//消息发送时间
chatMsg.setSendTime(new Date());
DataContent dataContentMsg = new DataContent();
dataContentMsg.setChatMsg(chatMsg);
// 给所有在线的 im用户 发送信息
for (Channel c : users) {
System.out.println("给所有在线的 im用户 发送信息");
c.writeAndFlush(new TextWebSocketFrame(JsonUtils.objectToJson(dataContent)));
}
// 更新消息状态为已读
log.info(" 群消息发送... users.size = " + users.size());
}
}
/**
* 当客户端连接服务端之后(打开连接)
* 获取客户端的channle,并且放到ChannelGroup中去进行管理
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
users.add(ctx.channel());
log.info(" netty 获得连接.....");
System.out.println("6.netty获取连接");
}
/**
* 移除
*
* @param ctx
* @throws Exception
*/
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
String channelId = ctx.channel().id().asShortText();
// 当触发handlerRemoved,ChannelGroup会自动移除对应客户端的channel
users.remove(ctx.channel());
log.info("客户端被移除,channelId为:" + channelId);
}
/**
* 连接发送异常
*
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
// 发生异常之后关闭连接(关闭channel),随后从ChannelGroup中移除
ctx.channel().close();
users.remove(ctx.channel());
log.info(" netty 异常了...... ");
}
}
用户id和channel的关联关系处理
package com.serene.im.config;
import io.netty.channel.Channel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
/**
* 用户id和channel的关联关系处理
*
* @author serene
* @date 2021/3/18 15:55
*/
public class UserChannelRel {
private static final Logger log = LoggerFactory.getLogger(UserChannelRel.class);
private static HashMap<String, Channel> manager = new HashMap<>();
public static void put(String senderId, Channel channel) {
manager.put(senderId, channel);
}
public static Channel get(String senderId) {
return manager.get(senderId);
}
public static void output() {
for (HashMap.Entry<String, Channel> entry : manager.entrySet()) {
log.info(" imChat获得连接: UserId: " + entry.getKey() + ", ChannelId: " + entry.getValue().id().asLongText());
System.out.println("-------imChat获得连接------");
}
}
}
4.用于检测channel的心跳handler
package com.serene.im.config;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.group.ChannelGroup;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 用于检测channel的心跳handler
* 继承ChannelInboundHandlerAdapter,从而不需要重写channelRead0方法
*
* @author serene
* @date 2021/3/18 15:55
*/
public class NettyHeartBeatHandler extends ChannelInboundHandlerAdapter {
private static final Logger log = LoggerFactory.getLogger(NettyHeartBeatHandler.class);
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
// 判断evt是否是IdleStateEvent(用于触发用户事件,包含 读空闲/写空闲/读写空闲 )
if (evt instanceof IdleStateEvent) {
// 强制类型转换
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
log.info("进入读空闲...");
} else if (event.state() == IdleState.WRITER_IDLE) {
log.info("进入写空闲...");
} else if (event.state() == IdleState.ALL_IDLE) {
log.info("所有的空闲...");
ChannelGroup users = NettyWsChannelInboundHandler.users;
log.info("channel关闭前,users的数量为:" + users.size());
Channel channel = ctx.channel();
// 关闭无用的channel,以防资源浪费
channel.close();
log.info("channel关闭后,users的数量为:" + users.size());
}
}
}
}
NettyBoot类
package com.serene.im;
import com.serene.im.config.NettyServer;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
@Component
public class NettyBooter implements ApplicationListener<ContextRefreshedEvent> {
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
System.out.println("1.进入NettyBooter");
if (event.getApplicationContext().getParent() == null) {
try {
NettyServer.getInstance().start();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
netty启动成功后进行登录就跟socket连接上了
可以群聊和好友的个人聊天,实现实时监听。
gitee项目地址:https://gitee.com/ckfeng/serene_im