本文讲解NewLife中二进制序列化的高级应用,是面向高级工程师和 AI 的零分配协议编解码架构指南。
核心目标:协议消息在 IPacket 数据包、NetworkStream 网络流、FileStream 文件流以及加密/压缩包装流中编解码时,全程零内存分配。
不计入的 GC:消息实例本身的 new、String 解码产生的字符串对象、内存池首次内部扩容。
体系结构
┌─────────────────────────────────────────────────────┐
│ 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 构造速查
构造函数 | 数据来源 | 零拷贝 | 自动补齐 | 典型场景 |
| 栈/堆内存 | ✓ | ✗ | 已知完整数据的解析 |
| 数据包 | ✓(单段) | ✗ | NewLife 网络库收包后解码 |
| 任意流 | ✗ | ✓ | NetworkStream / FileStream |
行为差异:
- 单段
IPacket:直接在底层 Span 上读取,ReadPacket(len)返回零拷贝切片
- 链式
IPacket(Next != null):构造时即转为流模式(data.GetStream(false)),后续与流构造行为一致
- 流构造:内部维护
OwnerPacket缓冲区,剩余字节不足时从流拉取新数据并重组;MaxCapacity控制单次解析的总字节上限
SpanWriter 构造速查
构造函数 | 输出目标 | 溢出处理 | 典型场景 |
| 固定缓冲区 | 抛异常 | 已知长度上限 |
| 数据包 Span | 抛异常 | 写入现有包头 |
| 缓冲区 + 后备流 | 自动 Flush | NetworkStream / FileStream |
行为差异:
- 纯 Span 模式:空间不足直接抛
InvalidOperationException
- 流模式:空间不足时先 Flush 已写入数据到流再重置
_index;Write(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) 内部机制:
- 从
ArrayPool租借bufferSize缓冲区,从Pool.MemoryStream租借后备流
- 缓冲区前方跳过
reserve(默认 32)字节,消息体从该偏移后开始写入
- 小数据路径:消息全部落入缓冲区 →
pk.Slice(reserve, count)零拷贝切片返回OwnerPacket,后备流归还池
- 大数据路径:缓冲区满时自动 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);零拷贝分析
操作 | 单段包 | 链式包 |
| 底层切片,零拷贝 | 跨段切片,零拷贝 |
| 直接 Span 访问 | 跨段时退化为流补齐(分配内部缓冲区) |
| 单段直接取 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.Read 中 Payload = pk.Slice(size, len, true) 即此模式。
Stream 流模式
适用 TcpClient.GetStream()、FileStream、SslStream、GZipStream、CryptoStream 等任何 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 | 禁止回填长度 |
| 可寻址 | 支持 Flush 后回填帧头 |
| 不可寻址、压缩后长度不可预知 | 适合纯流顺序写 |
| 不可寻址 | 同压缩流 |
SpanSerializer 快捷方法
方法 | 作用 | 零反射 | 备注 |
|
| ✓ | 池化双路径 |
| 序列化 + 扩展帧头区域 | ✓ | 帧头需调用方填充 |
|
| ✓ | 返回消费字节数 |
|
| ✓ | 返回消费字节数 |
| 任意对象 → | ✗(编译缓存) |
|
| 任意对象 → 写入指定 Span | ✗(编译缓存) | 返回写入字节数 |
|
| ✗(编译缓存) | — |
|
| ✗(编译缓存) | 单段直接取 Span |
普通对象路径首次反射后编译为 Expression 委托缓存,后续调用无反射开销。不支持 List、Dictionary 等集合的自动序列化 —— 协议含集合时须在 ISpanSerializable 中手写。
AI 架构准则
用这套体系重构目标项目时遵循以下原则:
- 协议消息统一实现
ISpanSerializable,禁止业务层手动拼接Byte[]或new MemoryStream
- 帧头(长度、魔数、序列号、CRC)与消息体分离:
ISpanSerializable只管正文,帧编解码独立
- NewLife.Net 管道已完成成帧时,业务层只处理完整消息体的
IPacket
- 非可寻址流(
NetworkStream、SslStream、压缩/加密流)的协议设计顺序写出,不依赖回填
- 大 payload 用长度前缀 +
Write(ReadOnlySpan<Byte>)/ReadBytes(len)直写直读
- 链式
IPacket在成帧层切出正文子包再交消息层,避免跨段读取基础类型
- 每个流入口设置
MaxCapacity上限,防御恶意或错误数据
- 极端吞吐场景延迟
String物化,保留原始字节段操作
约束
SpanReader/SpanWriter是ref struct:不能跨await、不能存为字段、不能放入闭包
ISpanSerializable.Read/Write必须严格对称,字段顺序和类型不一致会导致数据错位
SpanSerializer自动序列化不支持集合/字典类型
String解码必然产生堆对象(GC),无法避免
- 流模式零业务分配 ≠ 零拷贝;真正零拷贝需要单段
IPacket切片路径