【微服务即时通讯】消息存储子服务2

目录

一.RPC服务的定义

1.1.message.proto的编写

1.2.RPC接口的定义

1.2.1.获取指定时间范围内的历史消息

1.2.2.获取指定会话的最近N条消息

1.2.3.基于关键字的文本消息搜索

二.MessageServer类的编写

三.MessageServerBuilder类编写

3.1.订阅队列消息的处理回调函数

3.2.完整代码

四.搭建消息存储子服务

五.测试


一.RPC服务的定义

1.1.message.proto的编写

这里实现了3个RPC服务

这个 protobuf 文件定义了一个 消息存储服务,为聊天系统提供了三个核心的 RPC 接口:

  1. 获取历史消息:允许客户端根据时间区间拉取指定会话的消息记录,适用于"查看更多历史消息"或按日期翻页的场景。

  2. 获取最近消息:允许客户端指定数量,获取某个会话中最新的一批消息,适用于聊天窗口初始加载或下拉刷新。

  3. 消息搜索:允许客户端在指定会话中根据关键词检索消息内容,帮助用户快速定位包含特定文字的聊天记录。

这三个接口共同构成了完整的消息存取能力,支持客户端灵活地加载、浏览和搜索聊天历史。

我们先看看获取历史消息

我们这里的获取历史消息是按照时间区段来实现的。

  • 起始时间(包含)
  • 终止时间(包含)

此外,我们还需要聊天会话ID,因为消息和聊天会话ID是绑定死了的。

然后,由于我们这个是登录后的操作,所以避免不了

  • 用户ID
  • 会话ID
javascript 复制代码
// ---------------------------- 获取历史消息请求 ----------------------------
// 根据时间段获取指定聊天会话的历史消息
message GetHistoryMsgReq 
{
    string request_id = 1;           // 请求唯一标识
    string chat_session_id = 2;      // 聊天会话ID,标识本次请求所属的会话
    int64 start_time = 3;            // 起始时间
    int64 over_time = 4;             // 结束时间
    optional string user_id = 5;     // 用户ID(可选)
    optional string session_id = 6;  // 会话ID(可选)
}

// ---------------------------- 获取历史消息响应 ----------------------------
// 返回历史消息列表及操作结果
message GetHistoryMsgRsp 
{
    string request_id = 1;           // 对应的请求ID,便于客户端匹配
    bool success = 2;                // 操作是否成功
    string errmsg = 3;               // 错误信息,当 success 为 false 时提供详细原因
    repeated MessageInfo msg_list = 4; // 消息列表,按时间顺序排列(通常为升序)
}

接下来我们讲解下一个RPC服务

这个是获取最近N条消息请求

我们需要现在这个时间,然后还需要这个消息条数,聊天会话ID

javascript 复制代码
// ---------------------------- 获取最近N条消息请求 ----------------------------
// 获取指定聊天会话最近若干条消息
message GetRecentMsgReq 
{
    string request_id = 1;           // 请求唯一标识
    string chat_session_id = 2;      // 聊天会话ID
    int64 msg_count = 3;             // 需要获取的消息条数
    optional int64 cur_time = 4;     // 当前时间(扩展字段,用于获取指定时间前的最近N条消息)
    optional string user_id = 5;     // 用户ID(可选)
    optional string session_id = 6;  // 会话ID(可选)
}

// ---------------------------- 获取最近N条消息响应 ----------------------------
// 返回最近消息列表及操作结果
message GetRecentMsgRsp 
{
    string request_id = 1;           // 对应的请求ID
    bool success = 2;                // 操作是否成功
    string errmsg = 3;               // 错误信息
    repeated MessageInfo msg_list = 4; // 消息列表,按时间升序排列(最新的消息在末尾)
}

那么我们有必要去讲解一下下面这2个RPC服务的区别

  • GetHistoryMsg :通过时间区间来获取消息。客户端传入起始时间和结束时间,服务端返回该时间段内的所有消息,适合"按日期翻页"或"加载某段时间的历史记录"。

  • GetRecentMsg :通过固定条数 来获取消息。客户端指定要获取的消息条数(如20条),服务端返回该会话中最新的 N 条消息,适合聊天窗口初始加载或下拉刷新时获取最新内容。

接下来这个RPC服务就是消息检索了,我们在使用过程中肯定避免不了去根据关键词搜索历史消息,那么那么多消息,我们必须进行检索

javascript 复制代码
// ---------------------------- 消息搜索请求 ----------------------------
// 根据关键词搜索消息
message MsgSearchReq 
{
    string request_id = 1;           // 请求唯一标识
    optional string user_id = 2;     // 用户ID(可选,用于限定用户范围)
    optional string session_id = 3;  // 会话ID(可选,用于限定会话范围)
    string chat_session_id = 4;      // 聊天会话ID(必填,标识在哪个会话中搜索)
    string search_key = 5;           // 搜索关键词,用于匹配消息内容
}

// ---------------------------- 消息搜索响应 ----------------------------
// 返回搜索结果及操作状态
message MsgSearchRsp 
{
    string request_id = 1;           // 对应的请求ID
    bool success = 2;                // 操作是否成功
    string errmsg = 3;               // 错误信息
    repeated MessageInfo msg_list = 4; // 匹配的消息列表
}

完整代码

javascript 复制代码
// Protocol Buffers 版本声明
syntax = "proto3";
// 定义包名,用于命名空间隔离
package IMS;
// 导入基础定义(可能包含 MessageInfo 等公共结构)
import "base.proto";

// 开启通用服务选项,表示生成的服务类支持 RPC 框架的通用服务接口
option cc_generic_services = true;

// ---------------------------- 获取历史消息请求 ----------------------------
// 根据时间段获取指定聊天会话的历史消息
message GetHistoryMsgReq 
{
    string request_id = 1;           // 请求唯一标识
    string chat_session_id = 2;      // 聊天会话ID,标识本次请求所属的会话
    int64 start_time = 3;            // 起始时间
    int64 over_time = 4;             // 结束时间
    optional string user_id = 5;     // 用户ID(可选)
    optional string session_id = 6;  // 会话ID(可选)
}

// ---------------------------- 获取历史消息响应 ----------------------------
// 返回历史消息列表及操作结果
message GetHistoryMsgRsp 
{
    string request_id = 1;           // 对应的请求ID,便于客户端匹配
    bool success = 2;                // 操作是否成功
    string errmsg = 3;               // 错误信息,当 success 为 false 时提供详细原因
    repeated MessageInfo msg_list = 4; // 消息列表,按时间顺序排列(通常为升序)
}

// ---------------------------- 获取最近N条消息请求 ----------------------------
// 获取指定聊天会话最近若干条消息
message GetRecentMsgReq 
{
    string request_id = 1;           // 请求唯一标识
    string chat_session_id = 2;      // 聊天会话ID
    int64 msg_count = 3;             // 需要获取的消息条数
    optional int64 cur_time = 4;     // 当前时间(扩展字段,用于获取指定时间前的最近N条消息)
    optional string user_id = 5;     // 用户ID(可选)
    optional string session_id = 6;  // 会话ID(可选)
}

// ---------------------------- 获取最近N条消息响应 ----------------------------
// 返回最近消息列表及操作结果
message GetRecentMsgRsp 
{
    string request_id = 1;           // 对应的请求ID
    bool success = 2;                // 操作是否成功
    string errmsg = 3;               // 错误信息
    repeated MessageInfo msg_list = 4; // 消息列表,按时间升序排列(最新的消息在末尾)
}

// ---------------------------- 消息搜索请求 ----------------------------
// 根据关键词搜索消息
message MsgSearchReq 
{
    string request_id = 1;           // 请求唯一标识
    optional string user_id = 2;     // 用户ID(可选,用于限定用户范围)
    optional string session_id = 3;  // 会话ID(可选,用于限定会话范围)
    string chat_session_id = 4;      // 聊天会话ID(必填,标识在哪个会话中搜索)
    string search_key = 5;           // 搜索关键词,用于匹配消息内容
}

// ---------------------------- 消息搜索响应 ----------------------------
// 返回搜索结果及操作状态
message MsgSearchRsp 
{
    string request_id = 1;           // 对应的请求ID
    bool success = 2;                // 操作是否成功
    string errmsg = 3;               // 错误信息
    repeated MessageInfo msg_list = 4; // 匹配的消息列表
}

// ---------------------------- 消息存储服务定义 ----------------------------
// 提供历史消息查询、最近消息获取、消息搜索等 RPC 接口
service MsgStorageService 
{
    // 获取指定时间段内的历史消息
    rpc GetHistoryMsg(GetHistoryMsgReq) returns (GetHistoryMsgRsp);
    // 获取最近 N 条消息
    rpc GetRecentMsg(GetRecentMsgReq) returns (GetRecentMsgRsp);
    // 根据关键词搜索消息
    rpc MsgSearch(MsgSearchReq) returns (MsgSearchRsp);
}

1.2.RPC接口的定义

1.2.1.获取指定时间范围内的历史消息

对于这个RPC服务而言,它最重要的就是去MySQL数据库里面查询对应消息。

那么我们最终都是调用这个mysql_message.hpp里面的range接口来数据库里面查询[start_time,over_time]这个区间内的所有消息的。

javascript 复制代码
// 获取指定聊天会话在某个时间区间内的所有消息(包含边界)
        // @param chat_ssid 聊天会话ID
        // @param stime 起始时间(包含)
        // @param etime 结束时间(包含)
        // @return 返回消息对象的vector,按时间升序排列(由数据库查询结果决定,通常自然顺序即为时间升序)
        std::vector<Message> range(const std::string &chat_ssid,
                                   boost::posix_time::ptime &stime,
                                   boost::posix_time::ptime &etime)
        {
            std::vector<Message> res;
            try
            {
                // 开启事务
                odb::transaction trans(_db->begin());
                // 定义查询类型别名
                typedef odb::query<Message> query;
                typedef odb::result<Message> result;

                // 执行查询:条件为 chat_session_id 等于 chat_ssid,且 create_time 在 [stime, etime] 之间
                result r(_db->query<Message>(query::chat_session_id == chat_ssid &&
                                             query::create_time >= stime &&
                                             query::create_time <= etime));

                // 将查询结果依次压入vector(数据库返回顺序通常为插入顺序或主键顺序,若create_time为索引则自然升序)
                for (result::iterator i(r.begin()); i != r.end(); ++i)
                {
                    res.push_back(*i);
                }

                // 提交事务
                trans.commit();
            }
            catch (std::exception &e)
            {
                // 捕获异常,记录错误日志(包括聊天会话ID、时间范围和异常信息)
                LOG_ERROR("获取区间消息失败:{}-{}:{}-{}!", chat_ssid,
                          boost::posix_time::to_simple_string(stime),
                          boost::posix_time::to_simple_string(etime), e.what());
            }
            return res;
        }

可以看到,它返回的是[start_time,over_time]这个区间内的所有类型的消息。

这个查询是我们这个RPC服务的基础!!!

但是,我们也需要思考一些问题:

首先我们的消息是分成4大类型的

  • 纯文本消息
  • 图片消息
  • 文件消息
  • 语音消息

难道我们需要将所有类型的消息内容都存储到MySQL数据库里面吗??

实则不然,我们只在数据库里面存储纯文本消息,剩余3种类型的消息,我们将它存储到一个个文件里面去。

  • 对于纯文本消息,文本内容存储在 _content 字段中。
  • 对于图片、文件、语音这三种类型,只存储了文件标识、文件名、文件大小等元信息,文件本身的二进制数据并不存入数据库。

那么当我们想要去获取这些二进制数据的时候就需要去借助我们的文件管理子服务了。

那么我们怎么去区分纯文本消息和非纯文本消息,其实很简单,就检测一下文件ID这个字段是不是为空,如果为空,就说明是纯文本字段,如果不为空,就说明是图片、文件、语音这三种类型,那么对于这些消息,我们就需要借助于文件管理子服务的批量下载文件的RPC服务了。

