Go-Zero项目开发24: 基于Bitmap实现群聊消息已读未读

纲要

  • 需求场景与设计思路
  • 数据模型与Bitmap的已读记录字段
  • WebSocket消息体扩展:已读请求结构
  • 消费者架构重构:抽象BaseTransfer,避免代码重复
  • 新增已读消费者,配置与注册
  • 已读业务逻辑:更新Bitmap、查询消息、构建已读回执
  • WebSocket层接入已读消息处理与路由
  • API层增加已读记录查询接口
  • 测试验证:群聊消息已读推送与查询

背景与需求

在即时通讯系统中,消息的已读/未读状态对用户体验至关重要。本次技术实践将基于go-zero框架,结合Bitmap实现群聊消息的已读未读功能。Bitmap以高效的空间利用率记录大量用户对单条消息的阅读状态,特别适合群聊中成员众多的场景。需要前后端协同,前端上报已读消息ID,服务端更新对应消息的Bitmap,并通过WebSocket将已读状态变更推送给相关用户。

数据结构设计

消息模型

首先为聊天消息模型增加一个字段,用于存储该消息的已读记录(使用Bitmap)。以go-zero的模型文件为例,在ChatLog结构体中新增ReadBitmap字段,类型为[]byte,映射数据库的read_bitmap列。

go 复制代码
// model/chatlog.go 
package model 
 
import "time"
 
type ChatLog struct {
    Id          int64     `db:"id"`
    SenderId    int64     `db:"sender_id"`
    SessionId   string    `db:"session_id"`
    Content     string    `db:"content"`
    ReadBitmap  []byte    `db:"read_bitmap"`  // 记录已读用户位图 
    CreateTime  time.Time `db:"create_time"`
}

WebSocket 消息体扩展

客户端需要告知服务端哪些消息已读,因此定义一个新的消息结构ReadMsgReq,包含接收者ID、会话ID以及消息ID列表。

go 复制代码
// types/ws.go 
package types 
 
type ReadMsgReq struct {
    ReceiverId int64   `json:"receiver_id"` // 已读结果的接收者(通常为发送者本人)
    SessionId  string  `json:"session_id"`  // 会话ID 
    MsgIds     []int64 `json:"msg_ids"`     // 已读的消息ID列表 
}

对应的,服务端推送已读记录回执时,消息体内增加内容类型和已读数据字段。定义内容类型常量,区分普通聊天消息和已读回执。

go 复制代码
// types/ws.go (续)
const (
    ContentTypeChat = 1 // 普通聊天消息 
    ContentTypeRead = 2 // 已读回执消息 
)
 
type PushMsg struct {
    ContentType int    `json:"content_type"` // 内容类型 
    MsgId       int64  `json:"msg_id"`       // 消息ID 
    SenderId    int64  `json:"sender_id"`
    ReceiverId  int64  `json:"receiver_id"`
    SessionId   string `json:"session_id"`
    Content     string `json:"content"`      // 普通消息内容 
    ReadData    string `json:"read_data"`    // 已读bitmap的base64编码,仅在content_type=2时有效 
    SendTime    int64  `json:"send_time"`
}

消费者架构重构

原有系统中已经存在一个用于消息转发的消费者(MessageTransfer),其核心流程是:接收消息 -> 业务处理 -> 结果转发。新增的已读消费者逻辑类似,也会接收已读指令 -> 更新bitmap -> 推送变更。为避免重复,将共同部分抽象到BaseTransfer

类层次结构

dir 复制代码
├─ internal/
│  ├─ consumer/
│  │  ├─ base_transfer.go   (基础转发逻辑)
│  │  ├─ msg_transfer.go    (普通消息处理,继承BaseTransfer)
│  │  └─ read_transfer.go   (已读消息处理,继承BaseTransfer)
│  └─ config/
│     └─ config.go          (配置)
 

BaseTransfer 实现

BaseTransfer包含通用的推送方法,根据会话类型(私聊/群聊)将结果发送到对应的WebSocket连接。

