IM即時通訊設計 高並發聊天服務:伺服器 + qt客戶端(附源碼)
來源:微信公眾號「編程學習基地」
IM即時通訊程式設計
介面相對簡陋,主要介面如下
- 登錄介面
- 註冊介面
- 聊天介面
- 添加好友介面
支援的功能
- 註冊帳號
- 登錄帳號
- 添加好友
- 群聊
- 私聊
後續UI美化以及功能增加持續更新,關注微信公眾號「編程學習基地」最快諮詢..
IM即時通訊
本系列將帶大家從零開始搭建一個輕量級的IM服務端,麻雀雖小,五臟俱全,我們搭建的IM服務端實現以下功能:
- 註冊
- 登錄
- 私聊
- 群聊
- 好友關係
第一版只實現了IM即時通訊的基礎功能,其他功能後續增加.
設計一款高並發聊天服務需要注意什麼
- 實時性
在網路良好的狀態下伺服器能夠及時處理用戶消息
- 可靠性
服務端如何防止粘包,半包,保證數據完全接收,不丟數據,不重數據
- 一致性
保證發送方發送順序與接收方展現順序一致
實時性就不必細說了,保證伺服器能夠及時處理用戶消息就行,重點說下可靠性
如何設計可靠的消息處理服務
簡單來說就是客戶端每次發送的數據長度不定,服務端需要保證能夠解析每一個用戶發送過來的消息。
這就涉及到粘包和半包,這裡說下粘包和半包是什麼情況
什麼是粘包
多個數據包被連續存儲於連續的快取中,在對數據包進行讀取時無法確定發生方的發送邊界.
例如:客戶端需要給服務端發送兩條消息,發送數據如下
char msg[1024] = "hello world";
int nSend = write(sockFd, msg, strlen(msg));
nSend = write(sockFd, "粘包", strlen("粘包"));
服務端接收
char buff[1024];
read(connect_fd,buff,1024);
printf("recv msg:%s\n",buff);
結果就是服務端將兩條消息當成一條消息全部存入buff中。輸出如下
recv msg:hello world粘包
當客戶端兩條消息發的很快的時候,服務端無法判斷消息邊界導致照單全收的情況就是粘包。
什麼是半包
單個數據包過大,服務端預定緩衝不夠,導致對數據包接收不全
例如:客戶端需要給服務端發送一條消息,發送數據如下
char msg[1024] = "hello world";
int nSend = write(sockFd, msg, 1024); //發送位元組大小為1024
服務端接收
char buff[128];
read(connect_fd,buff,128);
printf("recv msg:%s\n",buff);
結果就是服務端緩衝不夠,只能讀取部分包內容。
解決粘包和半包
如何解決粘包和半包的問題?
通過自定義應用協議,客戶端給數據包進行封包,服務端進行拆包。
以項目實例來說,定義包頭 + 包 +負載
其實就是發送數據包的時候先發一個包頭,包頭裡面有一個欄位表示包的大小
包頭後緊跟著包,這個包還不是數據包,只是數據包的描述資訊,例如發送消息代表一個命令,欄位command用來從存儲命令,讓伺服器能夠解析這是群聊數據包還是私聊數據包。包頭和包定義付下
struct DeMessageHead{
char mark[2]; // "DE" 認證deroy的協議
char version;
char encoded; //0 不加密,1 加密
int length;
};
struct DeMessagePacket
{
int mode; //1 請求,2 應答,3 消息通知
int error; //0 成功,非0,對應的錯誤碼
int sequence; //序列號
int command; //命令號
};
負載就是你真正要發送的數據包結構了,可能是msg消息,又或者其他的自定義消息。
IM通訊協議
所謂「協議」是雙方共同遵守的規則.
協議有語法、語義、時序三要素:
(1)語法:即數據與控制資訊的結構或格式
(2)語義:即需要發出何種控制資訊,完成何種動作以及做出何種響應
(3)時序:即事件實現順序的詳細說明
一套典型的IM通訊協議設計分為三層:應用層、安全層、傳輸層。
應用層協議設計
在通訊過程中,chat_room使用的是tcp作為傳輸層的協議,暫時未引入數據加密解密,所以未涉及安全層協議。
應用層協議選型,常見的有三種:文本協議、二進位協議、流式XML協議。
文本協議
文本協議是指 「貼近人類書面語言表達」的通訊傳輸協議,典型的協議是http協議。
一個http協議大致長成這樣:
GET / HTTP/1.1
User-Agent: curl
Host: musicml.net
Accept: */*
文本協議的特點是:
a. 可讀性好,便於調試
b. 擴展性也好(通過key:value擴展)
c. 解析效率一般(一行一行讀入,按照冒號分割,解析key和value)
d. 對二進位的支援不好 ,比如語音/影片
二進位協議
二進位協議是指binary協議,典型是ip協議。二進位協議一般定長包頭和可擴展變長包體 ,每個欄位固定了含義,此次項目設計chat_room採用的就是二進位協議作為應用層的傳輸協議。
二進位協議有這樣一些特點:
a. 可讀性差,難於調試
b. 擴展性不好 ,如果要擴展欄位,舊版協議就不兼容了。
c. 解析效率超高
QQ使用的就是二進位協議
流式XML協議
這個一般場景用的比較少了,我所接觸的就是Onvif協議交互用的就是流式XML協議。
XML協議特點:
a.它是准標準協議,可以跨域互通
b.XML的優點,可讀性好,擴展性好
c.解析代價超高
d.有效數據傳輸率超低(大量的標籤)
數據傳輸格式
即時通訊應用(包括IM聊天應用、實時消息推送應用等)在選擇數據傳輸格式的時候比較糾結,不過我個人建議將Protobuf作為即時通訊應用的首選通訊協議格式。此次項目設計未使用Protobuf是因為不想導入第三方庫,怕有些同學直接勸退。
據說,手機QQ的數據傳輸協議已在使用Protobuf了,而從官方流出資料來看微信很早就在使用Protobuf(而且為了儘可能地壓縮流量,甚至對Protobuf進行了極致優化)。
此次項目使用的是二進位數據流作為數據傳輸格式,其實就是一堆結構體變數。
例如登陸的數據包定義如下:
struct LoginInfoReq{
int m_account;
char m_password[32];
};
服務端和客戶端雙方約定好一個數據結構就可以了,特點就是簡單。
聊天服務設計
目前採用的是多執行緒處理客戶端請求,即一個客戶端一個執行緒,這周會改成IO多路復用,用epoll來接受更高的並發。
整體設計如下:
第一步:客戶端發送數據包
第二步:服務端解析數據包,傳遞給各個業務處理模組
第三步:業務處理模組按照通訊協議解析並處理消息
消息處理
對客戶端的消息處理就是接受一個完整的數據包,傳遞給伺服器。
由於採用封包-拆包作為通訊的傳輸協議,所以在處理數據包的時候需要一個健壯的數據處理邏輯
此次項目處理邏輯如下
int Session::readEvent()
{
int ret = 0;
switch (m_type)
{
case RECV_HEAD:
ret = recvHead();
break;
case RECV_BODY:
ret = recvBody();
break;
default:
break;
}
if (ret == RET_AGAIN)
return readEvent();
return ret;
}
先讀取頭,在讀取到head包頭之後申請body(包+負載)所需空間,再讀取body,body讀取完畢之後傳給消息分發的邏輯。
消息分發
服務端是如何區分群聊消息和私聊消息?在我們解決粘包和半包問題的時候就給出了答案。
客戶端封包結構為:包頭 + 包 +負載
在Pack包裡面有一個代表命令的欄位 command
.
struct DeMessagePacket
{
int mode; //1 請求,2 應答,3 消息通知
int error; //0 成功,非0,對應的錯誤碼
int sequence; //序列號
int command; //命令號
};
服務端可客戶端雙方約定的 cmmand
如下
//命令枚舉
enum{
CommandEnum_Registe,
CommandEnum_Login,
CommandEnum_Logout,
CommandEnum_GroupChat,
CommandEnum_AddFriend,
CommandEnum_delFriend,
CommandEnum_PrivateChat,
CommandEnum_CreateGroup,
CommandEnum_GetGroupList,
CommandEnum_GetGroupInfo,
CommandEnum_GetFriendInfo,
};
服務端通過switch匹配各個命令,進而對每個命令進行處理。
用戶註冊
用戶註冊請求,響應的數據格式如下
/**
* @brief 註冊用戶資訊
*/
struct RegistInfoReq{
char m_userName[32];
char m_password[32];
};
struct RegistInfoResp{
int m_account;
};
在用戶註冊時,服務端生成一個唯一的帳號發送給客戶端,客戶端只能通過該帳號與服務端交互。
用戶註冊完成之後會存放在服務端的一個全局map表中,方便集中管理
typedef std::map<int,RegistInfoReq*> mapAccountInfo; //註冊用戶表
static mapAccountInfo g_AccountInfoMap; //註冊賬戶資訊表
用戶登陸
用戶登陸請求,響應的數據格式如下
struct LoginInfoReq{
int m_account; //帳號
char m_password[32];
};
用戶登陸成功後會創建一個用戶資訊 UserInfo
並將該用戶資訊添加到全局的一個用戶map表中集中管理
typedef std::map<int,UserInfo*> mapUserInfo; //在線用戶表
static mapUserInfo g_UserInfoMap; //在線用戶資訊表
登陸成功之後發回給客戶端的是一個沒有負載的包,包中的error欄位置0.
用戶登出
客戶端直接斷開即可,具體登出數據格式暫未實現.
群聊
此次設計中有一個公共群聊(帳號為0),所有用戶都在群聊裡面。
用戶群聊請求,響應的數據格式如下
truct GroupChatReq
{
int m_UserAccount; //發送的帳號
int m_msgLen;
int m_type; //數據類型 0:文本,1:圖片 ...
int m_GroupAccount; //發送群號 0:廣播
};
看著沒啥毛病但是群消息在哪?要發送的數據在哪?
還記得我們客戶端封包結構:包頭 + 包 +負載
負載裡面包含了 數據傳輸格式+其他數據
在群聊請求裡面有一個 m_msgLen
欄位用來區分消息的邊界,因為客戶端發送的消息是不定長的,所以需要這麼一個欄位來區分消息的邊界。
私聊
用戶私聊請求,響應的數據格式如下
struct PrivateChatReq
{
int m_UserAccount; //發送的帳號
int m_msgLen;
int m_type; //數據類型 0:文本,1:圖片 ...
int m_FriendAccount; //發送好友帳號
};
跟群聊類似,其實這兩個數據格式可以用同一個。
添加好友
用戶添加好友請求,響應的數據格式如下
struct AddFriendInfoReq
{
int m_friendAccount; //好友帳號
int m_senderAccount; //發送端帳號
char m_reqInfo[64]; //請求資訊 例如我是xxx
};
struct AddFriendInfoResp
{
int m_friendAccount; //好友帳號
int m_senderAccount; //發送端帳號
int status; //同意0,不同意-1
};
添加好友的流暢比較複雜,我在設計的時候也卡了一下。
主要流程如圖
- 客戶端A給伺服器發送添加好友的請求
AddFriendInfoReq
,伺服器解析請求將B的資訊添加到客戶端A的好友表中。 - 伺服器B給客戶端B轉發好友請求。
- 客戶端B同意或者拒絕,給伺服器發送添加好友的響應
AddFriendInfoResp
,伺服器解析請求將A的資訊添加到客戶端B的好友表中,將客戶端A的好友表中屬於客戶端B的好友狀態欄位m_status置1或0。
獲取好友資訊
用戶獲取好友資訊請求,響應的數據格式如下
/* 好友請求介面封裝 */
struct GetFriendInfoResp
{
int m_size; //群成員大小
};
struct FriendInfo{
char m_userName[32];//好友用戶名
int m_account; //帳號
int m_status; //是否添加成功 0:等待添加 1:同意
};
這裡大夥可能有點蒙了,又是包頭,又是包,又是負載的,拿著數據格式到底屬於那塊的
其實數據格式(例如GetFriendInfoResp結構體)和數據都屬於負載裡面的,如圖所示。
對於通訊協議為二進位的協議來說,解析起來效率是最快的。
獲取群列表
用戶獲取群列表資訊請求,響應的數據格式如下
struct GetGroupListResp
{
int m_size; //群數量大小
};
struct GroupChatInfo
{
char m_groupName[32]; //群名稱
int m_account; //群帳號
int m_size; //群大小
};
數據的傳輸同獲取好友資訊,在這裡群列表也有一個map表統一管理。
獲取群資訊
用戶獲取群資訊請求,響應的數據格式如下
struct GetGroupInfoReq
{
int m_GroupAccount; //群號 0:廣播
};
struct GetGroupInfoResp
{
char m_groupName[32]; //群名稱
int m_GroupAccount; //群號 0:廣播
int m_size; //群成員大小
};
struct GroupUserInfo{
char m_userName[32];
int m_account; //帳號
int m_right; //許可權 0:群成員 1:群管 2:群主
};
這裡的數據傳輸和獲取好友資訊一樣。
到這裡我們的服務端介紹完了,比較複雜,但是知識點超多。客戶端設計相對容易些,但是我感覺單純的終端客戶端太掉逼格了,就又寫個一個qt的客戶端,重溫了一邊qt的UI設計,簡直不要太爽,qt的客戶端設計會另外再補一篇文章。
github源碼
chat_room://github.com/ADeRoy/chat_room
歡迎慷慨 star