在我们的代码中,我们使用了下面这个_GetFile函数,它的本质就是调用了文件管理子服务里面的批量下载文件这个RPC服务

cpp 复制代码
// 获取多个文件的内容
// 参数:
//   rid: 请求ID,用于标识本次请求
//   file_id_lists: 需要获取的文件ID集合(无序集合)
//   file_data_lists: 输出参数,存储获取到的文件ID及其内容(映射表)
// 返回值:
//   true: 成功获取所有文件数据
//   false: 获取失败(如无可用的文件服务节点、RPC调用失败等)
bool _GetFile(const std::string &rid,
              const std::unordered_set<std::string> &file_id_lists,
              std::unordered_map<std::string, std::string> &file_data_lists)
{
    // 从服务管理器中选择一个可用的文件服务子服务节点
    auto channel = _mm_channels->choose(_file_service_name);
    if (!channel)   // 没有可用节点时记录错误并返回失败
    {
        LOG_ERROR("{} 没有可供访问的文件子服务节点!", _file_service_name);
        return false;
    }

    // 创建RPC存根,用于调用文件服务的接口
    FileService_Stub stub(channel.get());

    // 构建请求消息
    GetMultiFileReq req;
    GetMultiFileRsp rsp;
    req.set_request_id(rid);                     // 设置请求ID
    for (const auto &id : file_id_lists)         // 将所有文件ID添加到请求中
    {
        req.add_file_id_list(id);
    }

    // 发起RPC调用(使用brpc框架)
    brpc::Controller cntl;
    stub.GetMultiFile(&cntl, &req, &rsp, nullptr);

    // 检查调用是否失败:控制层失败或业务层返回失败标志
    if (cntl.Failed() == true || rsp.success() == false)
    {
        LOG_ERROR("文件子服务调用失败:{}!", cntl.ErrorText());
        return false;
    }

    // 调用成功,从响应中提取文件数据
    const auto &fmap = rsp.file_data();          // 获取响应中的文件数据映射表
    for (auto it = fmap.begin(); it != fmap.end(); ++it)   // 遍历每个文件ID及其内容
    {
        // 将文件ID和内容插入到输出映射表中
        file_data_lists.insert(std::make_pair(it->first, it->second.file_content()));
    }

    return true;   // 成功返回
}

此外,我们的消息类型是需要记载我们的发送者的信息的,

cpp 复制代码
//作为一条消息,只有消息类型+消息内容是不行的
// 消息结构,表示一条完整的聊天消息
message MessageInfo 
{
    string message_id = 1;          // 消息ID,全局唯一
    string chat_session_id = 2;     // 消息所属聊天会话ID
    int64 timestamp = 3;             // 消息产生的时间戳(Unix时间戳,秒)
    UserInfo sender = 4;             // 消息发送者的用户信息
    MessageContent message = 5;      // 消息内容体
}

但是我们在数据库里面存储的仅仅只是发送者的用户ID啊,我们需要去根据这个用户ID来获取到用户的信息,因此,在我们的代码中,我们使用了下面这个_GetUser函数,它的本质就是调用了用户管理子服务里面的批量获取用户信息这个RPC服务

cpp 复制代码
// 获取多个用户的信息
// 参数:
//   rid: 请求ID,用于标识本次请求
//   user_id_lists: 需要获取的用户ID集合(无序集合)
//   user_lists: 输出参数,存储获取到的用户ID及其详细信息(映射表)
// 返回值:
//   true: 成功获取所有用户信息
//   false: 获取失败(如无可用的用户服务节点、RPC调用失败等)
bool _GetUser(const std::string &rid,
              const std::unordered_set<std::string> &user_id_lists,
              std::unordered_map<std::string, UserInfo> &user_lists)
{
    // 从服务管理器中选择一个可用的用户服务子服务节点
    auto channel = _mm_channels->choose(_user_service_name);
    if (!channel)   // 无可用节点时记录错误并返回失败
    {
        LOG_ERROR("{} 没有可供访问的用户子服务节点!", _user_service_name);
        return false;
    }

    // 创建RPC存根,用于调用用户服务的接口
    UserService_Stub stub(channel.get());

    // 构建请求消息
    GetMultiUserInfoReq req;
    GetMultiUserInfoRsp rsp;
    req.set_request_id(rid);                     // 设置请求ID
    for (const auto &id : user_id_lists)         // 将所有用户ID添加到请求中
    {
        req.add_users_id(id);
    }

    // 发起RPC调用(使用brpc框架)
    brpc::Controller cntl;
    stub.GetMultiUserInfo(&cntl, &req, &rsp, nullptr);

    // 检查调用是否失败:控制层失败或业务层返回失败标志
    if (cntl.Failed() == true || rsp.success() == false)
    {
        LOG_ERROR("用户子服务调用失败:{}!", cntl.ErrorText());
        return false;
    }

    // 调用成功,从响应中提取用户信息
    const auto &umap = rsp.users_info();         // 获取响应中的用户信息映射表
    for (auto it = umap.begin(); it != umap.end(); ++it)   // 遍历每个用户ID及其信息
    {
        // 将用户ID和信息插入到输出映射表中
        user_lists.insert(std::make_pair(it->first, it->second));
    }

    return true;   // 成功返回
}

那么现在,我们就很快就能写出我们的RPC服务的定义了

cpp 复制代码
// RPC 服务方法:获取指定时间范围内的历史消息
        // 该方法从数据库中查询历史消息,并批量获取关联的文件数据和用户信息,最终组织成响应返回
        // 参数说明:
        //   controller - RPC 控制器,用于控制 RPC 调用行为(如超时设置、取消请求等),此处未直接使用
        //   request    - 获取历史消息的请求对象,包含请求ID、会话ID、起始时间和结束时间等
        //   response   - 响应对象,用于返回操作结果、错误信息以及消息列表
        //   done       - 回调闭包,在方法执行完毕后(无论是正常返回还是异常返回)会被调用,用于通知 RPC 框架处理完成
        virtual void GetHistoryMsg(::google::protobuf::RpcController *controller,
                                   const ::IMS::GetHistoryMsgReq *request,
                                   ::IMS::GetHistoryMsgRsp *response,
                                   ::google::protobuf::Closure *done)
        {
            // brpc 提供的闭包守护工具,确保在函数退出时(包括异常或提前 return)自动调用 done 回调
            // 避免因忘记调用 done 而导致 RPC 请求挂起
            brpc::ClosureGuard rpc_guard(done);

            // 定义错误响应辅助 lambda,用于快速返回失败响应,减少重复代码
            // 参数 rid:请求ID,用于客户端匹配响应
            // 参数 errmsg:错误描述信息
            auto err_response = [this, response](const std::string &rid,
                                                 const std::string &errmsg) -> void
            {
                response->set_request_id(rid);
                response->set_success(false);
                response->set_errmsg(errmsg);
                return;
            };

            // 1. 提取请求中的关键参数:请求ID、会话ID、起始时间和结束时间
            //    这些参数用于后续的数据库查询和响应组装
            std::string rid = request->request_id();
            std::string chat_ssid = request->chat_session_id();
            // 将 protobuf 中的 int64 时间戳(通常为 Unix 时间戳,秒)转换为 boost::posix_time::ptime 类型
            // boost::posix_time 用于后续的数据库查询条件构建
            boost::posix_time::ptime stime = boost::posix_time::from_time_t(request->start_time());
            boost::posix_time::ptime etime = boost::posix_time::from_time_t(request->over_time());

            // 2. 从数据库中查询指定时间范围内的消息列表
            //    _mysql_message 是消息表操作对象,range 方法返回该会话在 [stime, etime] 区间内的消息
            //    返回的 msg_lists 是一个 std::vector<Message>,Message 是 ODB 映射的消息对象
            auto msg_lists = _mysql_message->range(chat_ssid, stime, etime);

            // 如果没有查询到任何消息,直接返回成功响应,并携带空的消息列表
            // 此时无需后续的批量数据获取,直接返回即可
            if (msg_lists.empty())
            {
                response->set_request_id(rid);
                response->set_success(true);
                return;
            }

            //对于纯文本消息,文本内容存储在 _content 字段中。
            //对于图片、文件、语音这三种类型,只存储了文件标识、文件名、文件大小等元信息,文件本身的二进制数据并不存入数据库。
            //所以如果我们想要获取图片、文件、语音这三种类型的消息,我们必须借助这个文件管理子服务去下载这些文件

            // 3. 收集所有非纯文本类型(图片,文件,语音)消息的文件ID,并从文件子服务进行批量文件下载
            //    这一步的目的是为了将消息中引用的文件内容(图片、文件、语音等)一并返回给客户端,
            //    避免客户端再次发起多次文件下载请求,提升用户体验
            std::unordered_set<std::string> file_id_lists;
            for (const auto &msg : msg_lists) // msg_lists是从MySQL数据库里面查询出来的[start_time,over_time]这个区间内的所有类型的消息
            {
                // 跳过没有关联文件的消息(纯文本消息)
                if (msg.file_id().empty()) // 只有图片,语音,文件类型的消息才会使用file_id这个字段,如果这个字段为空,那么就说明是纯文本消息
                {
                    continue;
                }
                LOG_DEBUG("需要下载的文件ID: {}", msg.file_id());
                file_id_lists.insert(msg.file_id()); // 只有图片,语音,文件类型的消息才会使用file_id这个字段
            }

            // 调用文件子服务的批量获取接口,传入文件ID集合,返回文件ID到文件二进制内容的映射
            // 该调用可能是 RPC 调用或本地函数,取决于架构设计
            std::unordered_map<std::string, std::string> file_data_lists;
            bool ret = _GetFile(rid, file_id_lists, file_data_lists);//它的本质就是调用了文件管理子服务里面的批量下载文件这个RPC服务,文件的数据都会被存放到file_data_lists里面
            if (ret == false)
            {
                // 如果文件数据下载失败,记录错误并返回失败响应
                LOG_ERROR("{} 批量文件数据下载失败!", rid);
                return err_response(rid, "批量文件数据下载失败!");
            }

            // 4. 统计所有消息的发送者用户ID,从用户子服务进行批量用户信息获取
            //    这样可以一次性获取所有发送者的完整用户信息(昵称、头像等),避免逐条消息查询用户服务
            std::unordered_set<std::string> user_id_lists; // 注意这里是去重的用户ID集合
            for (const auto &msg : msg_lists)// msg_lists是从MySQL数据库里面查询出来的[start_time,over_time]这个区间内的所有类型的消息
            {
                user_id_lists.insert(msg.user_id());
            }

            // 调用用户子服务的批量获取接口,传入用户ID集合,返回用户ID到用户详细信息的映射
            // UserInfo 可能包含昵称、头像等字段
            std::unordered_map<std::string, UserInfo> user_lists;
            ret = _GetUser(rid, user_id_lists, user_lists);//它的本质就是调用了用户管理子服务里面的批量获取用户信息这个RPC服务,根据user_id_lists里面的用户ID去获取用户信息
            if (ret == false)
            {
                // 用户信息获取失败时记录日志并返回错误
                LOG_ERROR("{} 批量用户数据获取失败!", rid);
                return err_response(rid, "批量用户数据获取失败!");
            }

            // 5. 组织响应:将消息列表转换为 protobuf 定义的 GetHistoryMsgRsp 格式
            response->set_request_id(rid);
            response->set_success(true);
            // 遍历每条消息,填充到 response 的 msg_list 字段中
            for (const auto &msg : msg_lists)// msg_lists是从MySQL数据库里面查询出来的[start_time,over_time]这个区间内的所有类型的消息
            {
                // 为响应添加一个新的消息信息对象
                auto message_info = response->add_msg_list();

                // 填充消息的基础信息
                message_info->set_message_id(msg.message_id());                               // 消息唯一标识
                message_info->set_chat_session_id(msg.chat_session_id());                     // 所属会话ID
                message_info->set_timestamp(boost::posix_time::to_time_t(msg.create_time())); // 消息时间戳

                // 填充发送者信息:从之前批量获取的用户信息映射中取出对应的用户数据
                // mutable_sender() 返回一个 UserInfo 指针,通过 CopyFrom 将用户信息复制进去
                message_info->mutable_sender()->CopyFrom(user_lists[msg.user_id()]);//根据用户ID去获取用户信息

                // 根据消息类型,填充消息内容(oneof 字段)
                switch (msg.message_type())
                {
                case MessageType::STRING:
                    // 文本消息:设置类型为 STRING,并填充文本内容
                    message_info->mutable_message()->set_message_type(MessageType::STRING);
                    message_info->mutable_message()->mutable_string_message()->set_content(msg.content());
                    break;
                case MessageType::IMAGE:
                    // 图片消息:设置类型为 IMAGE,填充文件ID和图片二进制数据(从 file_data_lists 中获取)
                    message_info->mutable_message()->set_message_type(MessageType::IMAGE);
                    message_info->mutable_message()->mutable_image_message()->set_file_id(msg.file_id());
                    message_info->mutable_message()->mutable_image_message()->set_image_content(file_data_lists[msg.file_id()]);
                    break;
                case MessageType::FILE:
                    // 文件消息:设置类型为 FILE,填充文件ID、大小、文件名及文件内容
                    message_info->mutable_message()->set_message_type(MessageType::FILE);
                    message_info->mutable_message()->mutable_file_message()->set_file_id(msg.file_id());
                    message_info->mutable_message()->mutable_file_message()->set_file_size(msg.file_size());
                    message_info->mutable_message()->mutable_file_message()->set_file_name(msg.file_name());
                    message_info->mutable_message()->mutable_file_message()->set_file_contents(file_data_lists[msg.file_id()]);
                    break;
                case MessageType::SPEECH:
                    // 语音消息:设置类型为 SPEECH,填充文件ID和语音文件内容
                    message_info->mutable_message()->set_message_type(MessageType::SPEECH);
                    message_info->mutable_message()->mutable_speech_message()->set_file_id(msg.file_id());
                    message_info->mutable_message()->mutable_speech_message()->set_file_contents(file_data_lists[msg.file_id()]);
                    break;
                default:
                    // 遇到未知消息类型时记录错误日志并直接返回(这种情况下响应可能不完整,但这里简单处理)
                    LOG_ERROR("消息类型错误!!");
                    return;
                }
            }
            return;
        }

