C++微服务 UserServer 设计与实现

C++微服务 UserServer 设计与实现

实战 C++ 微服务:IM 项目用户服务(UserServer)设计与落地全记录

做 IM 项目时,用户服务(UserServer)是整个系统的基石 —— 所有业务(好友、消息、朋友圈)都依赖用户认证和基础信息。这篇文章就从实战角度,聊聊我是怎么设计、实现 UserServer 的,包括核心功能落地、依赖替换(比如用模拟短信服务替代真实平台)、以及那些踩过的坑,希望能给做 C++ 后端的朋友一些参考。

一、先搞懂:UserServer 在 IM 系统里的角色

在之前的 IM 微服务架构里,UserServer 承担 3 个核心职责:

  1. 用户认证:注册(用户名 / 手机号)、登录(用户名密码 / 手机验证码)、会话管理;
  2. 用户信息管理:头像、昵称、签名、手机号的修改与查询;
  3. 基础支撑:给其他服务提供用户信息(比如好友服务查好友资料、消息服务查发送者信息)。

所以设计时,必须考虑可扩展性(比如后续加第三方登录)、可测试性(比如不用真实短信也能测手机号登录)、性能(登录会话用 Redis 缓存,避免查库)。

二、核心设计:从依赖到架构,拒绝 “硬编码”

1. 依赖注入:让服务更灵活(踩过坑才懂的重要性)

最开始写 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完全不用动;
  • 测试方便:写单元测试时,能注入 “假的 Redis 客户端”“假的 ES 客户端”,不用依赖真实中间件。

2. 核心依赖拆解:每个组件各司其职

依赖组件作用实战细节
MockSmsClient模拟短信发送内部不调用外部平台,只打印日志 + 存 Redis 验证码
ODB(MySQL ORM)用户数据 CRUD需用odb工具生成 ORM 代码,避免手写 SQL
Redis(sw::redis++)会话存储、验证码、登录状态会话过期设 2 小时,验证码 5 分钟过期
Elasticlient用户搜索(比如好友搜索)初始化时创建user索引,支持昵称 / 手机号模糊查
ServiceManager调用其他微服务(如文件服务上传头像)基于 Etcd 发现服务节点,RR 轮询负载均衡

三、核心功能落地:从代码到业务,讲透细节

1. 用户注册:不只是存数据,还要做校验

注册逻辑看起来简单,但细节容易出问题,比如密码强度、昵称重复。看关键代码:

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); } 

实战踩坑:最开始没做密码校验,测试时输入特殊字符导致数据库存储异常,后来加了严格的字符校验,还在日志里打印不合法的密码,方便排查问题。

2. 手机号验证码:用模拟服务突破平台限制

真实短信服务(如阿里云 DMS)需要企业资质,个人开发没法用,所以做了MockSmsClient来模拟。核心逻辑是:生成验证码→存 Redis→返回验证码 ID,校验时从 Redis 查。

第一步:设计 MockSmsClient
// 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; // 用于后续扩展,比如存验证码 }; 
第二步:集成到 GetPhoneVerifyCode
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里换个注入对象就行,业务代码一行不用改。

3. 登录会话管理:Redis 防多端登录

登录成功后,要生成会话 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 命令,减少出错概率。

4. 用户信息修改:联动多存储(MySQL+ES + 文件服务)

以 “设置头像” 为例,需要上传头像到文件服务→更新 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 次),还在日志里打印文件服务的地址和错误信息,方便定位是网络问题还是服务本身的问题。

四、实战踩坑记录:这些问题比代码更重要

1. ODB 代码生成遗漏导致链接错误

最开始用 ODB 的 ORM,只写了user.hxx,没生成 ORM 实现代码,编译时出现一堆undefined reference to odb::access::object_traits_impl<zrt::User>错误。

解决方法

  1. 安装 ODB 工具:sudo apt install odb
  2. 生成 ORM 代码:odb -d mysql --std c++11 user.hxx -o source/
  3. CMake 里添加生成的user-odb.cxx到源文件列表:
