一夜搞懂——TCP基於NewLife.Net網絡庫的管道式幀長粘包處理方法

1.粘包現象

每個TCP 長連接都有自己的socket緩存buffer,默認大小是8K,可支持手動設置。粘包是TCP長連接中最常見的現象,如下圖

一夜搞懂——TCP基於NewLife.Net網絡庫的管道式幀長粘包處理方法

socket緩存中有5幀(或者說5包)心跳數據,包頭即F0 AA 55 0F(十六進制),通過數包頭數據我們確認出來緩存裡有5幀心跳包,但是5幀數據彼此頭尾相連粘合在了一起,這種常見的TCP緩存現象,我們稱之為粘包。

2.粘包原因

2.1. 同一客戶端連續發送

同一客戶端連續發送心跳數據,當TCP服務端還來不及解析(如果解析完會把緩存清掉)。造成了同一緩存數據包的粘合。

2.2. 網絡擁塞造成粘包

當某一時刻發生了網絡擁塞,一會之後,突然網絡暢通,TCP服務端收到同一客戶端的多個心跳包,多個數據包會在TCP服務端的緩存中進行了粘合。

2.3. 服務端卡死了

當服務端因為計算量過大或者其他的原因,計算緩慢,來不及處理TCP Socket緩存中的數據,多個心跳包(或者其他報文)也會在socket緩存中首尾相連,粘包。

總而言之,就是多個數據包在同一個TCP socket緩存中進行了首尾相連現象,即為粘包現象。

3. 粘包的危害

由於粘包現象存在的客觀性,我們必須人為地在程序邏輯裡將其區分,如果不去區分,任由各個數據包進行粘連,有以下幾點危害:

3.1. 無法正確解析數據包

服務端會不斷識別為無效包,告訴客戶端,客戶端會再次上報,因此會增加客戶端服務端的運行壓力,如果本身運算量很大,則會出現一些異常奔潰現象。

3.2. 錯誤數據包被錯誤解析

無巧不成書,如果錯誤的粘包,湊巧被服務端進行成功解析,則會進行錯誤的Handler 處理。這樣的錯誤處理方式危害會超過3.1。

3.3. 進入死循環

如果頻率過快,則會出現這種現象,服務器不斷識別粘包為無效包,客戶端不斷上報,以此消耗CPU的佔用率。

綜上,我們必須要進行TCP的粘包處理,這是軟件系統健壯性跟異常處理機制的基礎。

4. 粘包的邏輯處理方式

4.1. 根據包尾特徵參數進行區分

規定幾個字節為每幀TCP報文的包尾特徵(比如4個字節),檢索整個socket緩存字節,每當檢測到包尾特徵字節的時候,就劃分報文,以此來正確分割粘包。特徵:需要檢測每個字節,效率較低,適合短報文,如果報文很長則不適合。

4.2. 根據包頭包尾特徵參數進行區分

與4.1相似,多了包頭檢測部分。特徵:只需檢測第一幀的每個字節,第二幀只需檢測包頭部分,適合長報文

4.3. 根據報文長度來進行粘包區分

根據報文長度偏置值,讀第一幀的報文,從粘包中(socket緩存)劃分出第一幀正確報文,找第二幀的報文長度,劃分第二幀,以此劃分到底。舉例:如下長度偏置為5(從0開始計算),即第6,第7字節為報文長度字節。

一夜搞懂——TCP基於NewLife.Net網絡庫的管道式幀長粘包處理方法

特徵:只需檢測報文長度部分,適合長短報文的粘包劃分。

5. 根據報文長度來區分粘包的代碼落地——基於NewLife.Net的管道處理

5.1. NewLife.Net管道架構處理方式

Newlife.Net管道架構的設計,參考了java的Netty開源框架,因此大部分Netty的編解碼器都可以在此使用。具體在代碼中的表現為

<code> _pemsServer.Add(new StickPackageSplit { Size = 2 });
/<code>

即將LengthCodec這個編解碼器加入到了管道中去,所有的message都會經過LengthCodec這裡主要是解碼功能,沒有進行編碼,解碼成功後(粘包根據長度劃分出多個有效包)推送到OnReceive方法中去。Size = 2表示報文長度是2個字節。

5.2. 跟http的管道類比

與Net Core 的WEBAPI項目的管道添加,是否發現似曾相識?

<code>  app.UseAuthentication();
app.UseRequestLog();
app.UseCors(_defaultCorsPolicyName);
app.UseMvc();
/<code>

管道添加的先後順序即數據流流經管道的順序。只是沒去追求是先有socket的管道處理機制,還是http 上下文的管道處理機制。但是道理是相同的。

5.3.拆分粘包解碼器(根據長度解碼)

5.3.1. 長度偏移地址Offset屬性

長度所在位置的偏移地址。默認為5,解釋詳見4.3。

<code>        //
// 摘要:
// 長度所在位置
public int Offset
{
get;
set;
} = 5;
/<code>

