Socket開發框架之資料傳輸協議

伍華聰發表於2016-03-29

我在前面一篇隨筆《Socket開發框架之框架設計及分析》中,介紹了整個Socket開發框架的總體思路,對各個層次的基類進行了一些總結和抽象,已達到重用、簡化程式碼的目的。本篇繼續分析其中重要的協議設計部分,對其中訊息協議的設計,以及資料的拆包和封包進行了相關的介紹,使得我們在更高階別上更好利用Socket的特性。

1、協議設計思路

對Socket傳輸訊息的封裝和拆包,一般的Socket應用,多數採用基於順序位置和位元組長度的方式來確定相關的內容,這樣的處理方式可以很好減少資料大小,但是這些處理對我們分析複雜的協議內容,簡直是一場災難。對跟蹤解決過這樣協議的開發人員來說會很好理解其中的難處,協議位置一旦變化或者需要特殊的處理,就是很容易出錯的,而且大多數程式碼充斥著很多位置的數值變數,分析和理解都是非常不便的。隨著網路技術的發展,有時候傳輸的資料稍大一點,損失一些頻寬來傳輸資料,但是能成倍提高開發程式的效率,是我們值得追求的目標。例如,目前Web API在各種裝置大行其道,相對Socket訊息來說,它本身在資料大小上不佔優勢,但是開發的便利性和高效性,是眾所周知的。

借鑑了Web API的特點來考慮Socket訊息的傳輸,如果對於整體的內容,Socket應用也使用一種比較靈活的訊息格式,如JSON格式來傳輸資料,那麼我們可以很好的把訊息封裝和訊息拆包解析兩個部分,交給第三方的JSON解析器來進行,我們只需要關注具體的訊息處理邏輯就可以了,而且對於協議的擴充套件,就如JSON一樣,可以自由靈活,這樣瞬間,整個世界都會很清靜了。

對於Socket訊息的安全性和完整性,加密處理方面我們可以採用 RSA公鑰密碼系統。平臺通過傳送平臺RSA公鑰訊息向終端告知自己的RSA公鑰,終端回覆終端RSA公鑰訊息,這樣平臺和終端的訊息,就可以通過自身的私鑰加密,讓對方根據接收到的公鑰解密就可以了,雖然加密的資料長度會增加不少,但是對於安全性要求高的,採用這種方式也是很有必要的。

對於資料的完整性,傳統意義的CRC校驗碼其實沒有太多的用處了,因為我們的資料不會發生部分的丟失,而我們更應該關注的是資料是否被篡改過,這點我想到了微信公眾號API介面的設計,它們帶有一個安全簽名的加密字串,也就是對其中內容進行同樣規則的加密處理,然後對比兩個簽名內容是否一致即可。不過對於非對稱的加密傳輸,這種資料完整性的校驗也可以不必要。

前面介紹了,我們可以參照Web API的方式,以JSON格式作為我們傳輸的內容,方便序列號和反序列化,這樣我們可以大大降低Socket協議的分析難度和出錯機率,降低Socket開發難度並提高開發應用的速度。那麼我們應該如何設計這個格式呢?

首先我們需要為Socket訊息,定義好開始標識和結束標識,中間部分就是整個通用訊息的JSON內容。這樣,一條完整的Socket訊息內容,除了開始和結束標識位外,剩餘部分是一個JSON格式的字串資料。

我們準備根據需要,設計好整個JSON字串的內容,而且最好設計的較為通用一些,這樣便於我們承載更多的資料資訊。

2、協議設計分析和演化

參考微信的API傳遞訊息的定義,我設計了下面的訊息格式,包括了送達使用者ID,傳送使用者ID、訊息型別、建立時間,以及一個通用的內容欄位,這個通用的欄位應該是另外一個訊息實體的JSON字串,這樣我們整個訊息格式不用變化,但是具體的內容不同,我們把這個物件類稱之BaseMessage,常用欄位如下所示。

