从0开发IM,单聊群聊在线离线消息以及消息的已读未读功能

3,127 阅读12分钟

前言

以下是自己参考过的资料:

IM 群聊消息究竟是存 1 份(即扩散读)还是存多份(即扩散写)?

IM 群聊消息的已读回执功能该怎么实现?

求教 IM 离线消息,单聊和群聊是存一张表好还是分开存?

如何保证 IM 实时消息的“时序性”与“一致性”?

IM 群聊消息如此复杂,如何保证不丢不重?

......

即时通讯网 上有很多非常好的资料分享,如果想深入了解IM服务背后的知识,可以多看看

本文默认使用 socket.io 来实现IM服务的基本协议,因为该库实现了很多实用的功能,其中就是经典的 请求-响应 API

正文

首先,我们得对 一条消息的发出到被接收 这一过程有一个最基本的概念:

1. 消息从客户端初始化并发出
2. 服务端接收到客户端发的消息
3. 服务端开始处理,从消息体拿到接收者userId,判断在线状态:
      在线:服务端实时推送
      不在线:服务端将消息写入数据库记为离线消息,用户下次登录获取所有离线消息
4. 客户端接收到消息(不管是通过在线时实时推送,还是登录时通过拉取离线消息获取到的)

有了这些,那么我们正式开始。

是否应该为每一条消息都往数据库存一份

这个问题我们要对单聊消息和群聊的消息分开来讲:

单聊

首先我们先说结论:其实这样是可以的。但是,会有一点小问题。因为单聊情况下是不存在消息冗余的:一个用户给另一个用户发送消息,落到数据库中肯定只会写入一条数据。

对于在线消息,完全没问题。对于离线消息的处理,我们也可以通过只在接收方用户离线的时候才创建一条数据记录,或者往消息体里面增加一个 status 字段来将消息标记为离线消息,然后用户下次登录的时候拉取所有标记为离线的消息。

对应两种消息体:
{
  sender: userid,
  receiver: userid,
  content: "",
  ...
}

{
  sender: userid,
  receiver: userid,
  content: "",
  status: "not received" or "received"
  ...
}

但是会存在的小问题就是:用户在下次登录时获取所有离线消息的时候,服务端只能一条条的跟客户端确认对方成功收到哪一条消息,然后再从库删掉对应的消息或者更改它们的状态status为已接收状态。为了保证消息不丢,必须这么做。

群聊

结论:这种方案是不可以的,这对于数据库的储存存在很大的压力和挑战。试想一个200人的群,其中一个用户发送一条消息,那么数据库就要储存200条记录,而且都是重复的。

很容易想到的优化是: 群消息实体存储一份,用户只冗余消息ID。

那么基础数据可以由:

user_msgs(uid,msgid,groupid,sender,receiver,time,content);

优化为:

group_msgs(msgid,groupid,sender,receiver,time,content);  
user_msgs(uid, msgid, groupid);

但是根本问题还是存在,同一个用户 user_msgs 还是会存在多条记录。

给出消息储存的最终方案

无论是单聊还是群聊的消息都只存一份,而且存在一张表即可。单聊和群聊消息需要相关的信息字段其实都差不多,只不过需要通过一个字段来区分是单聊还是群聊的消息,这一点可以通过 groupId 来判断,放在一张表好处是用户在线时 拉取 离线消息时,只需要查询一张表。那么我们有了最基本的消息的格式:

type Message = {
  id: bigint; // primaryKey autoIncrement 唯一递增 这一点很重要 保证消息的“偏序”特性
  msgId: string; // uuid即可
  type: Common.MsgType; // 一个 enum:text image video audio 表示消息的类型
  sender: number; // sender userId
  groupId?: number; // if group message
  receiver: number; // 单聊是receiver的userId 群聊传groupId即可,因为接收者应该是该群的所有成员
  content: string; // 消息内容
  timer: string; // 时间戳
  ext?: string; // 额外的字段
};

