利用Netty,从零到一实现自己的简易实时聊天系统

400 阅读6分钟

1. 前言

先看实现效果:

20241203-154713.gif

由于篇幅有限,而且代码量还是有的,所以下面只给出重要部分的代码,详细大家可以去项目里面看,完整代码已经完全开源。(仓库链接在文章末尾)

到这里我们可以看到接下来要实现的内容包括:

  • 聊天主页面收到新消息后提示并刷新最新消息。
  • 消息已读/未读状态实时变更。
  • 消息实时收与发。
  • 聊天页面收到新消息或者发送了消息,聊天主页面消息展示同步更新。(视频未展示)

只要思路正确,最后实现出效果所产生的代码量并不是很多。

寥寥的四条实现效果,可能并不多,但是你要学习的东西却与之成反比例。

通过本篇文章您大至能够了解到或者学习到:

  • Netty的基础知识
  • websocket基础知识
  • 小程序开发基础知识(uni-app vscode开发版)
  • vue、vuex的一些内容

2. 前置知识准备

这里说一下实现这些功能需要具备的一些基础,读者可以进一步的深入了解,更有利于自身的学习与成长,仅仅介绍技术的基础,认识到这个是做什么的,不深入讨论某些功能。

2.1 简单介绍一下Netty

Netty是什么?Netty 是一个基于 Java 的高性能网络应用框架,主要用于开发客户端-服务器 (Client-Server) 的通信程序,支持多种传输协议(如 TCP、UDP、HTTP 等)。它是一个异步事件驱动的网络框架,能够大幅简化网络编程,特别是在高并发场景下。

  • 异步和事件驱动 基于事件驱动机制,通过 ChannelEventLoopFuture 等模型实现高效的异步通信。
  • 多协议支持 支持 TCP、UDP、HTTP/2、WebSocket 等协议,也可以自定义协议。
  • 高性能
    • 线程模型优化:采用 Reactor 模式,使用少量线程处理大量连接。
    • 内存优化:通过对象池和零拷贝技术优化内存使用。
  • 可扩展性 通过 ChannelHandlerPipeline 实现灵活的业务逻辑扩展。(重要,本文介绍的内容基于该特性实现)

它的一些应用场景:

  1. 即时通讯:IM 系统、聊天服务(如微信、QQ)。
  2. 网关系统:微服务网关、API 网关。
  3. 高性能 HTTP 服务:如 HTTP 反向代理、流媒体服务。
  4. 游戏服务器:需要长连接和低延迟的实时通信场景。
  5. 分布式系统:消息队列、RPC 框架的底层实现。

选择使用Netty,它有这比较丰富的API和工具,简化socket编程,在了解它的前提下可以基于它快速构建自己的应用。

我有一篇文章介绍了Netty的一部分源码内容,感兴趣的可以去研究,看源码是一项有挑战性的事情。Netty部分源码

2.2 WebSocket

WebSocket表示一种网络传输协议,位于OSI模型的应用层,并且依赖于传输层的TCP协议。它是一种不同于http的协议,但是RFC 6455中:it is designed to work over HTTP ports 80 and 443 as well as to support HTTP proxies and intermediaries(WebSocket通过HTTP端口80和443进行工作,并支持HTTP代理和中介),为了实现兼容性,WebSocket握手使用HTTP Upgrade头从HTTP协议更改为WebSocket协议。

WebSocket协议支持Web浏览器(或其他客户端应用程序)与Web服务器之间的交互,具有较低的开销,便于实现客户端与服务器的实时数据传输。

2.3 开发工具

使用的是uniapp跨端开发语言,实际使用和vue一样,只是有一些新组件,看官方文档就知道如何使用。

uni-app官网

开发工具使用的是vscode,至于如何在vscode上面开发uniapp,要先调教一下vscode,笔者是参考的这篇文章。

vscode开发uni-app

小程序开发工具下载

微信开发者工具

可以去微信开发者平台注册一个账号,拿到appid后面方便自己调试使用。

微信开放平台

3. 数据库设计

看到这里,相信你已经对用到的技术和工具有了大致的了解,现在下面开始动手如何去实现。