上面的Content欄位就是用來承載具體的訊息資料的,它會根據不同的訊息型別,傳送不同的內容的,而這些內容也是具體的實體類序列化為JSON字串的,我們為了方便,也設計了這些類的基類,也就是Socket傳遞資料的實體類基類BaseEntity。

我們在不同的請求和應答訊息,都繼承於它即可。我們為了方便讓它轉換為我們所需要的BaseMessage訊息,為它增加一個MsgType協議型別的標識,同時增加PackData的方法,讓它把實體類轉換為JSON字串。

例如我們一般情況下的請求Request和應答Response的訊息物件,都是繼承自BaseEntity的,我們可以把這兩類訊息物件放在不同的目錄下方便管理。

繼承關係示例如下所示。

其中子類都可以使用基類的PackData方法,直接序列號為JSON字串即可,那個PacketData的函式主要就是用來組裝好待傳送的物件BaseMessage的,函式程式碼如下所示:

        /// <summary>
        /// 封裝資料進行傳送
        /// </summary>
        /// <returns></returns>
        public BaseMessage PackData()
        {
            BaseMessage info = new BaseMessage()
            {
                MsgType = this.MsgType,
                Content = this.SerializeObject()
            };
            return info;
        }

有時候我們需要根據請求的資訊,用來構造返回的應答訊息,因為需要把傳送者ID和送達者ID逆反過來。

        /// <summary>
        /// 封裝資料進行傳送(複製請求部分資料)
        /// </summary>
        /// <returns></returns>
        public BaseMessage PackData(BaseMessage request)
        {
            BaseMessage info = new BaseMessage()
            {
                MsgType = this.MsgType,
                Content = this.SerializeObject(),
                CallbackID = request.CallbackID
            };

            if(!string.IsNullOrEmpty(request.ToUserId))
            {
                info.ToUserId = request.FromUserId;
                info.FromUserId = request.ToUserId;
            }

            return info;
        }

以登陸請求的資料實體物件介紹,它繼承自BaseEntity,同時指定好對應的訊息型別即可。

    /// <summary>
    /// 登陸請求訊息實體
    /// </summary>
    public class AuthRequest : BaseEntity
    {
        #region 欄位資訊

        /// <summary>
        /// 使用者帳號
        /// </summary>
        public string UserId { get; set; }

        /// <summary>
        /// 使用者密碼
        /// </summary>
        public string Password { get; set; }

        #endregion

        /// <summary>
        /// 預設建構函式
        /// </summary>
        public AuthRequest()
        {
            this.MsgType = DataTypeKey.AuthRequest;
        }

        /// <summary>
        /// 引數化建構函式
        /// </summary>
        /// <param name="userid">使用者帳號</param>
        /// <param name="password">使用者密碼</param>
        public AuthRequest(string userid, string password) : this()
        {
            this.UserId = userid;
            this.Password = password;
        }
    }

這樣我們的訊息內容就很簡單,方便我們傳遞及處理了。

3、訊息的接收和傳送

前面我們介紹過了一些基類,包括Socket客戶端基類,和資料接收的基類設計,這些封裝能夠給我提供很好的便利性。

在上面的BaseSocketClient裡面,我們為了能夠解析不同協議的Socket訊息,把它轉換為我們所需要的基類物件,那麼我們這裡引入一個解析器MessageSplitter,這個類主要的職責就是用來分析位元組資料,並進行整條訊息的提取的。

因此我們把BaseSocketClient的類定義的程式碼設計如下所示。

    /// <summary>
    /// 基礎的Socket操作類,提供連線、斷開、接收和傳送等相關操作。
    /// </summary>
    /// <typeparam name="TSplitter">對應的訊息解析類,繼承自MessageSplitter</typeparam>
    public class BaseSocketClient<TSplitter>  where TSplitter : MessageSplitter, new()