我们这里需要注意:纯文本消息与其他类型消息的处理区别

纯文本消息

  • 特点:消息内容就是一段文本,不需要附加任何文件。

  • 处理过程

    • 从数据库查出的消息记录中,消息类型标识为"纯文本"。注意了,数据库会直接存储纯文本消息类型的数据,存储在字段content里面

    • 组装响应时,直接从content字段中取出文本内容字段,填充到响应的文本消息部分。

    • 不需要发起任何额外的网络请求------不需要调用文件服务,也不涉及文件 ID 的收集与处理。

图片、文件、语音等富媒体消息

  • 特点:消息本身只包含一个文件 ID(以及可选的文件名、文件大小),而真正的文件二进制数据存储在独立的文件服务中。

  • 处理过程

    1. 在遍历所有消息记录时,将所有非空的文件 ID 收集到一个集合中(自动去重)。

    2. 批量调用文件服务,将这一批文件 ID 作为参数,一次性获取所有文件的二进制内容,得到一个"文件 ID → 文件内容"的映射表。

    3. 如果文件服务调用失败,整个请求就会返回错误,不会给客户端返回不完整的消息。

    4. 在组装响应时,根据消息类型分别处理:

      • 图片消息:填充文件 ID,并从映射表中取出图片二进制数据,放入响应的图片字段。

      • 文件消息:填充文件 ID、文件大小、文件名,以及文件内容。

      • 语音消息:填充文件 ID 和语音文件内容。

为什么要批量获取? 如果不批量处理,而是一条消息一条消息地去请求文件服务,那么一次查询如果有 100 条图片消息,就会产生 100 次 RPC 调用,延迟会成倍增长,还可能因为连接数过多被服务端限流。批量获取将多次网络往返合并为一次,是提升性能的关键优化。

1.2.2.获取指定会话的最近N条消息

其实这个和获取指定时间范围内的历史消息是差不多的,核心区别就是我们去数据库查询的时候,是基于下面这个查询接口(mysql_message.hpp里面的)

cpp 复制代码
// 获取指定聊天会话最近的若干条消息(按时间倒序,但返回顺序为正序)
        // @param chat_ssid 聊天会话ID
        // @param count 需要获取的消息数量(最近count条)
        // @return 返回消息对象的vector,按时间升序排列(最早的在前)
        std::vector<Message> recent(const std::string &chat_ssid, int count)
        {
            std::vector<Message> res;
            try
            {
                // 开启事务
                odb::transaction trans(_db->begin());
                // 定义查询类型别名
                typedef odb::query<Message> query;
                typedef odb::result<Message> result;

                // 构建查询条件字符串:
                // chat_session_id='xx' order by create_time desc limit count;
                std::stringstream cond;
                cond << "chat_session_id='" << chat_ssid << "' ";
                cond << "order by create_time desc limit " << count;

                // 执行查询,返回按时间倒序的结果集(最新的在前)
                result r(_db->query<Message>(cond.str()));

                // 将查询结果依次压入vector(此时顺序为倒序)
                for (result::iterator i(r.begin()); i != r.end(); ++i)
                {
                    res.push_back(*i);
                }

                // 反转vector,使最终结果按时间升序(最早的在前)
                std::reverse(res.begin(), res.end());

                // 提交事务
                trans.commit();
            }
            catch (std::exception &e)
            {
                // 捕获异常,记录错误日志(包括聊天会话ID、数量和异常信息)
                LOG_ERROR("获取最近消息失败:{}-{}-{}!", chat_ssid, count, e.what());
            }
            return res;
        }

有了这个,我们就能封装出我们的RPC服务了,由于其他部分和上面那个RPC服务严重重合,我就不说了

cpp 复制代码
// 获取指定会话的最近N条消息
        // 参数:
        //   controller: RPC控制器,用于控制RPC调用行为
        //   request: 请求参数,包含请求ID、会话ID、需要获取的消息数量
        //   response: 响应对象,用于返回消息列表和状态信息
        //   done: RPC完成时的回调闭包,由框架管理
        virtual void GetRecentMsg(::google::protobuf::RpcController *controller,
                                  const ::IMS::GetRecentMsgReq *request,
                                  ::IMS::GetRecentMsgRsp *response,
                                  ::google::protobuf::Closure *done)
        {
            // 使用RAII管理RPC完成回调,确保在函数退出时自动调用done
            brpc::ClosureGuard rpc_guard(done);

            // 定义错误响应辅助函数,当发生错误时快速返回失败响应
            auto err_response = [this, response](const std::string &rid,
                                                 const std::string &errmsg) -> void
            {
                response->set_request_id(rid);
                response->set_success(false);
                response->set_errmsg(errmsg);
                return;
            };

            // 步骤1:提取请求中的关键要素:请求ID、会话ID、要获取的消息数量
            std::string rid = request->request_id();
            std::string chat_ssid = request->chat_session_id();
            int msg_count = request->msg_count();

            // 步骤2:从数据库获取最近的消息元信息(不含文件内容和用户详情)
            auto msg_lists = _mysql_message->recent(chat_ssid, msg_count);// msg_lists是从MySQL数据库里面查询出来的最近N条所有类型的消息
            if (msg_lists.empty())
            {
                // 无消息时直接返回成功,无需进一步处理
                response->set_request_id(rid);
                response->set_success(true);
                return;
            }

            // 步骤3:收集所有消息中文件类型消息的文件ID,批量从文件服务下载文件内容
            std::unordered_set<std::string> file_id_lists;
            for (const auto &msg : msg_lists)
            {
                if (msg.file_id().empty())// 只有图片,语音,文件类型的消息才会使用file_id这个字段,如果这个字段为空,那么就说明是纯文本消息
                {    
                    continue;
                }
                    LOG_DEBUG("需要下载的文件ID: {}", msg.file_id());
                file_id_lists.insert(msg.file_id());
            }

            std::unordered_map<std::string, std::string> file_data_lists;
            bool ret = _GetFile(rid, file_id_lists, file_data_lists);//// 它的本质就是调用了文件管理子服务里面的批量下载文件这个RPC服务
            if (ret == false)
            {
                LOG_ERROR("{} 批量文件数据下载失败!", rid);
                return err_response(rid, "批量文件数据下载失败!");
            }

            // 步骤4:收集所有消息的发送者用户ID,批量从用户服务获取用户详细信息
            std::unordered_set<std::string> user_id_lists;
            for (const auto &msg : msg_lists)
            {
                user_id_lists.insert(msg.user_id());
            }

            std::unordered_map<std::string, UserInfo> user_lists;
            ret = _GetUser(rid, user_id_lists, user_lists);//// 它的本质就是调用了用户管理子服务里面的批量获取用户信息这个RPC服务
            if (ret == false)
            {
                LOG_ERROR("{} 批量用户数据获取失败!", rid);
                return err_response(rid, "批量用户数据获取失败!");
            }

            // 步骤5:组装响应消息,将数据库元信息与获取的文件内容、用户信息合并
            response->set_request_id(rid);
            response->set_success(true);
            for (const auto &msg : msg_lists)// msg_lists是从MySQL数据库里面查询出来的最近N条所有类型的消息
            {
                auto message_info = response->add_msg_list();
                message_info->set_message_id(msg.message_id());
                message_info->set_chat_session_id(msg.chat_session_id());
                message_info->set_timestamp(boost::posix_time::to_time_t(msg.create_time()));
                message_info->mutable_sender()->CopyFrom(user_lists[msg.user_id()]);

                // 根据消息类型填充对应的消息内容
                switch (msg.message_type())
                {
                case MessageType::STRING:
                    message_info->mutable_message()->set_message_type(MessageType::STRING);
                    message_info->mutable_message()->mutable_string_message()->set_content(msg.content());
                    break;
                case MessageType::IMAGE:
                    message_info->mutable_message()->set_message_type(MessageType::IMAGE);
                    message_info->mutable_message()->mutable_image_message()->set_file_id(msg.file_id());
                    message_info->mutable_message()->mutable_image_message()->set_image_content(file_data_lists[msg.file_id()]);
                    break;
                case MessageType::FILE:
                    message_info->mutable_message()->set_message_type(MessageType::FILE);
                    message_info->mutable_message()->mutable_file_message()->set_file_id(msg.file_id());
                    message_info->mutable_message()->mutable_file_message()->set_file_size(msg.file_size());
                    message_info->mutable_message()->mutable_file_message()->set_file_name(msg.file_name());
                    message_info->mutable_message()->mutable_file_message()->set_file_contents(file_data_lists[msg.file_id()]);
                    break;
                case MessageType::SPEECH:
                    message_info->mutable_message()->set_message_type(MessageType::SPEECH);
                    message_info->mutable_message()->mutable_speech_message()->set_file_id(msg.file_id());
                    message_info->mutable_message()->mutable_speech_message()->set_file_contents(file_data_lists[msg.file_id()]);
                    break;
                default:
                    LOG_ERROR("消息类型错误!!");
                    return;
                }
            }
            return;
        }

大体步骤和上面那个是一模一样的。我不想多说

1.2.3.基于关键字的文本消息搜索

那么我们在使用过程中,我们经常可能会对我们的纯文本消息进行基于关键字的搜索。

那么这个功能其实是基于data_es.hpp里面的ESMessage::search函数来完成的,也就是下面这个

