C++項(xiàng)目推薦-基于muduo庫(kù)的單聊群聊項(xiàng)目-可寫(xiě)簡(jiǎn)歷
1 項(xiàng)目簡(jiǎn)介
今天分享基于muduo庫(kù)實(shí)現(xiàn)的單聊群聊項(xiàng)目,該項(xiàng)目支持QT客戶端一對(duì)一聊天,服務(wù)端基于muduo+MySQL+Redis.
視頻講解:C++校招項(xiàng)目-基于muduo庫(kù)的分布式單聊群聊項(xiàng)目-可寫(xiě)簡(jiǎn)歷
源項(xiàng)目地址:https://github.com/haojoy/WeChat.git
2 Linux C++后端編譯和運(yùn)行
部署項(xiàng)目,我只講解基于我改過(guò)的版本,原始版本大家參考原有的部署方式。
部署前提:
- 安裝好MySQL
- 安裝好Redis
該項(xiàng)目需要MySQL和redis基礎(chǔ)
首先安裝依賴庫(kù):
- json庫(kù)相關(guān):sudo apt-get install nlohmann-json3-dev
- redis開(kāi)發(fā)包: sudo apt-get install -y libhiredis-dev
#首先解壓老廖提供的代碼 # 進(jìn)入項(xiàng)目 cd WeChat mkdir build cd build #重新cmake 編譯debug方式 cmake -DCMAKE_BUILD_TYPE=Debug .. make -j4
編譯成功后build目錄的bin目錄產(chǎn)生ChatServer和ChatClient執(zhí)行文件。
- ChatServer:是后端服務(wù)程序,可以接入qt客戶端,也可以接入ChatClient
- ChatClient:是Linux環(huán)境的命令行客戶端,具體功能看源碼實(shí)現(xiàn)。
啟動(dòng)ChatServer之前,我們要根據(jù)自己的MySQL賬號(hào)信息修改mysql相關(guān)的配置。
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下。
運(yùn)行后端程序,記得以sudo方式運(yùn)行,因?yàn)槔锩嬗行┠夸浀膭?chuàng)建需要sudo權(quán)限。
sudo ./bin/ChatServer
默認(rèn)的監(jiān)聽(tīng)端口:
8088:fileserver相關(guān)
8080:chatserver相關(guān)
啟動(dòng)Linux命令行客戶端
./bin/ChatClient 127.0.0.1 8080
我們可以選擇創(chuàng)建用戶
======================== 1. login 2. register 3. quit ======================== choice:2 username:darren userpassword:123 name register success, userid is 1, do not forget it!
3 QT客戶端編譯和運(yùn)行
編譯環(huán)境:QT5.15.2 MinGW 64-bit
3.1 修改chatserver和fileserver地址
運(yùn)行代碼前修改服務(wù)器地址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 啟動(dòng)QT和注冊(cè)賬號(hào)
這個(gè)QT客戶端是有修改過(guò):void Kernel::slot_ChangeUserIcon()才正常設(shè)置頭像。
使用QT5.15.2 MinGW 64-bit啟動(dòng)QT,如果需要聊天,則需要啟動(dòng)兩個(gè)qt客戶端。
這里開(kāi)了兩個(gè)QT客戶端進(jìn)行聊天。
注意:目前QT客戶端還有部分功能并不完整,大家可以自行添加功能,或者修改Linux命令行的ChatClient進(jìn)行測(cè)試。
4 Linux后端框架快速分析
這個(gè)項(xiàng)目基于muduo架構(gòu),如果你不熟悉muduo則需要先學(xué)習(xí)muduo網(wǎng)絡(luò)模型,這個(gè)網(wǎng)上資料很多的。
4.1 數(shù)據(jù)庫(kù)的創(chuàng)建
application/chatserver/src/db/connection.cpp,這里直接使用代碼創(chuàng)建數(shù)據(jù)庫(kù)和對(duì)應(yīng)的表單
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函數(shù)位置
main函數(shù)入口位置: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 架構(gòu)核心
消息協(xié)議設(shè)計(jì)
協(xié)議采用json做序列化,設(shè)計(jì)的json字符串里有個(gè)msgid字段,用來(lái)區(qū)分不同的消息。消息類型如下所示:
enum Message { LOGIN_MSG = 1, // 登錄消息 LOGIN_MSG_ACK, // 登錄響應(yīng)消息 LOGINOUT_MSG, // 注銷消息 REG_MSG, // 注冊(cè)消息 REG_MSG_ACK, // 注冊(cè)響應(yīng)消息 ONE_CHAT_MSG, // 聊天消息 ADD_FRIEND_REQ, // 添加好友消息 ADD_FRIEND_RSP, CREATE_GROUP_MSG, // 創(chuàng)建群組 ADD_GROUP_MSG, // 加入群組 GROUP_CHAT_MSG, // 群聊天 GET_FRIEND_INFO_REQ, // 獲取待添加好友的信息 GET_FRIEND_INFO_RSP, // 查找信息結(jié)果 REFRESH_FRIEND_LIST, SET_AVATAR_RQ, SET_AVATAR_RS, SET_AVATAR_COMPLETE_NOTIFY, };
然后通過(guò)_msgHandlerMap根據(jù)不同的msgid調(diào)用對(duì)應(yīng)的函數(shù)進(jìn)行處理。
// 注冊(cè)消息以及對(duì)應(yīng)的Handler回調(diào)操作 ChatService::ChatService() { // 用戶基本業(yè)務(wù)管理相關(guān)事件處理回調(diào)注冊(cè) _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)}); // 群組業(yè)務(wù)管理相關(guān)事件處理回調(diào)注冊(cè) _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服務(wù)器 if (_redis.connect()) { // 設(shè)置上報(bào)消息的回調(diào) _redis.init_notify_handler(std::bind(&ChatService::handleRedisSubscribeMessage, this, _1, _2)); } }
處理邏輯
// 上報(bào)讀寫(xiě)事件相關(guān)信息的回調(diào)函數(shù) void ChatServer::onMessage(const TcpConnectionPtr &conn, Buffer *buffer, Timestamp time) { string buf = buffer->retrieveAllAsString(); // 測(cè)試,添加json打印代碼 cout << buf << endl; // 數(shù)據(jù)的反序列化 json js; ....... js = json::parse(buf); ........ // 達(dá)到的目的:完全解耦網(wǎng)絡(luò)模塊的代碼和業(yè)務(wù)模塊的代碼 // 通過(guò)js["msgid"] 獲取=》業(yè)務(wù)handler=》conn js time auto msgHandler = ChatService::instance()->getHandler(js["msgid"].get<int>()); // 回調(diào)消息綁定好的事件處理器,來(lái)執(zhí)行相應(yīng)的業(yè)務(wù)處理 msgHandler(conn, js, time); }
登錄邏輯
登錄正常后,以u(píng)ser id作為key, TcpConnectionPtr作為value插入到_userConnMap,后續(xù)發(fā)送消息就是根據(jù)這個(gè)user id找到對(duì)應(yīng)的客戶端連接。
// 處理登錄業(yè)務(wù) 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)
- 查詢?nèi)毫斜恚篲groupModel.queryGroups(id)
然后根據(jù)查詢結(jié)果,做json序列化后發(fā)送給客戶端。
4.4 分布式框架
這個(gè)項(xiàng)目采用了分布式架構(gòu)的方式,以支持更多的客戶端加入,不同的服務(wù)直接使用redis進(jìn)行消息轉(zhuǎn)發(fā)。
其實(shí)邏輯并不復(fù)雜,以一對(duì)一聊天代碼為例:
登錄相關(guān)處理
- 不管客戶端登錄哪個(gè)ChatServer,登錄成功后都從redis消息隊(duì)列訂閱自己的channel,channel根據(jù)user id
void ChatService::login(const TcpConnectionPtr &conn, json &js, Timestamp time) { // id用戶登錄成功后,向redis訂閱channel(id) _redis.subscribe(id); }
一對(duì)一聊天相關(guān)處理
一對(duì)一聊天發(fā)送消息相關(guān)處理(ChatService::oneChat函數(shù)):
- 先查詢對(duì)方是否在同一個(gè)ChatServer,如果是同一個(gè)ChatServer,則可以在_userConnMap查詢到對(duì)方
int toid = js["receiverid"].get<int>(); { lock_guard<mutex> lock(_connMutex); auto it = _userConnMap.find(toid); if (it != _userConnMap.end()) { // toid在線,轉(zhuǎn)發(fā)消息 服務(wù)器主動(dòng)推送消息給toid用戶 it->second->send(js.dump()); //如果在同一個(gè)服務(wù)器,則直接發(fā)送 return; } }
直接調(diào)用對(duì)方的tcpconnection發(fā)送信息即可。
- 如果查詢不到對(duì)方,則將消息發(fā)送給消息隊(duì)列,以對(duì)方user id作為channel,這樣對(duì)方就能收到消息的推送
// 查詢toid是否在線 User user = _userModel.query(toid); if (user.getState() == "online") { _redis.publish(toid, js.dump()); return; }
如何獲取訂閱數(shù)據(jù)
訂閱數(shù)據(jù)的獲取有單獨(dú)的線程,class Redis 這個(gè)類在封裝的時(shí)候提供了回調(diào)接口_notify_message_handler,
有獨(dú)立的線程不斷調(diào)用observer_channel_message:
// 在獨(dú)立線程中接收訂閱通道中的消息 void Redis::observer_channel_message() { redisReply *reply = nullptr; while (REDIS_OK == redisGetReply(this->_subcribe_context, (void **)&reply)) { // 訂閱收到的消息是一個(gè)帶三元素的數(shù)組 if (reply != nullptr && reply->element[2] != nullptr && reply->element[2]->str != nullptr) { // 給業(yè)務(wù)層上報(bào)通道上發(fā)生的消息 _notify_message_handler(atoi(reply->element[1]->str) , reply->element[2]->str); } freeReplyObject(reply); } cerr << ">>>>>>>>>>>>> observer_channel_message quit <<<<<<<<<<<<<" << endl; }
具體是調(diào)用ChatService::handleRedisSubscribeMessage處理訂閱的數(shù)據(jù)
// 從redis消息隊(duì)列中獲取訂閱的消息 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; } // 存儲(chǔ)該用戶的離線消息 _offlineMsgModel.insert(userid, msg); }
5 擴(kuò)展
需要思考的問(wèn)題:
- 當(dāng)前的協(xié)議設(shè)計(jì)是否有粘包半包的問(wèn)題
- 是否可以使用kafka消息隊(duì)列替換redis消息隊(duì)列。
- 目前的明文密碼方式是否合適。
- 在線狀態(tài)寫(xiě)到數(shù)據(jù)庫(kù)里是否合適?