文章目录
- 微服务即时通讯系统:用户子服务实现逻辑全解析
-
- 一、子服务核心定位与技术选型
-
- [1. 核心定位](#1. 核心定位)
-
- [1. 用户注册(用户名 + 密码)](#1. 用户注册(用户名 + 密码))
- [2. 用户登录(用户名 + 密码)](#2. 用户登录(用户名 + 密码))
- [3. 获取短信验证码](#3. 获取短信验证码)
- [4. 手机号注册](#4. 手机号注册)
- [5. 手机号登录](#5. 手机号登录)
- [6. 获取用户信息](#6. 获取用户信息)
- [7. 设置头像](#7. 设置头像)
- [8. 设置昵称](#8. 设置昵称)
- [9. 设置签名](#9. 设置签名)
- [10. 设置绑定手机号](#10. 设置绑定手机号)
- [2. 关键技术选型](#2. 关键技术选型)
- 二、数据模型设计
-
- (一)MySQL:用户核心数据持久化实现
-
- [1. 数据模型映射(ODB ORM 实现)](#1. 数据模型映射(ODB ORM 实现))
- [2. 核心数据操作接口](#2. 核心数据操作接口)
- [3. 业务场景联动](#3. 业务场景联动)
- (二)Redis:高性能缓存与状态管理
-
- [1. 缓存数据结构设计](#1. 缓存数据结构设计)
- [2. 核心缓存操作封装](#2. 核心缓存操作封装)
- [3. 业务场景联动](#3. 业务场景联动)
- (三)Elasticsearch:用户搜索索引管理
-
- [1. 索引结构设计](#1. 索引结构设计)
- [2. 核心索引操作封装](#2. 核心索引操作封装)
- [3. 业务场景联动](#3. 业务场景联动)
- 三、核心功能实现流程
-
- [1. 用户注册(用户名 + 密码)](#1. 用户注册(用户名 + 密码))
- [2. 用户登录(双模式支持)](#2. 用户登录(双模式支持))
-
- [(1)用户名 + 密码登录](#(1)用户名 + 密码登录)
- [(2)手机号 + 验证码登录](#(2)手机号 + 验证码登录)
- [3. 用户信息管理(头像 / 昵称 / 签名 / 手机号修改)](#3. 用户信息管理(头像 / 昵称 / 签名 / 手机号修改))
- [4. 用户信息查询(单用户 / 批量用户)](#4. 用户信息查询(单用户 / 批量用户))
- 四、跨服务交互设计
- 五、高可用与安全性设计
- 六、总结
- 服务端整体实现与设计架构
-
- (一)核心设计理念
- [(二)核心配置定义(gflags 实现)](#(二)核心配置定义(gflags 实现))
-
- [1. 配置项分类与说明](#1. 配置项分类与说明)
- [2. 配置项核心代码](#2. 配置项核心代码)
- [(三)服务初始化流程(Builder 模式)](#(三)服务初始化流程(Builder 模式))
-
- [1. 初始化步骤拆解](#1. 初始化步骤拆解)
- [2. Builder 模式核心优势](#2. Builder 模式核心优势)
- (四)核心组件初始化细节
-
- [1. 日志模块初始化](#1. 日志模块初始化)
- [2. 依赖组件初始化(MySQL/Redis/ES)](#2. 依赖组件初始化(MySQL/Redis/ES))
- [3. 服务注册与发现(etcd)](#3. 服务注册与发现(etcd))
- [4. RPC 服务初始化](#4. RPC 服务初始化)
- (五)服务启动与运行
-
- [1. 服务启动](#1. 服务启动)
- [2. 运行时特性](#2. 运行时特性)
- 客户端测试用例设计与实现
-
- (一)测试框架与核心依赖
-
- [1. 技术选型](#1. 技术选型)
- [2. 核心配置定义](#2. 核心配置定义)
- (二)测试环境初始化流程
- (三)核心测试用例设计
-
- [1. 注册模块测试(用户名 + 手机号)](#1. 注册模块测试(用户名 + 手机号))
- [2. 登录模块测试(用户名 + 手机号)](#2. 登录模块测试(用户名 + 手机号))
- [3. 用户信息修改模块测试](#3. 用户信息修改模块测试)
- [4. 用户信息查询模块测试](#4. 用户信息查询模块测试)
- (四)测试用例设计亮点
- (五)测试执行与结果验证
微服务即时通讯系统:用户子服务实现逻辑全解析
在即时通讯(IM)系统中,用户子服务是整个架构的核心基础,负责用户身份管理、认证授权、信息维护等核心能力。本文将详细拆解基于 C++ 技术栈构建的用户子服务实现逻辑,包括核心功能设计、技术选型、数据流转流程及关键接口实现细节。
一、子服务核心定位与技术选型
1. 核心定位
用户子服务的核心能力通过 9 个核心接口落地,每个接口均遵循「参数校验 - 业务处理 - 数据持久化 - 响应反馈」的标准化流程,确保逻辑严谨性与数据一致性。
1. 用户注册(用户名 + 密码)
- 从请求中提取昵称和密码参数;
- 昵称合法性校验:仅支持字母、数字、连字符(-)、下划线(_),长度限制 3~15 位;
- 密码合法性校验:仅支持字母、数字,长度限制 6~15 位;
- 唯一性校验:通过昵称查询 MySQL 数据库,判断是否已被注册;
- 数据持久化:向 MySQL 新增用户记录(生成唯一用户 ID);
- 索引同步:向 Elasticsearch(ES)新增用户核心信息(用户 ID、昵称),支撑搜索功能;
- 结果响应:返回注册成功 / 失败状态及对应错误信息。
2. 用户登录(用户名 + 密码)
- 从请求中提取昵称和密码参数;
- 身份校验:通过昵称查询 MySQL 获取用户信息,比对密码一致性;
- 登录态校验:查询 Redis 中用户登录状态标记,避免重复登录;
- 会话创建:生成唯一会话 ID(UUID);
- 缓存更新:向 Redis 添加「会话 ID - 用户 ID」映射及「用户 ID - 登录状态」标记;
- 结果响应:返回登录成功状态及会话 ID(后续接口鉴权凭证)。
3. 获取短信验证码
- 从请求中提取手机号码参数;
- 手机号格式校验:必须以 1 开头,第二位为 3~9,后续为 9 位数字(共 11 位);
- 验证码生成:随机生成 4 位数字验证码;
- 短信发送:通过短信平台 SDK 向目标手机号发送验证码;
- 缓存存储:生成唯一验证码 ID,将「验证码 ID - 验证码」映射存入 Redis(设置 60 秒过期);
- 结果响应:返回验证码 ID(用于后续注册 / 登录校验)。
4. 手机号注册
- 从请求中提取手机号码、验证码 ID 及验证码参数;
- 手机号格式校验:遵循 11 位标准手机号规则;
- 验证码校验:通过验证码 ID 查询 Redis,比对验证码一致性(校验后删除该记录);
- 唯一性校验:通过手机号查询 MySQL,判断是否已被注册;
- 数据持久化:向 MySQL 新增用户记录(生成唯一用户 ID);
- 索引同步:向 ES 新增用户核心信息(用户 ID、手机号);
- 结果响应:返回注册成功 / 失败状态。
5. 手机号登录
- 从请求中提取手机号码、验证码 ID 及验证码参数;
- 手机号格式校验:遵循 11 位标准手机号规则;
- 验证码校验:通过验证码 ID 查询 Redis,比对验证码一致性(校验后删除该记录);
- 用户校验:通过手机号查询 MySQL,确认用户已注册;
- 登录态校验:查询 Redis 中用户登录状态标记,避免重复登录;
- 会话创建:生成唯一会话 ID,向 Redis 添加会话映射及登录状态标记;
- 结果响应:返回登录成功状态及会话 ID。
6. 获取用户信息
- 从请求中提取用户 ID 参数;
- 基础信息查询:通过用户 ID 查询 MySQL,获取昵称、手机号、签名、头像文件 ID 等基础信息;
- 头像数据获取:若存在头像文件 ID,调用文件服务获取头像二进制数据;
- 数据组装:整合基础信息与头像数据,构建完整用户信息;
- 结果响应:返回完整用户信息。
7. 设置头像
- 从请求中提取用户 ID 及头像二进制数据;
- 用户校验:通过用户 ID 查询 MySQL,确认用户存在;
- 头像上传:调用文件服务上传头像数据,获取文件唯一 ID;
- 数据库更新:将新的头像文件 ID 更新至 MySQL 用户记录;
- 索引同步:更新 ES 中用户头像关联信息;
- 结果响应:返回更新成功 / 失败状态。
8. 设置昵称
- 从请求中提取用户 ID 及新昵称参数;
- 昵称合法性校验:遵循「3~15 位、支持字母 / 数字 /-/_」规则;
- 用户校验:通过用户 ID 查询 MySQL,确认用户存在;
- 数据库更新:将新昵称更新至 MySQL 用户记录;
- 索引同步:更新 ES 中用户昵称信息(保障搜索准确性);
- 结果响应:返回更新成功 / 失败状态。
9. 设置签名
- 从请求中提取用户 ID 及新签名参数;
- 用户校验:通过用户 ID 查询 MySQL,确认用户存在;
- 数据库更新:将新签名更新至 MySQL 用户记录;
- 索引同步:更新 ES 中用户签名关联信息;
- 结果响应:返回更新成功 / 失败状态。
10. 设置绑定手机号
- 从请求中提取用户 ID、新手机号、验证码 ID 及验证码参数;
- 手机号格式校验:遵循 11 位标准手机号规则;
- 验证码校验:通过验证码 ID 查询 Redis,比对验证码一致性(校验后删除该记录);
- 唯一性校验:通过新手机号查询 MySQL,判断是否已被其他用户绑定;
- 数据库更新:将新手机号更新至 MySQL 用户记录;
- 索引同步:更新 ES 中用户手机号信息;
- 结果响应:返回更新成功 / 失败状态。
2. 关键技术选型
为满足高可用、高性能、可扩展的设计目标,技术栈选型如下:
| 模块功能 | 技术框架 / 工具 | 核心作用 |
|---|---|---|
| RPC 通信 | brpc | 提供高性能 RPC 服务,支撑跨服务调用 |
| 配置解析 | gflags | 统一管理服务配置参数 |
| 日志输出 | spdlog | 高性能日志记录,便于问题排查 |
| 服务注册发现 | etcd | 实现服务注册、健康检查与服务发现 |
| 关系型存储 | MySQL + ODB | 持久化存储用户核心信息(结构化数据) |
| 缓存存储 | Redis + redis++ | 存储登录会话、验证码、登录状态(高性能读写) |
| 搜索引擎 | Elasticsearch | 支持用户信息模糊搜索(分词索引提升查询效率) |
| 数据校验 | 自定义工具类 | 实现用户名、密码、手机号格式校验 |
二、数据模型设计
(一)MySQL:用户核心数据持久化实现
MySQL 作为关系型数据库,承担用户核心信息的持久化存储职责,所有用户注册、信息修改操作均需通过 MySQL 完成数据落地,核心设计围绕「结构化存储 + 唯一性约束 + CRUD 标准化」展开。
1. 数据模型映射(ODB ORM 实现)
通过 ODB 框架将 C++ 类与 MySQL 表结构关联,确保数据操作的类型安全与便捷性,核心映射代码如下:
cpp
#pragma once
#include <odb/core.hxx>
#include <odb/forward.hxx>
#include <odb/nullable.hxx>
#include <odb/database.hxx>
#include <string>
namespace bite_im
{
#pragma db object table("user")
class User
{
friend class odb::access;
public:
User()
{}
User(const std::string& uid
,const std::string& nickname
,const std::string& password)
: user_id_(uid)
, nickname_(nickname)
, password_(password)
{}
User(const std::string& uid
,const std::string& phone)
: user_id_(uid)
, phone_(phone)
,nickname_(uid)
{}
unsigned long id()
{
return id_;
}
//用户id获取与修改
std::string user_id()
{
if(user_id_)
return *user_id_;
return std::string();
}
void user_id(const std::string& val)
{
user_id_ = val;
}
//用户昵称获取与修改
std::string nickname()
{
if(nickname_)
return *nickname_;
return std::string();
}
void nickname(const std::string& val)
{
nickname_ = val;
}
//用户签名获取与修改
std::string description()
{
if(description_)
return *description_;
return std::string();
}
void description(const std::string& val)
{
description_ = val;
}
//用户密码获取与修改
std::string password()
{
if(password_)
return *password_;
return std::string();
}
void password(const std::string& val)
{
password_ = val;
}
//用户手机号获取与修改
std::string phone()
{
if(phone_)
return *phone_;
return std::string();
}
void phone(const std::string& val)
{
phone_ = val;
}
//用户头像文件id获取与修改
std::string avatar_id()
{
if(avatar_id_)
return *avatar_id_;
return std::string();
}
void avatar_id(const std::string& val)
{
avatar_id_ = val;
}
~User()
{}
private:
#pragma db id auto
unsigned long id_; //自增主键
#pragma db type("varchar(64)") index unique
odb::nullable<std::string> user_id_; //用户id
#pragma db type("varchar(64)") index unique
odb::nullable<std::string> nickname_; //用户昵称
odb::nullable<std::string> description_; //用户签名
#pragma db type("varchar(64)")
odb::nullable<std::string> password_; //登陆密码
#pragma db type("varchar(64)") index unique
odb::nullable<std::string> phone_; //注册手机号
#pragma db type("varchar(64)")
odb::nullable<std::string> avatar_id_; //头像文件id
};
};
//odb -d mysql --std c++11 --generate-query --generate-schema --profile boost/date-time person.hxx
//odb -d mysql -q -s ../../odb/user.hxx
- 关键约束:
user_id_(用户唯一标识)、nickname_(昵称)、phone_number_(手机号)均设置唯一约束,避免重复数据; - 可空字段:支持手机号、头像、签名等信息后续补充,适配不同注册场景(用户名注册无需绑定手机号)。
2. 核心数据操作接口
基于 ODB 封装标准化 CRUD 操作,支撑所有业务接口的数据持久化需求:
cpp
#pragma once
#include "../user/build/user-odb.hxx"
#include "../odb/user.hxx"
#include "LogTool.hpp"
#include <memory>
#include <odb/mysql/database.hxx>
#include <odb/mysql/query.hxx>
#include <odb/result.hxx>
#include <odb/transaction.hxx>
#include <sstream>
#include <string>
#include <vector>
namespace bite_im
{
typedef odb::query<bite_im::User> user_query;
typedef odb::result<bite_im::User> user_result;
class UserTable
{
public:
UserTable(std::shared_ptr<odb::core::database>& db)
: db_(db)
{}
bool db_check_nullptr()
{
return db_ == nullptr;
}
bool execute(std::vector<std::string>& sqls)
{
if(db_check_nullptr())
{
LOG_ERROR("数据库未完成初始化!");
return false;
}
odb::transaction t(db_->begin());
try
{
for(auto &sql : sqls)
{
db_->execute(sql);
}
LOG_INFO("执行sql语句成功!");
t.commit();
return true;
}
catch (const std::exception& e)
{
t.rollback();
LOG_ERROR("UserTable 插入失败-原因:{}", e.what());
}
return false;
}
bool insert(bite_im::User& user)
{
if(db_check_nullptr())
{
LOG_ERROR("数据库未完成初始化!");
return false;
}
odb::transaction t(db_->begin());
try
{
db_->persist(user);
t.commit();
LOG_INFO("插入用户数据user_id:{}成功!", user.user_id());
return true;
}
catch (const std::exception& e)
{
t.rollback();
LOG_ERROR("UserTable 插入失败-原因:{}", e.what());
}
return false;
}
bool update(std::shared_ptr<bite_im::User>& user)
{
if(db_check_nullptr())
{
LOG_ERROR("数据库未完成初始化!");
return false;
}
odb::transaction t(db_->begin());
try
{
db_->update(*user);
t.commit();
LOG_INFO("更新用户数据user_id:{}成功!", user->user_id());
return true;
}
catch (const std::exception& e)
{
t.rollback();
LOG_ERROR("UserTable 更新失败-user_id:{}, 原因:{}", user->user_id(), e.what());
}
return false;
}
std::shared_ptr<bite_im::User> select_by_nickname(const std::string& nickname)
{
if(db_check_nullptr())
{
LOG_ERROR("数据库未完成初始化!");
return std::shared_ptr<bite_im::User>();
}
if(nickname.empty())
{
LOG_ERROR("空nickname不能查找!");
return std::shared_ptr<bite_im::User>();
}
odb::transaction t(db_->begin());
try
{
std::shared_ptr<bite_im::User> res;
res.reset(db_->query_one<bite_im::User>(user_query::nickname == nickname));
t.commit();
//LOG_INFO("查找用户数据nickname:{}成功!", nickname);
return res;
}
catch (const std::exception& e)
{
t.rollback();
LOG_ERROR("UserTable 查找失败-nickname:{}, 原因:{}", nickname, e.what());
}
return std::shared_ptr<bite_im::User>();
}
std::shared_ptr<bite_im::User> select_by_phone(const std::string& phone)
{
if(db_check_nullptr())
{
LOG_ERROR("数据库未完成初始化!");
return std::shared_ptr<bite_im::User>();
}
if(phone.empty())
{
LOG_ERROR("空phone不能查找!");
return std::shared_ptr<bite_im::User>();
}
odb::transaction t(db_->begin());
try
{
std::shared_ptr<bite_im::User> res;
res.reset(db_->query_one<bite_im::User>(user_query::phone == phone));
t.commit();
//LOG_INFO("查找用户数据phone:{}成功!", phone);
return res;
}
catch (const std::exception& e)
{
t.rollback();
LOG_ERROR("UserTable 查找失败-phone:{}, 原因:{}", phone, e.what());
}
return std::shared_ptr<bite_im::User>();
}
std::shared_ptr<bite_im::User> select_by_id(const std::string& user_id)
{
if(db_check_nullptr())
{
LOG_ERROR("数据库未完成初始化!");
return std::shared_ptr<bite_im::User>();
}
if(user_id.empty())
{
LOG_ERROR("空user_id不能查找!");
return std::shared_ptr<bite_im::User>();
}
odb::transaction t(db_->begin());
try
{
std::shared_ptr<bite_im::User> res;
res.reset(db_->query_one<bite_im::User>(user_query::user_id == user_id));
t.commit();
//LOG_INFO("查找用户数据user_id:{}成功!", user_id);
return res;
}
catch (const std::exception& e)
{
t.rollback();
LOG_ERROR("UserTable 查找失败-user_id:{}, 原因:{}", user_id, e.what());
}
return std::shared_ptr<bite_im::User>();
}
std::vector<bite_im::User> select_multi_users(std::vector<std::string> id_list)
{
if(db_check_nullptr())
{
LOG_ERROR("数据库未完成初始化!");
return {};
}
if (id_list.empty())
{
LOG_DEBUG("查询 id 列表为空,返回空结果");
return {};
}
odb::transaction t(db_->begin());
try
{
std::vector<bite_im::User> res;
std::stringstream ss;
ss << "user_id in (";
for (size_t i = 0; i < id_list.size(); i++)
{
ss << "'" << id_list[i] << "',";
}
std::string condition = ss.str();
condition[condition.size()-1] = ')';
user_result result(db_->query<bite_im::User>(condition));
for(auto it : result)
{
res.push_back(it);
}
t.commit();
//LOG_INFO("查找复数用户数据成功!");
return res;
}
catch (const std::exception& e)
{
t.rollback();
LOG_ERROR("UserTable 多id查找失败-原因:{}", e.what());
}
return {};
}
~UserTable()
{}
private:
std::shared_ptr<odb::core::database> db_;
};
}
3. 业务场景联动
- 注册场景(用户名 / 手机号):通过
insert接口新增用户记录,依赖唯一约束自动拦截重复昵称 / 手机号; - 登录场景:通过
select_by_nickname/select_by_phone查询用户信息,完成密码 / 身份校验; - 信息修改场景:通过
select_by_id查询用户记录,修改后调用update接口落地更新。
(二)Redis:高性能缓存与状态管理
Redis 作为内存数据库,聚焦「高频读写 + 临时数据存储 + 状态标记」核心场景,支撑登录态管理、验证码校验等高性能需求,核心设计围绕「键值对结构 + 过期策略 + 快速查询」展开。
1. 缓存数据结构设计
按业务场景划分三类核心缓存,键名设计遵循「前缀 + 唯一标识」规则,确保命名规范与唯一性:
| 业务场景 | 键(Key)格式 | 值(Value) | 过期策略 | 核心代码实现 |
|---|---|---|---|---|
| 登录会话映射 | session:{会话ID} |
用户 ID | 无(登出时删除) | redis->set("session:" + ssid, user_id); |
| 登录状态标记 | login_status:{用户ID} |
空字符串(仅占位) | 无(连接断开时删除) | redis->set("login_status:" + user_id, ""); |
| 短信验证码 | verify_code:{验证码ID} |
4 位数字验证码 | 60 秒自动过期 | redis->set_ex("verify_code:" + cid, vcode, std::chrono::seconds(60)); |
2. 核心缓存操作封装
基于 redis++ 封装专用操作类,隔离缓存逻辑与业务逻辑,核心代码如下:
cpp
#pragma once
#include "LogTool.hpp"
#include <sw/redis++/redis++.h>
#include <memory>
#include <chrono>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <sw/redis++/redis.h>
#include <sw/redis++/utils.h>
#include <utility> // 用于 std::move
namespace bite_im
{
class RedisClientBuilder {
public:
/**
* @brief 构造函数(带默认参数,支持灵活配置)
* @param host Redis 服务器地址(默认:本地 127.0.0.1)
* @param port Redis 服务器端口(默认:6379,Redis 默认端口)
* @param db 要连接的 Redis 数据库编号(默认:0,Redis 默认数据库)
* @param timeout 连接超时时间(默认:10000ms = 10秒,避免无限阻塞)
* @param keepalive 是否启用 TCP 保活(默认:true,防止连接被防火墙断开)
* @throw std::invalid_argument 非法参数(如端口超出范围、超时为负)
* @throw sw::redis::Error Redis 客户端创建失败(如连接超时、认证失败、服务未启动)
*/
RedisClientBuilder(std::string host = "127.0.0.1",
int32_t port = 6379,
uint8_t db = 0,
std::chrono::milliseconds timeout = std::chrono::milliseconds(10000),
bool keepalive = true)
{
// 1. 参数合法性校验(提前拦截无效配置,避免底层报错)
validate_params(host, port, db, timeout);
// 2. 构建 Redis 连接配置对象
sw::redis::ConnectionOptions conn_opts;
conn_opts.host = std::move(host);
conn_opts.port = static_cast<uint16_t>(port);
conn_opts.db = db;
conn_opts.connect_timeout = timeout;
conn_opts.keep_alive = keepalive;
redis_client_ = std::make_shared<sw::redis::Redis>(conn_opts);
// 3. 可选:验证连接有效性(主动发起一次 ping,确保连接成功)
ping();
}
//禁止拷贝和允许移动
RedisClientBuilder(const RedisClientBuilder&) = delete;
RedisClientBuilder& operator=(const RedisClientBuilder&) = delete;
RedisClientBuilder(RedisClientBuilder&&) noexcept = default;
RedisClientBuilder& operator=(RedisClientBuilder&&) noexcept = default;
~RedisClientBuilder() = default;
/**
* @brief 获取 Redis 客户端实例(核心接口)
* @return std::shared_ptr<sw::redis::Redis> 线程安全的客户端智能指针
* @throw std::logic_error 客户端未初始化(理论上不会触发,除非构造函数异常)
*/
std::shared_ptr<sw::redis::Redis> get_client() const
{
if (!redis_client_)
{
throw std::logic_error("RedisClientBuilder: 客户端未初始化(构造函数执行失败)");
}
return redis_client_;
}
bool ping() const {
if (!redis_client_) {
throw std::logic_error("RedisClientBuilder: 客户端未初始化,无法执行 ping");
}
// redis++ 的 ping() 方法返回 "PONG" 字符串,此处简化为布尔值
return redis_client_->ping() == "PONG";
}
private:
void validate_params(const std::string& host, int32_t port, uint8_t db,
std::chrono::milliseconds timeout) const
{
// 校验主机地址非空
if (host.empty()) {
throw std::invalid_argument("RedisClientBuilder: 服务器地址不能为空");
}
// 校验端口范围(1-65535)
if (port < 1 || port > 65535) {
throw std::invalid_argument("RedisClientBuilder: 端口号非法(必须在 1-65535 之间),当前值:" + std::to_string(port));
}
// 校验数据库编号(通常 Redis 默认配置支持 0-15,可根据实际配置调整)
if (db > 15) {
throw std::invalid_argument("RedisClientBuilder: 数据库编号非法(默认支持 0-15),当前值:" + std::to_string(db));
}
// 校验超时时间非负
if (timeout.count() < 0) {
throw std::invalid_argument("RedisClientBuilder: 超时时间不能为负数,当前值:" + std::to_string(timeout.count()) + "ms");
}
}
private:
/// Redis 客户端智能指针(自动管理生命周期,线程安全)
std::shared_ptr<sw::redis::Redis> redis_client_;
};
class Session
{
public:
Session(std::shared_ptr<sw::redis::Redis>& redis_client)
: redis_client_(redis_client)
{
if (!redis_client_)
{
throw std::invalid_argument("Session: Redis客户端不能为空");
}
}
bool append(const std::string& ssid, const std::string& uid)
{
if(ssid.empty() || uid.empty())
{
LOG_ERROR("参数不能有空!");
return false;
}
try
{
if(!redis_client_->set(ssid, uid))
throw std::runtime_error("新增ssid失败!-" + ssid);
return true;
}
catch (const std::exception& e)
{
LOG_ERROR("append ssid 发生错误:{}", e.what());
}
return false;
}
bool remove(const std::string& ssid)
{
if(ssid.empty())
{
LOG_ERROR("ssid未初始化!");
return false;
}
try
{
size_t del_count = redis_client_->del(ssid);
if (del_count >= 1)
{
LOG_INFO("Session::remove: 删除Session成功-ssid:{}", ssid);
}
else
{
LOG_WARN("Session::remove: Session不存在-ssid:{}", ssid);
}
return true;
}
catch (const std::exception& e)
{
LOG_ERROR("remove ssid 发生错误:{}", e.what());
}
return false;
}
sw::redis::OptionalString uid(const std::string& ssid)
{
if(ssid.empty())
{
LOG_ERROR("ssid未初始化!");
return sw::redis::OptionalString();
}
try
{
sw::redis::OptionalString redis_ret = redis_client_->get(ssid);
if (redis_ret)
{
// 转换为std::optional(更标准,调用方无需依赖redis++类型)
std::string uid = *redis_ret;
LOG_INFO("Session::uid(): 查询成功-ssid:{}, uid:{}", ssid, uid);
return redis_ret;
}
else
{
LOG_WARN("Session::uid(): Session不存在-ssid:{}", ssid);
return std::nullopt;
}
}
catch (const std::exception& e)
{
LOG_ERROR("uid() 发生错误:{}", e.what());
}
return sw::redis::OptionalString();
}
~Session() = default;
private:
std::shared_ptr<sw::redis::Redis> redis_client_;
};
class Status
{
public:
Status(std::shared_ptr<sw::redis::Redis>& redis_client)
: redis_client_(redis_client)
{
if (!redis_client_)
{
throw std::invalid_argument("Status: Redis客户端不能为空");
}
}
bool append(const std::string& uid)
{
if(uid.empty())
{
LOG_ERROR("参数不能为空!");
return false;
}
try
{
if(!redis_client_->set(uid, ""))
throw std::runtime_error("新增status uid失败!-" + uid);
return true;
}
catch (const std::exception& e)
{
LOG_ERROR("append uid 发生错误:{}", e.what());
}
return false;
}
bool remove(const std::string& uid)
{
if(uid.empty())
{
LOG_ERROR("uid未初始化!");
return false;
}
try
{
size_t del_count = redis_client_->del(uid);
if (del_count >= 1)
{
LOG_INFO("Status::remove: 删除Status成功-uid:{}", uid);
}
else
{
LOG_WARN("Status::remove: Status不存在-uid:{}", uid);
}
return true;
}
catch (const std::exception& e)
{
LOG_ERROR("remove uid 发生错误:{}", e.what());
}
return false;
}
bool exists(const std::string& uid)
{
if(uid.empty())
{
LOG_ERROR("uid未初始化!");
return false;
}
try
{
bool ret = redis_client_->exists(uid); // 直接调用 exists 命令
LOG_INFO("Status::exists: uid:{} 存在状态:{}", uid, ret ? "是" : "否");
return ret;
}
catch (const std::exception& e)
{
LOG_ERROR("exists() 发生错误:{}", e.what());
}
return false;
}
~Status() = default;
private:
std::shared_ptr<sw::redis::Redis> redis_client_;
};
class Codes
{
public:
Codes(std::shared_ptr<sw::redis::Redis>& redis_client)
: redis_client_(redis_client)
{
if (!redis_client_)
{
throw std::invalid_argument("Codes: Redis客户端不能为空");
}
}
bool append(const std::string &cid
, const std::string &code
, const std::chrono::milliseconds &t = std::chrono::milliseconds(300000))
{
if(cid.empty() || code.empty())
{
LOG_ERROR("参数不能有空!");
return false;
}
try
{
if(!redis_client_->set(cid, code, t))
throw std::runtime_error("新增cid失败!-" + cid);
return true;
}
catch (const std::exception& e)
{
LOG_ERROR("append cid 发生错误:{}", e.what());
}
return false;
}
bool remove(const std::string& cid)
{
if(cid.empty())
{
LOG_ERROR("cid未初始化!");
return false;
}
try
{
size_t del_count = redis_client_->del(cid);
if (del_count >= 1)
{
LOG_INFO("Codes::remove: 删除Codes成功-cid:{}", cid);
}
else
{
LOG_WARN("Codes::remove: Codes不存在-cid:{}", cid);
}
return true;
}
catch (const std::exception& e)
{
LOG_ERROR("remove cid 发生错误:{}", e.what());
}
return false;
}
sw::redis::OptionalString code(const std::string& cid)
{
if(cid.empty())
{
LOG_ERROR("cid未初始化!");
return sw::redis::OptionalString();
}
try
{
sw::redis::OptionalString redis_ret = redis_client_->get(cid);
if (redis_ret)
{
// 转换为std::optional(更标准,调用方无需依赖redis++类型)
std::string code = *redis_ret;
LOG_INFO("Codes::code(): 查询成功-cid:{}, code:{}", cid, code);
return redis_ret;
}
else
{
LOG_WARN("Codes::code(): Codes不存在-cid:{}", cid);
return std::nullopt;
}
}
catch (const std::exception& e)
{
LOG_ERROR("code() 发生错误:{}", e.what());
}
return sw::redis::OptionalString();
}
~Codes() = default;
private:
std::shared_ptr<sw::redis::Redis> redis_client_;
};
};
3. 业务场景联动
- 登录流程:登录成功后通过
Session::append存储会话、Status::append标记登录状态;后续接口通过Session::get_user_id鉴权,通过Status::exists防重复登录; - 验证码流程:获取验证码时通过
Codes::append存储(60 秒过期),校验时通过Codes::code查询比对,校验后通过Codes::remove删除,避免重复使用。
(三)Elasticsearch:用户搜索索引管理
Elasticsearch(ES)专注于用户信息的「模糊搜索」场景,通过分词索引提升查询效率,核心设计围绕「轻量存储 + 索引同步 + 搜索适配」展开,仅存储搜索必需字段,避免数据冗余。
1. 索引结构设计
ES 索引仅存储用户搜索核心字段,字段类型适配搜索场景(关键词类型支持精确匹配,文本类型支持分词模糊匹配),索引创建代码如下:
cpp
#pragma once
#include "ESTool.hpp"
#include "LogTool.hpp"
#include "../odb/user.hxx"
#include <elasticlient/client.h>
#include <memory>
#include <vector>
namespace bite_im
{
class ESUser
{
public:
ESUser(std::vector<std::string> hosturls = {"http://127.0.0.1:9200/"}
,int timeout_ms = 5000)
{
es_client_ = std::make_shared<elasticlient::Client>(hosturls, timeout_ms);
}
ESUser(std::shared_ptr<elasticlient::Client>& es_client)
{
es_client_ = es_client;
}
bool CreateIndex()
{
try
{
ESTool::ESIndex esindex(es_client_);
esindex.addFieldMapping("user_id", "keyword")
.addFieldMapping("nickname", "text")
.addFieldMapping("phone", "keyword")
.addFieldMapping("description", "text", false, "standard")
.addFieldMapping("avatar_id", "keyword", false, "standard")
.setIndexSettings(1, 0);
if(!esindex.createIndex("user"))
{
LOG_WARN("创建用户索引失败:{}", esindex.getLastError());
return false;
}
LOG_INFO("创建用户索引成功");
}
catch (const std::exception& e)
{
LOG_ERROR("创建用户索引异常:{}", e.what());
return false;
}
return true;
}
bool AppendData(const std::string& userid
,const std::string& nickname
,const std::string& phone
,const std::string& description
,const std::string& avatar_id)
{
try
{
ESTool::ESInsert esinsert(es_client_);
esinsert.SetIndexName("user");
esinsert.append("user_id", userid)
.append("nickname", nickname)
.append("phone", phone)
.append("description", description)
.append("avatar_id", avatar_id);
if(!esinsert.insert(userid))
{
LOG_ERROR("添加用户数据失败:{}", esinsert.getLastError());
return false;
}
LOG_INFO("用户数据添加成功, userid:{}", userid);
}
catch (const std::exception& e)
{
LOG_ERROR("用户数据添加异常:{}, 异常userid:{}", e.what(), userid);
return false;
}
return true;
}
std::vector<bite_im::User> Search(const std::string& key
, const std::vector<std::string>& no_uids)
{
std::vector<bite_im::User> users;
try
{
ESTool::ESSearch esserach(es_client_);
esserach.SetIndexName("user");
esserach.append_must_not("user_id", no_uids, "term");
esserach.append_should("nickname", key, "match")
.append_should("phone", key, "term")
.append_should("user_id", key, "term");
auto results = esserach.query();
last_query_data_ = esserach.getLastQueryData();
if(results.isNull() || results.empty())
{
LOG_INFO("用户数据搜索成功, key:{}, 无结果返回", key);
LOG_TRACE("查询语句:{}", getlastQueryData());
return users;
}
if(results.isArray() == false)
{
LOG_WARN("用户数据搜索结果格式错误, key:{}", key);
return users;
}
int size = results.size();
if(size == 0)
{
LOG_INFO("用户数据搜索成功, key:{}, 无结果返回", key);
return users;
}
for(int i = 0; i < size; ++i)
{
bite_im::User user;
user.user_id(results[i]["_source"]["user_id"].asString());
user.nickname(results[i]["_source"]["nickname"].asString());
user.phone(results[i]["_source"]["phone"].asString());
user.description(results[i]["_source"]["description"].asString());
user.avatar_id(results[i]["_source"]["avatar_id"].asString());
users.push_back(user);
}
LOG_INFO("用户数据搜索成功, key:{}, 排除用户数量:{}", key, no_uids.size());
}
catch (const std::exception& e)
{
LOG_ERROR("用户数据搜索异常:{}, 异常key:{}", e.what(), key);
}
return users;
}
std::string getlastQueryData()
{
return last_query_data_;
}
bool DeleteData(const std::string& userid)
{
try
{
ESTool::ESDelete esdelete(es_client_);
esdelete.SetIndexName("user");
if(!esdelete.DeleteId(userid))
{
LOG_ERROR("删除用户数据失败:{}", esdelete.getLastError());
return false;
}
LOG_INFO("用户数据删除成功, userid:{}", userid);
}
catch (const std::exception& e)
{
LOG_ERROR("用户数据删除异常:{}, 异常userid:{}", e.what(), userid);
return false;
}
return true;
}
bool DeleteIndex()
{
try
{
ESTool::ESDelete esdelete(es_client_);
esdelete.SetIndexName("user");
if(!esdelete.DeleteIndex())
{
LOG_ERROR("删除用户索引失败:{}", esdelete.getLastError());
return false;
}
LOG_INFO("用户索引删除成功");
}
catch (const std::exception& e)
{
LOG_ERROR("用户索引删除异常:{}", e.what());
return false;
}
return true;
}
~ESUser()
{}
private:
std::shared_ptr<elasticlient::Client> es_client_;
std::string last_query_data_;
};
}
- 分词策略:采用
ik_max_word分词器(中文友好),支持昵称、签名的模糊搜索(如输入「张三」可匹配「张三 123」「爱张三」等); - 字段类型:
user_id和phone_number设为keyword类型,适配精确查询;nickname和description设为text类型,适配模糊搜索。
2. 核心索引操作封装
ES 操作与 MySQL 数据强同步,确保搜索结果的实时性,核心代码如下:
cpp
// 新增/更新用户索引(注册/信息修改场景)
bool AppendData(const std::string& userid
,const std::string& nickname
,const std::string& phone
,const std::string& description
,const std::string& avatar_id)
{
try
{
ESTool::ESInsert esinsert(es_client_);
esinsert.SetIndexName("user");
esinsert.append("user_id", userid)
.append("nickname", nickname)
.append("phone", phone)
.append("description", description)
.append("avatar_id", avatar_id);
if(!esinsert.insert(userid))
{
LOG_ERROR("添加用户数据失败:{}", esinsert.getLastError());
return false;
}
LOG_INFO("用户数据添加成功, userid:{}", userid);
}
catch (const std::exception& e)
{
LOG_ERROR("用户数据添加异常:{}, 异常userid:{}", e.what(), userid);
return false;
}
return true;
}
std::vector<bite_im::User> Search(const std::string& key
, const std::vector<std::string>& no_uids)
{
std::vector<bite_im::User> users;
try
{
ESTool::ESSearch esserach(es_client_);
esserach.SetIndexName("user");
esserach.append_must_not("user_id", no_uids, "term");
esserach.append_should("nickname", key, "match")
.append_should("phone", key, "term")
.append_should("user_id", key, "term");
auto results = esserach.query();
last_query_data_ = esserach.getLastQueryData();
if(results.isNull() || results.empty())
{
LOG_INFO("用户数据搜索成功, key:{}, 无结果返回", key);
LOG_TRACE("查询语句:{}", getlastQueryData());
return users;
}
if(results.isArray() == false)
{
LOG_WARN("用户数据搜索结果格式错误, key:{}", key);
return users;
}
int size = results.size();
if(size == 0)
{
LOG_INFO("用户数据搜索成功, key:{}, 无结果返回", key);
return users;
}
for(int i = 0; i < size; ++i)
{
bite_im::User user;
user.user_id(results[i]["_source"]["user_id"].asString());
user.nickname(results[i]["_source"]["nickname"].asString());
user.phone(results[i]["_source"]["phone"].asString());
user.description(results[i]["_source"]["description"].asString());
user.avatar_id(results[i]["_source"]["avatar_id"].asString());
users.push_back(user);
}
LOG_INFO("用户数据搜索成功, key:{}, 排除用户数量:{}", key, no_uids.size());
}
catch (const std::exception& e)
{
LOG_ERROR("用户数据搜索异常:{}, 异常key:{}", e.what(), key);
}
return users;
}
3. 业务场景联动
- 注册场景:用户注册成功后,调用
AppendData接口向 ES 新增索引(存储用户 ID + 昵称 / 手机号); - 信息修改场景:用户修改昵称 / 签名 / 手机号后,调用
AppendData接口同步更新 ES 索引,确保搜索结果准确; - 搜索场景:前端发起用户搜索请求时,调用
Search接口,通过多字段模糊匹配返回符合条件的用户列表。
三、核心功能实现流程
1. 用户注册(用户名 + 密码)
流程拆解:
- 参数校验:检查昵称(3-15 位,支持字母 / 数字 /_/-)和密码(6-15 位,支持字母 / 数字)格式合法性;
- 唯一性校验:通过昵称查询 MySQL,确认昵称未被占用;
- 数据持久化:
- 生成唯一用户 ID(UUID);
- 插入 MySQL 用户表(用户 ID、昵称、密码);
- 同步用户信息到 Elasticsearch(用于搜索);
- 响应结果:返回注册成功 / 失败状态及错误信息。
关键代码片段:
cpp
bool validate_nickname(const std::string& nickname)
{
if(nickname.size() >= 22)
{
return false;
}
return true;
}
bool validate_password(const std::string& password)
{
if(password.size() < 6 || password.size() > 15)
{
LOG_WARN("用户设置密码长度不合法, 长度:{}", password.size());
return false;
}
for(char c : password)
{
if(!((c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'z')
|| (c >= 'A' && c <= 'Z')
|| c == '_' || c == '-' ))
{
LOG_WARN("用户设置密码包含非法字符:{}", c);
return false;
}
}
return true;
}
void UserRegister(google::protobuf::RpcController* controller,
const ::bite_im::UserRegisterReq* request,
::bite_im::UserRegisterRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到用户注册请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
//1.参数校验
std::string nickname = request->nickname();
std::string password = request->password();
if(!validate_nickname(nickname))
{
LOG_DEBUG("签名不合法,长度不能超过22个字符");
err_response("昵称不合法,长度不能超过22个字符");
return;
}
if(!validate_password(password))
{
LOG_DEBUG("密码不合法,长度6-15,仅支持数字字母下划线和减号");
err_response("密码不合法,长度6-15,仅支持数字字母下划线和减号");
return;
}
//2.检查昵称是否已被使用
auto exist_user = mysql_user_->select_by_nickname(nickname);
if(exist_user)
{
LOG_DEBUG("昵称已被使用,请更换昵称");
err_response("昵称已被使用,请更换昵称");
return;
}
//3.插入mysql用户数据
bite_im::User new_user;
new_user.user_id(bite_im::uuid());
new_user.nickname(nickname);
new_user.password(password);
bool ret = mysql_user_->insert(new_user);
if(ret == false)
{
LOG_DEBUG("昵称注册时数据库插入数据失败");
err_response("数据库插入数据失败!");
return;
}
//4.插入es搜索引擎
ret = es_user_->AppendData(new_user.user_id(), nickname, "", "", "");
if(ret == false)
{
LOG_DEBUG("昵称注册时ES插入数据失败");
err_response("ES插入数据失败!");
return;
}
//5.响应
response->set_success(true);
}
catch (const std::exception& e)
{
LOG_ERROR("用户昵称注册异常,异常原因:{}", e.what());
err_response("触发昵称注册异常:" + std::string(e.what()));
}
}
2. 用户登录(双模式支持)
支持「用户名 + 密码」和「手机号 + 验证码」两种登录模式,核心流程一致:
(1)用户名 + 密码登录
- 参数校验:获取昵称和密码;
- 身份校验:通过昵称查询 MySQL,比对密码一致性;
- 登录态校验:检查 Redis 中用户登录状态,防止重复登录;
- 会话创建:
- 生成会话 ID(UUID);
- Redis 中存储「会话 ID - 用户 ID」映射和「用户 ID - 登录状态」标记;
- 响应结果:返回会话 ID(后续接口鉴权凭证)。
cpp
void UserLogin(google::protobuf::RpcController* controller,
const ::bite_im::UserLoginReq* request,
::bite_im::UserLoginRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到用户登录请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
//1.校验用户登录账户密码
std::string nickname = request->nickname();
std::string password = request->password();
auto user_ptr = mysql_user_->select_by_nickname(nickname);
if(!user_ptr || user_ptr->password() != password)
{
LOG_DEBUG("用户{}登录账户或者密码不正确!", nickname);
err_response("用户登录账户或者密码不正确!");
return;
}
//2.检测用户登录状态
bool user_status = redis_status_->exists(user_ptr->user_id());
if(user_status)
{
LOG_DEBUG("用户{}已经登录账户!", nickname);
err_response("用户已经登录账户!");
return;
}
//3.创建用户登录状态
std::string ssid = bite_im::uuid();
bool ret = redis_session_->append(ssid, user_ptr->user_id());
if(!ret)
{
LOG_DEBUG("创建redis会话异常!nickname-{}", nickname);
err_response("redis会话异常!");
return;
}
ret = redis_status_->append(user_ptr->user_id());
if(!ret)
{
LOG_DEBUG("创建redis状态异常!nickname-{}", nickname);
err_response("redis状态异常!");
return;
}
//4.响应
response->set_success(true);
response->set_login_session_id(ssid);
}
catch (const std::exception& e)
{
LOG_ERROR("用户登录异常,异常原因:{}", e.what());
err_response("触发登录异常:" + std::string(e.what()));
}
}
(2)手机号 + 验证码登录
- 参数校验:校验手机号格式(11 位,以 1 开头,第二位 3-9);
- 验证码校验:通过验证码 ID 查询 Redis,比对验证码一致性(校验后删除验证码);
- 用户校验:通过手机号查询 MySQL,确认用户已注册;
- 后续流程与用户名登录一致(登录态校验、会话创建)。
cpp
void PhoneLogin(google::protobuf::RpcController* controller,
const ::bite_im::PhoneLoginReq* request,
::bite_im::PhoneLoginRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到用户手机号登录请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
//1.参数校验
std::string phone = request->phone_number();
std::string cid = request->verify_code_id();
std::string code = request->verify_code();
if(check_phone_number(phone) == false)
{
err_response("手机号不合法!");
return;
}
//2.检查手机号是否已被注册与验证码是否合法
auto exist_user = mysql_user_->select_by_phone(phone);
if(!exist_user)
{
LOG_DEBUG("{}-手机号-{}未被注册!", request->request_id(), phone);
err_response("手机号未注册!");
return;
}
auto check_code = redis_codes_->code(cid);
if(!check_code || *check_code != code)
{
LOG_DEBUG("验证码不正确或者已经过期!");
err_response("验证码不正确或者已经过期!");
return;
}
redis_codes_->remove(cid);
// 3.检验是否已经登录
bool login_status = redis_status_->exists(exist_user->user_id());
if (login_status)
{
LOG_DEBUG("{}-用户-{}已经登录!", request->request_id(), exist_user->user_id());
err_response("用户已经登录!");
return;
}
// 4.创建登录会话
std::string ssid = bite_im::uuid();
bool ret = redis_session_->append(ssid, exist_user->user_id());
if(!ret)
{
LOG_DEBUG("{}-手机号登录redis会话异常!-phone:{}",request->request_id(), phone);
err_response("redis会话异常!");
return;
}
ret = redis_status_->append(exist_user->user_id());
if(!ret)
{
LOG_DEBUG("{}-手机号登录redis状态异常!-phone:{}",request->request_id(), phone);
err_response("redis状态异常!");
return;
}
// 5.响应
response->set_login_session_id(ssid);
response->set_success(true);
}
catch (const std::exception& e)
{
LOG_ERROR("用户手机号登录异常,异常原因:{}", e.what());
err_response("触发登录手机号异常:" + std::string(e.what()));
}
}
3. 用户信息管理(头像 / 昵称 / 签名 / 手机号修改)
所有信息修改流程遵循「校验 - 更新 - 同步」三步法,以头像修改为例:
- 用户校验:通过用户 ID 查询 MySQL,确认用户存在;
- 跨服务交互:调用文件服务上传头像文件,获取文件唯一 ID;
- 数据更新:
- 更新 MySQL 用户表中的头像文件 ID;
- 同步更新 Elasticsearch 中的用户信息;
- 响应结果:返回更新成功 / 失败状态。
cpp
void SetUserAvatar(google::protobuf::RpcController* controller,
const ::bite_im::SetUserAvatarReq* request,
::bite_im::SetUserAvatarRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到设置头像请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
// 1. 检验用户是否存在
std::string uid = request->user_id();
auto user = mysql_user_->select_by_id(uid);
if(!user)
{
LOG_DEBUG("{}-用户不存在,不能修改用户数据!-user_id:{}",request->request_id(), uid);
err_response("用户不存在!");
return;
}
// 2.通过文件子服务上传用户头像数据
//选择信道
brpcChannelTool::channelptr ptr = service_manager_->Choose(file_service_name_);
//实例化文件rpc服务
bite_im::FileService_Stub stub(ptr.get());
//请求
bite_im::PutSingleFileReq req;
std::string rqid = request->request_id();
req.set_request_id(rqid); //请求id
req.mutable_file_data()->set_file_name(request->user_id()); //请求文件名
std::string data = request->avatar();
req.mutable_file_data()->set_file_size(data.size()); //文件大小
req.mutable_file_data()->set_file_content(data); //文件内容
//响应
bite_im::PutSingleFileRsp rsp;
//发起rpc远程调用
brpc::Controller ctl;
stub.PutSingleFile(&ctl, &req, &rsp, nullptr);
if(ctl.Failed() == true)
{
LOG_DEBUG("{}-rpc文件子服务调用失败-{},不能上传文件数据!-user_id:{}",request->request_id(), ctl.ErrorText(), uid);
err_response("rpc文件子服务调用失败!");
return;
}
if(rsp.success() == false)
{
LOG_DEBUG("{}-文件子服务返回响应失败-{},不能上传文件数据!-user_id:{}",request->request_id(), rsp.errmsg(), uid);
err_response("文件子服务返回响应失败!");
return;
}
// 3. 更新文件id到数据库
user->avatar_id(rsp.file_info().file_id());
bool ret = mysql_user_->update(user);
if(ret == false)
{
LOG_DEBUG("{}-更新头像数据到数据库失败,不能上传文件数据!-user_id:{}",request->request_id(), uid);
err_response("更新头像数据到数据库失败!");
return;
}
// 4. 更新用户数据到ES
ret = es_user_->AppendData(uid, user->nickname(), user->phone(), user->description(), user->avatar_id());
if(ret == false)
{
LOG_DEBUG("{}-更新头像数据到ES失败,不能上传文件数据!-user_id:{}",request->request_id(), uid);
err_response("更新头像数据到ES失败!");
return;
}
// 5. 成功响应
response->set_success(true);
}
catch (const std::exception& e)
{
LOG_ERROR("设置头像异常,异常原因:{}", e.what());
err_response("触发设置头像异常:" + std::string(e.what()));
}
}
void SetUserNickname(google::protobuf::RpcController* controller,
const ::bite_im::SetUserNicknameReq* request,
::bite_im::SetUserNicknameRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到设置头像请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
// 1. 检验用户是否存在
std::string uid = request->user_id();
auto user = mysql_user_->select_by_id(uid);
if(!user)
{
LOG_DEBUG("{}-用户不存在,不能修改用户数据!-user_id:{}",request->request_id(), uid);
err_response("用户不存在!");
return;
}
// 2.更改用户名
user->nickname(request->nickname());
// 3. 更新用户数据到数据库
bool ret = mysql_user_->update(user);
if(ret == false)
{
LOG_DEBUG("{}-更新昵称数据到数据库失败,不能上传文件数据!-user_id:{}",request->request_id(), uid);
err_response("更新昵称数据到数据库失败!");
return;
}
// 4. 更新用户数据到ES
ret = es_user_->AppendData(uid, user->nickname(), user->phone(), user->description(), user->avatar_id());
if(ret == false)
{
LOG_DEBUG("{}-更新昵称数据到ES失败,不能上传文件数据!-user_id:{}",request->request_id(), uid);
err_response("更新昵称数据到ES失败!");
return;
}
// 5. 成功响应
response->set_success(true);
}
catch (const std::exception& e)
{
LOG_ERROR("设置昵称修改异常,异常原因:{}", e.what());
err_response("触发设置昵称修改异常:" + std::string(e.what()));
}
}
void SetUserDescription(google::protobuf::RpcController* controller,
const ::bite_im::SetUserDescriptionReq* request,
::bite_im::SetUserDescriptionRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到设置签名请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
// 1. 检验用户是否存在
std::string uid = request->user_id();
auto user = mysql_user_->select_by_id(uid);
if(!user)
{
LOG_DEBUG("{}-用户不存在,不能修改用户数据!-user_id:{}",request->request_id(), uid);
err_response("用户不存在!");
return;
}
// 2.更改签名
user->description(request->description());
// 3. 更新用户数据到数据库
bool ret = mysql_user_->update(user);
if(ret == false)
{
LOG_DEBUG("{}-更新签名数据到数据库失败,不能上传文件数据!-user_id:{}",request->request_id(), uid);
err_response("更新签名数据到数据库失败!");
return;
}
// 4. 更新用户数据到ES
ret = es_user_->AppendData(uid, user->nickname(), user->phone(), user->description(), user->avatar_id());
if(ret == false)
{
LOG_DEBUG("{}-更新签名数据到ES失败,不能上传文件数据!-user_id:{}",request->request_id(), uid);
err_response("更新签名数据到ES失败!");
return;
}
// 5. 成功响应
response->set_success(true);
}
catch (const std::exception& e)
{
LOG_ERROR("设置签名更改异常,异常原因:{}", e.what());
err_response("触发设置签名更改异常:" + std::string(e.what()));
}
}
void SetUserPhoneNumber(google::protobuf::RpcController* controller,
const ::bite_im::SetUserPhoneNumberReq* request,
::bite_im::SetUserPhoneNumberRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到设置手机号请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
// 1. 检验用户是否存在
std::string uid = request->user_id();
auto user = mysql_user_->select_by_id(uid);
if(!user)
{
LOG_DEBUG("{}-用户不存在,不能修改用户数据!-user_id:{}",request->request_id(), uid);
err_response("用户不存在!");
return;
}
// 2.更改手机号
// 2.1参数校验
std::string phone = user->phone();
std::string cid = request->phone_verify_code_id();
std::string code = request->phone_verify_code();
if(check_phone_number(phone) == false)
{
err_response("手机号不合法!");
return;
}
// 2.2检查手机号是否已被注册与验证码是否合法
auto exist_user = mysql_user_->select_by_phone(phone);
if(!exist_user)
{
LOG_DEBUG("{}-手机号-{}未被注册!", request->request_id(), phone);
err_response("手机号未注册!");
return;
}
auto check_code = redis_codes_->code(cid);
if(!check_code || *check_code != code)
{
LOG_DEBUG("验证码不正确或者已经过期!");
err_response("验证码不正确或者已经过期!");
return;
}
redis_codes_->remove(cid);
user->phone(request->phone_number());
// 3. 更新用户数据到数据库
bool ret = mysql_user_->update(user);
if(ret == false)
{
LOG_DEBUG("{}-更新手机号数据到数据库失败,不能上传文件数据!-user_id:{}",request->request_id(), uid);
err_response("更新手机号数据到数据库失败!");
return;
}
// 4. 更新用户数据到ES
ret = es_user_->AppendData(uid, user->nickname(), user->phone(), user->description(), user->avatar_id());
if(ret == false)
{
LOG_DEBUG("{}-更新手机号数据到ES失败,不能上传文件数据!-user_id:{}",request->request_id(), uid);
err_response("更新手机号数据到ES失败!");
return;
}
// 5. 成功响应
response->set_success(true);
}
catch (const std::exception& e)
{
LOG_ERROR("设置手机号更改异常,异常原因:{}", e.what());
err_response("触发设置手机号更改异常:" + std::string(e.what()));
}
}
关键亮点:
- 头像文件存储在独立文件服务,用户子服务仅存储文件 ID,符合微服务职责拆分原则;
- 所有信息修改均同步更新 ES,保证搜索数据一致性。
4. 用户信息查询(单用户 / 批量用户)
(1)单用户信息查询
- 通过用户 ID 查询 MySQL,获取基础信息(昵称、手机号、签名、头像文件 ID);
- 若存在头像文件 ID,调用文件服务获取头像二进制数据;
- 组装完整用户信息并返回。
cpp
void GetUserInfo(google::protobuf::RpcController* controller,
const ::bite_im::GetUserInfoReq* request,
::bite_im::GetUserInfoRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到用户信息获取请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
// 1.获取用户id
std::string uid = request->user_id();
// 2.从MySQL数据库获取用户数据
auto user = mysql_user_->select_by_id(uid);
if(!user)
{
LOG_DEBUG("{}-用户不存在,不能获取用户数据!-user_id:{}",request->request_id(), uid);
err_response("用户不存在!");
return;
}
// 3.设置用户数据
auto rsp_user = response->mutable_user_info();
rsp_user->set_user_id(user->user_id());
rsp_user->set_nickname(user->nickname());
rsp_user->set_phone(user->phone());
rsp_user->set_description(user->description());
// 4.获取用户头像数据
std::string avatar_id = user->avatar_id();
if(avatar_id.empty() == false)
{
brpcChannelTool::channelptr ptr = service_manager_->Choose(file_service_name_);
if(!ptr)
{
LOG_ERROR("请求id:{} - 未找到文件管理子服务节点 -file_service_name_:{} -user_id:{}!",
request->request_id(), file_service_name_, uid);
err_response("未找到文件管理子服务节点!");
return;
}
bite_im::FileService_Stub stub(ptr.get());
//获取单个文件的请求
bite_im::GetSingleFileReq req;
req.set_request_id(request->request_id());
req.set_file_id(avatar_id);
//获取单个文件的回复
bite_im::GetSingleFileRsp rsp;
brpc::Controller ctl;
//发起rpc调用
stub.GetSingleFile(&ctl, &req, &rsp, nullptr);
if(ctl.Failed() == true || rsp.success() == false)
{
LOG_ERROR("请求id:{} - rpc调用文件服务失败 -file_service_name_:{} -失败原因:{}!",
request->request_id(), file_service_name_, ctl.ErrorText());
err_response("rpc调用文件服务失败!");
return;
}
rsp_user->set_avatar(rsp.file_data().file_content());
}
// 5.响应
response->set_success(true);
}
catch (const std::exception& e)
{
LOG_ERROR("获取用户信息异常,异常原因:{}", e.what());
err_response("触发用户信息获取异常:" + std::string(e.what()));
}
}
(2)批量用户信息查询
- 批量获取用户 ID 列表,批量查询 MySQL;
- 收集所有用户的头像文件 ID,批量调用文件服务获取头像数据;
- 以用户 ID 为键,组装多用户信息映射并返回(提升批量查询效率)。
cpp
void GetMultiUserInfo(google::protobuf::RpcController* controller,
const ::bite_im::GetMultiUserInfoReq* request,
::bite_im::GetMultiUserInfoRsp* response,
::google::protobuf::Closure* done) override
{
brpc::ClosureGuard guard(done);
LOG_DEBUG("收到批量用户信息获取请求!");
//定义一个错误处理函数,当出错的时候被调用
auto err_response = [this, response](const std::string &errmsg) -> void {
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
try
{
response->set_request_id(request->request_id());
// 1.批量获取用户id
std::vector<std::string> uids;
for (size_t i = 0; i < request->users_id_size(); i++)
{
uids.push_back(request->users_id(i));
}
// 2.从MySQL数据库批量获取用户数据
std::vector<std::shared_ptr<bite_im::User>> user_list;
for (size_t i = 0; i < uids.size(); i++)
{
std::string uid = uids[i];
std::shared_ptr<bite_im::User> user = mysql_user_->select_by_id(uid);
if(!user)
{
LOG_DEBUG("{}-用户不存在,不能获取用户数据!-user_id:{}",request->request_id(), uid);
err_response("有用户不存在!");
return;
}
user_list.push_back(user);
}
// 3.批量获取用户头像文件数据
brpcChannelTool::channelptr ptr = service_manager_->Choose(file_service_name_);
if(!ptr)
{
LOG_ERROR("请求id:{} - 未找到文件管理子服务节点 -file_service_name_:{}",
request->request_id(), file_service_name_);
err_response("未找到文件管理子服务节点!");
return;
}
bite_im::FileService_Stub stub(ptr.get());
bite_im::GetMultiFileReq req;
req.set_request_id(request->request_id());
for (size_t i = 0; i < user_list.size(); i++)
{
std::string fileid = user_list[i]->avatar_id();
if(fileid.empty())
continue;
req.add_file_id_list(fileid);
}
bite_im::GetMultiFileRsp rsp;
brpc::Controller ctl;
stub.GetMultiFile(&ctl, &req, &rsp, nullptr);
if(ctl.Failed() == true || rsp.success() == false)
{
LOG_ERROR("请求id:{} - rpc调用文件服务失败 -file_service_name_:{} -失败原因:{}!",
request->request_id(), file_service_name_, ctl.ErrorText());
err_response("rpc调用文件服务失败!");
return;
}
auto filedata_map = rsp.mutable_file_data();
auto users_info_map = response->mutable_users_info();
// 4.批量设置用户数据
for (size_t i = 0; i < user_list.size(); i++)
{
bite_im::UserInfo userinfo;
userinfo.set_user_id(user_list[i]->user_id());
userinfo.set_nickname(user_list[i]->nickname());
userinfo.set_phone(user_list[i]->phone());
userinfo.set_description(user_list[i]->description());
if(user_list[i]->avatar_id().empty())
{
(*users_info_map)[user_list[i]->user_id()] = userinfo;
continue;
}
std::string avatar = (*filedata_map)[user_list[i]->avatar_id()].file_content();
userinfo.set_avatar(avatar);
//设置进入响应
(*users_info_map)[user_list[i]->user_id()] = userinfo;
}
// 5.响应
response->set_success(true);
}
catch (const std::exception& e)
{
LOG_ERROR("获取用户信息异常,异常原因:{}", e.what());
err_response("触发用户信息获取异常:" + std::string(e.what()));
}
}
四、跨服务交互设计
用户子服务需与两个核心外部服务交互,依赖 etcd 实现服务发现:
- 文件服务 :
- 用途:上传 / 下载用户头像文件;
- 交互方式:通过 brpc 调用文件服务的
PutSingleFile(上传)和GetMultiFile(批量下载)接口; - 服务发现:通过 etcd 监控文件服务节点,动态选择可用节点。
- 消息服务 :
- 用途:获取用户最近聊天消息(预留接口,支持会话列表展示);
- 交互方式:通过 brpc 调用消息服务接口(本文代码中已预留服务调用框架)。
五、高可用与安全性设计
- 防重复登录:Redis 中存储用户登录状态标记,登录时校验,避免同一账号多端同时登录;
- 验证码安全:验证码有效期 60 秒,验证后立即删除,防止恶意复用;
- 参数合法性校验:所有输入参数(用户名、密码、手机号)均做格式校验,防止非法数据注入;
- 异常处理:所有核心流程均捕获异常,记录详细日志,返回友好错误信息;
- 服务注册发现:基于 etcd 实现服务注册与健康检查,支持服务节点动态扩容。
六、总结
本用户子服务的实现严格遵循微服务架构设计原则,核心亮点如下:
- 职责单一:专注用户身份与信息管理,不耦合其他业务逻辑;
- 分层存储:MySQL 存储核心数据、Redis 存储高频临时数据、ES 支撑搜索,各取所长;
- 高扩展性:通过 etcd 实现服务发现,支持多节点部署与动态扩容;
- 安全性强:完善的参数校验、登录态管理、验证码机制,保障系统安全;
- 跨服务兼容:基于 brpc 构建标准化 RPC 接口,便于与其他微服务集成。
该实现方案可直接支撑即时通讯系统的核心用户管理需求,同时预留了扩展空间(如第三方登录、用户标签管理等),具备较强的实用性与可扩展性。
服务端整体实现与设计架构
用户子服务的服务端实现遵循「配置驱动 + 模块化构建 + 标准化流程」设计思路,通过 gflags 统一管理配置、Builder 模式封装初始化流程、微服务架构实现跨服务协同,确保服务的可配置性、可扩展性与高可用性。
(一)核心设计理念
- 配置与业务解耦:通过 gflags 定义所有可配置参数(端口、数据库地址、日志级别等),支持启动时动态调整,无需修改代码即可适配不同部署环境;
- 模块化分层:将服务初始化拆分为配置解析、日志初始化、依赖组件构建、RPC 服务启动等独立步骤,每层职责单一,便于维护与扩展;
- 依赖注入:通过 Builder 类统一管理 MySQL、Redis、ES、etcd 等依赖组件的实例化,组件间通过接口交互,降低耦合度;
- 微服务协同:基于 etcd 实现服务注册与发现,无缝对接文件子服务,支撑跨服务的头像上传 / 下载等功能。
(二)核心配置定义(gflags 实现)
服务端所有可配置项通过 gflags 定义,覆盖日志、服务注册、RPC 通信、数据库、缓存、搜索引擎等核心模块,配置项命名规范、含义清晰,支持命令行传入或配置文件加载。
1. 配置项分类与说明
| 配置模块 | 核心配置项 | 含义说明 | 默认值 |
|---|---|---|---|
| 日志配置 | log_mode |
日志模式:false(调试模式)、true(发布模式) | false |
log_output_mode |
输出方式:false(控制台)、true(文件) | false | |
log_file |
发布模式日志文件名 | app.log | |
log_level |
日志等级(对应 spdlog 级别) | 2(INFO 级) | |
| 服务注册(etcd) | registry_host |
etcd 服务地址 | http://127.0.0.1:2379 |
base_service |
服务监控根目录 | /service | |
instance_name |
当前服务实例名称 | /user_service/instance | |
access_host |
服务外部访问地址(IP: 端口) | 127.0.0.1:10003 | |
file_service_name |
文件子服务在 etcd 的注册路径(用于服务发现) | /service/file_service | |
| RPC 服务 | rpc_listen_port |
RPC 监听端口 | 10003 |
rpc_timeout |
RPC 调用超时时间(-1 表示无超时) | -1 | |
rpc_threads |
RPC IO 线程数 | 1 | |
| MySQL 数据库 | dbuser/dbpassword |
数据库用户名 / 密码 | root/123456 |
dbname/dbhost/dbport |
数据库名 / 地址 / 端口 | user/127.0.0.1/3306 | |
dbcharset |
数据库字符集(支持 emoji 需 utf8mb4) | utf8mb4 | |
db_con_num |
数据库连接池最大连接数 | 20 | |
| Elasticsearch | eshost |
ES 服务地址 | http://127.0.0.1:9200/ |
| Redis 缓存 | redis_host/redis_port |
Redis 地址 / 端口 | 127.0.0.1/6379 |
redis_db |
Redis 数据库编号 | 0 | |
redis_timeout |
Redis 连接超时时间(毫秒) | 10000 | |
redis_keepalive |
是否启用 TCP 长连接 | true |
2. 配置项核心代码
cpp
// 日志配置
DEFINE_bool(log_mode, false, "日志模式: false(调试模式,默认), true(发布模式)");
DEFINE_bool(log_output_mode, false, "控制台模式: false, 文件模式:true");
DEFINE_string(log_file, "app.log", "发布模式下日志名称");
DEFINE_int32(log_level, 2, "发布模式下日志等级设置");
// etcd 服务注册与发现
DEFINE_string(registry_host, "http://127.0.0.1:2379", "etcd服务注册中心地址");
DEFINE_string(base_service, "/service", "服务监控根目录");
DEFINE_string(instance_name, "/user_service/instance", "当前实例名称");
DEFINE_string(access_host, "127.0.0.1:10003", "当前实例的外部访问地址");
DEFINE_string(file_service_name, "/service/file_service", "文件子服务在etcd的注册路径");
// RPC 服务配置
DEFINE_int32(rpc_listen_port, 10003, "Rpc服务器监听端口");
DEFINE_int32(rpc_timeout, -1, "Rpc调用超时时间");
DEFINE_int32(rpc_threads, 1, "Rpc的IO线程数量");
// MySQL 配置
DEFINE_string(dbuser, "root", "数据库用户名");
DEFINE_string(dbpassword, "123456", "数据库密码");
DEFINE_string(dbname, "user", "数据库名称");
DEFINE_string(dbhost, "127.0.0.1", "数据库主机地址");
DEFINE_int32(dbport, 3306, "数据库端口");
DEFINE_string(dbcharset, "utf8mb4", "数据库字符集");
DEFINE_int32(db_con_num, 20, "数据库最大连接池数量");
// ES 配置
DEFINE_string(eshost, "http://127.0.0.1:9200/", "ES主机地址");
// Redis 配置
DEFINE_string(redis_host, "127.0.0.1", "Redis主机地址");
DEFINE_int32(redis_port, 6379, "Redis端口");
DEFINE_int32(redis_db, 0, "Redis数据库编号");
DEFINE_int64(redis_timeout, 10000, "Redis连接超时时间(毫秒)");
DEFINE_bool(redis_keepalive, true, "Redis是否启用长连接");
(三)服务初始化流程(Builder 模式)
服务端通过 UserServerBuilder 类封装所有初始化逻辑,采用「链式调用 + 分步构建」方式,确保初始化流程清晰、可扩展,核心流程如下:
1. 初始化步骤拆解
cpp
int main(int argc, char* argv[])
{
// 1. 解析命令行配置
gflags::ParseCommandLineFlags(&argc, &argv, true);
// 2. 初始化日志模块
LogModule::Log::Init(FLAGS_log_mode
, FLAGS_log_file
, FLAGS_log_level
, FLAGS_log_output_mode);
// 3. 构建 UserServer 实例(Builder 模式)
bite_im::UserServerBuilder usb;
// 3.1 初始化服务管理器(用于跨服务调用文件子服务)
usb.make_channel_manager_object(FLAGS_file_service_name);
// 3.2 初始化 MySQL 客户端(用户核心数据存储)
usb.make_mysql_object(FLAGS_dbuser
, FLAGS_dbpassword
, FLAGS_dbname
, FLAGS_dbhost
, FLAGS_dbport
, FLAGS_dbcharset
, FLAGS_db_con_num);
// 3.3 初始化 ES 客户端(用户搜索索引)
usb.make_es_object({FLAGS_eshost});
// 3.4 初始化 etcd 监控(监听文件子服务上线/下线)
usb.make_mon_object(FLAGS_registry_host
, FLAGS_file_service_name);
// 3.5 初始化 Redis 客户端(会话、验证码、登录状态存储)
usb.make_redis_object(FLAGS_redis_host
, FLAGS_redis_port
, FLAGS_redis_db
, std::chrono::milliseconds(FLAGS_redis_timeout)
, FLAGS_redis_keepalive);
// 3.6 初始化 RPC 服务(绑定端口、注册服务实现)
usb.make_rpc_object(FLAGS_rpc_listen_port
, FLAGS_rpc_timeout
, FLAGS_rpc_threads);
// 3.7 注册服务到 etcd(供其他服务发现)
usb.make_regs_object(FLAGS_registry_host
, FLAGS_base_service+FLAGS_instance_name
, FLAGS_access_host);
// 4. 构建并启动服务
std::shared_ptr<bite_im::UserServer> server = usb.build();
server->start(); // 阻塞运行,直到收到退出信号
return 0;
}
2. Builder 模式核心优势
- 流程可控:初始化步骤按依赖顺序排列(如先初始化服务管理器,再初始化 etcd 监控),避免依赖错误;
- 扩展性强 :新增依赖组件(如消息队列、第三方存储)时,仅需在 Builder 中新增
make_xxx_object方法,不影响现有逻辑; - 代码简洁 :将复杂的初始化逻辑封装在 Builder 类中,
main函数仅需调用链式方法,可读性强; - 组件复用:Builder 类统一管理所有依赖组件的实例化,确保组件单例复用,避免资源浪费。
(四)核心组件初始化细节
1. 日志模块初始化
基于 spdlog 封装的日志模块,支持调试 / 发布两种模式,可灵活配置输出方式(控制台 / 文件)和日志等级:
cpp
LogModule::Log::Init(
FLAGS_log_mode, // 模式:调试/发布
FLAGS_log_file, // 日志文件名(发布模式)
FLAGS_log_level, // 日志等级(发布模式)
FLAGS_log_output_mode // 输出方式:控制台/文件
);
- 调试模式:输出到控制台,日志等级默认 DEBUG,便于开发调试;
- 发布模式:输出到文件,支持按大小 / 时间轮转(需 spdlog 配置),日志等级可配置为 INFO/WARN/ERROR,减少日志量。
2. 依赖组件初始化(MySQL/Redis/ES)
- MySQL :通过
ODBFactory创建数据库连接池,配置字符集、最大连接数等参数,确保数据库操作的高效性与稳定性; - Redis:基于 redis++ 构建客户端,启用长连接减少连接开销,设置超时时间避免阻塞;
- ES:初始化 elasticlient 客户端,指定 ES 服务地址和超时时间,支撑用户搜索索引的读写操作。
3. 服务注册与发现(etcd)
- 服务注册 :通过
RegisterEtcd将当前服务的访问地址(access_host)注册到 etcd 的指定路径(base_service+instance_name),供其他服务(如消息服务)发现; - 服务发现 :通过
MonitorEtcd监听文件子服务的注册路径(file_service_name),当文件服务上线 / 下线时,自动更新服务管理器中的可用节点列表,确保跨服务调用的可用性。
4. RPC 服务初始化
基于 brpc 框架构建 RPC 服务,核心配置包括监听端口、IO 线程数、超时时间:
cpp
usb.make_rpc_object(
FLAGS_rpc_listen_port, // 监听端口:10003
FLAGS_rpc_timeout, // 超时时间:-1(无超时)
FLAGS_rpc_threads // IO 线程数:1(可根据服务器配置调整)
);
- 服务注册:将
UserServiceImpl实现类绑定到 RPC 服务,对外提供用户相关的 RPC 接口; - 线程模型:brpc 采用 Reactor 模式,IO 线程负责处理网络事件,业务逻辑在 IO 线程中执行(若业务复杂,可配置独立的业务线程池)。
(五)服务启动与运行
1. 服务启动
cpp
std::shared_ptr<bite_im::UserServer> server = usb.build();
server->start(); // 阻塞调用,运行直到收到退出信号(如 SIGINT/SIGTERM)
server->start()内部调用brpc::Server::RunUntilAskedToQuit(),阻塞当前线程,持续监听 RPC 请求;- 支持优雅退出:当收到退出信号时,brpc 会自动关闭监听端口,释放资源,确保数据不丢失。
2. 运行时特性
- 高并发支持:brpc 框架具备优秀的并发处理能力,IO 线程数可通过配置调整,适配不同的并发请求量;
- 负载均衡:etcd 支持多实例注册,当用户服务部署多个节点时,其他服务通过 etcd 获取所有可用节点,实现负载均衡;
- 故障自动转移:当某个用户服务节点故障下线时,etcd 会自动删除其注册信息,其他服务不再向该节点发送请求,实现故障自动转移。
客户端测试用例设计与实现
为确保用户子服务的所有核心功能稳定可靠,我们基于 Google Test(gtest)框架设计了一套完整的客户端测试用例,覆盖注册、登录、信息修改、信息查询等全业务流程。测试用例通过 RPC 调用真实服务端接口,模拟真实用户操作场景,验证接口功能正确性、参数校验有效性及跨服务交互可用性。
(一)测试框架与核心依赖
1. 技术选型
| 组件 | 技术工具 / 框架 | 核心作用 |
|---|---|---|
| 测试框架 | Google Test(gtest) | 提供测试用例组织、断言机制、测试执行等核心能力 |
| RPC 通信 | brpc | 与服务端一致的 RPC 框架,确保测试调用与真实客户端行为一致 |
| 服务发现 | etcd + brpcChannelTool | 从 etcd 动态获取用户子服务地址,支持多实例部署场景下的测试 |
| 日志模块 | spdlog + brpc 日志 | 输出测试过程日志,便于问题定位(如接口调用失败原因) |
| 配置管理 | gflags | 统一管理测试环境配置(etcd 地址、服务注册路径等),适配不同测试环境 |
2. 核心配置定义
测试客户端通过 gflags 定义配置项,与服务端配置对齐,确保测试环境可灵活调整:
cpp
// 日志配置(与服务端一致,便于日志统一分析)
DEFINE_bool(log_mode, false, "日志模式: false(调试模式,默认), true(发布模式)");
DEFINE_bool(log_output_mode, false, "控制台模式: false, 文件模式:true");
DEFINE_string(log_file, "test_app.log", "发布模式下日志名称");
DEFINE_int32(log_level, 0, "发布模式下日志等级设置");
// etcd 服务发现配置(需与服务端注册配置一致)
DEFINE_string(registry_host, "http://127.0.0.1:2379", "etcd服务注册中心地址");
DEFINE_string(base_service, "/service", "服务监控根目录");
DEFINE_string(user_host, "/service/user_service", "用户服务在etcd的注册路径");
DEFINE_string(file_host, "/service/file_service", "文件服务在etcd的注册路径(跨服务测试依赖)");
(二)测试环境初始化流程
测试客户端启动时需完成日志初始化、服务发现初始化等准备工作,确保测试用例可正常调用服务端接口:
cpp
int main(int argc, char* argv[])
{
// 1. 初始化 gtest 框架
testing::InitGoogleTest(&argc, argv);
// 2. 解析命令行配置
gflags::ParseCommandLineFlags(&argc, &argv, true);
// 3. 配置日志(关闭 brpc 自带日志,使用自定义日志模块)
logging::SetMinLogLevel(4);
LogModule::Log::Init(FLAGS_log_mode, FLAGS_log_file, FLAGS_log_level, FLAGS_log_output_mode);
// 4. 初始化 RPC 服务管理器(用于从 etcd 发现用户服务)
manager = std::make_shared<brpcChannelTool::ServiceManager>();
manager->set_is_follow(false); // 关闭关注模式,适配所有服务节点
// 5. 初始化 etcd 监视器(监听用户服务上线/下线)
Etcd_Tool::monitor_t info;
info.monitor_path_ = FLAGS_user_host; // 监听用户服务注册路径
// 服务上线回调:将服务节点添加到管理器
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);
info.type_ = Etcd_Tool::DIR; // 监听目录类型(多实例部署)
m.PushMonitor(info);
// 6. 等待服务发现初始化完成(确保测试用例执行前已找到可用服务)
if (!m.WaitForInitialDiscovery())
{
LOG_ERROR("服务发现初始化失败,未找到用户子服务节点");
return -1;
}
// 7. 执行所有测试用例
return RUN_ALL_TESTS();
}
(三)核心测试用例设计
测试用例按业务模块分类,覆盖用户子服务所有核心接口,每个用例遵循「准备请求参数→调用 RPC 接口→断言响应结果」的标准化流程。
1. 注册模块测试(用户名 + 手机号)
(1)用户名注册测试
验证用户名、密码合法性校验及注册流程正确性:
cpp
TEST(用户注册, 用户名注册测试)
{
// 1. 从服务管理器获取用户服务 RPC 信道
auto ptr = manager->Choose(FLAGS_user_host);
ASSERT_TRUE(ptr != nullptr) << "未找到可用的用户服务节点";
// 2. 初始化 RPC 客户端存根
bite_im::UserService_Stub user_stub(ptr.get());
brpc::Controller ctl;
// 3. 构造请求参数(合法昵称+密码)
bite_im::UserRegisterReq req;
req.set_nickname("李四"); // 符合 3~15 位规则
req.set_password("123456"); // 符合 6~15 位数字规则
req.set_request_id(bite_im::uuid()); // 生成唯一请求 ID
bite_im::UserRegisterRsp rsp;
// 4. 调用 RPC 接口
user_stub.UserRegister(&ctl, &req, &rsp, nullptr);
// 5. 断言结果(接口调用成功+响应参数一致)
ASSERT_EQ(ctl.Failed(), false) << "RPC 调用失败:" << ctl.ErrorText();
ASSERT_EQ(rsp.success(), true) << "注册失败:" << rsp.errmsg();
ASSERT_EQ(rsp.request_id(), req.request_id()) << "请求 ID 响应不一致";
}
(2)手机号注册测试
验证手机号格式校验、验证码校验及注册流程正确性(依赖「获取短信验证码」接口):
cpp
TEST(用户注册, 手机号注册测试)
{
auto ptr = manager->Choose(FLAGS_user_host);
ASSERT_TRUE(ptr != nullptr) << "未找到可用的用户服务节点";
bite_im::UserService_Stub user_stub(ptr.get());
brpc::Controller ctl1, ctl2;
// 第一步:获取短信验证码
bite_im::PhoneVerifyCodeReq code_req;
code_req.set_phone_number("13544448888"); // 合法手机号
code_req.set_request_id(bite_im::uuid());
bite_im::PhoneVerifyCodeRsp code_rsp;
user_stub.GetPhoneVerifyCode(&ctl1, &code_req, &code_rsp, nullptr);
ASSERT_EQ(ctl1.Failed(), false) << "获取验证码 RPC 调用失败:" << ctl1.ErrorText();
ASSERT_EQ(code_rsp.success(), true) << "获取验证码失败:" << code_rsp.errmsg();
// 第二步:使用验证码完成手机号注册
bite_im::PhoneRegisterReq req;
req.set_phone_number("13544448888");
req.set_request_id(bite_im::uuid());
req.set_verify_code_id(code_rsp.verify_code_id()); // 验证码 ID(从第一步响应获取)
req.set_verify_code("7878"); // 测试用固定验证码(实际应从短信获取)
bite_im::PhoneRegisterRsp rsp;
user_stub.PhoneRegister(&ctl2, &req, &rsp, nullptr);
// 断言结果
ASSERT_EQ(ctl2.Failed(), false) << "手机号注册 RPC 调用失败:" << ctl2.ErrorText();
ASSERT_EQ(rsp.success(), true) << "手机号注册失败:" << rsp.errmsg();
ASSERT_EQ(rsp.request_id(), req.request_id()) << "请求 ID 响应不一致";
}
2. 登录模块测试(用户名 + 手机号)
(1)用户名登录测试
验证用户名密码校验、登录态创建及会话 ID 返回正确性:
cpp
TEST(用户登录, 用户名登录测试)
{
auto ptr = manager->Choose(FLAGS_user_host);
ASSERT_TRUE(ptr != nullptr) << "未找到可用的用户服务节点";
bite_im::UserService_Stub user_stub(ptr.get());
brpc::Controller ctl;
// 构造登录请求(使用已注册的用户名+密码)
bite_im::UserLoginReq req;
req.set_nickname("李四");
req.set_password("123456");
req.set_request_id(bite_im::uuid());
bite_im::UserLoginRsp rsp;
// 调用登录接口
user_stub.UserLogin(&ctl, &req, &rsp, nullptr);
// 断言结果(需返回会话 ID)
ASSERT_EQ(ctl.Failed(), false) << "登录 RPC 调用失败:" << ctl.ErrorText();
ASSERT_EQ(rsp.success(), true) << "登录失败:" << rsp.errmsg();
ASSERT_EQ(rsp.request_id(), req.request_id()) << "请求 ID 响应不一致";
ASSERT_FALSE(rsp.login_session_id().empty()) << "未返回会话 ID";
}
(2)手机号登录测试
验证手机号、验证码校验及登录态创建正确性:
cpp
TEST(用户登录, 手机号登录测试)
{
auto ptr = manager->Choose(FLAGS_user_host);
ASSERT_TRUE(ptr != nullptr) << "未找到可用的用户服务节点";
bite_im::UserService_Stub user_stub(ptr.get());
brpc::Controller ctl, ctl2;
// 第一步:获取短信验证码
bite_im::PhoneVerifyCodeReq code_req;
code_req.set_phone_number("13544448888");
code_req.set_request_id(bite_im::uuid());
bite_im::PhoneVerifyCodeRsp code_rsp;
user_stub.GetPhoneVerifyCode(&ctl, &code_req, &code_rsp, nullptr);
ASSERT_EQ(ctl.Failed(), false) << "获取验证码失败:" << ctl.ErrorText();
// 第二步:使用验证码登录
bite_im::PhoneLoginReq req;
req.set_phone_number("13544448888");
req.set_request_id(bite_im::uuid());
req.set_verify_code_id(code_rsp.verify_code_id());
req.set_verify_code("7878");
bite_im::PhoneLoginRsp rsp;
user_stub.PhoneLogin(&ctl2, &req, &rsp, nullptr);
// 断言结果
ASSERT_EQ(ctl2.Failed(), false) << "手机号登录失败:" << ctl2.ErrorText();
ASSERT_EQ(rsp.success(), true) << "登录失败:" << rsp.errmsg();
ASSERT_FALSE(rsp.login_session_id().empty()) << "未返回会话 ID";
}
3. 用户信息修改模块测试
覆盖昵称、签名、手机号、头像四类信息修改场景,验证数据更新正确性及跨服务交互(如头像上传依赖文件服务)。
(1)昵称修改测试
cpp
TEST(用户数据修改, 用户名修改测试)
{
auto ptr = manager->Choose(FLAGS_user_host);
ASSERT_TRUE(ptr != nullptr) << "未找到可用的用户服务节点";
bite_im::UserService_Stub user_stub(ptr.get());
brpc::Controller ctl;
// 构造修改请求(需传入已存在的用户 ID)
bite_im::SetUserNicknameReq req;
req.set_nickname("李四改王五"); // 新昵称(符合格式规则)
req.set_user_id("13229c-dc19ee-0001"); // 已注册用户 ID
req.set_request_id(bite_im::uuid());
bite_im::SetUserNicknameRsp rsp;
// 调用修改接口
user_stub.SetUserNickname(&ctl, &req, &rsp, nullptr);
// 断言结果
ASSERT_EQ(ctl.Failed(), false) << "昵称修改 RPC 调用失败:" << ctl.ErrorText();
ASSERT_EQ(rsp.success(), true) << "昵称修改失败:" << rsp.errmsg();
}
(2)头像上传测试(跨服务依赖)
验证头像文件上传至文件服务、数据库更新头像 ID 全流程:
cpp
TEST(用户数据修改, 头像数据上传测试)
{
auto ptr = manager->Choose(FLAGS_user_host);
ASSERT_TRUE(ptr != nullptr) << "未找到可用的用户服务节点";
bite_im::UserService_Stub user_stub(ptr.get());
brpc::Controller ctl;
// 第一步:读取本地测试图片文件(二进制数据)
bite_im::SetUserAvatarReq req;
std::string body;
bite_im::ReadFile("./testicon.jpg", body); // 自定义工具函数:读取文件内容
req.set_avatar(body); // 传入头像二进制数据
req.set_user_id("13229c-dc19ee-0001"); // 已注册用户 ID
req.set_request_id(bite_im::uuid());
bite_im::SetUserAvatarRsp rsp;
// 第二步:调用头像设置接口(内部会调用文件服务上传文件)
user_stub.SetUserAvatar(&ctl, &req, &rsp, nullptr);
// 断言结果
ASSERT_EQ(ctl.Failed(), false) << "头像上传 RPC 调用失败:" << ctl.ErrorText();
ASSERT_EQ(rsp.success(), true) << "头像上传失败:" << rsp.errmsg();
}
4. 用户信息查询模块测试
覆盖单用户、批量用户查询场景,验证信息完整性(含头像数据)。
(1)单用户信息查询测试
cpp
TEST(用户数据获取, 单个用户数据获取测试)
{
auto ptr = manager->Choose(FLAGS_user_host);
ASSERT_TRUE(ptr != nullptr) << "未找到可用的用户服务节点";
bite_im::UserService_Stub user_stub(ptr.get());
brpc::Controller ctl;
// 构造查询请求(传入已注册用户 ID)
bite_im::GetUserInfoReq req;
req.set_user_id("13229c-dc19ee-0001");
req.set_request_id(bite_im::uuid());
bite_im::GetUserInfoRsp rsp;
// 调用查询接口
user_stub.GetUserInfo(&ctl, &req, &rsp, nullptr);
// 断言结果(响应成功+用户信息完整)
ASSERT_EQ(ctl.Failed(), false) << "单用户查询 RPC 调用失败:" << ctl.ErrorText();
ASSERT_EQ(rsp.success(), true) << "单用户查询失败:" << rsp.errmsg();
// 打印用户信息(辅助验证)
bite_im::UserInfo info = rsp.user_info();
LOG_INFO("用户昵称:{}", info.nickname());
LOG_INFO("用户签名:{}", info.description());
LOG_INFO("用户手机号:{}", info.phone());
ASSERT_FALSE(info.avatar().empty()) << "未获取到头像数据";
}
(2)批量用户信息查询测试
验证批量查询效率及多用户信息完整性:
cpp
TEST(用户数据获取, 批量用户数据获取测试)
{
auto ptr = manager->Choose(FLAGS_user_host);
ASSERT_TRUE(ptr != nullptr) << "未找到可用的用户服务节点";
bite_im::UserService_Stub user_stub(ptr.get());
brpc::Controller ctl;
// 构造批量查询请求(传入多个已注册用户 ID)
bite_im::GetMultiUserInfoReq req;
req.add_users_id("44955d-b2cede-0000");
req.add_users_id("13229c-dc19ee-0001");
req.set_request_id(bite_im::uuid());
bite_im::GetMultiUserInfoRsp rsp;
// 调用批量查询接口
user_stub.GetMultiUserInfo(&ctl, &req, &rsp, nullptr);
// 断言结果
ASSERT_EQ(ctl.Failed(), false) << "批量用户查询 RPC 调用失败:" << ctl.ErrorText();
ASSERT_EQ(rsp.success(), true) << "批量用户查询失败:" << rsp.errmsg();
// 验证所有请求的用户都返回了信息
auto infomap = rsp.users_info();
ASSERT_EQ(infomap.size(), 2) << "返回的用户数量与请求不一致";
// 打印批量查询结果
for(auto& it : infomap)
{
bite_im::UserInfo info = it.second;
LOG_INFO("用户 ID:{},昵称:{},手机号:{}", info.user_id(), info.nickname(), info.phone());
}
}
(四)测试用例设计亮点
- 全流程覆盖:覆盖用户从注册→登录→信息修改→信息查询的完整生命周期,确保端到端功能可用;
- 跨服务兼容:头像上传测试依赖文件服务,验证了微服务间协同的正确性;
- 参数合法性校验:测试用例中传入合法参数,间接验证服务端参数校验逻辑(如需测试非法参数,可新增用例传入超长昵称、非法手机号等);
- 可重复性:测试用例使用固定测试用户 ID 和验证码(测试环境),确保每次执行结果一致;
- 日志可追溯:每个用例输出详细日志,调用失败时可快速定位原因(如服务未启动、参数错误、跨服务调用失败等)。
(五)测试执行与结果验证
- 执行前提:启动 etcd、MySQL、Redis、ES、文件服务及用户子服务,确保所有依赖组件正常运行;
- 执行命令:编译测试代码后,直接运行测试程序,gtest 会自动执行所有未注释的测试用例;
- 结果判断 :所有用例执行通过(显示
OK)则说明服务端功能正常;若有失败用例,可通过日志查看具体失败原因(如 RPC 调用失败、响应结果不符合预期等)。