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 };
新增好友的流暢比較複雜,我在設計的時候也卡了一下。
主要流程如圖
AddFriendInfoReq AddFriendInfoResp
獲取好友資訊
使用者獲取好友資訊請求,響應的資料格式如下
/* 好友請求介面封裝 */ 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: http://github.com/ADeRoy/chat_room
歡迎慷慨 star
- 模板化的封裝,降低業務程式碼開發
- 分享一個 SpringCloud Feign 中所埋藏的坑
- MySQL 事務常見面試題總結 | JavaGuide 稽核中
- 型別安全的 Go HTTP 請求
- 從幾次事故引起的對專案質量保障的思考
- 聯盟鏈 Hyperledger Fabric 應用場景
- 上半年最中意的 GitHub 更新「GitHub 熱點速覽 v.22.21」
- 為什麼我寫了路由懶載入但程式碼卻沒有分割?
- 這個設計原則,你認同嗎?
- SpringCloud基礎概念學習筆記(Eureka、Ribbon、Feign、Zuul)
- 自動微分原理
- layui資料表格搜尋
- Python 中的記憶體管理
- spring 配置檔案 --bean
- 【leetcode】239. 滑動視窗最大值
- Spring 原始碼(17)Spring Bean的建立過程(8)Bean的初始化
- SpringBoot進階教程(七十四)整合ELK
- 連結串列的基本操作和高頻演算法題
- 【python】python連線Oracle資料庫
- Python技法:浮點數取整、格式化和NaN處理