OpenIM 源码深度解析系列(七):单聊(第一阶段)消息发送流程

4 阅读25分钟

单聊(第一阶段)消息发送流程

📊 消息状态与序号机制概览

🔢 消息发送成功后的状态详解

当消息发送到服务端并收到响应后,本地消息的状态和序号机制如下:

⚠️ 重要声明: 本文档仅描述第一阶段(发送流程),在此阶段服务端不分配Seq序号,只是将消息投递到Kafka队列。Seq序号的分配发生在第二阶段的MsgTransfer消费流程中。

🔴 红点机制与已读状态的重要说明

红点(未读数)的计算逻辑

OpenIM的红点机制基于简单而精确的数学公式:

未读数 = MaxSeq(会话最大序号) - ReadSeq(用户已读序号)

自己发送消息的红点处理机制

📊 问题场景分析

当用户发送消息时,会出现以下序号变化:

时机MaxSeq状态ReadSeq状态计算结果红点显示
第一阶段完成不变(消息未分配Seq)不变0无红点 ✅
第二阶段Seq分配+1(新消息获得序号)不变1会出现红点 ❌
第二阶段已读处理不变+1(自动设置已读)0无红点 ✅
🎯 核心解决方案

为什么自己发送的消息不应该有红点?

  • 用户发送消息意味着已经"看过"了消息内容
  • 自己的消息不应该贡献未读数统计
  • 需要在第二阶段自动处理发送者的已读状态

第二阶段的关键处理逻辑

  1. Seq分配:为消息分配正式序号,MaxSeq +1
  2. 自动已读:检测到是发送者本人,自动设置ReadSeq +1
  3. 状态同步:将消息的IsRead字段设置为true
  4. 红点平衡:确保MaxSeq - ReadSeq = 0,不产生红点

已读机制的双重设计

OpenIM采用双重已读机制确保精确控制:

1. 会话级别已读(ReadSeq)
  • 作用:控制红点显示和未读计数
  • 更新时机:用户查看消息、发送消息时
  • 影响范围:整个会话的未读数统计
2. 消息级别已读(IsRead)
  • 作用:精确标记单条消息的已读状态
  • 更新时机:用户查看消息、收到已读回执时
  • 影响范围:单条消息的状态显示(如已读标识)

⚠️ 第一阶段的局限性提醒

第一阶段不处理的重要逻辑

  • 不分配Seq序号:MaxSeq不会增加
  • 不更新ReadSeq:已读序号不会变化
  • 不设置IsRead:消息级别已读状态不变
  • 不影响红点:未读计数暂时不变

这些关键逻辑都在第二阶段的MsgTransfer流程中处理,确保:

  • 发送者不会因为自己的消息产生红点
  • 接收者能正确看到新消息的红点提醒
  • 已读状态在多端之间正确同步

💡 设计思考: 分阶段处理的好处是提升用户体验(快速响应)的同时,确保复杂的状态逻辑在后台正确处理,避免界面卡顿。

1. 消息状态变迁
阶段消息状态Seq序号说明
初始创建MsgStatusSending(1)0客户端创建消息,未分配序号
本地存储MsgStatusSending(1)0保存到本地数据库,等待发送
服务端响应MsgStatusSendSuccess(2)0收到服务端响应,消息已投递到Kafka
2. 序号分配机制详解
// 消息序号的核心字段
type MsgStruct struct {
    Seq          int64  // 服务端分配的序列号(全局唯一递增,在第二阶段分配)
    ClientMsgID  string // 客户端消息ID(本地唯一标识)
    ServerMsgID  string // 服务端消息ID(全局唯一标识)
    Status       int32  // 消息状态(1=发送中,2=成功,3=失败)
    IsRead       bool   // 已读状态(发送者角度,自己发送的消息默认已读)
}
3. 重要序号概念
序号类型作用范围值说明用途
Seq会话级别服务端分配(第二阶段),严格递增消息排序、去重、同步
MaxSeq会话级别会话中最大的消息序号未读计数、增量同步
ReadSeq用户级别用户已读的最大序号未读统计、已读回执
4. 发送者角度的消息状态

发送成功后的消息特征

  • Status: MsgStatusSendSuccess(2) - 发送成功状态
  • ⚠️ IsRead: 仍为false - 第一阶段未修改已读状态
  • ⚠️ Seq: 仍为0 - 第一阶段未分配,需等待第二阶段存储流程
  • ServerMsgID: 非空 - 服务端生成的全局唯一ID
  • SendTime: 更新为服务端时间戳

📝 重要说明: 第一阶段发送成功只是确认消息投递到Kafka队列成功,Seq序号分配和IsRead状态修改都发生在后续的消息处理流程中。

5. 序号在未读计数中的作用
  • 发送者视角:自己发送的消息在第一阶段IsRead=false,需要在第二阶段自动设置已读状态,防止产生红点
  • 接收者视角:通过比较ReadSeq和消息的Seq来判断是否未读,产生正确的红点提醒
  • 会话更新:消息的Seq会更新会话的MaxSeq,影响未读计数基准
  • 红点控制:关键在于第二阶段必须同步更新发送者的ReadSeq,确保MaxSeq - ReadSeq = 0

📱 阶段一:消息发送完整链路分析

🔄 流程概览

单聊消息发送第一阶段涵盖了从Android客户端创建消息到服务端处理完成的完整链路:

Android客户端 → OpenIM SDK → WebSocket长连接 → 服务端消息网关 → 消息服务 → Kafka队列

🚀 第一步:Android客户端消息创建

1.1 用户输入触发消息创建

文件位置: BottomInputCote.java:90-109

// 用户点击发送按钮或按下回车键触发
view.chatMoreOrSend.setOnClickListener(chatMoreOrSendClick = new OnDedrepClickListener() {
    @Override
    public void click(View v) {
        if (!isSend) {
            // 显示更多功能面板
            switchFragment(inputExpandFragment);
            return;
        }
        // 核心逻辑:创建文本消息
        Message msg = OpenIMClient.getInstance().messageManager.createTextMessage(vm.inputMsg.val().toString());
        if (null != msg) {
            // 调用ViewModel发送消息
            vm.sendMsg(msg);
            reset(); // 重置UI状态
        }
    }
});

