在协议实现中,常常需要用到Byte数组和Stream数据流,为了减少内存拷贝及GC回收,封装了数据包Packet类。Packet设计于2016年,最低支持.NET2.0/.NET4.0。.NETCore 2.1已经支持Span,正是Packet努力的目标,同时也表明Packet的设计方向正确无误!

Nuget包:NewLife.Core

源码:https://github.com/NewLifeX/X/tree/master/NewLife.Core/Data

视频:https://www.bilibili.com/video/BV14e4y1q7G9


核心原理

数据包Packet本质上就是对字节数组的一个包装,同时存储着这个字节数组的起始位置和有效长度,在切分新数据包时,实际上就是再次引用这个字节数组,仅改变起始位置和有效长度。理念上跟Span是一致的。

在现代计算机程序设计中,内存拷贝的损耗相当大,即使部分指令集支持SIMD,也仍然有不小的开销。

从以下字段属性能够看出其结构:

/// <summary>数据</summary>
public Byte[] Data { get; private set; }

/// <summary>偏移</summary>
public Int32 Offset { get; private set; }

/// <summary>长度</summary>
public Int32 Count { get; private set; }

/// <summary>下一个链式包</summary>
public Packet Next { get; set; }

/// <summary>总长度</summary>
public Int32 Total => Count + (Next != null ? Next.Total : 0);

Data 是数据数组,Offset 是起始位置,Count 是有效长度,该数据包表示的是Data中,从Offset开始共Count个字节的范围。

Next用于数据包级联,方便头部和负载数据分离的协议解析。


通过构造函数有多种形式得到数据包实例

/// <summary>根据数据区实例化</summary>
/// <param name="data"></param>
/// <param name="offset"></param>
/// <param name="count"></param>
public Packet(Byte[] data, Int32 offset = 0, Int32 count = -1) => Set(data, offset, count);

/// <summary>根据数组段实例化</summary>
/// <param name="seg"></param>
public Packet(ArraySegment<Byte> seg)
{
    if (seg.Array == null) throw new ArgumentNullException(nameof(seg));

    Set(seg.Array, seg.Offset, seg.Count);
}

/// <summary>从可扩展内存流实例化,尝试窃取内存流内部的字节数组,失败后拷贝</summary>
/// <remarks>因数据包内数组窃取自内存流,需要特别小心,避免多线程共用。常用于内存流转数据包,而内存流不再使用</remarks>
/// <param name="stream"></param>
public Packet(Stream stream)
{
    if (stream is MemoryStream ms)
    {
#if !NET45
        // 尝试抠了内部存储区,下面代码需要.Net 4.6支持
        if (ms.TryGetBuffer(out var seg))
        {
            if (seg.Array == null) throw new ArgumentNullException(nameof(seg));

            Set(seg.Array, seg.Offset + (Int32)ms.Position, seg.Count - (Int32)ms.Position);
            return;
        }
#endif
    }

    var buf = new Byte[stream.Length - stream.Position];
    var count = stream.Read(buf, 0, buf.Length);
    Set(buf, 0, count);

    // 必须确保数据流位置不变
    if (count > 0) stream.Seek(-count, SeekOrigin.Current);
}

最常用的是 data+offset+count,其次是 stream 构造。


获取设置

Packet提供了Set方法,用于更改内部数据区和偏移长度等。

/// <summary>设置新的数据区</summary>
/// <param name="data">数据区</param>
/// <param name="offset">偏移</param>
/// <param name="count">字节个数</param>
public virtual void Set(Byte[] data, Int32 offset = 0, Int32 count = -1);


Packet也支持按照索引去读写各个位置的字节数据,数据包的第n个位置,实际上就是Data的Offset+n位置。

/// <summary>获取/设置 指定位置的字节</summary>
/// <param name="index"></param>
/// <returns></returns>
public Byte this[Int32 index];


数据切片

Packet最重要的目标就是数据切片,创建一个新的Packet,但还是引用原来的字节数组。

/// <summary>截取子数据区</summary>
/// <param name="offset">相对偏移</param>
/// <param name="count">字节个数</param>
/// <returns></returns>
public Packet Slice(Int32 offset, Int32 count = -1);

级联搜索

