本文讲解NewLife中二进制序列化的高级应用,是面向高级工程师和 AI 的零分配协议编解码架构指南。

核心目标:协议消息在 IPacket 数据包、NetworkStream 网络流、FileStream 文件流以及加密/压缩包装流中编解码时,全程零内存分配。

不计入的 GC:消息实例本身的 newString 解码产生的字符串对象、内存池首次内部扩容。


体系结构

┌─────────────────────────────────────────────────────┐
│  SpanSerializer (static)                            │
│  快捷方法: ToPacket / FromPacket / Serialize / ...   │
├─────────────────────────────────────────────────────┤
│  ISpanSerializable                                  │
│  消息契约: Write(ref SpanWriter) / Read(ref SpanReader)│
├──────────────────────┬──────────────────────────────┤
│  SpanWriter          │  SpanReader                  │
│  ref struct 写入器    │  ref struct 读取器            │
│  Span / Span+Stream  │  Span / IPacket / Stream     │
└──────────────────────┴──────────────────────────────┘

ISpanSerializable 定义消息体字段布局,由 SpanWriter/SpanReader 执行底层读写。SpanSerializer 在此基础上封装池化缓冲区管理和头部预留。

SpanReader 构造速查

构造函数

数据来源

零拷贝

自动补齐

典型场景

SpanReader(ReadOnlySpan<Byte>)

栈/堆内存

已知完整数据的解析

SpanReader(IPacket)

数据包

✓(单段)

NewLife 网络库收包后解码

SpanReader(Stream, IPacket?, bufferSize)

任意流

NetworkStream / FileStream

行为差异:

  • 单段 IPacket:直接在底层 Span 上读取,ReadPacket(len) 返回零拷贝切片
  • 链式 IPacketNext != null):构造时即转为流模式(data.GetStream(false)),后续与流构造行为一致
  • 流构造:内部维护 OwnerPacket 缓冲区,剩余字节不足时从流拉取新数据并重组;MaxCapacity 控制单次解析的总字节上限

SpanWriter 构造速查

构造函数

输出目标

溢出处理

典型场景

SpanWriter(Span<Byte>)

固定缓冲区

抛异常

已知长度上限

SpanWriter(IPacket)

数据包 Span

抛异常

写入现有包头

SpanWriter(Span<Byte>, Stream)

缓冲区 + 后备流

自动 Flush

NetworkStream / FileStream

行为差异:

  • 纯 Span 模式:空间不足直接抛 InvalidOperationException
  • 流模式:空间不足时先 Flush 已写入数据到流再重置 _indexWrite(ReadOnlySpan<Byte>) 支持超过缓冲区的大块数据分块刷流
  • 流模式约束:单次原子写入(如 Write(Int32))不能超过缓冲区容量
  • TotalWritten 属性反映含已 Flush 部分的总长度;Dispose() 自动调用 Flush()

ISpanSerializable 消息体

协议消息实现 ISpanSerializable,手写字段布局,零反射:

public sealed class DeviceReport : ISpanSerializable
{
    public UInt16 DeviceId { get; set; }
    public Int64 Timestamp { get; set; }
    public Double Temperature { get; set; }
    public String? Location { get; set; }

    public void Write(ref SpanWriter writer)
    {
        writer.Write(DeviceId);
        writer.Write(Timestamp);
        writer.Write(Temperature);
        writer.Write(Location, 0); // 7 位压缩长度前缀 + UTF-8
    }

    public void Read(ref SpanReader reader)
    {
        DeviceId = reader.ReadUInt16();
        Timestamp = reader.ReadInt64();
        Temperature = reader.ReadDouble();
        Location = reader.ReadString(); // length=0 → 7 位压缩长度
    }
}

规则:

  • 字段顺序即协议格式,Read/Write 必须严格对称,发布后不可调整
  • 字符串:Write(value, 0) + ReadString(0) = 7 位压缩长度前缀
  • 大块 payload:先写长度再 Write(ReadOnlySpan<Byte>),不要先复制到 Byte[]
  • 字节序:通过 writer.IsLittleEndian / reader.IsLittleEndian 控制,默认小端
  • Binary 类线格式完全兼容(IsLittleEndian 匹配时),可双向互读

IPacket 数据包模式

适用 NewLife 网络库。SessionBase.ProcessReceive 收到的 IPacket 是本次缓冲区数据,经管道(LengthFieldCodec / StandardCodec)成帧后为完整消息体。

编码

// 消息 → 数据包(池化缓冲区,小数据零拷贝切片返回)
using var pk = report.ToPacket();
session.Send(pk);