go 复制代码
// internal/consumer/base_transfer.go 
package consumer 
 
import (
    "context"
    "im/internal/svc"
    "im/internal/ws"
    "im/types"
 
    "github.com/zeromicro/go-zero/core/logx"
)
 
type BaseTransfer struct {
    svcCtx *svc.ServiceContext 
    logx.Logger 
}
 
func NewBaseTransfer(svcCtx *svc.ServiceContext) *BaseTransfer {
    return &BaseTransfer{
        svcCtx: svcCtx,
        Logger: logx.WithContext(context.Background()),
    }
}
 
// Transfer 根据会话类型转发消息 
func (b *BaseTransfer) Transfer(ctx context.Context, sessionId string, chatType int64, sendTime int64, senderId int64, receiverIds []int64, content string, contentTyp int, readData string, msgId int64) error {
    pushMsg := &types.PushMsg{
        ContentType: contentTyp,
        MsgId:       msgId,
        SenderId:    senderId,
        SessionId:   sessionId,
        Content:     content,
        ReadData:    readData,
        SendTime:    sendTime,
    }
    switch chatType {
    case types.ChatTypePrivate: // 私聊 
        return b.transferPrivate(ctx, pushMsg, receiverIds)
    case types.ChatTypeGroup:   // 群聊 
        return b.transferGroup(ctx, pushMsg, receiverIds)
    default:
        return nil 
    }
}
 
func (b *BaseTransfer) transferPrivate(ctx context.Context, msg *types.PushMsg, receiverIds []int64) error {
    // 私聊逻辑:推送给接收者 
    for _, uid := range receiverIds {
        if conn, ok := ws.GetConnection(uid); ok {
            conn.WriteJSON(msg)
        }
    }
    return nil 
}
 
func (b *BaseTransfer) transferGroup(ctx context.Context, msg *types.PushMsg, receiverIds []int64) error {
    // 群聊逻辑:推送给所有在线成员(需根据群组获取成员列表)
    // 示例:通过群组服务获取成员ID 
    members, err := b.svcCtx.GroupModel.FindMembers(ctx, msg.SessionId)
    if err != nil {
        return err 
    }
    for _, uid := range members {
        if conn, ok := ws.GetConnection(uid); ok {
            conn.WriteJSON(msg)
        }
    }
    return nil 
}

普通消息消费者

原先的消息消费者改写为继承BaseTransfer,去除重复的转发代码。

go 复制代码
// internal/consumer/msg_transfer.go 
package consumer 
 
import (
    "context"
    "im/internal/svc"
    "im/model"
 
    "github.com/zeromicro/go-zero/core/logx"
)
 
type MsgTransfer struct {
    *BaseTransfer 
}
 
func NewMsgTransfer(svcCtx *svc.ServiceContext) *MsgTransfer {
    return &MsgTransfer{
        BaseTransfer: NewBaseTransfer(svcCtx),
    }
}
 
func (m *MsgTransfer) Consume(ctx context.Context, key, value string) error {
    // 1. 解析消息 
    var msg model.ChatLog 
    // ... 反序列化逻辑 
 
    // 2. 存储消息并设置发送者已读 
    msg.ReadBitmap = SetBit(nil, int(msg.SenderId), 1) // 发送者自己默认已读 
    // insert to db 
 
    // 3. 推送 
    return m.Transfer(ctx, msg.SessionId, chatType, sendTime, msg.SenderId, receiverIds, content, types.ContentTypeChat, "", msg.Id)
}

已读消费者实现

配置文件

go-zero的配置文件(如etc/im.yaml)中添加已读消费者的Kafka配置。此处假设使用Kafka作为消息队列。

yaml 复制代码
Kafka:
  ReadMsgGroup: read-msg-group 
  ReadMsgTopic: read-msg-topic 
  Brokers:
    - 127.0.0.1:9092 

消费者代码

已读消费者ReadTransfer同样继承BaseTransfer,核心业务是:根据消息ID列表更新每条消息的已读bitmap,然后构造已读记录回执推送给相关用户。