首先我们需要一张消息表来记录各自收发的信息,不能离开聊天界面把消息都丢了,当然如果你不需要存储消息,只是即时聊天,也可以不用搞一个表去存储消息。

create table tbl_message
(
    id                  varchar(32) primary key,
    message_type        smallint      not null comment '消息类型:待办通知:0 申请结果通知:1',
    read_state          smallint      not null default 0 comment '阅读状态:0未读,1已读',
    content             varchar(1024) not null comment '消息内容',
    message_receiver_id varchar(32)   not null comment '消息接收者id',
    message_sender_id   varchar(32)   not null comment '消息发送者id',
    create_time         bigint        not null comment '创建时间',
    update_time         bigint        not null comment '修改时间',
    constraint message_receiver_fk foreign key (message_receiver_id) references tbl_user (id),
    constraint message_sender_fk foreign key (message_sender_id) references tbl_user (id)
) comment '消息表';

避免篇幅过长这里的用户表就不贴出了。

位置在这里:src/main/resources/db/migration/V1.0__init.sql

3. 后端

netty的依赖要引入到项目中:

<!-- https://mvnrepository.com/artifact/io.netty/netty-all -->
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.110.Final</version>
</dependency>

创建Netty启动类:

@Component
@Slf4j
public class NettyBootstrapRunner implements ApplicationRunner, ApplicationListener<ContextClosedEvent>, ApplicationContextAware {

    @Value("${netty.websocket.port}")
    private int port;

    @Value("${netty.websocket.pemFile}")
    private String pemFile;

    @Value("${netty.websocket.keyFile}")
    private String keyFile;

    private Channel serverChannel;

    private ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 使用自签名证书进行 SSL 配置
        ApplyRoomRecordConfig globalConfig = applicationContext.getBean(ApplyRoomRecordConfig.class);
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.group(bossGroup, workerGroup);
            serverBootstrap.channel(NioServerSocketChannel.class);
            serverBootstrap.localAddress(new InetSocketAddress(port));
            serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel socketChannel) throws Exception {
                    ChannelPipeline pipeline = socketChannel.pipeline();
                    addSslHandler(pipeline, socketChannel, globalConfig);
                    pipeline.addLast(new HttpServerCodec());//请求解码器
                    pipeline.addLast(new HttpObjectAggregator(65536));//将多个消息转换成单一的消息对象
                    pipeline.addLast(new ChunkedWriteHandler());//支持异步发送大的码流,一般用于发送文件流
                    pipeline.addLast(new WebSocketServerCompressionHandler());//压缩处理
                    pipeline.addLast(applicationContext.getBean(UserAuthHandler.class));// 用户认证处理
                    // 参数配置请百度
                    pipeline.addLast(new WebSocketServerProtocolHandler("/websocket", null, true, 16384, false, true, 60000L));//websocket协议处理
                    pipeline.addLast(applicationContext.getBean(SocketConnectedHandler.class)); // 自定义处理器,处理消息发送与在线统计
                }
            });
            serverChannel = serverBootstrap.bind().sync().channel();
            log.info("websocket 服务启动,port={}", this.port);
            serverChannel.closeFuture().sync();
        } catch (Exception e) {
            log.error(e.getMessage());
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        if (this.serverChannel != null) {
            this.serverChannel.close();
        }
        log.info("websocket 服务停止");
    }

    private void addSslHandler(ChannelPipeline pipeline, SocketChannel socketChannel, ApplyRoomRecordConfig globalConfig) {
        // 取决于你的配置,如果配置了是,那么请您同时配置pem和key文件
        // 这两个文件请放在resource目录下
        if (globalConfig.getUseWebsocketSSL()) {
            try {
                ClassPathResource pem = new ClassPathResource(pemFile);
                ClassPathResource key = new ClassPathResource(keyFile);
                SslContext sslCtx = SslContextBuilder.forServer(pem.getStream(), key.getStream()).build();
                pipeline.addLast(applicationContext.getBean(HttpRequestCheckHandler.class));
                pipeline.addLast(sslCtx.newHandler(socketChannel.alloc()));  // 添加 SSL 处理
            } catch (SSLException e) {
                throw new RuntimeException(e);
            }
        }

    }
}