核心要点

  • 输入校验:检查是否有可发送内容(isSend标志)
  • 消息创建:调用OpenIM SDK的createTextMessage接口
  • UI重置:发送后清空输入框和相关状态

1.2 SDK接口调用(JNI桥接)

文件位置: conversation_msg.go:57-59

func CreateTextMessage(operationID string, text string) string {
    // 同步调用:通过JNI桥接到Go SDK
    return syncCall(operationID, IMUserContext.Conversation().CreateTextMessage, text)
}

调用链路分析

  1. Java层调用:Android通过JNI调用Go SDK接口
  2. 同步执行syncCall确保操作完成后再返回
  3. 上下文传递IMUserContext提供全局SDK上下文

📝 第二步:SDK核心消息结构创建

2.1 消息基础信息初始化

文件位置: create_message.go:18-27

func (c *Conversation) CreateTextMessage(ctx context.Context, text string) (*sdk_struct.MsgStruct, error) {
    s := sdk_struct.MsgStruct{}
    // 核心步骤:初始化消息基础信息
    err := c.initBasicInfo(ctx, &s, constant.UserMsgType, constant.Text)
    if err != nil {
        return nil, err
    }
    // 设置文本消息特有内容
    s.TextElem = &sdk_struct.TextElem{Content: text}
    return &s, nil
}
🔄 消息状态调整要点
  • Status初始化:消息创建时状态设置为MsgStatusSending(1),表示准备发送
  • 序号初始化Seq字段设置为0,等待第二阶段服务端分配正式序号
  • 时间戳设置CreateTimeSendTime都设置为当前时间戳
  • 已读状态IsRead设置为false第一阶段不会修改此状态

2.2 消息基础字段详细解析

文件位置: api.go:1481-1519

func (c *Conversation) initBasicInfo(ctx context.Context, message *sdk_struct.MsgStruct, msgFrom, contentType int32) error {
    // 时间戳设置
    message.CreateTime = utils.GetCurrentTimestampByMill()
    message.SendTime = message.CreateTime

    // 消息状态设置(关键字段)
    message.IsRead = false                    // 默认未读状态
    message.Status = constant.MsgStatusSending // 发送中状态

    // 发送者信息设置
    message.SendID = c.loginUserID
    message.SenderPlatformID = c.platform

    // 生成客户端消息ID(去重关键)
    ClientMsgID := utils.GetMsgID(message.SendID)
    message.ClientMsgID = ClientMsgID

    // 消息属性设置
    message.MsgFrom = msgFrom           // 消息来源(用户/系统/机器人)
    message.ContentType = contentType   // 内容类型(文本/图片/音频等)
    
    // 获取发送者用户信息
    userInfo, err := c.user.GetUserInfoWithCache(ctx, c.loginUserID)
    if err != nil {
        return err
    }
    message.SenderFaceURL = userInfo.FaceURL
    message.SenderNickname = userInfo.Nickname

    return nil
}

核心字段说明

字段名类型作用初始值
ClientMsgIDstring客户端唯一标识,防重发UUID格式
IsReadbool已读状态标记false
Statusint32消息发送状态MsgStatusSending(1)
CreateTimeint64本地创建时间戳当前毫秒时间戳
SendTimeint64发送时间戳初始等于CreateTime
SendIDstring发送者用户ID当前登录用户ID
ContentTypeint32消息内容类型Text(101)

📤 第三步:Android端发送消息

3.1 ViewModel调用发送

文件位置: ChatVM.java:843-894

public void sendMsg(Message msg) {
    sendMsg(msg, false); // 非重发模式
}

public void sendMsg(Message msg, boolean isResend) {
    // 消息去重检查
    if (!isResend) {
        for (Message message : mMsgList) {
            if (TextUtils.equals(message.getClientMsgID(), msg.getClientMsgID())) {
                return; // 防止重复发送
            }
        }
    }

    // 添加到本地消息列表(立即显示)
    insertMessageToList(msg);

    // 调用SDK发送消息(异步操作)
    OpenIMClient.getInstance().messageManager.sendMessage(
        new OnMsgSendCallback() {
            @Override
            public void onError(int code, String error) {
                // 🔴 发送失败处理
                msg.setStatus(MessageStatus.SEND_FAILURE);
                updateMsgStatusAndRefresh(msg);
            }

            @Override
            public void onSuccess(Message message) {
                // 🟢 发送成功处理
                msg.setStatus(MessageStatus.SEND_SUCCESS);
                msg.setServerMsgID(message.getServerMsgID());
                msg.setSendTime(message.getSendTime());
                updateMsgStatusAndRefresh(msg);
            }
        }, msg, userID, groupID, null
    );
}

关键逻辑

  1. 去重保护:检查ClientMsgID防止重复发送
  2. 乐观更新:立即显示消息在聊天界面
  3. 异步发送:调用SDK异步发送,通过回调更新状态
🔄 Android端消息状态调整
  • 立即显示:消息创建后立即添加到mMsgList,状态保持MsgStatusSending
  • 成功回调:收到服务端响应后,更新消息的StatusServerMsgIDSendTime
  • 失败处理:发送失败时设置Status = MsgStatusSendFailure,用户可重发
  • UI刷新:每次状态变更都调用updateMsgStatusAndRefresh()刷新界面

🔗 第四步:SDK协程调用机制

4.1 异步调用封装

文件位置: conversation_msg.go:119-121

func SendMessage(callback open_im_sdk_callback.SendMsgCallBack, operationID, message, recvID, groupID, offlinePushInfo string, isOnlineOnly bool) {
    // 消息发送专用协程调用
    messageCall(callback, operationID, IMUserContext.Conversation().SendMessage, message, recvID, groupID, offlinePushInfo, isOnlineOnly)
}

4.2 协程调用实现

文件位置: caller.go:361-485

