IM即時通訊設計 高併發聊天服務:伺服器 + qt客戶端(附原始碼)

DeRoy發表於2021-12-13

來源:微信公眾號「程式設計學習基地」

IM即時通訊程式設計

介面相對簡陋,主要介面如下

  • 登入介面

登入介面

  • 註冊介面

註冊介面

  • 聊天介面

聊天介面

  • 新增好友介面

新增好友介面

支援的功能

  • 註冊賬號
  • 登入賬號
  • 新增好友
  • 群聊

群聊

  • 私聊

私聊

後續UI美化以及功能增加持續更新,關注微信公眾號「程式設計學習基地」最快諮詢..

IM即時通訊

本系列將帶大家從零開始搭建一個輕量級的IM服務端,麻雀雖小,五臟俱全,我們搭建的IM服務端實現以下功能

  • 註冊
  • 登入
  • 私聊
  • 群聊
  • 好友關係

第一版只實現了IM即時通訊的基礎功能,其他功能後續增加.

設計一款高併發聊天服務需要注意什麼

  1. 實時性

在網路良好的狀態下伺服器能夠及時處理使用者訊息

  1. 可靠性

服務端如何防止粘包,半包,保證資料完全接收,不丟資料,不重資料

  1. 一致性

保證傳送方傳送順序與接收方展現順序一致

實時性就不必細說了,保證伺服器能夠及時處理使用者訊息就行,重點說下可靠性

如何設計可靠的訊息處理服務

簡單來說就是客戶端每次傳送的資料長度不定,服務端需要保證能夠解析每一個使用者傳送過來的訊息。

這就涉及到粘包和半包,這裡說下粘包和半包是什麼情況

什麼是粘包

多個資料包被連續儲存於連續的快取中,在對資料包進行讀取時無法確定發生方的傳送邊界.

例如:客戶端需要給服務端傳送兩條訊息,傳送資料如下

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
};

新增好友的流暢比較複雜,我在設計的時候也卡了一下。

主要流程如圖

請新增圖片描述

  1. 客戶端A給伺服器傳送新增好友的請求 AddFriendInfoReq,伺服器解析請求將B的資訊新增到客戶端A的好友表中。
  2. 伺服器B給客戶端B轉發好友請求。
  3. 客戶端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

相關文章