数据库储存消息的表按照这样设计即可。

离线消息

但是这样我们应该怎样去判断对于一个用户来说哪些消息是离线消息呢,因为消息都是有顺序且递增的,那么我们可以这么设计:新建一张表,保存用户上一次接收到的message的id。

// MessageAck Model表 用来记录用户上一次接收到的最后一条消息
// 只需要两个字段 接收者userId 和 lastAck(last ack msgId)
type MesssageAck = {
  receiver: number; // 接收者 userId
  lastAck: bigint; // last ack msgId
}

对于单聊,那么我们就可以通过 select * from Messages where id > lastAck 来获取到所有的离线消息了,群聊则是 select * from Messages where (userid in groupId' members) & id > msgId(伪代码),然后在用户登录拉取离线消息时,更新数据库中 lastAck 为最后一条 message 的 id 即可。

在线实时推送

对于在线用户,服务端实时推送消息,成功则更新 lastAck 为此次推送消息的 id,失败则不需要处理。这一切好像都没有问题,但是存在很大的问题,会导致出现丢消息的情况。比如服务端向客户端连续推送三个消息,然后第一条失败,第二条第三条都成功,那么此时 lastAck 会是第三条消息的id,那么发送失败的第一条消息(id < lastAck)就会丢失。还有服务器一般都是在并发的情况下给客户端推送消息的,这种情况下也是一样会出现丢消息的情况。

// 还是通过例子来解释为什么消息不能并发推送
客户端A向客户端B(在线)发送了三条消息:`msgId1 msgId2 msgId3`,服务端向B并发推送这三条消息

此时`msgId1 和 msgId3`成功,`msgId2`失败或者超时
那么推送`msgId3`消息成功时会将 `lastAck` 设置为 `msgId3`
`msgId2` 消息会丢失
因为通过 `id > lastAck` 不能判断出`msgId2`丢失了(下次用户在线离线消息也没有该消息)
保证不了 `msgId2` 消息都在发送成功的消息的最后。

这个问题好像很棘手...但是其实也可以解决这个问题。

解决丢消息的问题(本文的重点)

丢消息的根本原因:在这种方案下,保证不了 lastAck 的值一定是最后一次推送成功的消息的id。这样就算有推送失败的消息,这时候发送失败的消息的id比 lastAck 要大,我们就可以通过 select * from Messages where id > lastAck 重新又获取到这些发送失败的消息了,虽然客户端会收到重复的消息,但是客户端通过消息的id做去重处理即可。

那么要解决这个问题来保证消息不丢,那么在此期间,lastAck 的值一定需要比最早推送失败时消息的 msgId 要小,这里 最早 消息的解释:比如在此期间产生了 msgId1 msgId2 msgId3 msgId4... 等消息, msgId2 & msgId4 失败,那么在此期间需要保证最终的 lastAck 比 msgId2 要小即可(特别是在并发情况下)。

只要问题原因和解决方向之后,那么开始解决:

  1. 首先,在数据库表 ModelAck Model 中新增一个字段 lastAckErr,用来记录推送失败的任务的 msgId。
type MesssageAck = {
  receiver: number; // 接收者 userId
  lastAck: bigint; // last ack msgId
  lastAckErr: bigint; // last ack error msgId
}
  1. 借助 lastAckErr 字段来保证 lastAck 一定比最早推送失败消息的msgId小,具体代码逻辑:

    当服务器开始进入到队列中开始推送消息时,存在两种情况:消息推送成功 & 消息推送失败或者超时(也是失败)。

    在消息推送失败时,需要去更新数据库中 lastAcklastAckErr 的值,不过需要加上判断:

     // 约定推送失败消息的id为 msgId1
     当 lastAckErr === null 时,更新 lastAckErr 为 msgId1,
     如果 msgId1 <= lastAck,更新 lastAck 为 msgId1 - 1
    
     当 lastAckErr !== null 时,如果 msgId1 <= lastAckErr,更新 lastAckErr 为 msgId1,
     如果 msgId1 <= lastAck,更新 lastAck 为 msgId1 - 1
    

    在消息推送成功时,也需要去更新数据库中 lastAcklastAckErr 的值,仍然需要加上判断:

     // 约定推送成功消息的id为 msgId1
     当 lastAckErr === null && msgId1 > lastAck 时,更新 lastAck 为 msgId1
    
     当 lastAckErr !== null :
       msgId1 < lastAckErr 则更新 lastAck 为 msgId1
       msgId1 >= lastAckErr 则不做处理
    
  2. 此时,可以保证了最终的 lastAck 一定比最早推送失败消息的msgId小。那么客户端就可以通过 id > lastAck 来重新获取到丢失的消息,对于客户端会拉取到重复的消息,只需要根据 id 做去重处理即可。需要注意的是,在客户端通过 id > lastAck 来重新获取到所有消息(登录时获取所有离线消息也符合这种情况)的时候,需要在将lastAck设置为消息列表中最后一条消息的msgIdn的同时,还需要将lastAckErr更新为null

那么至此,我们的方案在保证消息的可靠不丢的同时也解决了消息储存压力的问题,更主要的是,批量拉取离线消息的时候可以只通过一次操作(更新lastAck)来告知服务器用户收到了已经收到了哪些消息。

可能直接这么解释还不没讲清楚,看这个例子:

比如此时队列中有四个推送消息的任务 msgId1 msgId2 msgId3 msgId4(约定此时 lastAckmsgId0lastAckErrnull ),因为是并发执行,所以所有任务的执行先后顺序可以是任意:

先说消息都发送成功的情况:

所有任务推送成功的回调处理函数中都符合 `lastAckErr === null`,那么此时通过 `msgId > lastAck` 来更新 `lastAck` 的值,
不管这几个任务执行顺序是怎样,最终 `lastAck` 的值一定为 `msgId4`。
如 `msgId1` 先执行完,更新 `lastAck``masgId1`,
然后 `msgId4` 执行完,更新 `lastAck``msgId4`,
然后不管 `msgId2 msgId3` 任务以什么顺序执行,都不合符 `msgId > lastAck`,
那么 lastAck 最终为 `msgId4`

然后消息都发送失败(我们应该不会写出这样的服务吧...):

假设 `msgId2` 最先执行完,
此时 `lastAckErr !== null` 更新 `lastAckErr``msgId2`,
因为不符合 `msgId2 <= lastAck(msgId0)`,所以 `lastAck` 仍为 `msgId0`;

然后 `msgId4` 执行完,
此时 `lastAckErr !== null`,因为不符合 `msgId4 <= lastAckErr(msgId2)`,所以 `lastAckErr` 仍为 `msgId2`,
然后又因为不符合 `msgId4 <= lastAck(msgId0)`,所以 `lastAck` 仍为 `msgId0`;

接着 `msgId1` 执行完,
此时`lastAckErr !== null`,因为符合 `msgId1 <= lastAckErr(msgId2)`,所以设置 `lastAckErr``msgId1`,
但是因为不符合 `msgId1 <= lastAck(msgId0)`,所以 `lastAck` 仍为 `msgId0`;

最后 `msgId3` 执行完毕,
此时`lastAckErr !== null`,因为不符合 `msgId3 <= lastAckErr(msgId1)`,所以 `lastAckErr` 仍为 `msgId1`,
然后又因为不符合 `msgId3 <= lastAck(msgId0)`,所以 `lastAck` 仍为 `msgId0`// 流程
`msgId2` 执行完, lastAck: msgId0  lastAckErr: msgId2
`msgId4` 执行完, lastAck: msgId0  lastAckErr: msgId2
`msgId1` 执行完, lastAck: msgId0  lastAckErr: msgId1
`msgId3` 执行完, lastAck: msgId0  lastAckErr: msgId1

所以在消息推送都失败的情况下也能完美找出 最早(msgId值最小) 出现错误的 msgId1 消息,以及保证 lastAck 的值是比 msgId1 小的。

最后是消息有发送成功有发送失败的情况(也是最复杂的一种情况):

假设 `msgId2 msgId4` 失败 & `msgId1 msgId3` 成功:

首先 `msgId3` 执行完,
因为符合 `lastAckErr === null && msgId3 > lastAck(msgId0)`,
所以更新 `lastAck``msgId3`,此时 `lastAckErr` 仍为 `null`;

然后 `msgId2` 执行完,
因为符合 `lastAckErr === null`,更新 `lastAckErr``msgId2`,
又因为符合 `msgId2 <= lastAck(msgId3)`,所以更新 `lastAck``msgId2 - 1,即 msgId1`。
此时 `lastAck``msgId1``lastAckErr``msgId2`;

其次 `msgId1` 执行完,
因为符合 `lastAckErr !== null && msgId1 < lastAckErr(msgId2)`,所以更新 `lastAck``msgId1`,
此时 `lastAck``msgId1``lastAckErr` 仍为 `msgId2`;

最后 `msgId4` 执行完,
因为符合 `lastAckErr !== null && msgId4 >= lastAckErr(msgId2)`,所以不做任何处理,
最终 `lastAck``msgId1``lastAckErr``msgId2`// 流程
`msgId3` 执行完, lastAck: msgId3  lastAckErr: null
`msgId2` 执行完, lastAck: msgId1  lastAckErr: msgId2
`msgId1` 执行完, lastAck: msgId1  lastAckErr: msgId2
`msgId4` 执行完, lastAck: msgId1  lastAckErr: msgId2

所以这种假设情况下也能完美找出 最早(msgId值最小) 出现错误的 msgId1 消息,以及保证 lastAck 的值是比 msgId1 小的。消息有发送成功有发送失败的情况下,我只是举例验证了这一种具体情况,如果更换成其他执行顺序或者任务成功和失败情况,该方案仍然有效,如果不清楚,可以自己再举例思考一下。

如这种执行顺序: m1 => m3 => m4 => m2
m1 m3 lastAckErr: null lastAck: m3
m4 lastAckErr: m4 lastAck: m3
m2 lastAckErr: m2 lastAck: m2 - 1 => m1
仍然顺利找出

消息的已读未读功能实现

要实现消息的已读未读功能,其实跟上面保证消息不丢的 lastAck 方案一样,我们再新建一张表:

// MessageRead Model表 
type MessageRead = { 
  sender: number; // 发送者 userId or groupId(群消息时,sender就是为groupId) 
  receiver: number; // 接收者 userId 
  lastRead: bigint; // last read msgId 
}

但是消息的已读过程比 lastAck 方案简单一些,不需要一定保证每个已读消息的可靠性:

  1. 客户端 B 在已读 msgId 消息时,向服务端发送一条已读消息,服务端更新 lastReadmsgId & 向队列加入一个向客户端 A 推送已读消息的任务,然后通过 callback 告知客户端 B 发送已读消息成功,此时客户端 B 发送已读消息成功。

  2. 然后服务器进入到队列中执行向客户端 A 推送msgId客户端 B 得已读消息。

    客户端 A 不在线时,不需要做任何处理,结束该任务。

    客户端 A 在线时,向客户端 A 推送这条已读消息,在推送成功、失败或者超时情况下服务器都不需要做任何处理。

  3. 对于客户端 A 来说存在如下情况:

    在线并成功接收到已读消息:客户端直接通过 id <= lastRead 判断当前消息是否已读。

    离线:下次在线时,客户端向服务端查询获取 lastRead ,然后通过id <= lastRead 判断消息是否已读。

    在线但是推送失败(客户端未接收到已读消息):不需要做任何处理,因为消息的已读未读的实效性要求并不那么高,可以通过下一条消息的已读消息来更新,或者下次登录时查询最新的 lastRead 来更新。

这样数据库也不会出现冗余的数据,减轻储存压力。

简述一下群聊发送消息的过程(利用消息的“偏序”特性优雅地实现“只存 1 份”,以及群聊消息的已读未读功能)

群聊可复用上述单聊 lastAck & lastRead 机制,还是通过例子先简述发送群聊的整个过程:

lastAck 能完美复用并使用同一条数据库记录,但是 lastRead 不行,所以需要新建一张 MessageRead

如一个群存在 user1 user2 user3 user4 四个成员(群的 id 记位 groupId),user1 向该群发送了一条 msgId+1 消息,此时 user3 离线, 其他人都在线:

约定此时 lastAckmsgId0 此时数据库存在三条 MessageAck Model 数据:

{ receiver: user2, lastAck: msgId0, lastAckErr: null }
{ receiver: user3, lastAck: msgId0, lastAckErr: null }
{ receiver: user4, lastAck: msgId0, lastAckErr: null }
  1. 服务端收到 user1 发送的 msgId1 消息,通过 groupId 判断出是向该群发送的一条消息,服务器向数据库写入一条 msgId1 消息记录 & 向队列中添加一个向该群每个成员推送消息的任务,然后通过 callback 机制告知 user1 消息发送成功,此时对于 user1 该消息已发送成功。

  2. 服务器进入到队列中执行向该群其他成员推送消息的任务。

    对于不在线用户 user3,不需要做任何事直接结束当前任务。

    对于在线用户 user2 user4,服务器分别向该用户推送 msgId1 消息,客户端通过 ack 告知服务端是否成功接收,如成功接收服务端更新 lastAck 为 msgId1同样失败或者超时情况下将 lastAck 设置为 (msgId1) - 1(此方案和单聊一样,存在问题,最终解决方案见下文)。

    { receiver: user2, lastAck: msgId1, lastAckErr: null }
    { receiver: user4, lastAck: msgId1, lastAckErr: null }
    
  3. 对于离线用户 user3 下次在线时可以通过 select * from Messages where receiver = user2 & groupId = groupId & id > msgId 来获取到所有离线消息。

    对于在线用户 user2 user4 在消息接收失败或者超时情况下,可复用单聊的查找逻辑,找出 lastAck 还是 msgId0,下次在线可以也可以通过 select * from Messages where receiver = user2 & groupId = groupId & id > msgId 来获取到发送失败的消息(客户端根据消息的 id 去重)。

    在客户端通过 id > lastAck(msgId0) 来重新获取到所有消息(登录时获取所有离线消息也符合这种情况)的时候,需要在将lastAck设置为消息列表中最后一条消息的msgIdn的同时,还需要将lastAckErr更新为null

    此时三条 lastAck 都为最新的 msgId1

    { receiver: user2, lastAck: msgId1, lastAckErr: null }
    { receiver: user3, lastAck: msgId1, lastAckErr: null }
    { receiver: user4, lastAck: msgId1, lastAckErr: null }
    

群聊消息的已读消息跟单聊的类似,只不过需要根据每个群成员单独写一条数据

{ sender: groupId, receiver: user1, lastRead: msgId0 }
{ sender: groupId, receiver: user2, lastRead: msgId1 }
{ sender: groupId, receiver: user3, lastRead: msgId2 }
{ sender: groupId, receiver: user4, lastRead: msgId2 }

判断该 群 groupIdmsgId1 消息有哪些人已读:

select * from MessageExt where sender = groupId & lastRead >= msgId1;

{ sender: groupId, receiver: user2, lastRead: msgId1 }
{ sender: groupId, receiver: user3, lastRead: msgId2 }
{ sender: groupId, receiver: user4, lastRead: msgId2 }

判断该 群 groupIdmsgId1 消息有哪些人未读:

select * from MessageExt where sender = groupId & lastRead < msgId1;

{ sender: groupId, receiver: user1, lastRead: msgId0 }

至此,即保证了消息的实时性和可靠性、不丢消息又解决了群消息读、写扩散“消息风暴扩散系数”之大和储存问题。

参考IM群聊消息的已读回执功能该怎么实现?

再次详细的分析下,群消息已读回执的“消息风暴扩散系数”,假设每个群有 200 个用户
其中 20%的用户在线,即 40 各用户在线。

那么,群用户每发送一条群消息,会有:
40 个消息,通知给群友;
40 个 ack 修改 last_ack_msgid,发给服务端;
40 个已读回执,通知给发送方。

可见,其消息风暴扩散系数非常之大。

同时:

需要存储 40 条 ack 记录。

群数量,群友数量,群消息数量越来越多之后,存储也会成为问题。

为什么离线消息会通过客户端主动拉的形式获取

在离线消息数量不多的情况下,客户端主动拉 或者 服务器主动推 其实都可以。

但是当离线详细数量很大的情况下,两者在性能表现上差距还是很大的:

服务器主动推,只能无脑把所有消息都推送给客户端,就算分几次推那也只能是单线性的,会长时间阻塞,用户体验不好; 客户端主动拉,客户端可以并行(http2 的多路复用)的分页去获取所有的离线消息,这样拉取离线消息的时间会成倍的减少。

具体方案:
客户端在socket connect成功时,服务端推送消息告诉客户端现一共有 1000 条离线消息(服务端很容易获取到)
然后客户端根据总数 1000 将分页拉取,每次请求 100 条,分 10 次请求,基于 http2多路复用 可以并行拉取离线消息
注意要控制最大并发数量,避免网络一直被占用,一般可以控制在 3  6,最高 8 最合适

不过会存在一个问题,并行拉取的时候,其中某个请求失败或者超时怎么处理,客户端通过对比最终拿到的总的消息的数量和 1000 对比,
来判断是否出现失败的情况,如果出现失败情况,通过分页的信息很快能定位到是哪次的请求丢失,
然后将 lastAck 设置为该失败请求前一次请求的最后一条消息的 id 即可
当然会出现下次拉取消息重复的情况,但是客户端消息都会根据消息的 id 做去重处理

如何保证 IM 实时消息的“时序性”与“一致性”?

这部分可以参考 如何保证 IM 实时消息的“时序性”与“一致性”? 的文章。

但是问我自己也实现了一种简单但是实用的方案:

首先单聊比较简单,通过客户端发送消息时生成一个当前时间毫秒数 timer 排序即可

对于群聊则需要保证每个用户看到的消息列表的顺序都是一致,那么仅仅使用客户端生成的 timer 是满足不了的,最好的办法是根据服务端分配的一个 字增的序列 来判断,当然,如果跟我一样消息的 id 是唯一且递增的,那么可以通过这个主键 id 来排序,但是消息id不是这么设计的,那么可以通过以下方案来实现:

通过两个字段来判断消息的先后顺序:毫秒数 timer:number 和 一个自增序列 order:number 
为什么有了自增序列 order还需要再加一个 timer:
因为如果是自增序列的话,随着消息的增多,那么这个序列的值会变得非常大,服务器无法去维护,
那么可以想办法来削减 order 的最大峰值

那么可以这样设计:新的一天 order 都会重置为 0,并开始递增分配给消息
服务器记录一个时间点,比如 2023-02-21,如果当前时间还是在当天那么在 order 原有的值的基础上做递增,
如果分配 order 的时候,实现已经是第二天,那么先将 order 重置为 0,然后做递增

当然这个时间区间可以根据IM服务的用户量来决定,比如用户量大,那么可以考虑每隔几个小时或者一个小时重置 order 的值,如果用户群体不多,可以每隔几天来重置

表具体配置

Message Model: id 字段递增 保证消息的“偏序”特性,实现只存 1 份消息即可

type MessageBasic = {
  id: bigint;
  msgId: string;
  type: Common.MsgType;
  sender: Omit<User.UserAttributes, 'pwd'>;
  groupId?: number; // if group message
  receiver: number; // userId or groupId
  content: string;
  timer: string;
  ext?: string; // reserved field
};

// MessageModel

import { Model, Table, Column, DataType } from 'sequelize-typescript';
import * as dayjs from 'dayjs';

@Table
export class Message extends Model<Message> {
  @Column({
    type: DataType.BIGINT,
    primaryKey: true,
    autoIncrement: true,
    unique: true,
  })
  id: bigint;

  @Column({ type: DataType.STRING, unique: true, allowNull: false })
  msgId: string;

  @Column({
    type: DataType.STRING,
    allowNull: false,
    validate: {
      isIn: [['text', 'image', 'video', 'audio']], // ModuleIM.Common.MsgType
    },
  })
  type: ModuleIM.Common.MsgType;

  @Column({
    type: DataType.INTEGER,
    allowNull: false,
    comment: "sender's userid",
  })
  sender: number;

  @Column({
    type: DataType.INTEGER,
    allowNull: true,
    comment: 'groupId',
  })
  groupId: number;

  @Column({
    type: DataType.INTEGER,
    allowNull: false,
    comment: "receiver's userid",
  })
  receiver: number;

  @Column({ type: DataType.STRING })
  content: string;

  @Column({
    type: DataType.DATE,
    defaultValue: DataType.NOW,
    get() {
      return dayjs(this.getDataValue('createdAt')).format(
        'YYYY-MM-DD HH:mm:ss',
      );
    },
  })
  createdAt;

  @Column({
    type: DataType.DATE,
    defaultValue: DataType.NOW,
    get() {
      return dayjs(this.getDataValue('updatedAt')).format(
        'YYYY-MM-DD HH:mm:ss',
      );
    },
  })
  updatedAt;
}

MessageExt Model

{
  sender: number;
  receiver: number;
  groupId?: number; // if group message
  lastAck: bigint; // last ack msgId
  lastRead: bigint; // last read msgId
}

// Record the ack and read status of the message

import {
  Model,
  Table,
  Column,
  DataType,
} from 'sequelize-typescript';
import * as dayjs from 'dayjs';

@Table({
  indexes: [
    {
      using: 'BTREE',
      fields: ['sender', 'receiver'],
    },
  ],
})
export class MessageExt extends Model<MessageExt> {
  @Column({
    type: DataType.INTEGER,
    allowNull: false,
    comment: "sender's userid",
  })
  sender: number;

  @Column({
    type: DataType.INTEGER,
    allowNull: false,
    comment: "receiver's userid",
  })
  receiver: number;

  @Column({ type: DataType.INTEGER, allowNull: true })
  groupId: number;

  // @ForeignKey(() => Message)
  @Column({ type: DataType.BIGINT })
  lastAck: bigint;

  // @BelongsTo(() => Message, { foreignKey: 'lastAck', targetKey: 'id' })
  // message: Message;

  @Column({ type: DataType.BIGINT })
  lastRead: bigint;

  @Column({
    type: DataType.DATE,
    defaultValue: DataType.NOW,
    get() {
      return dayjs(this.getDataValue('createdAt')).format(
        'YYYY-MM-DD HH:mm:ss',
      );
    },
  })
  createdAt;

  @Column({
    type: DataType.DATE,
    defaultValue: DataType.NOW,
    get() {
      return dayjs(this.getDataValue('updatedAt')).format(
        'YYYY-MM-DD HH:mm:ss',
      );
    },
  })
  updatedAt;
}

最后

本文所有都是以自己写的项目为实践总结出来的,项目地址:im_server