netty实现私信聊天
websocket的介绍:
WebSocket是一种在网络通信中的协议,它是独立于HTTP协议的。该协议基于TCP/IP协议,可以提供双向通讯并保有状态。这意味着客户端和服务器可以进行实时响应,并且这种响应是双向的。WebSocket协议端口通常是80,443。
WebSocket的出现使得浏览器具备了实时双向通信的能力。与HTTP这种非持久单向响应应答的协议相比,WebSocket是一个持久化的协议。举例来说,即使在关闭网页或者浏览器后,WebSocket的连接仍然保持,用户也可以继续接收到服务器的消息。
此外,要建立WebSocket连接,需要浏览器和服务器握手进行建立连接。一旦连接建立,WebSocket可以在浏览器和服务器之间双向发送或接受信息。总的来说,WebSocket提供了一个高效、实时的双向通信方案。
package com.litblue.im.config.properties; import com.alibaba.fastjson2.JSONObject; import com.litblue.starter.pojo.im.properties.MsgContentProperties; import java.util.HashMap; import java.util.Map; public class RequestUriUtils { /** * 将路径参数转换成Map对象,如果路径参数出现重复参数名,将以最后的参数值为准 * * @param uri 传入的携带参数的路径 * @return */ public static Map<String, String> getParams(String uri) { Map<String, String> params = new HashMap<>(10); int idx = uri.indexOf("?"); if (idx != -1) { String[] paramsArr = uri.substring(idx + 1).split("&"); for (String param : paramsArr) { idx = param.indexOf("="); params.put(param.substring(0, idx), param.substring(idx + 1)); } } return params; } /** * 获取URI中参数以外部分路径 * * @param uri * @return */ public static String getBasePath(String uri) { if (uri == null || uri.isEmpty()) return null; int idx = uri.indexOf("?"); if (idx == -1) return uri; return uri.substring(0, idx); } /** * 解析消息内容 * * @return */ public static MsgContentProperties handleMessageInvoke(String params) { MsgContentProperties msgContentProperties = JSONObject.parseObject(params, MsgContentProperties.class); return msgContentProperties; } }
web配置类
package com.litblue.im.config.properties; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Data @Component @ConfigurationProperties(prefix = "chat.websocket") public class WebSocketProperties { private Integer port = 10001; // 监听端口 private String path = "/ws"; // 请求路径 private Integer boss = 2; // bossGroup线程数 private Integer work = 2; // workGroup线程数 }
package com.litblue.im.config.netty; import com.litblue.im.config.properties.WebSocketProperties; 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.codec.http.websocketx.WebSocketServerProtocolHandler; import io.netty.handler.stream.ChunkedWriteHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class NioWebSocketChannelInitializer extends ChannelInitializer<SocketChannel> { @Autowired private WebSocketProperties webSocketProperties; @Autowired private NioWebSocketHandler nioWebSocketHandler; @Override protected void initChannel(SocketChannel socketChannel) { socketChannel.pipeline() .addLast(new HttpServerCodec()) .addLast(new ChunkedWriteHandler()) .addLast(new HttpObjectAggregator(8192)) .addLast(nioWebSocketHandler) .addLast(new WebSocketServerProtocolHandler(webSocketProperties.getPath(), null, true, 65536)); } }
package com.litblue.im.config.netty; import io.netty.channel.Channel; import io.netty.channel.group.ChannelGroup; import io.netty.channel.group.DefaultChannelGroup; import io.netty.util.concurrent.GlobalEventExecutor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @Slf4j @Component public class NioWebSocketChannelPool { private final ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); /** * 新增一个客户端通道 * * @param channel */ public void addChannel(Channel channel) { channels.add(channel); } /** * 移除一个客户端连接通道 * * @param channel */ public void removeChannel(Channel channel) { channels.remove(channel); } /** * 获取所有连接信息 * @return */ public ChannelGroup getAllChannelGroup() { return channels; } }
package com.litblue.im.config.netty; import com.litblue.im.config.properties.WebSocketProperties; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Slf4j @Component public class NioWebSocketServer implements InitializingBean, DisposableBean { @Autowired private WebSocketProperties webSocketProperties; @Autowired private NioWebSocketChannelInitializer webSocketChannelInitializer; private EventLoopGroup bossGroup; private EventLoopGroup workGroup; private ChannelFuture channelFuture; @Override public void afterPropertiesSet() throws Exception { try { bossGroup = new NioEventLoopGroup(webSocketProperties.getBoss()); workGroup = new NioEventLoopGroup(webSocketProperties.getWork()); ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.option(ChannelOption.SO_BACKLOG, 1024) .group(bossGroup, workGroup) .channel(NioServerSocketChannel.class) .localAddress(webSocketProperties.getPort()) .childHandler(webSocketChannelInitializer); channelFuture = serverBootstrap.bind().sync(); } finally { if (channelFuture != null && channelFuture.isSuccess()) { log.info("Netty server startup on port: {} (websocket) with context path '{}'", webSocketProperties.getPort(), webSocketProperties.getPath()); } else { log.error("Netty server startup failed."); if (bossGroup != null) bossGroup.shutdownGracefully().sync(); if (workGroup != null) workGroup.shutdownGracefully().sync(); } } } @Override public void destroy() throws Exception { log.info("Shutting down Netty server..."); if (bossGroup != null) bossGroup.shutdownGracefully().sync(); if (workGroup != null) workGroup.shutdownGracefully().sync(); if (channelFuture != null) channelFuture.channel().closeFuture().syncUninterruptibly(); log.info("Netty server shutdown."); } }
package com.litblue.im.config.netty; import cn.hutool.core.bean.BeanUtil; import cn.hutool.extra.spring.SpringUtil; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.litblue.api.client.GetUserInfoClient; import com.litblue.common.exception.UnauthorizedException; import com.litblue.common.utils.CollUtils; import com.litblue.im.config.properties.RequestUriUtils; import com.litblue.im.config.properties.WebSocketProperties; import com.litblue.im.service.IMsgContentService; import com.litblue.starter.cache.redis.RedisCache; import com.litblue.starter.cache.redis.RedisKeys; import com.litblue.starter.pojo.im.domain.MsgContent; import com.litblue.starter.pojo.im.properties.MsgContentProperties; import com.litblue.starter.pojo.user.domian.LitUserInfo; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.group.ChannelGroup; import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.websocketx.*; import io.netty.util.AttributeKey; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; /** * 通讯处理核心类,主要作用处理wss消息 */ @Slf4j @ChannelHandler.Sharable @Component public class NioWebSocketHandler extends SimpleChannelInboundHandler<WebSocketFrame> { @Autowired private NioWebSocketChannelPool webSocketChannelPool; @Autowired private WebSocketProperties webSocketProperties; @Autowired private RedisCache redisCache; @Autowired private IMsgContentService msgContentService; /** * 建立连接 * * @param ctx * @throws Exception */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { log.debug("客户端连接:{}", ctx.channel().id()); webSocketChannelPool.addChannel(ctx.channel()); super.channelActive(ctx); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { log.debug("客户端断开连接:{}", ctx.channel().id()); webSocketChannelPool.removeChannel(ctx.channel()); super.channelInactive(ctx); } @Override public void channelReadComplete(ChannelHandlerContext ctx) { ctx.channel().flush(); } /** * 收到消息进行处理 * * @param ctx * @param frame */ @Override protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) { // 根据请求数据类型进行分发处理 if (frame instanceof PingWebSocketFrame) { pingWebSocketFrameHandler(ctx, (PingWebSocketFrame) frame); } else if (frame instanceof TextWebSocketFrame) { textWebSocketFrameHandler(ctx, (TextWebSocketFrame) frame); } else if (frame instanceof CloseWebSocketFrame) { closeWebSocketFrameHandler(ctx, (CloseWebSocketFrame) frame); } } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { log.info("客户端请求数据类型:{}", msg.getClass()); if (msg instanceof FullHttpRequest) { fullHttpRequestHandler(ctx, (FullHttpRequest) msg); } super.channelRead(ctx, msg); } /** * 处理连接请求,客户端WebSocket发送握手包时会执行这一次请求 * * @param ctx * @param request */ private void fullHttpRequestHandler(ChannelHandlerContext ctx, FullHttpRequest request) { String uri = request.uri(); Map<String, String> params = RequestUriUtils.getParams(uri); log.debug("客户端请求参数:{}", params); String token = params.get("token").toString(); log.info("token:{}", token); Long userId = redisCache.getCacheObject(RedisKeys.DEFINE_TOKEN + token); if (userId == null) { throw new UnauthorizedException("尚未完成登录"); } log.info("userId==>,{}", userId); String route = params.get("route").toString(); if (params.containsKey("ack") && "true".equals(params.get("ack").toString())) { // 消息已读确认 String targetId = params.get("targetId").toString(); route += targetId; msgContentService.handleFinishMessageReading(userId.toString(), targetId); } //绑定连接用户 AttributeKey<String> userKey = AttributeKey.valueOf("user"); ctx.channel().attr(userKey).set(userId.toString()); //绑定连接的路由 AttributeKey<String> routeKey = AttributeKey.valueOf("route"); ctx.channel().attr(routeKey).set(route); // 判断请求路径是否跟配置中的一致 if (webSocketProperties.getPath().equals(RequestUriUtils.getBasePath(uri))) // 因为有可能携带了参数,导致客户端一直无法返回握手包,因此在校验通过后,重置请求路径 request.setUri(webSocketProperties.getPath()); else ctx.close(); } /** * 客户端发送断开请求处理 * * @param ctx * @param frame */ private void closeWebSocketFrameHandler(ChannelHandlerContext ctx, CloseWebSocketFrame frame) { webSocketChannelPool.removeChannel(ctx.channel()); try { channelInactive(ctx); ctx.close(); } catch (Exception e) { throw new RuntimeException(e); } } /** * 创建连接之后,客户端发送的消息都会在这里处理 * * @param ctx * @param frame */ private void textWebSocketFrameHandler(ChannelHandlerContext ctx, TextWebSocketFrame frame) { String userId = ctx.channel().attr(AttributeKey.valueOf("user")).get().toString(); log.info("当前登录的用户是:{}", userId); String text = frame.text(); MsgContentProperties msgContentProperties = JSON.parseObject(text, MsgContentProperties.class); //设置发送时间 msgContentProperties.setSendTime(new Date()); //聊天对话分享 if ("0".equals(msgContentProperties.getMsgType()) || "2".equals(msgContentProperties.getMsgType())) { handleWebCommonMessage(ctx, msgContentProperties); } else if ("1".equals(msgContentProperties.getMsgType())) { //文件分享 handleWebFileMessage(ctx, msgContentProperties); } else if ("3".equals(msgContentProperties.getMsgType())) { //图片分享 handleFileImageMessage(ctx,msgContentProperties); } else if ("4".equals(msgContentProperties.getMsgType())) { //视频通话邀约 handleInviteVideoCallMessage(ctx, msgContentProperties); } else if ("5".equals(msgContentProperties.getMsgType())) { //同意接收视频对话,通知加入ice handleAgreeVideoCallMessage(msgContentProperties); } else if ("6".equals(msgContentProperties.getMsgType())) { //通话开始,交换信令,offer,answer,candidate handleVideoCallMessage(msgContentProperties); } else { } } /** * 发送图片资源 * @param ctx * @param msgContentProperties */ private void handleFileImageMessage(ChannelHandlerContext ctx, MsgContentProperties msgContentProperties) { this.handleWebFileMessage(ctx,msgContentProperties); } /** * 处理文件分享 * * @param ctx * @param msgContentProperties */ private void handleWebFileMessage(ChannelHandlerContext ctx, MsgContentProperties msgContentProperties) { List<String> acceptUserId = msgContentProperties.getAcceptUserId(); List<Channel> channelList = findChannelByUserId(acceptUserId); AtomicReference<Boolean> isLook = new AtomicReference<>(false); channelList.stream().forEach(channel -> { this.updateMessageReadStatus(channel, msgContentProperties, isLook); }); // 处理消息心跳并返回给自己 handleAndSendHeartbeat(ctx, msgContentProperties, isLook.get()); //保存聊天记录 saveChatRecords(acceptUserId, msgContentProperties); } /** * 同意通话 * * @param msgContentProperties */ private void handleAgreeVideoCallMessage(MsgContentProperties msgContentProperties) { List<String> acceptUserId = msgContentProperties.getAcceptUserId(); List<Channel> channelList = findChannelByUserId(acceptUserId); Channel channel = channelList.get(0); channel.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(msgContentProperties))); } /** * 通话邀约 * * @param ctx * @param msgContentProperties */ private void handleInviteVideoCallMessage(ChannelHandlerContext ctx, MsgContentProperties msgContentProperties) { List<String> acceptUserId = msgContentProperties.getAcceptUserId(); Map<String, Object> messageMap = msgContentProperties.getMessageMap(); //设置来电铃声 messageMap.put("callMusic", "http://101.43.99.167:9000/blue-oss/r8nx5-0hhz7.mp3"); // 计算来电过期时间,当前时间加一分钟 Instant now = Instant.now(); Instant overdueTime = now.plus(Duration.ofMinutes(1)); messageMap.put("overdueTime", overdueTime.toString()); // 转换为字符串表示 List<Channel> channelList = findChannelByUserId(acceptUserId); if (CollectionUtils.isEmpty(channelList)) { //对方不在线 msgContentProperties.setIsRead(false); msgContentProperties.setReadName("未读"); msgContentProperties.setMsgContent("未接来电"); } else { Channel channel = channelList.get(0); channel.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(msgContentProperties))); } saveChatRecords(acceptUserId, msgContentProperties); } /** * 处理普通对话消息 * * @param ctx * @param msgContentProperties */ private void handleWebCommonMessage(ChannelHandlerContext ctx, MsgContentProperties msgContentProperties) { // 接收人id List<String> acceptUserId = msgContentProperties.getAcceptUserId(); AtomicReference<Boolean> isLook = new AtomicReference<>(false); List<Channel> channelList = findChannelByUserId(acceptUserId); //对方离线 if (CollectionUtils.isEmpty(channelList)) { //消息未读或者不在对话路由下面 msgContentProperties.setIsRead(false); msgContentProperties.setReadName("未读"); } channelList.stream().forEach(channel -> { this.updateMessageReadStatus(channel, msgContentProperties, isLook); }); // 处理消息心跳并返回给自己 handleAndSendHeartbeat(ctx, msgContentProperties, isLook.get()); //保存聊天记录 saveChatRecords(acceptUserId, msgContentProperties); } /** * 处理并返回消息心跳 * * @param ctx ChannelHandlerContext 上下文 * @param msgContentProperties 消息内容属性 * @param isLook 是否已读的状态 */ private void handleAndSendHeartbeat(ChannelHandlerContext ctx, MsgContentProperties msgContentProperties, boolean isLook) { // 创建新的消息属性对象并设置读取状态 MsgContentProperties properties = BeanUtil.copyProperties(msgContentProperties, MsgContentProperties.class); properties.setIsRead(isLook); properties.setReadName(isLook ? "已读" : "未读"); // 返回给自己心跳消息 ctx.channel().writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(properties))); } /** * 更新消息的已读/未读状态 * * @param channel 当前处理的Channel * @param msgContentProperties 消息内容属性 * @param isLook 是否已读的原子引用 */ private void updateMessageReadStatus(Channel channel, MsgContentProperties msgContentProperties, AtomicReference<Boolean> isLook) { String route = channel.attr(AttributeKey.valueOf("route")).get().toString(); String chatRoute = "chat" + msgContentProperties.getSendUserId(); if (chatRoute.equals(route)) { msgContentProperties.setIsRead(true); msgContentProperties.setReadName("已读"); isLook.set(true); channel.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(msgContentProperties))); } else { msgContentProperties.setIsRead(false); msgContentProperties.setReadName("未读"); isLook.set(false); } } /** * 处理rtc视频通讯 * * @param msgContentProperties */ private void handleVideoCallMessage(MsgContentProperties msgContentProperties) { String message = JSONObject.toJSONString(msgContentProperties); sendToUser(msgContentProperties.getAcceptUserId(), message); String sendUserId = msgContentProperties.getSendUserId(); List<String> sendUserIdList = Collections.singletonList(sendUserId); sendToUser(sendUserIdList, message); } private void sendToUser(List<String> userId, String message) { // 找到目标用户的 Channel 并发送消息 List<Channel> targetChannel = findChannelByUserId(userId); if (!CollectionUtils.isEmpty(targetChannel)) { targetChannel.stream().forEach(channel -> { /* String route = channel.attr(AttributeKey.valueOf("route")).get().toString(); if ("rtc".equals(route)) { channel.writeAndFlush(new TextWebSocketFrame(message)); }*/ channel.writeAndFlush(new TextWebSocketFrame(message)); }); } else { log.warn("用户 {} 的 Channel 未找到", userId); } } /** * 查找连接池对应Channel * * @param userId * @return */ public List<Channel> findChannelByUserId(List<String> userId) { ChannelGroup channels = webSocketChannelPool.getAllChannelGroup(); if (CollectionUtils.isEmpty(channels)) { return CollUtils.emptyList(); } List<Channel> channelList = channels.stream().filter(item -> { return userId.contains(item.attr(AttributeKey.valueOf("user")).get().toString()); }).collect(Collectors.toList()); return channelList; } /** * 处理客户端心跳包 * * @param ctx * @param frame */ private void pingWebSocketFrameHandler(ChannelHandlerContext ctx, PingWebSocketFrame frame) { ctx.channel().writeAndFlush(new PongWebSocketFrame(frame.content().retain())); } /** * 保存聊天记录 * * @param acceptUserId * @param msgContentProperties */ private void saveChatRecords(List<String> acceptUserId, MsgContentProperties msgContentProperties) { List<MsgContent> msgContents = acceptUserId.stream() .map(userId -> { MsgContent msgContent = BeanUtil.copyProperties(msgContentProperties, MsgContent.class); String messageMap = JSONObject.toJSONString(msgContentProperties.getMessageMap()); msgContent.setMessageMap(messageMap); msgContent.setSendUserIsRemove(false); msgContent.setAcceptUserIsRemove(false); msgContent.setAcceptUserId(String.valueOf(userId)); // Assuming acceptUserId is a String in MsgContent return msgContent; }) .collect(Collectors.toList()); this.msgContentService.saveMsgContents(msgContents); // Ensure this method saves a list of MsgContent objects } }
聊天记录持久化到mongodb
/** * 保存聊天记录 * * @param acceptUserId * @param msgContentProperties */ private void saveChatRecords(List<String> acceptUserId, MsgContentProperties msgContentProperties) { List<MsgContent> msgContents = acceptUserId.stream() .map(userId -> { MsgContent msgContent = BeanUtil.copyProperties(msgContentProperties, MsgContent.class); String messageMap = JSONObject.toJSONString(msgContentProperties.getMessageMap()); msgContent.setMessageMap(messageMap); msgContent.setSendUserIsRemove(false); msgContent.setAcceptUserIsRemove(false); msgContent.setAcceptUserId(String.valueOf(userId)); // Assuming acceptUserId is a String in MsgContent return msgContent; }) .collect(Collectors.toList()); this.msgContentService.saveMsgContents(msgContents); // Ensure this method saves a list of MsgContent objects }
package com.litblue.starter.pojo.im.properties; import com.litblue.starter.pojo.artwork.domain.LitArtworkInfo; import com.litblue.starter.pojo.artwork.vo.LitArtworkInfoVo; import lombok.Data; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; /** * 消息内容 */ @Data public class MsgContentProperties { /** * 发送类型 (0 群发 1 单发) */ private String sendType; /** * 消息类型 (0 文本对话 * 1 文件 * 2 作品分享 * 3 发送图片 * 4 发起视频对话请求 * 5 接收视频对话 * 6 拒绝视频对话) */ private String msgType; /** * 其他业务消息结构体 */ private Map<String,Object> messageMap = new HashMap<>(); /** * 消息内容 */ private String msgContent; /** * 发送人id */ private String sendUserId; /** * 接收人id(在两人对话聊天,或者分享的时候携带) */ private List<String> acceptUserId; /** * 群组id(在群发的时候携带) */ private String groupId; /** * 发送时间 */ private Date sendTime; /** * 消息是否已读(0已读 1未读) */ private Boolean isRead; /** * 消息状态名称 */ private String readName; }
@Override public void saveMsgContents(List<MsgContent> msgContents) { msgContentRepository.saveAll(msgContents); }