func messageCall(callback open_im_sdk_callback.SendMsgCallBack, operationID string, fn any, args ...any) {
    // 启动独立协程处理消息发送
    go messageCall_(callback, operationID, fn, args...)
}

func messageCall_(callback open_im_sdk_callback.SendMsgCallBack, operationID string, fn any, args ...any) {
    // 异常处理和结果回调
    defer func() {
        if r := recover(); r != nil {
            callback.OnError(sdkerrs.ErrInternalServer.ErrCode, fmt.Sprintf("messageCall panic: %+v", r))
        }
    }()

    // 反射调用实际发送方法
    res, err := call_(operationID, fn, args...)
    if err != nil {
        callback.OnError(err.(*sdkerrs.CodeError).ErrCode, err.Error())
        return
    }

    // 发送成功回调
    callback.OnSuccess(res.(string))
}

设计要点

  • 异步执行:所有SDK调用都在独立协程中执行
  • 异常保护defer recover()确保异常不会崩溃主线程
  • 回调机制:通过callback将结果返回给上层
🔄 协程调用中的消息状态管理
  • 状态保持:协程调用期间消息状态始终为MsgStatusSending
  • 错误处理:异常时通过callback.OnError通知上层,状态变为失败
  • 成功回调:完成时通过callback.OnSuccess返回最终消息状态
  • 线程安全:通过协程隔离确保消息状态变更的线程安全

📨 第五步:SDK核心发送逻辑

5.1 消息发送主流程

文件位置: api.go:489-600

func (c *Conversation) SendMessage(ctx context.Context, s *sdk_struct.MsgStruct, recvID, groupID string, p *sdkws.OfflinePushInfo, isOnlineOnly bool) (*sdk_struct.MsgStruct, error) {
    // 第一步:校验和设置消息接收者
    lc, err := c.checkID(ctx, s, recvID, groupID, options)
    if err != nil {
        return s, err
    }

    // 第二步:检查消息是否已存在(防重发)
    oldMessage, err := c.db.GetMessage(ctx, lc.ConversationID, s.ClientMsgID)
    if err == nil {
        // 消息已存在,检查是否为失败重发
        if oldMessage.Status != constant.MsgStatusSendFailure {
            return nil, sdkerrs.ErrRepeatMessage
        }
        // 失败重发:更新消息状态
        s.Status = constant.MsgStatusSending
        s.SendTime = utils.GetCurrentTimestampByMill()
    } else {
        // 第三步:新消息入库
        err = c.db.InsertMessage(ctx, lc.ConversationID, s)
        if err != nil {
            return s, err
        }
        
        // 第四步:插入发送中记录
        err = c.db.InsertSendingMessage(ctx, lc.ConversationID, s.ClientMsgID)
        if err != nil {
            log.ZWarn(ctx, "InsertSendingMessage failed", err)
        }
    }

    // 第五步:触发会话更新事件
    c.msgListener().OnMsgSendCallback(&CallbackMessage{
        LocalEx: constant.AddConOrUpLatMsg,
        Msg:     s,
        ConversationMsg: &model_struct.LocalConversation{
            ConversationID: lc.ConversationID,
            LatestMsg:      utils.StructToJsonString(s),
        },
    })

    // 第六步:发送到服务端
    return c.sendMessageToServer(ctx, s, lc, callback, delFiles, p, options, isOnlineOnly)
}

5.2 ID校验和会话设置

文件位置: api.go:348-444

func (c *Conversation) checkID(ctx context.Context, s *sdk_struct.MsgStruct, recvID, groupID string, options map[string]bool) (*model_struct.LocalConversation, error) {
    // 参数校验
    if recvID == "" && groupID == "" {
        return nil, sdkerrs.ErrArgs.WrapMsg("recv_id and group_id are both empty")
    }
    if recvID != "" && groupID != "" {
        return nil, sdkerrs.ErrArgs.WrapMsg("recv_id and group_id cannot both be set")
    }

    var lc model_struct.LocalConversation
    
    if recvID != "" {
        // 单聊逻辑
        lc.ConversationID = c.getConversationIDBySessionType(recvID, constant.SingleChatType)
        lc.UserID = recvID
        lc.ConversationType = constant.SingleChatType
        lc.FaceURL = faceUrl
        lc.ShowName = name
    } else {
        // 群聊逻辑(此处省略)
    }

    // 设置消息字段
    s.SendID = c.loginUserID
    s.SenderPlatformID = c.platform
    s.ConversationID = lc.ConversationID
    s.SessionType = lc.ConversationType

    return &lc, nil
}
🔄 ID校验阶段的消息调整
  • 发送者信息设置:补充SendIDSenderPlatformID等发送者标识
  • 会话ID生成:根据单聊/群聊类型生成对应的ConversationID
  • 接收者信息:单聊时设置RecvID,群聊时设置GroupID
  • 私聊模式检测:检查会话是否为私聊,设置相应的附加信息和options
  • 用户信息获取:获取并设置发送者的头像和昵称信息

🌐 第六步:WebSocket长连接发送

6.1 服务端发送接口调用

文件位置: api.go:933-1021

func (c *Conversation) sendMessageToServer(ctx context.Context, s *sdk_struct.MsgStruct, lc *model_struct.LocalConversation, callback open_im_sdk_callback.SendMsgCallBack, delFiles []string, offlinePushInfo *sdkws.OfflinePushInfo, options map[string]bool, isOnlineOnly bool) (*sdk_struct.MsgStruct, error) {
    
    // 构建发送请求
    req := &msg.SendMsgReq{
        MsgData:         convert.MsgStructToPb(s),
        OfflinePushInfo: offlinePushInfo,
        IsOnlineOnly:    isOnlineOnly,
    }

    // 关键步骤:通过长连接发送(阻塞等待响应)
    resp, err := c.LongConnMgr.SendReqWaitResp(ctx, req, constant.WSSendMsg, &msg.SendMsgResp{})
    if err != nil {
        return s, err
    }

    // 解析服务端响应
    msgResp := resp.(*msg.SendMsgResp)
    s.SendTime = msgResp.SendTime
    s.ServerMsgID = msgResp.ServerMsgID
    s.Status = constant.MsgStatusSendSuccess

    // 更新本地数据库
    c.updateMsgStatusAndTriggerConversation(ctx, s.ClientMsgID, s.ServerMsgID, s.SendTime, s.Status, s, lc, isOnlineOnly)

    return s, nil
}