go 复制代码
// internal/consumer/read_transfer.go 
package consumer 
 
import (
    "context"
    "encoding/base64"
    "fmt"
    "im/internal/svc"
    "im/model"
    "im/types"
    "strconv"
    "strings"
 
    "github.com/zeromicro/go-zero/core/logx"
)
 
type ReadTransfer struct {
    *BaseTransfer 
}
 
func NewReadTransfer(svcCtx *svc.ServiceContext) *ReadTransfer {
    return &ReadTransfer{
        BaseTransfer: NewBaseTransfer(svcCtx),
    }
}
 
// Consume 处理已读消息 
func (r *ReadTransfer) Consume(ctx context.Context, key, value string) error {
    // 解析已读请求 
    var req types.ReadMsgReq 
    // ... 反序列化 
 
    // 1. 根据msgIds批量查询聊天记录 
    chatLogs, err := r.svcCtx.ChatLogModel.FindByIds(ctx, req.MsgIds)
    if err != nil {
        return err 
    }
 
    // 2. 逐个更新已读bitmap,并收集变更结果 
    readRecords := make(map[int64]string) // key: msgId, value: base64编码的bitmap 
    for _, log := range chatLogs {
        bitmap := log.ReadBitmap 
        // 将当前请求用户标记为已读(假设已从上下文获取请求用户ID userId)
        bitmap = SetBit(bitmap, int(userId), 1) // userId需从消息上下文获取 
        // 更新数据库 
        err = r.svcCtx.ChatLogModel.UpdateReadBitmap(ctx, log.Id, bitmap)
        if err != nil {
            r.Errorf("update read bitmap error: %v", err)
            continue 
        }
        // 将bitmap转为base64以便网络传输 
        readRecords[log.Id] = base64.StdEncoding.EncodeToString(bitmap)
    }
 
    // 3. 推送已读回执给接收者(一般是消息发送者)
    for msgId, bitmapBase64 := range readRecords {
        // 此处需要获取该消息的发送者作为接收者 
        msg, err := r.svcCtx.ChatLogModel.FindOne(ctx, msgId)
        if err != nil {
            continue 
        }
        // 发送已读回执 
        r.Transfer(ctx, msg.SessionId, chatType, sendTime, msg.SenderId, []int64{req.ReceiverId}, "", types.ContentTypeRead, bitmapBase64, msgId)
    }
    return nil 
}
 
// SetBit 位图操作辅助函数:设置指定位置为1 
func SetBit(bitmap []byte, index int, value int) []byte {
    // 实现细节...
    return bitmap 
}

ServiceContext中注册消费者:

go 复制代码
// internal/svc/service_context.go 
package svc 
 
import (
    "im/internal/consumer"
    "github.com/zeromicro/go-zero/core/service"
)
 
type ServiceContext struct {
    Config    config.Config 
    ReadConsumer *consumer.ReadTransfer 
}
 
func NewServiceContext(c config.Config) *ServiceContext {
    return &ServiceContext{
        Config: c,
        ReadConsumer: consumer.NewReadTransfer(c),
    }
}

WebSocket 层接入

客户端注册

WebSocket模块需要新增一个方法处理客户端发来的已读消息,将请求推送到Kafka中供ReadConsumer消费。

go 复制代码
// internal/ws/handler.go 
package ws 
 
import (
    "context"
    "im/types"
    "encoding/json"
 
    "github.com/zeromicro/go-zero/core/logx"
)
 
type Handler struct {
    svcCtx *svc.ServiceContext 
}
 
func (h *Handler) ReadMsgHandler(userId int64, conn *websocket.Conn, data []byte) error {
    var req types.ReadMsgReq 
    if err := json.Unmarshal(data, &req); err != nil {
        return err 
    }
    // 将已读请求写入Kafka 
    msgKey := fmt.Sprintf("read:%d:%s", userId, req.SessionId)
    msgValue, _ := json.Marshal(req)
    return h.svcCtx.KafkaPusher.Push("read-msg-topic", msgKey, string(msgValue))
}