5.3.2.長度字節數Size屬性

本文討論長度字節數為2,詳見4.3

<code>        //
// 摘要:
// 長度佔據字節數,1/2/4個字節,0表示壓縮編碼整數,默認2
public int Size
{
get;
set;
} = 2;
/<code>

5.3.3. 編碼方法Encode

<code>        //
// 摘要:
// 編碼,此應用不需要編碼,只需解碼,
// 按長度將粘包劃分成多個數據包

//
// 參數:
// context:
//
// msg:
protected override object Encode(IHandlerContext context, Packet msg)
{
return msg;
}
/<code>

這裡無需編碼,故直接返回msg。

5.3.4. 解碼方法Decode

<code>        //
// 摘要:
// 解碼
//
// 參數:
// context:
//
// pk:
protected override IList<packet> Decode(IHandlerContext context, Packet pk)
{
IExtend extend = context.Owner as IExtend;

LengthCodec packetCodec = extend["Codec"] as LengthCodec;

if (packetCodec == null)
{
IExtend extend2 = extend;
LengthCodec obj = new LengthCodec
{
Expire = Expire,
GetLength = ((Packet p) => MessageCodec<packet>.GetLength(p, Offset, Size))
};
packetCodec = obj;
extend2["Codec"] = obj;
}

Console.WriteLine("報文解碼前:{0}", BitConverter.ToString(pk.ToArray()));
IList<packet> list = packetCodec.Parse(pk);
Console.WriteLine("報文解碼");
foreach (var item in list)
{
Console.WriteLine("粘包處理結果:{0}", BitConverter.ToString(item.ToArray()));

}

return list;
}
/<packet>/<packet>/<packet>/<code>

5.3.4.1.解碼步驟1——實例化長度解碼器對象

實例化長度解碼器完成之後,並將其添加到字典中去。

<code>    IExtend extend2 = extend;
LengthCodec obj = new LengthCodec
{
Expire = Expire,
GetLength = ((Packet p) => MessageCodec<packet>.GetLength(p, Offset, Size))
};
packetCodec = obj;
extend2["Codec"] = obj;
/<packet>/<code>

5.3.4.2.解碼步驟2——將解碼前的報文打印

此步驟非必須,為了最後能讓讀者看到效果增加。

<code>    Console.WriteLine("報文解碼前:{0}", BitConverteToString(pk.ToArray()));
/<code>

5.3.4.3.解碼步驟3——將報文進行解碼

<code> IList<packet> list = packetCodec.Parse(pk);
/<packet>/<code>

解碼代碼如下:

<code>        //
// 摘要:
// 分析數據流,得到一幀數據
//
// 參數:

// pk:
// 待分析數據包
public virtual IList<packet> Parse(Packet pk)
{
MemoryStream stream = Stream;
bool num = stream == null || stream.Position < 0 || stream.Position >= stream.Length;
List<packet> list = new List<packet>();


if (num)
{

if (pk == null)
{
return list.ToArray();
}
int i;
int num2;

for (i = 0; i < pk.Total; i += num2)
{
Packet packet = pk.Slice(i);

num2 = GetLength(packet);

Console.WriteLine(" pk. GetLength(packet):{0}", num2);

if (num2 <= 0 || num2 > packet.Total)
{
break;
}
packet.Set(packet.Data, packet.Offset, num2);
list.Add(packet);
}


if (i == pk.Total)
{

return list.ToArray();
}
pk = pk.Slice(i);
}

lock (this)
{
CheckCache();
stream = Stream;
if (pk != null && pk.Total > 0)
{

long position = stream.Position;
stream.Position = stream.Length;
pk.CopyTo(stream);
stream.Position = position;
}
while (stream.Position < stream.Length)
{
Packet packet2 = new Packet(stream);
int num3 = GetLength(packet2);
if (num3 <= 0 || num3 > packet2.Total)
{
break;
}
packet2.Set(packet2.Data, packet2.Offset, num3);
list.Add(packet2);
stream.Seek(num3, SeekOrigin.Current);
}
if (stream.Position >= stream.Length)
{
stream.SetLength(0L);
stream.Position = 0L;
}


return list;
}
}
/<packet>/<packet>/<packet>/<code>

解碼核心代碼如下:即獲得每幀報文的長度,通過委託方法 GetLength(packet),然後循環所有粘包報文,根據每幀報文的長度分割保存到list中去,最後返回list。list的每個元素會觸發message接收事件。

委託的使用請敬請關注下一篇,委託代碼詳見6.

<code>    for (i = 0; i < pk.Total; i += num2)
{
Packet packet = pk.Slice(i);

num2 = GetLength(packet);

Console.WriteLine(" pk. GetLength(packet):{0}", num2);

if (num2 <= 0 || num2 > packet.Total)
{
break;
}
packet.Set(packet.Data, packet.Offset, num2);
list.Add(packet);
}
/<code>