6.2 长连接管理器发送实现

文件位置: long_conn_mgr.go:192-241

func (c *LongConnMgr) SendReqWaitResp(ctx context.Context, m proto.Message, reqIdentifier int, resp proto.Message) error {
    // 序列化请求消息
    data, err := proto.Marshal(m)
    if err != nil {
        return err
    }

    // 构建WebSocket请求
    req := GeneralWsReq{
        ReqIdentifier: reqIdentifier,
        Token:         c.token,
        SendID:        c.userID,
        OperationID:   mcontext.GetOperationID(ctx),
        MsgIncr:       GenMsgIncr(c.userID),
        Data:          data,
    }

    // 发送并等待响应(关键阻塞点)
    wsResp, err := c.sendAndWaitResp(&req)
    if err != nil {
        return err
    }

    // 解析响应数据
    if wsResp.ErrCode != 0 {
        return sdkerrs.NewCodeError(wsResp.ErrCode, wsResp.ErrMsg)
    }

    return proto.Unmarshal(wsResp.Data, resp)
}
🔄 长连接发送阶段的消息调整
  • 序列化处理:将消息结构体序列化为二进制数据传输
  • MsgIncr分配:为每个请求分配唯一的消息增量ID,用于响应匹配
  • 超时控制:设置30秒超时,防止请求无限等待
  • 重试机制:连接失败时自动重试,最多3次
  • 响应解析:收到服务端响应后反序列化为消息结构

6.3 异步响应处理机制

文件位置: long_conn_mgr.go:504-531

func (c *LongConnMgr) sendAndWaitResp(msg *GeneralWsReq) (*GeneralWsResp, error) {
    // 创建响应通道
    ch, err := c.writeBinaryMsgAndRetry(msg)
    if err != nil {
        return nil, err
    }

    // 确保清理响应通道
    defer c.Syncer.DelCh(msg.MsgIncr)

    // 等待响应(超时30秒)
    select {
    case resp := <-ch:
        if resp == nil {
            return nil, errors.New("response channel closed")
        }
        return resp, nil
    case <-time.After(30 * time.Second):
        return nil, errors.New("send message timeout")
    }
}

6.4 WebSocket连接复用和重试机制

文件位置: long_conn_mgr.go:532-569

func (c *LongConnMgr) writeBinaryMsgAndRetry(msg *GeneralWsReq) (chan *GeneralWsResp, error) {
    // 关键机制:通过MsgIncr建立请求-响应绑定关系
    msgIncr, ch := c.Syncer.AddCh(c.userID)
    msg.MsgIncr = msgIncr  // 设置消息增量ID,这是同步的核心
    
    // 重试机制:最多重试3次
    var err error
    for i := 0; i < 3; i++ {
        err = c.writeBinaryMsg(*msg)
        if err == nil {
            return ch, nil  // 发送成功,返回响应通道
        }
        
        // 连接异常处理:尝试重连
        if c.IsConnected() {
            continue  // 连接正常但发送失败,直接重试
        }
        
        // 连接断开:等待重连完成
        select {
        case <-c.ctx.Done():
            return nil, c.ctx.Err()
        case <-time.After(time.Second * 2):
            // 等待2秒后重试,给重连机制时间
        }
    }
    
    // 重试失败:清理通道并返回错误
    c.Syncer.DelCh(msgIncr)
    return nil, fmt.Errorf("writeBinaryMsgAndRetry failed after 3 attempts: %w", err)
}

MsgIncr机制的核心作用

  1. 唯一标识:每个WebSocket请求都有唯一的MsgIncr标识
  2. 上下文绑定:建立发送协程和响应协程之间的关联
  3. 并发安全:多个协程同时发送消息时不会串扰
  4. 超时控制:支持单独的请求超时和清理
  5. 重连恢复:连接断开重连后能正确匹配响应

异步响应原理

文件位置: ws_resp_asyn.go:61-86

func (u *WsRespAsyn) AddCh(userID string) (string, chan *GeneralWsResp) {
    u.wsMutex.Lock()
    defer u.wsMutex.Unlock()
    
    // 生成唯一消息增量ID
    msgIncr := GenMsgIncr(userID)
    ch := make(chan *GeneralWsResp, 1)
    
    // 注册响应通道
    u.wsNotification[msgIncr] = ch
    return msgIncr, ch
}

func (u *WsRespAsyn) NotifyResp(ctx context.Context, wsResp GeneralWsResp) error {
    u.wsMutex.RLock()
    ch, exists := u.wsNotification[wsResp.MsgIncr]
    u.wsMutex.RUnlock()
    
    if !exists {
        return errors.New("notification channel not found")
    }

    // 通知等待的协程
    return u.notifyCh(ch, &wsResp, 3000) // 3秒超时
}

WebSocket同步机制总结

阶段操作MsgIncr作用并发处理
请求发送生成MsgIncr并注册通道建立唯一标识线程安全的map操作
等待响应阻塞在channel上通过MsgIncr匹配响应每个请求独立的channel
接收响应服务端返回相同MsgIncr精确路由到对应协程避免响应串扰
清理资源删除通道和MsgIncr映射防止内存泄露确保资源及时释放

🌐 第七步:服务端消息接收处理

7.1 WebSocket服务器消息读取

文件位置: ws_server.go:768-850

func (ws *WsServer) wsHandler(w http.ResponseWriter, r *http.Request) {
    // 升级为WebSocket连接
    conn, err := ws.upgrader.Upgrade(w, r, nil)
    if err != nil {
        return
    }

    // 创建客户端连接对象
    client := ws.clientPool.Get().(*Client)
    client.ResetClient(&UserConnContext{...}, newGWebSocket(conn), ws)

    // 启动消息读取协程
    go client.readMessage()
}