add_executable(user_server source/user_server.cc source/user-odb.cxx # 必须加,否则链接不到ORM实现 ) 

2. Redis 客户端初始化顺序错误

最开始在make_redis_object里创建了Codes实例,但没赋值给_codes_client,导致make_mock_sms_object_codes_client是空的,运行崩溃。

解决方法:调整初始化顺序,确保 Redis 客户端先初始化,再创建CodesMockSmsClient

// 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); } 

3. 依赖库链接不全导致未定义错误

编译时出现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 # 线程库 ) 

五、总结:UserServer 设计的 3 个核心要点

  1. 无状态设计:用户服务不存本地数据(会话、状态都放 Redis),方便横向扩展,加节点就能扛更高并发;
  2. 依赖注入优先:所有外部依赖(短信、数据库、缓存)都通过构造函数注入,方便替换和测试,比如用模拟短信突破平台限制;
  3. 分层清晰:业务逻辑(注册登录)、数据访问(ODB/Redis/ES)、服务调用(ServiceManager)分层,修改某一层不影响其他层。

做用户服务时,最容易忽略的是 “可测试性” 和 “容错性”—— 比如一开始没做模拟短信,导致没法本地测试手机号登录;没加重试机制,文件服务偶尔超时就失败。这些问题都是实战中踩出来的,比单纯的代码实现更有价值。

如果大家也在做 C++ 微服务或 IM 项目,欢迎交流更多细节,比如用户服务的横向扩展、会话共享等问题,一起避坑~

Read more

C++:模板的幻觉 —— 实例化、重定义与隐藏依赖势中

C++:模板的幻觉 —— 实例化、重定义与隐藏依赖势中

一、表象之下:模板真的“生成代码”吗? 很多人第一次学 C++ 模板时,会这样理解: “模板是一种代码生成机制,编译器在编译时会根据不同类型生成不同版本的函数或类。” 乍一看没错,比如: template<typename T> void print(T x) { std::cout << x << std::endl; } int main() { print(42); print("Hello"); } 似乎编译器确实“生成了两份函数”: print<int>(int) 与 print<const

By Ne0inhk
深入解剖STL map/multimap:接口使用与核心特性详解

深入解剖STL map/multimap:接口使用与核心特性详解

❤️@燃于AC之乐 来自重庆 计算机专业的一枚大学生 ✨专注 C/C++ Linux 数据结构 算法竞赛 AI 🏞️志同道合的人会看见同一片风景! 👇点击进入作者专栏: 《算法画解》 ✅ 《linux系统编程》✅ 《C++》 ✅ 🌟《算法画解》算法相关题目点击即可进入实操🌟 感兴趣的可以先收藏起来,请多多支持,还有大家有相关问题都可以给我留言咨询,希望希望共同交流心得,一起进步,你我陪伴,学习路上不孤单! 文章目录 * 前言(map系列容器概述) * 一、map类介绍 * 1.1 map的类模板声明 * 二、pair类型介绍 * 2.1 pair的结构定义 * 2.2 pair的使用要点 * 三、map的构造与迭代器 * 3.1 构造接口 * 3.2 迭代器接口 * 四、map的增删查操作

By Ne0inhk
【C++】继承

【C++】继承

继承 ✨前言:继承是C++面向对象编程的核心特性之一,它允许我们在已有类的基础上创建新类,实现代码的复用和功能的扩展。通过继承,我们可以构建出层次分明的类体系,让代码更加结构化、可维护。本文将深入探讨继承的各个方面,从基本概念到底层实现,帮助读者全面掌握这一重要特性。 📖专栏:【C++成长之旅】 目录 * 继承 * 一、继承的概念及定义 * 1.1 继承的概念 * 1.2 继承的定义 * 1.2.1 定义格式 * 1.2.2 继承基类成员访问方式的变化 * 1.3 继承类模板 * 二、基类和派生类间的转化 * 三、继承中的作用域 * 3.1 隐藏规则 * 3.2 考察继承作用域相关选择题 * 3.2.1

By Ne0inhk
【C++STL】map与set(举例+详解,一文说懂)!

【C++STL】map与set(举例+详解,一文说懂)!

🌟个人主页:第七序章   🌈专栏系列:C++ 目录 ❄️前言: 一、☀️序列式容器与关联式容器 二、☀️键值对 三、☀️树形结构的关联式容器 四、☀️set 4.1 🌙set介绍  4.2 🌙set的构造和迭代器 4.3 🌙set的增删查 4.4 🌙insert和迭代器遍历使用样例  4.5 🌙find和erase使用样例 4.6 🌙multiset和set的差异 4.7 🌙set相关题目练习 五、☀️multiset 5.1 🌙multiset介绍 5.2 🌙multiset使用 六、☀️map 6.1 🌙map介绍 6.2

By Ne0inhk