ToPacket(bufferSize, reserve) 内部机制:

  1. ArrayPool 租借 bufferSize 缓冲区,从 Pool.MemoryStream 租借后备流
  1. 缓冲区前方跳过 reserve(默认 32)字节,消息体从该偏移后开始写入
  1. 小数据路径:消息全部落入缓冲区 → pk.Slice(reserve, count) 零拷贝切片返回 OwnerPacket,后备流归还池
  1. 大数据路径:缓冲区满时自动 Flush 到后备流 → 从流包装新 OwnerPacket 返回

带帧头发送(利用预留区)

协议格式 [4 字节长度][消息体]

// ToFrame 内部完成 ToPacket(reserve) + OwnerPacket 扩展,一步到位
using var frame = report.ToFrame(4);

// 帧头区域 = GetSpan()[..4],正文长度 = Length - 4
var hw = new SpanWriter(frame.GetSpan()[..4]);
hw.Write(frame.Length - 4);

session.Send(frame);

ToFrame(headerSize) 等价于 ToPacket(reserve: headerSize) + new OwnerPacket(owner, headerSize)OwnerPacket(owner, expandSize)Offset 前移 expandSize 字节并转移所有权 —— 这是预留区设计的核心价值。

解码

// 管道已成帧 → 直接读
var report = new DeviceReport();
report.FromPacket(e.Packet);

// 原始协议包 → 拆帧后读
var reader = new SpanReader(e.Packet);
var bodyLen = reader.ReadInt32();
using var body = reader.ReadPacket(bodyLen); // 单段包零拷贝切片
var report2 = new DeviceReport();
report2.FromPacket(body);

零拷贝分析

操作

单段包

链式包

ReadPacket(len)

底层切片,零拷贝

跨段切片,零拷贝

ReadInt32() 等定长读取

直接 Span 访问

跨段时退化为流补齐(分配内部缓冲区)

FromPacket(pk) 扩展方法

单段直接取 Span

构造时转流

最佳实践:成帧层保证完整消息落在一个连续段上,或先 ReadPacket 取正文子包再交消息层。

携带负载的消息(IPacket Payload)

协议消息内嵌可变长度负载时,将 Payload 设计为 IPacket 类型,解码时 Slice 切片零拷贝获取,同时完成所有权转移:

public sealed class DataMessage : ISpanSerializable, IDisposable
{
    public Byte Command { get; set; }
    public UInt16 Sequence { get; set; }
    /// <summary>负载数据,持有切片的缓冲区所有权</summary>

    public IPacket? Payload { get; set; }

    public void Write(ref SpanWriter writer)
    {
        writer.Write(Command);
        writer.Write(Sequence);
        var body = Payload;
        var len = body?.Total ?? 0;
        writer.Write(len);
        if (body != null) writer.Write(body.GetSpan());
    }

    public void Read(ref SpanReader reader)
    {
        Command = reader.ReadByte();
        Sequence = reader.ReadUInt16();
        var len = reader.ReadInt32();
        // ReadPacket 对 OwnerPacket 底层包执行 Slice,所有权转移给 Payload
        // 原始包后续 Dispose 不会重复归还该段缓冲区
        if (len > 0) Payload = reader.ReadPacket(len);
    }

    /// <summary>释放负载数据,归还池化缓冲区</summary>

    public void Dispose() => Payload.TryDispose();
}

所有权流转:

网络收包 pk (OwnerPacket, 持有缓冲区)
  ↓ Slice(offset, len) — 所有权转移
msg.Payload (OwnerPacket 切片, 持有缓冲区)
  ↓ msg.Dispose() / Payload.TryDispose()
缓冲区归还 ArrayPool