这样随着项目的启动而运行,项目的停止而停止。

下面就是Netty启动后我的事件驱动重要的几个自定义部分,UserAuthHandler负责身份的认证,SocketConnectedHandler负责消息的转发。

UserAuthHandler是根据url中拼接的token参数来验证身份的,项目中使用的权限框架时sa-token,所以我们根据token就能拿到用户的登录id。StpUtil.getLoginIdByToken(token)根据获取的id是否为空判断用户是否登录,然后将获取的userId存放到channel的attr中,因为后面的handler会用到。

@ChannelHandler.Sharable
@Component
@Slf4j
public class UserAuthHandler extends SimpleChannelInboundHandler<FullHttpRequest> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) {
        String token = String.valueOf(UrlQuery.of(msg.uri(), Charset.defaultCharset()).get("token"));
        String platform = String.valueOf(UrlQuery.of(msg.uri(), Charset.defaultCharset()).get("platform"));
        // 是否是授权访问socket
        String userId = (String) StpUtil.getLoginIdByToken(token);
        if (StrUtil.isEmpty(token) || StrUtil.isEmpty(userId)) {
            errorResponse(ctx);
            ctx.channel().close();
            return;
        }
        if (StrUtil.isEmpty(platform)) {
            log.warn("Platform param is invalid. Param is {}.", platform);
            errorResponse(ctx);
            ctx.channel().close();
            return;
        }
        // 信息记录流转到业务逻辑中
        ctx.channel().attr(AttributeKey.valueOf("userId")).set(userId);
        ctx.channel().attr(AttributeKey.valueOf("platform")).set(platform);
        ctx.fireChannelRead(msg.retain());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        super.exceptionCaught(ctx, cause);
    }

    private void errorResponse(ChannelHandlerContext ctx) {
        FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED);
        ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
    }
}

下面时重要的消息处理逻辑SocketConnectedHandler:

我们约定前后端的消息交互格式:

{"content":"Chrome131.0(Windows)","type":"device","toUserId":""}

会有不同类型的消息。

@ChannelHandler.Sharable
@Component
@Slf4j
public class SocketConnectedHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) {
        // 心跳检测信息
        if (msg.text().equalsIgnoreCase("ping")) {
            ctx.writeAndFlush(new TextWebSocketFrame("pong"));
        }
        // 这里可以考虑使用不同的manager处理不同type的消息,将功能解耦
        if (StrUtil.isNotEmpty(msg.text()) && !"ping".equalsIgnoreCase(msg.text())) {
            JSONObject jsonObject = JSON.parseObject(msg.text());
            String type = jsonObject.getString("type");
            // 用户第一次进入app,发送的设备信息,通知其他监听的用户
            if (type.equals(SocketMsgType.DEVICE.getType())) {
                // 告知当前设备消息发送成功
                ctx.writeAndFlush(new TextWebSocketFrame("ok"));
                UserOnlineManager.addChannel(ctx.channel(), msg.text());
                UserOnlineManager.broadCastToOnlineUser();
            } else {
                // 用户发送的消息,通知对应的用户,如果在线的话
                UserOnlineManager.sendMessage(msg.text(), jsonObject.getString("toUserId"));
            }
        }
    }

    @Override
    public void channelUnregistered(ChannelHandlerContext ctx) {
//        log.info("用户断开连接:{}", ctx.channel().attr(AttributeKey.valueOf("userId")).get());
        UserOnlineManager.removeChannel(ctx.channel());
        UserOnlineManager.broadCastToOnlineUser();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        if (cause instanceof NotSslRecordException) {
            // 处理 SSL 相关的异常
            log.error("SSL 请求异常,非https请求, 异常请求channel: {}, message is {}", ctx.channel(), cause.getMessage());
        } else if (cause instanceof IOException) {
            // 处理 I/O 异常
            log.error("I/O 异常:{}", cause.getMessage());
        } else if (cause instanceof DecoderException) {
            log.error("请求解码异常, 异常请求channel: {},已关闭. message is {}", ctx.channel(), cause.getMessage());
        } else {
            // 处理其他异常
            log.error("未知异常:", cause);
        }
        // 关闭连接
        UserOnlineManager.removeChannel(ctx.channel());
        UserOnlineManager.broadCastToOnlineUser();
    }
}