cpp 复制代码
// 在指定会话中搜索包含关键词的消息
        // 参数:
        //   key  - 搜索关键词(在消息内容中匹配)
        //   chat_ssid - 聊天会话ID
        // 返回值:IMS::Message对象的vector
        std::vector<IMS::Message> search(const std::string &key, const std::string &chat_ssid)
        {
            std::vector<IMS::Message> res; // 存储结果
            // 构建搜索查询:必须满足聊天会话ID匹配,并且内容包含关键词
            Json::Value json_user = ESSearch(_es_client, "message")                        // 指定索引"message"
                                        .append_must_term("chat_session_id.keyword", chat_ssid) // 必须匹配聊天会话ID(精确)
                                        .append_must_match("content", key)                 // 必须匹配消息内容(分词匹配)
                                        .search();                                         // 执行搜索
            if (json_user.isArray() == false)
            { // 检查结果是否为数组
                LOG_ERROR("用户搜索结果为空,或者结果不是数组类型");
                return res; // 返回空vector
            }
            int sz = json_user.size(); // 结果数量
            LOG_DEBUG("检索结果条目数量:{}", sz);
            // 遍历搜索结果,转换为Message对象
            for (int i = 0; i < sz; i++)
            {
                IMS::Message message; // 创建Message对象
                // 设置用户ID
                message.user_id(json_user[i]["_source"]["user_id"].asString());
                // 设置消息ID
                message.message_id(json_user[i]["_source"]["message_id"].asString());
                // 从时间戳构造boost::posix_time::ptime对象(需要先转为time_t)
                boost::posix_time::ptime ctime(boost::posix_time::from_time_t(
                    json_user[i]["_source"]["create_time"].asInt64()));
                message.create_time(ctime); // 设置创建时间
                // 设置聊天会话ID
                message.chat_session_id(json_user[i]["_source"]["chat_session_id"].asString());
                // 设置消息内容
                message.content(json_user[i]["_source"]["content"].asString());
                res.push_back(message); // 添加到结果vector
            }
            return res;
        }

可以看出来,我们这个检索是只针对于这个content字段的,也就是说这个检索服务是仅仅针对于我们的纯文本消息的,对于其他类型的消息,我们不能去基于关键字的文本信息搜索

那么既然是纯文本消息,那么我们就没有必要去使用到这个文件管理子服务的下载文件的RPC服务了,我们只需要使用用户管理子服务的批量获取用户信息的RPC服务即可

cpp 复制代码
// 基于关键字的文本消息搜索
        // 参数:
        //   controller: RPC控制器,用于控制RPC调用行为
        //   request: 请求参数,包含请求ID、会话ID、搜索关键字
        //   response: 响应对象,用于返回搜索结果和状态信息
        //   done: RPC完成时的回调闭包,由框架管理
        virtual void MsgSearch(::google::protobuf::RpcController *controller,
                               const ::IMS::MsgSearchReq *request,
                               ::IMS::MsgSearchRsp *response,
                               ::google::protobuf::Closure *done)
        {
            //特别注意:我们这个消息搜索仅仅针对这个纯文本消息的检索,其他类型的消息是不能进行检索的

            // 使用RAII管理RPC完成回调,确保在函数退出时自动调用done
            brpc::ClosureGuard rpc_guard(done);

            // 定义错误响应辅助函数,当发生错误时快速返回失败响应
            auto err_response = [this, response](const std::string &rid,
                                                 const std::string &errmsg) -> void
            {
                response->set_request_id(rid);
                response->set_success(false);
                response->set_errmsg(errmsg);
                return;
            };

            // 关键字的消息搜索--只针对文本消息
            // 步骤1:从请求中提取关键要素:请求ID、会话ID、搜索关键字
            std::string rid = request->request_id();
            std::string chat_ssid = request->chat_session_id();
            std::string skey = request->search_key();

            // 步骤2:从ES搜索引擎中进行关键字消息搜索,获取匹配的消息列表
            auto msg_lists = _es_message->search(skey, chat_ssid);//msg_lists是从ES里面搜索出来的所有带这个关键词的纯文本消息
            if (msg_lists.empty())
            {
                // 无搜索结果时直接返回成功,无需进一步处理
                response->set_request_id(rid);
                response->set_success(true);
                return;
            }

            //那么既然是纯文本消息,那么我们就没有必要去使用到这个文件管理子服务的下载文件的RPC服务了,我们只需要使用用户管理子服务的批量获取用户信息的RPC服务即可


            // 步骤3:收集所有消息的发送者用户ID,批量从用户服务获取用户详细信息
            std::unordered_set<std::string> user_id_lists;
            for (const auto &msg : msg_lists)
            {
                user_id_lists.insert(msg.user_id());
            }

            std::unordered_map<std::string, UserInfo> user_lists;
            bool ret = _GetUser(rid, user_id_lists, user_lists);
            if (ret == false)
            {
                LOG_ERROR("{} 批量用户数据获取失败!", rid);
                return err_response(rid, "批量用户数据获取失败!");
            }

            // 步骤4:组装响应消息,将搜索结果与用户信息合并
            response->set_request_id(rid);
            response->set_success(true);
            for (const auto &msg : msg_lists)//msg_lists是从ES里面搜索出来的所有带这个关键词的纯文本消息
            {
                auto message_info = response->add_msg_list();
                message_info->set_message_id(msg.message_id());
                message_info->set_chat_session_id(msg.chat_session_id());
                message_info->set_timestamp(boost::posix_time::to_time_t(msg.create_time()));
                message_info->mutable_sender()->CopyFrom(user_lists[msg.user_id()]);

                // 搜索结果仅包含文本消息,直接填充内容
                message_info->mutable_message()->set_message_type(MessageType::STRING);
                message_info->mutable_message()->mutable_string_message()->set_content(msg.content());
            }

            return;
        }

整个代码的流程非常的简单了。

二.MessageServer类的编写

这个类也就是我们消息存储子服务的核心类所在了。

cpp 复制代码
// 消息服务类 - 负责管理IM系统的消息相关服务
// 整合了RPC服务、消息队列、搜索引擎、数据库访问和服务注册发现等功能
class MessageServer
{
public:
    using ptr = std::shared_ptr<MessageServer>;

    // 构造函数:初始化消息服务器所需的各种客户端和服务组件
    // 参数:
    //   mq_client: 消息队列客户端,用于异步消息处理
    //   service_discoverer: 服务发现客户端,用于发现其他微服务节点
    //   reg_client: 服务注册客户端,用于将本服务注册到服务中心
    //   es_client: Elasticsearch客户端,用于消息搜索功能
    //   mysql_client: MySQL数据库客户端,用于消息持久化存储
    //   server: brpc服务器实例,用于提供RPC服务
    MessageServer(const MQClient::ptr &mq_client,
                  const Discovery::ptr service_discoverer,
                  const Registry::ptr &reg_client,
                  const std::shared_ptr<elasticlient::Client> &es_client,
                  const std::shared_ptr<odb::core::database> &mysql_client,
                  const std::shared_ptr<brpc::Server> &server) : _mq_client(mq_client),
                                                                 _service_discoverer(service_discoverer),
                                                                 _registry_client(reg_client),
                                                                 _es_client(es_client),
                                                                 _mysql_client(mysql_client),
                                                                 _rpc_server(server) {}

    // 析构函数:默认实现,无需额外清理
    ~MessageServer() {}

    // 启动RPC服务器,阻塞运行直到收到退出信号
    void start()
    {
        _rpc_server->RunUntilAskedToQuit();
    }

private:
    // 服务发现客户端,用于获取其他微服务的地址信息
    Discovery::ptr _service_discoverer;
    // 服务注册客户端,用于向服务中心注册本服务
    Registry::ptr _registry_client;
    // 消息队列客户端,用于消息的异步生产和消费
    MQClient::ptr _mq_client;
    // Elasticsearch客户端,用于消息内容的全文检索
    std::shared_ptr<elasticlient::Client> _es_client;
    // MySQL数据库客户端,用于消息的持久化存储
    std::shared_ptr<odb::core::database> _mysql_client;
    // brpc服务器实例,负责处理RPC请求
    std::shared_ptr<brpc::Server> _rpc_server;
};

三.MessageServerBuilder类编写

3.1.订阅队列消息的处理回调函数

这里其实和之前的没什么太大的区别,唯一需要注意的就是消息队列这个组件!!!

我们的消息是从消息队列里面来的,那么我们就是采用了订阅队列消息的策略。

订阅了_queue_name这个队列里面的消息,一旦_queue_name里面有消息到来,我们就调用MessageServiceImpl::onMessage进行处理

MessageServiceImpl::onMessage如下:

cpp 复制代码
// 消息队列消费回调函数:处理从MQ接收到的消息
// 参数:
//   body: 消息体数据指针
//   sz: 消息体大小
void onMessage(const char *body, size_t sz)
{
    LOG_DEBUG("收到新消息,进行存储处理!");

    // 步骤1:将接收到的二进制数据反序列化为MessageInfo对象
    IMS::MessageInfo message;
    bool ret = message.ParseFromArray(body, sz);
    if (ret == false)
    {
        LOG_ERROR("对消费到的消息进行反序列化失败!");
        return;
    }

    // 步骤2:根据消息类型进行不同的处理
    std::string file_id, file_name, content;
    int64_t file_size;
    switch (message.message().message_type())
    {
    // 情况1:文本类型消息,将元信息存入ES搜索引擎
    case MessageType::STRING:
        content = message.message().string_message().content();
        ret = _es_message->appendData(
            message.sender().user_id(),
            message.message_id(),
            message.timestamp(),
            message.chat_session_id(),
            content);
        if (ret == false)
        {
            LOG_ERROR("文本消息向存储引擎进行存储失败!");
            return;
        }
        break;

    // 情况2:图片消息,将图片内容上传到文件子服务,获取文件ID
    case MessageType::IMAGE:
    {
        const auto &msg = message.message().image_message();
        // 图片消息无文件名,直接上传内容
        ret = _PutFile("", msg.image_content(), msg.image_content().size(), file_id);
        if (ret == false)
        {
            LOG_ERROR("上传图片到文件子服务失败!");
            return;
        }
    }
    break;

    // 情况3:文件消息,将文件内容上传到文件子服务,获取文件ID
    case MessageType::FILE:
    {
        const auto &msg = message.message().file_message();
        file_name = msg.file_name();
        file_size = msg.file_size();
        ret = _PutFile(file_name, msg.file_contents(), file_size, file_id);
        if (ret == false)
        {
            LOG_ERROR("上传文件到文件子服务失败!");
            return;
        }
    }
    break;

    // 情况4:语音消息,将语音内容上传到文件子服务,获取文件ID
    case MessageType::SPEECH:
    {
        const auto &msg = message.message().speech_message();
        // 语音消息无文件名,直接上传内容
        ret = _PutFile("", msg.file_contents(), msg.file_contents().size(), file_id);
        if (ret == false)
        {
            LOG_ERROR("上传语音到文件子服务失败!");
            return;
        }
    }
    break;

    default:
        LOG_ERROR("消息类型错误!");
        return;
    }

    // 步骤3:提取消息的元信息,存储到MySQL数据库中
    IMS::Message msg(message.message_id(),
                     message.chat_session_id(),
                     message.sender().user_id(),
                     message.message().message_type(),
                     boost::posix_time::from_time_t(message.timestamp()));
    msg.content(content);      // 设置文本内容(仅文本消息有值)
    msg.file_id(file_id);      // 设置文件ID(仅文件类消息有值)
    msg.file_name(file_name);  // 设置文件名(仅文件消息有值)
    msg.file_size(file_size);  // 设置文件大小(仅文件消息有值)
    ret = _mysql_message->insert(msg);
    if (ret == false)
    {
        LOG_ERROR("向数据库插入新消息失败!");
        return;
    }
}

这个函数是一个消息队列的消费回调函数 ,当从消息队列(MQ)中收到一条新消息时,它会被自动调用。它的核心职责是将一条消息完整地存储下来,具体分为三个主要步骤:

1. 反序列化消息

函数首先将收到的二进制数据(body)还原成程序可以操作的 MessageInfo 对象。如果这一步反序列化失败,说明消息格式损坏,函数会记录错误并直接返回,不再继续处理。

2. 根据消息类型分别处理内容

