目录
那么有了上面这些模块,我们现在就能来搭建起我们用户管理子服务的客户端了。
一.RPC服务的定义
我们用户管理子服务提供了以下 11 个 RPC 接口,涵盖了用户注册、登录、信息获取及修改等完整功能:
-
用户名注册:用户使用昵称和密码进行注册,可选验证码验证。
-
用户名登录:用户使用昵称和密码登录,可选验证码验证,成功返回会话 ID。
-
获取邮箱验证码:向指定邮箱发送验证码,返回验证码 ID 用于后续验证。
-
邮箱注册:用户通过邮箱和验证码完成注册,无需密码。
-
邮箱登录:用户通过邮箱和验证码登录(免密登录),成功返回会话 ID。
-
获取当前用户信息:根据会话 ID 或用户 ID 获取登录用户的详细信息。
-
批量获取用户信息:内部接口,根据用户 ID 列表批量查询多个用户的详细信息。
-
修改头像:上传图片数据更新当前登录用户的头像。
-
修改昵称:更新当前登录用户的昵称。
-
修改签名:更新当前登录用户的个性签名。
-
修改邮箱:在验证码校验通过后更新当前登录用户的邮箱。
user.proto
cpp
syntax = "proto3"; // 指定使用 proto3 语法
package IMS; // 包名,用于避免命名冲突
import "base.proto"; // 导入基础定义(如 UserInfo 等)
option cc_generic_services = true; // 启用 C++ 通用服务支持
//----------------------------
// 用户名注册请求
message UserRegisterReq
{
string request_id = 1; // 请求唯一标识,用于追踪和关联响应
string nickname = 2; // 用户昵称,作为登录名
string password = 3; // 密码,明文传输(实际生产环境应加密)
optional string verify_code_id = 4; // 验证码 ID
optional string verify_code = 5; // 验证码内容
}
// 用户名注册响应
message UserRegisterRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否注册成功
string errmsg = 3; // 错误信息(成功时为空)
}
//----------------------------
// 用户名登录请求
message UserLoginReq
{
string request_id = 1; // 请求唯一标识
string nickname = 2; // 用户昵称
string password = 3; // 密码
optional string verify_code_id = 4; // 验证码 ID
optional string verify_code = 5; // 验证码内容
}
// 用户名登录响应
message UserLoginRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否登录成功
string errmsg = 3; // 错误信息
string login_session_id = 4; // 登录成功后返回的会话 ID,用于后续鉴权
}
//----------------------------
// 邮箱验证码获取请求
message EmailVerifyCodeReq
{
string request_id = 1; // 请求唯一标识
string email = 2; // 目标邮箱地址
}
// 邮箱验证码获取响应
message EmailVerifyCodeRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否成功发送验证码
string errmsg = 3; // 错误信息
string verify_code_id = 4; // 验证码 ID,用于后续验证
}
//----------------------------
// 邮箱注册请求
message EmailRegisterReq
{
string request_id = 1; // 请求唯一标识
string email = 2; // 邮箱地址
string verify_code_id = 3; // 验证码 ID(从获取验证码响应中获得)
string verify_code = 4; // 用户输入的验证码
}
// 邮箱注册响应
message EmailRegisterRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否注册成功
string errmsg = 3; // 错误信息
}
//----------------------------
// 邮箱登录请求
message EmailLoginReq
{
string request_id = 1; // 请求唯一标识
string email = 2; // 邮箱地址
string verify_code_id = 3; // 验证码 ID
string verify_code = 4; // 验证码
}
// 邮箱登录响应
message EmailLoginRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否登录成功
string errmsg = 3; // 错误信息
string login_session_id = 4; // 登录成功后返回的会话 ID
}
// 获取当前登录用户信息请求
// 客户端只需填充 session_id,user_id 由网关鉴权后填入
message GetUserInfoReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID,用于身份识别
}
// 获取当前登录用户信息响应
message GetUserInfoRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否成功获取
string errmsg = 3; // 错误信息
UserInfo user_info = 4; // 用户详细信息(定义在 base.proto)
}
// 内部接口:批量获取用户信息
message GetMultiUserInfoReq
{
string request_id = 1; // 请求唯一标识
repeated string users_id = 2; // 需要查询的用户 ID 列表
}
// 内部接口:批量获取用户信息响应
message GetMultiUserInfoRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否成功
string errmsg = 3; // 错误信息
map<string, UserInfo> users_info = 4; // 用户 ID 到用户信息的映射
}
//----------------------------
// 修改用户头像请求
message SetUserAvatarReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID
bytes avatar = 4; // 头像图片数据(二进制)
}
// 修改用户头像响应
message SetUserAvatarRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否修改成功
string errmsg = 3; // 错误信息
}
//----------------------------
// 修改用户昵称请求
message SetUserNicknameReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID
string nickname = 4; // 新昵称
}
// 修改用户昵称响应
message SetUserNicknameRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否修改成功
string errmsg = 3; // 错误信息
}
//----------------------------
// 修改用户签名请求
message SetUserDescriptionReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID
string description = 4; // 新签名内容
}
// 修改用户签名响应
message SetUserDescriptionRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否修改成功
string errmsg = 3; // 错误信息
}
//----------------------------
// 修改用户邮箱请求
message SetUserEmailReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID
string email = 4; // 新邮箱地址
string verify_code_id = 5; // 验证码 ID
string verify_code = 6; // 验证码
}
// 修改用户邮箱响应
message SetUserEmailRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否修改成功
string errmsg = 3; // 错误信息
}
// 用户服务定义
service UserService
{
rpc UserRegister(UserRegisterReq) returns (UserRegisterRsp); // 用户名注册
rpc UserLogin(UserLoginReq) returns (UserLoginRsp); // 用户名登录
rpc GetEmailVerifyCode(EmailVerifyCodeReq) returns (EmailVerifyCodeRsp); // 获取邮箱验证码
rpc EmailRegister(EmailRegisterReq) returns (EmailRegisterRsp); // 邮箱注册
rpc EmailLogin(EmailLoginReq) returns (EmailLoginRsp); // 邮箱登录(验证码登录)
rpc GetUserInfo(GetUserInfoReq) returns (GetUserInfoRsp); // 获取当前登录用户信息
rpc GetMultiUserInfo(GetMultiUserInfoReq) returns (GetMultiUserInfoRsp); // 内部接口:批量获取用户信息
rpc SetUserAvatar(SetUserAvatarReq) returns (SetUserAvatarRsp); // 修改头像
rpc SetUserNickname(SetUserNicknameReq) returns (SetUserNicknameRsp); // 修改昵称
rpc SetUserDescription(SetUserDescriptionReq) returns (SetUserDescriptionRsp); // 修改签名
rpc SetUserEmail(SetUserEmailReq) returns (SetUserEmailRsp); // 修改邮箱
}
有了这个,我们就能去定义我们的RPC服务了。
那么首先,我们想要实现上面那些功能,我们需要哪些组件呢?
其实也很清楚了,就是我们上面前置准备那些组件
cpp
class UserServiceImpl : public IMS::UserService
{
public:
UserServiceImpl(const MailClient::ptr &mail_client,
const std::shared_ptr<elasticlient::Client> &es_client,
const std::shared_ptr<odb::core::database> &mysql_client,
const std::shared_ptr<sw::redis::Redis> &redis_client,
const ServiceManager::ptr &channel_manager,
const std::string &file_service_name) : _es_user(std::make_shared<ESUser>(es_client)),
_mysql_user(std::make_shared<UserTable>(mysql_client)),
_redis_session(std::make_shared<Session>(redis_client)),
_redis_status(std::make_shared<Status>(redis_client)),
_redis_codes(std::make_shared<Codes>(redis_client)),
_file_service_name(file_service_name),
_mm_channels(channel_manager),
_mail_client(mail_client)
{
_es_user->createIndex();
}
~UserServiceImpl() {}
......
private:
// Elasticsearch 用户索引操作对象,用于搜索用户信息
ESUser::ptr _es_user;
// MySQL 用户表操作对象,用于用户的增删改查
UserTable::ptr _mysql_user;
// Redis 会话管理对象,存储会话 ID 与用户 ID 的映射
Session::ptr _redis_session;
// Redis 在线状态管理对象,标记用户是否在线
Status::ptr _redis_status;
// Redis 验证码管理对象,存储验证码及其有效期
Codes::ptr _redis_codes;
// RPC 调用相关客户端对象
// 文件服务名称,用于 RPC 调用定位文件服务
std::string _file_service_name;
// 服务管理器对象,用于获取其他微服务的 RPC 通道
ServiceManager::ptr _mm_channels;
// 邮件客户端对象,用于发送验证码邮件
MailClient::ptr _mail_client;
};
1.1.注册与登陆服务
1.1.1.用户名注册
首先用户注册,其实我们就是注册用户信息啊
那么我们的用户信息有哪些呢?
其实就是在base.proto里面我们写好的
cpp
// 用户信息结构,用于表示一个用户的基本资料
message UserInfo
{
string user_id = 1; // 用户ID,唯一标识一个用户
string nickname = 2; // 用户昵称
string description = 3; // 个人签名/描述
string email = 4; // 绑定邮箱号,可用于登录或联系
bytes avatar = 5; // 头像图片的二进制数据
}
这些信息就是我们用户注册所需要的!!
但是请注意,我们这里是用户名注册请求,我们完全可以使用 用户名+密码 就能完成注册的!!!
所以,我们这里的注册请求里面最核心的还是这个用户名+密码(我们看看user.proto里面的情况)
cpp
//----------------------------
// 用户名注册请求
message UserRegisterReq
{
string request_id = 1; // 请求唯一标识,用于追踪和关联响应
string nickname = 2; // 用户昵称,作为登录名
string password = 3; // 密码,明文传输(实际生产环境应加密)
optional string verify_code_id = 4; // 验证码 ID
optional string verify_code = 5; // 验证码内容
}
// 用户名注册响应
message UserRegisterRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否注册成功
string errmsg = 3; // 错误信息(成功时为空)
}
但是这个密码和昵称都是有格式限制的
- 昵称限制:长度必须小于 22 个字符(即最多 21 个字符),不限制具体字符类型。
- 密码限制:长度要求在 6 到 15 个字符之间,且只能包含字母(大小写均可)、数字、下划线 _ 和连字符 -,不允许其他符号。
cpp
// 昵称格式检测函数
// 检查昵称长度是否小于22个字符(简单长度限制)
bool nickname_check(const std::string &nickname)
{
return nickname.size() < 22; // 返回 true 表示昵称长度合法(小于22)
}
// 密码格式检测函数
// 检查密码是否符合安全要求:长度6-15,仅允许字母、数字、下划线、连字符
bool password_check(const std::string &password)
{
// 检查密码长度是否在6到15之间
if (password.size() < 6 || password.size() > 15)
{
LOG_ERROR("密码长度不合法:{}-{}", password, password.size());
return false;
}
// 遍历每个字符,确保只包含允许的字符集
for (int i = 0; i < password.size(); i++)
{
// 允许的字符:小写字母a-z、大写字母A-Z、数字0-9、下划线_、连字符-
if (!((password[i] > 'a' && password[i] < 'z') ||
(password[i] > 'A' && password[i] < 'Z') ||
(password[i] > '0' && password[i] < '9') ||
password[i] == '_' || password[i] == '-'))
{
LOG_ERROR("密码字符不合法:{}", password);
return false;
}
}
return true;
}
有了这个限制,我们就能编写我们的RPC服务了
整体流程可以概括为以下几个步骤:
- 接收请求并准备响应
- 服务端收到注册请求后,立即记录日志,并进入处理流程。框架会自动生成一个"响应保障机制",确保无论后续处理成功还是失败,最终都能向客户端返回一个明确的响应,避免因异常导致客户端挂起。
- 提取并校验输入数据
- 提取信息:从请求中获取用户填写的昵称和密码。
- 昵称校验:检查昵称长度是否小于 22 个字符(即最多 21 个字符),只做长度限制,不限制字符类型。
- 密码校验:检查密码长度是否在 6 到 15 个字符之间,且只能包含大小写字母、数字、下划线 _ 和连字符 -,不允许其他符号。
- 如果任意一项校验失败,立即记录错误日志,并通过统一错误响应函数返回失败结果(包含错误原因和请求 ID),流程结束。
- 唯一性检查
- 根据昵称去查询MySQL数据库,确认该昵称是否已被注册。如果查询到已有用户,说明昵称被占用,同样返回失败响应,提示"用户名被占用"。
- 数据持久化
- 通过唯一性检查后,开始正式注册:
- 生成用户标识:为当前用户生成一个全局唯一的用户 ID。
- 写入主数据库:**将用户 ID、昵称、密码等信息存入 MySQL 数据库。**如果写入失败,返回"数据库新增数据失败"的错误。
- 同步至搜索引擎:为了支持后续的用户搜索功能,将用户的昵称等关键信息写入 Elasticsearch。如果这一步失败,也会返回"搜索引擎新增数据失败"的错误。
- 这两个持久化步骤是顺序执行的,任一失败都会中断流程,并返回对应的失败信息。
- 返回成功响应
- 所有操作(数据库写入、搜索引擎写入)均成功后,设置响应中的成功标识,并附带请求 ID,最后将响应返回给客户端。此时注册流程完整结束。
cpp
// 用户注册RPC接口实现
virtual void UserRegister(::google::protobuf::RpcController *controller,
const ::IMS::UserRegisterReq *request,
::IMS::UserRegisterRsp *response,
::google::protobuf::Closure *done)
{
LOG_DEBUG("收到用户注册请求!");
brpc::ClosureGuard rpc_guard(done); // 确保done回调被正确执行
// 定义错误响应辅助函数,统一设置响应字段
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid); // 设置请求ID
response->set_success(false); // 标记失败
response->set_errmsg(errmsg); // 设置错误信息
return;
};
// 1. 从请求中取出昵称和密码
std::string nickname = request->nickname(); // 用户昵称
std::string password = request->password(); // 用户密码
// 2. 检查昵称是否合法(长度限制)
bool ret = nickname_check(nickname);
if (ret == false)
{
LOG_ERROR("{} - 用户名长度不合法!", request->request_id());
return err_response(request->request_id(), "用户名长度不合法!");
}
// 3. 检查密码是否合法(长度和字符集限制)
ret = password_check(password);
if (ret == false)
{
LOG_ERROR("{} - 密码格式不合法!", request->request_id());
return err_response(request->request_id(), "密码格式不合法!");
}
// 4. 根据昵称在数据库进行判断是否昵称已存在
auto user = _mysql_user->select_by_nickname(nickname);
if (user)
{
LOG_ERROR("{} - 用户名被占用- {}!", request->request_id(), nickname);
return err_response(request->request_id(), "用户名被占用!");
}
//走到这里说明数据库里面是没有这个用户存在的,所以我们可以进行注册
// 5. 向数据库新增数据
std::string uid = uuid(); // 生成唯一用户ID
user = std::make_shared<User>(uid, nickname, password); // 创建用户对象
ret = _mysql_user->insert(user); // 插入MySQL数据库
if (ret == false)
{
LOG_ERROR("{} - Mysql数据库新增数据失败!", request->request_id());
return err_response(request->request_id(), "Mysql数据库新增数据失败!");
}
// 6. 向 ES 服务器中新增用户信息(用于搜索)
ret = _es_user->appendData(uid, "", nickname, "", ""); // 空字段表示暂不填写其他信息
if (ret == false)
{
LOG_ERROR("{} - ES搜索引擎新增数据失败!", request->request_id());
return err_response(request->request_id(), "ES搜索引擎新增数据失败!");
}
// 7. 组织响应,返回成功标识
response->set_request_id(request->request_id());
response->set_success(true);
}
我们可能会有一个疑问?为什么服务端生成的uid不需要传递给客户端???
在典型的微服务架构中,客户端确实不需要知道服务端生成的用户ID(uid),更不需要在请求中直接传递它
- 客户端在登录成功后,服务端返回一个 login_session_id。
- 客户端保存这个 session_id,并在后续每个请求中附带它。
- 然后客户端是和服务端的网关进行直连的。
- 服务端的网关通过客户端传递过来的 session_id 在数据库中查找对应的 user_id,然后填充到请求的 user_id 字段中,然后才会将这个请求分发到我们的用户管理子服务。
因此,客户端根本不需要知道自己的 user_id,更不需要在请求中手动填写它。这既安全(避免用户伪造 ID),又简化了客户端逻辑。
1.1.2.用户登录
用户登录,我们这里还是使用昵称+密码的形式来进行登录的
我们看看user.proto
cpp
//----------------------------
// 用户名登录请求
message UserLoginReq
{
string request_id = 1; // 请求唯一标识
string nickname = 2; // 用户昵称
string password = 3; // 密码
optional string verify_code_id = 4; // 验证码 ID
optional string verify_code = 5; // 验证码内容
}
// 用户名登录响应
message UserLoginRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否登录成功
string errmsg = 3; // 错误信息
string login_session_id = 4; // 登录成功后返回的会话 ID,用于后续鉴权
}
注意:用户登录成功后,服务端会分配一个全局唯一的会话 ID。
- 这个会话 ID 是在验证用户身份、确认没有重复登录后生成的,通常是一个 UUID 或类似的唯一字符串。服务端会将这个会话ID与用户 ID 的映射关系存入 Redis,同时标记该用户为"已登录"状态。随后,服务端将这个会话 ID 返回给客户端。
- 客户端后续发起其他需要身份认证的请求时,只需携带这个会话 ID,服务端就可以通过它识别出是哪个用户、确认其登录状态是否有效,从而实现无状态的鉴权,无需每次都输入密码。
cpp
// 用户登录RPC接口实现
virtual void UserLogin(::google::protobuf::RpcController *controller,
const ::IMS::UserLoginReq *request,
::IMS::UserLoginRsp *response,
::google::protobuf::Closure *done)
{
LOG_DEBUG("收到用户登录请求!");
brpc::ClosureGuard rpc_guard(done);
// 定义错误响应辅助函数
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出昵称和密码
std::string nickname = request->nickname(); // 用户昵称
std::string password = request->password(); // 用户密码
// 2. 通过昵称获取用户信息,进行密码是否一致的判断
auto user = _mysql_user->select_by_nickname(nickname);
// 若用户不存在或密码不匹配,则登录失败
if (!user || password != user->password())
{
LOG_ERROR("{} - 用户名或密码错误 - {}-{}!", request->request_id(), nickname, password);
return err_response(request->request_id(), "用户名或密码错误!");
}
// 3. 根据 redis 中的登录标记信息是否存在判断用户是否已经登录
bool ret = _redis_status->exists(user->user_id()); // 检查用户是否已登录
if (ret == true)
{
LOG_ERROR("{} - 用户已在其他地方登录 - {}!", request->request_id(), nickname);
return err_response(request->request_id(), "用户已在其他地方登录!");
}
// 4. 构造会话 ID,生成会话键值对,向 redis 中添加会话信息以及登录标记信息
std::string ssid = uuid(); // 生成唯一会话ID
_redis_session->append(ssid, user->user_id()); // 保存会话ID与用户ID的映射
// 5. 添加用户登录状态标记
_redis_status->append(user->user_id()); // 标记该用户已登录
// 6. 组织响应,返回生成的会话 ID
response->set_request_id(request->request_id());
response->set_login_session_id(ssid); // 返回会话ID用于后续鉴权
response->set_success(true);
}
这个 RPC 服务实现的是用户登录功能,整体流程围绕"身份验证 → 防重复登录 → 创建会话 → 返回凭证"展开。
第一步:提取并验证凭证
**根据昵称去 MySQL 数据库查询用户信息。**因为用户注册时的账号密码信息持久化存储在 MySQL 中,需要从这里取出密码与请求中的密码进行比对。
-
如果 MySQL 中查不到该昵称,就说明用户不存在。
-
如果查到了但密码不匹配,就说明密码错误。
第二步:检查重复登录
**根据用户ID去Redis查询用户的登陆登录 。**因为登录状态是临时性信息,适合放在 Redis 这类高速缓存中,能够快速判断用户当前是否处于登录状态。
- 如果 Redis 中存在该用户的登录标记,就说明用户已在其他地方登录。
第三步:创建会话并标记状态
创建会话和标记登录状态时,操作的是 Redis。
-
将会话 ID 与用户 ID 的映射关系存入 Redis,用于后续鉴权时根据会话 ID 找到对应用户。
-
同时将用户的登录状态标记写入 Redis,供后续重复登录检查使用。
第四步:返回成功响应
不涉及查询操作,直接返回生成的会话 ID。
整个流程中,MySQL 负责持久化的用户数据查询(验证身份),Redis 负责临时性的会话管理和登录状态维护(防重复登录、会话存储)。
1.1.3.邮箱验证码获取
我们这里是使用这个邮箱来发送验证码的。
大家需要进行注意:我们的验证码都是有一个独一无二的验证码ID的。它们是死死绑定的。
那么请求里面肯定需要包含目标邮箱地址。但是我们的响应里面需要包含验证码ID。
响应里返回验证码 ID,是为了在后续的验证环节(比如用户输入验证码后提交)中,让客户端带上这个 ID,服务端就能通过 ID 在 Redis 里找到对应的验证码进行比对。
这样做有几个好处:
-
关联性:每次请求生成的验证码都有一个唯一 ID,后续验证时可以精确匹配,避免混淆。
-
安全性:验证码本身不暴露在客户端,只返回一个 ID,即使被截获也无法直接冒充验证码。
-
存储管理:Redis 中可以给每个验证码设置有效期(如 5 分钟),过期自动清除,ID 就是存储的键,方便管理。
cpp
//----------------------------
// 邮箱验证码获取请求
message EmailVerifyCodeReq
{
string request_id = 1; // 请求唯一标识
string email = 2; // 目标邮箱地址
}
// 邮箱验证码获取响应
message EmailVerifyCodeRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否成功发送验证码
string errmsg = 3; // 错误信息
string verify_code_id = 4; // 验证码 ID,用于后续验证
}
但是我们的邮箱是有格式要求的,所以我们就写了一个函数来验证我们的邮箱格式
cpp
// 邮箱格式验证函数
bool email_check(const std::string &email)
{
// 简单验证:必须包含 '@' 和 '.',且 '@' 在 '.' 之前,且两端非空
size_t at_pos = email.find('@');
size_t dot_pos = email.rfind('.');
if (at_pos == std::string::npos || dot_pos == std::string::npos)
return false;
if (at_pos == 0 || at_pos + 1 >= dot_pos)
return false; // @前不能为空,@后必须有内容,且点不能在@前
if (dot_pos == email.length() - 1)
return false; // 点不能在末尾
return true;
}
这个函数对邮箱格式的要求可以概括为以下 4 条规则:
-
必须包含
@和.邮箱地址中一定要有
@符号和一个点.,缺一不可。 -
@必须在最后一个.之前
@出现的位置要早于最后一个点,确保域名部分(@后面)至少有一个点。 -
@前后都不能为空
@前面要有内容(本地部分不能空),
@和最后一个点之间也要有内容(即域名部分至少要有 "xxx.yy" 这样的结构,中间不能紧挨着)。 -
最后一个点不能在末尾
域名结尾不能是点,比如
"abc@def."是不允许的。
简单来说,它验证的是类似 username@domain.com 这种最基础的形式,但不检查更复杂的规则(如多个点、特殊字符等)。
cpp
// 邮箱验证码获取RPC服务接口实现
virtual void GetEmailVerifyCode(::google::protobuf::RpcController *controller,
const ::IMS::EmailVerifyCodeReq *request,
::IMS::EmailVerifyCodeRsp *response,
::google::protobuf::Closure *done)
{
LOG_DEBUG("收到邮箱验证码获取请求!");
brpc::ClosureGuard rpc_guard(done);
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出邮箱地址
std::string email = request->email();
// 2. 验证邮箱地址格式是否正确
bool ret = email_check(email);
if (ret == false)
{
LOG_ERROR("{} - 邮箱地址格式错误 - {}!", request->request_id(), email);
return err_response(request->request_id(), "邮箱地址格式错误!");
}
// 3. 生成 4 位随机验证码
std::string code_id = uuid();
std::string code = vcode(); // 随机生成了一个验证码
// 4. 基于邮件平台 SDK 发送验证码
ret = _mail_client->send(email, code);
if (ret == false)
{
LOG_ERROR("{} - 邮箱验证码发送失败 - {}!", request->request_id(), email);
return err_response(request->request_id(), "邮箱验证码发送失败!");
}
// 5. 构造验证码 ID,添加到 redis 验证码映射键值索引中
_redis_codes->append(code_id, code);
// 6. 组织响应,返回生成的验证码 ID
response->set_request_id(request->request_id());
response->set_success(true);
response->set_verify_code_id(code_id);
LOG_DEBUG("获取邮箱验证码处理完成!");
}
这个 RPC 服务实现的是获取邮箱验证码的功能,主要用于用户注册或安全验证场景。流程如下:
-
接收请求并提取邮箱
服务端收到请求后,首先记录日志,并进入处理流程,同时确保在任何情况下都能正确返回响应。
-
校验邮箱格式
从请求中取出邮箱地址,进行格式校验。如果格式不正确(比如缺少 @ 符号、域名不合法等),则立即返回失败响应,并告知"邮箱地址格式错误",流程结束。
-
生成验证码并发送邮件
如果邮箱格式正确,就生成一个唯一的验证码 ID(用于后续验证时标识本次验证码)和一个随机的 4 位数字验证码。
然后调用邮件发送客户端,向该邮箱发送包含验证码的邮件。
- 如果邮件发送失败(例如邮件服务器异常、邮箱地址不存在等),则返回"邮箱验证码发送失败"的错误响应,流程结束。
-
存储验证码映射关系
邮件发送成功后,将验证码 ID 与验证码的映射关系存入 Redis。这样后续当用户提交验证码时,可以通过验证码 ID 在 Redis 中查找对应的验证码进行校验。(注意这个键值对存进去之后,这个键值对我们默认设置了5分钟的TTL租约内,5分钟之后就会从Redis里面清除,因为我们在data_redis.hpp里面的Codes::append函数里面设定好的默认参数就是5分钟)
-
返回成功响应
最后返回成功响应,并将验证码 ID(而不是验证码本身)返回给客户端。客户端需要保存这个 ID,在后续提交验证码时一起发送,以便服务端定位到正确的验证码进行比对。
整个流程中,Redis 用于临时存储验证码映射(通常设置过期时间,防止验证码长期有效),MySQL 和 Elasticsearch 均未参与。邮件发送依赖于我们之前使用libcurl库编写的邮件发送客户端完成的。
响应里返回验证码 ID,是为了在后续的验证环节(比如用户输入验证码后提交)中,让客户端带上这个 ID,服务端就能通过 ID 在 Redis 里找到对应的验证码进行比对。如果没有在Redis中找到,就是验证码过期了,被Redis清除掉了。
1.1.4.邮箱注册
我们这里其实是通过邮箱来注册一个新用户啊
通过邮箱进行注册新用户,那么肯定是邮箱+验证码来进行注册的!!
但是这个验证码是用户通过调用邮箱验证码获取RPC服务来获取的,服务器会在响应里返回一个验证码ID给我们的客户端。同时,我们用户的邮箱也会收到验证码。
那么我们的请求里面就应该包含3样东西
- 邮箱
- 验证码
- 验证码ID
至于响应?就没什么好说的了。
cpp
//----------------------------
// 邮箱注册请求
message EmailRegisterReq
{
string request_id = 1; // 请求唯一标识
string email = 2; // 邮箱地址
string verify_code_id = 3; // 验证码 ID(从获取验证码响应中获得)
string verify_code = 4; // 用户输入的验证码
}
// 邮箱注册响应
message EmailRegisterRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否注册成功
string errmsg = 3; // 错误信息
}
我们也很快就能写出我们的RPC服务
cpp
// 邮箱注册新用户RPC接口实现
virtual void EmailRegister(::google::protobuf::RpcController *controller,
const ::IMS::EmailRegisterReq *request,
::IMS::EmailRegisterRsp *response,
::google::protobuf::Closure *done)
{
LOG_DEBUG("收到邮箱注册新用户请求!");
brpc::ClosureGuard rpc_guard(done); // 确保done回调被正确执行
// 定义错误响应辅助函数
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出邮箱、验证码ID和验证码
std::string email = request->email();
std::string code_id = request->verify_code_id();
std::string code = request->verify_code();
// 2. 检查邮箱格式是否合法
bool ret = email_check(email); // 使用邮箱格式校验函数
if (ret == false)
{
LOG_ERROR("{} - 邮箱格式错误 - {}!", request->request_id(), email);
return err_response(request->request_id(), "邮箱格式错误!");
}
// 3. 从redis中验证验证码ID和验证码是否匹配
auto vcode = _redis_codes->code(code_id);//这一步会自动判断验证码是否过期,因为过期就会被Redis自动删除
if (vcode != code)
{
LOG_ERROR("{} - 验证码错误 - {}-{}!", request->request_id(), code_id, code);
return err_response(request->request_id(), "验证码错误!");
}
// 4. 通过数据库查询判断邮箱是否已经注册过
auto user = _mysql_user->select_by_email(email);//通过邮箱来查询用户信息
if (user)
{
LOG_ERROR("{} - 该邮箱已注册过用户 - {}!", request->request_id(), email);
return err_response(request->request_id(), "该邮箱已注册过用户!");
}
// 5. 向数据库新增用户信息
std::string uid = uuid(); // 生成唯一用户ID
user = std::make_shared<User>(uid, email);//将用户ID和邮箱进行绑定
ret = _mysql_user->insert(user);//保存到mysql数据库
if (ret == false)
{
LOG_ERROR("{} - 向数据库添加用户信息失败 - {}!", request->request_id(), email);
return err_response(request->request_id(), "向数据库添加用户信息失败!");
}
// 6. 向ES搜索引擎中新增用户信息(用于搜索)
ret = _es_user->appendData(uid, email, uid, "", ""); // 参数含义需根据实际调整
if (ret == false)
{
LOG_ERROR("{} - ES搜索引擎新增数据失败!", request->request_id());
return err_response(request->request_id(), "ES搜索引擎新增数据失败!");
}
// 7. 组织响应,返回成功标识
response->set_request_id(request->request_id());
response->set_success(true);
}
这个 RPC 服务实现的是邮箱注册新用户的功能,即用户通过邮箱地址和收到的验证码完成注册。整体流程围绕"验证邮箱格式 → 校验验证码 → 检查是否已注册 → 写入数据库和搜索引擎"展开,每个环节都有清晰的失败处理。
第一步:接收请求并提取注册信息
服务端收到请求后,先记录日志并进入处理流程,同时确保任何情况下都能正确返回响应。随后从请求中取出用户提供的邮箱地址、验证码 ID 和验证码内容。
第二步:校验邮箱格式
对邮箱地址进行格式检查(例如是否包含 @ 符号、域名是否合法等)。如果格式错误,则立即返回失败响应,提示"邮箱格式错误",流程结束。
第三步:验证验证码
**根据客户端传来的验证码 ID,在 Redis 中查找对应的验证码。**因为验证码在生成时设置了有效期,如果过期 Redis 会自动删除,所以这一步也隐含了过期检查。
- 如果查不到或查到的验证码与用户输入的验证码不一致,就说明验证码错误,返回失败响应,流程结束。
第四步:检查邮箱是否已被注册
通过邮箱地址查询 MySQL 数据库,判断该邮箱是否已经注册过用户。
- 如果查询到已有记录,说明该邮箱已被占用,返回失败响应,提示"该邮箱已注册过用户",流程结束。
第五步:向数据库写入新用户信息
通过上述检查后,生成一个全局唯一的用户 ID,并将用户 ID 与邮箱地址绑定,存入 MySQL 数据库。
- 如果写入失败,返回"向数据库添加用户信息失败"的错误响应。
第六步:同步至搜索引擎
将用户的关键信息(如用户 ID、邮箱等)写入 Elasticsearch,以便后续支持通过邮箱或用户 ID 进行搜索。
- 如果写入失败,返回"ES 搜索引擎新增数据失败"的错误响应。
第七步:返回成功响应
所有操作成功后,设置响应中的成功标识,告知客户端注册完成。至此,邮箱注册流程结束。
整个过程中,Redis 用于验证码的临时存储和校验,MySQL 用于持久化用户数据并保证邮箱唯一性,Elasticsearch 用于写入用户信息以支持搜索功能。任何一步失败都会立即中断并返回明确的错误提示,同时记录详细日志便于排查。
这里其实有一个细节,就是我们使用邮箱注册新用户的时候,默认情况是将用户ID作为用户的默认昵称存进了Elasticsearch里面,而数据库中的昵称字段是空的 。
1.1.5.邮箱登录
我们是怎么进行邮箱登录的?
其实就是邮箱+验证码进行邮箱登录的。
但是这个验证码是用户通过调用邮箱验证码获取RPC服务来获取的,服务器会在响应里返回一个验证码ID给我们的客户端。同时,我们用户的邮箱也会收到验证码。
那么我们的请求里面就应该包含3样东西
- 邮箱
- 验证码
- 验证码ID
至于响应啊,我们只需要注意一个东西即可:会话ID
**用户登录成功后,服务端会为其分配一个全局唯一的会话 ID。**这个会话 ID 与用户 ID 是一一对应的关系,作为用户在本次登录会话中的身份凭证。
会话 ID 通常在验证用户身份、确认未在其他地方重复登录后生成,一般采用 UUID 或类似的唯一字符串。服务端会将会话 ID 与用户 ID 的映射关系存入 Redis,同时标记该用户为"已登录"状态。随后,服务端将这个会话 ID 返回给客户端,客户端需要在后续请求中携带该 ID 以证明自己的登录状态。
**当客户端发起需要身份认证的请求时,服务端根据请求中携带的会话 ID,从 Redis 中查找对应的用户 ID,从而识别出具体是哪个用户,并确认其登录状态是否仍然有效。**这种方式实现了无状态的鉴权机制,服务端无需在本地存储登录信息,每次请求都通过 Redis 查询完成身份识别,既保证了水平扩展能力,也让客户端无需重复输入密码即可保持登录状态。
当用户主动退出登录,或因超时导致会话失效时,服务端会将该会话 ID 与用户 ID 的映射关系从 Redis 中删除,同时清除用户的登录标记。此后该会话 ID 即失效,无法再用于身份认证,从而确保会话的安全性和状态的准确控制。
cpp
//----------------------------
// 邮箱登录请求
message EmailLoginReq
{
string request_id = 1; // 请求唯一标识
string email = 2; // 邮箱地址
string verify_code_id = 3; // 验证码 ID
string verify_code = 4; // 验证码
}
// 邮箱登录响应
message EmailLoginRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否登录成功
string errmsg = 3; // 错误信息
string login_session_id = 4; // 登录成功后返回的会话 ID
}
那么我们就来编写我们的
cpp
// 邮箱登录RPC接口实现
virtual void EmailLogin(::google::protobuf::RpcController *controller,
const ::IMS::EmailLoginReq *request,
::IMS::EmailLoginRsp *response,
::google::protobuf::Closure *done)
{
LOG_DEBUG("收到邮箱登录请求!");
brpc::ClosureGuard rpc_guard(done);
// 定义错误响应辅助函数
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出邮箱、验证码ID和验证码
std::string email = request->email();
std::string code_id = request->verify_code_id();
std::string code = request->verify_code();
// 2. 检查邮箱格式是否合法
bool ret = email_check(email);
if (ret == false)
{
LOG_ERROR("{} - 邮箱格式错误 - {}!", request->request_id(), email);
return err_response(request->request_id(), "邮箱格式错误!");
}
// 3. 根据邮箱从数据库查询用户信息,判断用户是否存在
auto user = _mysql_user->select_by_email(email);//通过邮箱来数据库查询用户信息
if (!user)
{
LOG_ERROR("{} - 该邮箱未注册用户 - {}!", request->request_id(), email);
return err_response(request->request_id(), "该邮箱未注册用户!");
}
// 4. 从redis中验证验证码ID和验证码是否匹配
auto vcode = _redis_codes->code(code_id);//这一步暗含了判断验证码是否过期,因为验证码过期就会被Redis自动删除
if (vcode != code)
{
LOG_ERROR("{} - 验证码错误 - {}-{}!", request->request_id(), code_id, code);
return err_response(request->request_id(), "验证码错误!");
}
_redis_codes->remove(code_id); // 验证通过后删除验证码,防止重复使用
// 5. 检查用户是否已经登录(防止重复登录)
ret = _redis_status->exists(user->user_id());//通过存在性判断,来判断用户是否在线,如果在线,说明已经登陆了
if (ret == true)
{
LOG_ERROR("{} - 用户已在其他地方登录 - {}!", request->request_id(), email);
return err_response(request->request_id(), "用户已在其他地方登录!");
}
// 6. 生成会话ID,并保存会话信息到redis
std::string ssid = uuid(); // 生成唯一会话ID
_redis_session->append(ssid, user->user_id()); // 保存会话ID与用户ID的映射
// 7. 添加用户登录状态标记
_redis_status->append(user->user_id());
// 8. 组织响应,返回会话ID
response->set_request_id(request->request_id());
response->set_login_session_id(ssid);
response->set_success(true);
}
这个 RPC 服务实现的是邮箱验证码登录功能,即用户通过邮箱地址和收到的验证码完成登录,无需输入密码。整个流程围绕"验证邮箱 → 确认用户存在 → 校验验证码 → 防重复登录 → 创建会话"展开。
第一步:接收请求并提取信息
服务端收到请求后记录日志,进入处理流程并确保任何情况下都能正确返回响应。从请求中取出用户提供的邮箱地址、验证码 ID 和验证码内容。
第二步:校验邮箱格式
检查邮箱地址格式是否合法(如是否包含 @ 符号、域名是否正确等)。
- 如果格式错误,立即返回失败响应,提示"邮箱格式错误"。
第三步:查询用户是否存在
根据邮箱地址查询 MySQL 数据库,判断该邮箱是否已注册用户。
- 如果查不到对应记录,说明该邮箱尚未注册,返回失败响应,提示"该邮箱未注册用户"。
第四步:验证验证码
**根据客户端传来的验证码 ID,在 Redis 中查找对应的验证码。**验证码在生成时会设置有效期,过期自动删除,因此这一步也隐含了过期检查。
-
如果查不到或查到的验证码与用户输入的不一致,返回失败响应,提示"验证码错误"。
-
验证通过后,立即从 Redis 中删除该验证码,防止被重复使用。
第五步:检查是否已登录
通过 Redis 中存储的用户在线状态,判断该用户当前是否已经处于登录状态。
- 如果检测到用户已在线,说明账号已在其他地方登录,返回失败响应,提示"用户已在其他地方登录",防止重复登录。
第六步:创建会话并标记登录状态
通过上述所有检查后,生成一个全局唯一的会话 ID(如 UUID)。
-
将会话 ID 与用户 ID 的映射关系存入 Redis,用于后续鉴权时识别用户身份。
-
同时标记该用户为"已登录"状态,写入 Redis,用于后续的重复登录检查。
第七步:返回成功响应
**将生成的会话 ID 返回给客户端,用户登录成功。**客户端后续可以携带这个会话 ID 进行身份认证,无需再次输入验证码。
整个过程中,MySQL 用于持久化用户信息(判断邮箱是否已注册),Redis 承担了三项临时数据管理:验证码的临时存储与校验、用户在线状态的标记与检查、会话 ID 与用户 ID 的映射存储。任何一步失败都会立即中断并返回明确的错误提示,同时记录详细日志便于排查。
1.2.用户登陆后的服务
那么接下来的都是用户登陆之后才能调用的RPC服务,那么我们知道用户登陆后,Redis里面会存储<会话ID,用户ID>,那么我们就借助这个键值对来判断是哪个用户发起的请求。
所以我们会在下面的所以请求定义里面看到会话ID+用户ID
1.2.1.获取当前登陆的用户信息
首先我们需要搞明白,我们需要获取啥用户信息??
那么我们就需要去base.proto里面看看用户信息的定义了
cpp
// 用户信息结构,用于表示一个用户的基本资料
message UserInfo
{
string user_id = 1; // 用户ID,唯一标识一个用户
string nickname = 2; // 用户昵称
string description = 3; // 个人签名/描述
string email = 4; // 绑定邮箱号,可用于登录或联系
bytes avatar = 5; // 头像图片的二进制数据
}
这些信息就是我们需要获取的信息。
那么我们的响应里面就必须有这么一个UserInfo的成员。
至于我们的请求啊!!
由于我们获取的是当前登陆的用户信息,**用户登录成功后,服务端会为其分配一个全局唯一的会话 ID。**这个会话 ID 与用户 ID 是一一对应的关系,作为用户在本次登录会话中的身份凭证。
所以我们就通过这个会话ID+用户ID来锁定我们登陆的用户
cpp
//----------------------------
// 获取当前登录用户信息请求
// 客户端只需填充 session_id,user_id 由网关鉴权后填入
message GetUserInfoReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID,用于身份识别
}
// 获取当前登录用户信息响应
message GetUserInfoRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否成功获取
string errmsg = 3; // 错误信息
UserInfo user_info = 4; // 用户详细信息(定义在 base.proto)
}
那么我们就能写出我们的
cpp
// 获取当前登录用户信息
virtual void GetUserInfo(::google::protobuf::RpcController *controller,
const ::IMS::GetUserInfoReq *request,
::IMS::GetUserInfoRsp *response,
::google::protobuf::Closure *done)
{
LOG_DEBUG("收到获取当前登陆的用户信息请求!");
brpc::ClosureGuard rpc_guard(done);
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出用户 ID
std::string uid = request->user_id();
// 2. 通过用户 ID,从数据库中查询用户信息
auto user = _mysql_user->select_by_id(uid);
if (!user)
{
LOG_ERROR("{} - 未找到用户信息 - {}!", request->request_id(), uid);
return err_response(request->request_id(), "未找到用户信息!");
}
// 3. 根据用户信息中的头像 ID,从文件服务器获取头像文件数据,组织完整用户信息
UserInfo *user_info = response->mutable_user_info();
user_info->set_user_id(user->user_id());
user_info->set_nickname(user->nickname());
user_info->set_description(user->description());
user_info->set_email(user->email());
// 如果用户有头像 ID,则从文件服务获取头像内容
if (!user->avatar_id().empty())
{
// 从信道管理对象中,获取到连接了文件管理子服务的 channel
auto channel = _mm_channels->choose(_file_service_name);
if (!channel)
{
// 未找到可用的文件服务节点,记录错误并返回
LOG_ERROR("{} - 未找到文件管理子服务节点 - {} - {}!",
request->request_id(), _file_service_name, uid);
return err_response(request->request_id(), "未找到文件管理子服务节点!");
}
// 创建文件服务的 stub 对象,用于发起 RPC 调用
IMS::FileService_Stub stub(channel.get());
IMS::GetSingleFileReq req; // 请求消息
IMS::GetSingleFileRsp rsp; // 响应消息
req.set_request_id(request->request_id()); // 传递请求ID
req.set_file_id(user->avatar_id()); // 设置要下载的文件ID(头像ID)
brpc::Controller cntl; // 控制 RPC 调用的对象
stub.GetSingleFile(&cntl, &req, &rsp, nullptr); // 同步调用文件下载接口
// 检查 RPC 调用是否失败,或者文件服务返回失败状态
if (cntl.Failed() == true || rsp.success() == false)
{
LOG_ERROR("{} - 文件子服务调用失败:{}!", request->request_id(), cntl.ErrorText());
return err_response(request->request_id(), "文件子服务调用失败!");
}
// 将获取到的文件内容设置到用户信息的 avatar 字段
user_info->set_avatar(rsp.file_data().file_content());
}
// 4. 组织响应,返回用户信息
response->set_request_id(request->request_id());
response->set_success(true);
}
这个 RPC 服务实现的是获取当前登陆用户信息的功能,主要用于根据用户 ID 查询该用户的详细信息,并可能附带头像数据。整个流程围绕"查询用户基本数据 → 按需拉取头像文件 → 聚合返回"展开。
第一步:接收请求并提取用户 ID
服务端收到请求后记录日志,进入处理流程并确保任何情况下都能正确返回响应。从请求中取出需要查询的用户 ID。
第二步:从数据库查询用户基本信息
根据用户 ID 在 MySQL 数据库中查找对应的用户记录。
- 如果查不到,说明用户不存在,返回失败响应,提示"未找到用户信息"。
第三步:构建响应中的用户信息
如果查询到用户记录,先在响应中填充用户的基本信息,包括用户 ID、昵称、个人简介、邮箱地址等。这些数据直接从数据库返回。
第四步:按需获取头像文件
检查用户记录中是否有头像 ID。
-
如果没有头像 ID,则直接跳过这一步。
-
如果有头像 ID,则需要从文件存储子服务中获取头像文件的数据。
首先通过服务发现选择一个可用的文件管理子服务节点;然后发起一个 RPC 请求,调用文件服务的下载接口,传入头像 ID 来获取文件内容。
- 如果找不到可用的文件服务节点,或 RPC 调用失败,则返回失败响应,提示"文件子服务调用失败"。
-
成功获取到头像文件数据后,将其填充到用户信息中的头像字段。
第五步:返回完整用户信息
将所有信息(包括基本信息和头像数据)组织到响应中,标记成功并返回给客户端。
整个过程中,MySQL 存储用户的基本资料(非头像),文件服务负责存储头像文件等静态资源,通过服务间的 RPC 调用实现数据聚合。这种设计将用户信息与文件存储解耦,便于独立扩展和维护。任何一步失败都会及时返回明确的错误信息,并记录详细日志。
特别注意:
- 由于头像这类数据具有特殊性,它属于非结构化的二进制文件,无法像昵称、用户 ID 那样以文本形式直接存储在数据库字段中并直接获取。
- 通常的做法是将头像以二进制文件的形式单独保存到服务器端的文件系统或对象存储中,而在数据库里只存放文件路径或唯一标识。
- 因此,当客户端需要展示用户头像时,无法通过一次普通的接口调用直接拿到头像数据,而是必须先向服务器请求该二进制文件,即通过文件下载的方式获取,再在本地进行解析和展示。
所以就会使用到文件存储子服务的下载单个文件的RPC请求,我们见file.proto的定义
cpp// 下载单个文件的请求消息 message GetSingleFileReq { string request_id = 1; // 请求ID,用于唯一标识本次请求 string file_id = 2; // 要下载的文件ID optional string user_id = 3; // 用户ID(可选),用于鉴权或记录,optional表示可选 optional string session_id = 4;// 会话ID(可选),用于标识所属会话 } // 下载单个文件的响应消息 message GetSingleFileRsp { string request_id = 1; // 请求ID,与请求对应 bool success = 2; // 操作是否成功 string errmsg = 3; // 错误信息(如果失败) optional FileDownloadData file_data = 4; // 文件下载数据(成功时返回),optional表示可选 }
1.2.2.获取多个用户信息
这里就不存在说什么会话ID了,因为它就是单纯获取多个用户的信息,不管你登陆还是不登陆。
那么剩下的部分的这个思路其实和这个获取单个用户的差不多啊,只不过需要注意的是,这里使用的是数组来保存uid和用户数据。
cpp
//----------------------------
// 批量获取用户信息
message GetMultiUserInfoReq
{
string request_id = 1; // 请求唯一标识
repeated string users_id = 2; // 需要查询的用户 ID 列表
}
// 批量获取用户信息响应
message GetMultiUserInfoRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否成功
string errmsg = 3; // 错误信息
map<string, UserInfo> users_info = 4; // 用户 ID 到用户信息的映射
}
我们直接定义
cpp
// 获取批量用户信息
virtual void GetMultiUserInfo(::google::protobuf::RpcController *controller,
const ::IMS::GetMultiUserInfoReq *request,
::IMS::GetMultiUserInfoRsp *response,
::google::protobuf::Closure *done)
{
// 记录收到批量请求的日志
LOG_DEBUG("收到批量用户信息获取请求!");
// 确保在函数退出时调用 done->Run()
brpc::ClosureGuard rpc_guard(done);
// 1. 定义错误回调 lambda,用于统一返回错误响应
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 2. 从请求中取出用户ID列表
std::vector<std::string> uid_lists;
for (int i = 0; i < request->users_id_size(); i++)
{
uid_lists.push_back(request->users_id(i));
}
// 3. 从数据库进行批量用户信息查询
auto users = _mysql_user->select_multi_users(uid_lists);
// 检查查询到的用户数量是否与请求中的ID数量一致,防止数据缺失
if (users.size() != request->users_id_size())
{
LOG_ERROR("{} - 从数据库查找的用户信息数量不一致 {}-{}!",
request->request_id(), request->users_id_size(), users.size());
return err_response(request->request_id(), "从数据库查找的用户信息数量不一致!");
}
// 4. 批量从文件管理子服务进行头像文件下载
// 获取连接文件服务的 channel
auto channel = _mm_channels->choose(_file_service_name);
if (!channel)
{
LOG_ERROR("{} - 未找到文件管理子服务节点 - {}!", request->request_id(), _file_service_name);
return err_response(request->request_id(), "未找到文件管理子服务节点!");
}
// 创建文件服务的 stub 对象
IMS::FileService_Stub stub(channel.get());
IMS::GetMultiFileReq req; // 批量文件下载请求
IMS::GetMultiFileRsp rsp; // 批量文件下载响应
req.set_request_id(request->request_id());
// 遍历用户列表,收集所有非空的头像 ID 用于批量下载
for (auto &user : users)
{
if (user.avatar_id().empty())
continue;
req.add_file_id_list(user.avatar_id());
}
// 同步调用文件服务的批量下载接口
brpc::Controller cntl;
stub.GetMultiFile(&cntl, &req, &rsp, nullptr);
// 检查 RPC 调用是否成功以及业务响应是否成功
if (cntl.Failed() == true || rsp.success() == false)
{
LOG_ERROR("{} - 文件子服务调用失败:{} - {}!", request->request_id(),
_file_service_name, cntl.ErrorText());
return err_response(request->request_id(), "文件子服务调用失败!");
}
// 5. 组织响应:为每个用户组装完整信息,包括头像内容
for (auto &user : users)
{
auto user_map = response->mutable_users_info(); // 获取响应中的用户信息 map
auto file_map = rsp.mutable_file_data(); // 获取文件服务返回的文件数据 map
UserInfo user_info;
// 填充用户基本信息
user_info.set_user_id(user.user_id());
user_info.set_nickname(user.nickname());
user_info.set_description(user.description());
user_info.set_email(user.email());
// 从文件服务返回的 map 中取出对应头像内容,若头像 ID 为空则 map 中不存在,此时文件内容为空
user_info.set_avatar((*file_map)[user.avatar_id()].file_content());
// 将用户信息存入响应 map,键为用户 ID
(*user_map)[user_info.user_id()] = user_info;
}
// 设置响应头,标记成功
response->set_request_id(request->request_id());
response->set_success(true);
}
这个 RPC 服务实现的是批量获取用户信息的功能,用于一次性查询多个用户的基本信息,并附带上各自的头像数据。整体流程围绕"批量查询数据库 → 批量拉取头像文件 → 聚合返回"展开。
第一步:接收请求并提取用户 ID 列表
服务端收到请求后记录日志,进入处理流程并确保任何情况下都能正确返回响应。从请求中提取出需要查询的用户 ID 列表,保存在一个集合中。
第二步:批量查询数据库获取用户基本信息
根据这些用户 ID,在 MySQL 数据库中一次性查询对应的用户记录。
- 如果查询到的用户数量与请求中的用户 ID 数量不一致(说明某些用户不存在),则返回失败响应,提示"从数据库查找的用户信息数量不一致"。
第三步:批量获取头像文件数据
首先通过服务发现选择一个可用的文件管理子服务节点。然后遍历查询到的用户记录,收集所有非空的头像 ID,构建一个批量下载请求。
-
如果找不到可用的文件服务节点,则返回失败响应,提示"未找到文件管理子服务节点"。
-
发起 RPC 请求调用文件服务的批量下载接口,传入头像 ID 列表,获取对应的文件内容。
-
如果 RPC 调用失败或文件服务返回失败,则返回失败响应,提示"文件子服务调用失败"。
第四步:组装响应数据
将数据库查询到的用户基本信息与从文件服务获取的头像数据组合起来。对于每个用户,填充其用户 ID、昵称、个人简介、邮箱地址,并将头像文件内容(如果存在)一并放入响应。最终将所有用户信息组织成一个映射结构(用户 ID 对应完整的用户信息对象)返回给客户端。
整个过程中,MySQL 负责批量查询用户资料,文件服务通过一次 RPC 调用批量返回多个头像文件,有效减少了网络交互次数,提升了批量场景下的性能。任何一步失败都会及时返回明确的错误信息,并记录详细日志。
特别注意:我们这里使用了文件管理子服务的下载多个文件的RPC服务
cpp// 下载多个文件的请求消息 message GetMultiFileReq { string request_id = 1; // 请求ID optional string user_id = 2; // 用户ID(可选) optional string session_id = 3; // 会话ID(可选) repeated string file_id_list = 4; // 要下载的文件ID列表,repeated 是一个字段修饰符,用来表示该字段可以包含 0 个或多个相同类型的值。简单来说,它相当于一个数组或列表。 } // 下载多个文件的响应消息 message GetMultiFileRsp { string request_id = 1; // 请求ID bool success = 2; // 是否成功 string errmsg = 3; // 错误信息 map<string, FileDownloadData> file_data = 4; // 文件ID到文件下载数据的映射 }
1.2.3.修改用户头像
设置用户头像,首先需要先上传我们的用户头像吧。
- 由于头像这类数据具有特殊性,它属于非结构化的二进制文件,无法像昵称、用户 ID 那样以文本形式直接存储在数据库字段中并直接获取。
- 通常的做法是将头像以二进制文件的形式单独保存到服务器端的文件系统或对象存储中,而在数据库里只存放文件路径或唯一标识。
- 因此,当客户端需要展示用户头像时,无法通过一次普通的接口调用直接拿到头像数据,而是必须先向服务器请求该二进制文件,即通过文件下载的方式获取,再在本地进行解析和展示。
那么这个就又会使用到文件管理服务的上传单个文件的RPC服务(file.proto里的定义如下)
cpp
// 上传单个文件的请求消息
message PutSingleFileReq {
string request_id = 1; // 请求ID
optional string user_id = 2; // 用户ID(可选)
optional string session_id = 3; // 会话ID(可选)
FileUploadData file_data = 4; // 文件上传数据
}
// 上传单个文件的响应消息
message PutSingleFileRsp {
string request_id = 1; // 请求ID
bool success = 2; // 是否成功
string errmsg = 3; // 错误信息
FileMessageInfo file_info = 4; // 文件元信息(成功时返回)
}
那么我们就需要去定义出我们设置用户头像的请求与响应了。
请求里面肯定是需要有我们的头像数据的,其次我们设置的是当前登陆用户的头像数据,那么势必需要会话ID+用户ID的
cpp
//----------------------------
// 修改用户头像请求
message SetUserAvatarReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID
bytes avatar = 4; // 头像图片数据(二进制)
}
// 修改用户头像响应
message SetUserAvatarRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否修改成功
string errmsg = 3; // 错误信息
}
那么我们很快就能定义出下面这个RPC服务的接口
cpp
// 设置用户头像(RPC服务接口实现)
virtual void SetUserAvatar(::google::protobuf::RpcController *controller,
const ::IMS::SetUserAvatarReq *request,
::IMS::SetUserAvatarRsp *response,
::google::protobuf::Closure *done)
{
// 记录收到头像设置请求的日志
LOG_DEBUG("收到用户头像设置请求!");
// 确保函数退出时自动调用 done->Run()
brpc::ClosureGuard rpc_guard(done);
// 定义错误响应 lambda,统一返回失败响应
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出用户 ID 与头像数据
std::string uid = request->user_id();
// 2. 从数据库通过用户 ID 进行用户信息查询,判断用户是否存在
auto user = _mysql_user->select_by_id(uid);
if (!user)
{
LOG_ERROR("{} - 未找到用户信息 - {}!", request->request_id(), uid);
return err_response(request->request_id(), "未找到用户信息!");
}
// 3. 上传头像文件到文件子服务
// 获取连接文件服务的 channel
auto channel = _mm_channels->choose(_file_service_name);
if (!channel)
{
LOG_ERROR("{} - 未找到文件管理子服务节点 - {}!", request->request_id(), _file_service_name);
return err_response(request->request_id(), "未找到文件管理子服务节点!");
}
// 创建文件服务的 stub 对象
IMS::FileService_Stub stub(channel.get());
IMS::PutSingleFileReq req; // 上传文件请求
IMS::PutSingleFileRsp rsp; // 上传文件响应
req.set_request_id(request->request_id());
// 构造文件数据,文件名留空,大小和内容从请求中获取
req.mutable_file_data()->set_file_name("");
req.mutable_file_data()->set_file_size(request->avatar().size());
req.mutable_file_data()->set_file_content(request->avatar());
brpc::Controller cntl;
stub.PutSingleFile(&cntl, &req, &rsp, nullptr); // 同步调用上传接口
// 检查 RPC 调用和业务是否成功
if (cntl.Failed() == true || rsp.success() == false)
{
LOG_ERROR("{} - 文件子服务调用失败:{}!", request->request_id(), cntl.ErrorText());
return err_response(request->request_id(), "文件子服务调用失败!");
}
// 获取文件服务返回的文件 ID(头像 ID)
std::string avatar_id = rsp.file_info().file_id();
// 4. 将返回的头像文件 ID 更新到数据库中
user->avatar_id(avatar_id);
bool ret = _mysql_user->update(user);
if (ret == false)
{
LOG_ERROR("{} - 更新数据库用户头像ID失败 :{}!", request->request_id(), avatar_id);
return err_response(request->request_id(), "更新数据库用户头像ID失败!");
}
// 5. 更新 ES 服务器中用户信息(搜索引擎索引)
ret = _es_user->appendData(user->user_id(), user->email(),
user->nickname(), user->description(), user->avatar_id());
if (ret == false)
{
LOG_ERROR("{} - 更新搜索引擎用户头像ID失败 :{}!", request->request_id(), avatar_id);
return err_response(request->request_id(), "更新搜索引擎用户头像ID失败!");
}
// 6. 组织响应,返回更新成功
response->set_request_id(request->request_id());
response->set_success(true);
}
这个 RPC 服务实现的是设置用户头像的功能,用于将用户上传的图片保存为头像,并更新相关存储中的用户信息。
第一步:接收请求并提取信息
服务端收到请求后记录日志,进入处理流程并确保任何情况下都能正确返回响应。从请求中取出用户 ID 和头像文件数据(二进制内容)。
第二步:验证用户是否存在
根据用户 ID 在 MySQL 数据库中查询用户信息。
- 如果查不到对应记录,说明用户不存在,返回失败响应,提示"未找到用户信息"。
第三步:上传头像文件到文件服务
通过服务发现选择一个可用的文件管理子服务节点。构造一个文件上传请求,将头像文件数据 (文件名留空,大小和内容从请求中获取)发送给文件服务。
-
如果找不到可用的文件服务节点,或文件服务的上传接口调用失败,则返回失败响应,提示"文件子服务调用失败"。
-
文件服务成功处理后,会返回一个唯一的文件 ID,这个 ID 将作为头像 ID 使用。
第四步:更新数据库中的头像 ID
将文件服务返回的头像 ID 更新到 MySQL 数据库中对应用户的记录里,保存头像与用户的关联。
- 如果数据库更新失败,返回失败响应,提示"更新数据库用户头像ID失败"。
第五步:同步更新搜索引擎
将用户的最新信息(包括更新后的头像 ID)同步到 Elasticsearch 中,确保搜索引擎中的用户数据与数据库保持一致,便于后续搜索和展示。
- 如果同步失败,返回失败响应,提示"更新搜索引擎用户头像ID失败"。
第六步:返回成功响应
所有操作成功后,设置响应中的成功标识,告知客户端头像设置完成。
整个过程中,MySQL 存储用户与头像的关联关系,文件服务 负责存储头像文件的实际数据并返回文件 ID,Elasticsearch 保持搜索数据的同步。任何一步失败都会立即中断并返回明确的错误提示,同时记录详细日志便于排查。
1.2.4.修改用户昵称
那么修改用户昵称,肯定是修改我们登陆的这个用户的昵称,那肯定少不了这个会话ID+用户ID的。然后还需要我们想要修改的昵称
cpp
//----------------------------
// 修改用户昵称请求
message SetUserNicknameReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID
string nickname = 4; // 新昵称
}
// 修改用户昵称响应
message SetUserNicknameRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否修改成功
string errmsg = 3; // 错误信息
}
这个就还好吧
cpp
// 修改用户昵称
virtual void SetUserNickname(::google::protobuf::RpcController *controller,
const ::IMS::SetUserNicknameReq *request,
::IMS::SetUserNicknameRsp *response,
::google::protobuf::Closure *done)
{
// 记录收到昵称设置请求的日志
LOG_DEBUG("收到用户昵称设置请求!");
// 确保函数退出时自动调用 done->Run()
brpc::ClosureGuard rpc_guard(done);
// 定义错误响应 lambda,统一返回失败响应
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出用户 ID 与新的昵称
std::string uid = request->user_id();
std::string new_nickname = request->nickname();
// 2. 判断昵称格式是否正确(长度、字符等校验)
bool ret = nickname_check(new_nickname);
if (ret == false)
{
LOG_ERROR("{} - 用户名长度不合法!", request->request_id());
return err_response(request->request_id(), "用户名长度不合法!");
}
// 3. 从数据库通过用户 ID 进行用户信息查询,判断用户是否存在
auto user = _mysql_user->select_by_id(uid);
if (!user)
{
LOG_ERROR("{} - 未找到用户信息 - {}!", request->request_id(), uid);
return err_response(request->request_id(), "未找到用户信息!");
}
// 4. 将新的昵称更新到数据库中
user->nickname(new_nickname);
ret = _mysql_user->update(user);
if (ret == false)
{
LOG_ERROR("{} - 更新数据库用户昵称失败 :{}!", request->request_id(), new_nickname);
return err_response(request->request_id(), "更新数据库用户昵称失败!");
}
// 5. 更新 ES 服务器中用户信息(搜索引擎索引)
ret = _es_user->appendData(user->user_id(), user->email(),
user->nickname(), user->description(), user->avatar_id());
if (ret == false)
{
LOG_ERROR("{} - 更新搜索引擎用户昵称失败 :{}!", request->request_id(), new_nickname);
return err_response(request->request_id(), "更新搜索引擎用户昵称失败!");
}
// 6. 组织响应,返回更新成功
response->set_request_id(request->request_id());
response->set_success(true);
}
这个 RPC 服务实现的是设置用户昵称的功能,用于让用户修改自己的昵称,并同步更新相关存储。
第一步:接收请求并提取信息
服务端收到请求后记录日志,进入处理流程并确保任何情况下都能正确返回响应。从请求中取出用户 ID 和新昵称。
第二步:校验新昵称格式
检查新昵称是否符合长度限制(小于 22 个字符)。
- 如果格式不合法,立即返回失败响应,提示"用户名长度不合法"。
第三步:验证用户是否存在
根据用户 ID 在 MySQL 数据库中查询用户信息。
- 如果查不到对应记录,说明用户不存在,返回失败响应,提示"未找到用户信息"。
第四步:更新数据库中的昵称
将用户记录中的昵称字段更新为新昵称,并写回 MySQL 数据库。
- 如果更新失败,返回失败响应,提示"更新数据库用户昵称失败"。
第五步:同步更新搜索引擎
将用户的最新信息(包括更新后的昵称)同步到 Elasticsearch 中,确保搜索引擎中的用户数据与数据库保持一致,便于后续搜索和展示。
- 如果同步失败,返回失败响应,提示"更新搜索引擎用户昵称失败"。
第六步:返回成功响应
所有操作成功后,设置响应中的成功标识,告知客户端昵称修改完成。
整个过程中,MySQL 负责持久化存储用户昵称,Elasticsearch 负责保持搜索数据的同步,确保用户在修改昵称后能立即在搜索结果中体现。任何一步失败都会立即中断并返回明确的错误提示,同时记录详细日志便于排查。
1.2.5.修改用户签名
这个其实和之前的都差不多了
cpp
//----------------------------
// 修改用户签名请求
message SetUserDescriptionReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID
string description = 4; // 新签名内容
}
// 修改用户签名响应
message SetUserDescriptionRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否修改成功
string errmsg = 3; // 错误信息
}
我们直接写
cpp
virtual void SetUserDescription(::google::protobuf::RpcController *controller,
const ::IMS::SetUserDescriptionReq *request,
::IMS::SetUserDescriptionRsp *response,
::google::protobuf::Closure *done)
{
LOG_DEBUG("收到用户签名设置请求!");
brpc::ClosureGuard rpc_guard(done);
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出用户 ID 与新的昵称
std::string uid = request->user_id();
std::string new_description = request->description();
// 3. 从数据库通过用户 ID 进行用户信息查询,判断用户是否存在
auto user = _mysql_user->select_by_id(uid);
if (!user)
{
LOG_ERROR("{} - 未找到用户信息 - {}!", request->request_id(), uid);
return err_response(request->request_id(), "未找到用户信息!");
}
// 4. 将新的昵称更新到数据库中
user->description(new_description);
bool ret = _mysql_user->update(user);
if (ret == false)
{
LOG_ERROR("{} - 更新数据库用户签名失败 :{}!", request->request_id(), new_description);
return err_response(request->request_id(), "更新数据库用户签名失败!");
}
// 5. 更新 ES 服务器中用户信息
ret = _es_user->appendData(user->user_id(), user->email(),
user->nickname(), user->description(), user->avatar_id());
if (ret == false)
{
LOG_ERROR("{} - 更新搜索引擎用户签名失败 :{}!", request->request_id(), new_description);
return err_response(request->request_id(), "更新搜索引擎用户签名失败!");
}
// 6. 组织响应,返回更新成功与否
response->set_request_id(request->request_id());
response->set_success(true);
}
这个 RPC 服务实现的是**设置用户签名(个人简介)**的功能,用于让用户修改自己的个性签名,并同步更新相关存储。
第一步:接收请求并提取信息
服务端收到请求后记录日志,进入处理流程并确保任何情况下都能正确返回响应。从请求中取出用户 ID 和新的签名内容。
第二步:验证用户是否存在
根据用户 ID 在 MySQL 数据库中查询用户信息。
- 如果查不到对应记录,说明用户不存在,返回失败响应,提示"未找到用户信息"。
第三步:更新数据库中的签名
将用户记录中的签名字段更新为新内容,并写回 MySQL 数据库。
- 如果更新失败,返回失败响应,提示"更新数据库用户签名失败"。
第四步:同步更新搜索引擎
将用户的最新信息(包括更新后的签名)同步到 Elasticsearch 中,确保搜索引擎中的用户数据与数据库保持一致,便于后续搜索和展示。
- 如果同步失败,返回失败响应,提示"更新搜索引擎用户签名失败"。
第五步:返回成功响应
所有操作成功后,设置响应中的成功标识,告知客户端签名修改完成。
整个过程中,MySQL 负责持久化存储用户签名,Elasticsearch 负责保持搜索数据的同步,确保用户在修改签名后能立即在搜索结果或用户信息展示中生效。任何一步失败都会立即中断并返回明确的错误提示,同时记录详细日志便于排查。
1.2.6.修改用户邮箱
首先我们修改用户邮箱,这个是需要我们发邮件验证码去确认的。
但是这个验证码是用户通过调用邮箱验证码获取RPC服务来获取的,服务器会在响应里返回一个验证码ID给我们的客户端。同时,我们用户的邮箱也会收到验证码。
那么我们的请求里面就应该包含3样东西
- 邮箱
- 验证码
- 验证码ID
至于会话ID+用户ID我们就不说了,这个是最基本的东西
cpp
//----------------------------
// 修改用户邮箱请求
message SetUserEmailReq
{
string request_id = 1; // 请求唯一标识
optional string user_id = 2; // 用户 ID(网关鉴权后自动填充)
optional string session_id = 3; // 会话 ID
string email = 4; // 新邮箱地址
string verify_code_id = 5; // 验证码 ID
string verify_code = 6; // 验证码
}
// 修改用户邮箱响应
message SetUserEmailRsp
{
string request_id = 1; // 对应请求中的 request_id
bool success = 2; // 是否修改成功
string errmsg = 3; // 错误信息
}
我们直接写
cpp
//修改用户邮箱
virtual void SetUserEmail(::google::protobuf::RpcController *controller,
const ::IMS::SetUserEmailReq *request,
::IMS::SetUserEmailRsp *response,
::google::protobuf::Closure *done)
{
LOG_DEBUG("收到用户邮箱设置请求!");
brpc::ClosureGuard rpc_guard(done);
auto err_response = [this, response](const std::string &rid,
const std::string &errmsg) -> void
{
response->set_request_id(rid);
response->set_success(false);
response->set_errmsg(errmsg);
return;
};
// 1. 从请求中取出用户 ID 与新的邮箱
std::string uid = request->user_id();
std::string new_email = request->email();
std::string code = request->verify_code();
std::string code_id = request->verify_code_id();
// 2. 对验证码进行验证
auto vcode = _redis_codes->code(code_id);
if (vcode != code)
{
LOG_ERROR("{} - 验证码错误 - {}-{}!", request->request_id(), code_id, code);
return err_response(request->request_id(), "验证码错误!");
}
// 3. 从数据库通过用户 ID 进行用户信息查询,判断用户是否存在
auto user = _mysql_user->select_by_id(uid);
if (!user)
{
LOG_ERROR("{} - 未找到用户信息 - {}!", request->request_id(), uid);
return err_response(request->request_id(), "未找到用户信息!");
}
// 4. 将新的邮箱更新到数据库中
user->email(new_email);
bool ret = _mysql_user->update(user);
if (ret == false)
{
LOG_ERROR("{} - 更新数据库用户邮箱失败 :{}!", request->request_id(), new_email);
return err_response(request->request_id(), "更新数据库用户邮箱失败!");
}
// 5. 更新 ES 服务器中用户信息
ret = _es_user->appendData(user->user_id(), user->email(),
user->nickname(), user->description(), user->avatar_id());
if (ret == false)
{
LOG_ERROR("{} - 更新搜索引擎用户邮箱失败 :{}!", request->request_id(), new_email);
return err_response(request->request_id(), "更新搜索引擎用户邮箱失败!");
}
// 6. 组织响应,返回更新成功与否
response->set_request_id(request->request_id());
response->set_success(true);
}
这个 RPC 服务实现的是修改用户邮箱的功能,允许用户通过验证码验证后,将账号绑定的邮箱地址更换为新邮箱,并同步更新相关存储。
第一步:接收请求并提取信息
服务端收到请求后记录日志,进入处理流程并确保任何情况下都能正确返回响应。从请求中取出用户 ID、新邮箱地址、验证码 ID 和验证码内容。
第二步:验证验证码
根据客户端传来的验证码 ID,在 Redis 中查找对应的验证码。验证码在生成时会设置有效期,过期自动删除,因此这一步也隐含了过期检查。
- 如果查不到或查到的验证码与用户输入的不一致,返回失败响应,提示"验证码错误"。
第三步:验证用户是否存在
根据用户 ID 在 MySQL 数据库中查询用户信息。
- 如果查不到对应记录,说明用户不存在,返回失败响应,提示"未找到用户信息"。
第四步:更新数据库中的邮箱
将用户记录中的邮箱字段更新为新邮箱,并写回 MySQL 数据库。
- 如果更新失败,返回失败响应,提示"更新数据库用户邮箱失败"。
第五步:同步更新搜索引擎
将用户的最新信息(包括更新后的邮箱)同步到 Elasticsearch 中,确保搜索引擎中的用户数据与数据库保持一致,便于后续搜索和展示。
- 如果同步失败,返回失败响应,提示"更新搜索引擎用户邮箱失败"。
第六步:返回成功响应
所有操作成功后,设置响应中的成功标识,告知客户端邮箱修改完成。
整个过程中,Redis 负责验证码的临时存储与校验,确保邮箱修改操作由用户本人发起;MySQL 负责持久化存储新邮箱;Elasticsearch 负责保持搜索数据的同步。任何一步失败都会立即中断并返回明确的错误提示,同时记录详细日志便于排查。
二.UserServer类
这个类其实就是我们用户管理子服务的核心类。
这个的实现思想也和之前的文件管理子服务差不多了多少
cpp
// 用户服务类,负责启动和运行用户管理相关的 RPC 服务
class UserServer
{
public:
using ptr = std::shared_ptr<UserServer>;
// 构造函数,初始化用户服务所需的各种客户端和服务器组件
// @param service_discoverer 服务发现客户端,用于发现其他微服务节点
// @param reg_client 服务注册客户端,用于将本服务注册到注册中心
// @param es_client Elasticsearch 客户端,用于用户信息的索引和搜索
// @param mysql_client MySQL 数据库客户端,用于用户数据的持久化存储
// @param redis_client Redis 客户端,用于缓存或会话管理
// @param server brpc 服务器对象,用于启动 RPC 服务
UserServer(const Discovery::ptr service_discoverer,
const Registry::ptr ®_client,
const std::shared_ptr<elasticlient::Client> &es_client,
const std::shared_ptr<odb::core::database> &mysql_client,
std::shared_ptr<sw::redis::Redis> &redis_client,
const std::shared_ptr<brpc::Server> &server) : _service_discoverer(service_discoverer),
_registry_client(reg_client),
_es_client(es_client),
_mysql_client(mysql_client),
_redis_client(redis_client),
_rpc_server(server) {}
// 析构函数(默认实现)
~UserServer() {}
// 搭建 RPC 服务器并启动,进入事件循环直到进程退出
void start()
{
_rpc_server->RunUntilAskedToQuit();
}
private:
// 服务发现客户端,用于获取其他微服务的地址信息
Discovery::ptr _service_discoverer;
// 服务注册客户端,用于向注册中心上报本服务信息
Registry::ptr _registry_client;
// Elasticsearch 客户端,用于用户信息的索引和搜索
std::shared_ptr<elasticlient::Client> _es_client;
// MySQL 数据库客户端,用于用户数据的持久化存储
std::shared_ptr<odb::core::database> _mysql_client;
// Redis 客户端,用于缓存或会话管理
std::shared_ptr<sw::redis::Redis> _redis_client;
// brpc 服务器对象,用于接收和处理 RPC 请求
std::shared_ptr<brpc::Server> _rpc_server;
};
brpc::Server 的 RunUntilAskedToQuit() 方法用于阻塞当前线程,启动服务器的服务循环,使服务器持续处理 RPC 请求,直到收到明确的退出指令(如调用 Stop() 或接收到中断信号)。
简单来说,它让服务器开始运行并等待停止,通常在主线程中调用,以保证进程不会立即退出。
三.UserServerBuilder类
我们发现上面的UserServer类的构造函数是个拷贝构造函数
cpp
// 构造函数,初始化用户服务所需的各种客户端和服务器组件
// @param service_discoverer 服务发现客户端,用于发现其他微服务节点
// @param reg_client 服务注册客户端,用于将本服务注册到注册中心
// @param es_client Elasticsearch 客户端,用于用户信息的索引和搜索
// @param mysql_client MySQL 数据库客户端,用于用户数据的持久化存储
// @param redis_client Redis 客户端,用于缓存或会话管理
// @param server brpc 服务器对象,用于启动 RPC 服务
UserServer(const Discovery::ptr service_discoverer,
const Registry::ptr ®_client,
const std::shared_ptr<elasticlient::Client> &es_client,
const std::shared_ptr<odb::core::database> &mysql_client,
std::shared_ptr<sw::redis::Redis> &redis_client,
const std::shared_ptr<brpc::Server> &server) : _service_discoverer(service_discoverer),
_registry_client(reg_client),
_es_client(es_client),
_mysql_client(mysql_client),
_redis_client(redis_client),
_rpc_server(server) {}
那么这就说明我们需要在构造这个UserServer类之前就把这些成员变量先定义好。然后再进行通用的构造,那么我们的UserServerBuilder类就是干这件事的,它是专门用于构造这个UserServer类对象的,那么我们很容易就得知,这个UserServerBuilder类的成员变量肯定是和上面UserServer类构造函数里面的那些是一模一样的
cpp
// 用户服务构建器类,采用建造者模式,用于逐步构建用户服务所需的各个组件,
// 最后构建并返回 UserServer 对象
class UserServerBuilder
{
public:
......
private:
// 服务注册客户端
Registry::ptr _registry_client;
// 各组件客户端对象
std::shared_ptr<elasticlient::Client> _es_client; // Elasticsearch 客户端
std::shared_ptr<odb::core::database> _mysql_client; // MySQL 数据库客户端
std::shared_ptr<sw::redis::Redis> _redis_client; // Redis 客户端
// 服务发现相关
std::string _file_service_name; // 需要管理的文件服务名称
ServiceManager::ptr _mm_channels; // 信道管理器,维护下游服务的连接
Discovery::ptr _service_discoverer; // 服务发现对象
// 邮件客户端
std::shared_ptr<MailClient> _mail_client;
// RPC 服务器对象
std::shared_ptr<brpc::Server> _rpc_server;
};
那么剩下的也没什么好讲的,其实都是将这些成员变量分开来构造而已
cpp
// 用户服务构建器类,采用建造者模式,用于逐步构建用户服务所需的各个组件,
// 最后构建并返回 UserServer 对象
class UserServerBuilder
{
public:
// 构造 Elasticsearch 客户端对象
// @param host_list ES 服务器地址列表
void make_es_object(const std::vector<std::string> host_list)
{
_es_client = ESClientFactory::create(host_list);
}
// 构造邮件客户端对象(基于 SMTP 协议发送验证码邮件)
// @param username 邮箱用户名(即邮箱账号)
// @param password 邮箱授权码(需在邮箱设置中获取,不是登录密码)
// @param url SMTP 服务器 URL,例如 163 邮箱为 "smtps://smtp.163.com:465"
// @param from 发件人邮箱地址(通常与 username 相同)
void make_email_object(const std::string &username,
const std::string &password,
const std::string &url,
const std::string &from)
{
// 构建邮件配置结构体
IMS::mail_settings settings{username, password, url, from};
// 创建 MailClient 实例
_mail_client = std::make_shared<IMS::MailClient>(settings);
}
// 构造 MySQL 数据库客户端对象
// @param user 数据库用户名
// @param pswd 数据库密码
// @param host 数据库主机地址
// @param db 数据库名称
// @param cset 字符集
// @param port 端口号
// @param conn_pool_count 连接池大小
void make_mysql_object(
const std::string &user,
const std::string &pswd,
const std::string &host,
const std::string &db,
const std::string &cset,
int port,
int conn_pool_count)
{
_mysql_client = ODBFactory::create(user, pswd, host, db, cset, port, conn_pool_count);
}
// 构造 Redis 客户端对象
// @param host Redis 主机地址
// @param port Redis 端口
// @param db 数据库编号
// @param keep_alive 是否保持长连接
void make_redis_object(const std::string &host,
int port,
int db,
bool keep_alive)
{
_redis_client = RedisClientFactory::create(host, port, db, keep_alive);
}
// 构造服务发现客户端和信道管理对象
// @param reg_host 注册中心地址
// @param base_service_name 基础服务名(通常用于服务发现的根路径)
// @param file_service_name 需要管理的文件子服务名称
void make_discovery_object(const std::string ®_host,
const std::string &base_service_name,
const std::string &file_service_name)
{
_file_service_name = file_service_name;
_mm_channels = std::make_shared<ServiceManager>();
// 声明需要管理的文件服务名称
_mm_channels->declared(file_service_name);//声明需要关系的服务,只有被关系的服务在服务上下线的时候才会去调用服务上下线回调函数
LOG_DEBUG("设置文件子服务为需添加管理的子服务:{}", file_service_name);
// 设置服务上下线的回调函数,将事件通知给 ServiceManager
auto put_cb = std::bind(&ServiceManager::onServiceOnline, _mm_channels.get(), std::placeholders::_1, std::placeholders::_2);
auto del_cb = std::bind(&ServiceManager::onServiceOffline, _mm_channels.get(), std::placeholders::_1, std::placeholders::_2);
// 创建服务发现对象,传入回调以实时感知服务变化
_service_discoverer = std::make_shared<Discovery>(reg_host, base_service_name, put_cb, del_cb);
}
// 构造服务注册客户端对象,并将本服务注册到注册中心
// @param reg_host 注册中心地址
// @param service_name 本服务的名称
// @param access_host 本服务对外暴露的地址(IP:Port)
void make_registry_object(const std::string ®_host,
const std::string &service_name,
const std::string &access_host)
{
_registry_client = std::make_shared<Registry>(reg_host);
_registry_client->registry(service_name, access_host);
}
// 构造 RPC 服务器对象,并启动监听
// @param port 监听端口
// @param timeout 空闲超时时间(秒)
// @param num_threads 工作线程数
void make_rpc_server(uint16_t port, int32_t timeout, uint8_t num_threads)
{
// 检查所有必需的组件是否已经初始化
if (!_es_client)
{
LOG_ERROR("还未初始化ES搜索引擎模块!");
abort();
}
if (!_mysql_client)
{
LOG_ERROR("还未初始化Mysql数据库模块!");
abort();
}
if (!_redis_client)
{
LOG_ERROR("还未初始化Redis数据库模块!");
abort();
}
if (!_mm_channels)
{
LOG_ERROR("还未初始化信道管理模块!");
abort();
}
if (!_mail_client)
{
LOG_ERROR("还未初始化邮箱平台模块!");
abort();
}
// 创建 brpc 服务器实例
_rpc_server = std::make_shared<brpc::Server>();
// 创建用户服务实现对象,并将所有依赖传入
UserServiceImpl *user_service = new UserServiceImpl(_mail_client, _es_client,
_mysql_client, _redis_client, _mm_channels, _file_service_name);
// 将服务添加到 brpc 服务器,并让服务器负责管理服务对象的生命周期
int ret = _rpc_server->AddService(user_service,
brpc::ServiceOwnership::SERVER_OWNS_SERVICE);
if (ret == -1)
{
LOG_ERROR("添加Rpc服务失败!");
abort();
}
// 配置服务器选项
brpc::ServerOptions options;
options.idle_timeout_sec = timeout; // 空闲连接超时
options.num_threads = num_threads; // 工作线程数
// 启动服务器,开始监听端口
ret = _rpc_server->Start(port, &options);
if (ret == -1)
{
LOG_ERROR("服务启动失败!");
abort();
}
}
// 最终构建 UserServer 对象,并返回
// 检查所有必要组件是否已就绪,然后组装 UserServer
// @return 构造好的 UserServer 智能指针
UserServer::ptr build()
{
if (!_service_discoverer)
{
LOG_ERROR("还未初始化服务发现模块!");
abort();
}
if (!_registry_client)
{
LOG_ERROR("还未初始化服务注册模块!");
abort();
}
if (!_rpc_server)
{
LOG_ERROR("还未初始化RPC服务器模块!");
abort();
}
UserServer::ptr server = std::make_shared<UserServer>(
_service_discoverer, _registry_client,
_es_client, _mysql_client, _redis_client, _rpc_server);
return server;
}
private:
// 服务注册客户端
Registry::ptr _registry_client;
// 各组件客户端对象
std::shared_ptr<elasticlient::Client> _es_client; // Elasticsearch 客户端
std::shared_ptr<odb::core::database> _mysql_client; // MySQL 数据库客户端
std::shared_ptr<sw::redis::Redis> _redis_client; // Redis 客户端
// 服务发现相关
std::string _file_service_name; // 需要管理的文件服务名称
ServiceManager::ptr _mm_channels; // 信道管理器,维护下游服务的连接
Discovery::ptr _service_discoverer; // 服务发现对象
// 邮件客户端
std::shared_ptr<MailClient> _mail_client;
// RPC 服务器对象
std::shared_ptr<brpc::Server> _rpc_server;
};
四.搭建用户管理子服务
那么到现在,我们终于是可以搭建起我们的用户管理子服务了。
注意:邮箱信息要换成你自己的哦
cpp
// 主程序入口:用户管理子服务
// 功能:初始化配置、构建用户服务所需组件(邮件、ES、MySQL、Redis、服务发现、RPC服务器),
// 并将服务注册到注册中心,最后启动服务进入事件循环
#include "user_server.hpp"
// ==================== 命令行参数定义 ====================
// 程序运行模式:false=调试模式,true=发布模式
DEFINE_bool(run_mode, false, "程序的运行模式,false-调试; true-发布;");
// 发布模式下日志输出文件路径
DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件");
// 发布模式下日志输出等级
DEFINE_int32(log_level, 0, "发布模式下,用于指定日志输出等级");
// 服务注册中心地址(etcd)
DEFINE_string(registry_host, "http://127.0.0.1:2379", "服务注册中心地址");
// 当前实例在注册中心中的名称(全路径)
DEFINE_string(instance_name, "/user_service/instance", "当前实例名称");
// 当前实例对外暴露的访问地址(IP:Port)
DEFINE_string(access_host, "127.0.0.1:10003", "当前实例的外部访问地址");
// RPC服务器配置
DEFINE_int32(listen_port, 10003, "Rpc服务器监听端口");
DEFINE_int32(rpc_timeout, -1, "Rpc调用超时时间"); // -1表示不超时
DEFINE_int32(rpc_threads, 1, "Rpc的IO线程数量");
// 服务发现相关配置
DEFINE_string(base_service, "/service", "服务监控根目录");
DEFINE_string(file_service, "/service/file_service", "文件管理子服务名称");
// Elasticsearch搜索引擎地址
DEFINE_string(es_host, "http://127.0.0.1:9200/", "ES搜索引擎服务器URL");
// MySQL数据库配置
DEFINE_string(mysql_host, "127.0.0.1", "Mysql服务器访问地址");
DEFINE_string(mysql_user, "root", "Mysql服务器访问用户名");
DEFINE_string(mysql_pswd, "123456", "Mysql服务器访问密码");
DEFINE_string(mysql_db, "IMS", "Mysql默认库名称");
DEFINE_string(mysql_cset, "utf8", "Mysql客户端字符集");
DEFINE_int32(mysql_port, 0, "Mysql服务器访问端口"); // 0表示使用默认端口
DEFINE_int32(mysql_pool_count, 4, "Mysql连接池最大连接数量");
// Redis缓存配置
DEFINE_string(redis_host, "127.0.0.1", "Redis服务器访问地址");
DEFINE_int32(redis_port, 6379, "Redis服务器访问端口");
DEFINE_int32(redis_db, 0, "Redis默认库号");
DEFINE_bool(redis_keep_alive, true, "Redis长连接保活选项");
// 邮件服务配置(用于发送验证码)
DEFINE_string(email_user, "your_email@example.com", "邮箱账号");
DEFINE_string(email_password, "your_authorization_code", "邮箱授权码");
DEFINE_string(email_url, "smtps://smtp.qq.com:465", "SMTP服务器地址");
DEFINE_string(email_from, "your_email@example.com", "发件人邮箱地址");
// ==================== 主函数 ====================
int main(int argc, char *argv[])
{
// 1. 解析命令行参数(gflags)
google::ParseCommandLineFlags(&argc, &argv, true);
// 2. 初始化日志系统(根据运行模式选择日志输出方式)
bite_im::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);
// 3. 创建用户服务器构建器对象
bite_im::UserServerBuilder usb;
// 4. 构建邮件客户端(用于发送验证码邮件)
usb.make_email_object(FLAGS_email_user, FLAGS_email_password, FLAGS_email_url, FLAGS_email_from);
// 5. 构建 Elasticsearch 客户端(用于用户信息搜索)
usb.make_es_object({FLAGS_es_host});
// 6. 构建 MySQL 数据库客户端(用于用户数据持久化)
usb.make_mysql_object(FLAGS_mysql_user, FLAGS_mysql_pswd, FLAGS_mysql_host,
FLAGS_mysql_db, FLAGS_mysql_cset, FLAGS_mysql_port, FLAGS_mysql_pool_count);
// 7. 构建 Redis 客户端(用于缓存、会话等)
usb.make_redis_object(FLAGS_redis_host, FLAGS_redis_port, FLAGS_redis_db, FLAGS_redis_keep_alive);
// 8. 构建服务发现客户端和信道管理器(用于感知文件服务等下游服务的上下线)
usb.make_discovery_object(FLAGS_registry_host, FLAGS_base_service, FLAGS_file_service);
// 9. 构建 RPC 服务器并启动监听(但尚未进入事件循环)
usb.make_rpc_server(FLAGS_listen_port, FLAGS_rpc_timeout, FLAGS_rpc_threads);
// 10. 将本服务注册到注册中心(etcd),供其他服务发现
usb.make_registry_object(FLAGS_registry_host, FLAGS_base_service + FLAGS_instance_name, FLAGS_access_host);
// 11. 组装所有组件,构建最终的 UserServer 对象
auto server = usb.build();
// 12. 启动服务,进入事件循环(阻塞,直到进程被终止)
server->start();
return 0;
}
五.测试
在测试之前,我们需要先去mysql数据库里面创建好一个IMS数据库