UserOnlineManager负责控制和存储各个channel,这里也会存储用户在线设备信息,推送给管理端实时用户在线信息。

@Component
@Slf4j
public class UserOnlineManager {

    private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);

    private static final ConcurrentMap<Channel, String> channelToSource = new ConcurrentHashMap<>();

    private static final ConcurrentMap<String, List<String>> userIdToSources = new ConcurrentHashMap<>();

    private static final ConcurrentMap<Channel, String> channelToPlatform =  new ConcurrentHashMap<>();

    private static final ConcurrentMap<String, Channel> userIdToChannel =  new ConcurrentHashMap<>();

    public static void addChannel(Channel channel, String source) {
        String userId = String.valueOf(channel.attr(AttributeKey.valueOf("userId")).get());
        String platform = String.valueOf(channel.attr(AttributeKey.valueOf("platform")).get());
        if (platform.equals("wx") ) {
            userIdToChannel.put(userId, channel);
        }
        channelToPlatform.put(channel, platform);
        channelToSource.put(channel, source);
        List<String> sources = userIdToSources.getOrDefault(userId, new ArrayList<>());
        sources.add(source);
        userIdToSources.put(userId, sources);
    }

    public static void removeChannel(Channel channel) {
        try {
            lock.writeLock().lock();
            channel.close();
            String source = channelToSource.getOrDefault(channel, null);
            channelToPlatform.remove(channel); // 移除连接平台信息
            if (StrUtil.isNotEmpty(source)) {
                String userId = String.valueOf(channel.attr(AttributeKey.valueOf("userId")).get());
//                log.info("移除用户连接{}", userId);
                List<String> sources = userIdToSources.getOrDefault(userId, new ArrayList<>());
                // 删除
                sources.remove(source);
                channelToSource.remove(channel);
                userIdToChannel.remove(userId);
                // 完全下线
                if (sources.isEmpty()) {
                    userIdToSources.remove(userId);
                }
            }
        } finally {
            lock.writeLock().unlock();
        }

    }

    public static ConcurrentMap<String, List<String>> getOnlineUserIdToSources() {
        return userIdToSources;
    }

    public static void broadCastToOnlineUser() {
        try {
            lock.readLock().lock();
            Set<Channel> channels = channelToSource.keySet();
            for (Channel channel : channels) {
                String platform = channelToPlatform.get(channel);
                // web管理的需要通知有新用户并展示
                if ("web".equals(platform)) {
                    if (channel.isActive() && channel.isOpen() && channel.isWritable() && channel.isRegistered()) {
//                        log.info("转发给用户{}更新信息", channel.attr(AttributeKey.valueOf("userId")).get());
                        channel.writeAndFlush(new TextWebSocketFrame(IdUtil.fastSimpleUUID()));
                    } else {
                        //log.info("remove channel: {}", channel.id());
                        removeChannel(channel);
                    }
                }
            }
        } finally {
            lock.readLock().unlock();
        }
    }

    public static void sendMessage(String message, String toUserId) {
        Channel channel = userIdToChannel.get(toUserId);
        if (channel != null && StrUtil.isNotEmpty(toUserId) && StrUtil.isNotEmpty(message)) {
            if (channel.isActive() && channel.isOpen() && channel.isWritable() && channel.isRegistered()) {
                // 通知接收者
                channel.writeAndFlush(new TextWebSocketFrame(message));
            } else {
                removeChannel(channel);
            }
        }
    }
}

所有代码都在netty包下。

4. 前端

从演示的视频可以看到,我们需要在聊天列表页面和聊天页面使用到socket接收和发送信息,所以使用vuex创建一个全局websocket,结合vue的数据变更监听来实现实时显示。

全局websocket:

image-20241203151250844

socket的链接实在应用启动的时候,写在了App.vue中。

import indexConfig from "@/config/index.config.js";
import { getToken } from "@/utils/auth.js";