7.2 消息处理

文件位置: client.go:186-263

func (c *Client) readMessage() {
    defer func() {
        c.close()                    // 确保连接关闭
        c.longConnServer.UnRegister(c) // 注销客户端
    }()

    for {
        // 读取WebSocket消息
        _, message, err := c.conn.ReadMessage()
        if err != nil {
            return
        }

        // 处理接收到的消息
        if err := c.handleMessage(message); err != nil {
            log.ZError(context.Background(), "handle message error", err)
        }
    }
}

func (c *Client) handleMessage(message []byte) error {
    var binaryReq Req
    err := c.Encoder.Decode(message, &binaryReq)
    if err != nil {
        return err
    }

    // 根据请求标识符路由消息
    switch binaryReq.ReqIdentifier {
    case WSSendMsg:
        // 处理发送消息请求
        return c.longConnServer.SendMessage(ctx, &binaryReq)
    case WSPullMsgBySeqList:
        // 处理拉取消息请求
        return c.longConnServer.PullMessageBySeqList(ctx, &binaryReq)
    default:
        return fmt.Errorf("unknown request identifier: %d", binaryReq.ReqIdentifier)
    }
}

7.3 消息网关调用消息服务

文件位置: message_handler.go:169-194

// SendMessage handles the sending of messages through gRPC. It unmarshals the request data,
// validates the message, and then sends it using the message RPC client.
func (g *GrpcHandler) SendMessage(ctx context.Context, data *Req) ([]byte, error) {
    var msgData sdkws.MsgData
    if err := proto.Unmarshal(data.Data, &msgData); err != nil {
        return nil, errs.WrapMsg(err, "SendMessage: error unmarshaling message data", "action", "unmarshal", "dataType", "MsgData")
    }

    if err := g.validate.Struct(&msgData); err != nil {
        return nil, errs.WrapMsg(err, "SendMessage: message data validation failed", "action", "validate", "dataType", "MsgData")
    }

    req := msg.SendMsgReq{MsgData: &msgData}
    // 调用消息服务RPC接口(阻塞调用)
    resp, err := g.msgClient.MsgClient.SendMsg(ctx, &req)
    if err != nil {
        return nil, err
    }

    // 序列化响应数据
    c, err := proto.Marshal(resp)
    if err != nil {
        return nil, errs.WrapMsg(err, "SendMessage: error marshaling response", "action", "marshal", "dataType", "SendMsgResp")
    }

    return c, nil
}

消息网关处理流程

  1. 消息解析:将WebSocket二进制数据解析为MsgData结构
  2. 数据验证:验证消息数据的完整性和合法性
  3. RPC调用:同步调用消息服务的SendMsg接口
  4. 响应处理:将RPC响应序列化后返回给客户端
🔄 服务端消息接收阶段的调整
  • 消息解析:将客户端二进制数据还原为完整的消息结构
  • 数据验证:校验消息字段完整性,确保数据安全传输
  • 路由分发:根据ReqIdentifier将消息路由到对应的处理器
  • RPC转换:将WebSocket请求转换为内部RPC调用格式

📮 第八步:消息服务RPC处理

8.1 消息服务核心发送逻辑

文件位置: send.go:37-61

func (m *msgServer) SendMsg(ctx context.Context, req *pbmsg.SendMsgReq) (*pbmsg.SendMsgResp, error) {
    // 第一步:消息数据封装
    m.encapsulateMsgData(req.MsgData)

    // 第二步:消息验证
    if err := m.messageVerification(ctx, req); err != nil {
        return nil, err
    }

    // 第三步:根据会话类型处理
    switch req.MsgData.SessionType {
    case constant.SingleChatType:
        return m.sendMsgSingleChat(ctx, req)
    case constant.GroupChatType:
        return m.sendMsgGroupChat(ctx, req)
    case constant.NotificationChatType:
        return m.sendMsgNotification(ctx, req)
    default:
        return nil, sdkerrs.ErrArgs.WrapMsg("invalid session type")
    }
}

8.2 消息数据封装

文件位置: verify.go:177-236

func (m *msgServer) encapsulateMsgData(msg *sdkws.MsgData) {
    // 生成唯一的服务器消息ID
    msg.ServerMsgID = GetMsgID(msg.SendID)

    // 设置发送时间(如果客户端没有提供)
    if msg.SendTime == 0 {
        msg.SendTime = timeutil.GetCurrentTimestampByMill()
    }

    // 设置消息选项(默认配置)
    if msg.Options == nil {
        msg.Options = msgprocessor.NewMsgOptions()
    }
}

func GetMsgID(sendID string) string {
    // 使用时间戳 + 用户ID + 随机数生成唯一ID
    return utils.Md5(fmt.Sprintf("%d%s%d", timeutil.GetCurrentTimestampByMill(), sendID, rand.Int63()))
}
🔄 消息数据封装阶段的调整
  • ServerMsgID生成:为消息分配全局唯一的服务端消息ID
  • 发送时间校正:如果客户端时间为0,使用服务端时间戳
  • 选项初始化:为消息设置默认的处理选项(推送、存储等)
  • MD5生成:使用时间戳+用户ID+随机数确保消息ID的唯一性

8.3 单聊消息处理

文件位置: send.go:216-280