关键规则:

  • Slice 默认 transferOwner: true,切片接管缓冲区所有权,原始包失去管理权
  • 消息生命周期结束时通过 Payload.TryDispose() 归还池化缓冲区
  • 若需跨线程/异步延迟使用 Payload,必须先深拷贝(Payload.ReadBytes()ToArray()
  • 编码时用 writer.Write(body.GetSpan()) 直接写入,不需要先转 Byte[]

参考实现:DefaultMessage.ReadPayload = pk.Slice(size, len, true) 即此模式。


Stream 流模式

适用 TcpClient.GetStream()FileStreamSslStreamGZipStreamCryptoStream 等任何 Stream。不使用 NewLife 网络库时的标准路径。

编码:消息 → 流

// stackalloc 缓冲区,适合小消息
Span<Byte> buf = stackalloc Byte[256];
using var writer = new SpanWriter(buf, stream);
report.Write(ref writer);
// Dispose 自动 Flush 剩余
// 池化缓冲区,适合较大消息
var buffer = Pool.Shared.Rent(4096);
try
{
    using var writer = new SpanWriter(buffer, stream);
    report.Write(ref writer);
}
finally
{
    Pool.Shared.Return(buffer);
}

带帧头写流(利用预留区)

与 IPacket 模式的帧头写法一致,使用 ToFrame 一步完成序列化和头部扩展:

using var frame = report.ToFrame(4);

// 填充帧头
var hw = new SpanWriter(frame.GetSpan()[..4]);
hw.Write(frame.Length - 4);

// 整帧一次写入流
frame.CopyTo(stream);

带帧头写流(仅限可寻址流,如 FileStream)

// 记录帧头位置,先写占位
var headerPos = stream.Position;
Span<Byte> placeholder = stackalloc Byte[4];
stream.Write(placeholder);

// 写消息体
var buffer = Pool.Shared.Rent(4096);
try
{
    using var writer = new SpanWriter(buffer, stream);
    report.Write(ref writer);
}
finally
{
    Pool.Shared.Return(buffer);
}

// 回填帧头
var bodyLen = (Int32)(stream.Position - headerPos - 4);
stream.Position = headerPos;
var hw = new SpanWriter(placeholder);
hw.Write(bodyLen);
stream.Write(placeholder);
stream.Seek(0, SeekOrigin.End);

NetworkStream 不可寻址,禁止使用回填方案。

解码:流 → 消息

var reader = new SpanReader(stream, bufferSize: 1024)
{
    MaxCapacity = 64 * 1024  // 单帧上限,必须设置
};

var report = new DeviceReport();
report.Read(ref reader);

带帧头解码

var reader = new SpanReader(stream, bufferSize: 1024)
{
    MaxCapacity = 64 * 1024
};

var bodyLen = reader.ReadInt32();
var start = reader.Position;

var report = new DeviceReport();
report.Read(ref reader);

if (reader.Position - start != bodyLen)
    throw new InvalidDataException("帧长度不匹配");

混合模式:初始包 + 流

已收到部分头部数据,后续正文需从流继续拉取:

var reader = new SpanReader(stream, headerPacket, bufferSize: 8192);
report.Read(ref reader);

各类 Stream 注意事项

流类型

特征

注意

NetworkStream

不可寻址、不可回退

禁止回填长度

SslStream

同 NetworkStream

禁止回填长度

FileStream

可寻址

支持 Flush 后回填帧头

GZipStream / DeflateStream

不可寻址、压缩后长度不可预知

适合纯流顺序写

CryptoStream

不可寻址

同压缩流


SpanSerializer 快捷方法

方法

作用

零反射

备注

msg.ToPacket(bufferSize, reserve)

ISpanSerializableIOwnerPacket

池化双路径

msg.ToFrame(headerSize, bufferSize)

序列化 + 扩展帧头区域

帧头需调用方填充

msg.FromPacket(packet)

IPacket → 填充已有实例

返回消费字节数

msg.FromSpan(data)

ReadOnlySpan<Byte> → 填充已有实例

返回消费字节数

SpanSerializer.Serialize(obj)

任意对象 → IOwnerPacket

✗(编译缓存)

HeaderReserve 预留

SpanSerializer.Serialize(obj, span)

任意对象 → 写入指定 Span

✗(编译缓存)

返回写入字节数

SpanSerializer.Deserialize<T>(data)

ReadOnlySpan<Byte> → 新实例

✗(编译缓存)

SpanSerializer.Deserialize<T>(packet)

IPacket → 新实例

✗(编译缓存)

单段直接取 Span

普通对象路径首次反射后编译为 Expression 委托缓存,后续调用无反射开销。不支持 ListDictionary 等集合的自动序列化 —— 协议含集合时须在 ISpanSerializable 中手写。


AI 架构准则

用这套体系重构目标项目时遵循以下原则:

  1. 协议消息统一实现 ISpanSerializable,禁止业务层手动拼接 Byte[]new MemoryStream
  1. 帧头(长度、魔数、序列号、CRC)与消息体分离:ISpanSerializable 只管正文,帧编解码独立
  1. NewLife.Net 管道已完成成帧时,业务层只处理完整消息体的 IPacket
  1. 非可寻址流(NetworkStreamSslStream、压缩/加密流)的协议设计顺序写出,不依赖回填
  1. 大 payload 用长度前缀 + Write(ReadOnlySpan<Byte>) / ReadBytes(len) 直写直读
  1. 链式 IPacket 在成帧层切出正文子包再交消息层,避免跨段读取基础类型
  1. 每个流入口设置 MaxCapacity 上限,防御恶意或错误数据
  1. 极端吞吐场景延迟 String 物化,保留原始字节段操作

约束

  • SpanReader/SpanWriterref struct:不能跨 await、不能存为字段、不能放入闭包
  • ISpanSerializable.Read/Write 必须严格对称,字段顺序和类型不一致会导致数据错位
  • SpanSerializer 自动序列化不支持集合/字典类型
  • String 解码必然产生堆对象(GC),无法避免
  • 流模式零业务分配 ≠ 零拷贝;真正零拷贝需要单段 IPacket 切片路径