【C++项目】基于微服务的即使通信系统
基于微服务的即使通信系统
- 学习项目使用的框架/库
- 一.项目功能总览
- 二.框架与子服务拆分设计
- 网关服务
- HTTP通信
- WebSocket通信
- 用户管理子服务
- 好友管理子服务
- 文件管理子服务
- 消息管理子服务
- 转发管理子服务
- 语音转换子服务
- 后台服务技术框架图
- 后台服务的模块层次图
- 后台服务的通信流程图
- 入口网关子服务业务接口
- 用户管理子服务业务接口
- 好友管理子服务业务接口
- 三.通信接口设计
- 网关服务
- 网关HTTP接口
- 网关 Websocket 接口
- 四.子服务实现
- 语音转换子服务
- 功能设计
- 模块划分
- 示意图
- 接口实现流程
- 文件管理子服务
- 功能设计
- 模块划分
- 模块功能示意图
- 接口实现流程
- 用户管理子服务
- 功能设计
- 模块划分
- 模块功能示意图
- 数据管理
- 内存数据库数据管理
- 会话信息映射键值对
- 验证码信息映射键值对
- 文档数据库数据管理
- 接口实现流程
- 消息转发子服务
- 功能设计
- 模块划分
- 功能模块示意图
- 接口实现流程
- 消息存储子服务
- 功能设计
- 模块划分
- 功能结构示意图
- 数据管理
- 数据库消息管理
- 数据库表结构
- 数据库操作
- ES 文本消息管理
- 接口实现流程
- 好友管理子服务
- 功能设计
- 模块划分
- 功能模块示意图
- 数据管理
- 用户信息表
- 用户关系表
- 会话信息
- 会话成员
- 好友申请事件
- 接口实现流程
- 网关子服务
- 功能设计
- 模块划分
- 功能模块示意图
- 接口实现流程
- 用户名登录
- 短信验证码获取
- 手机号码注册
- 手机号码登录
- 修改用户昵称
- 获取待处理好友申请
- 好友申请处理
- 搜索用户
- 获取用户聊天会话列表
- 获取指定时间段消息列表
- 获取最近 N 条消息列表
- 多个文件数据上传
- 五. 后台服务器部署
- 编写项目配置文件
- 查询程序依赖
- 编写 dockerfile
- 编写 entrypoint.sh
- 编写 docker-compose
注意:阿里云的短信验证码服务,最近不再给个人开放,所以以下接口中有用到验证码的,一律使用的是固定验证码(1234)
学习项目使用的框架/库
- gflags:针对程序运行所需的运行参数解析/配置文件解析框架。
- gtest:针对程序编写到一定阶段后,进行的单元测试框架
- spdlog:针对项目中进行日志输出的框架。
- protobuf:针对项目中的网络通信数据所采用的序列化和反序列化框架
- brpc:项目中的rpc调用使用的框架。
- redis:高性能键值存储系统,用于项目中进行用户登录会话信息的存储管理。
- mysql:关系型数据库系统,用于项目中的业务数据的存储管理。
- ODB:项目中mysql数据库操作的ORM框架(Object-RelationalMapping,对象关系映射)
- Etcd:分布式、高可用的一致性键值存储系统,用于项目中实现服务注册与发现功能的框架。
- cpp-httplib:用于搭建简单轻量 HTTP服务器的框架。
- websocketpp:用于搭建 Websocket服务器的框架。
- rabbitMQ:用于搭建消息队列服务器,用于项目中持久化消息的转发消费
- elasticsearch:用于搭建文档存储/搜索服务器,用于项目中历史消息的存储管理
- 语音云平台:采用百度语音识别技术云平台实现语音转文字功能。
- 短信云平台:采用阿里云短信云平台实现手机短信验证码通知功能。
- cmake:项目工程的构建工具。
- docker:项目工程的一键式部署工具。
一.项目功能总览
本项目就是模仿微信/QQ两个社交平台做的通信系统,所以一些最基本的接口设计就是:
- 用户注册:用户输入用户名(昵称),以及密码进行用户名的注册
- 用户登录:用户通过用户名和密码进行登录
- 短信验证码获取:当用户通过手机号注册或登录的时候,需要获取短信验证码
- 手机号注册:用户输入手机号和短信验证码进行手机号的用户注册
- 手机号登录:用户输入手机号和短信验证码进行手机号的用户登录
- 用户信息获取:当用户登录之后,获取个人信息进行展示
- 头像修改:设置用户头像
- 昵称修改:设置用户昵称
- 签名修改:设置用户签名
- 手机号修改:修改用户的绑定手机号
- 好友列表的获取:当用户登录成功之后,获取自己好友列表进行展示
- 申请好友:搜索用户之后,点击申请好友,向对方发送好友申请
- 待处理申请的获取:当用户登录成功之后,会获取离线的好友申请请求以待处理
- 好友申请的处理:针对收到的好友申请进行同意/拒绝的处理
- 删除好友:删除当前好友列表中的好友
- 用户搜索:可以进行用户的搜索用于申请好友
- 聊天会话列表的获取:每个单人/多人聊天都有一个聊天会话,在登录成功后可以获取聊天会话,查看历史的消息以及对方的各项信息
- 多人聊天会话的创建:单人聊天会话在对方同意好友时创建,而多人会话需要调用该接口进行手动创建
- 聊天成员列表的获取:多人聊天会话中,可以点击查看群成员按钮,查看群成员信息
- 发送聊天消息:在聊天框输入内容后点击发送,则向服务器发送消息聊天请求
- 获取历史消息:
a. 获取最近 N 条消息:用于登录成功后,点击对方头像打开聊天框时显示最近的消息
b. 获取指定时间段内的消息:用户可以进行聊天消息的按时间搜索 - 消息搜索:用户可以进行聊天消息的关键字搜索
- 文件的上传:
a. 单个文件的上传:这个接口基本用于后台部分,收到文件消息后将文件数据转发给文件子服务进行存储
b. 多个文件的上传:这个接口基本用于后台部分,收到文件消息后将文件数据转发给文件子服务进行存储 - 文件的下载:
a. 单个文件的下载:在后台用于获取用户头像文件数据,以及客户端用于获取文件/语音/图片消息的文件数据
b. 多个文件的下载:在后台用于大批量获取用户头像数据(比如获取用户列表的时候),以及前端的批量文件下载 - 语音消息的文字转换:客户端进行语音消息的文字转换。
上面都是服务器和客户端交互的接口,还有一些辅助接口
- 消息的存储:将文本消息存储起来,用于消息的搜索,以及离线消息的存储
- 文件的存储:存储用户头像数据,以及聊天中的语音/图片/文件等数据的存储
- 用户,好友,会话数据的存储管理
二.框架与子服务拆分设计
项目的设计采用的是微服务框架设计,将一个大的业务拆分成几个子业务,分别在不同的机器节点上,提供对应的服务,由网关服务统一接收多个客户端的不同业务请求,在根据业务的类型发送到相应的子服务上进行处理,获取响应后,再转发给客户端
微服务架构的主要思想了解一下
- 服务拆分:将应用程序拆分成多个小型服务,每个服务负责一部分业务功能,具有独立的生命周期和部署。
- 独立部署:每个微服务可以独立于其他服务进行部署、更新和扩展。
- 语言和数据的多样性:不同的服务可以使用不同的编程语言和数据库,根据服务的特定需求进行技术选型。
- 轻量级通信:服务之间通过定义良好的 API 进行通信,通常使用 HTTP/REST、gRPC 等协议。
- 去中心化治理:每个服务可以有自己的开发团队,拥有自己的技术栈和开发流程。
- 弹性和可扩展性:微服务架构支持服务的动态扩展和收缩,以适应负载的变化。
- 容错性:设计时考虑到服务可能会失败,通过断路器、重试机制等手段提高系统的容错性。
- 去中心化数据管理:每个服务管理自己的数据库,数据在服务之间是私有的,这有助于保持服务的独立性。
- 自动化部署:通过持续集成和持续部署(CI/CD)流程自动化服务的构建、测试和部署。
- 监控和日志:对每个服务进行监控和日志记录,以便于跟踪问题和性能瓶颈。
- 服务发现:服务实例可能动态变化,需要服务发现机制来动态地找到服务实例。
- 安全:每个服务需要考虑安全问题,包括认证、授权和数据传输的安全性。
基于微服务的思想,以及聊天室项目的业务功能,将聊天室项目进行服务拆分为以下几个子服务:
网关服务
用于和客户端直接进行交互,获取客户端的请求之后,通过用户鉴权,将请求发送给指定的子服务,接收到子服务的响应后,转发给客户端。
用户鉴权:客户端创建成功之后,后台会给这个客户端创建登录会话,并向客户端返回一个登录会话id,之后客户端进行所有的操作时都必须带有这个登录会话id,否则就不能进行注册/登录/验证码获取之外的操作。
网关服务中,根据不同的目的,设计了两种通信:
HTTP通信
客户端的大部分业务都是,请求-响应的的模式进行的,依次基于便于拓展,设计简单的目的,采用HTTP协议与客户端进行基础的通信协议。
WebSocket通信
在本服务中,出了客户端向服务器发送请求,还有一些由服务器主动推送给客户端的通知,因为HTTP不支持主动推送,所以采用了WebSocket的长连接的通信,向客户端发送通知类型的数据
有以下的通知:
- 好友申请通知
- 好友申请是否通过的通知
- 好友删除的通知
- 聊天会话建立的通知
- 聊天新消息的通知
用户管理子服务
主要用于管理用户数据,以及用户信息的各项操作,因此设计了以下的接口:
- 用户注册:用户输入用户名(昵称),以及密码进行用户名的注册
- 用户登录:用户通过用户名和密码进行登录
- 短信验证码获取:当用户通过手机号注册或登录的时候,需要获取短信验证码
- 手机号注册:用户输入手机号和短信验证码进行手机号的用户注册
- 手机号登录:用户输入手机号和短信验证码进行手机号的用户登录
- 用户信息获取:当用户登录之后,获取个人信息进行展示
- 头像修改:设置用户头像
- 昵称修改:设置用户昵称
- 签名修改:设置用户签名
- 手机号修改:修改用户的绑定手机号
好友管理子服务
主要用户管理好友相关的数据和操作,接口如下:
- 好友列表的获取:当用户登录成功之后,获取自己好友列表进行展示
- 申请好友:搜索用户之后,点击申请好友,向对方发送好友申请
- 待处理申请的获取:当用户登录成功之后,会获取离线的好友申请请求以待处理
- 好友申请的处理:针对收到的好友申请进行同意/拒绝的处理
- 删除好友:删除当前好友列表中的好友
- 用户搜索:可以进行用户的搜索用于申请好友
- 聊天会话列表的获取:每个单人/多人聊天都有一个聊天会话,在登录成功后可以获取聊天会话,查看历史的消息以及对方的各项信息
- 多人聊天会话的创建:单人聊天会话在对方同意好友时创建,而多人会话需要调用该接口进行手动创建
- 聊天成员列表的获取:多人聊天会话中,可以点击查看群成员按钮,查看群成员信息
文件管理子服务
主要用于管理用户的头像数据,以及聊天中的文件存储
- 文件的上传
a. 单个文件的上传:这个接口基本用于后台部分,收到文件消息后将文件数据转发给文件子服务进行存储
b. 多个文件的上传:这个接口基本用于后台部分,收到文件消息后将文件数据转发给文件子服务进行存储 - 文件的下载
a. 单个文件的下载:在后台用于获取用户头像文件数据,以及客户端用于获取文件/语音/图片消息的文件数据
b. 多个文件的下载:在后台用于大批量获取用户头像数据(比如获取用户列表的时候),以及前端的批量文件下载
消息管理子服务
主要用于管理消息的元信息的存储,提供以下接口
- 获取历史消息
a. 获取最近的N条消息:登录成功之后,点击对方头像,聊天框打开之后显示最近消息
b. 获取指定时间段内的消息 - 消息搜索: 可以根据关键字进行消息的关键字搜索
转发管理子服务
注意:转发子服务并不是进行消息的转发的,而是针对一条消息内容,进行消息信息的组织,比如消息ID,然后告诉网关这一条消息应该转发给谁。
消息都是以聊天会话为基础的,根据会话找到所有成员,这就是转发的目标,除此之外,转发子服务还需要把消息放入消息队列中,由文件管理子服务/消息管理子服务进行消息存储:后续把消息存储到ES搜索引擎当中,方便进行消息查询
- 获取消息转发内容:针对消息内容,组织消息,告诉网关发送目标
语音转换子服务
语音转换子服务,用于调用语音识别 SDK,进行语音识别,将语音转为文字后返回给网关。
- 语音消息的文字转换:客户端进行语音消息的文字转换。
后台服务技术框架图
后台服务的模块层次图
后台服务的通信流程图
入口网关子服务业务接口
用户管理子服务业务接口
好友管理子服务业务接口
三.通信接口设计
因为微服务框架的思想是将业务拆分到不同的节点主机上提供服务,因此主机节点之间的通信就尤为重要,而在进行开发之前,首先要做的就是将通信接口定义出来,这样只要双方遵循约定,即可实现业务往来。
网关服务
网关负责直接与客户端进行通信,其基础业务请求使用 HTTP 协议进行通信,通知类业务使用 Websocket 协议进行通信,接口定义如下:
网关HTTP接口
HTTP 通信,分为首行,头部和正文三部分,首行中的 URI 明确了业务请求目标,头部进行正文或连接描述,正文中包含请求或响应的内容,在约定的内容中,首先需要定义出来的就是 URI:
//在客户端与网关服务器的通信中,使用HTTP协议进行通信
// 通信时采用POST请求作为请求方法
// 通信时,正文采用protobuf作为正文协议格式,具体内容字段以前边各个文件中定义的字段格式为准
/* 以下是HTTP请求的功能与接口路径对应关系:
SERVICE HTTP PATH:
{
获取随机验证码 /service/user/get_random_verify_code
获取短信验证码 /service/user/get_phone_verify_code
用户名密码注册 /service/user/username_register
用户名密码登录 /service/user/username_login
手机号码注册 /service/user/phone_register
手机号码登录 /service/user/phone_login
获取个人信息 /service/user/get_user_info
修改头像 /service/user/set_avatar
修改昵称 /service/user/set_nickname
修改签名 /service/user/set_description
修改绑定手机 /service/user/set_phone
获取好友列表 /service/friend/get_friend_list
获取好友信息 /service/friend/get_friend_info
发送好友申请 /service/friend/add_friend_apply
好友申请处理 /service/friend/add_friend_process
删除好友 /service/friend/remove_friend
搜索用户 /service/friend/search_friend
获取指定用户的消息会话列表 /service/friend/get_chat_session_list
创建消息会话 /service/friend/create_chat_session
获取消息会话成员列表 /service/friend/get_chat_session_member
获取待处理好友申请事件列表 /service/friend/get_pending_friend_events
获取历史消息/离线消息列表 /service/message_storage/get_history
获取最近N条消息列表 /service/message_storage/get_recent
搜索历史消息 /service/message_storage/search_history
发送消息 /service/message_transmit/new_message
获取单个文件数据 /service/file/get_single_file
获取多个文件数据 /service/file/get_multi_file
发送单个文件 /service/file/put_single_file
发送多个文件 /service/file/put_multi_file
语音转文字 /service/speech/recognition
}
*/
其次,在 HTTP 请求正文中,将采用 protobuf 协议作为正文的序列化方式,不同的请求正文与后台的请求基本上吻合,因此请求正文结构,将与后台服务之间复用同一套接口,具体接口格式在下列各项子服务中给出
网关 Websocket 接口
Websocket接口中,包含两个方面的内容:
- 连接的身份识别
当用户登录成功后,向服务器发起 websocket 长连接请求,建立长连接。
长连接建立成功后,向服务器发送身份鉴权请求,请求内容为 protobuf 结构数据,主要内容为:
1.请求ID
2.登录会话ID:用于进行身份识别
该请求不需要服务端进行回复,鉴权成功则长连接保持,鉴权失败则断开长连接即可
syntax = "proto3";
package yjt_im;
import "base.proto";
option cc_generic_services = true;
message ClientAuthenticationReq {
string request_id = 1;
string session_id = 2;
}
- 事件的通知
因为事件通知在 websocket 长连接通信中进行,因此只需要定义出消息结构即可:先将一些公共结构给提取出来进行定义,定义到一个 base.proto 文件中
syntax = "proto3";
package yjt_im;
option cc_generic_services = true;
//用户信息结构
message UserInfo {
string user_id = 1;
//用户ID
string nickname = 2;
//昵称
string description = 3;
//个人签名/描述
string phone = 4;
//绑定手机号
bytes avatar = 5;
//头像照片,文件内容使用二进制
}
//聊天会话信息
message ChatSessionInfo {
optional string single_chat_friend_id = 1;
//群聊会话不需要设置,单聊会话设置为对方ID
string chat_session_id = 2;
//会话ID
string chat_session_name = 3;
//会话名称git
optional MessageInfo prev_message = 4;
//会话上一条消息,新建的会话没有最新消息
optional bytes avatar = 5;
//会话头像 --群聊会话不需要,直接由前端固定渲染,单聊就是对方的头像
}
//消息类型
enum MessageType {
STRING = 0;
IMAGE = 1;
FILE = 2;
SPEECH = 3;
}
message StringMessageInfo {
string content = 1;
//文字聊天内容
}
message ImageMessageInfo {
optional string file_id = 1;
//图片文件id,客户端发送的时候不用设置,由transmit服务器进行设置后交给storage的时候设置
optional bytes image_content = 2;
//图片数据,在ES中存储消息的时候只要id不要文件数据, 服务端转发的时候需要原样转发
}
message FileMessageInfo {
optional string file_id = 1;
//文件id,客户端发送的时候不用设置
int64 file_size = 2;
//文件大小
string file_name = 3;
//文件名称
optional bytes file_contents = 4;
//文件数据,在ES中存储消息的时候只要id和元信息,不要文件数据, 服务端转发的时候也不需要填充
}
message SpeechMessageInfo {
optional string file_id = 1;
//语音文件id,客户端发送的时候不用设置
optional bytes file_contents = 2;
//文件数据,在ES中存储消息的时候只要id不要文件数据, 服务端转发的时候也不需要填充
}
message MessageContent {
MessageType message_type = 1;
//消息类型
oneof msg_content {
StringMessageInfo string_message = 2;
//文字消息
FileMessageInfo file_message = 3;
//文件消息
SpeechMessageInfo speech_message = 4;
//语音消息
ImageMessageInfo image_message = 5;
//图片消息
};
}
//消息结构
message MessageInfo {
string message_id = 1;
//消息ID
string chat_session_id = 2;
//消息所属聊天会话ID
int64 timestamp = 3;
//消息产生时间
UserInfo sender = 4;
//消息发送者信息
MessageContent message = 5;
}
message Message {
string request_id = 1;
MessageInfo message = 2;
}
message FileDownloadData {
string file_id = 1;
bytes file_content = 2;
}
message FileUploadData {
string file_name = 1;
int64 file_size = 2;
bytes file_content = 3;
}
然后,开始定义通知内容结构:
syntax = "proto3";
package yjt_im;
import "base.proto";
option cc_generic_services = true;
enum NotifyType {
FRIEND_ADD_APPLY_NOTIFY = 0;
FRIEND_ADD_PROCESS_NOTIFY = 1;
CHAT_SESSION_CREATE_NOTIFY = 2;
CHAT_MESSAGE_NOTIFY = 3;
FRIEND_REMOVE_NOTIFY = 4;
}
message NotifyFriendAddApply {
UserInfo user_info = 1;
//申请人信息
}
message NotifyFriendAddProcess {
bool agree = 1;
UserInfo user_info = 2;
//处理人信息
}
message NotifyFriendRemove {
string user_id = 1;
//删除自己的用户ID
}
message NotifyNewChatSession {
ChatSessionInfo chat_session_info = 1;
//新建会话信息
}
message NotifyNewMessage {
MessageInfo message_info = 1;
//新消息
}
message NotifyMessage {
optional string notify_event_id = 1;
//通知事件操作id(有则填无则忽略)
NotifyType notify_type = 2;
//通知事件类型
oneof notify_remarks {
//事件备注信息
NotifyFriendAddApply friend_add_apply = 3;
NotifyFriendAddProcess friend_process_result = 4;
NotifyFriendRemove friend_remove = 7;
NotifyNewChatSession new_chat_session_info = 5;
//会话信息
NotifyNewMessage new_message_info = 6;
//消息信息
}
}
剩余的proto文件就不一一列举出来了,可以去我的gitee上面查找
https://gitee.com/coco_yang_jun_tao/yjt_chat
四.子服务实现
语音转换子服务
功能设计
语音转换子服务,用于调用语音识别 SDK,进行语音识别,将语音转为文字后返回给网关即可,因此提供的功能性接口只有一个:
- 语音消息的文字转换:客户端进行语音消息的文字转换。
模块划分
- 参数/配置文件解析模块:基于
gflags
框架直接使用进行参数/配置文件解析。 - 日志模块:基于
spdlog
框架封装的模块直接使用进行日志输出。 - 服务注册模块:基于
etcd
框架封装的注册模块直接使用进行语音识别子服务的服务注册。 - rpc 服务模块:基于
brpc
框架搭建 rpc 服务器。 - 语音识别 SDK 模块:基于语音识别平台提供的
sdk
直接使用,完成语音的识别转文字。
示意图
接口实现流程
语音识别:
- 接收请求,从请求中取出语音数据
- 基于语音识别sdk进行语音识别,获取识别后的文本内容
- 组织响应进行返回
文件管理子服务
功能设计
- 文件的上传
- 单个文件的上传:这个接口基本用于后台部分,收到文件消息后将文件数据转发给文件子服务进行存储
- 多个文件的上传:这个接口基本用于后台部分,收到文件消息后将文件数据转发给文件子服务进行存储
- 文件的下载
- 单个文件的下载:在后台用于获取用户头像文件数据,以及客户端用于获取文件 / 语音 / 图片 消息的文件数据
- 多个文件的下载:在后台用于大批量获取用户头像数据(比如获取用户列表的时候),以及前端的批量文件下载
模块划分
- 参数/配置文件解析模块:基于
gflags
框架直接使用进行参数/配置文件解析。 - 日志模块:基于
spdlog
框架封装的模块直接使用进行日志输出。 - 服务注册模块:基于
etcd
框架封装的注册模块直接使用进行文件存储管理子服务的服务注册。 - rpc 服务模块:基于
brpc
框架搭建 rpc 服务器。 - 文件操作模块:基于标准库的文件流操作实现文件读写的封装。
模块功能示意图
接口实现流程
- 单个文件的上传:
- 获取文件元数据(大小、文件名、文件内容)
- 为文件分配文件 ID
- 以文件 ID 为文件名打开文件,并写入数据
- 组织响应进行返回
- 多个文件的上传:
多文件上传,其实相较于单文件上传,就是将处理的过程循环进行了而已
- 从请求中获取文件元数据
- 为文件分配文件 ID
- 以文件 ID 为文件名打开文件,并写入数据
- 回到第一步进行下一个文件的处理
- 当所有文件数据存储完毕,组织响应进行返回
- 单个文件的下载:
- 从请求中获取文件 ID
- 以文件 ID 作为文件名打开文件,获取文件大小,并从中读取文件数据
- 组织响应进行返回
- 多个文件的下载:
多文件下载,其实相较于单文件下载,就是将处理的过程循环进行了而已
- 从请求中获取文件 ID
- 以文件 ID 作为文件名打开文件,获取文件大小,并从中读取文件数据
- 回到第一步进行下一个文件的处理
- 当所有文件数据获取完毕,组织响应进行返回
用户管理子服务
功能设计
用户管理子服务,主要用于管理用户的数据,以及关于用户信息的各项操作,因此在上述项目功能中,用户子服务需要提供以下接口:
- 用户注册:用户输入用户名(昵称),以及密码进行用户名的注册
- 用户登录:用户通过用户名和密码进行登录
- 短信验证码获取:当用户通过手机号注册或登录的时候,需要获取短信验证码
- 手机号注册:用户输入手机号和短信验证码进行手机号的用户注册
- 手机号登录:用户输入手机号和短信验证码进行手机号的用户登录
- 用户信息获取:当用户登录之后,获取个人信息进行展示
- 头像修改:设置用户头像
- 昵称修改:设置用户昵称
- 签名修改:设置用户签名
- 手机号修改:修改用户的绑定手机号
模块划分
- 参数/配置文件解析模块:基于
gflags
框架直接使用进行参数/配置文件解析。 - 日志模块:基于
spdlog
框架封装的模块直接使用进行日志输出。 - 服务注册模块:基于
etcd
框架封装的注册模块直接使用,进行聊天消息存储子服务的注册。 - 数据库数据操作模块:基于
odb-mysql
数据管理封装的模块,实现关系型数据库中数据的操作。- 用户进行用户名/手机号注册的时候在数据库中进行新增信息
- 用户修改个人信息的时候修改数据库中的记录
- 用户登录的时候,在数据库中进行用户名密码的验证
- redis 客户端模块:基于
redis++
封装的客户端进行内存数据库数据操作- 当用户登录的时候需要为用户创建登录会话,会话信息保存在 redis 服务器中。
- 当用户手机号进行获取/验证验证码的时候,验证码与对应信息保存在 redis 服务器中
- rpc 服务模块:基于
brpc
框架搭建 rpc 服务器。 - rpc 服务发现与调用模块:基于
etcd
框架与brpc
框架封装的服务发现与调用模块,- 连接文件管理子服务:获取用户信息的时候,用户头像是通过文件的形式存储在文件子服务中的。
- 连接消息管理子服务:在打开聊天会话的时候,需要获取最近的一条消息进行展示。
- ES 客户端模块:基于
elasticsearch
框架实现访问客户端,向 ES 服务器中存储用户简息,以便于用户的搜索 - 短信平台客户端模块:基于短信平台 SDK 封装使用,用于向用户手机号发送指定验证码。
模块功能示意图
数据管理
关系数据库数据管理:
在关系型数据库中,对于用户子服务来说,总体只进行了一个信息数据的存储与管理,那就是用户信息数据,因此只需要构建好用户信息表,提供好对应的操作即可。用户数据表:
包含字段:
- 主键ID:自动生成
- 用户ID:用户唯一标识
- 用户昵称:用户的昵称,也可用作登录用户名
- 用户签名:
- 登录密码
- 绑定手机号:可以绑定手机号,绑定后可以通过手机号登录
- 用户头像文件ID:头像文件存储的唯一标识,具体头像数据存储再文件子服务器中
提供的操作:
- 通过昵称获取用户信息
- 通过手机号获取用户信息
- 通过通过用户id获取用户信息
- 新增用户
- 更新用户信息
ODB 映射数据结构:
#include <string>#include <cstddef>#include <odb/core.hxx>#include <odb/nullable.hxx>#pragma db objectclass user{public:user () {}private:friend class odb::access;#pragma db id autounsigned long _id;#pragma db unique type("VARCHAR(127)")std::string _user_id;#pragma db unique type("VARCHAR(63)")odb::nullable<std::string> _nickname;#pragma db type("VARCHAR(255)")odb::nullable<std::string> _passwd;#pragma db type("VARCHAR(127)")odb::nullable<std::string> _avatar_id;#pragma db unique type("VARCHAR(15)")odb::nullable<std::string> _phone_number;#pragma db type("VARCHAR(255)")odb::nullable<std::string> _description;};
内存数据库数据管理
会话信息映射键值对
映射类型:字符串键值对映射 映射字段:
- 会话ID(key)-用户ID(val):便于通过会话ID查找用户ID,进行后续操作时的连接身份识别鉴权
- 用户登陆时新增数据
- 用户登录后的操作中进行有无验证和查询
- 用户 ID(key) - 空(val) :这是一个用户登录状态的标记,用于避免同时重复登录
1.在用户登录的时候新增数据
2. 在用户连接断开的时候删除数据
验证码信息映射键值对
映射字段:字符串键值对映射 映射字段:
- 验证码ID-验证码:用于生成一个验证id和验证码
- 用户获取验证码的时候新增数据
- 验证码通过信息平台发送给手机用户
- 验证码id直接响应发送给用户,用户登录的时通过这两个信息进行验证
- 映射字段设置60s过期自动删除
文档数据库数据管理
用户信息的用户 ID,手机号,昵称字段需要在 ES 服务器额外进行一份存储,其目的是因为有用户搜索的功能,用户搜索通常会是一种字符串的模糊匹配方式,用传统的关系型数据库进行模糊匹配效率会极差,因此采用 ES 服务对索引字段进行分词后构建倒排索引,根据关键词进行搜索,效率会大大提升。
接口实现流程
用户注册
- 从请求中取出昵称和密码
- 检查昵称是否合法(只包含字母,数字,连字符,下划线,限制在3~15)
- 检查密码是否合法(只能包含字母,数字,限制在6~15)
- 检查数据库中是否已经存在该昵称
- 向数据库中新增数据
- 向ES服务器中新增用户信息
- 组织响应,进行成功与否的响应即可
用户登录
- 从请求中取出昵称和密码
- 通过昵称获取用户信息,进行密码一致性的判断
- 根据redis数据库中的登录信息标识判断该用户是否已经登录
- 构造会话ID,生成会话键值对,向redis数据库中添加会话信息以及登录标记信息
- 组织响应,返回生成的会话ID
获取短信验证码
- 从请求中取出手机号码
- 验证手机号码的格式是否正确(必须以1为开始,第二位3~9之间,后面9个数字字符)
- 生成4位随机验证码
- 基于短信SDK发送验证码
- 构建验证码ID,添加到redis数据库验证码映射键值索引
- 组织响应,返回生成的验证码ID
手机号注册
- 从请求中取出手机号码和验证码
- 检查注册手机号码是否合法
- 从redis数据库中进行验证码ID-验证码一致性匹配
- 通过数据库查询判断手机号是否已经注册过
- 向数据库中新增用户信息
- 向ES服务器中新增用户信息
- 组织响应,返回注册成功与否
手机号登录
- 从请求中取出手机号码和验证码ID,验证码
- 检查注册手机号码是否合法
- 根据手机号从数据库进行用户信息查询,判断用户是否已经存在
- 从redis数据库中进行验证码id - 验证码一致性的判断
- 根据redis数据库中的登录信息判断用户是否已经登录
- 构建登录会话ID,生成会话键值对,向redis数据库中添加绘画信息以及登录信息标识
- 组织响应,返回生成的会话ID
获取用户信息
- 从请求中读取用户ID
- 通过用户ID,从数据库中查找用户信息
- 根据用户信息的头像ID,从文件服务器中获取头像文件数据,组织完整用户信息
- 组织响应返回用户信息
设置头像
- 从请求中获取用户ID和头像数据
- 从数据库中通过用户ID进行用户信息查询,判断用户是否存在
- 上传头像文件到文件子服务中
- 将返回的头像文件ID更新到数据库中
- 更新ES服务器的用户信息
- 组织响应返回更新成功与否
设置签名
- 从请求中获取用户ID和新签名
- 从数据库中通过用户ID进行用户信息查询,判断用户是否存在
- 将新的签名更新到数据库中
- 同步更新ES服务器上的用户信息
- 组织响应返回更新成功与否
设置绑定的手机号
- 从请求中取出手机号码和验证码 ID,以及验证码。
- 检查手机号是否合法
- 从redis数据库中查看验证码和验证ID的一致性匹配
- 根据手机号从数据数据进行用户信息查询,判断用用户是否存在
- 将新的手机号更新到数据库中,更新 ES 服务器中用户信息
- 组织响应,返回更新成功与否
消息转发子服务
功能设计
转发子服务并不是直接进行转发的,而是针对一条消息内容进行组织消息ID以及各项要素,并告诉网关子服务这条消息要转发给谁。
通常消息都是聊天会话的基础进行发送的,根据会话找到他的所有成员,这就是转发目标
除此之外,转发子服务将收到的消息,放入消息队列中,由消息存储管理子服务进行消费存储
- 获取消息转发目标:针对消息内容进行组织,并告知网关转发目标
模块划分
- 参数/配置文件解析模块:基于 gflags 框架直接使用进行参数/配置文件解析。
- 日志模块:基于 spdlog 框架封装的模块直接使用进行日志输出。
- 服务注册模块:基于 etcd 框架封装的注册模块直接使用进行消息转发服务的服务注册。
- 数据库数据操作模块:基于 odb-mysql 数据管理封装的模块,从数据库获取会话成员。
- 服务发现与调用模块:基于 etcd 框架与 brpc 框架封装的服务发现与调用模块,从用户子服务获取消息发送者的用户信息。
- rpc 服务模块:基于 brpc 框架搭建 rpc 服务器。
- MQ 发布模块:基于 rabbitmq-client 封装的模块将消息发布到消息队列,让消息存储子服务进行消费,对消息进行存储。
功能模块示意图
接口实现流程
获取消息转发目标与消息处理
- 从消息中取出会话ID,用户ID,消息内容
- 根据用户ID从用户子服务中获取当前发送者的用户信息
- 根据消息内容构建完全的消息结构(分配消息ID,填充发送者信息,填充消息产生时间)
- 将消息序列化之后发布到MQ消息队列中,让消息存储子服务进行消息的持久化存储
- 从数据库中获取会话中所有成员ID
- 组织响应(完整的消息结构+目标用户ID),发送给网关告知网关应该把消息发送给谁
消息存储子服务
功能设计
消息管理子服务,主要用于管理消息的存储:
- 文本消息,存储在ES文档搜索服务中
- 文件/语音/图片,需要存储到文件管理子服务中
除了管理消息的存储,还需要管理消息的搜索获取,因此需要对外提供以下接口
- 获取历史消息
1. 获取最近的N条消息:用于登录成功之后,打开对象的聊天框,显示最近的消息
2. 获取指定时间段的消息:用户可以进行聊天消息的按时间搜索 - 关键字消息搜索:用户可以针对指定好友的聊天进行聊天消息的关键字搜索
模块划分
- 参数/配置文件解析模块:基于
gflags
框架直接使用进行参数/配置文件解析。 - 日志模块:基于
spdlog
框架封装的模块直接使用进行日志输出。 - 服务注册模块:基于
etcd
框架封装的注册模块直接使用,进行聊天消息存储子服务的注册。 - 数据库数据操作模块:基于
odb-mysql
数据管理封装的模块,进行数据库数据操作,用于从 MQ 中消费到消息后,向数据库中存储一份,以便于通过时间进行范围性查找。
a. 从数据库根据指定用户的所有好友信息 - rpc 服务模块:基于
brpc
框架搭建 rpc 服务器。 - 服务发现与调用模块:基于
etcd
框架与brpc
框架封装的服务发现与调用模块,
a. 连接文件管理子服务:将文件/语音/图片类型的消息以及用户头像之类的文件数据转储到文件管理子服务。
b. 连接用户管理子服务:在消息搜索时,根据发送用户的 ID 获取发送者用户信息 - ES 客户端模块:基于
elasticsearch
框架实现访问客户端,向 es 服务器进行文本聊天消息的存储,以便于文本消息的关键字搜索。 - MQ 消费模块:基于
rabbitmq-client
封装的消费者模块从消息队列服务器消费获取聊天消息,将文本消息存储到 ElasticSearch 服务,将文件消息转储到文件管理子服务,所有消息的简息都需要向数据库存储一份。
功能结构示意图
数据管理
数据库消息管理
在消息的存储管理中,所有的消息简息都要在数据库中存储一份,进行消息的持久化,以便于进行时间范围性查询和离线消息的实现。
消息类型有四种:文本,文件,语音,图片。
我们不可能将文件数据也存储到数据库中,因此数据库中只存储文本消息和其他类型消息的元信息即可。
数据库表结构
- 消息 ID:唯一标识
- 消息产生时间:用于进行时间性搜索
- 消息发送者用户 ID:明确消息的发送者
- 消息产生会话 ID:明确消息属于哪个会话
- 消息类型:明确消息的类型
- 消息内容:只存储文本消息;文件/语音/图片数据不进行存储,或者说是存储在文件子服务中。
- 文件 ID:只有文件/语音/图片类消息会用到
- 文件大小:只有文件/语音/图片类消息会用到
- 文件名称:只有文件类消息会用到
数据库操作
- 新增消息
- 通过消息ID获取消息信息
- 通过会话ID,时间范围,获取指定时间段的消息
- 通过会话ID,消息数量,获取最近的 N 条消息(逆序+limit 即可)
ODB 映射数据结构:
#include <string>#include <cstddef>#include <odb/core.hxx>#include <odb/nullable.hxx>#include <boost/date_time/posix_time/posix_time.hpp>#pragma db objectclass message{public:message (){}private:friend class odb::access;#pragma db id autounsigned long _id;#pragma db unique type("VARCHAR(127)")std::string _message_id ;#pragma db type("TIMESTAMP") not_nullboost::posix_time::ptime _created_time;#pragma db type("VARCHAR(127)")std::string _from_user_id ;#pragma db type("VARCHAR(127)")std::string _to_session_id ;#pragma db not_nullsigned char _message_type ;odb::nullable<std::string> _content;#pragma db type("VARCHAR(127)")odb::nullable<std::string> _file_id ;#pragma db type("VARCHAR(127)")odb::nullable<std::string> _filename ;odb::nullable<unsigned long> _filesize;};
ES 文本消息管理
因为当前聊天室项目中,实现了聊天内容的关键字搜索功能,但是如果在数据库中进行关键字的模糊匹配,则效率会非常低,因此采用 ES 进行消息内容存储与搜索,但是在搜索的时候需要进行会话的过滤,因此这里也要考虑 ES 索引的构造。
接口实现流程
获取最近的N条消息
- 从请求中获取会话ID和获取的消息数量N
- 访问数据库,从数据库按时间排序,获取指定数量的消息简略信息(消息 ID,会话 ID,消息类型,产生时间,发送者用户 ID,文本消息内容,文件消息元信息)
- 循环构造完整的消息信息(从用户子服务获取消息的发送者用户信息,从文件子服务获取文件/语音/图片数据)
- 组织响应返回给网关服务器。
指定时间段消息搜索
- 从请求中,获取会话 ID, 要获取的消息的起始时间与结束时间。
- 访问数据库,从数据库中按时间进行范围查询,获取消息简略信息(消息ID,会话 ID,消息类型,产生时间,发送者用户 ID,文本消息内容,文件消息元信息)
- 循环构造完整消息(从用户子服务获取消息的发送者用户信息,从文件子服务获取文件/语音/图片数据)
- 组织响应返回给网关服务器。
关键字消息搜索
- 从请求中,获取会话 ID, 搜索关键字。
- 基于封装的 ES 客户端,访问 ES 服务器进行文本消息搜索(以消息内容进行搜索,以会话 ID 进行过滤),从 ES 服务器获取到消息简息(消息 ID,会话 ID, 文本消息内容)。
- 循环从数据库根据消息 ID 获取消息简息(消息 ID,消息类型,会话 ID,发送者ID,产生时间,文本消息内容,文件消息元数据)。
- 循环从用户子服务获取所有消息的发送者用户信息,构造完整消息。
- 组织响应返回给网关服务器。
好友管理子服务
功能设计
好友管理子服务,主要用于管理好友相关的数据与操作,因此主要负责以下接口:
- 好友列表的获取:当用户登录成功之后,获取自己好友列表进行展示
- 申请好友:搜索用户之后,点击申请好友,向对方发送好友申请
- 待处理申请的获取:当用户登录成功之后,会获取离线的好友申请请求以待处理
- 好友申请的处理:针对收到的好友申请进行同意/拒绝的处理
- 删除好友:删除当前好友列表中的好友
- 用户搜索:可以进行用户的搜索用于申请好友
- 聊天会话列表的获取:每个单人/多人聊天都有一个聊天会话,在登录成功后可以获取聊天会话,查看历史的消息以及对方的各项信息
- 多人聊天会话的创建:单人聊天会话在对方同意好友时创建,而多人会话需要调用该接口进行手动创建
- 聊天成员列表的获取:多人聊天会话中,可以点击查看群成员按钮,查看群成员信息
模块划分
- 参数/配置文件解析模块:基于
gflags
框架直接使用进行参数/配置文件解析。 - 日志模块:基于
spdlog
框架封装的模块直接使用进行日志输出。 - 服务注册模块:基于
etcd
框架封装的注册模块直接使用,进行聊天消息存储子服务的注册。 - 数据库数据操作模块:基于
odb-mysql
数据管理封装的模块,实现数据库中数据的操作。- 申请好友的时候,根据数据库中的数据判断两人是否已经是好友关系
- 申请好友的时候,根据数据库中的数据判断是否已经申请过好友
- 申请好友的时候,针对两位用户 ID 建立好友申请事件信息
- 好友信息处理的时候,找到申请事件,进行删除。
- 获取待处理好友申请事件的时候,从数据库根据用户 ID 查询出所有的申请信息
- 同意好友申请的时候,需要创建单聊会话,向数据库中插入会话信息
- 从数据库根据指定用户 ID 获取所有好友 ID
- 创建群聊的时候,需要创建群聊会话,向数据库中插入会话信息
- 查看群聊成员的时候,从数据库根据会话 ID 获取所有会话成员 ID
- 获取会话列表的时候,从数据库根据用户 ID 获取到所有会话信息
- 删除好友的时候,从数据库中删除两人的好友关系,以及单聊会话,以及会话成员信息
- rpc 服务模块:基于
brpc
框架搭建 rpc 服务器。 - rpc 服务发现与调用模块:基于
etcd
框架与brpc
框架封装的服务发现与调用模块,- 连接用户管理子服务:获取好友列表,会话成员,好友申请事件的时候获取用户信息。
- 连接消息管理子服务:在打开聊天会话的时候,需要获取最近的一条消息进行展示。
- ES 客户端模块:基于
elasticsearch
框架实现访问客户端,从 es 服务器进行用户的关键字搜索(用户信息由用户子服务在用户注册的时候添加进去)
功能模块示意图
数据管理
根据好友相关操作分析,好友操作相关所需要有以下数据表:
用户信息表
该表由用户操作服务进行创建,并在用户注册时添加数据,好友这里只进行查询。
- 通过用户ID获取用户的详细信息
用户关系表
因为本身用户服务器已经管理了用户个人信息,因此没必要再整一份用户信息出来,也因为当前用户之间只有好友关系(目前未实现:黑名单,陌生人…),因此这里是一个好友关系表,表示谁和谁是好友
包含字段
- ID:主键
- 用户ID
- 好友ID
需要注意的是两个用户结为好友时,需要添加 (1,2),(2,1) 两条数据
提供的操作:
- 新增用户关系
- 新增好友,通常伴随着新增会话,新增会话伴随着新增会话成员
- 移除用户关系
- 移除好友,通常伴随着移除会话,移除会话伴随着移除会话成员
- 判断两人是否是好友关系
- 以用户 ID 获取用户的所有好友 ID
- 调用用户子服务,以用户 ID 获取所有好友详细信息
ODB映射结构代码
#pragma once
#include <odb/core.hxx>#include <odb/nullable.hxx>#pragma db object table("relation")class Relation{public:Relation() {}private:friend class odb::access;#pragma db id autounsigned long _id;#pragma db type("varchar(64)") index std::string _user_id;#pragma db type("varchar(64)")std::string _peer_id;};
会话信息
在多人聊天中,舍弃了群的概念,添加了聊天会话的概念,因为会话既可以是两人单聊会话,也可以是多人聊天会话,这样就可以统一管理了。
包含字段
- ID:作为主键
- 会话 ID:会话标识
- 会话名称:单聊会话则设置为“单聊会话” 或直接为空就行,因为单聊会话名称就是对方名称,头像就是对方头像
- 会话类型:
SINGLE
-单聊 /GROUP
-多人(单聊由服务器在同意好友时创建,多人由用户申请创建)
提供的操作
- 新增会话
- 向会话成员表中新增会话成员信息
- 向会话表中新增会话信息
- 删除会话
- 删除会话成员表中的所有会话成员信息
- 删除会话表中的会话信息
- 通过会话 ID,获取会话的详细信息
- 通过用户 ID 获取所有的好友单聊会话(连接会话成员表和会话表)
- 所需字段:
- 会话 ID
- 会话名称:好友的昵称
- 会话类型:单聊类型
- 会话头像 ID:好友的头像 ID
- 好友 ID:
- 所需字段:
- 通过用户 ID 获取所有自己的群聊会话信息(连接会话成员表和会话表)
- 所需字段:
- 会话 ID
- 会话名称
- 会话类型:群聊类型
- 所需字段:
ODB 映射结构
#pragma once
#include <odb/core.hxx>#include <odb/nullable.hxx>enum class session_type_t{SINGLE = 1,GROUP = 2};#pragma db objectclass chat_session{public:chat_session() {}private:friend class odb::access;#pragma db id autolong int _id;#pragma db unique type("VARCHAR(127)")std::string _session_id;#pragma db type("VARCHAR(127)")odb::nullable<std::string> _session_name;#pragma db type("TINYINT")session_type_t _session_type;};
会话成员
每个会话中都会有两个以上的成员,2个成员是单聊会话,2个以上就是多聊会话,为了明确哪个用户属于哪个会话,或者是会话中包含了哪些成员,所以需要会话成员的数据管理
包含字段:
- ID:作为主键
- 会话iD
- 用户ID
有了这张表就可以轻松的找出哪个用户属于哪个会话了,也可以根据会话 ID 获取所有成员 ID。
提供的操作:
- 向指定会话中添加单个成员
- 向指定会话中添加多个成员。
- 从指定会话中删除单个成员
- 通过会话 ID,获取会话的所有成员 ID
- 删除会话所有成员:在删除会话的时候使用。
odb映射结构
#pragma once
#include <odb/core.hxx>#include <odb/nullable.hxx>#pragma db objectclass chat_session_member{public:chat_session_member (){}private:friend class odb::access;#pragma db id autounsigned long _id;#pragma db index type("VARCHAR(127)")std::string _session_id;#pragma db type("VARCHAR(127)")std::string _user_id;};
好友申请事件
好友申请的操作需要额外管理,用户A申请用户B不是一次性完成的,需要B对本次事件进行处理,同意之后才算一次完整的请求,而在两次操作之间我们就需要为两次操作建立起相匹配的关系映射。
包含字段:
- ID:作为主键
- 事件ID
- 请求者用户ID
- 响应者用户ID
- 状态:用于表示本次请求的处理阶段,其包含三种状态:待处理-todo,同意-accept,拒绝-reject。
包含的操作:
- 新增好友申请事件:申请的时候新增
- 删除好友申请事件:处理完毕(同意/拒绝)的时候删除
- 获取指定用户的所有待处理事件及关联申请者用户信息(连接用户表)
ODB映射结构:
enum class fevent_status
{
PENDING = 1,
ACCEPT = 2,
REJECT = 3
};
#pragma db object
class friend_event
{
public:
friend_event() {
}
private:
friend class odb
::access;
#pragma db id auto
long int _id;
#pragma db unique type("VARCHAR(127)")
std::string _event_id;
#pragma db type("VARCHAR(127)")
std::string _req_user_id;
#pragma db type("VARCHAR(127)")
std::string _rsp_user_id;
#pragma db type("TINYINT")
fevent_status _status;
};
接口实现流程
获取好友列表
- 获取请求中的用户ID
- 根据用户ID,从数据库中取出该用户所有的好友ID
- 从用户子服务中批量获取用户信息
- 组织响应,将好友列表返回给网关
申请添加好友
- 从申请中获取请求者ID和被请求者ID
- 判断是否已经是好友
- 判断是否已经申请过好友
- 向好友时间表中新增申请信息
- 组织响应,将事件ID信息返回个网关
获取待处理好友申请事件
- 取出请求中的用户ID
- 从申请时间表和用户表中找到该用户状态为PENDING的待处理事件关联申请人用户的信息
- 根据申请人用户头像id,从文件存储子服务获取所有用户头像信息,组织用户信息结构
- 组织响应,将申请事件列表相应给网关
处理好友申请
- 取出请求中的申请人 ID,和被申请人 ID,以及处理结果
- 根据两人 ID 在申请事件表中查询判断是否存在申请事件
- 判断两人是否已经是好友(互相加好友的情况)
- 不管拒绝还是同意,删除申请事件表中的事件信息(该事件处理完毕)
- 若同意申请,则向用户关系表中添加好友关系数据,向会话表中新增会话信息,向会话成员表中新增成员信息
- 组织响应,将新生成的会话 ID 响应给网关。
删除好友
- 取出请求中的删除者 ID 和被删除者 ID
- 从用户好友关系表中删除相关关系数据,从会话表中删除单聊会话,从会话成员
表中删除会话成员信息 - 组织响应,返回给网关
搜索好友
- 取出请求中的用户 ID,和搜索关键字
- 从好友关系表中取出该用户所有好友 ID
- 根据关键字从 ES 服务器中进行用户搜索,搜索的时候需要将关键字作为用户 ID/ 手机号/ 昵称的搜索关键字进行搜索,且需要根据自己的 ID 和好友 ID 过滤掉自己和自己的好友。
- 根据搜索到的用户简息中的头像 ID,从文件服务器批量获取用户头像数据
- 组织响应,将搜索到的用户列表响应给网关
创建会话
- 从请求中取出用户 ID 与会话名称,以及会话的成员 ID 列表
- 生成会话 ID,并向会话表中新增会话信息数据,会话为群聊会话(单聊会话是同意好友申请的时候创建的)
- 向会话成员表中新增所有的成员信息
- 组织响应,将组织好的会话信息响应给网关。
获取会话列表
- 从请求中取出用户 ID
- 根据用户 ID,从会话表&会话成员表&用户表中取出好友的单聊会话列表(会话 ID,好友用户 ID,好友昵称,好友头像 ID),并组织会话信息结构对象
- 根据单聊会话 ID,从消息存储子服务获取会话的最后一条消息
- 根据好友头像 ID,从文件存储子服务批量获取好友头像数据,
- 组织好单聊会话结构数据
- 根据用户 ID,从会话表&会话成员表中取出群聊会话列表(会话 ID,会话名称)
- 根据群聊会话 ID,从消息存储子服务获取会话的最后一条消息
- 组织好群聊会话结构数据
- 将单聊会话数据和群聊会话数据组织到一起,响应给网关。
获取会话成员
- 取出请求中用户 ID,和会话 ID
- 根据会话 ID,从会话成员表&用户表中取出所有的成员用户信息
- 根据成员信息中的头像 ID,从文件存储子服务批量获取头像数据组织用户信息结构
- 组织响应,将会话的成员用户信息列表响应给网关
网关子服务
功能设计
网关服务器在设计中,最重要的两个功能:
- 作为入口服务器接收客户端的所有请求,进行请求的子服务分发,得到响应后进行响应
- 对客户端进行事件通知(好友申请和处理及删除,单聊/群聊会话创建,新消息)
基于以上的两个功能,因此网关服务器包含两项通信:
- HTTP 通信:进行业务处理
- WEBSOCKET 通信:进行事件通知
模块划分
- 参数/配置文件解析模块:基于
gflags
框架直接使用进行参数/配置文件解析。 - 日志模块:基于
spdlog
框架封装的模块直接使用进行日志输出。 - rpc 服务发现与调用模块:基于
etcd
框架与brpc
框架封装的服务发现与调用模块- 因为要分发处理所有请求,因此所有的子服务都需要进行服务发现。
- redis 客户端模块:基于
redis++
封装的客户端进行内存数据库数据操作- 根据用户子服务添加的会话信息进行用户连接身份识别与鉴权
- HTTP 通信服务器模块:基于
cpp-httplib
库搭建 HTTP 服务器,接收 HTTP 请求进行业务处理。 - WEBSOCKET 服务器模块:基于
Websocketpp
库,搭建 websocket 服务器,进行事件通知。 - 客户端长连接管理模块:建议用户 ID 与长连接句柄映射关系,便于后续根据用户 ID 找到连接进行事件通知
功能模块示意图
接口实现流程
用户名登录
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 查找用户子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端。
短信验证码获取
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 查找用户子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端。
手机号码注册
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 查找用户子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端。
手机号码登录
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 查找用户子服务
- 查找用户子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端。
修改用户昵称
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 根据请求中的会话 ID 进行鉴权,并获取用户 ID,向请求中设置用户 ID
- 根据请求中的用户 ID,调用用户子服务,获取用户的详细信息
- 查找好友子服务
- 调用子服务对应接口进行业务处理
- 若处理成功,则通过被申请人 ID,查找对方长连接
- 若长连接存在(对方在线),则组织好友申请通知进行事件通知
- 将处理结果响应给客户端。
获取待处理好友申请
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 根据请求中的会话 ID 进行鉴权,并获取用户 ID,向请求中设置用户 ID
- 查找好友子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端。
好友申请处理
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 根据请求中的会话 ID 进行鉴权,并获取用户 ID,向请求中设置用户 ID
- 查找好友子服务
- 调用子服务对应接口进行业务处理
- 若处理成功,则通过被删除者用户 ID,查找对方长连接
- 若长连接存在(对方在线),则组织好友删除通知进行事件通知
- 将处理结果响应给客户端。
搜索用户
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 根据请求中的会话 ID 进行鉴权,并获取用户 ID,向请求中设置用户 ID
- 查找好友子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端。
获取用户聊天会话列表
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 根据请求中的会话 ID 进行鉴权,并获取用户 ID,向请求中设置用户 ID
- 查找消息转发子服务
- 调用子服务对应接口进行业务处理
- 若处理成功,则根据处理结果中的用户 ID 列表,循环找到目标长连接,根据处理结果中的消息字段组织新消息通知,逐个对目标进行新消息通知。
- 若处理失败,则根据处理结果中的错误提示信息,设置响应内容
- 将处理结果响应给客户端。
获取指定时间段消息列表
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 根据请求中的会话 ID 进行鉴权,并获取用户 ID,向请求中设置用户 ID
- 查找消息存储子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端。
获取最近 N 条消息列表
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 根据请求中的会话 ID 进行鉴权,并获取用户 ID,向请求中设置用户 ID
- 查找文件子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端。
多个文件数据上传
- 取出 HTTP 请求正文,进行 ProtoBuf 反序列化
- 根据请求中的会话 ID 进行鉴权,并获取用户 ID,向请求中设置用户 ID
- 查找语音子服务
- 调用子服务对应接口进行业务处理
- 将处理结果响应给客户端
五. 后台服务器部署
docker 是一个用 Go 语言实现的应用容器引擎开源项目,可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。其广泛应用于开发、测试和生产环境中,帮助开发者和系统管理员简化应用的部署和管理,实现快速的交付,测试和部署。
编写项目配置文件
在项目的各个子服务中,每个子服务可能都会有不同的配置,代码中我们通过 gflags进行了参数解析,但是如果改换了部署的机器,就需要修改代码中的数据,然后重新编译代码,这是一件非常麻烦的事情,会导致项目的自动部署成为空谈,幸好, gflags不仅支持参数的解析,也支持配置文件的解析,因此我们需要将代码中需要的参数通过配置文件来进行配置。
#程序的运行模式, false-调试; true-发布; );
-run_mode=false
#发布模式下,用于指定日志的输出文件);
-log_file=/im/logs/file.log
#发布模式下,用于指定日志输出等级);
-log_level=0
#服务注册中心地址);
-registry_host=http://10.0.0.235:2379#服务监控根目录);
-base_service=/service
#当前实例名称);
-instance_name=/file_service/instance
-instance_name=/friend_service/instance
-instance_name=/message_service/instance
-instance_name=/speech_service/instance
-instance_name=/transmite_service/instance
-instance_name=/user_service/instance
#当前实例的外部访问地址);
-access_host=10.0.0.235:10002
#Rpc 服务器监听端口);
-listen_port=10002
#Rpc 调用超时时间);
-rpc_timeout=-1
#Rpc 的 IO 线程数量);
-rpc_threads=1
#, Mysql 服务器访问地址);
-mysql_host=10.0.0.235
#, Mysql 服务器访问用户名);
-mysql_user=root
#, Mysql 服务器访问密码);
-mysql_pswd=123456
#, Mysql 默认库名称);
-mysql_db=yjt_im
#, Mysql 客户端字符集);
-mysql_cset=utf8
#, Mysql 服务器访问端口);
-mysql_port=3306
#, Mysql 连接池最大连接数量);
-mysql_pool_count=4
-es_host=http://10.0.0.235:9200/
#, Redis 服务器访问地址);
-redis_host=10.0.0.235
#, Redis 服务器访问端口);
-redis_port=6379
#, Redis 默认库号);
-redis_db=0
#, Redis 长连接保活选项);
-redis_keep_alive=true
#, 消息队列服务器访问用户名);
-mq_user=root
#, 消息队列服务器访问密码);
-mq_pswd=123456
#, 消息队列服务器访问地址);
-mq_host=10.0.0.235:5672
#, 持久化消息的发布交换机名称);
-mq_msg_exchange=msg_exchange
#, 持久化消息的发布队列名称);
-mq_msg_queue=msg_queue
#, 交换机与队列的绑定 key);
-mq_msg_binding_key=msg_queue
#, 短信平台密钥 ID);
-dms_key_id=xxx
#, 短信平台密钥);
-dms_key_secret=xxx
#, 语音平台应用 ID);
-app_id=xxx
#, 语音平台 API 密钥);
-api_key=xxx
#, 语音平台加密密钥);
-secret_key=xxx
-file_service=/service/file_service
#, 好友管理子服务名称);
-friend_service=/service/friend_service
#, 消息存储子服务名称);
-message_service=/service/message_service
#, 用户管理子服务名称);
-user_service=/service/user_service
#, 语音识别子服务名称);
-speech_service=/service/speech_service
#, 转发管理子服务名称);
-transmite_service=/service/transmite_service
查询程序依赖
在我们的子服务中,所采用的 docker 镜像与我们的开发环境保持一致,使用ubuntu:22.04,但是这个镜像是一个空白的镜像,因此我们需要针对这个空白的 ubuntu 镜像进行改造,搭建我们服务的运行环境。
注意,我们要搭建的是运行环境,而不是开发环境,也就是说只需要镜像中包含有我们程序运行所需的动态库即可,因此我们需要查找到我们子服务程序的依赖库,并将其拷贝到镜像中。
#!/bin/bash
deplist=$( ldd $1 | awk '{if (match($3,"/")){ print $3}}' )
cp -Lr $deplist $2
编写 dockerfile
编写每个子服务的 dockerfile,用于构造 docker 镜像。
# 声明基础镜像来源
FROM ubuntu:22.04
# 声明工作路径
WORKDIR /im
RUN mkdir -p /im/logs &&\
mkdir -p /im/data &&\
mkdir -p /im/conf &&\
mkdir -p /im/bin
# 将可执行程序文件,拷贝进入镜像
COPY ./build/file_server /im/bin/
# 将可执行程序依赖,拷贝进入镜像
COPY ./depends/ /lib/x86_64-linux-gnu/
COPY ./nc /bin/
# 设置容器的启动默认操作 --- 运行程序
CMD /im/bin/file_server -flagfile=/im/conf/file_server.conf
编写 entrypoint.sh
包含中间件在内,我们共有 13 个服务需要启动,这些服务之间会存在一些依赖关系,比如 user_service 启动之前,必须保证 mysql, redis, etcd 这些中间件已经启动才可以,因此我们需要做一些启动的顺序控制。
但是单纯 yaml 配置文件中的 depends 无法满足需求,因为它只能控制容器的启动顺序,无法控制容器内程序的启动顺序,因此,我们需要通过端口探测的方式进行程序运行的控制。
#!/bin/bash
#./entrypoint.sh -h 127.0.0.1 -p 3306,2379,6379 -c '/im/bin/file_server -flagfile=./xx.conf'
# 1. 编写一个端口探测函数,端口连接不上则循环等待
# wait_for 127.0.0.1 3306
wait_for() {
while ! nc -z $1 $2
do
echo "$2 端口连接失败,休眠等待!";
sleep 1;
done
echo "$1:$2 检测成功!";
}
# 2. 对脚本运行参数进行解析,获取到ip,port,command
declare ip
declare ports
declare command
while getopts "h:p:c:" arg
do
case $arg in
h)
ip=$OPTARG;
;
p)
ports=$OPTARG;
;
c)
command=$OPTARG;
;
esac
done
# 3. 通过执行脚本进行端口检测
# ${port //,/ } 针对port中的内容,以空格替换字符串中的, shell中数组--一种以空格间隔的字符串
for port in ${ports//,/ }
do
wait_for $ip $port
done
# 4. 执行command
# eval 对一个字符串进行二次检测,将其当作命令进行执行
eval $command
编写 docker-compose
包含中间件在内,我们共有 13 个服务需要启动,若一一都需要手动启动,会比较麻烦,因此我们使用 docker-compose 进行统一管理启动。
version: "3.8"
services:
etcd:
image: quay.io/coreos/etcd:v3.3.25
container_name: etcd-service
environment:
- ETCD_NAME=etcd-s1
- ETCD_DATA_DIR=/var/lib/etcd
- ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
- ETCD_ADVERTISE_CLIENT_URLS=http://0.0.0.0:2379
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
- ./middle/data/etcd:/var/lib/etcd:rw
ports:
- 2379:2379
restart: always
mysql:
image: mysql:8.0.42
container_name: mysql-service
environment:
MYSQL_ROOT_PASSWORD: 502502
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
- ./sql:/docker-entrypoint-initdb.d/:rw
- ./middle/data/mysql:/var/lib/mysql:rw
ports:
- 3306:3306
restart: always
redis:
image: redis:6.0.16
container_name: redis-service
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
- ./middle/data/redis:/var/lib/redis:rw
ports:
- 6379:6379
restart: always
elasticsearch:
image: elasticsearch:7.17.21
container_name: elasticsearch-service
environment:
- "discovery.type=single-node"
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
- ./middle/data/elasticsearch:/data:rw
ports:
- 9200:9200
- 9300:9300
restart: always
rabbitmq:
image: rabbitmq:3.9.13
container_name: rabbitmq-service
environment:
RABBITMQ_DEFAULT_USER: root
RABBITMQ_DEFAULT_PASS: 502502
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
- ./middle/data/rabbitmq:/var/lib/rabbitmq:rw
ports:
- 5672:5672
restart: always
file_server:
build: ./file
#image: server-user_server
container_name: file_server-service
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
# 挂载的信息: entrypoint.sh文件 数据目录(im/logs, im/data), 配置文件
- ./conf/file_server.conf:/im/conf/file_server.conf
- ./middle/data/logs:/im/logs:rw
- ./middle/data/data:/im/data:rw
- ./entrypoint.sh:/im/bin/entrypoint.sh
ports:
- 10002:10002
restart: always
entrypoint:
# 跟dockerfile中的cmd比较类似,都是容器启动后的默认操作--替代dockerfile中的cmd
/im/bin/entrypoint.sh -h 10.1.12.8 -p 2379 -c "/im/bin/file_server -flagfile=/im/conf/file_server.conf"
depends_on:
- etcd
friend_server:
build: ./friend
#image: file-server:v1
container_name: friend_server-service
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
# 挂载的信息: entrypoint.sh文件 数据目录(im/logs, im/data), 配置文件
- ./conf/friend_server.conf:/im/conf/friend_server.conf
- ./middle/data/logs:/im/logs:rw
- ./middle/data/data:/im/data:rw
- ./entrypoint.sh:/im/bin/entrypoint.sh
ports:
- 10006:10006
restart: always
depends_on:
- etcd
- mysql
- elasticsearch
entrypoint:
# 跟dockerfile中的cmd比较类似,都是容器启动后的默认操作--替代dockerfile中的cmd
/im/bin/entrypoint.sh -h 10.1.12.8 -p 2379,3306,9200 -c "/im/bin/friend_server -flagfile=/im/conf/friend_server.conf"
gateway_server:
build: ./gateway
#image: file-server:v1
container_name: gateway_server-service
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
# 挂载的信息: entrypoint.sh文件 数据目录(im/logs, im/data), 配置文件
- ./conf/gateway_server.conf:/im/conf/gateway_server.conf
- ./middle/data/logs:/im/logs:rw
- ./middle/data/data:/im/data:rw
- ./entrypoint.sh:/im/bin/entrypoint.sh
ports:
- 9000:9000
- 9001:9001
restart: always
depends_on:
- etcd
- redis
entrypoint:
# 跟dockerfile中的cmd比较类似,都是容器启动后的默认操作--替代dockerfile中的cmd
/im/bin/entrypoint.sh -h 10.1.12.8 -p 2379,6379 -c "/im/bin/gateway_server -flagfile=/im/conf/gateway_server.conf"
message_server:
build: ./message
#image: file-server:v1
container_name: message_server-service
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
# 挂载的信息: entrypoint.sh文件 数据目录(im/logs, im/data), 配置文件
- ./conf/message_server.conf:/im/conf/message_server.conf
- ./middle/data/logs:/im/logs:rw
- ./middle/data/data:/im/data:rw
- ./entrypoint.sh:/im/bin/entrypoint.sh
ports:
- 10005:10005
restart: always
depends_on:
- etcd
- mysql
- elasticsearch
- rabbitmq
entrypoint:
# 跟dockerfile中的cmd比较类似,都是容器启动后的默认操作--替代dockerfile中的cmd
/im/bin/entrypoint.sh -h 10.1.12.8 -p 2379,3306,9200,5672 -c "/im/bin/message_server -flagfile=/im/conf/message_server.conf"
speech_server:
build: ./speech
#image: file-server:v1
container_name: speech_server-service
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
# 挂载的信息: entrypoint.sh文件 数据目录(im/logs, im/data), 配置文件
- ./conf/speech_server.conf:/im/conf/speech_server.conf
- ./middle/data/logs:/im/logs:rw
- ./middle/data/data:/im/data:rw
- ./entrypoint.sh:/im/bin/entrypoint.sh
ports:
- 10001:10001
restart: always
depends_on:
- etcd
entrypoint:
# 跟dockerfile中的cmd比较类似,都是容器启动后的默认操作--替代dockerfile中的cmd
/im/bin/entrypoint.sh -h 10.1.12.8 -p 2379 -c "/im/bin/speech_server -flagfile=/im/conf/speech_server.conf"
transmite_server:
build: ./transmite
#image: file-server:v1
container_name: transmite_server-service
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
# 挂载的信息: entrypoint.sh文件 数据目录(im/logs, im/data), 配置文件
- ./conf/transmite_server.conf:/im/conf/transmite_server.conf
- ./middle/data/logs:/im/logs:rw
- ./middle/data/data:/im/data:rw
- ./entrypoint.sh:/im/bin/entrypoint.sh
ports:
- 10004:10004
restart: always
depends_on:
- etcd
- mysql
- rabbitmq
entrypoint:
# 跟dockerfile中的cmd比较类似,都是容器启动后的默认操作--替代dockerfile中的cmd
/im/bin/entrypoint.sh -h 10.1.12.8 -p 2379,3306,5672 -c "/im/bin/transmite_server -flagfile=/im/conf/transmite_server.conf"
user_server:
build: ./user
#image: file-server:v1
container_name: user_server-service
volumes:
# 1. 希望容器内的程序能够访问宿主机上的文件
# 2. 希望容器内程序运行所产生的数据文件能落在宿主机上
# 挂载的信息: entrypoint.sh文件 数据目录(im/logs, im/data), 配置文件
- ./conf/user_server.conf:/im/conf/user_server.conf
- ./middle/data/logs:/im/logs:rw
- ./middle/data/data:/im/data:rw
- ./entrypoint.sh:/im/bin/entrypoint.sh
ports:
- 10003:10003
restart: always
depends_on:
- etcd
- mysql
- redis
- elasticsearch
entrypoint:
# 跟dockerfile中的cmd比较类似,都是容器启动后的默认操作--替代dockerfile中的cmd
/im/bin/entrypoint.sh -h 10.1.12.8 -p 2379,3306,5672,9200 -c "/im/bin/user_server -flagfile=/im/conf/user_server.conf"