IM 项目用户服务(UserServer)设计与实现
在 IM 系统中,用户服务(UserServer)是整个系统的基石 —— 所有业务(好友、消息、朋友圈)都依赖用户认证和基础信息。本文从实战角度,介绍 UserServer 的设计与实现,包括核心功能落地、依赖替换(如用模拟短信服务替代真实平台)、以及常见工程问题。
本文阐述了 C++ 微服务 UserServer 在 IM 系统中的核心职责与设计实现。内容包括用户认证、信息管理、基础支撑三大功能模块。架构上采用依赖注入解耦外部依赖,支持 Mock 短信服务替代真实平台以提升测试效率。核心功能涵盖注册校验、验证码发送、登录会话管理(Redis 存储)及用户信息修改(联动 MySQL、ES 与文件服务)。此外总结了 ODB 代码生成、Redis 初始化顺序及依赖库链接等常见工程问题,强调了无状态设计、分层清晰与可测试性的重要性。

在 IM 系统中,用户服务(UserServer)是整个系统的基石 —— 所有业务(好友、消息、朋友圈)都依赖用户认证和基础信息。本文从实战角度,介绍 UserServer 的设计与实现,包括核心功能落地、依赖替换(如用模拟短信服务替代真实平台)、以及常见工程问题。
UserServer 承担 3 个核心职责:
设计时需考虑可扩展性(如后续加第三方登录)、可测试性(如不用真实短信也能测手机号登录)、性能(登录会话用 Redis 缓存,避免查库)。
最开始写 UserServiceImpl 的时候,直接在类里 new 了 DMSClient(真实短信服务),后来发现个人开发者没法申请企业短信资质,想换成模拟服务时,改了大半天代码。后来重构时,把所有外部依赖都通过构造函数注入,这才清爽了。
看核心构造函数:
UserServiceImpl( const MockSmsClient::ptr& mock_sms_client, // 短信服务(真实/模拟可替换)
const std::shared_ptr<elasticlient::Client>& es_client, // ES(用户搜索用)
const std::shared_ptr<odb::core::database>& mysql_client, // MySQL(用户数据存储)
const std::shared_ptr<sw::redis::Redis>& redis_client, // Redis(会话/验证码)
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),
_dms_client(mock_sms_client) // 注入短信服务,而非内部new
{
_es_user->createIndex(); // 初始化 ES 用户索引
}
这样设计的好处:
MockSmsClient 换成真实 DMSClient,只需要改构建器(UserServerBuilder)的初始化逻辑,UserServiceImpl 里的 GetPhoneVerifyCode 完全不用动;| 依赖组件 | 作用 | 实战细节 |
|---|---|---|
| MockSmsClient | 模拟短信发送 | 内部不调用外部平台,只打印日志 + 存 Redis 验证码 |
| ODB(MySQL ORM) | 用户数据 CRUD | 需用 odb 工具生成 ORM 代码,避免手写 SQL |
| Redis(sw::redis++) | 会话存储、验证码、登录状态 | 会话过期设 2 小时,验证码 5 分钟过期 |
| Elasticlient | 用户搜索(比如好友搜索) | 初始化时创建 user 索引,支持昵称/手机号模糊查 |
| ServiceManager | 调用其他微服务(如文件服务上传头像) | 基于 Etcd 发现服务节点,RR 轮询负载均衡 |
注册逻辑看起来简单,但细节容易出问题,比如密码强度、昵称重复。看关键代码:
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++) {
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;
}
void UserRegister(...) { // 1. 取请求参数
std::string nickname = request->nickname();
std::string password = request->password();
// 2. 校验昵称、密码
if (!nickname_check(nickname)) {
return err_response("用户名长度不合法!");
}
if (!password_check(password)) {
return err_response("密码格式不合法!");
}
// 3. 查昵称是否已存在(ODB ORM 调用)
auto user = _mysql_user->select_by_nickname(nickname);
if (user) {
return err_response("用户名被占用!");
}
// 4. 生成用户 ID,存 MySQL+ES
std::string uid = uuid(); // 自定义工具函数,生成唯一 ID
user = std::make_shared<User>(uid, nickname, password);
if (!_mysql_user->insert(user)) {
return err_response("Mysql 数据库新增数据失败!");
}
if (!_es_user->appendData(uid, "", nickname, "", "")) {
return err_response("ES 搜索引擎新增数据失败!");
}
// 5. 返回成功
response->set_success(true);
}
实战踩坑:最开始没做密码校验,测试时输入特殊字符导致数据库存储异常,后来加了严格的字符校验,还在日志里打印不合法的密码,方便排查问题。
真实短信服务(如阿里云 DMS)需要企业资质,个人开发没法用,所以做了 MockSmsClient 来模拟。核心逻辑是:生成验证码→存 Redis→返回验证码 ID,校验时从 Redis 查。
// mock_sms.hpp
class MockSmsClient {
public:
using ptr = std::shared_ptr<MockSmsClient>;
MockSmsClient(const Codes::ptr& codes_client) : _codes_client(codes_client) {}
// 与真实 DMSClient 接口完全一致,方便替换
bool send(const std::string& phone, const std::string& code) {
// 不调用外部平台,只打印日志(测试时能直接看到验证码)
LOG_INFO("【模拟短信】向{}发送验证码:{}", phone, code);
return true;
}
private:
Codes::ptr _codes_client; // 用于后续扩展,比如存验证码
};
void GetPhoneVerifyCode(...) { // 1. 校验手机号格式(11 位,以 1 开头,第二位 3-9)
std::string phone = request->phone_number();
if (!phone_check(phone)) {
return err_response("手机号码格式错误!");
}
// 2. 生成 4 位验证码(自定义工具函数 vcode())
std::string code_id = uuid();
std::string code = vcode(); // 返回如"1234"
// 3. 调用模拟短信服务(实际只打日志)
if (!_dms_client->send(phone, code)) {
return err_response("短信验证码发送失败!");
}
// 4. 存 Redis(5 分钟过期)
_redis_codes->append(code_id, code, std::chrono::minutes(5));
// 5. 返回验证码 ID
response->set_verify_code_id(code_id);
response->set_success(true);
}
关键优势:后来要对接真实短信服务时,只需要实现一个 RealSmsClient,保持 send 接口一致,在 UserServerBuilder 里换个注入对象就行,业务代码一行不用改。
登录成功后,要生成会话 ID(ssid),存 Redis,还要标记用户登录状态,防止同一账号多端登录:
void UserLogin(...) { // 1. 校验用户名密码
auto user = _mysql_user->select_by_nickname(nickname);
if (!user || password != user->password()) {
return err_response("用户名或密码错误!");
}
// 2. 查是否已登录(Redis 查登录状态)
if (_redis_status->exists(user->user_id())) {
return err_response("用户已在其他地方登录!");
}
// 3. 生成 ssid,存 Redis(2 小时过期)
std::string ssid = uuid();
_redis_session->append(ssid, user->user_id(), std::chrono::hours(2));
// 4. 标记登录状态(2 小时过期,与会话同步)
_redis_status->append(user->user_id(), std::chrono::hours(2));
// 5. 返回 ssid
response->set_login_session_id(ssid);
response->set_success(true);
}
细节:_redis_session 和 _redis_status 是封装的 Redis 操作类,内部调用 sw::redis::Redis::set 并设置过期时间,避免手动写 Redis 命令,减少出错概率。
以'设置头像'为例,需要上传头像到文件服务→更新 MySQL 的 avatar_id→同步 ES 信息:
void SetUserAvatar(...) { // 1. 取用户 ID 和头像数据
std::string uid = request->user_id();
std::string avatar_data = request->avatar();
// 2. 查用户是否存在
auto user = _mysql_user->select_by_id(uid);
if (!user) {
return err_response("未找到用户信息!");
}
// 3. 调用文件服务上传头像(通过 ServiceManager 找文件服务节点)
auto channel = _mm_channels->choose(_file_service_name);
FileService_Stub stub(channel.get());
PutSingleFileReq file_req;
PutSingleFileRsp file_rsp;
file_req.mutable_file_data()->set_file_content(avatar_data);
stub.PutSingleFile(&cntl, &file_req, &file_rsp, nullptr);
if (cntl.Failed() || !file_rsp.success()) {
return err_response("文件子服务调用失败!");
}
// 4. 更新 MySQL 的 avatar_id
std::string avatar_id = file_rsp.file_info().file_id();
user->avatar_id(avatar_id);
if (!_mysql_user->update(user)) {
return err_response("更新数据库用户头像 ID 失败!");
}
// 5. 同步 ES 信息
if (!_es_user->appendData(user->user_id(), user->phone(), user->nickname(), user->description(), avatar_id)) {
return err_response("更新搜索引擎用户头像 ID 失败!");
}
response->set_success(true);
}
经验:文件服务调用可能失败,后来加了重试机制(失败后重试 2 次),还在日志里打印文件服务的地址和错误信息,方便定位是网络问题还是服务本身的问题。
最开始用 ODB 的 ORM,只写了 user.hxx,没生成 ORM 实现代码,编译时出现一堆 undefined reference to odb::access::object_traits_impl<zrt::User> 错误。
解决方法:
sudo apt install odb;odb -d mysql --std c++11 user.hxx -o source/;user-odb.cxx 到源文件列表:add_executable(user_server source/user_server.cc source/user-odb.cxx # 必须加,否则链接不到 ORM 实现
)
最开始在 make_redis_object 里创建了 Codes 实例,但没赋值给 _codes_client,导致 make_mock_sms_object 时 _codes_client 是空的,运行崩溃。
解决方法:调整初始化顺序,确保 Redis 客户端先初始化,再创建 Codes 和 MockSmsClient:
// UserServerBuilder
void make_redis_object(...) {
_redis_client = RedisClientFactory::create(...); // 初始化 Codes,赋值给成员变量
_codes_client = std::make_shared<Codes>(_redis_client);
}
void make_mock_sms_object() {
// 此时_codes_client 已初始化,不会空指针
_mock_sms_client = std::make_shared<MockSmsClient>(_codes_client);
}
编译时出现 undefined reference to sw::redis::Redis::set,是因为 CMake 没链接 swredis++ 和 hiredis 库。
解决方法:在 CMake 里添加链接:
# 查找依赖库
find_package(swredis++ REQUIRED)
find_package(hiredis REQUIRED)
# 链接到目标
target_link_libraries(user_server PRIVATE sw::redis++::swredis++ hiredis::hiredis odb::mysql # ODB MySQL 库
elasticlient::elasticlient # ES 客户端库
brpc # RPC 库
pthread # 线程库
)
做用户服务时,最容易忽略的是'可测试性'和'容错性'—— 比如一开始没做模拟短信,导致没法本地测试手机号登录;没加重试机制,文件服务偶尔超时就失败。这些问题都是实战中踩出来的,比单纯的代码实现更有价值。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online