WELCOMEBACK,MingH|

MingHaiZ

园龄:8个月粉丝:2关注:2

使用Netty与前端请求进行交互实现实时通讯

引言

因为不满足与一般的SpringBoot CRUD开发(太无聊了)所以去学一下网络编程,第一站就是通过B站老罗的EasyChat项目了解到了Netty这个网络框架,在学习这个项目之前也是去学习了一下Netty框架的使用以及相关的原理知识所以是有一定了解的,但是只是一味的学习不去实践总感觉是空中楼阁,于是我在学习了一些API和运行的模型原理之后就直接开始了这个项目的实践

Netty的初始化

在这之前,跳过一些例如导入依赖以及前面的一些功能的CRUD直接开始步入到InitNetty环节
>要注意一点的是学习的时候学习的是两个client进行通讯,但是我想学的是通过JS的http协议升级到ws协议进行通讯

1.初始化NettySocketServerStater

  • 服务端启动
    服务端这里的初始化准备工作和之前学的还是差不多的代码如下
NettySocketStarter
@Component
public class NettySocketStarter {
private static final Logger log = LoggerFactory.getLogger(NettySocketStarter.class);
private static EventLoopGroup BOSS_GROUP = new NioEventLoopGroup(1);
private static EventLoopGroup WORKER_GROUP = new NioEventLoopGroup();
@Resource
private HandlerWebSocket handlerWebSocket;
@Resource
private AppConfig appConfig;
@PreDestroy
public void close() {
BOSS_GROUP.shutdownGracefully();
WORKER_GROUP.shutdownGracefully();
}
public void startNettyServer() {
try {
Integer wsPort = appConfig.getWsPort();
String property = System.getProperty("ws.port");
if (!StringTools.isEmpty(property)) {
wsPort = Integer.parseInt(property);
}
ChannelFuture sync = new ServerBootstrap()
.group(BOSS_GROUP, WORKER_GROUP)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.DEBUG))
.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(Channel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
// 设置几个重要的处理器
// 对Http协议的支持,使用http的解码器,编码器
pipeline.addLast(new HttpServerCodec());
// 聚合解码 httpRequest/httpContent/lastHttpContent到fullHttpRequest
// 保证接收到的http请求的完整性
pipeline.addLast(new HttpObjectAggregator(60 * 1024));
// 心跳机制 long readerIdleTime, long writerIdleTime, long allIdleTime, TimeUnit unit
// readIdleTime读超时时间 即测试端一定时间内未接收到被测试的消息
// writerIdleTime写超时时间 即测试段一定时间内未向测试端发送测试消息
// allIdleTime所有类型的超时时间
pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
pipeline.addLast(new HandlerHeartBeat());
// 将http协议升级到ws协议,对websocket支持
pipeline.addLast(new WebSocketServerProtocolHandler(Constants.WS_PROTOCOL, null, true, 64 * 1024, true, true, 10000L));
pipeline.addLast(handlerWebSocket);
}
})
.bind(wsPort)
.sync();
log.info("Netty客户端启动成功");
sync.channel().closeFuture().sync();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
这里和之前学习的有点出入,因为现在是基于SpringBoot的框架下集成的Netty,所以我们开发的时候是将Netty交给Spring IOC容器来管理.但是这里我们需要注意一点,就是Netty的线程启动的时候会阻塞在代码中的

sync.channel().closeFuture().sync(); 这一行,这时候我们需要在初始化的init类当中使用多线程,新建一个线程来给Netty服务

netty初始化
@Component
public class InitRun implements ApplicationRunner {
@Resource
private NettySocketStarter nettySocketStarter;
@Resource
private HandlerWebSocket handlerWebSocket;
private static final Logger logger = LoggerFactory.getLogger(InitRun.class);
private static final ExecutorService executorService = Executors.newFixedThreadPool(5);
@Override
public void run(ApplicationArguments args) throws Exception {
try {
executorService.execute(() -> {
nettySocketStarter.startNettyServer();
});
logger.info("SpringBoot启动成功!");
} catch (Exception e) {
}
}
}

