项目概述
项目是集群聊天工具(项目源代码链接),包括客户端和服务端,功能和QQ或微信类似,包括:注册、登录、加好友、私聊、创建群、加群、群聊等。整个项目的业务流程如图:
项目需解决以下几个问题:
业务数据如何存储?
数据层用MySQL存储数据,如用户id,离线消息等,通过model类对业务层封装底层数据库,实现MySQL连接池,提高MySQL数据库的访问效率。
网络层与业务层如何解耦?
网络层用muduo网络库,解耦网络层和业务层的代码,让开发者专注于业务开发提高开发效率。而业务层通过哈希表将消息id和消息回调绑定,当网络I/O有读事件时,服务器OnMessage
被调用,解析json,得到消息id,查找哈希表找到回调并执行,处理消息。
服务端和客户端通信格式?
网络层通过json格式将通信消息进行打包和解包传输,简单方便实现。
如何集群,跨服务器通信如何解决?
集群层配置Nginx支持tcp的负载均衡,支持多机扩展,部署多台网络服务器。引入Redis作为消息队列,利用其发布订阅功能实现跨服务器的消息通信功能。
数据层
数据表
服务器要存储用户信息,如用户名、密码等。 登录时,可查询User表信息进行身份验证,注册时,可往User表写入数据
表User:
登录或注册后,聊天前须知道好友或加好友。 可查询Friend表或写入Friend表
表Friend:
当用户下线朋友发来消息时,需存储离线消息,因此可写入OfflineMessage表,用户登录后查询该表是否有离线数据
表OfflineMessage:
然后便是群组业务,需要有一个记录群组信息的表,方便创建群时写入数据
表AllGroup:
同时需要一个记录群成员的表,我们在加入群的时候,把用户id写入该表。发送群消息时查询该表由服务器向这些成员转发消息
表GroupUser :
数据库模块
XXXModel类是对mysql语句的封装,而其上层类对应数据表中包含不同成员变量的类,以UserModel和User类为例,其他类类似,FriendModel和OfflineModel由于较简单,这里未定义上层类
// User表的数据操作类
class UserModel {
public:
// User表的增加方法
bool insert(User &user);
// 根据用户号码查询用户信息
User query(int id);
// 更新用户的状态信息
bool updateState(User user);
// 重置用户的状态信息
void resetState();
};
class User
{
public:
User(int id = -1, string name = "", string pwd = "", string state = "offline")
{
this->id = id;
this->name = name;
this->password = pwd;
this->state = state;
}
void setId(int id) { this->id = id; }
void setName(string name) { this->name = name; }
void setPwd(string pwd) { this->password = pwd; }
void setState(string state) { this->state = state; }
int getId() { return this->id; }
string getName() { return this->name; }
string getPwd() { return this->password; }
string getState() { return this->state; }
protected:
int id;
string name;
string password;
string state;
};
通信格式
服务器和客户端的通信采用JSON格式传输
1.登录
json["msgid"] = LOGIN_MSG;
json["id"] //用户id
json["password"] //密码
2.登录响应
json["msgid"] = LOGIN_MSG_ACK;
json["id"] //登录用户id
json["name"] //登录用户密码
json["offlinemsg"] //离线消息
json["friends"] //好友信息,里面有id、name、state三个字段
json["groups"] //群组信息,里面有id,groupname,groupdesc,users三个字段
//users里面则有id,name,state,role四个字段
json["errno"] //错误字段,错误时被设置成1,用户不在线设置成2
json["errmsg"] //错误信息
3.注册
json["msgid"] = REG_MSG;
json["name"] //用户姓名
json["password"] //用户姓名
4.注册响应
json["msgid"] = REG_MSG_ACK;
json["id"] //给用户返回他的id号
json["errno"] //错误信息,失败会被设置为1
5.加好友
json["msgid"] = ADD_FRIEND_MSG;
json["id"] //当前用户id
json["friendid"] //要加的好友的id
6.一对一聊天
json["msgid"] = ONE_CHAT_MSG;
json["id"] //发送者id
json["name"] //发送者姓名
json["to"] //接受者id
json["msg"] //消息内容
json["time"] //发送时间
7.创建群
json["msgid"] = CREATE_GROUP_MSG;
json["id"] //群创建者id
json["groupname"] //群名
json["groupdesc"] //群描述
8.加入群
json["msgid"] = ADD_GROUP_MSG;
json["id"] //用户id
json["groupid"] //群id
9.群聊
json["msgid"] = GROUP_CHAT_MSG;
json["id"] //发送者id
json["name"] //发送者姓名
json["groupid"] //发送者姓名
json["msg"] //消息内容
json["time"] //发送时间
10.注销
json["msgid"] = LOGINOUT_MSG;
json["id"] //要注销的id
网络层
并未直接使用socket API,而直接使用muduo网络库,因为muduo网络库使用one loop per thread,即封装epoll+非阻塞IO及IO多路复用+线程池,使用起来既简单又高效。
one loop per thread:muduo网络库采用reactor模型,在muduo设计中,main threadr负责接收来自客户端的连接。然后使用轮询的方式给sub thread去分配连接,每个连接对应一个线程且该线程最多一个EventLoop,该连接的读写事件都在这个sub thread上进行。
对于陈硕说的”三个半事件“,即:“连接已建立”事件、“消息/数据到达”事件、“消息/数据发送完毕” 事件, muduo只需通过setConnectionCallback
、setMessageCallback
、setWriteComplete
注册对应回调函数并实现回调函数即可,这只需关注前两事件
// 注册链接回调
_server.setConnectionCallback(std::bind(&ChatServer::onConnection, this, _1));
// 注册消息回调
_server.setMessageCallback(std::bind(&ChatServer::onMessage, this, _1, _2, _3));
// 设置线程数量
_server.setThreadNum(4);
当用户进行连接或者断开连接时,主线程便会调用onConnection
方法
当发生读写事件时,子线程则会调用onMessage
方法,该方法中执行注册的消息业务回调
业务回调
通过哈希表记录消息id和消息业务回调,消息类型:
enum EnMsgType
{
LOGIN_MSG = 1, // 登录消息
LOGIN_MSG_ACK, // 登录响应消息
LOGINOUT_MSG, // 注销消息
REG_MSG, // 注册消息
REG_MSG_ACK, // 注册响应消息
ONE_CHAT_MSG, // 聊天消息
ADD_FRIEND_MSG, // 添加好友消息
CREATE_GROUP_MSG, // 创建群组
ADD_GROUP_MSG, // 加入群组
GROUP_CHAT_MSG, // 群聊天
};
注册的消息业务回调:
// 用户基本业务管理相关事件处理回调注册
_msgHandlerMap.insert({LOGIN_MSG, std::bind(&ChatService::login, this, _1, _2, _3)});
_msgHandlerMap.insert({LOGINOUT_MSG, std::bind(&ChatService::loginout, 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)});
在onMessage
中获取消息id并查找注册的回调,从而实现网络与业务解耦:
string buf = buffer->retrieveAllAsString();
// 数据的反序列化
json js = json::parse(buf);
// 达到的目的:完全解耦网络模块的代码和业务模块的代码
// 通过js["msgid"] 获取=》业务handler=》conn js time
auto msgHandler = ChatService::instance()->getHandler(js["msgid"].get<int>());
// 回调消息绑定好的事件处理器,来执行相应的业务处理
msgHandler(conn, js, time);
业务层
注册
服务器收到 json 字符串时,得到注册的用户名和密码,然后写入到User表中,封装响应并序列化发送出去
// 处理注册业务 name password
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 state = _userModel.insert(user);
json response;
if (state)
{
// 注册成功
response["msgid"] = REG_MSG_ACK;
response["errno"] = 0;
response["id"] = user.getId();
}
else
{
// 注册失败
response["msgid"] = REG_MSG_ACK;
response["errno"] = 1;
}
conn->send(response.dump());
}
登录
服务器收到 json 字符串时,得到注册的用户名和密码,判断用户名和密码是否与服务器中的数据匹配,若不匹配就设置错误信息。若匹配就检测是否在线,若在线就设置错误信息,若不在线用户登陆成功,服务器就把用户好友列表,群组列表以及离线消息都推送给用户
int id = js["id"].get<int>();
string pwd = js["password"];
User user = _userModel.query(id);
json response;
if(user.getId() == id &&user.getPwd() == pwd){
if(user.getState()=="online"){
//用户已登录,不允许重复登录
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 2;
response["errmsg"] = "该账号已经登录,请重新输入新账号";
}else{
//登录成功,记录用户连接信息
{
lock_guard<mutex> lock(_connMutex);
_userConnMap.insert({id,conn});
}
// id用户登录成功后,向redis订阅channel(id)
_redis.subscribe(id);
//登录成功,更新用户状态信息
user.setState("online");
_userModel.updateState(user);
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 0;
response["id"] = user.getId();
response["name"] = user.getName();
//查询用户是否有离线消息
vector<string> vec = _offlineMsgModel.query(id);
if (!vec.empty())
{
response["offlinemsg"] = vec;
// 读取该用户的离线消息后,把该用户的所有离线消息删除掉
_offlineMsgModel.remove(id);
}
// 查询该用户的好友信息并返回
vector<User> userVec = _friendModel.query(id);
if (!userVec.empty())
{
vector<string> vec2;
for (User &user : userVec)
{
json js;
js["id"] = user.getId();
js["name"] = user.getName();
js["state"] = user.getState();
vec2.push_back(js.dump());
}
response["friends"] = vec2;
}
// 查询用户的群组信息
vector<Group> groupuserVec = _groupModel.queryGroups(id);
if (!groupuserVec.empty())
{
// group:[{groupid:[xxx, xxx, xxx, xxx]}]
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;
}
}
}else{
// 该用户不存在、用户存在但是密码错误,登录失败
response["msgid"] = LOGIN_MSG_ACK;
response["errno"] = 1;
response["errmsg"] = "id or password is invalid!";
}
conn->send(response.dump());
加好友
服务器得到反序列化的信息,然后将信息写入Friend表中即可
void ChatService::addFriend(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
int userid = js["userid"].get<int>();
int friendid = js["friendid"].get<int>();
// 存储好友信息
_friendModel.insert(userid, friendid);
}
一对一聊天
服务器接收到客户端信息,查找服务器接受信息的用户是否在本服务器在线,在线则直接转发即可,不在线则看数据库中的信息是否在线,若在线则接受用户在其他服务器登录,将消息通过redis中间件转发即可,若均不在线,转存离线消息
// 一对一聊天业务
void ChatService::oneChat(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
int toid = js["toid"].get<int>();
//注意下面要包括号的代码,原因是若不在,有可能发的时候该conn被删除,和异常退出不同
{
lock_guard<mutex> lock(_connMutex);
auto it = _userConnMap.find(toid);
if (it != _userConnMap.end())
{
// toid在线,转发消息 服务器主动推送消息给toid用户
it->second->send(js.dump());
return;
}
}
// 查询toid是否在线 若在线但又不再本服务器,则向消息队列服务器发布
User user = _userModel.query(toid);
if (user.getState() == "online")
{
_redis.publish(toid, js.dump());
return;
}
// toid不在线,数据库也查不到在线,存储离线消息
_offlineMsgModel.insert(toid, js.dump());
}
创建群
服务器收到客户端信息,把群组信息写入AllGroup表,并将创建者信息写入GroupUser中,设置创建者为creator
// 创建群组业务 未加响应到json响应中
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");
}
}
加入群
服务器收到客户端信息,将用户数据写入GroupUser表,并将role角色设置为normal
// 加入群组业务 未加响应到json响应中
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");
}
群聊
服务器收到客户端信息,先查询GroupUser查所有群员id,然后查询服务器接受用户是否在本服务器在线,在线直接转发,不在线,看数据库里是否在线,在线则就是接收用户在其他服务器登录,将消息通过redis中间件转发,均不在线存储离线消息
// 群组聊天业务 未加响应到json响应中
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);//查询userid所属的group的其他的用户,下面遍历给每个人发消息
lock_guard<mutex> lock(_connMutex);//该锁加载for循环外面,若在里面则每次循环都要上锁解锁,影响性能
for (int id : useridVec)
{
auto it = _userConnMap.find(id);
if (it != _userConnMap.end())
{
// 转发群消息
it->second->send(js.dump());
}
else
{
// // 查询toid是否在线
User user = _userModel.query(id);
if (user.getState() == "online")
{
_redis.publish(id, js.dump());
}
else
{
// 存储离线群消息
_offlineMsgModel.insert(id, js.dump());
}
}
}
}
注销
服务器收到客户端信息,取消中间件redis订阅,并将该用户在User表中所对应的state改为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);
}
集群层
集群
单机服务器有以下缺点:
- 能承受的用户并发量受限硬件资源,32位Linux最多支持两万并发量
- 运维困难,任意模块的修改都需要整个项目的重新编译、部署
- 系统中,有些模块是CPU密集型,有些是I/O密集型。造成各模块对硬件资源的需求不同
特别是业务高速增长时,单机服务器的性能远远达不到高并发, 这时如何提高并发量和扩展能力非常关键,因此引出集群(2和3涉及到分布式,暂时先不考虑),所谓的集群就是服务器群中有很多服务器,且每台服务器可独立运行整个系统,但又有新的问题,多个服务器共同处理客户端的并发请求,但客户端如何知道连接哪台服务器?有的服务器繁忙有的空闲,有的服务器宕机,如何选择服务器?此时就需负载均衡服务器,常用ngnix作为负载均衡器,通过预设的负载算法,指导客户端连接服务器,使每个服务器的连接数整体差不多,这样不至于有的忙有的闲,这样整体并发就提高,同时ngnix内置健壮性检测,负载均衡服务器就知道哪些服务器正常哪些宕机
负载均衡器
当负载均衡器从监听端口收到新的客户端连接时,执行路由调度算法,获得指定需要连接的服务IP,然后创建一个新的上游连接,连接到指定服务器,调度算法包括Round Robin(默认,轮询调度),哈希(选择一致)等,使用Hash负载均衡的调度方法,可以使用$remote_addr(客户端IP)来达成简单持久化会话(同一个客户端IP的连接,总是到同一个server上),当nginx收到任意一方的关闭连接通知,或者TCP连接被闲置超过了proxy_timeout配置的时间,连接将会被关闭
TCP负载均衡模块支持内置健壮性检测,一台上游服务器如果拒绝TCP连接超过proxy_connect_timeout时间,会被认为已失效。此时,nginx立刻尝试连接upstream组内的另一台正常的服务器。连接失败信息将会记录到nginx的错误日志中。若一台服务器反复失败(超过max_fails或fail_timeout),Nginx会踢掉该服务器。服务器被踢掉60秒后,nginx会偶尔尝试重连它,检测它是否恢复正常。如果服务器恢复正常,Nginx将它加回到upstream组内,缓慢加大连接请求的比例。
ngnix直到1.9版本开始才支持tcp的长连接负载均衡 ,并且默认没有编译tcp负载均衡模块,需加入--with-stream
参数重新编译。编译后,配置conf目录中的nginx.conf文件,使得支持tcp负载均衡:
stream {
# 添加socket转发的代理
upstream MyServerGroup {
hash $remote_addr consistent;
# 转发的目的地址和端口
server 125.208.14.177:3306 weight=1 max_fails=3 fail_timeout=30s; #服务器和负载均衡器的配置
server 125.208.14.177:3307 weight=1 max_fails=3 fail_timeout=30s;
}
# 提供转发的服务,会跳转至代理MyServerGroup指定的转发地址
server {
listen 12345;
proxy_connect_timeout 1s;#代理连接超时时间
proxy_timeout 3s; #代理超时时间
proxy_pass MyServerGroup;
}
}
跨服务器通信
若两个客户端连接同一服务器还好,可直接通信,若不在同一服务器,此时有两种选择:
- 每台服务器两两连接,但这样会使系统非常复杂,且不便扩展新服务器
- 引入中间层,也就是消息队列中间件,服务器和客户端通过它通信
当客户端登录时,服务器把它的id号 subscribe到redis中间件,表示该服务器对这个id发生的事件感兴趣,而Redis收到发送给该id的消息时就会把消息转发到这个服务器上
总结
使用muduo网络库,mysql存储数据,json格式传输数据,ngnix作为负载均衡器并使用redis订阅-发布功能解决跨服务器通信问题,功能包括:注册、登录、加好友、私聊、创建群、加群、群聊等,整个项目的源代码在[这