Socket通用TCP通訊協議設計及實現(防止粘包,可移植,可靠)
Socket通用TCP通訊協議設計及實現(防止粘包,可移植,可靠)
引文
我們接收Socket位元組流資料一般都會定義一個數據包協議。我們每次開發一個軟體的通訊模組時,儘管具體的資料內容是不盡相同的,但是大體上的框架,以及常用的一些函式比如轉碼,校驗等等都是相似甚至一樣的。所以我感覺設計一個通用的通訊協議,可以在之後的開發中進行移植實現高效率的開發是很有必要的。另外,本協議結合我自己所瞭解的通訊知識儘可能的提升了可靠性和移植性,可處理類似粘包這樣的問題。對於本文中可能存在的問題,歡迎各位大神多多指點。
報文設計
本報文的欄位結構分為Hex編碼和BCD(8421)編碼兩種。由於BCD編碼的取值範圍其實是Hex編碼的真子集,也就是所謂的16進位制編碼中的“ABCDEF”這六個字母對應的數值是在BCD編碼中無法取值的。所以我利用這個特點,將報文中的用於標識的不含實際資料的抽象欄位用Hex編碼,且取值範圍在A~F之間。將反應實際資料的欄位用BCD編碼。這樣,具有標識作用的欄位與實際資料欄位的取值是互不交叉的。這無形中就避免了很多出現的問題,增強了報文的可靠性。例如:我使用”0xFFFF”代表報文起始符,這個取值是不會在任何一個數據欄位中出現的,應為它們是BCD編碼。也就是是說,位元組流緩衝區中只要出現”0xFFFF”我們就可以判斷這個是一個數據包的開頭(我在實現在緩衝區中找尋資料包演算法時還做了另外的控制,進行雙重保障)。
對於正文部分,我設計成了“識別符號|資料”成對出現的形式。每個識別符號用來指示後面出現的資料的含義,資料欄位用於傳輸真實的資料。這種對的形式,增強了報文的移植性,在新的一次開發到來時,我們只要按需求定義好正文部分的“識別符號|資料”對即可。另外,這種設計還增強了傳送報文方的靈活性。識別符號的存在使得各項資料可以按照任意的順序傳送,沒有的資料也可以不發。
基於以上的這些考慮,我把報文設計成了如下形式:
通用報文協議
序號 |
名稱 |
編碼說明 |
1 |
報文起始符 |
2位元組Hex編碼 0xFFFF |
2 |
功能碼(報文型別) |
2位元組Hex編碼 0xD1D1 |
3 |
密碼 |
4位元組BCD編碼 00 00 00 01 |
4 |
長度 |
2位元組BCD編碼 正文實際長度 |
5 |
識別符號1 |
2位元組Hex編碼 自定義資料識別符號 0xA001 |
6 |
資料1 |
N位元組BCD編碼 N根據實際情況自定義 |
7 |
識別符號2 |
2位元組Hex編碼 自定義資料識別符號 0xA002 |
8 |
資料2 |
N位元組BCD編碼 N根據實際情況自定義 |
... |
… |
|
報文終止符 |
2位元組Hex編碼 0xEEEE |
|
校驗碼 |
校驗碼前所有位元組的CRC校驗,生成多項式:X16+X15+X2+1,高位位元組在前,低位位元組在後。 |
報文示例:
示例背景:傳送報文通知遠端伺服器第1號裝置開關的當前狀態為開啟
需自定義正文部分,含兩個欄位,裝置編號和開關狀態
傳送的位元組陣列:255 255 | 209209 | 0 0 0 1 | 0 6 | 160 1 | 1 | 160 2| 0 | 238 238 | 245 40 |
對應含義解釋: 起始符FFFF | 功能碼D1D1 | 密碼00 00 00 01 | 長度(正文)00 06| 識別符號A001 | 資料 1 | 識別符號A002 | 資料 0 | 報文終止符 EEEE | 校驗結果 |
粘包問題的解決
針對我的協議,我設計了一個緩衝區中找尋資料包演算法,這兩者的配合完美的實現了防止粘包,過濾噪聲資料等類似的各種令人頭疼的問題。此演算法思路來自博文點選開啟連結
演算法流程圖如下:
演算法C#程式碼具體實現:
/// <summary>
/// 資料緩衝區
/// </summary>
public class DataBuffer
{
//位元組緩衝區
private List<byte> m_buffer = new List<byte>();
#region 私有方法
/// <summary>
/// 尋找第一個報頭 (0xFFFF)
/// </summary>
/// <returns>返回報文起始符索引,沒找到返回-1</returns>
private int findFirstDataHead()
{
int tempIndex=m_buffer.FindIndex(o => o == 0xFF);
if (tempIndex == -1)
return -1;
if ((tempIndex + 1) < m_buffer.Count) //防止越界
if (m_buffer[tempIndex + 1] != 0xFF)
return -1;
return tempIndex;
}
/// <summary>
/// 尋找第一個報尾 (0xEEEE)
/// </summary>
/// <returns></returns>
private int findFirstDataEnd()
{
int tempIndex = m_buffer.FindIndex(o => o == 0xEE);
if (tempIndex == -1)
return -1;
if((tempIndex+1)<m_buffer.Count) //防止越界
if (m_buffer[tempIndex + 1] != 0xEE)
return -1;
return tempIndex;
}
#endregion
/// <summary>
/// 在緩衝區中尋找完整合法的資料包
/// </summary>
/// <returns>找到返回資料包長度len,資料包範圍即為0~(len-1);未找到返回0</returns>
public int Find()
{
if (m_buffer.Count == 0)
return 0;
int HeadIndex = findFirstDataHead();//查詢報頭的位置
if (HeadIndex == -1)
{
//沒找到報頭
m_buffer.Clear();
return 0;
}
if (HeadIndex >= 1)//不為開頭移掉之前的位元組
m_buffer.RemoveRange(0, HeadIndex);
int length = GetLength();
if (length==0)
{
//報文還未全部接收
return 0;
}
int TailIndex = findFirstDataEnd(); //查詢報尾的位置
if (TailIndex == -1)
{
return 0;
}
else if (TailIndex + 4 != length) //包尾與包長度不匹配
{
//退出前移除當前報頭
m_buffer.RemoveRange(0, 2);
return 0;
}
return length;
}
/// <summary>
/// 包長度
/// </summary>
/// <returns></returns>
public int GetLength()
{
//報文起始符 功能碼 密碼 正文長度 報文終止符 CRC校驗碼 這六個基礎結構佔14位元組
//因此報文長度至少為14
if (m_buffer.Count >= 14)
{
int length = m_buffer[8] * 256 + m_buffer[9];//正文長度
return length + 14;
}
return 0;
}
/// <summary>
/// 提取資料
/// </summary>
public void Dequeue(byte[] buffer, int offset, int size)
{
m_buffer.CopyTo(0, buffer, offset, size);
m_buffer.RemoveRange(offset, size);
}
/// <summary>
/// 佇列資料
/// </summary>
/// <param name="buffer"></param>
public void Enqueue(byte[] buffer)
{
m_buffer.AddRange(buffer);
}
}
呼叫示例:
private void receive()
{
while (true)//迴圈直至使用者主動終止執行緒
{
int len = Server.Available;
if (len > 0)
{
byte[] temp = new byte[len];
Server.Receive(temp,len,SocketFlags.None);
buffer.Enqueue(temp);
while (buffer.Find()!=0) //while可處理同時接收到多個包的情況
{
int length = buffer.GetLength();
byte[] readBuffer = new byte[len];
buffer.Dequeue(readBuffer, 0, length);
//OnReceiveDataEx(readBuffer); //這裡自己寫一個委託或方法就OK了,封裝收到一個完整資料包後的工作
//示例,這裡簡單實用靜態屬性處理:
DataPacketEx da = Statute.UnPackMessage(readBuffer);
ComFun.receiveList.Add(da);
}
}
Thread.Sleep(100);//這裡需要根據實際的資料吞吐量合理選定執行緒掛起時間
}
}
其中DataPacketEx是封裝資料包正文部分的類,其中的屬性記錄了要傳送的資料使用時只需開啟一個執行緒,不斷的將收到的位元組流資料加入緩衝區中。呼叫Find()方法找尋下一個資料包,如果該方法返回0,說明當前緩衝區中不存在資料包(資料尚未完整接收/存在錯誤資料,該方法可自行進行處理),如果返回一個正數n,則當前緩衝區中索引0-(n-1)的資料即為一個收到的完整的資料包。對其進行處理即可。
協議的實現
在實現協議前,首先我在自定義的TransCoding類中實現了幾個靜態方法用於Hex、BCD、string等之間的轉換。
/// <summary>
/// 將十進位制形式字串轉換為BCD碼的形式
/// </summary>
/// <param name="str">十進位制形式的待轉碼字串,每個字元需為0~9的十進位制數字</param>
/// <returns></returns>
public static byte[] BCDStrToByte(string str)
{
#region 原方法
//長度為奇數,隊首補0
if (str.Length % 2 != 0)
{
str = '0' + str;
}
byte[] bcd = new byte[str.Length / 2];
for (int i = 0; i < str.Length / 2; i++)
{
int index = i * 2;
//計算BCD[index]處的位元組
byte high = (byte)(str[index] - 48); //高四位
high = (byte)(high << 4);
byte low = (byte)(str[index + 1] - 48); //低四位
bcd[i] = (byte)(high | low);
}
return bcd;
#endregion
}
/// <summary>
/// 將位元組資料轉化為16進位制的字串(注意:同樣適用與轉8421格式的BCD碼!!!!)
/// </summary>
/// <param name="hex"></param>
/// <param name="index"></param>
/// <returns></returns>
public static string ByteToHexStr(byte[] hex, int index)
{
string hexStr = "";
if (index >= hex.Length || index < 0)
throw new Exception("索引超出界限");
for (int i = index; i < hex.Length; i++)
{
if (Convert.ToInt16(hex[i]) >= 16)
{
hexStr += Convert.ToString(hex[i], 16).ToUpper();
}
else
{
hexStr += "0" + Convert.ToString(hex[i], 16).ToUpper();
}
}
return hexStr;
}
/// <summary>
/// 將16進位制字串轉化為位元組資料
/// </summary>
/// <param name="hexStr"></param>
/// <returns></returns>
public static byte[] HexStrToByte(string hexStr)
{
if (hexStr.Trim().Length % 2 != 0)
{
hexStr = "0" + hexStr;
}
byte[] hexByte = new byte[hexStr.Length / 2];
for (int i = 0; i < hexByte.Length; i++)
{
string hex = hexStr[i * 2].ToString(CultureInfo.InvariantCulture) + hexStr[i * 2 + 1].ToString(CultureInfo.InvariantCulture);
hexByte[i] = byte.Parse(hex, NumberStyles.AllowHexSpecifier);
}
return hexByte;
#region 使用Convert.ToByte轉換
//長度為奇數,隊首補0,確保整數
//if (str.Length % 2 != 0)
//{
// str = '0' + str;
//}
//string temp = "";
//byte[] BCD = new byte[str.Length / 2];
//for (int index = 0; index < str.Length; index += 2)
//{
// temp = str.Substring(index, 2);
// BCD[index / 2] = Convert.ToByte(temp, 16);
//}
//return BCD;
#endregion
}
以下是協議的實現的兩個核心方法,裝包和解包
裝包方法將已有的具體的不同資料型別的資料轉換成byte位元組流,以便進行socket通訊
解包方法將socket接收到的完整資料包位元組流解析成封裝資料包的類DataPacketEx
/// <summary>
/// 構造向終端傳送的訊息(示例)
/// </summary>
/// <param name="data">記錄傳送訊息內容的資料包</param>
/// <returns>傳送的訊息</returns>
public byte[] BuildMessage(DataPacketEx data)
{
List<byte> msg = new List<byte>(); //先用訊息連結串列,提高效率
//幀起始符
byte[] tempS = TransCoding.HexStrToByte("FFFF");
ComFun.bytePaste(msg, tempS);
//功能碼
tempS = TransCoding.HexStrToByte("D1D1");
ComFun.bytePaste(msg, tempS);
//密碼
tempS = TransCoding.BCDStrToByte("00000001");
ComFun.bytePaste(msg, tempS);
//長度
tempS = TransCoding.BCDStrToByte("0006");
ComFun.bytePaste(msg, tempS);
//開關裝置編號識別符號
tempS = TransCoding.HexStrToByte("A001");
ComFun.bytePaste(msg, tempS);
//開關裝置編號
tempS = TransCoding.BCDStrToByte(data.ObjectID);
ComFun.bytePaste(msg, tempS);
//開/關識別符號
tempS = TransCoding.HexStrToByte("A002");
ComFun.bytePaste(msg, tempS);
//開/關
tempS = TransCoding.BCDStrToByte(data.IsOpen);
ComFun.bytePaste(msg, tempS);
//報文終止符
tempS = TransCoding.HexStrToByte("EEEE");
ComFun.bytePaste(msg, tempS);
//CRC校驗
byte[] message = new byte[msg.Count];
for (int i = 0; i < msg.Count; i++)
{
message[i] = msg[i];
}
byte[] crc = new byte[2];
Checksum.CalculateCrc16(message, out crc[0], out crc[1]);
message = new byte[msg.Count + 2];
for (int i = 0; i < msg.Count; i++)
{
message[i] = msg[i];
}
message[message.Length - 2] = crc[0];
message[message.Length - 1] = crc[1];
return message;
}
/// <summary>
/// 解包資料
/// </summary>
/// <param name="message">需要解包的資料</param>
/// <returns>成功解析返回true,否則返回false </returns>
public DataPacketEx UnPackMessage(byte[] message)
{
//先校驗資訊是否傳輸正確
if (!CheckRespose(message))
return null;
//檢查密碼是否正確.(假設當前密碼為00 00 00 01,需在應用時根據實際情況解決)
byte[] temp = new byte[4];
temp[0] = message[4];
temp[1] = message[5];
temp[2] = message[6];
temp[3] = message[7];
if (TransCoding.ByteToHexStr(temp, 0) != "00000001")
return null;
DataPacketEx DataPacket = new DataPacketEx("", "", "");
//獲取功能碼
byte[] funType = new byte[2] { message[2], message[3] };
string functionStr = TransCoding.ByteToHexStr(funType, 0);
#region 具體解包過程,需根據實際情況修改
int index = 10; //(當前索引指向第一個識別符號)
string tempStr="";
switch (functionStr)
{
case "D1D1":
temp = new byte[2] { message[index], message[index + 1] };
index = index + 2;
tempStr = TransCoding.ByteToHexStr(temp, 0);
while (tempStr != "EEEE")
{
switch (tempStr)
{
//注意:每種識別符號對應的資料長度是協議中自定義的
case "A001":
//開關裝置編號
temp = new byte[1] { message[index] };
index = index + 1;
tempStr = TransCoding.ByteToHexStr(temp, 0);
DataPacket.ObjectID = tempStr;
break;
case "A002":
//開or關(開:00 關:11)
temp = new byte[1] { message[index] };
index = index + 1;
tempStr = TransCoding.ByteToHexStr(temp, 0);
DataPacket.IsOpen = tempStr;
break;
//case "其他識別符號":
// //對應資訊
// break;
}
temp = new byte[2] { message[index], message[index + 1] };
index = index + 2;
tempStr = TransCoding.ByteToHexStr(temp, 0);
}
break;
//case "其他功能碼":
// //對應功能
// break;
}
#endregion
return DataPacket;
}
對於通訊可靠性的驗證
對此,我製作了兩個簡單的demo,一個伺服器端,一個客戶端。
客戶端可想伺服器端迴圈傳送資料,其中以0.5的概率夾雜著隨機長度隨機取值的干擾資料,以此來判斷本協議在實際應用中的可行性。
伺服器端負責迴圈接收並處理顯示收到的資料
最終的執行結果如下圖:
由執行結果可以看出,伺服器端完美遮蔽掉了客戶端發出的錯誤資料,全部解析出了客戶端傳送的實際資料。證明本協議可以解決類似粘包,傳錯等等類似的通訊中的棘手問題。當然,協議中如果有不完美的地方,希望各位大神指教。另外,上面的demo只是為了驗證協議所做,還存在一些零零碎碎的小bug。
以上就是通訊協議的全部核心內容。
具體實現的程式碼中可能包含一些並未給出的不太重要的類,並不影響理解。
畢竟認真總結了好久,所以設定了積分大家不要介意哈