反序列化成功后,函数会查看这条消息的类型(文本、图片、文件或语音),并针对不同类型做不同的"内容存储"操作:

  • 文本消息 :直接将消息的文本内容、发送者、时间等信息写入ES搜索引擎。这一步的目的是让文本消息可以被后续的搜索功能检索到。

  • 图片消息 :将图片的二进制内容上传到一个独立的文件子服务 中,上传成功后,文件子服务会返回一个唯一的文件ID(file_id),用于后续关联。

  • 文件消息:与图片类似,将文件内容上传到文件子服务,同时还会记录文件名和文件大小,同样获得一个文件ID。

  • 语音消息:同样将语音的二进制内容上传到文件子服务,获取文件ID。

  • 其他类型:如果消息类型不在上述范围内,函数会记录错误并退出。

这一步的本质是:对于非文本的内容(图片、文件、语音),不直接存数据库,而是交给专门的存储服务去管理,只留下一个文件ID用于关联

3. 存储消息的元信息到数据库

无论哪种消息类型,最终都需要在MySQL数据库中记录这条消息的"元信息"------也就是描述这条消息的数据,包括:

  • 消息ID

  • 所属会话ID

  • 发送者ID

  • 消息类型

  • 发送时间戳

  • 如果是文本消息,还存下文本内容

  • 如果是文件类消息,则存下文件ID、文件名、文件大小

这一步完成后,数据库里就多了一条消息记录,可以用于消息历史查询、会话展示等场景。


这里面其实使用到了文件管理子服务的上传单个文件的RPC服务,我们把它封装到下面这个函数里面。

cpp 复制代码
// 上传单个文件到文件服务
// 参数:
//   filename: 文件名
//   body: 文件内容的二进制数据(或字符串)
//   fsize: 文件大小(字节数)
//   file_id: 输出参数,存储上传成功后返回的文件ID
// 返回值:
//   true: 上传成功
//   false: 上传失败(如无可用的文件服务节点、RPC调用失败等)
bool _PutFile(const std::string &filename,
              const std::string &body,
              const int64_t fsize,
              std::string &file_id)
{
    // 从服务管理器中选择一个可用的文件服务子服务节点
    auto channel = _mm_channels->choose(_file_service_name);
    if (!channel)   // 无可用节点时记录错误并返回失败
    {
        LOG_ERROR("{} 没有可供访问的文件子服务节点!", _file_service_name);
        return false;
    }

    // 创建RPC存根,用于调用文件服务的接口
    FileService_Stub stub(channel.get());

    // 构建上传请求消息
    PutSingleFileReq req;
    PutSingleFileRsp rsp;
    // 设置待上传文件的基本信息:文件名、大小、内容
    req.mutable_file_data()->set_file_name(filename);
    req.mutable_file_data()->set_file_size(fsize);
    req.mutable_file_data()->set_file_content(body);

    // 发起RPC调用(使用brpc框架)
    brpc::Controller cntl;
    stub.PutSingleFile(&cntl, &req, &rsp, nullptr);

    // 检查调用是否失败:控制层失败或业务层返回失败标志
    if (cntl.Failed() == true || rsp.success() == false)
    {
        LOG_ERROR("文件子服务调用失败:{}!", cntl.ErrorText());
        return false;
    }

    // 上传成功,从响应中获取文件服务分配的文件ID
    file_id = rsp.file_info().file_id();

    return true;   // 成功返回
}

3.2.完整代码

cpp 复制代码
// 消息服务器构建器类 - 采用建造者模式,分步骤构建MessageServer所需的各种组件
    // 通过链式调用各个make_xxx方法,最后调用build()完成服务器对象的构建
    class MessageServerBuilder
    {
    public:
        // 构造Elasticsearch客户端对象
        // 参数:
        //   host_list: ES集群的节点地址列表,例如 ["http://127.0.0.1:9200"]
        void make_es_object(const std::vector<std::string> host_list)
        {
            _es_client = ESClientFactory::create(host_list);
        }

        // 构造MySQL数据库客户端对象
        // 参数:
        //   user: 数据库用户名
        //   pswd: 数据库密码
        //   host: 数据库主机地址
        //   db: 数据库名称
        //   cset: 字符集
        //   port: 端口号
        //   conn_pool_count: 连接池大小
        void make_mysql_object(
            const std::string &user,
            const std::string &pswd,
            const std::string &host,
            const std::string &db,
            const std::string &cset,
            int port,
            int conn_pool_count)
        {
            _mysql_client = ODBFactory::create(user, pswd, host, db, cset, port, conn_pool_count);
        }

        // 构造服务发现客户端和信道管理对象
        // 参数:
        //   reg_host: 服务注册中心地址(如Consul地址)
        //   base_service_name: 基础服务名称(本服务名称)
        //   file_service_name: 文件子服务名称,用于服务发现
        //   user_service_name: 用户子服务名称,用于服务发现
        void make_discovery_object(const std::string &reg_host,
                                   const std::string &base_service_name,
                                   const std::string &file_service_name,
                                   const std::string &user_service_name)
        {
            _user_service_name = user_service_name;
            _file_service_name = file_service_name;
            // 创建服务管理器,负责管理多个子服务的信道
            _mm_channels = std::make_shared<ServiceManager>();
            // 声明需要管理的子服务(文件服务和用户服务)
            _mm_channels->declared(file_service_name);
            _mm_channels->declared(user_service_name);
            LOG_DEBUG("设置文件管理子服务为需添加管理的子服务:{}", file_service_name);
            LOG_DEBUG("设置用户管理子服务为需添加管理的子服务:{}", user_service_name);

            // 注册服务上线/下线的回调函数,当子服务节点变化时更新信道管理
            auto put_cb = std::bind(&ServiceManager::onServiceOnline, _mm_channels.get(), std::placeholders::_1, std::placeholders::_2);
            auto del_cb = std::bind(&ServiceManager::onServiceOffline, _mm_channels.get(), std::placeholders::_1, std::placeholders::_2);
            // 创建服务发现对象,并传入回调,实现动态服务发现
            _service_discoverer = std::make_shared<Discovery>(reg_host, base_service_name, put_cb, del_cb);
        }

        // 构造服务注册客户端对象,将本服务注册到服务中心
        // 参数:
        //   reg_host: 服务注册中心地址
        //   service_name: 本服务的名称
        //   access_host: 本服务对外提供RPC服务的地址(IP:端口)
        void make_registry_object(const std::string &reg_host,
                                  const std::string &service_name,
                                  const std::string &access_host)
        {
            _registry_client = std::make_shared<Registry>(reg_host);
            _registry_client->registry(service_name, access_host);
        }

        // 构造消息队列客户端对象(RabbitMQ)
        // 参数:
        //   user: MQ用户名
        //   passwd: MQ密码
        //   host: MQ主机地址
        //   exchange_name: 交换机名称
        //   queue_name: 队列名称
        //   binding_key: 绑定键,用于路由消息
        void make_mq_object(const std::string &user,
                            const std::string &passwd,
                            const std::string &host,
                            const std::string &exchange_name,
                            const std::string &queue_name,
                            const std::string &binding_key)
        {
            _exchange_name = exchange_name;
            _queue_name = queue_name;
            _mq_client = std::make_shared<MQClient>(user, passwd, host);
            // 声明交换器、队列并进行绑定
            _mq_client->declareComponents(exchange_name, queue_name, binding_key);
        }

        // 构造并启动RPC服务器,同时注册消息服务实现
        // 参数:
        //   port: RPC服务监听端口
        //   timeout: 连接空闲超时时间(秒)
        //   num_threads: 工作线程数量
        void make_rpc_server(uint16_t port, int32_t timeout, uint8_t num_threads)
        {
            // 检查必要组件是否已初始化
            if (!_es_client)
            {
                LOG_ERROR("还未初始化ES搜索引擎模块!");
                abort();
            }
            if (!_mysql_client)
            {
                LOG_ERROR("还未初始化Mysql数据库模块!");
                abort();
            }
            if (!_mm_channels)
            {
                LOG_ERROR("还未初始化信道管理模块!");
                abort();
            }

            // 创建brpc服务器实例
            _rpc_server = std::make_shared<brpc::Server>();

            // 创建消息服务实现对象,传入所需依赖
            MessageServiceImpl *msg_service = new MessageServiceImpl(_es_client,
                                                                     _mysql_client, _mm_channels, _file_service_name, _user_service_name);
            // 将服务添加到RPC服务器,由服务器负责生命周期管理
            int ret = _rpc_server->AddService(msg_service,
                                              brpc::ServiceOwnership::SERVER_OWNS_SERVICE);
            if (ret == -1)
            {
                LOG_ERROR("添加Rpc服务失败!");
                abort();
            }

            // 配置服务器选项并启动
            brpc::ServerOptions options;
            options.idle_timeout_sec = timeout;
            options.num_threads = num_threads;
            ret = _rpc_server->Start(port, &options);
            if (ret == -1)
            {
                LOG_ERROR("服务启动失败!");
                abort();
            }

            // 注册消息消费回调函数,将MQ中的消息交由MessageServiceImpl处理
            auto callback = std::bind(&MessageServiceImpl::onMessage, msg_service,
                                      std::placeholders::_1, std::placeholders::_2);
            _mq_client->consume(_queue_name, callback);//可以理解为订阅了_queue_name这个队列里面的消息,一旦_queue_name里面有消息到来,我们就调用MessageServiceImpl::onMessage进行处理
        }

        // 构建最终的MessageServer对象
        // 返回值:构建完成的MessageServer智能指针
        MessageServer::ptr build()
        {
            // 检查所有必要模块是否已正确初始化
            if (!_service_discoverer)
            {
                LOG_ERROR("还未初始化服务发现模块!");
                abort();
            }
            if (!_registry_client)
            {
                LOG_ERROR("还未初始化服务注册模块!");
                abort();
            }
            if (!_rpc_server)
            {
                LOG_ERROR("还未初始化RPC服务器模块!");
                abort();
            }

            // 创建MessageServer实例,传入所有组件
            MessageServer::ptr server = std::make_shared<MessageServer>(
                _mq_client, _service_discoverer, _registry_client,
                _es_client, _mysql_client, _rpc_server);
            return server;
        }

    private:
        // 服务注册客户端,用于将本服务注册到服务中心
        Registry::ptr _registry_client;

        // Elasticsearch客户端,用于消息全文检索
        std::shared_ptr<elasticlient::Client> _es_client;
        // MySQL数据库客户端,用于消息持久化
        std::shared_ptr<odb::core::database> _mysql_client;

        // 需要依赖的远程子服务名称
        std::string _user_service_name;
        std::string _file_service_name;
        // 多子服务信道管理器,负责维护与文件服务、用户服务的连接
        ServiceManager::ptr _mm_channels;
        // 服务发现客户端,用于动态发现其他微服务节点
        Discovery::ptr _service_discoverer;

        // 消息队列相关配置
        std::string _exchange_name;
        std::string _queue_name;
        // 消息队列客户端
        MQClient::ptr _mq_client;

        // brpc服务器实例
        std::shared_ptr<brpc::Server> _rpc_server;
    };

那么接下来我们就讲讲我们整个消息存储子服务的流程

一、消息存储流程

消息存储子服务启动后,会订阅消息队列(RabbitMQ)中指定的队列。一旦队列中有新消息到来,服务便会自动回调MessageServiceImpl::onMessage函数进行处理。

onMessage 的处理逻辑会根据消息类型走不同分支:

  • 文本消息 :直接将消息内容写入 Elasticsearch(ES),以便后续提供全文检索能力。

  • 图片、文件、语音消息 :先调用文件子服务,将文件二进制内容上传到文件存储系统,获取一个唯一的文件 ID;随后将该文件 ID 以及文件名、文件大小等元信息保存下来。

  • 无论哪种类型,最终都会提取消息的公共元信息(消息 ID、会话 ID、发送者 ID、发送时间、消息类型等),并写入 MySQL 数据库,形成一条完整的消息记录。

通过这样的设计,文本内容进入 ES 供搜索,大文件内容交给专业文件服务管理,而所有消息的元数据统一存储在 MySQL 中,既保证了检索效率,又避免了数据库的大字段负担。


