集群聊天服务器
该项目是基于muduo网络库的集群聊天服务器,主要实现了登录、注销、注册、添加好友、一对一聊天、群组创建、群聊以及离线消息的接收和存储
项目内容
- 仿照muduo设计思想实现网络库,作为项目网络核心模块,提高并发网络IO,解耦网络与业务模块
- 使用json序列化与反序列化消息作为私有通信协议
- 配置nginx基于TCP的负载均衡,实现集群聊天服务器功能,提高后端并发能力
- 基于Redis的发布-订阅的服务器中间件,实现跨服务器的消息通信
- 使用MySQL作为项目数据的落地存储
项目讲解
数据库表设计
User表
字段名 | 字段类型 | 字段说明 | 约束 |
---|---|---|---|
id | INT | 用户id | PRIMARY KEY、AUTO_INCREMENT |
name | VARCHAR(50) | 用户名 | NOT NULL、UNIQUE |
password | VARCHAR(50) | 用户密码 | NOT NULL |
state | ENUM(‘online’,'offline) | 当前登录状态 | DEFAULT ‘offline’ |
Friend表
字段名 | 字段类型 | 字段说明 | 约束 |
---|---|---|---|
userid | INT | 用户id | NOT NULL、联合主键 |
friendif | INT | 好友id | NOT NULL、联合主键 |
AllGroup表
字段名 | 字段类型 | 字段说明 | 约束 |
---|---|---|---|
id | INT | 组id | PRIMARY KEY、AUTO_INCREMENT |
groupname | VARCHAR(50) | 组名 | NOT NULL、UNIQUE |
groupdesc | VARCHAR(200) | 组介绍 | DEFAULT ‘’ |
GroupUser表
字段名 | 字段类型 | 字段说明 | 约束 |
---|---|---|---|
groupid | INT | 组id | NOT NULL、联合主键 |
userid | INT | 用户id | NOT NULL、联合主键 |
grouprole | ENUM(‘creator’,‘normal’) | 组内角色 | DEFAULT ‘normal’ |
OfflineMessage表
字段名 | 字段类型 | 字段说明 | 约束 |
---|---|---|---|
userid | INT | 用户id | NOT NULL |
message | VARCHAR(500) | 离线消息(存储json字符串) | NOT NULL |
网络模块设计
仿照muduo设计思想实现网络库作为项目网络核心模块
muduo的线程模型为one loop per thread + threadPool:一个线程对应一个事件循环(EventLoop),也对应着一个Reactor模型,EventLoop负责IO与定时器事件的分派
muduo是主从Reactor模型,由mainReactor和subReactor组成,mainReactor通过Acceptor接收新连接,再将新连接分发到subReactor上进行连接的维护
如何解耦网络模块与业务模块
为每一个请求类型分配一个id,将请求id与对应的处理函数使用unordered_map保存起来,就可以根据id去获取到对应的处理函数了
// 注册消息以及对应的handler回调操作
ChatService::ChatService() {
msgHandlerMap_.insert({LOGIN_MSG, std::bind(&ChatService::login,this,_1,_2,_3)});
msgHandlerMap_.insert({REG_MSG,std::bind(&ChatService::reg,this,_1,_2,_3)});
msgHandlerMap_.insert({ONE_CHAT_MSG,std::bind(&ChatService::oneChat,this,_1,_2,_3)});
msgHandlerMap_.insert({ADD_FRIEND_MSG,std::bind(&ChatService::addFriend,this,_1,_2,_3)});
msgHandlerMap_.insert({CREATE_GROUP_MSG,std::bind(&ChatService::createGroup,this,_1,_2,_3)});
msgHandlerMap_.insert({ADD_GROUP_MSG,std::bind(&ChatService::addGroup,this,_1,_2,_3)});
msgHandlerMap_.insert({GROUP_CHAT_MSG,std::bind(&ChatService::groupChat,this,_1,_2,_3)});
msgHandlerMap_.insert({LOGINOUT_MSG,std::bind(&ChatService::loginout,this,_1,_2,_3)});
// 初始化redis
if (redis_.connect()) {
// 设置上报消息回调
redis_.initNotifyHandler(std::bind(&ChatService::handleRedisSubscribeMessage,this,_1,_2));
}
}
当有客户端请求到来时,muduo会执行消息事件到来时的回调函数,在这个回调函数中会去读取请求内容包含的msgId,根据msgId去获取对应的处理函数,以此实现网络与业务模块的解耦
void ChatServer::onConnection(const TcpConnectionPtr &conn) {
// 客户端连接断开
if (!conn->connected()) {
// 客户端异常退出处理
ChatService::instance()->clientCloseException(conn);
conn->shutdown();
}
}
业务模块设计
注册模块
从网络模块中接收数据,并根据msgId定位到注册模块
根据传递过来的json对象获取用户名称与用户密码生成User对象,调用model层方法将User插入到数据库中
插入成功则注册成功获取用户Id返回给用户,反之插入失败
void ChatService::reg(const TcpConnectionPtr &conn, json &js, Timestamp time) {
string name = js["name"];
string pwd = js["password"];
User user;
user.setName(name);
user.setPwd(pwd);
bool res = userModel_.insert(user);
if (res) {
// 注册成功
json response;
response["msgid"] = REG_MSG_ACK;
response["errno"] = 0;
response["id"] = user.getId();
conn->send(response.dump());
} else {
// 注册失败
json response;
response["msgid"] = REG_MSG_ACK;
response["errno"] = 1;
conn->send(response.dump());
}
}
登录模块
根据从json对象中获取到的用户Id和密码在数据库中查询用户信息是否匹配
如果不匹配则告诉用户登录失败,如果匹配则判断是否已经登录
如果已经登录则返回错误信息,否则登录成功则将用户id与连接插入到当前服务器的一个哈希键值对表中,并向Redis订阅通道为id的消息;最后在数据库中修改用户的登录状态以及给用户返回相关信息(好友列表、群组列表、离线消息等等)
void ChatService::login(const TcpConnectionPtr &conn, json &js, Timestamp time) {
int id = js["id"].get<int>();
string pwd = js["password"];
User user = userModel_.query(id);
if (user.getId() != -1 && user.getPwd() == pwd) {
if (user.getState() == "online") {
// 用户已经登录 不允许重复登录
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 2;
response["errmsg"] = "this account is using, input another!";
conn->send(response.dump());
} else {
// 登录成功
// 记录用户连接信息
{
lock_guard<mutex> lock(connMutex_);
userConnMap_.insert({id,conn});
}
// 向redis订阅
redis_.subscribe(id);
// 更新用户状态信息
user.setState("online");
userModel_.updateState(user);
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 0;
response["id"] = user.getId();
response["name"] = user.getName();
// 查询用户是否有离线消息
vector<string> msgs = offlineMsgModel_.query(id);
if (!msgs.empty()) {
response["offlinemsg"] = msgs;
// 将用户所有离线消息删除
offlineMsgModel_.remove(id);
}
// 查询该用户的好友信息
vector<User> friendVec = friendModel_.query(id);
if (!friendVec.empty()) {
vector<string> friends;
for (User &f : friendVec) {
json js;
js["id"] = f.getId();
js["name"] = f.getName();
js["state"] = f.getState();
friends.push_back(js.dump());
}
response["friends"] = friends;
}
// 查询用户的群组信息
vector<Group> groupuserVec = groupModel_.queryGroups(id);
if (!groupuserVec.empty()) {
vector<string> groupV;
for (Group &group : groupuserVec) {
json grpjson;
grpjson["id"] = group.getId();
grpjson["groupname"] = group.getName();
grpjson["groupdesc"] = group.getDesc();
vector<string> userV;
for (GroupUser &user : group.getUsers()) {
json js;
js["id"] = user.getId();
js["name"] = user.getName();
js["state"] = user.getState();
js["role"] = user.getRole();
userV.push_back(js.dump());
}
grpjson["users"] = userV;
groupV.push_back(grpjson.dump());
}
response["groups"] = groupV;
}
conn->send(response.dump());
}
} else {
// 登录失败
json response;
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 1;
response["errmsg"] = "id or password is invalid!";
conn->send(response.dump());
}
}
客户端注销模块
根据用户id在当前服务器的id和连接集合中找到对应的连接,并将其从集合中删除,取消该用户的消息订阅,将用户的状态设置为offline
void ChatService::loginout(const TcpConnectionPtr &conn, json &js, Timestamp time) {
int userid = js["id"].get<int>();
{
lock_guard<mutex> lock(connMutex_);
auto it = userConnMap_.find(userid);
if (it != userConnMap_.end()) {
userConnMap_.erase(it);
}
}
// 向redis取消订阅
redis_.unsubscribe(userid);
// 更新用户状态信息
User user(userid,"","","offline");
userModel_.updateState(user);
}
客户端异常退出模块
muduo的连接回调函数中检测到客户端连接断开就去执行相应的异常退出处理并释放相应的连接资源
void ChatServer::onConnection(const TcpConnectionPtr &conn) {
// 客户端连接断开
if (!conn->connected()) {
// 客户端异常退出处理
ChatService::instance()->clientCloseException(conn);
conn->shutdown();
}
}
根据连接在当前服务器的id和连接集合中找到连接对应的用户id,并将其从map中删除,取消该用户的消息订阅,将用户的状态设置为offline
void ChatService::clientCloseException(const TcpConnectionPtr &conn) {
User user;
// 找到客户的对应id
{
lock_guard<mutex> lock(connMutex_);
for (auto it = userConnMap_.begin(); it != userConnMap_.end(); it++) {
if (it->second == conn) {
user.setId(it->first);
// 从map删除用户连接信息
userConnMap_.erase(it);
break;
}
}
}
redis_.unsubscribe(user.getId());
// 更新用户的状态信息
if (user.getId() != -1) {
user.setState("offline");
userModel_.updateState(user);
}
}
服务端异常退出模块
异常退出一般是ctrl+c,通过捕捉信号并注册相应的信号处理回调函数执行服务器异常退出的业务
将所有在线的客户端状态都设置为offline
// main.cpp
// 处理服务器ctrl+c结束后重置user的状态信息
void restHandler(int) {
ChatService::instance()->reset();
exit(0);
}
int main() {
signal(SIGINT,restHandler);
}
// ChatService.cpp
void ChatService::reset() {
// 将onlind状态的用户置为offline
userModel_.resetState();
}
一对一聊天模块
先根据接收者id在当前服务器的id链接集合中查询用户是否存在,如果存在则主动推送消息
如果不存在则在数据库中查询是否在线,如果在线则说明在其他服务器上登录,采用Redis的分布订阅方式推送给用户订阅的频道
如果不在线则在数据库中存储离线消息等下回该用户上线再将离线消息推送给用户
void ChatService::oneChat(const TcpConnectionPtr &conn, json &js, Timestamp time) {
int toId = js["toid"].get<int>();
// 在同一台服务器上登录
{
lock_guard<mutex> lock(connMutex_);
auto it = userConnMap_.find(toId);
if (it != userConnMap_.end()) {
// 在线 服务器主动推送消息给用户
it->second->send(js.dump());
return;
}
}
// 在不同服务器上登录
User user = userModel_.query(toId);
if (user.getState() == "online") {
redis_.publish(toId,js.dump());
return;
}
// 存储离线消息
offlineMsgModel_.insert(toId,js.dump());
}
添加好友模块
获取用户id和要添加的好友id,查询好友id是否存在,如果存在则在数据库中添加相应的记录
void ChatService::addFriend(const TcpConnectionPtr &conn, json &js, Timestamp time) {
int userid = js["id"].get<int>();
int friendid = js["friendid"].get<int>();
// 存储好友信息
friendModel_.insert(userid,friendid);
}
创建群组模块
根据传递过来的json对象获取群组名称与群组介绍生成Group对象,调用model层方法将Group插入到数据库中
插入成功则注册成功获取群组Id返回给用户,反之插入失败
插入成功后存储用户与群组的关系
void ChatService::createGroup(const TcpConnectionPtr &conn, json &js, Timestamp time) {
int userid = js["id"].get<int>();
string name = js["groupname"];
string desc = js["groupdesc"];
Group group(-1,name,desc);
if (groupModel_.createGroup(group)) {
groupModel_.addGroup(userid,group.getId(),"creator");
}
}
添加群组业务
获取用户id和要添加的群组id,查询群组id是否存在,如果存在则在数据库中添加相应的记录
void ChatService::addGroup(const TcpConnectionPtr &conn, json &js, Timestamp time) {
int userid = js["id"].get<int>();
int groupid = js["groupid"].get<int>();
groupModel_.addGroup(userid,groupid,"normal");
}
群聊天业务
根据群id获取群成员,再向所有群成员发送该消息,逻辑与一对一聊天基本相同
void ChatService::groupChat(const TcpConnectionPtr &conn, json &js, Timestamp time) {
int userid = js["id"].get<int>();
int groupid = js["groupid"].get<int>();
vector<int> useridVec = groupModel_.queryGroupUsers(userid,groupid);
lock_guard<mutex> lock(connMutex_);
for (int id : useridVec) {
auto it = userConnMap_.find(id);
if (it != userConnMap_.end()) {
it->second->send(js.dump());
} else {
// 在其他服务器上登录
User user = userModel_.query(id);
if (user.getState() == "online") {
redis_.publish(id,js.dump());
} else {
offlineMsgModel_.insert(id,js.dump());
}
}
}
}
集群实现设计
使用Nginx负载均衡模块
当请求数过大时,单个服务器会处理不过来,可以增加服务器数量,将请求分发到各个服务器上,将原先请求集中到单个服务器上的情况改为请求分发到多个服务器上
使用Nginx的Tcp负载均衡模块原因
- 能够将客户端的请求按照负载均衡算法分发到不同服务器上
- 能够与服务器保持心跳机制,检测服务器故障
- 能够发现新添加的服务器,方便扩展服务器数量
Redis分布-订阅机制解决跨服务器通信问题
在单机模式下,如果接收者在线,则直接在连接集合中找到接收者的连接并将消息主动推送给接收者
但是在多机情况下,接收者可能与发送者不在同一台服务器上,就没办法找到对应的连接进行推送了
让各个服务器之间直接建立TCP连接同步各自保存的连接信息
- 会导致各个服务器之间耦合度高,不利于系统收缩与扩展
- 会占用系统大量的socket资源,需要进行同步
引入中间件消息队列
解耦各个服务器,使整个系统松耦合,提供服务器的响应能力,节省服务器的带宽资源
在Redis中设置相应的频道有消息到来时的回调
// 注册消息以及对应的handler回调操作
// 服务器的ChatService在创建时会与Redis建立连接
// 开启一个线程监听通道上的事件,有消息给业务层进行上报
bool Redis::connect()
{
// 负责publish发布消息的上下文连接
publishContext_ = redisConnect("127.0.0.1", 6379);
if (nullptr == publishContext_)
{
cerr << "connect redis failed!" << endl;
return false;
}
// 负责subscribe订阅消息的上下文连接
subcribeContext_ = redisConnect("127.0.0.1", 6379);
if (nullptr == subcribeContext_)
{
cerr << "connect redis failed!" << endl;
return false;
}
seqContext_ = redisConnect("127.0.0.1", 6379);
if (nullptr == seqContext_)
{
cerr << "connect redis failed!" << endl;
return false;
}
// 在单独的线程中,监听通道上的事件,有消息给业务层进行上报
thread t([&]() {
observerChannelMessage();
});
t.detach();
cout << "connect redis-server success!" << endl;
return true;
}
// 在独立线程中接收订阅通道中的消息
void Redis::observerChannelMessage ()
{
redisReply *reply = nullptr;
while (REDIS_OK == redisGetReply(this->subcribeContext_, (void **)&reply))
{
// 订阅收到的消息是一个带三元素的数组
if (reply != nullptr && reply->element[2] != nullptr && reply->element[2]->str != nullptr)
{
// 给业务层上报通道上发生的消息
notifyMessageHandler_(atoi(reply->element[1]->str) , reply->element[2]->str);
}
freeReplyObject(reply);
}
cerr << ">>>>>>>>>>>>> observer_channel_message quit <<<<<<<<<<<<<" << endl;
}
void Redis::initNotifyHandler (function<void(int,string)> fn)
{
this->notifyMessageHandler_ = fn;
}
ChatService::ChatService() {
// 初始化redis
if (redis_.connect()) {
// 设置上报消息回调
redis_.initNotifyHandler(std::bind(&ChatService::handleRedisSubscribeMessage,this,_1,_2));
}
}
用户a在服务器A上登录,用户b在服务器B上登录,a要发送消息给b
- b登录时B服务器会在Redis订阅b的信息
- a要发送消息给b时会在A服务器上查找有没有b的连接,如果有则直接推送
- 如果没有则在数据库中查找b是否在线
- 如果不在线则存储离线消息
- 如果在线则将消息通过Redis的发布-订阅机制发布到b的频道
- B服务器的Redis线程会监听到有频道有消息,执行相应的回调函数
- 获取接收者id与消息,在B服务器上找到b的连接并将消息推送给b
void ChatService::handleRedisSubscribeMessage(int userid, string msg) {
lock_guard<mutex> lock(connMutex_);
auto it = userConnMap_.find(userid);
if (it != userConnMap_.end()) {
it->second->send(msg);
return;
}
// 存储该用户的离线消息
offlineMsgModel_.insert(userid,msg);
}
在这里插入代码片
用户a在服务器A上登录,用户b在服务器B上登录,a要发送消息给b
- b登录时B服务器会在Redis订阅b的信息
- a要发送消息给b时会在A服务器上查找有没有b的连接,如果有则直接推送
- 如果没有则在数据库中查找b是否在线
- 如果不在线则存储离线消息
- 如果在线则将消息通过Redis的发布-订阅机制发布到b的频道
- B服务器的Redis线程会监听到有频道有消息,执行相应的回调函数
- 获取接收者id与消息,在B服务器上找到b的连接并将消息推送给b
void ChatService::handleRedisSubscribeMessage(int userid, string msg) {
lock_guard<mutex> lock(connMutex_);
auto it = userConnMap_.find(userid);
if (it != userConnMap_.end()) {
it->second->send(msg);
return;
}
// 存储该用户的离线消息
offlineMsgModel_.insert(userid,msg);
}