const MAX_RECONNECT_ATTEMPTS = 5; // 最大重连次数
const socketData = {
  state: {
   socketTask: null, //socket实例

   websocketData: '', // 存放从后端接收到的websocket数据

   webSocketPingTimer: null, // 心跳定时器

   webSocketPingTime: 5000, // 心跳的间隔,当前为 10秒,

   webSocketReconnectCount: 1, // 重连次数

   webSocketIsReconnect: true, // 是否重连

   webSocketIsOpen: true, //链接是否在打开
  },
  getters: {
   websocketData(state) {
    return state.websocketData;
   },
  },
  mutations: {
   setSockTask(state, data) {
    state.socketTask = data;
   },

   setWebsocketData(state, data) {
    state.websocketData = data;
   },

   setReconnectCount(state, count) {
    state.webSocketReconnectCount = count;
   },

   setIsOpen(state, data) {
    state.webSocketIsOpen = data;
   },
  },

  actions: {
   websocketInit({ state, dispatch, commit }) {
    let socketTask = uni.connectSocket({
     // url, // url是websocket连接ip
     url: `${indexConfig.socketUrl}?token=${getToken()}&platform=wx`,
     // 我只关心我的socket连接成功还是失败,你给我socketTask对象我自己调用其中的方法进行处理即可,不用关心uni接口调用成功还是失败。
     complete: () => {}, // 回调函数
    });
    //检测链接打开
    socketTask.onOpen(() => dispatch("websocketOnOpen"));
    //接收服务器消息
    socketTask.onMessage((res) => dispatch("websocketOnMessage", res));
    // 链接关闭事件
    socketTask.onClose((e) => dispatch("websocketOnClose"));
    //链接错误
    socketTask.onError((e) => dispatch("websocketOnError"));
    commit("setSockTask", socketTask);
   },
   // 连接打开时
   websocketOnOpen({ dispatch, commit }) {
    console.log("WebSocket连接正常打开!");
    commit("setIsOpen", true);
    commit("setReconnectCount", 1); // 重置重连计数
    // 发送在线设备信息
    uni.getSystemInfo({
     success: (e) => {
      const device = e.deviceBrand + "(" + e.deviceType + ")";
      let msgBody = {
       content: device,
       type: "device",
       toUserId: "",
      };
      // console.log('发送设备信息中');
      dispatch("websocketSend", JSON.stringify(msgBody));
     },
    });
    //开始心跳检测
    dispatch("webSocketPing");
   },
   // 发送数据
   websocketSend({ state, dispatch }, data) {
    state.socketTask.send({
     data,
     fail: (e) => {
      console.log("uni send接口调用失败", e);
      dispatch("webSocketClose");
     },
    });
   },
   // 收到数据
   websocketOnMessage({ commit }, res) {
    // 修改状态为未连接
    //接到推送的消息--显示全局弹窗
    // 存储消息
      // console.log('收到服务器内容', res)
    if (res.data !== "pong" && res.data !== 'ok') {
     // 心跳不记录
        // console.log("收到服务器内容:" + res.data);
     commit("setWebsocketData", res.data);
    }
   },
   websocketOnClose({ state, commit, dispatch }) {
    //服务端连接关闭,或者遇到error时触发
    commit("setIsOpen", false);
    clearTimeout(state.webSocketPingTimer); // 清理心跳定时器
    state.socketTask = null;
    console.log("websocketOnClose连接关闭");
   },
   // socket连接异常
   websocketOnError({ commit, dispatch }) {
    //链接关闭执行
    console.log("websocketOnError连接错误");
    dispatch("webSocketClose");
   },
   // 定时心跳告诉服务器自己还活着,防止丢包
   webSocketPing({ state, dispatch }) {
    if (getToken() && state.webSocketIsOpen) {
     state.webSocketPingTimer = setTimeout(() => {
      // console.log("发送心跳,ping");
      dispatch("websocketSend", "ping");
      clearTimeout(state.webSocketPingTimer); // 清理当前心跳定时器
      dispatch("webSocketPing"); // 重新开始心跳
     }, state.webSocketPingTime);
    }
   },
   // 连接断开做的处理
   webSocketClose({ state, dispatch, commit }) {
    if (getToken()) {
     commit("setIsOpen", false);
     clearTimeout(state.webSocketPingTimer); // 清理心跳定时器
     state.socketTask = null;
     if (
      state.webSocketIsReconnect &&
      state.webSocketReconnectCount <= MAX_RECONNECT_ATTEMPTS
     ) {
      dispatch("webSocketReconnect");
     }
    }
   },
   // WebSocket 重连
   webSocketReconnect({ state, dispatch, commit }) {
    if (
     getToken() &&
     !state.webSocketIsOpen &&
     state.webSocketReconnectCount <= MAX_RECONNECT_ATTEMPTS
    ) {
     console.log(`第 ${state.webSocketReconnectCount} 次重连`);
     commit("setReconnectCount", state.webSocketReconnectCount + 1);
     dispatch("websocketInit"); // 尝试重连
     // 5秒后检查是否重连成功,否则继续尝试
     setTimeout(() => {
      if (
       !state.webSocketIsOpen &&
       state.webSocketReconnectCount <= MAX_RECONNECT_ATTEMPTS
      ) {
       dispatch("webSocketReconnect");
      }
     }, 5000);
    }
   },
   //手动关闭websocket
   websocketCloseGuanBi({ state }) {
    if (!state.socketTask) return;
    state.socketTask.close({
     complete: () => {},
    });
   },
  },
};