func (m *msgServer) sendMsgSingleChat(ctx context.Context, req *pbmsg.SendMsgReq) (resp *pbmsg.SendMsgResp, err error) {
    // 第一步:好友关系和黑名单验证
    if err := m.verifyFriendship(ctx, req.MsgData.SendID, req.MsgData.RecvID); err != nil {
        return nil, err
    }

    // 第二步:检查接收方消息接收设置
    canSend, err := m.modifyMessageByUserMessageReceiveOpt(ctx, req.MsgData.RecvID, 
        utils.GetConversationIDByMsg(req.MsgData), int(req.MsgData.SessionType), req)
    if err != nil {
        return nil, err
    }
    if !canSend {
        // 对方设置了不接收消息
        return &pbmsg.SendMsgResp{
            ServerMsgID: req.MsgData.ServerMsgID,
            SendTime:    req.MsgData.SendTime,
        }, nil
    }

    // 第三步:生成会话唯一键
    conversationID := msgprocessor.GetConversationIDByMsg(req.MsgData)

    // 第四步:设置消息选项
    options := msgprocessor.Options(req.MsgData.Options)
    if !options.IsNotNotification() {
        // 普通消息,设置推送选项
        req.MsgData.Options = msgprocessor.WithOptions(req.MsgData.Options,
            msgprocessor.WithOfflinePush(true),
            msgprocessor.WithUnreadCount(true),
        )
    }

    // 第五步:将消息投递到消息队列
    if err := m.MsgDatabase.MsgToMQ(ctx, conversationutil.GenConversationUniqueKeyForSingle(req.MsgData.SendID, req.MsgData.RecvID), req.MsgData); err != nil {
        prommetrics.SingleChatMsgProcessFailedCounter.Inc()
        return nil, err
    }

    // 第六步:返回成功响应
    prommetrics.SingleChatMsgProcessSuccessCounter.Inc()
    return &pbmsg.SendMsgResp{
        ServerMsgID: req.MsgData.ServerMsgID,
        SendTime:    req.MsgData.SendTime,
    }, nil
}
🔄 单聊消息处理阶段的调整
  • 好友关系验证:检查发送者和接收者是否为好友关系,验证黑名单
  • 接收设置检查:根据接收者的消息接收配置调整推送选项
  • 会话键生成:生成单聊会话的唯一标识,用于消息队列分区
  • Options自动设置:为普通单聊消息自动开启离线推送和未读计数
  • 队列投递:将处理完的消息投递到Kafka队列进行异步处理

8.4 用户消息接收设置检查

文件位置: verify.go:244-300

func (m *msgServer) modifyMessageByUserMessageReceiveOpt(ctx context.Context, userID, conversationID string, sessionType int, pb *msg.SendMsgReq) (bool, error) {
    // 获取用户的全局消息接收设置
    opt, err := m.UserLocalCache.GetUserGlobalMsgRecvOpt(ctx, userID)
    if err != nil {
        return false, err
    }

    switch opt {
    case constant.ReceiveMessage:
        // 正常接收消息

    case constant.NotReceiveMessage:
        // 不接收任何消息
        return false, nil

    case constant.ReceiveNotNotifyMessage:
        // 接收消息但不推送通知
        if pb.MsgData.Options == nil {
            pb.MsgData.Options = make(map[string]bool, 10)
        }
        // 关闭离线推送选项
        datautil.SetSwitchFromOptions(pb.MsgData.Options, constant.IsOfflinePush, false)
        return true, nil
    }

    // 检查会话级别的接收设置
    singleOpt, err := m.ConversationLocalCache.GetSingleConversationRecvMsgOpt(ctx, userID, conversationID)
    if errs.ErrRecordNotFound.Is(err) {
        return true, nil
    } else if err != nil {
        return false, err
    }

    switch singleOpt {
    case constant.ReceiveMessage:
        return true, nil
    case constant.NotReceiveMessage:
        // 特殊消息类型仍需接收(如系统通知)
        if datautil.Contain(int(pb.MsgData.ContentType), ExcludeContentType...) {
            return true, nil
        }
        return false, nil
    case constant.ReceiveNotNotifyMessage:
        if pb.MsgData.Options == nil {
            pb.MsgData.Options = make(map[string]bool, 10)
        }
        datautil.SetSwitchFromOptions(pb.MsgData.Options, constant.IsOfflinePush, false)
        return true, nil
    }
    return true, nil
}

8.5 消息选项配置详解

消息选项(Options)控制消息的处理行为,OpenIM通过msgprocessor.Options类型实现细粒度的消息控制。

文件位置: options.go:31-539

Options字段详细说明
选项名称类型默认值作用描述使用场景
IsNotNotificationboolfalse是否为非通知消息
true = 系统通知消息
false = 普通用户消息
• 影响推送和计数策略
系统通知、群管理消息
IsSendMsgbooltrue是否发送消息
true = 需要发送到接收方
false = 仅本地处理
• 控制消息传递行为
草稿保存、本地记录
IsHistorybooltrue是否保存历史记录
true = 消息持久化到数据库
false = 仅临时处理
• 影响消息存储策略
临时消息、阅后即焚
IsPersistentbooltrue是否持久化存储
true = 存储到MongoDB
false = 仅缓存处理
• 决定数据持久性
重要消息、合规要求
IsOfflinePushbooltrue是否离线推送
true = 用户离线时推送
false = 不推送通知
• 控制推送行为
免打扰设置、静默消息
IsUnreadCountbooltrue是否计入未读数
true = 增加未读计数
false = 不影响未读数
• 影响会话未读统计
系统提示、状态消息
IsConversationUpdatebooltrue是否更新会话
true = 更新会话最新消息
false = 不更新会话列表
• 控制会话显示
静默消息、后台同步
IsSenderSyncbooltrue是否发送者同步
true = 同步到发送者其他设备
false = 仅发送给接收者
• 多端同步控制
单设备消息、临时通知
IsNotPrivateboolfalse是否非私密消息
true = 公开消息
false = 私密消息
• 隐私保护相关
群公告、系统广播
IsSenderConversationUpdatebooltrue是否更新发送者会话
true = 更新发送者会话列表
false = 不更新发送者会话
• 发送者界面控制
单向消息、系统通知
IsReactionFromCacheboolfalse是否从缓存获取反应
true = 优先使用缓存数据
false = 实时查询数据库
• 性能优化选项
高频查询、性能敏感场景
消息选项的组合使用
// 普通用户消息(默认配置)
normalMsgOptions := msgprocessor.NewMsgOptions()
// 等价于:
// map[string]bool{
//     "isNotNotification": false,
//     "isSendMsg": true,
//     "isHistory": true,
//     "isPersistent": true,
//     "isOfflinePush": true,
//     "isUnreadCount": true,
//     "isConversationUpdate": true,
//     "isSenderSync": true,
//     "isNotPrivate": false,
//     "isSenderConversationUpdate": true,
//     "isReactionFromCache": false,
// }