二、消息查询 RPC 接口

为了支持上层业务对历史消息的访问,消息存储子服务向外暴露了三个核心的 RPC 接口:

  1. 获取指定时间范围内的历史消息

    根据会话 ID 和起始/结束时间,从 MySQL 中拉取该时间段内的消息元数据,然后结合文件子服务(补充文件内容)和用户子服务(补充发送者昵称、头像等),组装成完整的消息列表返回。

  2. 获取指定会话的最近 N 条消息

    按时间倒序从 MySQL 中取出最近的 N 条消息记录,同样会批量调用文件子服务和用户子服务,将文件内容和用户信息一并填充到响应中。

  3. 基于关键字的文本消息搜索

    仅针对文本消息,根据关键字和会话 ID 从 Elasticsearch 中检索匹配的文本消息,再从用户子服务获取发送者信息,最终返回搜索结果。此接口不涉及文件子服务,因为文件类消息不支持文本搜索。

所有查询接口均直接从 MySQL 和 ES 中读取数据,对外屏蔽了底层存储细节,为上层提供统一的、高性能的消息访问能力。

四.搭建消息存储子服务

我们很快就能搭建出来

cpp 复制代码
// 主要实现消息子服务的服务器搭建
#include "message_server.hpp"

// 定义程序运行模式:false-调试模式;true-发布模式
DEFINE_bool(run_mode, false, "程序的运行模式,false-调试; true-发布;");
// 发布模式下,指定日志输出文件
DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件");
// 发布模式下,指定日志输出等级
DEFINE_int32(log_level, 0, "发布模式下,用于指定日志输出等级");

// 服务注册中心的地址(etcd)
DEFINE_string(registry_host, "http://127.0.0.1:2379", "服务注册中心地址");
// 当前实例在注册中心的名称
DEFINE_string(instance_name, "/message_service/instance", "当前实例名称");
// 当前实例的外部访问地址(IP:端口)
DEFINE_string(access_host, "127.0.0.1:10005", "当前实例的外部访问地址");

// RPC服务器监听的端口
DEFINE_int32(listen_port, 10005, "Rpc服务器监听端口");
// RPC调用超时时间(-1表示不超时)
DEFINE_int32(rpc_timeout, -1, "Rpc调用超时时间");
// RPC的IO线程数量
DEFINE_int32(rpc_threads, 1, "Rpc的IO线程数量");

// 服务监控根目录(用于服务发现)
DEFINE_string(base_service, "/service", "服务监控根目录");
// 文件管理子服务名称
DEFINE_string(file_service, "/service/file_service", "文件管理子服务名称");
// 用户管理子服务名称
DEFINE_string(user_service, "/service/user_service", "用户管理子服务名称");

// Elasticsearch搜索引擎服务器URL
DEFINE_string(es_host, "http://127.0.0.1:9200/", "ES搜索引擎服务器URL");

// MySQL数据库配置
DEFINE_string(mysql_host, "127.0.0.1", "Mysql服务器访问地址");
DEFINE_string(mysql_user, "root", "Mysql服务器访问用户名");
DEFINE_string(mysql_pswd, "123456", "Mysql服务器访问密码");
DEFINE_string(mysql_db, "IMS", "Mysql默认库名称");
DEFINE_string(mysql_cset, "utf8", "Mysql客户端字符集");
DEFINE_int32(mysql_port, 0, "Mysql服务器访问端口");
DEFINE_int32(mysql_pool_count, 4, "Mysql连接池最大连接数量");

// 消息队列(RabbitMQ)配置
DEFINE_string(mq_user, "root", "消息队列服务器访问用户名");
DEFINE_string(mq_pswd, "123456", "消息队列服务器访问密码");
DEFINE_string(mq_host, "127.0.0.1:5672", "消息队列服务器访问地址");
DEFINE_string(mq_msg_exchange, "msg_exchange", "持久化消息的发布交换机名称");
DEFINE_string(mq_msg_queue, "msg_queue", "持久化消息的发布队列名称");
DEFINE_string(mq_msg_binding_key, "msg_queue", "持久化消息的发布队列名称");

int main(int argc, char *argv[])
{
    // 解析命令行参数(gflags)
    google::ParseCommandLineFlags(&argc, &argv, true);
    // 初始化日志系统(根据运行模式、日志文件、日志等级)
    IMS::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);

    // 创建消息服务器构建器(Builder模式)
    IMS::MessageServerBuilder msb;

    // 配置消息队列对象(RabbitMQ连接、交换机、队列、绑定键)
    msb.make_mq_object(FLAGS_mq_user, FLAGS_mq_pswd, FLAGS_mq_host,
        FLAGS_mq_msg_exchange, FLAGS_mq_msg_queue, FLAGS_mq_msg_binding_key);

    // 配置Elasticsearch对象(搜索引擎地址)
    msb.make_es_object({FLAGS_es_host});

    // 配置MySQL数据库连接池(用户名、密码、地址、数据库名、字符集、端口、连接池大小)
    msb.make_mysql_object(FLAGS_mysql_user, FLAGS_mysql_pswd, FLAGS_mysql_host, 
        FLAGS_mysql_db, FLAGS_mysql_cset, FLAGS_mysql_port, FLAGS_mysql_pool_count);

    // 配置服务发现对象(注册中心地址、基础路径、文件服务名、用户服务名)
    msb.make_discovery_object(FLAGS_registry_host, FLAGS_base_service, FLAGS_file_service, FLAGS_user_service);

    // 配置RPC服务器(监听端口、超时时间、IO线程数)
    msb.make_rpc_server(FLAGS_listen_port, FLAGS_rpc_timeout, FLAGS_rpc_threads);

    // 配置服务注册对象(注册中心地址、实例注册路径、外部访问地址)
    msb.make_registry_object(FLAGS_registry_host, FLAGS_base_service + FLAGS_instance_name, FLAGS_access_host);

    // 构建完整的服务器对象
    auto server = msb.build();

    // 启动服务器(开始提供服务)
    server->start();

    return 0;
}

五.测试

我们怎么去写这个测试程序呢?

那么接下来我们就讲讲我们整个消息存储子服务的流程

一、消息存储流程

消息存储子服务启动后,会订阅消息队列(RabbitMQ)中指定的队列。一旦队列中有新消息到来,服务便会自动回调MessageServiceImpl::onMessage函数进行处理。

onMessage 的处理逻辑会根据消息类型走不同分支:

  • 文本消息 :直接将消息内容写入 Elasticsearch(ES),以便后续提供全文检索能力。

  • 图片、文件、语音消息 :先调用文件子服务,将文件二进制内容上传到文件存储系统,获取一个唯一的文件 ID;随后将该文件 ID 以及文件名、文件大小等元信息保存下来。

  • 无论哪种类型,最终都会提取消息的公共元信息(消息 ID、会话 ID、发送者 ID、发送时间、消息类型等),并写入 MySQL 数据库,形成一条完整的消息记录。

通过这样的设计,文本内容进入 ES 供搜索,大文件内容交给专业文件服务管理,而所有消息的元数据统一存储在 MySQL 中,既保证了检索效率,又避免了数据库的大字段负担。

二、消息查询 RPC 接口

为了支持上层业务对历史消息的访问,消息存储子服务向外暴露了三个核心的 RPC 接口:

  1. 获取指定时间范围内的历史消息

    根据会话 ID 和起始/结束时间,从 MySQL 中拉取该时间段内的消息元数据,然后结合文件子服务(补充文件内容)和用户子服务(补充发送者昵称、头像等),组装成完整的消息列表返回。

  2. 获取指定会话的最近 N 条消息

    按时间倒序从 MySQL 中取出最近的 N 条消息记录,同样会批量调用文件子服务和用户子服务,将文件内容和用户信息一并填充到响应中。

  3. 基于关键字的文本消息搜索

    仅针对文本消息,根据关键字和会话 ID 从 Elasticsearch 中检索匹配的文本消息,再从用户子服务获取发送者信息,最终返回搜索结果。此接口不涉及文件子服务,因为文件类消息不支持文本搜索。

所有查询接口均直接从 MySQL 和 ES 中读取数据,对外屏蔽了底层存储细节,为上层提供统一的、高性能的消息访问能力。

那么我们就编写一个消息发布程序,然后一个查询程序

message_publish.cc

cpp 复制代码
// 消息发布者程序 - 支持一次性发送多条测试消息
// 用于向 RabbitMQ 发送多条消息,以便消息存储子服务消费并存储

#include "base.pb.h"                                 // 基础定义
#include "logger.hpp"                                // 日志模块
#include "message.pb.h"                              // 消息 protobuf 定义
#include "rabbitmq.hpp"                              // MQClient 封装
#include "utils.hpp"                                 // 工具函数(如 uuid)
#include <boost/date_time/posix_time/posix_time.hpp> // 时间处理
#include <chrono>
#include <gflags/gflags.h> // 命令行参数解析
#include <iostream>
#include <string>
#include <thread>
#include <vector>

// 定义 RabbitMQ 连接参数(保留 gflags 方便配置)
DEFINE_bool(run_mode, false, "程序的运行模式,false-调试; true-发布;");
DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件");
DEFINE_int32(log_level, 0, "发布模式下,用于指定日志输出等级");

DEFINE_string(mq_user, "root", "消息队列服务器访问用户名");
DEFINE_string(mq_pswd, "123456", "消息队列服务器访问密码");
DEFINE_string(mq_host, "127.0.0.1:5672", "消息队列服务器访问地址");
DEFINE_string(mq_exchange, "msg_exchange", "消息交换机名称");
DEFINE_string(mq_queue, "msg_queue", "消息队列名称");
DEFINE_string(mq_binding_key, "msg_queue", "路由键");

// 辅助函数:发送一条消息
bool sendMessage(IMS::MQClient::ptr &mq_client,
                 const std::string &exchange,
                 const std::string &routing_key,
                 const IMS::MessageInfo &msg_info)
{
    // 序列化 MessageInfo 为字符串
    std::string serialized;
    if (!msg_info.SerializeToString(&serialized))
    {
        LOG_ERROR("序列化消息失败: {}", msg_info.message_id());
        return false;
    }

    // 发布消息
    if (!mq_client->publish(exchange, serialized, routing_key))
    {
        LOG_ERROR("发布消息失败: {}", msg_info.message_id());
        return false;
    }

    LOG_INFO("消息发布成功 - ID: {}, 类型: {}, 会话: {}",
             msg_info.message_id(),
             msg_info.message().message_type(),
             msg_info.chat_session_id());
    return true;
}