export default socketData;

这里会将接收到的socket消息存储下来,我们不同的页面根据不同的消息type来做不同的逻辑。

在首页实现消息提醒。

export default {
    computed: {
      ...mapGetters(["websocketData"]),
    },
    watch: {
      websocketData: {
       handler(newValue, oldValue) {
        // 只有在当前页面的时候执行此逻辑
        const pages = getCurrentPages();
        const currentPage = pages[pages.length - 1];
        if (currentPage.route === "pages/index") {
         console.log("home收到新消息");
         let newMsgBody = JSON.parse(newValue);
         if (newMsgBody.type === "user_message") {
          // 收到新消息
          let newMsg = newMsgBody.content;
          this.$refs.tips.show({
           msg: "收到新消息,已为您刷新",
           backgroundColor: "rgba(0, 0, 0, 0.4)",
           fontColor: "#FFFFFF",
           duration: 3000,
          });
          // 刷新对应tab
          this.getMessageList(newMsg.messageType);
         }
        }
       },
       deep: true, // 深度监听对象内的属性变化
      },
    },
}

完整代码位置:pages/component/home.vue

消息聊天界面:

export default {
    computed: {
      ...mapGetters(["websocketData"]),
    },
    watch: {
      websocketData: {
       handler(newValue, oldValue) {
        // 在这里处理websocketData变化后的逻辑
        // console.log("socket 数据发送变化", newValue);
        let newMsgBody = JSON.parse(newValue);
        if (newMsgBody.type === "user_message") {
         // 收到新消息
         let newMsg = newMsgBody.content;
         if (
          newMsg.messageSenderId === this.senderId &&
          newMsg.messageReceiverId === this.senderId
         ) {
          newMsg.type = 1;
         } else {
          newMsg.type = newMsg.messageSenderId === this.curUserId ? 1 : 0;
         }
         this.talkList.push(newMsg);
         this.$nextTick(() => {
          uni.pageScrollTo({
           scrollTop: 999999, // 设置一个超大值,以保证滚动条滚动到底部
           duration: 0,
          });
         });
         setMessageToReadApi([newMsg.id]).then(() => {
          this.notifyMsgToRead([newMsg.id]);
          newMsg.readState = 1;
         });
         uni.$emit("receiveNewMsg", { newMsg, index: this.curMsgIndex });
        } else if (newMsgBody.type === "user_message_read") {
         // 消息列表中id相同的置为已读
         let msgIds = newMsgBody.content;
         // talkList中id于msgIds相同的
         this.talkList.forEach((item) => {
          if (msgIds.includes(item.id)) {
           item.readState = 1;
          }
         });
        }
       },
       deep: true, // 深度监听对象内的属性变化
      },
    },
}

这里也是实现了其他功能,如通知消息列表页面刷新消息动态,消息内容。

位置:pages/message/index.vue

5. 占位

后端代码

管理客户端

客户端

管理端项目有演示版本可查看

www.mushanyu.xyz

登录账号:202300001,密码:123456