解决MySql布尔型新旧版本兼容问题,采用枚举来表示布尔型的数据表。由正向工程赋值
|
# 高级二进制序列化
面向高级工程师和 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 构造速查
| 构造函数 | 数据来源 | 零拷贝 | 自动补齐 | 典型场景 |
|---------|---------|:------:|:------:|---------|
| `SpanReader(ReadOnlySpan<Byte>)` | 栈/堆内存 | ✓ | ✗ | 已知完整数据的解析 |
| `SpanReader(IPacket)` | 数据包 | ✓(单段) | ✗ | NewLife 网络库收包后解码 |
| `SpanReader(Stream, IPacket?, bufferSize)` | 任意流 | ✗ | ✓ | NetworkStream / FileStream |
行为差异:
- **单段 `IPacket`**:直接在底层 Span 上读取,`ReadPacket(len)` 返回零拷贝切片
- **链式 `IPacket`**(`Next != null`):构造时即转为流模式(`data.GetStream(false)`),后续与流构造行为一致
- **流构造**:内部维护 `OwnerPacket` 缓冲区,剩余字节不足时从流拉取新数据并重组;`MaxCapacity` 控制单次解析的总字节上限
### SpanWriter 构造速查
| 构造函数 | 输出目标 | 溢出处理 | 典型场景 |
|---------|---------|---------|---------|
| `SpanWriter(Span<Byte>)` | 固定缓冲区 | 抛异常 | 已知长度上限 |
| `SpanWriter(IPacket)` | 数据包 Span | 抛异常 | 写入现有包头 |
| `SpanWriter(Span<Byte>, Stream)` | 缓冲区 + 后备流 | 自动 Flush | NetworkStream / FileStream |
行为差异:
- **纯 Span 模式**:空间不足直接抛 `InvalidOperationException`
- **流模式**:空间不足时先 Flush 已写入数据到流再重置 `_index`;`Write(ReadOnlySpan<Byte>)` 支持超过缓冲区的大块数据分块刷流
- **流模式约束**:单次原子写入(如 `Write(Int32)`)不能超过缓冲区容量
- `TotalWritten` 属性反映含已 Flush 部分的总长度;`Dispose()` 自动调用 `Flush()`
---
## ISpanSerializable 消息体
协议消息实现 `ISpanSerializable`,手写字段布局,零反射:
```csharp
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`)成帧后为完整消息体。
### 编码
```csharp
// 消息 → 数据包(池化缓冲区,小数据零拷贝切片返回)
using var pk = report.ToPacket();
session.Send(pk);
```
`ToPacket(bufferSize, reserve)` 内部机制:
1. 从 `ArrayPool` 租借 `bufferSize` 缓冲区,从 `Pool.MemoryStream` 租借后备流
2. 缓冲区前方跳过 `reserve`(默认 32)字节,消息体从该偏移后开始写入
3. **小数据路径**:消息全部落入缓冲区 → `pk.Slice(reserve, count)` 零拷贝切片返回 `OwnerPacket`,后备流归还池
4. **大数据路径**:缓冲区满时自动 Flush 到后备流 → 从流包装新 `OwnerPacket` 返回
#### 带帧头发送(利用预留区)
协议格式 `[4 字节长度][消息体]`:
```csharp
// 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` 字节并转移所有权 —— 这是预留区设计的核心价值。
### 解码
```csharp
// 管道已成帧 → 直接读
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` 切片零拷贝获取,同时完成所有权转移:
```csharp
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 网络库时的标准路径。
### 编码:消息 → 流
```csharp
// stackalloc 缓冲区,适合小消息
Span<Byte> buf = stackalloc Byte[256];
using var writer = new SpanWriter(buf, stream);
report.Write(ref writer);
// Dispose 自动 Flush 剩余
```
```csharp
// 池化缓冲区,适合较大消息
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` 一步完成序列化和头部扩展:
```csharp
using var frame = report.ToFrame(4);
// 填充帧头
var hw = new SpanWriter(frame.GetSpan()[..4]);
hw.Write(frame.Length - 4);
// 整帧一次写入流
frame.CopyTo(stream);
```
#### 带帧头写流(仅限可寻址流,如 FileStream)
```csharp
// 记录帧头位置,先写占位
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` 不可寻址,禁止使用回填方案。
### 解码:流 → 消息
```csharp
var reader = new SpanReader(stream, bufferSize: 1024)
{
MaxCapacity = 64 * 1024 // 单帧上限,必须设置
};
var report = new DeviceReport();
report.Read(ref reader);
```
#### 带帧头解码
```csharp
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("帧长度不匹配");
```
#### 混合模式:初始包 + 流
已收到部分头部数据,后续正文需从流继续拉取:
```csharp
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)` | `ISpanSerializable` → `IOwnerPacket` | ✓ | 池化双路径 |
| `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` 委托缓存,后续调用无反射开销。不支持 `List`、`Dictionary` 等集合的自动序列化 —— 协议含集合时须在 `ISpanSerializable` 中手写。
---
## AI 架构准则
用这套体系重构目标项目时遵循以下原则:
1. 协议消息统一实现 `ISpanSerializable`,禁止业务层手动拼接 `Byte[]` 或 `new MemoryStream`
2. 帧头(长度、魔数、序列号、CRC)与消息体分离:`ISpanSerializable` 只管正文,帧编解码独立
3. NewLife.Net 管道已完成成帧时,业务层只处理完整消息体的 `IPacket`
4. 非可寻址流(`NetworkStream`、`SslStream`、压缩/加密流)的协议设计顺序写出,不依赖回填
5. 大 payload 用长度前缀 + `Write(ReadOnlySpan<Byte>)` / `ReadBytes(len)` 直写直读
6. 链式 `IPacket` 在成帧层切出正文子包再交消息层,避免跨段读取基础类型
7. 每个流入口设置 `MaxCapacity` 上限,防御恶意或错误数据
8. 极端吞吐场景延迟 `String` 物化,保留原始字节段操作
---
## 约束
- `SpanReader`/`SpanWriter` 是 `ref struct`:不能跨 `await`、不能存为字段、不能放入闭包
- `ISpanSerializable.Read`/`Write` 必须严格对称,字段顺序和类型不一致会导致数据错位
- `SpanSerializer` 自动序列化不支持集合/字典类型
- `String` 解码必然产生堆对象(GC),无法避免
- 流模式零业务分配 ≠ 零拷贝;真正零拷贝需要单段 `IPacket` 切片路径
|