// 系统通知消息
notificationOptions := msgprocessor.WithOptions(msgprocessor.NewMsgOptions(),
    msgprocessor.WithNotNotification(true),   // 标记为通知消息
    msgprocessor.WithOfflinePush(false),      // 不推送
    msgprocessor.WithUnreadCount(false),      // 不计入未读
)

// 阅后即焚消息
burnAfterReadOptions := msgprocessor.WithOptions(msgprocessor.NewMsgOptions(),
    msgprocessor.WithHistory(false),          // 不保存历史
    msgprocessor.WithPersistent(),            // 但要持久化(用于追溯)
    msgprocessor.WithNotPrivate(),            // 标记为非私密
)

// 群组静默消息
silentGroupMsgOptions := msgprocessor.WithOptions(msgprocessor.NewMsgOptions(),
    msgprocessor.WithOfflinePush(false),      // 静默推送
    msgprocessor.WithUnreadCount(false),      // 不增加未读数
    msgprocessor.WithConversationUpdate(),    // 但更新会话列表
)
消息选项的判断方法
// 检查消息类型
if options.IsNotNotification() {
    // 处理系统通知逻辑
}

// 检查是否需要推送
if options.IsOfflinePush() {
    // 发送离线推送
}

// 检查是否计入未读数
if options.IsUnreadCount() {
    // 增加未读计数
}

// 检查是否更新会话
if options.IsConversationUpdate() {
    // 更新会话最新消息和时间
}
业务场景映射
业务场景选项配置说明
普通聊天消息全部默认true完整的消息流程和用户体验
系统通知IsNotNotification=true, IsOfflinePush=false不推送但显示在聊天中
输入状态IsHistory=false, IsPersistent=false临时状态,不保存
已读回执IsUnreadCount=false, IsConversationUpdate=false不影响会话状态
阅后即焚IsHistory=false阅读后自动删除
群管理消息IsNotNotification=true, IsUnreadCount=false管理类通知消息
撤回通知IsNotNotification=true, IsOfflinePush=false撤回提示消息

📊 第九步:消息投递到Kafka队列

9.1 Kafka消息投递

// 将消息投递到Kafka队列进行异步处理
func (d *msgDatabase) MsgToMQ(ctx context.Context, key string, msg *sdkws.MsgData) error {
    // 序列化消息
    data, err := proto.Marshal(msg)
    if err != nil {
        return err
    }

    // 投递到不同的Topic
    return d.producer.SendMessage(ctx, &sarama.ProducerMessage{
        Topic: d.config.Kafka.ToRedisTopic,    // 消息缓存处理
        Key:   sarama.StringEncoder(key),      // 分区键:会话ID
        Value: sarama.ByteEncoder(data),       // 消息数据
    })
}

Kafka Topic分配

  • toRedisTopic: 消息缓存和序列号分配
  • toMongoTopic: 消息持久化存储
  • toPushTopic: 在线用户推送
  • toOfflinePushTopic: 离线用户推送
🔄 Kafka队列投递阶段的调整
  • 消息序列化:将消息结构体序列化为二进制数据用于队列传输
  • 分区键设置:使用会话ID作为分区键,确保同一会话的消息有序处理
  • Topic路由:根据消息类型和处理需求路由到不同的Kafka Topic
  • 异步处理:消息投递后立即返回响应,不等待后续处理完成

🔄 第十步:响应返回和状态更新

10.1 服务端响应返回

当消息成功投递到Kafka后,服务端立即返回响应:

// 响应包含服务端生成的关键信息
response := &pbmsg.SendMsgResp{
    ServerMsgID: req.MsgData.ServerMsgID,  // 服务端消息ID
    SendTime:    req.MsgData.SendTime,     // 服务端时间戳
}

10.2 客户端状态更新

Android端接收到响应后更新UI

@Override
public void onSuccess(Message message) {
    // 更新消息状态为发送成功
    msg.setStatus(MessageStatus.SEND_SUCCESS);
    msg.setServerMsgID(message.getServerMsgID());
    msg.setSendTime(message.getSendTime());
    
    // 刷新UI显示
    updateMsgStatusAndRefresh(msg);
}

SDK端数据库更新

// 更新本地数据库中的消息状态
func (c *Conversation) updateMsgStatusAndTriggerConversation(ctx context.Context, clientMsgID, serverMsgID string, sendTime int64, status int32, s *sdk_struct.MsgStruct, lc *model_struct.LocalConversation, isOnlineOnly bool) {
    
    // 更新消息属性
    s.SendTime = sendTime
    s.Status = status
    s.ServerMsgID = serverMsgID

    // 更新数据库中的消息状态
    err := c.db.UpdateMessageTimeAndStatus(ctx, lc.ConversationID, clientMsgID, serverMsgID, sendTime, status)
    if err != nil {
        log.ZWarn(ctx, "send message update message status error", err)
    }

    // 删除发送中的消息记录
    err = c.db.DeleteSendingMessage(ctx, lc.ConversationID, clientMsgID)
    if err != nil {
        log.ZWarn(ctx, "send message delete sending message error", err)
    }

    // 更新会话的最新消息
    lc.LatestMsg = utils.StructToJsonString(s)
    lc.LatestMsgSendTime = sendTime

    // 触发会话更新事件
    c.msgListener().OnMsgSendCallback(&CallbackMessage{...})
}
🔄 响应返回和状态更新阶段的调整
  • 消息状态更新:将消息状态从MsgStatusSending改为MsgStatusSendSuccess
  • 服务端信息补充:填充ServerMsgID和服务端时间戳到消息结构
  • Seq保持为0:第一阶段不分配序号,等待第二阶段MsgTransfer处理
  • 本地数据同步:更新本地数据库中的消息状态和服务端信息
  • 发送记录清理:删除发送中状态的临时记录
  • 会话信息更新:更新会话的最新消息内容和时间戳
  • UI事件触发:通过回调通知UI层更新消息显示状态

