【C++ 基于微服务的即时通讯系统】文件存储子服务实战:UUID生成、文件IO封装与全接口单元测试


🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:


文章目录

  • 前言:
  • 一、文件存储子服务实现思想
    • [1.1 功能设计](#1.1 功能设计)
    • [1.2 模块划分](#1.2 模块划分)
    • [1.3 核心设计思路](#1.3 核心设计思路)
    • [1.4 Proto协议定义](#1.4 Proto协议定义)
  • 二、文件存储子服务代码实现
    • [2.1 基础框架搭建](#2.1 基础框架搭建)
    • [2.2 公共工具类实现](#2.2 公共工具类实现)
      • [2.2.1 UUID唯一ID生成器](#2.2.1 UUID唯一ID生成器)
      • [2.2.2 二进制文件读写封装](#2.2.2 二进制文件读写封装)
    • [2.3 业务接口具体实现](#2.3 业务接口具体实现)
  • 三、文件存储子服务单元测试
    • [3.1 测试设计思路](#3.1 测试设计思路)
    • [3.2 测试代码实现](#3.2 测试代码实现)
    • [3.3 CMake配置](#3.3 CMake配置)
    • [3.4 结果验证](#3.4 结果验证)
  • 四、开发过程中的真实踩坑
    • [4.1 日志命名空间找不到的编译错误](#4.1 日志命名空间找不到的编译错误)
    • [4.2 Protobuf Map的const引用问题](#4.2 Protobuf Map的const引用问题)
    • [4.3 数组越界导致的段错误](#4.3 数组越界导致的段错误)
    • [4.4 初始化列表遗漏导致的段错误](#4.4 初始化列表遗漏导致的段错误)
    • [4.5 文件散落在运行目录的问题](#4.5 文件散落在运行目录的问题)
  • 五、核心要点总结
  • 结尾:

前言:

上一篇我们落地了第一个业务子服务------语音识别,把微服务的标准开发流程完整走了一遍。这一篇趁热打铁,落地第二个独立服务:文件存储子服务。之所以第二个选它,原因和语音服务一样:它和其他子服务的业务交叉最少,职责单一闭环,只需要管好文件的上传下载。更重要的是,开发它的过程中能沉淀出两个通用公共组件:UUID 唯一ID生成器和二进制文件读写封装,后面用户、消息、好友等所有服务都能直接复用。本文顺着「设计思想 → 框架搭建 → 公共工具 → 业务实现 → 单元测试 → 踩坑修复」的完整开发路径,一步步拆解文件存储子服务的落地过程,所有代码和踩坑都是实战中真实遇到的,照着走就能复现。


一、文件存储子服务实现思想

1.1 功能设计

文件存储子服务是整个系统的文件资源中心,所有需要落盘的资源都交给它管理,包括用户头像、聊天图片、语音文件、普通文件消息。对外就两类能力,共四个接口:

文件上传

  • 单文件上传:最常用的场景,比如用户修改头像、发送单张图片,后台收到文件后转发给文件服务存储
  • 多文件上传:批量场景,一次请求处理多个文件,减少 RPC 调用次数,比如一次发多张图片

文件下载

  • 单文件下载:客户端获取图片、语音、文件消息,或者后台获取单个用户头像
  • 多文件下载:批量拉取场景,比如加载用户列表时批量获取所有人的头像,比循环调用单文件接口效率高很多

1.2 模块划分

延续标准的微服务五层模块划分,和语音服务的结构完全对齐:

  1. 参数解析模块:基于 gflags,管理监听端口、注册中心地址、文件存储路径等所有运行配置
  2. 日志模块:基于 spdlog 封装的公共日志组件,统一输出格式和级别
  3. 服务注册模块:基于 etcd 封装,服务启动后自动注册到注册中心
  4. RPC服务模块:基于 brpc 搭建服务器,对外提供四个文件操作接口
  5. 文件操作模块:基于标准库文件流封装,实现二进制文件的读写能力

1.3 核心设计思路

这里有一个非常关键的设计:上传的文件不保留原始文件名,而是生成全局唯一的 UUID 作为文件名存储。

这么做有三个明显的好处:

  • 彻底避免文件名冲突:不同用户上传同名文件不会互相覆盖,也不用处理重命名逻辑
  • 杜绝路径注入风险 :原始文件名可能带 ../ 之类的恶意路径,用纯随机的 UUID 当文件名,完全规避目录穿越漏洞
  • 管理简单:所有文件平级存储在同一个目录下,通过 ID 直接定位,不需要维护复杂的多级目录结构

调用流程很清晰:客户端上传文件 → 服务端生成 UUID 存盘 → 返回文件 ID;客户端下载只需要传文件 ID,服务端用 ID 当文件名读取返回。

1.4 Proto协议定义

先看公共数据结构,定义在 base.proto 里,全服务复用:

复制代码
// 单文件下载数据结构
message FileDownloadData {
    string file_id = 1;       // 文件唯一ID
    bytes file_content = 2;   // 文件二进制内容
}

// 单文件上传数据结构
message FileUploadData {
    string file_name = 1;     // 原始文件名
    int64 file_size = 2;      // 文件大小
    bytes file_content = 3;   // 文件二进制内容
}

// 文件元信息结构
message FileMessageInfo {
    optional string file_id = 1;    // 文件ID
    optional int64 file_size = 2;   // 文件大小
    optional string file_name = 3;  // 原始文件名
    optional bytes file_contents = 4;// 文件内容
}

四个业务接口的请求响应定义在 file.proto 中:

复制代码
// 单文件上传
message PutSingleFileReq {
    string request_id = 1;
    optional string user_id = 2;
    optional string session_id = 3;
    FileUploadData file_data = 4;
}
message PutSingleFileRsp {
    string request_id = 1;
    bool success = 2;
    string errmsg = 3;
    FileMessageInfo file_info = 4;
}

// 多文件上传
message PutMultiFileReq {
    string request_id = 1;
    optional string user_id = 2;
    optional string session_id = 3;
    repeated FileUploadData file_data = 4;
}
message PutMultiFileRsp {
    string request_id = 1;
    bool success = 2;
    string errmsg = 3;
    repeated FileMessageInfo file_info = 4;
}

// 多文件下载
message GetMultiFileReq {
    string request_id = 1;
    optional string user_id = 2;
    optional string session_id = 3;
    repeated string file_id_list = 4;
}
message GetMultiFileRsp {
    string request_id = 1;
    bool success = 2;
    string errmsg = 3;
    map<string, FileDownloadData> file_data = 4;
}

设计细节解读 :

多文件下载的返回值没有用 repeated 数组,而是用了 map<string, FileDownloadData>。这是个很实用的设计:调用方拿到结果后,直接通过文件 ID 就能索引到对应数据,不用循环遍历匹配,代码更简洁,查找效率也更高。

二、文件存储子服务代码实现

2.1 基础框架搭建

因为和语音识别子服务的架构完全一致,我们直接复用建造者模式的框架,只替换业务实现部分即可。这样所有服务的代码结构统一,后面维护起来也省心。

骨架代码 file_server.hpp 如下,核心还是三个类:

  • FileServiceImpl:RPC 业务实现类,重写四个接口的业务逻辑

  • FileServer:服务管理类,封装启动入口

  • FileServerBuilder:建造者类,分步初始化各个模块,最后构建出完整服务对象

    #pragma once
    #include <brpc/server.h>
    #include "etcd.hpp"
    #include "logger.hpp"
    #include "base.pb.h"
    #include "file.pb.h"

    namespace bite_im {

    // RPC业务实现类
    class FileServiceImpl : public bite_im::FileService {
    public:
    explicit FileServiceImpl(const std::string& storage_path);
    ~FileServiceImpl() override = default;

    复制代码
      void GetSingleFile(google::protobuf::RpcController* controller,
                         const ::bite_im::GetSingleFileReq* request,
                         ::bite_im::GetSingleFileRsp* response,
                         ::google::protobuf::Closure* done) override;
    
      void GetMultiFile(google::protobuf::RpcController* controller,
                        const ::bite_im::GetMultiFileReq* request,
                        ::bite_im::GetMultiFileRsp* response,
                        ::google::protobuf::Closure* done) override;
    
      void PutSingleFile(google::protobuf::RpcController* controller,
                         const ::bite_im::PutSingleFileReq* request,
                         ::bite_im::PutSingleFileRsp* response,
                         ::google::protobuf::Closure* done) override;
    
      void PutMultiFile(google::protobuf::RpcController* controller,
                        const ::bite_im::PutMultiFileReq* request,
                        ::bite_im::PutMultiFileRsp* response,
                        ::google::protobuf::Closure* done) override;

    private:
    std::string _storage_path; // 文件存储根目录
    };

    // 服务管理类
    class FileServer {
    public:
    using ptr = std::shared_ptr;
    FileServer(const Registry::ptr& reg_client,
    const std::shared_ptrbrpc::Server& rpc_server)
    : _reg_client(reg_client), _rpc_server(rpc_server) {}

    复制代码
      void start() { _rpc_server->RunUntilAskedToQuit(); }

    private:
    Registry::ptr _reg_client;
    std::shared_ptrbrpc::Server _rpc_server;
    };

    // 建造者类
    class FileServerBuilder {
    public:
    // 构造RPC服务器
    void make_rpc_server(uint16_t port, int32_t timeout, uint8_t num_threads, const std::string& storage_path);
    // 构造服务注册客户端并注册服务
    void make_reg_object(const std::string& reg_host,
    const std::string& service_name,
    const std::string& access_host);
    // 构建最终服务对象
    FileServer::ptr build();
    private:
    Registry::ptr _reg_client;
    std::shared_ptrbrpc::Server _rpc_server;
    };

    } // namespace bite_im

主函数入口的结构也和语音服务完全一致,解析 gflags 参数,分步建造,最后启动服务。核心是新增了一个 storage_path 参数,用来配置文件存储目录。

2.2 公共工具类实现

在写业务逻辑之前,先实现两个底层工具:UUID 生成器和文件读写封装。这两个都会下沉到公共组件 common 里,后面所有服务都能用。

2.2.1 UUID唯一ID生成器

我们实现一个 16 位十六进制的 UUID,采用「随机数 + 原子自增」的组合方案,兼顾随机性和多线程安全性。

复制代码
#pragma once
#include <string>
#include <atomic>
#include <random>
#include <sstream>
#include <iomanip>
#include "logger.hpp"

namespace bite_im {

// 生成16位十六进制唯一ID
std::string uuid() {
    // 第一部分:6字节真随机数 → 12位十六进制
    std::random_device rd;                  // 硬件设备随机数,生成种子
    std::mt19937 generator(rd());          // 梅森旋转算法伪随机数生成器
    std::uniform_int_distribution<int> dist(0, 255); // 限定单字节范围0~255

    std::stringstream ss;
    for (int i = 0; i < 6; ++i) {
        // 宽度为2,不足补前导0,转十六进制
        ss << std::setw(2) << std::setfill('0') << std::hex << dist(generator);
    }

    // 第二部分:2字节原子自增编号 → 4位十六进制
    // 原子变量保证多线程并发下自增安全,同进程内绝对不重复
    static std::atomic<short> idx(0);
    short tmp = idx.fetch_add(1);
    ss << std::setw(4) << std::setfill('0') << std::hex << tmp;

    return ss.str();
}

} // namespace bite_im

代码解读:

  • 前 12 位用 std::random_device 真随机数做种子,配合梅森旋转算法生成,不同主机、不同进程生成的结果大概率不重复
  • 后 4 位用原子自增,保证同一进程内绝对不会出现重复 ID
  • std::setw(2) + std::setfill('0') 是很容易忽略的细节:如果生成的字节小于 0x10,前面会自动补 0,保证每字节都是两位,最终长度固定 16 位
  • 单机场景完全够用,多机分布式场景可以再加上主机 MAC 地址标识进一步降低冲突概率

2.2.2 二进制文件读写封装

两个工具函数,分别实现全量读取和全量写入,全程使用二进制模式,避免文本模式下换行符转换损坏文件。

复制代码
namespace bite_im {

// 读取整个文件内容到字符串中
bool readFile(const std::string& filename, std::string& body) {
    // 二进制 + 只读模式打开
    std::ifstream ifs(filename, std::ios::binary | std::ios::in);
    if (!ifs.is_open()) {
        LOG_ERROR("打开文件{}失败!", filename);
        return false;
    }

    // 跳转到文件末尾获取文件大小
    ifs.seekg(0, std::ios::end);
    size_t flen = ifs.tellg();
    // 跳回文件开头准备读取
    ifs.seekg(0, std::ios::beg);

    // 提前扩容,一次性读取,比循环读高效很多
    body.resize(flen);
    ifs.read(&body[0], flen);

    if (!ifs.good()) {
        LOG_ERROR("读取文件{}数据失败!", filename);
        ifs.close();
        return false;
    }

    ifs.close();
    return true;
}

// 将字符串数据写入文件
bool writeFile(const std::string& filename, const std::string& body) {
    // 二进制 + 写 + 截断模式:文件存在就清空重写
    std::ofstream ofs(filename, std::ios::out | std::ios::binary | std::ios::trunc);
    if (!ofs.is_open()) {
        LOG_ERROR("打开文件{}失败!", filename);
        return false;
    }

    ofs.write(body.c_str(), body.size());
    if (!ofs.good()) {
        LOG_ERROR("写入文件{}数据失败!", filename);
        ofs.close();
        return false;
    }

    ofs.close();
    return true;
}

} // namespace bite_im

代码解读:

  • 必须加 std::ios::binary,这是新手最高频的坑。文本模式下系统会自动转换换行符,图片、音频这类二进制文件读写后会直接损坏
  • 读文件用 seekg + tellg 一次性拿到大小,resize 后一次性 read,比循环读缓冲区效率高很多
  • 写文件用 std::ios::trunc,文件存在就直接截断覆盖,符合上传文件的语义

2.3 业务接口具体实现

工具准备就绪,现在填充四个业务接口的具体逻辑。构造函数里会做存储目录的初始化,确保目录存在,并且统一补齐末尾的斜杠。

构造函数

复制代码
FileServiceImpl::FileServiceImpl(const std::string& storage_path)
    : _storage_path(storage_path) {
    // 确保目录存在
    umask(0);
    mkdir(_storage_path.c_str(), 0775);
    // 确保路径末尾有斜杠,拼接文件名时不用再判断
    if (_storage_path.back() != '/') {
        _storage_path.push_back('/');
    }
}

单文件上传

复制代码
void FileServiceImpl::PutSingleFile(google::protobuf::RpcController* controller,
                                     const ::bite_im::PutSingleFileReq* request,
                                     ::bite_im::PutSingleFileRsp* response,
                                     ::google::protobuf::Closure* done) {
    brpc::ClosureGuard rpc_guard(done);
    response->set_request_id(request->request_id());

    // 1. 生成唯一文件ID,拼接完整存储路径
    std::string fid = uuid();
    std::string filename = _storage_path + fid;

    // 2. 写入磁盘
    bool ret = writeFile(filename, request->file_data().file_content());
    if (!ret) {
        response->set_success(false);
        response->set_errmsg("写入文件数据失败!");
        LOG_ERROR("{}写入文件数据失败!", request->request_id());
        return;
    }

    // 3. 填充响应元信息
    response->mutable_file_info()->set_file_id(fid);
    response->mutable_file_info()->set_file_name(request->file_data().file_name());
    response->mutable_file_info()->set_file_size(request->file_data().file_size());
    response->set_success(true);
}

多文件上传

循环处理每个文件,任意一个失败就整体返回失败,保证原子性。

复制代码
void FileServiceImpl::PutMultiFile(google::protobuf::RpcController* controller,
                                    const ::bite_im::PutMultiFileReq* request,
                                    ::bite_im::PutMultiFileRsp* response,
                                    ::google::protobuf::Closure* done) {
    brpc::ClosureGuard rpc_guard(done);
    response->set_request_id(request->request_id());

    for (int i = 0; i < request->file_data_size(); ++i) {
        std::string fid = uuid();
        std::string filename = _storage_path + fid;

        bool ret = writeFile(filename, request->file_data(i).file_content());
        if (!ret) {
            response->set_success(false);
            response->set_errmsg("写入文件数据失败!");
            LOG_ERROR("{}写入文件数据失败!", request->request_id());
            return;
        }

        // repeated字段用add_*接口新增元素
        bite_im::FileMessageInfo* info = response->add_file_info();
        info->set_file_id(fid);
        info->set_file_size(request->file_data(i).file_size());
        info->set_file_name(request->file_data(i).file_name());
    }

    response->set_success(true);
}

单文件下载

复制代码
void FileServiceImpl::GetSingleFile(google::protobuf::RpcController* controller,
                                     const ::bite_im::GetSingleFileReq* request,
                                     ::bite_im::GetSingleFileRsp* response,
                                     ::google::protobuf::Closure* done) {
    brpc::ClosureGuard rpc_guard(done);
    response->set_request_id(request->request_id());

    std::string fid = request->file_id();
    std::string filename = _storage_path + fid;
    std::string body;

    bool ret = readFile(filename, body);
    if (!ret) {
        response->set_success(false);
        response->set_errmsg("读取文件数据失败!");
        LOG_ERROR("{}读取文件数据失败!", request->request_id());
        return;
    }

    response->mutable_file_data()->set_file_id(fid);
    response->mutable_file_data()->set_file_content(body);
    response->set_success(true);
}

多文件下载

复制代码
void FileServiceImpl::GetMultiFile(google::protobuf::RpcController* controller,
                                    const ::bite_im::GetMultiFileReq* request,
                                    ::bite_im::GetMultiFileRsp* response,
                                    ::google::protobuf::Closure* done) {
    brpc::ClosureGuard rpc_guard(done);
    response->set_request_id(request->request_id());

    for (int i = 0; i < request->file_id_list_size(); ++i) {
        std::string fid = request->file_id_list(i);
        std::string filename = _storage_path + fid;
        std::string body;

        bool ret = readFile(filename, body);
        if (!ret) {
            response->set_success(false);
            response->set_errmsg("读取文件数据失败!");
            LOG_ERROR("{}读取文件数据失败!", request->request_id());
            return;
        }

        // 插入到map中
        bite_im::FileDownloadData data;
        data.set_file_id(fid);
        data.set_file_content(body);
        (*response->mutable_file_data())[fid] = data;
    }

    response->set_success(true);
}

三、文件存储子服务单元测试

服务写好了不能光靠肉眼看,我们用 gtest 写一套完整的单元测试,覆盖四个接口,验证上传下载的数据一致性。

3.1 测试设计思路

测试流程和语音服务一致:先通过服务发现拿到文件服务的信道,然后依次执行四个测试用例:

  1. 单文件上传测试:读取本地文件上传,校验返回的文件大小、文件名是否正确
  2. 单文件下载测试:用上传返回的文件 ID 下载,校验 ID 匹配,落地文件后校验 md5
  3. 多文件上传测试:同时上传两个本地文件,校验返回数量和元信息
  4. 多文件下载测试:批量下载两个文件,校验都能正常获取,落地后校验 md5

3.2 测试代码实现

复制代码
#include <gflags/gflags.h>
#include <gtest/gtest.h>
#include <thread>
#include "etcd.hpp"
#include "channel.hpp"
#include "logger.hpp"
#include "file.pb.h"
#include "base.pb.h"
#include "utils.hpp"

DEFINE_bool(run_mode, false, "程序运行模式");
DEFINE_string(log_file, "", "日志输出文件");
DEFINE_int32(log_level, 0, "日志输出级别");

DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");
DEFINE_string(file_service, "/service/file_service", "文件服务名称");

bite_im::ServiceChannel::ChannelPtr channel;
std::string single_file_id;
std::vector<std::string> multi_file_id;

// 单文件上传测试
TEST(put_test, single_file) {
    std::string body;
    ASSERT_TRUE(bite_im::readFile("./Makefile", body));

    bite_im::FileService_Stub stub(channel.get());
    bite_im::PutSingleFileReq req;
    req.set_request_id("1111");
    req.mutable_file_data()->set_file_name("Makefile");
    req.mutable_file_data()->set_file_size(body.size());
    req.mutable_file_data()->set_file_content(body);

    brpc::Controller* cntl = new brpc::Controller();
    bite_im::PutSingleFileRsp* rsp = new bite_im::PutSingleFileRsp();
    stub.PutSingleFile(cntl, &req, rsp, nullptr);

    ASSERT_FALSE(cntl->Failed());
    ASSERT_TRUE(rsp->success());
    ASSERT_EQ(rsp->file_info().file_size(), body.size());
    ASSERT_EQ(rsp->file_info().file_name(), "Makefile");

    single_file_id = rsp->file_info().file_id();
    LOG_DEBUG("文件ID: {}", single_file_id);

    delete cntl;
    delete rsp;
}

// 单文件下载测试
TEST(get_test, single_file) {
    bite_im::FileService_Stub stub(channel.get());
    bite_im::GetSingleFileReq req;
    req.set_request_id("2222");
    req.set_file_id(single_file_id);

    brpc::Controller* cntl = new brpc::Controller();
    bite_im::GetSingleFileRsp* rsp = new bite_im::GetSingleFileRsp();
    stub.GetSingleFile(cntl, &req, rsp, nullptr);

    ASSERT_FALSE(cntl->Failed());
    ASSERT_TRUE(rsp->success());
    ASSERT_EQ(single_file_id, rsp->file_data().file_id());

    // 下载后落地到本地,方便md5校验
    bite_im::writeFile("make_file_download", rsp->file_data().file_content());

    delete cntl;
    delete rsp;
}

// 多文件上传测试
TEST(put_test, multi_file) {
    std::string body1, body2;
    ASSERT_TRUE(bite_im::readFile("./base.pb.h", body1));
    ASSERT_TRUE(bite_im::readFile("./file.pb.h", body2));

    bite_im::FileService_Stub stub(channel.get());
    bite_im::PutMultiFileReq req;
    req.set_request_id("3333");

    auto file_data = req.add_file_data();
    file_data->set_file_name("base.pb.h");
    file_data->set_file_size(body1.size());
    file_data->set_file_content(body1);

    file_data = req.add_file_data();
    file_data->set_file_name("file.pb.h");
    file_data->set_file_size(body2.size());
    file_data->set_file_content(body2);

    brpc::Controller* cntl = new brpc::Controller();
    bite_im::PutMultiFileRsp* rsp = new bite_im::PutMultiFileRsp();
    stub.PutMultiFile(cntl, &req, rsp, nullptr);

    ASSERT_FALSE(cntl->Failed());
    ASSERT_TRUE(rsp->success());
    ASSERT_EQ(rsp->file_info_size(), 2);

    for (int i = 0; i < rsp->file_info_size(); ++i) {
        multi_file_id.push_back(rsp->file_info(i).file_id());
        LOG_DEBUG("文件ID: {}", multi_file_id[i]);
    }

    delete cntl;
    delete rsp;
}

// 多文件下载测试
TEST(get_test, multi_file) {
    bite_im::FileService_Stub stub(channel.get());
    bite_im::GetMultiFileReq req;
    req.set_request_id("4444");
    req.add_file_id_list(multi_file_id[0]);
    req.add_file_id_list(multi_file_id[1]);

    brpc::Controller* cntl = new brpc::Controller();
    bite_im::GetMultiFileRsp* rsp = new bite_im::GetMultiFileRsp();
    stub.GetMultiFile(cntl, &req, rsp, nullptr);

    ASSERT_FALSE(cntl->Failed());
    ASSERT_TRUE(rsp->success());

    // protobuf的map是const的,operator[]会修改,先拷贝一份再用
    auto file_map = rsp->file_data();
    ASSERT_TRUE(file_map.find(multi_file_id[0]) != file_map.end());
    ASSERT_TRUE(file_map.find(multi_file_id[1]) != file_map.end());

    bite_im::writeFile("base_download_file1", file_map[multi_file_id[0]].file_content());
    bite_im::writeFile("file_download_file2", file_map[multi_file_id[1]].file_content());

    delete cntl;
    delete rsp;
}

int main(int argc, char* argv[]) {
    testing::InitGoogleTest(&argc, argv);
    google::ParseCommandLineFlags(&argc, &argv, true);
    bite_im::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);

    // 初始化服务发现
    auto sm = std::make_shared<bite_im::ServiceManager>();
    sm->declared(FLAGS_file_service);

    auto put_cb = std::bind(&bite_im::ServiceManager::onServiceOnline, sm.get(), 
                            std::placeholders::_1, std::placeholders::_2);
    auto del_cb = std::bind(&bite_im::ServiceManager::onServiceOffline, sm.get(), 
                            std::placeholders::_1, std::placeholders::_2);

    bite_im::Discovery::ptr dclient = std::make_shared<bite_im::Discovery>(
        FLAGS_etcd_host, FLAGS_base_service, put_cb, del_cb);

    // 等待服务发现
    channel = sm->choose(FLAGS_file_service);
    if (!channel) {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        channel = sm->choose(FLAGS_file_service);
        if (!channel) {
            std::cerr << "没有找到可用的文件服务节点" << std::endl;
            return -1;
        }
    }

    return RUN_ALL_TESTS();
}

3.3 CMake配置

CMakeLists.txt 和语音服务的结构基本一致,主要改动是加上 gtest 链接库:

复制代码
# 测试客户端
set(test_client "file_client")
set(test_files "")
aux_source_directory(${CMAKE_CURRENT_SOURCE_DIR}/test test_files)
add_executable(${test_client} ${test_files} ${proto_srcs})
target_link_libraries(${test_client} 
    -lgtest -lgflags -lspdlog -lfmt 
    -lbrpc -lssl -lcrypto -lprotobuf -lleveldb 
    -letcd-cpp-api -lcpprest
)

3.4 结果验证

测试全部跑通后,我们用 md5sum 命令校验上传前后的文件一致性:

复制代码
md5sum Makefile
md5sum make_file_download

两个文件的 md5 值完全一致,说明上传下载过程中数据没有任何损坏,服务逻辑正确。

四、开发过程中的真实踩坑

整个开发过程中遇到了几个非常典型的错误,都是 C++ 微服务开发里很常见的坑,这里整理出来帮大家避坑。

4.1 日志命名空间找不到的编译错误

现象 :编译时报错 'g_default_logger' was not declared in this scope。

原因 :日志组件定义在 bite_im 命名空间里,但是宏展开后直接用了全局的 g_default_logger,找不到符号。

解决:所有使用日志宏的地方,确保已经引入正确的命名空间,或者在宏定义里就带上命名空间前缀。

4.2 Protobuf Map的const引用问题

现象 :多文件下载测试里,直接用 rsp->file_data()[id] 编译报错,提示丢弃了 const 限定符。

原因 :protobuf 的 Map 的 operator[] 不是 const 成员函数,因为 key 不存在时会插入默认值。const 对象不能调用非 const 方法。

解决 :先把 map 拷贝一份到局部变量,再用下标访问;或者用 find() 方法,find 是 const 的。

4.3 数组越界导致的段错误

现象 :多文件上传测试运行时直接段错误。

原因 :multi_file_id 定义为空 vector,代码里直接用下标 multi_file_id[i] = xxx 赋值,越界访问非法内存。

解决 :改成 push_back() 方式添加元素,不要用下标给空 vector 赋值。

4.4 初始化列表遗漏导致的段错误

现象 :服务启动后 Ctrl+C 退出时出现段错误。

原因 :FileServer 的构造函数初始化列表漏写了 _reg_client 成员,导致智能指针成员没有被正确初始化,析构时访问野指针。

解决:构造函数初始化列表必须覆盖所有成员变量,尤其是智能指针成员,漏初始化就是空悬指针。

4.5 文件散落在运行目录的问题

现象 :上传的文件都散落在程序运行的当前目录,乱糟糟不好管理。

解决 :增加 storage_path 配置项,所有文件统一存到指定目录下,构造服务时自动创建目录,结构清晰很多。

五、核心要点总结

  1. 职责单一的服务优先开发:先做和其他业务交叉少的服务,既能快速跑通微服务全流程,又能沉淀公共组件,逐步搭建整个系统。
  2. 文件名用UUID是最佳实践:不保留原始文件名,既能避免冲突,又能杜绝路径注入漏洞,安全性和可维护性都更好。
  3. 二进制文件读写必须加binary标志:这是新手必踩的坑,少了这个标志,不同平台下都可能出现文件损坏。
  4. Protobuf Map要注意const问题 :operator[] 有副作用,const 对象不能直接用,要么拷贝要么用 find。
  5. 单元测试用md5校验一致性:文件服务的正确性核心就是数据不损坏,上传下载后比对 md5 是最直接有效的验证方式。

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:到这里,文件存储子服务就完整落地了,四个接口全部通过单元测试,数据一致性验证通过。同时我们还沉淀了 UUID 生成器和文件 IO 封装两个公共工具,后面开发其他服务都能直接用。接下来我们就可以开始开发和业务结合更紧密的服务了,比如用户管理子服务,把数据库、缓存、注册登录这些核心业务串起来。后续也会持续更新整个 IM 系统的开发实战,欢迎跟着一起练手。

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど

相关推荐
进击的荆棘1 小时前
MySQL——库的操作
数据库·mysql
huaqianzkh1 小时前
WinForm C#:循环修改控件UI闪烁卡顿?SuspendLayout / ResumeLayout 与 BeginUpdate 优化
开发语言·ui·c#
2601_967980111 小时前
“小程序·云开发”上线 助力开发者的高效利器
数据库·存储管理·小程序云开发·云函数·开发者工具
zhangjw342 小时前
1-9 SELECT 查询基础:where、group by、having、order by、limit offset
数据库·postgresql·pg数据库
wuyk5552 小时前
Spider-第03讲-HTTP速通与读懂响应
网络·爬虫·网络协议·http
唐老板2 小时前
Postgres 慢查询,先取证再动手
数据库·ai编程
笃行3509 小时前
数据库性能优化的五处内核改造,KingbaseES 配了四件诊断工具
数据库
非优秀程序员11 小时前
第04章 邮箱大战,163 与 QQ 的暗战|《互联网产品恩仇录》
java·vue.js·架构
非优秀程序员11 小时前
第02章 杭电的英语老师与一台服务器|《互联网产品恩仇录》
数据库·node.js·代码规范