Packet还支持目标字节数组的搜索,以及Next级联。数据级联,把数据包附加在另一个数据包后面,在拼接头尾分离协议时特别有效,避免频繁拷贝字节数组。

/// <summary>查找目标数组</summary>
/// <param name="data">目标数组</param>
/// <param name="offset">本数组起始偏移</param>
/// <param name="count">本数组搜索个数</param>
/// <returns></returns>
public Int32 IndexOf(Byte[] data, Int32 offset = 0, Int32 count = -1);

/// <summary>附加一个包到当前包链的末尾</summary>
/// <param name="pk"></param>
public Packet Append(Packet pk);


读取数据

Packet可以转为字节数组,ToArray用于把整个数据包变成一个字节数组,ReadBytes用于读取数据包某一段数据。

/// <summary>返回字节数组。无差别复制,一定返回新数组</summary>
/// <returns></returns>
public virtual Byte[] ToArray();

/// <summary>从封包中读取指定数据区,读取全部时直接返回缓冲区,以提升性能</summary>
/// <param name="offset">相对于数据包的起始位置,实际上是数组的Offset+offset</param>
/// <param name="count">字节个数</param>
/// <returns></returns>
public Byte[] ReadBytes(Int32 offset = 0, Int32 count = -1);


Packet也可以转为数据片段ArraySegment,或者数据片段集合,在网络发送时有用,避免拷贝。

/// <summary>返回数据段</summary>
/// <returns></returns>
public ArraySegment<Byte> ToSegment();

/// <summary>返回数据段集合</summary>
/// <returns></returns>
public IList<ArraySegment<Byte>> ToSegments();

/// <summary>转为Span</summary>
/// <returns></returns>
public Span<Byte> AsSpan();

/// <summary>转为Memory</summary>
/// <returns></returns>
public Memory<Byte> AsMemory();


Packet支持转为数据流,常用于序列化。

/// <summary>获取封包的数据流形式</summary>
/// <returns></returns>
public virtual MemoryStream GetStream();

/// <summary>把封包写入到数据流</summary>
/// <param name="stream"></param>
public void CopyTo(Stream stream);

/// <summary>异步复制到目标数据流</summary>
/// <param name="stream"></param>
/// <returns></returns>
public async Task CopyToAsync(Stream stream);


数据转换

Packet还可以按照字符串编码输出为字符串,或者以十六进制编码输出字符串

/// <summary>以字符串表示</summary>
/// <param name="encoding">字符串编码,默认URF-8</param>
/// <param name="offset"></param>
/// <param name="count"></param>
/// <returns></returns>
public String ToStr(Encoding encoding = null, Int32 offset = 0, Int32 count = -1);

/// <summary>以十六进制编码表示</summary>
/// <param name="maxLength">最大显示多少个字节。默认-1显示全部</param>
/// <param name="separate">分隔符</param>
/// <param name="groupSize">分组大小,为0时对每个字节应用分隔符,否则对每个分组使用</param>
/// <returns></returns>
public String ToHex(Int32 maxLength = 32, String separate = null, Int32 groupSize = 0);

/// <summary>转为Base64编码</summary>
/// <returns></returns>
public String ToBase64();


Packet支持协议解析中常见的读取无符号整数,注意大小端参数。比如Modbus中常用ReadUInt16(false)。

/// <summary>读取无符号短整数</summary>
/// <param name="isLittleEndian"></param>
/// <returns></returns>
public UInt16 ReadUInt16(Boolean isLittleEndian = true);

/// <summary>读取无符号整数</summary>
/// <param name="isLittleEndian"></param>
/// <returns></returns>
public UInt32 ReadUInt32(Boolean isLittleEndian = true);


写入数据

Packet支持向内部某个位置写入一段数据

/// <summary>把封包写入到目标数组</summary>
/// <param name="buffer"></param>
/// <param name="offset"></param>
/// <param name="count"></param>
public void WriteTo(Byte[] buffer, Int32 offset = 0, Int32 count = -1);


总结

使用数据包Packet需要特别注意的是,各个Packet引用同一个Data,其中一个修改数据,将会影响到其它Packet对象。

Packet已经成为NewLife开源项目体系里面的二进制数据标准,包括Redis和RPC框架等多个地方都有优待处理。

二进制序列化