同时在路由中添加对read_msg消息类型的支持。

go 复制代码
// internal/ws/router.go 
func Route(h *Handler, conn *websocket.Conn, msgType string, data []byte) {
    switch msgType {
    case "send_msg":
        h.SendMsgHandler(conn, data)
    case "read_msg":
        userId := conn.GetUserId() // 从连接获取用户ID 
        h.ReadMsgHandler(userId, conn, data)
    default:
        logx.Error("unknown message type")
    }
}

API层:已读记录查询

为了方便前端展示某个消息的已读/未读用户列表,提供HTTP API接口。根据消息ID返回已读用户列表和未读用户列表(群聊需知道所有成员)。

go 复制代码
// internal/handler/read/read_record_handler.go 
package read 
 
import (
    "net/http"
    "im/internal/logic/read"
    "im/internal/svc"
    "im/internal/types"
 
    "github.com/zeromicro/go-zero/rest/httpx"
)
 
func ReadRecordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req types.ReadRecordReq 
        if err := httpx.Parse(r, &req); err != nil {
            httpx.Error(w, err)
            return 
        }
 
        l := read.NewReadRecordLogic(r.Context(), svcCtx)
        resp, err := l.ReadRecord(&req)
        if err != nil {
            httpx.Error(w, err)
            return 
        }
        httpx.OkJson(w, resp)
    }
}

业务逻辑:查询消息,解析bitmap,与群组成员列表比对,区分已读和未读。

go 复制代码
// internal/logic/read/read_record_logic.go 
package read 
 
import (
    "context"
    "im/internal/svc"
    "im/internal/types"
 
    "github.com/zeromicro/go-zero/core/logx"
)
 
type ReadRecordLogic struct {
    logx.Logger 
    ctx    context.Context 
    svcCtx *svc.ServiceContext 
}
 
func NewReadRecordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReadRecordLogic {
    return &ReadRecordLogic{
        Logger: logx.WithContext(ctx),
        ctx:    ctx,
        svcCtx: svcCtx,
    }
}
 
func (l *ReadRecordLogic) ReadRecord(req *types.ReadRecordReq) (*types.ReadRecordResp, error) {
    // 1. 获取消息记录 
    msg, err := l.svcCtx.ChatLogModel.FindOne(l.ctx, req.MsgId)
    if err != nil {
        return nil, err 
    }
    // 2. 获取群组所有成员(假设群聊)
    members, err := l.svcCtx.GroupModel.FindMembers(l.ctx, msg.SessionId)
    if err != nil {
        return nil, err 
    }
    // 3. 解析bitmap 
    readUids := make([]int64, 0)
    unreadUids := make([]int64, 0)
    for _, uid := range members {
        if GetBit(msg.ReadBitmap, int(uid)) == 1 {
            readUids = append(readUids, uid)
        } else {
            unreadUids = append(unreadUids, uid)
        }
    }
    return &types.ReadRecordResp{
        ReadUids:   readUids,
        UnreadUids: unreadUids,
    }, nil 
}
 
func GetBit(bitmap []byte, index int) int {
    // 实现位图读取...
    return 0 
}

流程示意

