文章目录
- 微服务即时聊天系统:文件存储模块全链路设计与实现
-
- 一、模块定位与核心需求
-
- [1. 核心功能需求](#1. 核心功能需求)
- [2. 技术栈选型](#2. 技术栈选型)
- 二、服务端核心设计与实现
-
- [1. 接口定义:基于 Protobuf 的契约设计](#1. 接口定义:基于 Protobuf 的契约设计)
-
- [1.1 核心数据结构](#1.1 核心数据结构)
- [1.2 服务接口(FileService)](#1.2 服务接口(FileService))
- [2. 服务实现层:FileServiceImpl 核心逻辑](#2. 服务实现层:FileServiceImpl 核心逻辑)
-
- [2.1 初始化:存储目录检查与创建](#2.1 初始化:存储目录检查与创建)
- [2.2 单个文件上传(PutSingleFile):校验 + UUID 命名](#2.2 单个文件上传(PutSingleFile):校验 + UUID 命名)
- [2.3 批量文件上传(PutMultiFile):原子性保障](#2.3 批量文件上传(PutMultiFile):原子性保障)
- [2.4 文件下载(GetSingleFile/GetMultiFile):快速定位与读取](#2.4 文件下载(GetSingleFile/GetMultiFile):快速定位与读取)
- [3. 服务封装与构建:FileServer 与 FileServerBuilder](#3. 服务封装与构建:FileServer 与 FileServerBuilder)
-
- [3.1 FileServer:服务运行封装](#3.1 FileServer:服务运行封装)
- [3.2 FileServerBuilder:构建器模式实现](#3.2 FileServerBuilder:构建器模式实现)
- [4. 服务启动入口:main 函数与配置解析](#4. 服务启动入口:main 函数与配置解析)
- [三、测试端设计:基于 GTest 的自动化测试](#三、测试端设计:基于 GTest 的自动化测试)
-
- [1. 测试环境初始化](#1. 测试环境初始化)
- [2. 核心测试用例设计](#2. 核心测试用例设计)
-
- [2.1 单文件上传测试(put_file, put_single_file)](#2.1 单文件上传测试(put_file, put_single_file))
- [2.2 多文件上传测试(put_file, put_multi_file)](#2.2 多文件上传测试(put_file, put_multi_file))
- [2.3 单文件下载测试(get_file, get_single_file)](#2.3 单文件下载测试(get_file, get_single_file))
- [2.4 多文件下载测试(get_file, get_multi_file)](#2.4 多文件下载测试(get_file, get_multi_file))
- [3. 测试执行与结果验证](#3. 测试执行与结果验证)
- 四、关键设计亮点与扩展方向
-
- [1. 核心设计](#1. 核心设计)
-
- 一、CMake设计逻辑:模块化与自动化的结合
- 二、CMake核心流程解析
- [3. CMake源码收集:业务代码与测试代码分离](#3. CMake源码收集:业务代码与测试代码分离)
- [4. CMake目标定义与编译:关联源码与依赖](#4. CMake目标定义与编译:关联源码与依赖)
-
- [4.1 定义可执行目标](#4.1 定义可执行目标)
- [4.2 头文件搜索路径配置](#4.2 头文件搜索路径配置)
- [4.3 链接第三方库](#4.3 链接第三方库)
- [5. CMake安装部署:标准化输出路径](#5. CMake安装部署:标准化输出路径)
- [2. 扩展方向](#2. 扩展方向)
- 五、总结
微服务即时聊天系统:文件存储模块全链路设计与实现
在即时聊天系统中,文件存储模块是支撑图片、文档、语音等非文本消息流转的核心基础设施。本文将从需求拆解出发,详细讲解文件存储模块从服务端架构设计、核心逻辑实现到自动化测试验证的全流程设计思路,为微服务场景下的文件管理提供可落地的解决方案。
一、模块定位与核心需求
文件存储模块(File Service)作为即时聊天系统的独立微服务,主要承担文件上传、文件下载、服务注册与发现三大核心职责,需满足高可用、数据一致性、可扩展性三大需求。
1. 核心功能需求
- 文件操作:支持单个 / 批量文件上传(Put)、单个 / 批量文件下载(Get),覆盖聊天场景中常见的文件交互场景。
- 服务治理:集成 Etcd 实现服务注册与发现,确保客户端能动态感知服务节点,支持服务水平扩展。
- 数据安全:上传文件时校验大小一致性,避免损坏文件存入;采用临时文件 + 原子重命名机制,保证文件写入的原子性。
- 可配置化:支持日志模式(调试 / 发布)、存储路径、RPC 线程数、超时时间等参数通过命令行配置,提升部署灵活性。
2. 技术栈选型
| 技术领域 | 选型 | 核心作用 |
|---|---|---|
| 通信框架 | brpc | 高性能 RPC 通信,支撑服务间同步调用 |
| 服务注册发现 | Etcd | 实现服务节点动态注册与客户端发现,支持服务扩容 |
| 数据序列化 | Protobuf | 定义文件服务接口与数据结构,保证跨语言兼容性 |
| 日志系统 | spdlog + butil-log | 支持调试 / 发布双模式日志,便于问题排查 |
| 测试框架 | Google Test(GTest) | 实现自动化单元测试,覆盖核心接口场景 |
二、服务端核心设计与实现
服务端采用分层设计,分为「服务实现层(FileServiceImpl)」「服务封装层(FileServer)」「构建器层(FileServerBuilder)」,通过依赖注入和模块化拆分降低耦合,提升可维护性。
1. 接口定义:基于 Protobuf 的契约设计
首先通过 Protobuf 定义服务接口与数据结构,明确客户端与服务端的通信契约,这是微服务解耦的基础。
1.1 核心数据结构
- FileUploadData:上传文件元信息,包含文件名、文件大小、文件二进制内容。
- FileDownloadData:下载文件数据,包含文件 ID、文件二进制内容。
- FileMessageInfo:文件元信息(用于上传后返回),包含文件 ID、大小、文件名,供客户端后续下载使用。
protobuf
message FileDownloadData {
string file_id = 1;
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;//文件名称
//文件数据,在ES中存储消息的时候只要id和元信息,不要文件数据, 服务端转发的时候也不需要填充
optional bytes file_contents = 4;
}
1.2 服务接口(FileService)
定义 4 个核心 RPC 方法,覆盖单文件 / 多文件的上传与下载:
protobuf
service FileService {
// 单个文件下载
rpc GetSingleFile(GetSingleFileReq) returns (GetSingleFileRsp);
// 批量文件下载
rpc GetMultiFile(GetMultiFileReq) returns (GetMultiFileRsp);
// 单个文件上传
rpc PutSingleFile(PutSingleFileReq) returns (PutSingleFileRsp);
// 批量文件上传
rpc PutMultiFile(PutMultiFileReq) returns (PutMultiFileRsp);
}
- 请求(Req) :均包含
request_id(唯一标识请求,便于链路追踪)、可选的user_id/session_id(预留用户权限校验扩展点)。 - 响应(Rsp) :包含
success(执行结果)、errmsg(错误信息)、业务数据(如文件元信息 / 二进制内容),便于客户端快速判断结果。
protobuf
message GetSingleFileReq {
string request_id = 1;
string file_id = 2;
optional string user_id = 3;
optional string session_id = 4;
}
message GetSingleFileRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
optional FileDownloadData file_data = 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;//文件ID与文件数据的映射map
}
message PutSingleFileReq {
string request_id = 1; //请求ID,作为处理流程唯一标识
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;
}
2. 服务实现层:FileServiceImpl 核心逻辑
FileServiceImpl 是文件服务的核心,负责具体的文件 IO 操作与业务逻辑处理,关键设计集中在文件上传的原子性 和批量操作的失败处理。
2.1 初始化:存储目录检查与创建
构造函数中完成存储目录的初始化,确保服务启动前目录可用,避免运行时文件写入失败:
cpp
FileServiceImpl(const std::string& storage_path) : storage_path_(storage_path) {
umask(0); // 重置文件权限掩码,确保创建的目录权限生效
// 目录不存在则创建,忽略"目录已存在"错误
if (mkdir(storage_path.c_str(), 0775) != 0 && errno != EEXIST) {
LOG_ERROR("创建存储目录{}失败: {}", storage_path, strerror(errno));
throw std::runtime_error("初始化存储目录失败"); // 终止服务启动
}
// 确保路径以"/"结尾,避免后续拼接文件名时出现路径错误
if(!storage_path_.empty() && storage_path_.back() != '/')
storage_path_.push_back('/');
}
2.2 单个文件上传(PutSingleFile):校验 + UUID 命名
核心逻辑:生成唯一文件 ID → 校验文件大小 → 写入文件 → 返回元信息。
- 唯一文件 ID :通过
bite_im::uuid()生成全局唯一 ID,避免文件名冲突(如不同用户上传同名文件)。 - 大小校验 :对比
file_size与file_content.size(),确保客户端上传的文件未损坏。 - 文件写入 :调用工具函数
WriteFile将二进制内容写入存储目录,失败则返回错误信息。
cpp
std::string uuid()
{
//生成一个由16位随机字符组成的字符串作为唯一ID
// 1. 生成6个0~255之间的随机数字(1字节-转换为16进制字符)--生成12位16进制字符
//实例化设备随机数对象-用于生成设备随机数
std::random_device rd;
//以设备随机数为种子,实例化伪随机数对象
std::mt19937 generator(rd());
//限定数据范围
std::uniform_int_distribution<int> distribution(0,255);
std::stringstream ss;
for (int i = 0; i < 6; i++)
{
if (i == 3) ss << "-";
ss << std::setw(2) << std::setfill('0') << std::hex << distribution(generator);
}
ss << "-";
// 2. 通过一个静态变量生成一个2字节的编号数字--生成4位16进制数字字符
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();
}
std::string vcode()
{
std::random_device rd;
std::mt19937 mt(rd());
std::uniform_int_distribution<int> dis(0,9);
std::stringstream ss;
for(int i = 0;i < 4; i++)
{
ss << dis(mt);
}
return ss.str();
}
// 检查文件是否存在且为常规文件
bool FileExists(const std::string& path)
{
struct stat st;
if (stat(path.c_str(), &st) != 0) return false;
return S_ISREG(st.st_mode); // 仅承认常规文件
}
bool ReadFile(const std::string& filepath, std::string& body)
{
try
{
if(!FileExists(filepath))
{
LOG_ERROR("文件-{}-不存在, 不能读取!", filepath);
return false;
}
std::ifstream in(filepath, std::ios::binary | std::ios::in);
if(!in.is_open())
{
LOG_ERROR("打开文件{}失败", filepath);
return false;
}
in.seekg(0, std::ios::end);
size_t flen = in.tellg();
body.resize(flen);
in.seekg(std::ios::beg);
in.read(&body[0], flen);
if(!in.good())
{
LOG_ERROR("读取文件{}数据失败", filepath);
return false;
}
return true;
}
catch (const std::exception& e)
{
LOG_ERROR("读取文件{}失败,原因:{}", filepath, e.what());
}
return false;
}
// 新增:写入临时文件后原子重命名(用于批量操作)
bool WriteFileAtomically(const std::string& filepath, const std::string& body)
{
try
{
std::string tmp_path = filepath + ".tmp";
std::ofstream out(tmp_path, std::ios::binary | std::ios::out | std::ios::trunc);
if(!out.is_open())
{
LOG_ERROR("打开临时文件{}失败", tmp_path);
return false;
}
out.write(&body[0], body.size());
if(!out.good())
{
LOG_ERROR("写入临时文件{}失败", tmp_path);
std::remove(tmp_path.c_str());
return false;
}
out.close();
// 原子重命名(保证文件完整性)
if (std::rename(tmp_path.c_str(), filepath.c_str()) != 0)
{
LOG_ERROR("重命名文件{}->{}失败", tmp_path, filepath);
std::remove(tmp_path.c_str());
return false;
}
return true;
}
catch (const std::exception& e)
{
LOG_ERROR("写入文件{}失败,原因:{}", filepath, e.what());
}
return false;
}
bool WriteFile(const std::string& filepath, const std::string& body)
{
return WriteFileAtomically(filepath, body);
}
2.3 批量文件上传(PutMultiFile):原子性保障
批量上传需保证 "要么全部成功,要么全部失败",避免部分文件上传成功导致的数据不一致,核心设计是临时文件 + 原子重命名:
- 临时文件写入 :每个文件先写入
.tmp后缀的临时文件(如xxx.tmp),避免未写完的文件被客户端读取。 - 批量重命名 :所有临时文件写入成功后,通过
std::rename将.tmp文件原子重命名为最终文件(如xxx)。 - 失败回滚:若任一文件写入或重命名失败,删除已创建的临时文件和已重命名的文件,确保数据一致性。
2.4 文件下载(GetSingleFile/GetMultiFile):快速定位与读取
- 单文件下载 :通过 "存储路径 + 文件 ID" 拼接文件路径,调用
ReadFile读取二进制内容,不存在则返回 "文件不存在" 错误。 - 批量文件下载 :遍历
file_id_list,逐个读取文件;若任一文件失败,立即返回错误,避免客户端等待过长时间。
3. 服务封装与构建:FileServer 与 FileServerBuilder
为简化服务初始化流程、提升可配置性,采用Builder 模式封装服务创建过程,将 "服务注册""RPC 服务启动""参数配置" 解耦。
3.1 FileServer:服务运行封装
负责启动 RPC 服务并阻塞等待退出信号,内部持有 Etcd 注册器(regs_)和 brpc 服务器(brpcServer_)的指针,屏蔽底层细节:
cpp
void start() {
brpcServer_->RunUntilAskedToQuit(); // 阻塞运行,直到收到退出信号(如Ctrl+C)
}
3.2 FileServerBuilder:构建器模式实现
提供链式调用接口,支持配置存储路径、Etcd TTL 等参数,分步骤初始化服务:
- 参数配置 :通过
set_storage_path、set_etcd_ttl设置核心参数,默认 Etcd TTL 为 10 秒。 - Etcd 注册(make_regs_object) :将服务地址(
access_host)注册到 Etcd,失败则终止服务(std::abort())。 - RPC 服务初始化(make_rpc_object) :创建
FileServiceImpl实例 → 添加到 brpc 服务器 → 启动 RPC 监听端口。 - 服务构建(build) :校验 Etcd 注册器和 RPC 服务是否初始化完成,返回
FileServer实例。
Builder 模式的优势:
- 隐藏复杂的初始化流程,客户端只需链式调用即可创建服务。
- 便于扩展新参数(如后续添加 SSL 配置),无需修改已有代码。
cpp
class FileServerBuilder
{
public:
FileServerBuilder() = default;
// 新增:设置存储路径
FileServerBuilder& set_storage_path(const std::string& path)
{
storage_path_ = path;
return *this;
}
// 新增:设置Etcd TTL
FileServerBuilder& set_etcd_ttl(int ttl)
{
etcd_ttl_ = ttl;
return *this;
}
void make_regs_object(const std::string& reghost
, const std::string &service_name
, const std::string &access_host)
{
regs_ = std::make_shared<Etcd_Tool::RegisterEtcd>(reghost);
Etcd_Tool::resinfo_t resinfo;
resinfo.service_name_ = service_name;
resinfo.service_addr_ = access_host;
resinfo.ttl_ = etcd_ttl_; // 使用配置的TTL
// 检查注册结果
if (!regs_->Register(resinfo))
{
LOG_ERROR("Etcd服务注册失败");
std::abort();
}
}
void make_rpc_object(in_port_t port
, int timeout_sec
, size_t threadnums)
{
if (storage_path_.empty())
{
LOG_ERROR("未设置存储路径");
std::abort();
}
brpcServer_ = std::make_shared<brpc::Server>();
brpc::ServerOptions op;
op.idle_timeout_sec = timeout_sec;
op.num_threads = threadnums;
// 使用配置的存储路径
FileServiceImpl *File_service = new FileServiceImpl(storage_path_);
if (brpcServer_->AddService(File_service, brpc::ServiceOwnership::SERVER_OWNS_SERVICE) != 0)
{
LOG_ERROR("添加Rpc服务失败!");
delete File_service; // 避免泄漏
std::abort();
}
if (brpcServer_->Start(port, &op) != 0)
{
LOG_ERROR("启动服务失败!");
std::abort();
}
}
std::shared_ptr<FileServer> build()
{
if(!regs_)
{
LOG_ERROR("Etcd注册器未初始化!");
std::abort();
}
if(!brpcServer_)
{
LOG_ERROR("brpc服务未初始化!");
std::abort();
}
return std::make_shared<FileServer>(regs_, brpcServer_);
}
private:
std::shared_ptr<Etcd_Tool::RegisterEtcd> regs_;
std::shared_ptr<brpc::Server> brpcServer_;
std::string storage_path_; // 存储路径配置
int etcd_ttl_ = 10; // 默认TTL
};
4. 服务启动入口:main 函数与配置解析
main 函数负责解析命令行参数、初始化日志、创建服务并启动,核心流程如下:
- 参数解析:使用 gflags 解析命令行参数(如 Etcd 地址、RPC 端口、日志模式)。
- 日志初始化 :调用
LogModule::Log::Init初始化日志,支持调试模式(控制台输出)和发布模式(文件输出)。 - 服务创建:通过 Builder 模式配置存储路径、注册 Etcd、启动 RPC 服务。
- 服务运行 :调用
server->start()阻塞运行,直到收到退出信号。
cpp
int main(int argc, char* argv[])
{
gflags::ParseCommandLineFlags(&argc, &argv, true);
logging::SetMinLogLevel(4);
LogModule::Log::Init(FLAGS_log_mode, FLAGS_log_file, FLAGS_log_level);
bite_im::FileServerBuilder builder;
builder.set_storage_path("/home/person/study/Project1/MicrCommSys/filestorage");
builder.make_regs_object(FLAGS_registry_host
, FLAGS_base_service + FLAGS_instance_name
, FLAGS_access_host);
builder.make_rpc_object(FLAGS_rpc_listen_port, FLAGS_rpc_timeout, FLAGS_rpc_threads);
std::shared_ptr<bite_im::FileServer> server = builder.build();
server->start();
return 0;
}
关键配置参数示例:
cpp
// Etcd配置
DEFINE_string(registry_host, "http://127.0.0.1:2379", "etcd服务注册中心地址");
// RPC配置
DEFINE_int32(rpc_listen_port, 10002, "Rpc服务器监听端口");
DEFINE_int32(rpc_threads, 1, "Rpc的IO线程数量");
// 日志配置
DEFINE_bool(log_mode, false, "日志模式: false(调试模式), true(发布模式)");
三、测试端设计:基于 GTest 的自动化测试
为确保文件服务的正确性,设计覆盖核心接口的自动化测试用例,采用客户端视角模拟真实调用场景,验证上传、下载功能的正确性。
1. 测试环境初始化
测试前需完成 Etcd 服务发现、RPC 信道管理、日志初始化,确保测试用例能正常调用服务端:
- Etcd 监视器 :通过
Etcd_Tool::MonitorEtcd监听文件服务节点变化,当服务节点上线 / 下线时,更新客户端的 RPC 信道列表。 - RPC 信道管理 :
brpcChannelTool::ServiceManager管理 RPC 信道,通过Choose方法随机选择一个可用的服务节点,实现负载均衡。 - 日志初始化:与服务端一致,支持调试 / 发布模式,便于测试过程中的问题排查。
初始化核心代码:
cpp
// 初始化Etcd监视器,监听文件服务节点变化
Etcd_Tool::monitor_t info;
info.monitor_path_ = FLAGS_file_host; // 文件服务在Etcd中的路径
info.put_on_ = std::bind(&brpcChannelTool::ServiceManager::Online, manager.get(), std::placeholders::_1, std::placeholders::_2);
info.put_off_ = std::bind(&brpcChannelTool::ServiceManager::Offline, manager.get(), std::placeholders::_1, std::placeholders::_2);
m.PushMonitor(info);
// 等待初始服务发现完成,确保测试用例有可用节点
if (!m.WaitForInitialDiscovery()) {
LOG_ERROR("服务发现初始化失败");
return -1;
}
2. 核心测试用例设计
采用 GTest 的TEST宏定义测试用例,覆盖 "单文件上传""多文件上传""单文件下载""多文件下载" 四大场景,每个用例包含 "调用前准备→发起 RPC 调用→结果断言" 三步。
2.1 单文件上传测试(put_file, put_single_file)
- 测试步骤 :
- 读取本地文件(如
Makefile)作为上传内容。 - 构造
PutSingleFileReq,设置request_id、文件元信息与内容。 - 发起 RPC 调用,断言响应
success为true,并记录返回的file_id(供后续下载测试使用)。
- 读取本地文件(如
- 关键断言 :
- 响应的
request_id与请求一致(确保请求未被混淆)。 - 响应
success为true(上传成功)。 - 返回的
file_id非空(生成了唯一文件 ID)。
- 响应的
2.2 多文件上传测试(put_file, put_multi_file)
- 测试步骤 :
- 读取多个本地文件(如
base.pb.h、base.pb.cc)。 - 构造
PutMultiFileReq,添加多个FileUploadData。 - 发起 RPC 调用,断言响应
success为true,并记录所有file_id。
- 读取多个本地文件(如
- 关键断言 :
- 响应的
file_info_size与上传文件数量一致(所有文件均上传成功)。
- 响应的
2.3 单文件下载测试(get_file, get_single_file)
- 测试步骤:
- 使用单文件上传测试中记录的
file_id,构造GetSingleFileReq。 - 发起 RPC 调用,断言响应
success为true。 - 将下载的
file_content写入本地文件(如3333.txt),验证文件内容正确性(可选)。
- 使用单文件上传测试中记录的
2.4 多文件下载测试(get_file, get_multi_file)
- 测试步骤 :
- 使用多文件上传测试中记录的
file_id列表,构造GetMultiFileReq。 - 发起 RPC 调用,断言响应
success为true。 - 遍历下载的文件内容,分别写入本地文件(如
4444.txt1、4444.txt2)。
- 使用多文件上传测试中记录的
- 关键断言 :
- 响应的
file_data大小与请求的file_id_list大小一致(所有文件均下载成功)。
- 响应的
cpp
//测试单个上传文件
TEST(put_file, put_single_file)
{
//选择信道
brpcChannelTool::channelptr ptr = manager->Choose(FLAGS_file_host);
ASSERT_TRUE(ptr != NULL);
//实例化文件rpc服务
bite_im::FileService_Stub stub(ptr.get());
//请求
bite_im::PutSingleFileReq req;
std::string rqid = "1111";
req.set_request_id(rqid); //请求id
req.mutable_file_data()->set_file_name("Makefile"); //请求文件名
std::string data;
ASSERT_TRUE(bite_im::ReadFile("./Makefile", data));
req.mutable_file_data()->set_file_size(data.size()); //文件大小
req.mutable_file_data()->set_file_content(data.c_str()); //文件内容
//响应
bite_im::PutSingleFileRsp rsp;
//发起rpc远程调用
brpc::Controller ctl;
stub.PutSingleFile(&ctl, &req, &rsp, nullptr);
ASSERT_EQ(rsp.success(), true);
ASSERT_EQ(rsp.request_id(), rqid);
LOG_INFO("单个文件上传请求调用成功,请求id:{}", rsp.request_id());
fileid.push_back(rsp.file_info().file_id());
}
//测试多个上传文件
TEST(put_file, put_multi_file)
{
//选择信道
brpcChannelTool::channelptr ptr = manager->Choose(FLAGS_file_host);
ASSERT_TRUE(ptr != NULL);
//实例化文件rpc服务
bite_im::FileService_Stub stub(ptr.get());
//请求
bite_im::PutMultiFileReq req;
std::string rqid = "2222";
req.set_request_id(rqid); //请求id
//复数请求设置
bite_im::FileUploadData* fupdata = req.add_file_data();
std::string data;
ASSERT_TRUE(bite_im::ReadFile("base.pb.h", data));
fupdata->set_file_size(data.size()); //文件大小
fupdata->set_file_content(data.c_str()); //文件内容
bite_im::FileUploadData* fupdata2 = req.add_file_data();
std::string data2;
ASSERT_TRUE(bite_im::ReadFile("base.pb.cc", data2));
fupdata2->set_file_size(data2.size()); //文件大小
fupdata2->set_file_content(data2.c_str()); //文件内容
//响应
bite_im::PutMultiFileRsp rsp;
//发起rpc远程调用
brpc::Controller ctl;
stub.PutMultiFile(&ctl, &req, &rsp, nullptr);
ASSERT_EQ(rsp.success(), true);
ASSERT_EQ(rsp.request_id(), rqid);
LOG_INFO("复数文件上传请求调用成功,请求id:{}", rsp.request_id());
for (size_t i = 0; i < rsp.file_info_size(); i++)
{
fileid.push_back(rsp.file_info(i).file_id());
}
}
TEST(get_file, get_single_file)
{
brpcChannelTool::channelptr ptr = manager->Choose(FLAGS_file_host);
ASSERT_TRUE(ptr != nullptr);
bite_im::FileService_Stub stub(ptr.get());
//获取单个文件的请求
bite_im::GetSingleFileReq req;
ASSERT_TRUE(fileid.empty() != true);
req.set_file_id(fileid[0]);
std::string rqid = "3333";
req.set_request_id(rqid);
//回复
bite_im::GetSingleFileRsp rsp;
brpc::Controller ctl;
stub.GetSingleFile(&ctl, &req, &rsp, nullptr);
ASSERT_TRUE(ctl.Failed() == false);
ASSERT_TRUE(rsp.success());
ASSERT_TRUE(bite_im::WriteFile("./3333.txt", rsp.file_data().file_content()));
LOG_INFO("单个文件获取请求调用成功,请求id:{}", rsp.request_id());
}
TEST(get_file, get_multi_file)
{
brpcChannelTool::channelptr ptr = manager->Choose(FLAGS_file_host);
ASSERT_TRUE(ptr != nullptr);
bite_im::FileService_Stub stub(ptr.get());
//获取单个文件的请求
bite_im::GetMultiFileReq req;
ASSERT_TRUE(fileid.empty() != true);
std::string rqid = "4444";
req.set_request_id(rqid);
ASSERT_TRUE(fileid.size() >= 3);
req.add_file_id_list(fileid[1]);
req.add_file_id_list(fileid[2]);
//回复
bite_im::GetMultiFileRsp rsp;
brpc::Controller ctl;
stub.GetMultiFile(&ctl, &req, &rsp, nullptr);
ASSERT_TRUE(ctl.Failed() == false);
ASSERT_TRUE(rsp.success());
int i = 1;
for (auto it = rsp.mutable_file_data()->begin();
it != rsp.mutable_file_data()->end();
it++)
{
ASSERT_TRUE(bite_im::WriteFile("./4444.txt" + to_string(i), it->second.file_content()));
i++;
}
LOG_INFO("单个文件获取请求调用成功,请求id:{}", rsp.request_id());
}
3. 测试执行与结果验证
运行测试程序时,GTest 会自动执行所有测试用例,并输出每个用例的执行结果(成功 / 失败)。若用例失败,可通过日志查看具体错误信息(如 "文件不存在""RPC 调用超时"),快速定位问题。
测试执行命令示例:
bash
./testClient --registry_host=http://127.0.0.1:2379 --file_host=/service/file_service
四、关键设计亮点与扩展方向
1. 核心设计
- 原子性保障:批量上传采用 "临时文件 + 原子重命名",避免部分文件上传成功导致的数据不一致。
- 可配置化与可扩展性 :通过 Builder 模式和 gflags 支持参数配置,便于不同环境部署;预留
user_id/session_id字段,支持后续添加权限校验。 - 服务治理:集成 Etcd 实现服务注册与发现,支持服务水平扩展,提升系统可用性。
- CMakeLists.txt:快速编译和链接,CMakeLists.txt 的设计是构建流程的核心,它负责将源代码、第三方依赖、Protobuf 自动生成代码等要素串联起来,最终生成可执行程序。以下从设计逻辑、核心流程和关键技术点三个维度,详细解析该 CMakeLists.txt 的设计思路。
一、CMake设计逻辑:模块化与自动化的结合
该 CMakeLists 的核心设计逻辑是 "模块化拆分 + 自动化处理" ,通过明确的步骤将 "代码生成""源码收集""编译链接""安装部署" 拆分为独立环节,同时通过 CMake 的自动化指令(如add_custom_command)减少人工干预,确保构建过程的可靠性和可扩展性。
具体表现为:
- 先处理依赖生成 :优先处理 Protobuf 代码生成,确保业务代码编译时能引用到最新的
.pb.h和.pb.cc文件。 - 再整合源码与依赖:将手动编写的业务源码、自动生成的 Protobuf 源码、第三方库依赖统一管理,避免编译时出现 "找不到文件" 或 "链接失败" 的问题。
- 最后标准化部署 :通过
INSTALL指令定义可执行程序的安装路径,确保部署流程一致。
二、CMake核心流程解析
1. 基础配置:指定 CMake 版本与工程名称
cmake
cmake_minimum_required(VERSION 3.1.3) # 声明最低支持的CMake版本,确保兼容性
project(fileServer) # 定义工程名称,用于后续目标命名和日志输出
- 作用:CMake 版本决定了可用的指令集(如
add_custom_command的PRE_BUILD参数在低版本可能不支持),工程名称则作为构建过程的标识。
2. Protobuf 代码自动生成:核心依赖处理
Protobuf 是微服务通信的基础(定义接口和数据结构),但.proto文件需要编译为 C++ 代码才能被业务逻辑引用。这一步是整个 CMakeLists 的核心难点,需要确保:
.proto文件更新时,自动重新生成.pb.h和.pb.cc。- 生成的代码能被后续编译流程正确引用。
关键实现步骤:
cmake
# 1. 定义proto文件路径和文件名
set(proto_path ${CMAKE_CURRENT_SOURCE_DIR}/../proto) # .proto文件所在目录
set(proto_files file.proto base.proto) # 需要处理的proto文件列表
# 2. 初始化变量,用于收集生成的.cc文件
set(proto_srcs "")
# 3. 遍历每个proto文件,生成对应的C++代码
foreach(proto_file ${proto_files})
# 生成对应的.h和.cc文件名(如file.proto → file.pb.h、file.pb.cc)
string(REPLACE ".proto" ".pb.h" proto_h ${proto_file})
string(REPLACE ".proto" ".pb.cc" proto_cc ${proto_file})
# 若目标文件不存在,或.proto文件更新过,则触发生成指令
if (NOT EXISTS ${CMAKE_CURRENT_BINARY_DIR}/${proto_cc})
add_custom_command(
PRE_BUILD # 在构建目标前执行(确保代码生成在编译前完成)
COMMAND protoc # 调用protoc编译器
ARGS
--cpp_out=${CMAKE_CURRENT_BINARY_DIR} # 生成代码的输出目录(build目录)
-I ${proto_path} # 指定proto文件的搜索目录
--experimental_allow_proto3_optional # 支持proto3的optional字段(兼容旧版本protoc)
${proto_path}/${proto_file} # 输入的proto文件路径
DEPENDS ${proto_path}/${proto_file} # 依赖:当.proto文件修改时,重新生成
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${proto_cc} # 生成的目标文件
COMMENT "生成proto框架代码文件: ${CMAKE_CURRENT_BINARY_DIR}/${proto_cc}" # 构建日志
)
endif()
# 将生成的.cc文件加入源码列表,供后续编译
list(APPEND proto_srcs ${CMAKE_CURRENT_BINARY_DIR}/${proto_cc})
endforeach()
设计亮点:
- 条件生成 :通过
if (NOT EXISTS ...)判断是否需要生成代码,避免重复执行protoc,提升构建效率。 - 依赖追踪 :
DEPENDS指令确保.proto文件修改后自动重新生成代码,解决 "代码与 proto 不同步" 的问题。 - 路径规范 :生成的代码统一输出到
CMAKE_CURRENT_BINARY_DIR(构建目录),避免污染源码目录。
3. CMake源码收集:业务代码与测试代码分离
为区分 "服务端程序" 和 "测试程序",分别收集对应的源码文件:
cmake
# 收集服务端业务源码(source目录下的所有.cpp文件)
set(src_files "")
aux_source_directory(${CMAKE_CURRENT_SOURCE_DIR}/source src_files)
# 收集测试代码(test目录下的所有.cpp文件)
set(test_src_files "")
aux_source_directory(${CMAKE_CURRENT_SOURCE_DIR}/test test_src_files)
aux_source_directory:自动递归收集指定目录下的所有源文件,避免手动列举文件名(减少维护成本)。- 分离的好处:服务端程序和测试程序可独立编译,测试代码的修改不会影响服务端程序的构建。
4. CMake目标定义与编译:关联源码与依赖
4.1 定义可执行目标
cmake
set(target "fileServer") # 服务端程序目标名
set(test_client "testClient") # 测试程序目标名
# 生成服务端程序:依赖业务源码和proto生成的代码
add_executable(${target} ${src_files} ${proto_srcs})
# 生成测试程序:依赖测试源码和proto生成的代码
add_executable(${test_client} ${test_src_files} ${proto_srcs})
- 核心:将手动编写的
src_files和自动生成的proto_srcs关联到目标,确保编译时能找到所有代码。
4.2 头文件搜索路径配置
源码中存在大量#include语句(如引用etcdTool.hpp、file.pb.h),需要告诉 CMake 头文件的位置:
cmake
include_directories(
${CMAKE_CURRENT_SOURCE_DIR}/source # 服务端源码的头文件目录
${CMAKE_CURRENT_BINARY_DIR} # proto生成的.pb.h文件目录(构建目录)
${CMAKE_CURRENT_SOURCE_DIR}/../common # 公共工具类的头文件目录(如etcdTool.hpp)
)
- 作用:解决编译时 "找不到头文件" 的错误,确保
#include "file.pb.h"等语句能正确定位文件。
4.3 链接第三方库
文件存储模块依赖大量第三方库(如 brpc、Protobuf、Etcd 客户端等),需要在链接阶段指定:
cmake
# 服务端程序链接的库
target_link_libraries(${target}
-lprotobuf # Protobuf库(序列化/反序列化)
-lgtest # 测试框架(服务端可能包含内部测试)
-lgflags # 命令行参数解析
-lspdlog # 日志库
-lbrpc # RPC通信框架
-lpthread # 多线程支持
-lssl -lcrypto # 加密库(brpc依赖)
-letcd-cpp-api # Etcd客户端库
-lcpprest # HTTP客户端(Etcd依赖)
-lcurl # 网络请求库
-lfmt # 格式化库
-ljsoncpp # JSON解析库
-L/usr/local/lib # 第三方库的安装路径(如brpc、etcd-cpp-api通常安装在这里)
)
# 测试程序链接的库(与服务端相同,因为测试也需要调用RPC和Etcd)
target_link_libraries(${test_client} ...) # 同上
- 设计逻辑:根据模块依赖的技术栈(见前文 "技术栈选型"),逐一列出所需库,确保链接时能找到对应的
.so或.a文件。 - 注意:
-L/usr/local/lib指定了非系统默认路径的库位置,避免因库安装路径特殊导致链接失败。
5. CMake安装部署:标准化输出路径
通过INSTALL指令定义程序安装位置,便于后续部署和运维:
cmake
INSTALL(TARGETS ${target} RUNTIME DESTINATION bin) # 服务端程序安装到bin目录
INSTALL(TARGETS ${test_client} RUNTIME DESTINATION bin) # 测试程序安装到bin目录
- 作用:执行
make install时,可将生成的fileServer和testClient复制到系统的bin目录(如/usr/local/bin),便于全局调用。
2. 扩展方向
- 文件分片上传:当前设计仅支持小文件(依赖 brpc 单次请求大小限制),后续可添加分片上传功能,支持 GB 级大文件。
- 文件过期清理:添加定时任务,根据文件元信息中的过期时间(需扩展 Protobuf 字段)清理过期文件,释放存储空间。
- 权限校验 :基于
user_id/session_id添加用户权限校验,确保用户只能下载自己有权访问的文件。 - 分布式存储:当前使用本地文件系统存储,后续可接入 MinIO、S3 等分布式存储,提升存储容量和可用性。
五、总结
文件存储模块作为即时聊天系统的核心微服务,通过 "Protobuf 接口定义→分层服务实现→Builder 模式封装→GTest 自动化测试" 的全链路设计,确保了功能正确性、可扩展性和可维护性。本文的设计思路不仅适用于即时聊天系统,也可迁移到其他需要文件管理的微服务场景,为同类系统的开发提供参考。