这里就完成了Netty初始化的准备,这当中涉及了很多之前学习两个client之间连接的时候没有学到的Handler上面的注解都写了(什么时候想深入了解的时候再去深入学习)

  • 自定义Handler
    在pipline当中我们使用了自己定义的一个Handler
HandlerWebSocket
@Component
@ChannelHandler.Sharable
public class HandlerWebSocket extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private static final Logger logger = LoggerFactory.getLogger(HandlerWebSocket.class);
@Resource
private RedisComponent redisComponent;
@Resource
private ChannelContextUtils channelContextUtils;
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
Channel channel = ctx.channel();
// logger.info("收到消息{}", msg.text());
// channel.writeAndFlush(new TextWebSocketFrame("客户端返回消息" + msg.text()));
Attribute<String> attr = channel.attr(AttributeKey.valueOf(channel.id().toString()));
String userId = attr.get();
// logger.info("收到用户[{}]的信息: {}", userId, msg.text());
redisComponent.saveUserHeartBeat(userId);
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
WebSocketServerProtocolHandler.HandshakeComplete e = (WebSocketServerProtocolHandler.HandshakeComplete) evt;
String s = e.requestUri();
logger.info("url{}", s);
String token = getToken(s);
if (token == null) {
ctx.writeAndFlush(new TextWebSocketFrame("Token校验不通过!"));
ctx.close();
}
TokenUserInfoDto tokenUserInfo = redisComponent.getTokenUserInfo(token);
if (tokenUserInfo == null) {
ctx.writeAndFlush(new TextWebSocketFrame("Token校验不通过"));
ctx.close();
return;
}
logger.info("redisToken: {}", tokenUserInfo.getToken());
logger.info("token:{}", token);
channelContextUtils.addContext(tokenUserInfo.getUserId(), ctx.channel());
// ctx.writeAndFlush(new TextWebSocketFrame("成功链接到服务器"));
}
}
private String getToken(String uri) {
if (StringTools.isEmpty(uri) || uri.indexOf("?") == -1) {
return null;
}
String[] queryParams = uri.split("\\?");
if (queryParams.length != 2) {
return null;
}
String[] params = queryParams[1].split("=");
if (params.length != 2) {
return null;
}
return params[1];
}
/**
* 通道就绪后调用,一般用来做初始化
*
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
logger.info("有新的链接加入");
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
logger.info("有链接断开");
channelContextUtils.removeContext(ctx.channel());
}
}

这里有个比较有意思的事情:
我当时想测试服务器向前端的返回,然后我在channelActive方法当中向ctx写入输出了数据,但是在调试的时候客户端并没有接收到这个数据,后来了解到是因为服务器和客户端的握手没有完成,所以此时服务器和客户端其实还没真正建立起数据传输连接,所以这时候发送数据客户端并接收不到.
这里重写了userEventTriggered方法并且监听WebSocketServerProtocolHandler.HandshakeComplete事件,这个就是服务器与客户端完成握手,此时服务器与客户端建立了数据传输连接,这时后就可以通过channel向客户端写入数据了

2.连接到NettySocketServer并管理channel

在连接到netty的时候,我们的handler会记录并且管理channel的状态

addContext
private static final ConcurrentMap<String, Channel> USER_CONTEXT_MAP = new ConcurrentHashMap<>();
private static final ConcurrentMap<String, ChannelGroup> GROUP_CONTEXT_MAP = new ConcurrentHashMap<>();
public void addContext(String userId, Channel channel) {
String channelId = channel.id().toString();
AttributeKey attributeKey = null;
if (!AttributeKey.exists(channelId)) {
attributeKey = AttributeKey.newInstance(channelId);
} else {
attributeKey = AttributeKey.valueOf(channelId);
}
channel.attr(attributeKey).set(userId);
List<String> contactIdList = redisComponent.getUserContact(userId);
for (String s : contactIdList) {
if (s.startsWith(UserContactTypeEnum.GROUP.getPrefix())) {
add2Group(s, channel);
}
}
USER_CONTEXT_MAP.put(userId, channel);
redisComponent.saveUserHeartBeat(userId);
// 更新用户最后连接时间
UserInfo updateInfo = new UserInfo();
updateInfo.setLastLoginTime(new Date());
userInfoMapper.updateByUserId(updateInfo, userId);
// 给用户发送消息
UserInfo userInfo = this.userInfoMapper.selectByUserId(userId);
Long sourceLastOffTime = userInfo.getLastOffTime();
Long lastOffTime = sourceLastOffTime;
if (sourceLastOffTime != null && System.currentTimeMillis() - Constants.MILLISSECONDS_3DAYS_BEFORE > sourceLastOffTime) {
lastOffTime = Constants.MILLISSECONDS_3DAYS_BEFORE;
}
// 1.查询会话信息 查询用户所有会话信息,保证换了设备会同步
ChatSessionUserQuery chatSessionUserQuery = new ChatSessionUserQuery();
chatSessionUserQuery.setUserId(userId);
chatSessionUserQuery.setOrderBy("last_receive_time desc");
List<ChatSessionUser> chatSessionUsers = chatSessionUserMapper.selectList(chatSessionUserQuery);
WsInitData wsInitData = new WsInitData();
wsInitData.setChatSessionList(chatSessionUsers);
// 2.查询聊天消息
// 查询所有的联系人
List<String> groupIdList = contactIdList.stream()
.filter((item) -> item.startsWith(UserContactTypeEnum.GROUP.getPrefix()))
.collect(Collectors.toList());
groupIdList.add(userId);
ChatMessageQuery chatMessageQuery = new ChatMessageQuery();
chatMessageQuery.setContactIdList(groupIdList);
chatMessageQuery.setLastReceiveTime(lastOffTime);
List<ChatMessage> chatMessages = this.chatMessageMapper.selectList(chatMessageQuery);
wsInitData.setChatMessageList(chatMessages);
// 3.查询好友申请
UserContactApplyQuery userContactApplyQuery = new UserContactApplyQuery();
userContactApplyQuery.setReceivceUserId(userId);
userContactApplyQuery.setStatus(UserContactApplyStatusEnum.INIT.getStatus());
userContactApplyQuery.setLastApplyTimestamp(lastOffTime);
Integer i = userContactApplyMapper.selectCount(userContactApplyQuery);
wsInitData.setApplyCount(i);
// 发送消息
MessageSendDto messageSendDto = new MessageSendDto();
messageSendDto.setMessageType(MessageTypeEnum.INIT.getType());
messageSendDto.setContactId(userId);
messageSendDto.setExtendData(wsInitData);
sendMsg(messageSendDto, userId);
}
使用ConcurrentMap来管理channel,ConcurrentMap是一个线程安全并且支持并发的MAP
在每个channel当中,都独自维护一个DefaultAttributeMap(里面好像是个链表结构?反正我在调试的时候看着像) 正解: 1.什么是 DefaultAttributeMap? **DefaultAttributeMap 是一个 数组 + 链表 结构的线程安全Map。**

根据是用户还是群组来分别管理,这里提到了一个比较有意思的API,那就是channelGroup

在之前的学习过程当中学习Netty群聊使用的都是普通的Map当中套一个Map,例如

Map<String,Map<String,Channel>>这里第一个key是群组的id,第二个map的kv值则是该群组里用户id和channel的对应值

让我们来看下ChannelGroup的源码

img

我们可以看到这个类其实就是自动管理一群channel的,比之前的map效率会高很多.

我们用户上限之后只需要将个人的channel加入到USERMAP当中,因为在redis里存储了当前用户userid对应的contactID list表,这时后只需要从redis里获取list并且遍历,找到group的ID然后再加入对应channelgroup就可以了.

3.发送消息

  • 思考: 在发送消息之前,有个问题需要我们去解决------集群发送消息,假如我们的服务器是A,负责的用户有123,另外一台集群服务器是B,负责的用户是456,因为channel无法序列化所以无法存在redis或者SQL当中而是存在服务器的内存当中,这时后如果A的用户1想发消息给用户4,此时用户4的channel存储在服务器B的内存当中,所以在服务器A当并读取不到用户4的channel,这时候就无法实现channel通讯,怎么办呢?

  • 解决方案: 使用中间件,例如redis和mq消息队列等进行集群消息的分发

  • 解决方法: 在这里就拿简单轻量化的reidsson来实现简单的消息队列.我们首先先配置一下我们的redisson

    @Value("${spring.redis.host}")
    private String redisHost;
    @Value("${spring.redis.port}")
    private String redisPort;
    @Bean(name = "redissonClient", destroyMethod = "shutdown")
    public RedissonClient redissonClient() {
    try {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort);
    RedissonClient redissonClient = Redisson.create(config);
    return redissonClient;
    } catch (Exception e) {
    logger.error("redis配置错误,请检查redis配置", e);
    }
    return null;
    }

    在redisconfig配置类当中配置一个bean,配置redisson连接到redisson的配置

    之后创建一个MessageHandler类来进行topic的监听和发布

    @Component
    public class MessageHandler {
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private ChannelContextUtils channelContextUtils;
    private static final Logger logger = LoggerFactory.getLogger(MessageHandler.class);
    private static final String MESSAGE_TOPIC = "message.topic";
    @PostConstruct
    public void lisMessage() {
    RTopic rTopic = redissonClient.getTopic(MESSAGE_TOPIC);
    rTopic.addListener(MessageSendDto.class, (MessageSendDto, sendDto) -> {
    logger.info("收到广播消息{}", JsonUtils.convertObj2Json(sendDto));
    channelContextUtils.sendMessage(sendDto);
    });
    }
    public void sendMessage(MessageSendDto sendDto) {
    RTopic rTopic = redissonClient.getTopic(MESSAGE_TOPIC);
    rTopic.publish(sendDto);
    }
    }

    redisson的topic生命周期和mq消息队列的并不一样,redisson的topic可以理解为一个动态生成的自定义name的channel,当用户监听指定name的topic的时候就会生成对应name的channel,当用户监听的时候就会一直保有这个channel,当没有消费者监听的时候就会销毁.

    mq消息队列的topic则是会持久化保存,而且有更多的功能和消息,比如消息的保存和offset偏移量和消费组这些

    生产者发布消息到topic的时候会通过对应name的channeltopic,发布完之后不再持有

img

这样子就能通过redisson的轻量级消息分发到集群服务器上了

随后就是正常的将消息发送到对应的channel当中了.这里其实比较简单,就是有个关于对于客户端来说发送人和联系人的问题

public void sendMsg(MessageSendDto messageSendDto, String reciveId) {
Channel userChannel = USER_CONTEXT_MAP.get(reciveId);
if (userChannel == null) {
return;
}
// 相对于A客户端客户端而言,B的联系人就是在看A看来就是A像B发送消息的人
if (MessageTypeEnum.ADD_FRIEND_SELF.getType().equals(messageSendDto.getMessageType())) {
UserInfo userInfo = (UserInfo) messageSendDto.getExtendData();
messageSendDto.setMessageType(MessageTypeEnum.ADD_FRIEND.getType());
messageSendDto.setContactId(userInfo.getUserId());
messageSendDto.setContactName(userInfo.getNickName());
messageSendDto.setExtendData(null);
} else {
messageSendDto.setContactId(messageSendDto.getSendUserId());
messageSendDto.setContactName(messageSendDto.getSendUserNickName());
}
userChannel.writeAndFlush(new TextWebSocketFrame(JsonUtils.convertObj2Json(messageSendDto)));
}

img

对于B来说,B是发送人A是联系人,对于A来说,A是发送人,B是联系人

其实我一直觉得这个逻辑有点混乱,让我来设计的话就是这个会话里面获取自己的id,如果sendid不是自己的话那么消息就是别人发送的放左边,如果sendid为自己的userid则是自己发送的放在右边,这样子只需要根据sessionID查询出该session的chatMessage之后再根据sendUserId来进行分类就可以了,具体知道netty怎么与前端实现http升级ws长连接之后就差不多了,像这些问题感觉都是设计上的事情,之后再去看一下前端的代码看看

本文作者:MingHaiZ

本文链接:https://www.cnblogs.com/MingHaiZ/p/18701880

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   MingHaiZ  阅读(172)  评论(2编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起