📋 消息状态变迁总结

🔄 完整的消息状态生命周期

阶段状态值状态名称Seq序号IsRead状态关键调整
初始创建1MsgStatusSending0false设置基础字段、生成ClientMsgID
本地存储1MsgStatusSending0false插入本地数据库、添加发送中记录
WebSocket发送1MsgStatusSending0false序列化、分配MsgIncr、发送到服务端
服务端处理1MsgStatusSending0false生成ServerMsgID、校正时间戳、设置Options
Kafka投递1MsgStatusSending0false投递到消息队列、返回成功响应
客户端收到响应2MsgStatusSendSuccess0false更新状态、补充服务端信息、触发UI更新
发送失败3MsgStatusSendFailure0false网络异常或服务端错误时的状态

🎯 关键状态调整节点说明

1. 消息创建阶段
  • Status: 初始化为MsgStatusSending(1)
  • Seq: 设置为0,等待第二阶段服务端分配
  • IsRead: 设置为false,第一阶段不会修改
  • 时间戳: 使用客户端当前时间
2. 服务端封装阶段
  • ServerMsgID: 生成全局唯一的服务端消息ID
  • SendTime: 如果客户端为0则使用服务端时间
  • Options: 自动设置推送、未读、存储等选项
3. 成功响应阶段
  • Status: 更新为MsgStatusSendSuccess(2)
  • Seq: 仍为0,需要等待后续的消息存储流程分配
  • IsRead: 保持false,未在此阶段修改
  • 服务端信息: 补充ServerMsgID和SendTime

📊 状态调整对用户体验的影响

状态UI显示用户操作数据一致性
Sending显示发送中图标可以取消发送本地临时状态,IsRead=false
Success显示发送成功可以撤回消息与服务端同步,但IsRead仍为false
Failed显示失败图标可以重新发送需要重试处理,IsRead=false

⚠️ 重要澄清:Seq序号分配时机

第一阶段发送流程的局限性

本文档描述的第一阶段发送流程,其核心目标是将消息成功投递到Kafka队列,而不是完成消息的最终存储。因此:

✅ 第一阶段完成的工作
  • 消息创建和基础信息设置
  • 通过WebSocket发送到服务端
  • 服务端生成ServerMsgID和校正时间戳
  • 消息成功投递到Kafka队列
  • 返回发送成功响应给客户端
❌ 第一阶段未完成的工作
  • Seq序号分配:需要在第二阶段的MsgTransfer中完成
  • IsRead状态设置:发送者的已读状态需要在后续流程中设置
  • ReadSeq自动更新:发送者的已读序号需要在第二阶段同步更新,防止红点
  • MaxSeq递增:会话最大序号在第二阶段随Seq分配而更新
  • 消息持久化存储:MongoDB存储发生在第二阶段
  • 接收者推送:消息推送依赖第二阶段的序号分配

Seq序号的真实分配流程

📍 第一阶段(发送): 消息投递 → Kafka队列
   └─ Seq = 0, IsRead = false, MaxSeq不变, ReadSeq不变

📍 第二阶段(存储): MsgTransfer处理 → Redis分配Seq → MongoDB存储
   └─ Seq = 实际序号, IsRead = true, MaxSeq +1, ReadSeq +1 (发送者自动已读)

📍 第三阶段(推送): 基于Seq的消息推送和接收
   └─ 接收者获得消息: Seq = 实际序号, IsRead = false, 产生红点提醒

为什么要分两个阶段?

  1. 性能优化:发送响应不等待完整存储,提升用户体验
  2. 系统解耦:消息网关和存储系统独立,提高系统稳定性
  3. 并发处理:Kafka队列支持高并发的消息处理
  4. 故障隔离:即使存储暂时异常,用户仍能获得发送确认
  5. 状态管理:复杂的红点逻辑和已读状态在后台异步处理,不影响发送响应速度

💡 总结: 发送成功≠消息完全处理完成。第一阶段的"成功"意味着消息已安全进入处理队列,但序号分配、已读状态设置和最终存储仍需要第二阶段完成。最关键的是,发送者的ReadSeq必须在第二阶段同步更新,确保不会因为自己的消息产生红点。

⏱️ 时序图

sequenceDiagram
    participant User as 用户
    participant UI as Android UI
    participant VM as ChatViewModel  
    participant SDK as OpenIM SDK
    participant WS as WebSocket长连接
    participant GW as 消息网关
    participant MSG as 消息服务
    participant MQ as Kafka队列

    User->>UI: 点击发送按钮
    UI->>SDK: createTextMessage(text)
    SDK-->>UI: 返回消息对象
    UI->>VM: sendMsg(message)
    VM->>UI: 立即显示消息(发送中状态)
    VM->>SDK: sendMessage(callback, message)
    
    Note over SDK: 协程处理发送逻辑
    SDK->>SDK: 检查消息去重
    SDK->>SDK: 保存到本地数据库
    SDK->>SDK: 插入发送中记录
    SDK->>SDK: 触发会话更新事件
    
    SDK->>WS: SendReqWaitResp(消息)
    WS->>GW: WebSocket发送
    GW->>MSG: RPC调用SendMsg
    
    Note over MSG: 服务端处理逻辑
    MSG->>MSG: 消息验证和封装
    MSG->>MSG: 好友关系验证
    MSG->>MSG: 消息接收设置检查
    MSG->>MQ: 投递到Kafka队列
    
    MSG-->>GW: 返回成功响应
    GW-->>WS: WebSocket响应
    WS-->>SDK: 唤醒等待协程
    SDK->>SDK: 更新消息状态和时间
    SDK->>SDK: 删除发送中记录
    SDK-->>VM: 回调onSuccess
    VM->>UI: 更新消息显示(发送成功)
    
    Note over MQ: 后续异步处理
    Note over MQ: 序列号分配、持久化、推送等