int main(int argc, char *argv[])
{
    // 解析命令行参数
    google::ParseCommandLineFlags(&argc, &argv, true);
    // 初始化日志系统
    IMS::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);

    // 1. 创建 MQClient 对象,连接 RabbitMQ
    IMS::MQClient::ptr mq_client = std::make_shared<IMS::MQClient>(
        FLAGS_mq_user, FLAGS_mq_pswd, FLAGS_mq_host);

    // 等待声明完成
    std::this_thread::sleep_for(std::chrono::seconds(1));

    // 3. 预定义多条测试消息
    std::vector<IMS::MessageInfo> messages;

    // 消息1:文本消息(会话ID1,包含关键字"盖浇")
    {
        IMS::MessageInfo msg;
        msg.set_message_id(IMS::uuid());
        msg.set_chat_session_id("会话ID1");
        boost::posix_time::ptime t = boost::posix_time::time_from_string("2024-08-01 00:00:00");
        msg.set_timestamp(boost::posix_time::to_time_t(t));
        msg.mutable_sender()->set_user_id("user_001");
        msg.mutable_message()->set_message_type(IMS::MessageType::STRING);
        msg.mutable_message()->mutable_string_message()->set_content("今天天气真好,盖浇饭好吃!");
        messages.push_back(std::move(msg));
    }

    // 消息2:文本消息(会话ID1,包含关键字"天气")
    {
        IMS::MessageInfo msg;
        msg.set_message_id(IMS::uuid());
        msg.set_chat_session_id("会话ID1");
        boost::posix_time::ptime t = boost::posix_time::time_from_string("2024-08-02 00:00:00");
        msg.set_timestamp(boost::posix_time::to_time_t(t));
        msg.mutable_sender()->set_user_id("user_002");
        msg.mutable_message()->set_message_type(IMS::MessageType::STRING);
        msg.mutable_message()->mutable_string_message()->set_content("天气预报说今天有雨,记得带伞。");
        messages.push_back(std::move(msg));
    }

    // 消息3:图片消息(会话ID1)
    {
        IMS::MessageInfo msg;
        msg.set_message_id(IMS::uuid());
        msg.set_chat_session_id("会话ID1");
        boost::posix_time::ptime t = boost::posix_time::time_from_string("2024-08-09 00:00:00");
        msg.set_timestamp(boost::posix_time::to_time_t(t));
        msg.mutable_sender()->set_user_id("user_003");
        msg.mutable_message()->set_message_type(IMS::MessageType::IMAGE);
        msg.mutable_message()->mutable_image_message()->set_image_content("这是一张图片的二进制数据(模拟)");
        messages.push_back(std::move(msg));
    }

    // 消息4:文件消息(会话ID1)
    {
        IMS::MessageInfo msg;
        msg.set_message_id(IMS::uuid());
        msg.set_chat_session_id("会话ID1");
        boost::posix_time::ptime t = boost::posix_time::time_from_string("2024-08-04 00:00:00");
        msg.set_timestamp(boost::posix_time::to_time_t(t));
        msg.mutable_sender()->set_user_id("user_001");
        auto file_msg = msg.mutable_message()->mutable_file_message();
        msg.mutable_message()->set_message_type(IMS::MessageType::FILE);
        file_msg->set_file_name("report.pdf");
        file_msg->set_file_size(2048);
        file_msg->set_file_contents("PDF文件二进制内容(模拟)");
        messages.push_back(std::move(msg));
    }

    // 消息5:语音消息(会话ID1)
    {
        IMS::MessageInfo msg;
        msg.set_message_id(IMS::uuid());
        msg.set_chat_session_id("会话ID1");
        boost::posix_time::ptime t = boost::posix_time::time_from_string("2024-08-07 00:00:00");
        msg.set_timestamp(boost::posix_time::to_time_t(t));
        msg.mutable_sender()->set_user_id("user_002");
        msg.mutable_message()->set_message_type(IMS::MessageType::SPEECH);
        msg.mutable_message()->mutable_speech_message()->set_file_contents("语音文件二进制内容(模拟)");
        messages.push_back(std::move(msg));
    }

    // 消息6:文本消息(会话ID2,用于测试跨会话查询)
    {
        IMS::MessageInfo msg;
        msg.set_message_id(IMS::uuid());
        msg.set_chat_session_id("会话ID1");
        boost::posix_time::ptime t = boost::posix_time::time_from_string("2024-08-10 00:00:00");
        msg.set_timestamp(boost::posix_time::to_time_t(t));
        msg.mutable_sender()->set_user_id("user_003");
        msg.mutable_message()->set_message_type(IMS::MessageType::STRING);
        msg.mutable_message()->mutable_string_message()->set_content("大家好,我是新会话的成员。");
        messages.push_back(std::move(msg));
    }

    // 4. 循环发送所有消息
    LOG_INFO("开始发送 {} 条消息...", messages.size());
    int success_count = 0;
    for (const auto &msg : messages)
    {
        if (sendMessage(mq_client, FLAGS_mq_exchange, FLAGS_mq_binding_key, msg))
        {
            success_count++;
        }
        // 适当延时,避免消息拥塞
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }

    LOG_INFO("消息发送完成:成功 {}/{} 条", success_count, messages.size());

    // 等待事件循环处理完网络操作后退出
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return 0;
}

message_client.cc

cpp 复制代码
// 消息存储服务客户端测试程序(使用 Google Test 框架)
#include "base.pb.h"
#include "channel.hpp"
#include "etcd.hpp"
#include "message.pb.h"
#include "user.pb.h"
#include "utils.hpp"
#include <boost/date_time/posix_time/posix_time.hpp>
#include <gflags/gflags.h>
#include <gtest/gtest.h>
#include <thread>

// 定义 gflags 命令行参数(与原来一致)
DEFINE_bool(run_mode, false, "程序的运行模式,false-调试; true-发布;");
DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件");
DEFINE_int32(log_level, 0, "发布模式下,用于指定日志输出等级");

DEFINE_string(etcd_host, "http://127.0.0.1:2379", "服务注册中心地址");
DEFINE_string(base_service, "/service", "服务监控根目录");
DEFINE_string(message_service, "/service/message_service", "服务监控根目录");

// 全局服务管理器,用于 RPC 信道选择(由测试环境管理)
IMS::ServiceManager::ptr sm;
IMS::Discovery::ptr dclient;

// 测试环境类:负责全局初始化(SetUpTestCase)和清理(TearDownTestCase)
class MessageServiceTestEnvironment : public ::testing::Environment
{
public:
    void SetUp() override
    {
        // 初始化日志系统
        IMS::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);

        // 创建 RPC 信道管理对象
        sm = std::make_shared<IMS::ServiceManager>();
        sm->declared(FLAGS_message_service);

        // 设置服务发现回调
        auto put_cb = std::bind(&IMS::ServiceManager::onServiceOnline, sm.get(),
                                std::placeholders::_1, std::placeholders::_2);
        auto del_cb = std::bind(&IMS::ServiceManager::onServiceOffline, sm.get(),
                                std::placeholders::_1, std::placeholders::_2);
        dclient = std::make_shared<IMS::Discovery>(FLAGS_etcd_host, FLAGS_base_service,
                                                   put_cb, del_cb);

        // 等待服务发现完成(简单等待,实际可加循环检查)
        std::this_thread::sleep_for(std::chrono::seconds(2));
    }

    void TearDown() override
    {
        // 可选:清理资源
        dclient.reset();
        sm.reset();
    }
};

// 测试区间消息查询
TEST(MessageServiceTest, GetHistoryMsg)
{
    auto channel = sm->choose(FLAGS_message_service);
    ASSERT_NE(channel, nullptr) << "获取通信信道失败";

    IMS::MsgStorageService_Stub stub(channel.get());
    IMS::GetHistoryMsgReq req;
    IMS::GetHistoryMsgRsp rsp;

    req.set_request_id(IMS::uuid());
    req.set_chat_session_id("会话ID1");

    boost::posix_time::ptime stime = boost::posix_time::time_from_string("2024-08-02 00:00:00");
    boost::posix_time::ptime etime = boost::posix_time::time_from_string("2024-08-09 00:00:00");
    req.set_start_time(boost::posix_time::to_time_t(stime));
    req.set_over_time(boost::posix_time::to_time_t(etime));

    brpc::Controller cntl;
    stub.GetHistoryMsg(&cntl, &req, &rsp, nullptr);

    EXPECT_FALSE(cntl.Failed()) << "RPC 调用失败: " << cntl.ErrorText();
    EXPECT_TRUE(rsp.success()) << "服务端返回失败: " << rsp.errmsg();

    std::cout << "返回的消息数量: " << rsp.msg_list_size() << std::endl;

    // 可选:打印消息内容(用于调试,不强制断言)
    for (int i = 0; i < rsp.msg_list_size(); ++i)
    {
        std::cout<<"获取会话ID1从2024-08-02 00:00:00到2024-08-09 00:00:00的所有消息:"<<std::endl;
        auto msg = rsp.msg_list(i);
        std::cout << msg.message_id() << std::endl;
        std::cout << msg.chat_session_id() << std::endl;
        std::cout << boost::posix_time::to_simple_string(boost::posix_time::from_time_t(msg.timestamp())) << std::endl;
        std::cout << msg.sender().user_id() << std::endl;
        std::cout << msg.sender().nickname() << std::endl;
        std::cout << msg.sender().avatar() << std::endl;
        if (msg.message().message_type() == IMS::MessageType::STRING)
        {
            std::cout << "文本消息:" << msg.message().string_message().content() << std::endl;
        }
        else if (msg.message().message_type() == IMS::MessageType::IMAGE)
        {
            std::cout << "图片消息:" << msg.message().image_message().image_content() << std::endl;
        }
        else if (msg.message().message_type() == IMS::MessageType::FILE)
        {
            std::cout << "文件消息:" << msg.message().file_message().file_contents() << std::endl;
            std::cout << "文件名称:" << msg.message().file_message().file_name() << std::endl;
        }
        else if (msg.message().message_type() == IMS::MessageType::SPEECH)
        {
            std::cout << "语音消息:" << msg.message().speech_message().file_contents() << std::endl;
        }
        else
        {
            std::cout << "类型错误!!\n";
        }
    }
}

// 测试最近 N 条消息查询
TEST(MessageServiceTest, GetRecentMsg)
{
    auto channel = sm->choose(FLAGS_message_service);
    ASSERT_NE(channel, nullptr) << "获取通信信道失败";

    IMS::MsgStorageService_Stub stub(channel.get());
    IMS::GetRecentMsgReq req;
    IMS::GetRecentMsgRsp rsp;

    req.set_request_id(IMS::uuid());
    req.set_chat_session_id("会话ID1");
    req.set_msg_count(2); // 获取最近2条消息

    brpc::Controller cntl;
    stub.GetRecentMsg(&cntl, &req, &rsp, nullptr);

    EXPECT_FALSE(cntl.Failed()) << "RPC 调用失败: " << cntl.ErrorText();
    EXPECT_TRUE(rsp.success()) << "服务端返回失败: " << rsp.errmsg();

    std::cout << "返回的消息数量: " << rsp.msg_list_size() << std::endl;

    // 可选:打印消息内容(用于调试,不强制断言)
    for (int i = 0; i < rsp.msg_list_size(); ++i)
    {
        std::cout<<"获取会话ID1最近2条消息:"<<std::endl;
        auto msg = rsp.msg_list(i);
        std::cout << msg.message_id() << std::endl;
        std::cout << msg.chat_session_id() << std::endl;
        std::cout << boost::posix_time::to_simple_string(boost::posix_time::from_time_t(msg.timestamp())) << std::endl;
        std::cout << msg.sender().user_id() << std::endl;
        std::cout << msg.sender().nickname() << std::endl;
        std::cout << msg.sender().avatar() << std::endl;
        if (msg.message().message_type() == IMS::MessageType::STRING)
        {
            std::cout << "文本消息:" << msg.message().string_message().content() << std::endl;
        }
        else if (msg.message().message_type() == IMS::MessageType::IMAGE)
        {
            std::cout << "图片消息:" << msg.message().image_message().image_content() << std::endl;
        }
        else if (msg.message().message_type() == IMS::MessageType::FILE)
        {
            std::cout << "文件消息:" << msg.message().file_message().file_contents() << std::endl;
            std::cout << "文件名称:" << msg.message().file_message().file_name() << std::endl;
        }
        else if (msg.message().message_type() == IMS::MessageType::SPEECH)
        {
            std::cout << "语音消息:" << msg.message().speech_message().file_contents() << std::endl;
        }
        else
        {
            std::cout << "类型错误!!\n";
        }
    }
}