将整个已读流程按服务间交互展示如下:
数据库 ReadConsumer Kafka WebSocket服务 客户端 数据库 ReadConsumer Kafka WebSocket服务 客户端 #mermaid-svg-5MhbCfCI4EkelaVa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5MhbCfCI4EkelaVa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5MhbCfCI4EkelaVa .error-icon{fill:#552222;}#mermaid-svg-5MhbCfCI4EkelaVa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5MhbCfCI4EkelaVa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5MhbCfCI4EkelaVa .marker.cross{stroke:#333333;}#mermaid-svg-5MhbCfCI4EkelaVa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5MhbCfCI4EkelaVa p{margin:0;}#mermaid-svg-5MhbCfCI4EkelaVa .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5MhbCfCI4EkelaVa text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5MhbCfCI4EkelaVa .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5MhbCfCI4EkelaVa .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5MhbCfCI4EkelaVa .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5MhbCfCI4EkelaVa #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5MhbCfCI4EkelaVa .sequenceNumber{fill:white;}#mermaid-svg-5MhbCfCI4EkelaVa #sequencenumber{fill:#333;}#mermaid-svg-5MhbCfCI4EkelaVa #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5MhbCfCI4EkelaVa .messageText{fill:#333;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5MhbCfCI4EkelaVa .labelText,#mermaid-svg-5MhbCfCI4EkelaVa .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .loopText,#mermaid-svg-5MhbCfCI4EkelaVa .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5MhbCfCI4EkelaVa .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5MhbCfCI4EkelaVa .noteText,#mermaid-svg-5MhbCfCI4EkelaVa .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5MhbCfCI4EkelaVa .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5MhbCfCI4EkelaVa .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5MhbCfCI4EkelaVa .actorPopupMenu{position:absolute;}#mermaid-svg-5MhbCfCI4EkelaVa .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-5MhbCfCI4EkelaVa .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5MhbCfCI4EkelaVa .actor-man circle,#mermaid-svg-5MhbCfCI4EkelaVa line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5MhbCfCI4EkelaVa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop每条消息 发送 read_msg {msgIds}产生消息到 read-msg-topic消费消息根据msgIds查询聊天记录更新bitmap (设置当前用户已读)写回新的bitmap构造已读回执 (bitmap base64)推送已读回执给发送者回执推送

测试与验证

启动所有服务后,使用多个WebSocket客户端连接,模拟群聊消息。

  1. 客户端A发送一条群消息,客户端B、C均收到该消息,消息体内包含msg_id
  2. 客户端B发送read_msg,携带刚才的msg_id
  3. 客户端B自身可在UI上看到已读状态变化,同时A和C会收到一条content_type=2的推送,包含read_data(base64编码的bitmap)。
  4. 调用API /read_record?msg_id=xxx查询,返回已读用户列表(含A作为发送者默认已读、B)和未读用户列表(C)。
  5. 再次由客户端C发送已读后,查询接口返回结果更新,所有成员均已读。

通过以上测试,功能符合预期。需要注意的是,bitmap的长度需根据可能的最大用户ID动态扩容,使用SetBitGetBit时做好边界检查。

总结

本文基于go-zero框架,利用Bitmap数据结构高效实现了群聊消息的已读未读功能。关键技术点包括:

  • 位图在消息模型中的存储与应用;
  • 消费者抽象重构,避免重复代码;
  • Kafka消息驱动,实现异步更新与推送;
  • WebSocket实时推送已读状态;
  • API查询接口方便前端数据渲染。

在实际项目中,还需考虑大量群聊成员时的bitmap长度优化、并发更新的原子性等问题,后续可进一步结合Redis缓存bitmap提升性能。

相关推荐
Conan在掘金2 小时前
鸿蒙报错速查:struct 里嵌 namespace 声明就炸,根因 + 真解法
后端
东方小月2 小时前
从零开发一个 Coding Agent(三):EventStream 事件流通道设计与实现
前端·人工智能·后端
卫子miao2 小时前
如何评价当前大语言模型的记忆机制?
后端·架构
神奇小汤圆2 小时前
为什么 AQS 成为 Java 并发的基石?
后端
星栈3 小时前
MCP 从 stdio 迁到 SSE,踩了 5 个传输层坑
人工智能·后端·架构
Conan在掘金3 小时前
鸿蒙报错速查:struct 里嵌 enum 声明就炸,根因 + 真解法
后端
吃好睡好便好4 小时前
MATLAB中图像的读取、写入和显示
开发语言·图像处理·学习·计算机视觉·matlab
yaoxin5211234 小时前
476. Java 反射 - 调用方法
java·开发语言
回家路上绕了弯4 小时前
Java双数据源实战全指南:Spring Boot多数据源配置、原理与踩坑避坑
后端