C++实现集群聊天服务器

项目概述

项目是集群聊天工具(项目源代码链接),包括客户端和服务端,功能和QQ或微信类似,包括:注册、登录、加好友、私聊、创建群、加群、群聊等。整个项目的业务流程如图:

image-20220813230309425

项目需解决以下几个问题:

业务数据如何存储?

数据层用MySQL存储数据,如用户id,离线消息等,通过model类对业务层封装底层数据库,实现MySQL连接池,提高MySQL数据库的访问效率。

网络层与业务层如何解耦?

网络层用muduo网络库,解耦网络层和业务层的代码,让开发者专注于业务开发提高开发效率。而业务层通过哈希表将消息id和消息回调绑定,当网络I/O有读事件时,服务器OnMessage被调用,解析json,得到消息id,查找哈希表找到回调并执行,处理消息。

服务端和客户端通信格式?

网络层通过json格式将通信消息进行打包和解包传输,简单方便实现。

如何集群,跨服务器通信如何解决?

集群层配置Nginx支持tcp的负载均衡,支持多机扩展,部署多台网络服务器。引入Redis作为消息队列,利用其发布订阅功能实现跨服务器的消息通信功能。

数据层

数据表

服务器要存储用户信息,如用户名、密码等。 登录时,可查询User表信息进行身份验证,注册时,可往User表写入数据

表User:

image-20220813175853829

登录或注册后,聊天前须知道好友或加好友。 可查询Friend表或写入Friend表

表Friend:

image-20220813180238084

当用户下线朋友发来消息时,需存储离线消息,因此可写入OfflineMessage表,用户登录后查询该表是否有离线数据

表OfflineMessage:

image-20220813180513436

然后便是群组业务,需要有一个记录群组信息的表,方便创建群时写入数据

表AllGroup:

image-20220813180641014

同时需要一个记录群成员的表,我们在加入群的时候,把用户id写入该表。发送群消息时查询该表由服务器向这些成员转发消息

表GroupUser :

image-20220813180746188

数据库模块

image-20220813182952483

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只需通过setConnectionCallbacksetMessageCallbacksetWriteComplete注册对应回调函数并实现回调函数即可,这只需关注前两事件

// 注册链接回调
_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);
}

集群层

集群

单机服务器有以下缺点:

  1. 能承受的用户并发量受限硬件资源,32位Linux最多支持两万并发量
  2. 运维困难,任意模块的修改都需要整个项目的重新编译、部署
  3. 系统中,有些模块是CPU密集型,有些是I/O密集型。造成各模块对硬件资源的需求不同

特别是业务高速增长时,单机服务器的性能远远达不到高并发, 这时如何提高并发量和扩展能力非常关键,因此引出集群(2和3涉及到分布式,暂时先不考虑),所谓的集群就是服务器群中有很多服务器,且每台服务器可独立运行整个系统,但又有新的问题,多个服务器共同处理客户端的并发请求,但客户端如何知道连接哪台服务器?有的服务器繁忙有的空闲,有的服务器宕机,如何选择服务器?此时就需负载均衡服务器,常用ngnix作为负载均衡器,通过预设的负载算法,指导客户端连接服务器,使每个服务器的连接数整体差不多,这样不至于有的忙有的闲,这样整体并发就提高,同时ngnix内置健壮性检测,负载均衡服务器就知道哪些服务器正常哪些宕机

image-20220813215818565

负载均衡器

当负载均衡器从监听端口收到新的客户端连接时,执行路由调度算法,获得指定需要连接的服务IP,然后创建一个新的上游连接,连接到指定服务器,调度算法包括Round Robin(默认,轮询调度),哈希(选择一致)等,使用Hash负载均衡的调度方法,可以使用$remote_addr(客户端IP)来达成简单持久化会话(同一个客户端IP的连接,总是到同一个server上),当nginx收到任意一方的关闭连接通知,或者TCP连接被闲置超过了proxy_timeout配置的时间,连接将会被关闭

image-20220813230206296

TCP负载均衡模块支持内置健壮性检测,一台上游服务器如果拒绝TCP连接超过proxy_connect_timeout时间,会被认为已失效。此时,nginx立刻尝试连接upstream组内的另一台正常的服务器。连接失败信息将会记录到nginx的错误日志中。若一台服务器反复失败(超过max_fails或fail_timeout),Nginx会踢掉该服务器。服务器被踢掉60秒后,nginx会偶尔尝试重连它,检测它是否恢复正常。如果服务器恢复正常,Nginx将它加回到upstream组内,缓慢加大连接请求的比例。

image-20220813225702919

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

}

跨服务器通信

若两个客户端连接同一服务器还好,可直接通信,若不在同一服务器,此时有两种选择:

  1. 每台服务器两两连接,但这样会使系统非常复杂,且不便扩展新服务器

image-20220813225137120

  1. 引入中间层,也就是消息队列中间件,服务器和客户端通过它通信

image-20220813224451615

当客户端登录时,服务器把它的id号 subscribe到redis中间件,表示该服务器对这个id发生的事件感兴趣,而Redis收到发送给该id的消息时就会把消息转发到这个服务器上

总结

使用muduo网络库,mysql存储数据,json格式传输数据,ngnix作为负载均衡器并使用redis订阅-发布功能解决跨服务器通信问题,功能包括:注册、登录、加好友、私聊、创建群、加群、群聊等,整个项目的源代码在[这

  • 6
    点赞
  • 44
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值