// 测试关键字搜索
TEST(MessageServiceTest, MsgSearch)
{
    auto channel = sm->choose(FLAGS_message_service);
    ASSERT_NE(channel, nullptr) << "获取通信信道失败";

    IMS::MsgStorageService_Stub stub(channel.get());
    IMS::MsgSearchReq req;
    IMS::MsgSearchRsp rsp;

    req.set_request_id(IMS::uuid());
    req.set_chat_session_id("会话ID1");
    req.set_search_key("盖浇");

    brpc::Controller cntl;
    stub.MsgSearch(&cntl, &req, &rsp, nullptr);

    EXPECT_FALSE(cntl.Failed()) << "RPC 调用失败: " << cntl.ErrorText();
    EXPECT_TRUE(rsp.success()) << "服务端返回失败: " << rsp.errmsg();

    std::cout << "返回的消息数量: " << rsp.msg_list_size() << std::endl;

    // 可选:打印消息内容(用于调试,不强制断言)
    for (int i = 0; i < rsp.msg_list_size(); ++i)
    {
        std::cout<<"基于 盖浇 关键字搜索的消息:"<<std::endl;
        auto msg = rsp.msg_list(i);
        std::cout << msg.message_id() << std::endl;
        std::cout << msg.chat_session_id() << std::endl;
        std::cout << boost::posix_time::to_simple_string(boost::posix_time::from_time_t(msg.timestamp())) << std::endl;
        std::cout << msg.sender().user_id() << std::endl;
        std::cout << msg.sender().nickname() << std::endl;
        std::cout << msg.sender().avatar() << std::endl;
        std::cout << "文本消息:" << msg.message().string_message().content() << std::endl;
    }
}

int main(int argc, char **argv)
{
    // 解析 gflags 命令行参数(必须在 Google Test 初始化之前)
    google::ParseCommandLineFlags(&argc, &argv, true);

    // 初始化 Google Test 框架
    ::testing::InitGoogleTest(&argc, argv);

    // 注册自定义测试环境(负责全局初始化)
    ::testing::AddGlobalTestEnvironment(new MessageServiceTestEnvironment);

    // 运行所有测试
    return RUN_ALL_TESTS();
}

首先我们需要先往这个用户表里面插入三个用户数据

cpp 复制代码
INSERT INTO user (user_id) VALUES ('user_001'), ('user_002'), ('user_003');

然后我们这个时候需要依次运行文件管理服务端,用户管理服务端,消息存储服务端,然后运行我们的消息发布客户端

运行完后,我们就可以在服务器里面的数据库中查询到

cpp 复制代码
mysql> select * from message;
+----+--------------------+-----------------+----------+--------------+---------------------+-----------------------------------------------+--------------------+------------+-----------+
| id | message_id         | chat_session_id | user_id  | message_type | create_time         | content                                       | file_id            | file_name  | file_size |
+----+--------------------+-----------------+----------+--------------+---------------------+-----------------------------------------------+--------------------+------------+-----------+
|  1 | 51d5-7199543d-0000 | 会话ID1         | user_001 |            0 | 2024-08-01 00:00:00 | 今天天气真好,盖浇饭好吃!                    |                    |            |         0 |
|  2 | 7326-39ad0b94-0001 | 会话ID1         | user_002 |            0 | 2024-08-02 00:00:00 | 天气预报说今天有雨,记得带伞。                |                    |            |         0 |
|  3 | d47a-c8124842-0002 | 会话ID1         | user_003 |            1 | 2024-08-09 00:00:00 |                                               | 9c3f-a8a19016-000c |            |         0 |
|  4 | 262f-cd1c37d5-0003 | 会话ID1         | user_001 |            2 | 2024-08-04 00:00:00 |                                               | 8254-b9c423b4-000d | report.pdf |      2048 |
|  5 | c1ba-fd23b380-0004 | 会话ID1         | user_002 |            3 | 2024-08-07 00:00:00 |                                               | e546-3af65eba-000e |            |      2048 |
|  6 | bf34-a94d6b9d-0005 | 会话ID1         | user_003 |            0 | 2024-08-10 00:00:00 | 大家好,我是新会话的成员。                    |                    |            |      2048 |
+----+--------------------+-----------------+----------+--------------+---------------------+-----------------------------------------------+--------------------+------------+-----------+
6 rows in set (0.00 sec)

这个时候我们也可以去这个ES里面看看

cpp 复制代码
GET /message/_search
{
  "query": {
    "match_all": {}
  }
}

我们就会查询到

cpp 复制代码
#! Elasticsearch built-in security features are not enabled. Without authentication, your cluster could be accessible to anyone. See https://www.elastic.co/guide/en/elasticsearch/reference/7.17/security-minimal-setup.html to enable security.
{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 4,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "message",
        "_type" : "_doc",
        "_id" : "default_index_id",
        "_score" : 1.0,
        "_source" : {
          "mappings" : {
            "dynamic" : true,
            "properties" : {
              "chat_session_id" : {
                "analyzer" : "standard",
                "type" : "keyword"
              },
              "content" : {
                "analyzer" : "ik_max_word",
                "type" : "text"
              },
              "create_time" : {
                "analyzer" : "standard",
                "enabled" : false,
                "type" : "long"
              },
              "message_id" : {
                "analyzer" : "standard",
                "enabled" : false,
                "type" : "keyword"
              },
              "user_id" : {
                "analyzer" : "standard",
                "enabled" : false,
                "type" : "keyword"
              }
            }
          },
          "settings" : {
            "analysis" : {
              "analyzer" : {
                "ik" : {
                  "tokenizer" : "ik_max_word"
                }
              }
            }
          }
        }
      },
      {
        "_index" : "message",
        "_type" : "_doc",
        "_id" : "51d5-7199543d-0000",
        "_score" : 1.0,
        "_source" : {
          "chat_session_id" : "会话ID1",
          "content" : "今天天气真好,盖浇饭好吃!",
          "create_time" : 1722470400,
          "message_id" : "51d5-7199543d-0000",
          "user_id" : "user_001"
        }
      },
      {
        "_index" : "message",
        "_type" : "_doc",
        "_id" : "7326-39ad0b94-0001",
        "_score" : 1.0,
        "_source" : {
          "chat_session_id" : "会话ID1",
          "content" : "天气预报说今天有雨,记得带伞。",
          "create_time" : 1722556800,
          "message_id" : "7326-39ad0b94-0001",
          "user_id" : "user_002"
        }
      },
      {
        "_index" : "message",
        "_type" : "_doc",
        "_id" : "bf34-a94d6b9d-0005",
        "_score" : 1.0,
        "_source" : {
          "chat_session_id" : "会话ID1",
          "content" : "大家好,我是新会话的成员。",
          "create_time" : 1723248000,
          "message_id" : "bf34-a94d6b9d-0005",
          "user_id" : "user_003"
        }
      }
    ]
  }
}

可以看到,我们的消息存储子服务完美实现了:消息存储子服务启动后,会订阅消息队列(RabbitMQ)中指定的队列。一旦队列中有新消息到来,服务便会自动回调MessageServiceImpl::onMessage函数进行处理。

onMessage 的处理逻辑会根据消息类型走不同分支:

  • 文本消息 :直接将消息内容写入 Elasticsearch(ES),以便后续提供全文检索能力。

  • 图片、文件、语音消息 :先调用文件子服务,将文件二进制内容上传到文件存储系统,获取一个唯一的文件 ID;随后将该文件 ID 以及文件名、文件大小等元信息保存下来。

  • 无论哪种类型,最终都会提取消息的公共元信息(消息 ID、会话 ID、发送者 ID、发送时间、消息类型等),并写入 MySQL 数据库,形成一条完整的消息记录。

通过这样的设计,文本内容进入 ES 供搜索,大文件内容交给专业文件服务管理,而所有消息的元数据统一存储在 MySQL 中,既保证了检索效率,又避免了数据库的大字段负担。

最后我们运行我们的测试程序

cpp 复制代码
ubuntu@10-13-52-255:~/cpp-chatsystem/server/Service/message/build$ ./message_client
[==========] Running 3 tests from 1 test suite.
[----------] Global test environment set-up.
[default-logger][21:58:15][1338570][debug   ][/home/ubuntu/cpp-chatsystem/server/Service/message/../../common/channel.hpp:172] /service/file_service-127.0.0.1:10002 服务上线了,但是当前并不关心!
[default-logger][21:58:15][1338570][debug   ][/home/ubuntu/cpp-chatsystem/server/Service/message/../../common/channel.hpp:196] /service/message_service-127.0.0.1:10005 服务上线新节点,进行添加管理!
[default-logger][21:58:15][1338570][debug   ][/home/ubuntu/cpp-chatsystem/server/Service/message/../../common/channel.hpp:172] /service/user_service-127.0.0.1:10003 服务上线了,但是当前并不关心!
[----------] 3 tests from MessageServiceTest
[ RUN      ] MessageServiceTest.GetHistoryMsg
返回的消息数量: 4
获取会话ID1从2024-08-02 00:00:00到2024-08-09 00:00:00的所有消息:
7326-39ad0b94-0001
会话ID1
2024-Aug-02 00:00:00
user_002


文本消息:天气预报说今天有雨,记得带伞。
获取会话ID1从2024-08-02 00:00:00到2024-08-09 00:00:00的所有消息:
d47a-c8124842-0002
会话ID1
2024-Aug-09 00:00:00
user_003


图片消息:这是一张图片的二进制数据(模拟)
获取会话ID1从2024-08-02 00:00:00到2024-08-09 00:00:00的所有消息:
262f-cd1c37d5-0003
会话ID1
2024-Aug-04 00:00:00
user_001


文件消息:PDF文件二进制内容(模拟)
文件名称:report.pdf
获取会话ID1从2024-08-02 00:00:00到2024-08-09 00:00:00的所有消息:
c1ba-fd23b380-0004
会话ID1
2024-Aug-07 00:00:00
user_002


语音消息:语音文件二进制内容(模拟)
[       OK ] MessageServiceTest.GetHistoryMsg (6 ms)
[ RUN      ] MessageServiceTest.GetRecentMsg
返回的消息数量: 2
获取会话ID1最近2条消息:
d47a-c8124842-0002
会话ID1
2024-Aug-09 00:00:00
user_003


图片消息:这是一张图片的二进制数据(模拟)
获取会话ID1最近2条消息:
bf34-a94d6b9d-0005
会话ID1
2024-Aug-10 00:00:00
user_003


文本消息:大家好,我是新会话的成员。
[       OK ] MessageServiceTest.GetRecentMsg (2 ms)
[ RUN      ] MessageServiceTest.MsgSearch
返回的消息数量: 1
基于 盖浇 关键字搜索的消息:
51d5-7199543d-0000
会话ID1
2024-Aug-01 00:00:00
user_001


文本消息:今天天气真好,盖浇饭好吃!
[       OK ] MessageServiceTest.MsgSearch (4 ms)
[----------] 3 tests from MessageServiceTest (12 ms total)

[----------] Global test environment tear-down
[warn] watcher does't exit normally
[==========] 3 tests from 1 test suite ran. (3027 ms total)
[  PASSED  ] 3 tests.

可以说和我们发送的完美匹配上了。

注意:我们做完测试之后,需要去

MySQL数据库

cpp 复制代码
TRUNCATE TABLE message;

ES,我们需要去使用浏览器(5601端口)

cpp 复制代码
DELETE /message

对于RabbitMQ,我们需要去使用浏览器

javascript 复制代码
主机IP:15672

然后

往下面就能看到

点击这个就能删除这个队列

往下

点击这个就能删除交换机

相关推荐
熊猫钓鱼>_>2 小时前
MinerU的正确使用方式:如何解析PDF成标准化向量数据,以供AI大模型等场景应用
人工智能·阿里云·架构·pdf·ocr·skill·mineru
heimeiyingwang2 小时前
【架构实战】分布式事务解决方案
分布式·架构
风向决定发型丶2 小时前
浅谈K8S的Label和Annotation
云原生·容器·kubernetes
Test-Sunny2 小时前
【实战问题汇总】大模型ai测试
ai·架构
全栈派森2 小时前
拒绝分布式大泥球:复杂系统微服务拆分与服务间通信的终极指南
后端·微服务
培小新2 小时前
【Docker安全优化】
云原生·eureka
easy_coder2 小时前
从 ManifestRender 到 Certificate:一次 Kubernetes 应用发布故障的深度排障实录
云原生·云计算
Allen_LVyingbo2 小时前
自进化医疗智能体:动态记忆与持续运行的Python架构编程(上)
数据结构·python·架构·动态规划·健康医疗
国科安芯2 小时前
商业航天视角下角度编码传感器的应用与MCU的集成适配
大数据·网络·单片机·嵌入式硬件·架构·制造·安全性测试