这个数据库是我们测试的基础。
其次,我们来思考一下如何进行测试?
我们注册登录成功后,我们需要进行属性的修改操作,但这类操作通常要求同时提供用户ID和会话ID。目前我们只获取到了会话ID,用户ID从哪里来呢?
在实际项目架构中,用户ID的获取是由网关子服务完成的。客户端直接与网关通信,只需发送会话ID,网关便会自动从数据库中查询对应的用户ID,并将其填充到请求中,再转发给用户管理子服务。因此,从客户端视角来看,根本不需要知道用户ID,也无从获取。
然而在测试环境中,由于没有网关介入,我们无法自动获得用户ID,也就无法继续执行登录后的操作。为了解决这个问题,我们决定采用以下方式:
先注册并登录一个用户,然后手动到服务器数据库中查询该用户的ID,将查询到的用户ID与登录返回的会话ID一并用于后续的测试请求。
这样,我们就模拟了网关的鉴权行为,从而顺利完成所有需要用户身份的测试。
5.1.注册测试
我们先进行注册测试
我们使用
- 账号+密码
- 邮箱+验证码
两种方式进行注册,分别注册一个用户。
cpp
//登录进去了,我们就进行属性的更改,但是属性的更改需要用户ID+会话ID,我们这里仅仅只获取到了会话ID,那么用户ID需要去哪里搞呢?
//事实上,我们这个项目是借助一个网关子服务来获取用户ID的,
//客户端其实是和网关进行直连的,然后客户端往网关子服务发送会话ID,网关会自己去数据库中查询对应的uid,填充到客户端的请求里,再去分发给我们的用户管理子服务
//也就是说我们用户根本就不需要知道这个uid到底是啥,没有uid,我们根本就进行不了我们后续登录后的操作,因此,我们这里决定,先注册用户,再去服务器那边查询数据,获取到uid
//后面我们再根据查询到的uid和会话ID来进行登录后的操作
#include "base.pb.h"
// 引入自定义的通信信道头文件
#include "channel.hpp"
// 引入etcd服务发现相关头文件
#include "etcd.hpp"
// 引入用户服务protobuf定义的头文件
#include "user.pb.h"
// 引入工具函数头文件
#include "utils.hpp"
// 引入gflags命令行参数解析库
#include <gflags/gflags.h>
// 引入Google Test框架头文件
#include <gtest/gtest.h>
// 引入线程相关头文件,用于延时
#include <thread>
// 定义命令行参数:运行模式,false为调试模式,true为发布模式
DEFINE_bool(run_mode, false, "程序的运行模式,false-调试; true-发布;");
// 定义命令行参数:发布模式下日志输出文件路径
DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件");
// 定义命令行参数:发布模式下日志输出等级
DEFINE_int32(log_level, 0, "发布模式下,用于指定日志输出等级");
// 定义etcd服务注册中心地址
DEFINE_string(etcd_host, "http://127.0.0.1:2379", "服务注册中心地址");
// 定义服务监控根目录
DEFINE_string(base_service, "/service", "服务监控根目录");
// 定义用户服务监控的根目录
DEFINE_string(user_service, "/service/user_service", "服务监控根目录");
// 全局变量:服务管理器智能指针,用于管理RPC信道
IMS::ServiceManager::ptr _user_channels;
// 测试用例:用户注册测试
TEST(用户子服务测试, 用户注册测试)
{
//通过用户昵称+密码来实现用户注册
// 从服务管理器中随机选择一个用户服务信道
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel); // 断言信道获取成功
// 构造注册请求
IMS::UserRegisterReq req;
req.set_request_id(IMS::uuid()); // 生成唯一请求ID
req.set_nickname("猪妈妈"); // 设置昵称,目前是"猪妈妈"
req.set_password("123456"); // 设置密码
// 构造响应和控制器
IMS::UserRegisterRsp rsp;
brpc::Controller cntl;
// 创建RPC存根,调用远程方法
IMS::UserService_Stub stub(channel.get());
stub.UserRegister(&cntl, &req, &rsp, nullptr);
// 断言RPC调用成功且业务返回成功
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
}
//注册好了用户
//用户昵称:猪妈妈
//用户密码:123456
//现在我们就去登录一下
// 全局变量:存储邮箱验证码ID
std::string code_id;
// 辅助函数:获取邮箱验证码
void get_code()
{
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::EmailVerifyCodeReq req;
req.set_request_id(IMS::uuid());
req.set_email("邮箱"); // 作为邮件接受方
IMS::EmailVerifyCodeRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.GetEmailVerifyCode(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
code_id = rsp.verify_code_id(); // 保存这个验证码ID,用于后续验证
}
// 测试用例:邮箱注册
TEST(用户子服务测试, 邮箱注册)
{
//邮箱注册只需要一个邮箱+验证码即可
//而在服务器里面默认情况是将用户ID作为用户的默认昵称存进了Elasticsearch里面,而数据库中的昵称字段是空的
get_code(); // 先获取验证码
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::EmailRegisterReq req;
req.set_request_id(IMS::uuid());
req.set_email("邮箱");
req.set_verify_code_id(code_id);
std::cout << "通过邮箱注册新用户,请输入验证码:";
std::string code;
std::cin >> code; // 从控制台输入验证码
req.set_verify_code(code);
IMS::EmailRegisterRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.EmailRegister(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
}
// 主函数
int main(int argc, char *argv[])
{
// 解析命令行参数(gflags)
google::ParseCommandLineFlags(&argc, &argv, true);
// 初始化日志系统
IMS::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);
// 1. 构造RPC信道管理对象
_user_channels = std::make_shared<IMS::ServiceManager>();
_user_channels->declared(FLAGS_user_service); // 声明需要监控的服务
// 绑定服务上线和下线回调函数
auto put_cb = std::bind(&IMS::ServiceManager::onServiceOnline, _user_channels.get(), std::placeholders::_1, std::placeholders::_2);
auto del_cb = std::bind(&IMS::ServiceManager::onServiceOffline, _user_channels.get(), std::placeholders::_1, std::placeholders::_2);
// 2. 构造服务发现对象,连接到etcd,监控服务变化
IMS::Discovery::ptr dclient = std::make_shared<IMS::Discovery>(FLAGS_etcd_host, FLAGS_base_service, put_cb, del_cb);
// 初始化Google Test框架
testing::InitGoogleTest(&argc, argv);
LOG_DEBUG("开始测试!");
// 运行所有测试用例
return RUN_ALL_TESTS();
}
编译之后,构建目录里面会生成一个user.sql文件,也就是下面这个

我们先将这个sql文件导入到我们的Mysql数据库里面,创建好我们的用户表
cpp
mysql -u root -D IMS -p <user.sql
导入进去之后,我们去数据库看看

那么接下来我们就运行我们的注册程序
注意:我们需要先运行起来我们的服务端程序,再去运行我们的注册程序


这个时候我们去我们设定的那个邮箱看看

然后我们输入4086

这个时候,我们就需要去数据库看看

可以看到,这个就是我们注册的两个用户。
邮箱注册的用户,默认情况下它的昵称就是用户ID。
那么我们现在就获取到了2个用户的用户ID
- 昵称+密码:5ede-177031f3-0000
- 邮箱+验证码:a02a-a169d131-0002
接下来我们就基于这个来进行登录后的测试
5.2.登录后的操作测试
注意:这里我们需要提前去启动我们的文件管理子服务才能让我们的测试完成。
cpp
// 引入protobuf生成的头文件,包含基础消息定义
#include "base.pb.h"
// 引入自定义的通信信道头文件
#include "channel.hpp"
// 引入etcd服务发现相关头文件
#include "etcd.hpp"
// 引入用户服务protobuf定义的头文件
#include "user.pb.h"
// 引入工具函数头文件
#include "utils.hpp"
// 引入gflags命令行参数解析库
#include <gflags/gflags.h>
// 引入Google Test框架头文件
#include <gtest/gtest.h>
// 引入线程相关头文件,用于延时
#include <thread>
// 定义命令行参数:运行模式,false为调试模式,true为发布模式
DEFINE_bool(run_mode, false, "程序的运行模式,false-调试; true-发布;");
// 定义命令行参数:发布模式下日志输出文件路径
DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件");
// 定义命令行参数:发布模式下日志输出等级
DEFINE_int32(log_level, 0, "发布模式下,用于指定日志输出等级");
// 定义etcd服务注册中心地址
DEFINE_string(etcd_host, "http://127.0.0.1:2379", "服务注册中心地址");
// 定义服务监控根目录
DEFINE_string(base_service, "/service", "服务监控根目录");
// 定义用户服务监控的根目录
DEFINE_string(user_service, "/service/user_service", "服务监控根目录");
// 全局变量:服务管理器智能指针,用于管理RPC信道
IMS::ServiceManager::ptr _user_channels;
// 全局变量:存储当前测试用户的信息
IMS::UserInfo user_info1;
IMS::UserInfo user_info2;
//注意:在运行下面所有测试之前,我们就已经将这个user_info1里面的信息填充好了
/* // 初始化全局测试用户信息
user_info1.set_nickname("猪妈妈");
user_info1.set_user_id("bb76-74207a2e-0000");//这一步本来应该是网关子服务来完成的,不过我们去查询了服务端数据得到的*/
// 全局变量:登录后获得的会话ID
std::string login_ssid;
//注册好了用户
//用户昵称:猪妈妈
//用户密码:123456
//现在我们就去登录一下
// 测试用例:用户登录测试
TEST(用户子服务测试, 用户登录测试)
{
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::UserLoginReq req;
req.set_request_id(IMS::uuid());
req.set_nickname("猪妈妈"); // 使用注册时设置的昵称
req.set_password("123456"); // 使用注册时设置的密码
IMS::UserLoginRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.UserLogin(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
//用户登录之后服务器会返回一个会话ID
login_ssid = rsp.login_session_id(); // 保存登录会话ID供后续测试使用
}
// 测试用例:用户头像设置测试
TEST(用户子服务测试, 用户头像设置测试)
{
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::SetUserAvatarReq req;
req.set_request_id(IMS::uuid());
req.set_user_id(user_info1.user_id()); // 使用全局user_info1的用户ID(注意:这个user_id是我们去服务端查询的)
req.set_session_id(login_ssid); // 使用登录会话ID
req.set_avatar("猪妈妈头像数据");
IMS::SetUserAvatarRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.SetUserAvatar(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
}
// 测试用例:用户签名设置测试
TEST(用户子服务测试, 用户签名设置测试)
{
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::SetUserDescriptionReq req;
req.set_request_id(IMS::uuid());
req.set_user_id(user_info1.user_id());
req.set_session_id(login_ssid);
req.set_description("这是一个美丽的猪妈妈");
IMS::SetUserDescriptionRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.SetUserDescription(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
}
// 测试用例:用户昵称设置测试
TEST(用户子服务测试, 用户昵称设置测试)
{
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::SetUserNicknameReq req;
req.set_request_id(IMS::uuid());
req.set_user_id(user_info1.user_id());
req.set_session_id(login_ssid);
req.set_nickname("亲爱的猪妈妈"); // 设置新昵称
IMS::SetUserNicknameRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.SetUserNickname(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
}
//在上面我们设置了用户的头像,签名,昵称的设置
//我们这里进行了用户的个人信息的更新,那么我们就很有必要去查询一下我们到底有没有更新成功
//那么我们就需要去调用这个用户信息获取RPC服务
// 测试用例:用户信息获取测试
TEST(用户子服务测试, 用户信息获取测试)
{
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::GetUserInfoReq req;
req.set_request_id(IMS::uuid());
req.set_user_id(user_info1.user_id());
req.set_session_id(login_ssid);
IMS::GetUserInfoRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.GetUserInfo(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
//在上面我们设置了用户的头像,签名,昵称的设置
// 验证返回的用户信息是否与预期一致
ASSERT_EQ(user_info1.user_id(), rsp.user_info().user_id());
ASSERT_EQ("亲爱的猪妈妈", rsp.user_info().nickname()); // 昵称应为新设置的
ASSERT_EQ("这是一个美丽的猪妈妈", rsp.user_info().description());
ASSERT_EQ("", rsp.user_info().email()); // 邮箱未设置应为空
ASSERT_EQ("猪妈妈头像数据", rsp.user_info().avatar());
}
// 全局变量:存储邮箱验证码ID
std::string code_id;
// 辅助函数:获取邮箱验证码
void get_code()
{
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::EmailVerifyCodeReq req;
req.set_request_id(IMS::uuid());
req.set_email(user_info2.email()); // 使用全局user_info2中的邮箱,作为邮件接受方
IMS::EmailVerifyCodeRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.GetEmailVerifyCode(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
code_id = rsp.verify_code_id(); // 保存这个验证码ID,用于后续验证
}
// 测试用例:邮箱登录
TEST(用户子服务测试, 邮箱登录)
{
std::this_thread::sleep_for(std::chrono::seconds(3)); // 延时等待,避免验证码过快失效
get_code(); // 重新获取验证码
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::EmailLoginReq req;
req.set_request_id(IMS::uuid());
req.set_email(user_info2.email());
req.set_verify_code_id(code_id);
std::cout << "通过邮箱登录新用户,请输入验证码:" << std::endl;
std::string code;
std::cin >> code;
req.set_verify_code(code);
IMS::EmailLoginRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.EmailLogin(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
std::cout << "邮箱登录会话ID:" << rsp.login_session_id() << std::endl;
}
// 测试用例:邮箱设置
TEST(用户子服务测试, 邮箱设置)
{
std::this_thread::sleep_for(std::chrono::seconds(10)); // 延时,可能为了避免验证码冲突
get_code(); // 获取验证码
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::SetUserEmailReq req;
req.set_request_id(IMS::uuid());
req.set_user_id(user_info2.user_id());
// 设置新的邮箱地址(此处为示例)
req.set_email("新邮箱");
req.set_verify_code_id(code_id);
std::cout << "邮箱设置时,输入验证码:";
std::string code;
std::cin >> code;
req.set_verify_code(code);
IMS::SetUserEmailRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.SetUserEmail(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
}
// 测试用例:批量用户信息获取测试
TEST(用户子服务测试, 批量用户信息获取测试)
{
auto channel = _user_channels->choose(FLAGS_user_service);
ASSERT_TRUE(channel);
IMS::GetMultiUserInfoReq req;
req.set_request_id(IMS::uuid());
req.add_users_id(user_info1.user_id());
req.add_users_id(user_info2.user_id());
IMS::GetMultiUserInfoRsp rsp;
brpc::Controller cntl;
IMS::UserService_Stub stub(channel.get());
stub.GetMultiUserInfo(&cntl, &req, &rsp, nullptr);
ASSERT_FALSE(cntl.Failed());
ASSERT_TRUE(rsp.success());
// 获取返回的用户信息映射
auto users_map = rsp.mutable_users_info();
// 验证用户信息
IMS::UserInfo fuser = (*users_map)[user_info1.user_id()];
ASSERT_EQ(fuser.user_id(), user_info1.user_id());
ASSERT_EQ(fuser.nickname(), "亲爱的猪妈妈");
ASSERT_EQ(fuser.description(), "这是一个美丽的猪妈妈");
ASSERT_EQ(fuser.email(), "");
ASSERT_EQ(fuser.avatar(), "猪妈妈头像数据");
// 验证用户信息
IMS::UserInfo puser = (*users_map)[user_info2.user_id()];
ASSERT_EQ(puser.user_id(), user_info2.user_id());
ASSERT_EQ(puser.nickname(), user_info2.nickname());
ASSERT_EQ(puser.description(), "");
ASSERT_EQ(puser.email(), "新邮箱");
ASSERT_EQ(puser.avatar(), "");
}
// 主函数
int main(int argc, char *argv[])
{
// 解析命令行参数(gflags)
google::ParseCommandLineFlags(&argc, &argv, true);
// 初始化日志系统
IMS::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);
// 1. 构造RPC信道管理对象
_user_channels = std::make_shared<IMS::ServiceManager>();
_user_channels->declared(FLAGS_user_service); // 声明需要监控的服务
// 绑定服务上线和下线回调函数
auto put_cb = std::bind(&IMS::ServiceManager::onServiceOnline, _user_channels.get(), std::placeholders::_1, std::placeholders::_2);
auto del_cb = std::bind(&IMS::ServiceManager::onServiceOffline, _user_channels.get(), std::placeholders::_1, std::placeholders::_2);
// 2. 构造服务发现对象,连接到etcd,监控服务变化
IMS::Discovery::ptr dclient = std::make_shared<IMS::Discovery>(FLAGS_etcd_host, FLAGS_base_service, put_cb, del_cb);
// 初始化全局测试用户信息
user_info1.set_nickname("猪妈妈");
user_info1.set_user_id("5ede-177031f3-0000");//这一步本来应该是网关子服务来完成的,不过我们去查询了服务端数据得到的
user_info2.set_user_id("a02a-a169d131-0002");//这一步本来应该是网关子服务来完成的,不过我们去查询了服务端数据得到的
user_info2.set_nickname("a02a-a169d131-0002");
user_info2.set_email("用户邮箱");//设置用户邮箱
// 初始化Google Test框架
testing::InitGoogleTest(&argc, argv);
LOG_DEBUG("开始测试!");
// 运行所有测试用例
return RUN_ALL_TESTS();
}
注意这里需要重新编译一次,但是不要停止我们的用户管理服务端程序(包括文件管理服务端)
重新编译后我们运行这个客户端程序

我们去数据库看看
cpp
mysql> select * from user;
+----+--------------------+--------------------+--------------------------------+----------+---------------------+--------------------+
| id | user_id | nickname | description | password | email | avatar_id |
+----+--------------------+--------------------+--------------------------------+----------+---------------------+--------------------+
| 1 | 5ede-177031f3-0000 | 亲爱的猪妈妈 | 这是一个美丽的猪妈妈 | 123456 | NULL | 06d9-27016b2d-0000 |
| 2 | a02a-a169d131-0002 | a02a-a169d131-0002 | NULL | NULL | 172070@139.com | NULL |
+----+--------------------+--------------------+--------------------------------+----------+---------------------+--------------------+
2 rows in set (0.00 sec)
我们发现这些数据都更新了。
我们也可以去Redis里面看看

我们也可以去ES里面查询一下,就执行下面这个命令
在 Kibana 的 Dev Tools 控制台中执行:
cpp
GET /user/_search
{
"query": { "match_all": {} },
"size": 100
}
查询结果如下:
cpp
#! Elasticsearch built-in security features are not enabled. Without authentication, your cluster could be accessible to anyone. See https://www.elastic.co/guide/en/elasticsearch/reference/7.17/security-minimal-setup.html to enable security.
{
"took" : 58,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 3,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "user",
"_type" : "_doc",
"_id" : "default_index_id",
"_score" : 1.0,
"_source" : {
"mappings" : {
"dynamic" : true,
"properties" : {
"avatar_id" : {
"analyzer" : "standard",
"enabled" : false,
"type" : "keyword"
},
"description" : {
"analyzer" : "standard",
"enabled" : false,
"type" : "text"
},
"email" : {
"analyzer" : "standard",
"type" : "keyword"
},
"nickname" : {
"analyzer" : "ik_max_word",
"type" : "text"
},
"user_id" : {
"analyzer" : "standard",
"type" : "keyword"
}
}
},
"settings" : {
"analysis" : {
"analyzer" : {
"ik" : {
"tokenizer" : "ik_max_word"
}
}
}
}
}
},
{
"_index" : "user",
"_type" : "_doc",
"_id" : "5ede-177031f3-0000",
"_score" : 1.0,
"_source" : {
"avatar_id" : "06d9-27016b2d-0000",
"description" : "这是一个美丽的猪妈妈",
"email" : "",
"nickname" : "亲爱的猪妈妈",
"user_id" : "5ede-177031f3-0000"
}
},
{
"_index" : "user",
"_type" : "_doc",
"_id" : "a02a-a169d131-0002",
"_score" : 1.0,
"_source" : {
"avatar_id" : "",
"description" : "",
"email" : "17207@139.com",
"nickname" : "a02a-a169d131-0002",
"user_id" : "a02a-a169d131-0002"
}
}
]
}
}
非常完美,和我们的都对的上。
注意:如果在测试过程中发生了什么异常事件,我们是需要去Mysql数据库,Redis数据库,ES里面清理掉我们的数据再重新进行测试
MySQL就是去把表的数据删了,我觉得那么都会
cppTRUNCATE TABLE user;那么Redis则如下图所示
那么ES则是在 Kibana 的 Dev Tools 中执行下面这个命令(注意是5601端口)
cppDELETE /user
整个的测试流程还是比较清晰的。

