C++项目推荐-基于muduo库的单聊群聊项目-可写简历
1 项目简介
今天分享基于muduo库实现的单聊群聊项目,该项目支持QT客户端一对一聊天,服务端基于muduo+MySQL+Redis.
视频讲解:C++校招项目-基于muduo库的分布式单聊群聊项目-可写简历
源项目地址:https://github.com/haojoy/WeChat.git
2 Linux C++后端编译和运行
部署项目,我只讲解基于我改过的版本,原始版本大家参考原有的部署方式。
部署前提:
- 安装好MySQL
- 安装好Redis
该项目需要MySQL和redis基础
首先安装依赖库:
- json库相关:sudo apt-get install nlohmann-json3-dev
- redis开发包: sudo apt-get install -y libhiredis-dev
#首先解压老廖提供的代码 # 进入项目 cd WeChat mkdir build cd build #重新cmake 编译debug方式 cmake -DCMAKE_BUILD_TYPE=Debug .. make -j4
编译成功后build目录的bin目录产生ChatServer和ChatClient执行文件。
- ChatServer:是后端服务程序,可以接入qt客户端,也可以接入ChatClient
- ChatClient:是Linux环境的命令行客户端,具体功能看源码实现。
启动ChatServer之前,我们要根据自己的MySQL账号信息修改mysql相关的配置。
WeChat/application/chatserver/include/server/db/connection.h
const string kMySqlIp = "127.0.0.1"; const string kMySqlUserName = "root"; const string kMySqlPassword = "123456"; const int kMySqlPort = 3306; const string kMySqlDbName = "wechat";
主要是修改用户kMySqlUserName和密码kMySqlPassword。
重新make下。
运行后端程序,记得以sudo方式运行,因为里面有些目录的创建需要sudo权限。
sudo ./bin/ChatServer
默认的监听端口:
8088:fileserver相关
8080:chatserver相关
启动Linux命令行客户端
./bin/ChatClient 127.0.0.1 8080
我们可以选择创建用户
======================== 1. login 2. register 3. quit ======================== choice:2 username:darren userpassword:123 name register success, userid is 1, do not forget it!
3 QT客户端编译和运行
编译环境:QT5.15.2 MinGW 64-bit
3.1 修改chatserver和fileserver地址
运行代码前修改服务器地址chatserver和fileserver的ip和端口。
3.1.1 修改chatserver地址
修改位置: Net\packdef.h
#define _DEF_TCP_PORT (8080)
#define _DEF_SERVER_IP ("192.168.1.27")
3.1.2 修改fileserver地址
修改位置: Common\fileTransferProtocol.h
#define _DEF_FILE_SERVER_IP ("192.168.1.27")
#define _DEF_FILE_SERVER_PROT (8088)
3.2 启动QT和注册账号
这个QT客户端是有修改过:void Kernel::slot_ChangeUserIcon()才正常设置头像。
使用QT5.15.2 MinGW 64-bit启动QT,如果需要聊天,则需要启动两个qt客户端。
这里开了两个QT客户端进行聊天。
注意:目前QT客户端还有部分功能并不完整,大家可以自行添加功能,或者修改Linux命令行的ChatClient进行测试。
4 Linux后端框架快速分析
这个项目基于muduo架构,如果你不熟悉muduo则需要先学习muduo网络模型,这个网上资料很多的。
4.1 数据库的创建
application/chatserver/src/db/connection.cpp,这里直接使用代码创建数据库和对应的表单
bool Connection::createDBTables() { if(createDBCnt_++ != 0){ return true; } if (mysql_real_connect(_conn, kMySqlIp.c_str(), kMySqlUserName.c_str(), kMySqlPassword.c_str(), nullptr, kMySqlPort, nullptr, 0) == nullptr) { LOG_ERROR << "MySQL connection error: " << mysql_error(_conn); return false; } string queryStr = "CREATE DATABASE IF NOT EXISTS `" + kMySqlDbName + "`"; if (mysql_query(_conn, queryStr.c_str()) != 0) { LOG_ERROR << "MySQL createDatabase error: " << mysql_error(_conn); return false; } queryStr = "USE `" + kMySqlDbName + "`"; if (mysql_query(_conn, queryStr.c_str()) != 0) { LOG_ERROR << "MySQL useDatabase error: " << mysql_error(_conn); return false; } string sql_tuser = "CREATE TABLE IF NOT EXISTS `t_user` (\ `userid` int NOT NULL AUTO_INCREMENT PRIMARY KEY, \ `avatar_id` VARCHAR(36) DEFAULT NULL, \ `username` VARCHAR(64) DEFAULT NULL, \ `password` VARCHAR(64) DEFAULT NULL, \ `tel` VARCHAR(15) DEFAULT NULL, \ `state` enum('online','offline') CHARACTER SET latin1 DEFAULT 'offline' \ )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; if (mysql_query(_conn, sql_tuser.c_str()) != 0) { LOG_ERROR << "MySQL createTable t_user error: " << mysql_error(_conn); return false; } string sql_tfile = "CREATE TABLE IF NOT EXISTS `t_file` (\ file_id VARCHAR(36) NOT NULL PRIMARY KEY, \ file_name VARCHAR(255) NOT NULL, \ file_path TEXT NOT NULL, \ file_size BIGINT NOT NULL, \ file_md5 CHAR(32) NOT NULL, \ file_state VARCHAR(50) NOT NULL DEFAULT 'PENDING' \ )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; if (mysql_query(_conn, sql_tfile.c_str()) != 0) { LOG_ERROR << "MySQL createTable t_file error: " << mysql_error(_conn); return false; } string sql_friendship = "CREATE TABLE IF NOT EXISTS `t_friendship` (\ `userid` int NOT NULL, \ `friend_id` int NOT NULL, \ KEY `userid` (`userid`,`friend_id`) \ )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; if (mysql_query(_conn, sql_friendship.c_str()) != 0) { LOG_ERROR << "MySQL createTable t_friendship error: " << mysql_error(_conn); return false; } string sql_offlinemsg = "CREATE TABLE IF NOT EXISTS `t_offlinemsg` (\ `id` INT AUTO_INCREMENT PRIMARY KEY, \ `sendTo` INT NOT NULL, \ `sendFrom` INT NOT NULL, \ `messageContent` TEXT NOT NULL, \ `messageType` ENUM('text', 'friend_apply', 'vedio', 'audio', 'file') NOT NULL DEFAULT 'text', \ `createTime` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP \ )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; if (mysql_query(_conn, sql_offlinemsg.c_str()) != 0) { LOG_ERROR << "MySQL createTable t_offlinemsg error: " << mysql_error(_conn); return false; } string sql_allgrp = "CREATE TABLE IF NOT EXISTS `t_allgrp` (\ `id` int(11) NOT NULL AUTO_INCREMENT, \ `groupname` varchar(50) CHARACTER SET latin1 NOT NULL, \ `groupdesc` varchar(200) CHARACTER SET latin1 DEFAULT '', \ PRIMARY KEY (`id`), \ UNIQUE KEY `groupname` (`groupname`) \ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; if (mysql_query(_conn, sql_allgrp.c_str()) != 0) { LOG_ERROR << "MySQL createTable t_allgrp error: " << mysql_error(_conn); return false; } string sql_grpuser = "CREATE TABLE IF NOT EXISTS `t_grpuser` (\ `groupid` int(11) NOT NULL, \ `userid` int(11) NOT NULL, \ `grouprole` enum('creator','normal') CHARACTER SET latin1 DEFAULT NULL, \ KEY `groupid` (`groupid`,`userid`) \ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; if (mysql_query(_conn, sql_grpuser.c_str()) != 0) { LOG_ERROR << "MySQL createTable t_grpuser error: " << mysql_error(_conn); return false; } return true; }
4.2 main函数位置
main函数入口位置:WeChat/application/chatserver/src/main.cpp, line 17
(gdb) b main Breakpoint 1 at 0x1584a6: file /home/lqf/linux/reactor/WeChat/application/chatserver/src/main.cpp, line 17. (gdb)
4.3 架构核心
消息协议设计
协议采用json做序列化,设计的json字符串里有个msgid字段,用来区分不同的消息。消息类型如下所示:
enum Message { LOGIN_MSG = 1, // 登录消息 LOGIN_MSG_ACK, // 登录响应消息 LOGINOUT_MSG, // 注销消息 REG_MSG, // 注册消息 REG_MSG_ACK, // 注册响应消息 ONE_CHAT_MSG, // 聊天消息 ADD_FRIEND_REQ, // 添加好友消息 ADD_FRIEND_RSP, CREATE_GROUP_MSG, // 创建群组 ADD_GROUP_MSG, // 加入群组 GROUP_CHAT_MSG, // 群聊天 GET_FRIEND_INFO_REQ, // 获取待添加好友的信息 GET_FRIEND_INFO_RSP, // 查找信息结果 REFRESH_FRIEND_LIST, SET_AVATAR_RQ, SET_AVATAR_RS, SET_AVATAR_COMPLETE_NOTIFY, };
然后通过_msgHandlerMap根据不同的msgid调用对应的函数进行处理。
// 注册消息以及对应的Handler回调操作 ChatService::ChatService() { // 用户基本业务管理相关事件处理回调注册 _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::userRegister, this, _1, _2, _3)}); _msgHandlerMap.insert({ONE_CHAT_MSG, std::bind(&ChatService::oneChat, this, _1, _2, _3)}); _msgHandlerMap.insert({ADD_FRIEND_REQ, std::bind(&ChatService::addFriendReq, this, _1, _2, _3)}); _msgHandlerMap.insert({ADD_FRIEND_RSP, std::bind(&ChatService::addFriendRsp, 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({GET_FRIEND_INFO_REQ, std::bind(&ChatService::getFriendInfoReq, this, _1, _2, _3)}); _msgHandlerMap.insert({SET_AVATAR_RQ, std::bind(&ChatService::dealAvatarUpdateRq, this, _1, _2, _3)}); _msgHandlerMap.insert({SET_AVATAR_COMPLETE_NOTIFY, std::bind(&ChatService::dealAvatarUploadComplete, this, _1, _2, _3)}); // 连接redis服务器 if (_redis.connect()) { // 设置上报消息的回调 _redis.init_notify_handler(std::bind(&ChatService::handleRedisSubscribeMessage, this, _1, _2)); } }
处理逻辑
// 上报读写事件相关信息的回调函数 void ChatServer::onMessage(const TcpConnectionPtr &conn, Buffer *buffer, Timestamp time) { string buf = buffer->retrieveAllAsString(); // 测试,添加json打印代码 cout << buf << endl; // 数据的反序列化 json js; ....... js = json::parse(buf); ........ // 达到的目的:完全解耦网络模块的代码和业务模块的代码 // 通过js["msgid"] 获取=》业务handler=》conn js time auto msgHandler = ChatService::instance()->getHandler(js["msgid"].get<int>()); // 回调消息绑定好的事件处理器,来执行相应的业务处理 msgHandler(conn, js, time); }
登录逻辑
登录正常后,以user id作为key, TcpConnectionPtr作为value插入到_userConnMap,后续发送消息就是根据这个user id找到对应的客户端连接。
// 处理登录业务 id pwd pwd void ChatService::login(const TcpConnectionPtr &conn, json &js, Timestamp time) { ....... // 登录成功,记录用户连接信息 { lock_guard<mutex> lock(_connMutex); _userConnMap.insert({id, conn}); } }
登录成功后,可以:
- 查询用户信息:_userModel.queryuserinfo
- 查询离线消息:_offlineMsgModel.query(id)
- 查询好友列表: _friendModel.query(id)
- 查询群列表:_groupModel.queryGroups(id)
然后根据查询结果,做json序列化后发送给客户端。
4.4 分布式框架
这个项目采用了分布式架构的方式,以支持更多的客户端加入,不同的服务直接使用redis进行消息转发。
其实逻辑并不复杂,以一对一聊天代码为例:
登录相关处理
- 不管客户端登录哪个ChatServer,登录成功后都从redis消息队列订阅自己的channel,channel根据user id
void ChatService::login(const TcpConnectionPtr &conn, json &js, Timestamp time) { // id用户登录成功后,向redis订阅channel(id) _redis.subscribe(id); }
一对一聊天相关处理
一对一聊天发送消息相关处理(ChatService::oneChat函数):
- 先查询对方是否在同一个ChatServer,如果是同一个ChatServer,则可以在_userConnMap查询到对方
int toid = js["receiverid"].get<int>(); { lock_guard<mutex> lock(_connMutex); auto it = _userConnMap.find(toid); if (it != _userConnMap.end()) { // toid在线,转发消息 服务器主动推送消息给toid用户 it->second->send(js.dump()); //如果在同一个服务器,则直接发送 return; } }
直接调用对方的tcpconnection发送信息即可。
- 如果查询不到对方,则将消息发送给消息队列,以对方user id作为channel,这样对方就能收到消息的推送
// 查询toid是否在线 User user = _userModel.query(toid); if (user.getState() == "online") { _redis.publish(toid, js.dump()); return; }
如何获取订阅数据
订阅数据的获取有单独的线程,class Redis 这个类在封装的时候提供了回调接口_notify_message_handler,
有独立的线程不断调用observer_channel_message:
// 在独立线程中接收订阅通道中的消息 void Redis::observer_channel_message() { redisReply *reply = nullptr; while (REDIS_OK == redisGetReply(this->_subcribe_context, (void **)&reply)) { // 订阅收到的消息是一个带三元素的数组 if (reply != nullptr && reply->element[2] != nullptr && reply->element[2]->str != nullptr) { // 给业务层上报通道上发生的消息 _notify_message_handler(atoi(reply->element[1]->str) , reply->element[2]->str); } freeReplyObject(reply); } cerr << ">>>>>>>>>>>>> observer_channel_message quit <<<<<<<<<<<<<" << endl; }
具体是调用ChatService::handleRedisSubscribeMessage处理订阅的数据
// 从redis消息队列中获取订阅的消息 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); }
5 扩展
需要思考的问题:
- 当前的协议设计是否有粘包半包的问题
- 是否可以使用kafka消息队列替换redis消息队列。
- 目前的明文密码方式是否合适。
- 在线状态写到数据库里是否合适?