MessageSplitter物件,給我們處理低層次的協議解析,前面介紹了我們除了協議頭和協議尾標識外,其餘部分就是一個JSON的,那麼它就需要根據這個規則來實現位元組資料到物件級別的轉換。

首先需要把位元組資料進行拆分,把它完整的一條資料加到列表裡面後續進行處理。

其中結尾部分,我們就是需要提取快取的直接資料到一個具體的物件上了。

RawMessage msg = this.ConvertMessage(MsgBufferCache, from);

這個轉換的大概規則如下所示。

這樣我們在收到訊息後,利用TSplitter物件來進行解析就可以了,如下所示就是對Socket訊息的處理。

                    TSplitter splitter = new TSplitter();
                    splitter.InitParam(this.Socket, this.StartByte, this.EndByte);//指定分隔符,用來拆包
                    splitter.DataReceived += splitter_DataReceived;//如果有完整的包處理,那麼通過事件通知

資料接收並獲取一條訊息的直接資料物件後,我們就進一步把直接物件轉換為具體的訊息物件了

        /// <summary>
        /// 訊息分拆類收到訊息事件
        /// </summary>
        /// <param name="data">原始訊息物件</param>
        void splitter_DataReceived(RawMessage data)
        {
            ReceivePackCount += 1;//增加收到的包數量
            OnReadRaw(data);
        }

        /// <summary>
        /// 接收資料後的處理,可供子類過載
        /// </summary>
        /// <param name="data">原始訊息物件(包含原始的位元組資料)</param>
        protected virtual void OnReadRaw(RawMessage data)
        {
            //提供預設的包體處理:假設整個內容為Json的方式;
            //如果需要處理自定義的訊息體,那麼需要在子類重寫OnReadMessage方法。
            if (data != null && data.Buffer != null)
            {
                var json = EncodingGB2312.GetString(data.Buffer);
                var msg = JsonTools.DeserializeObject<BaseMessage>(json);

                OnReadMessage(msg);//給子類過載
            }
        }

在更高一層的資料解析上面,我們就可以對物件級別的訊息進行處理了

例如我們收到訊息後,它本身解析為一個實體類BaseMessage的,那麼我們就可以利用BaseMessage的訊息內容,也可以把它的Content內容轉換為對應的實體類進行處理,如下程式碼所示是接收物件後的處理。

        void TextMsgAnswer(BaseMessage message)
        {
            var msg = string.Format("來自【{0}】的訊息:", message.FromUserId);

            var request = JsonTools.DeserializeObject<TextMsgRequest>(message.Content);
            if (request != null)
            {
                msg += string.Format("{0}  {1}", request.Message, message.CreateTime.IntToDateTime());
            }

            //MessageUtil.ShowTips(msg);
            Portal.gc.MainDialog.AppendMessage(msg);
        }

對於訊息的傳送處理,我們可以舉一個例子,如果客戶端登陸後,需要獲取線上使用者列表,那麼可以傳送一個請求命令,那麼伺服器需要根據這個命令返回列表資訊給終端,如下程式碼所示。

        /// <summary>
        /// 處理客戶端請求使用者列表的應答
        /// </summary>
        /// <param name="data">具體的訊息物件</param>
        private void UserListProcess(BaseMessage data)
        {
            CommonRequest request = JsonTools.DeserializeObject<CommonRequest>(data.Content);
            if (request != null)
            {
                Log.WriteInfo(string.Format("############\r\n{0}", data.SerializeObject()));

                List<CListItem> list = new List<CListItem>();
                foreach(ClientOfShop client in Singleton<ShopClientManager>.Instance.LoginClientList.Values)
                {
                    list.Add(new CListItem(client.Id, client.Id));
                }

                UserListResponse response = new UserListResponse(list);
                Singleton<ShopClientManager>.Instance.AddSend(data.FromUserId, response.PackData(data), true);
            }
        }

相關文章