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:https://github.com/ADeRoy/chat_room
歡迎慷慨 star