5.3.4.4.將粘包處理結果進行打印

<code>    foreach (var item in list)
{
Console.WriteLine("粘包處理結果:{0}"BitConverter.ToString(item.ToArray()));
}
/<code>

5.3.5.清空粘包編碼器

該方法由NewLife.Net網絡庫調用,我們無需關心。

<code>    //
// 摘要:
// 連接關閉時,清空粘包編碼器
//
// 參數:
// context:
//
// reason:
public override bool Close(IHandlerContext contextstring reason)
{
IExtend extend = context.Owner as IExtend;
if (extend != null)
{
extend["Codec"] = null;
}
return base.Close(context, reason);
}
/<code>

5.3.6.完整拆分粘包解碼器代碼

<code>    // 摘要: 

// 長度字段作為頭部
//
public class StickPackageSplit : MessageCodec<packet>
{
//
// 摘要:
// 長度所在位置
public int Offset
{
get;
set;
} = 5;

//
// 摘要:
// 長度佔據字節數,1/2/4個字節,0表示壓縮編碼整數,默認2
public int Size
{
get;
set;
} = 2;


//
// 摘要:
// 過期時間,超過該時間後按廢棄數據處理,默認500ms
public int Expire
{
get;
set;
} = 500;


//
// 摘要:
// 編碼,此應用不需要編碼,只需解碼,
// 按長度將粘包劃分成多個數據包
//
// 參數:
// context:
//

// msg:
protected override object Encode(IHandlerContext context, Packet msg)
{
return msg;
}

//
// 摘要:
// 解碼
//
// 參數:
// context:
//
// pk:
protected override IList<packet> Decode(IHandlerContext context, Packet pk)
{
IExtend extend = context.Owner as IExtend;

LengthCodec packetCodec = extend["Codec"] as LengthCodec;


if (packetCodec == null)
{
IExtend extend2 = extend;
LengthCodec obj = new LengthCodec
{
Expire = Expire,
GetLength = ((Packet p) => MessageCodec<packet>.GetLength(p, Offset, Size))
};
packetCodec = obj;
extend2["Codec"] = obj;
}

Console.WriteLine("報文解碼前:{0}", BitConverter.ToString(pk.ToArray()));
IList<packet> list = packetCodec.Parse(pk);
Console.WriteLine("報文解碼");
foreach (var item in list)
{
Console.WriteLine("粘包處理結果:{0}", BitConverter.ToString(item.ToArray()));
}

return list;
}

//
// 摘要:
// 連接關閉時,清空粘包編碼器

//
// 參數:
// context:
//
// reason:
public override bool Close(IHandlerContext context, string reason)
{
IExtend extend = context.Owner as IExtend;
if (extend != null)
{
extend["Codec"] = null;
}
return base.Close(context, reason);
}
}
/<packet>/<packet>/<packet>/<packet>/<code>

6.長度計算委託GetLength

5.3.6中會調用如下每個包的長度計算委託。關於委託的使用方法會在下一篇講解,這裡不再展開。

<code>//
// 摘要:
// 從數據流中獲取整幀數據長度
//
// 參數:
// pk:
//
// offset:
//
// size:
//
// 返回結果:
// 數據幀長度(包含頭部長度位)
protected static int GetLength(Packet pk, int offsetint size)
{
if (offset < 0)
{
return pk.Total - pk.Offset;
}
int offset2 = pk.Offset;
if (offset >= pk.Total)

{
return 0;
}
int num = 0;
switch (size)
{
case 0:
{
MemoryStream stream = pk.GetStream();
if (offset > 0)
{
stream.Seek(offset, SeekOrigiCurrent);
}
num = stream.ReadEncodedInt();
num += (int)(stream.Position - offset);
break;
}
case 1:
num = pk[offset];
break;
case 2:
num = pk.ReadBytes(offset, 2).ToUInt16();
break;
case 4:
num = (int)pk.ReadBytes(offset, 4).ToUInt32;
break;
case -2:
num = pk.ReadBytes(offset, 2).ToUInt16(0isLittleEndian: false);
break;
case -4:
num = (int)pk.ReadBytes(offset, 4).ToUInt(0, isLittleEndian: false);
break;
default:
throw new NotSupportedException();
}
if (num > pk.Total)
{
return 0;
}
return num;
}
/<code>

7.最終粘包拆分效果圖

一夜搞懂——TCP基於NewLife.Net網絡庫的管道式幀長粘包處理方法

最後

多說一句,很多人學Python過程中會遇到各種煩惱問題,沒有人解答容易放棄。小編是一名python開發工程師,這裡有我自己整理了一套最新的python系統學習教程,包括從基礎的python腳本到web開發、爬蟲、數據分析、數據可視化、機器學習等。想要這些資料的可以關注小編,並在後臺私信小編:“01”即可領取。


分享到:


相關文章: