Magicodes.IE.IO:IBufferWriter<byte>——为什么 XLSX 写入不直接使用 Stream

IBufferWriter<byte> 不是"更快的 Stream",而是另一种写入契约。本文从 GetSpanAdvanceBufferWriterStream 适配器出发,说明它何时能减少临时分配,何时仍然存在复制。

如果你写过一点高性能 .NET 代码,大概率见过 IBufferWriter<T> 这个接口。它在 ASP.NET Core、Kestrel、gRPC 等基础设施里经常出现,但很多业务开发者对它比较陌生。

下面直接从 Magicodes.IE.IO 的写入路径出发,说明它和 Streambyte[] 分别解决什么问题。

先看一个缓冲写入场景

假设你要往一个缓冲区写一些字节。最常见的写法是这样:

csharp 复制代码
byte[] buffer = new byte[1024];
int pos = 0;
// 每次要写东西,先确保空间,再拷贝
Array.Copy(source, 0, buffer, pos, source.Length);
pos += source.Length;

问题在哪?new byte[1024] 是每次都新建的。如果这段代码位于逐行写入的高频路径,重复分配会进入 GC 成本的一部分。你可能会说"用 ArrayPool"------对,但 ArrayPool 怎么和现有的写入 API 配合?传统的 Stream.Write(byte[], int, int) 需要调用方先准备好一块数组;现代 Stream 也提供 Span/Memory 重载,但数据仍然要由调用方准备。

IBufferWriter<byte> 就是为解决这个配合问题而生的。

IBufferWriter 的三个方法

接口只有三个成员:

csharp 复制代码
public interface IBufferWriter<T>
{
    void Advance(int count);
    Memory<T> GetMemory(int sizeHint = 0);
    Span<T> GetSpan(int sizeHint = 0);
}

关键是 GetSpan()GetMemory()------它们直接把内部缓冲区的 Span<T>/Memory<T> 暴露给你 。你可以直接往这块内存上写,写完了调 Advance(count) 告诉它你写了多少。

当生产者直接取得 GetSpan() 并在目标区域写入时,可以避免为该片段额外创建中间数组。若调用方手中已经有一段源数据,复制到 writer 提供的目标区域仍然存在;IBufferWriter<byte> 解决的是目标缓冲区的所有权和复用问题,不是让所有调用链天然零拷贝。

我们的 ByteBufferWriter

Magicodes.IE.IO 里的 ByteBufferWriterIBufferWriter<byte> 的一个实现。它内部用 ArrayPool<byte> 租用缓冲区,在写入管线中承担局部 XML 字节积累,并对外暴露 GetSpan

csharp 复制代码
public Span<byte> GetSpan(int sizeHint = 0)
{
    EnsureCapacity(sizeHint);
    return _buffer.AsSpan(_pos);
}

调用方拿到 Span<byte>,直接在上面写:

csharp 复制代码
public void WriteUtf8(ReadOnlySpan<byte> utf8)
{
    int len = utf8.Length;
    if (_pos + len > _buffer.Length)
        EnsureCapacity(len);
    utf8.CopyTo(_buffer.AsSpan(_pos));
    _pos += len;
}

注意这里没有 new byte[],没有 Array.Copy 创建新数组。utf8 的字节直接 CopyTo 到池化缓冲区。

为什么不直接用 Stream

Stream 抽象的是"字节流",它的 Write(byte[] buffer, int offset, int count) 要求调用方提供一个已经存在的 byte[]。这个 byte[] 要么是调用方 new 的(分配),要么是从别处拷贝过来的(拷贝)。

IBufferWriter<byte> 抽象的是"可写的缓冲区",它把目标内存交给写入方使用。直接面向这个接口写入时,可以避免为每个片段创建中间数组;但它不等于任何场景都零拷贝,写入方如果本身已经有一块输入缓冲区,仍然需要把数据复制到 writer 提供的目标区域。

Stream 由调用方提供源数据;IBufferWriter<byte> 由目标方提供可写区域,调用方提交已写长度。

把 IBufferWriter 当 Stream 用

有时候我们的代码已经写成了接受 Stream 的 API(比如 .NET 的很多标准接口都是 Stream)。这时候需要一个适配器,把 IBufferWriter<byte> 包装成一个 Stream------这就是 BufferWriterStream

csharp 复制代码
public override void Write(byte[] buffer, int offset, int count)
{
    EnsureNotDisposed();
    var dest = _writer.GetSpan(count);   // 直接拿缓冲区 Span
    buffer.AsSpan(offset, count).CopyTo(dest);
    _writer.Advance(count);
}

BufferWriterStream 继承自 Stream,但对 Write 的实现是:从 IBufferWriter<byte> 拿一块 Span,把传入的数据 CopyTo 过去,然后 Advance。它的价值是兼容只接受 Stream 的上层组件,并把最终目标放在 writer 的缓冲区中;这条适配路径仍有一次复制,不能称为零拷贝。

比如 Xlsx.Write(IBufferWriter<byte> output, ...) 这个重载,就是用一个 BufferWriterStream 把用户的 IBufferWriter<byte> 包成 Stream 喂给引擎:

csharp 复制代码
XlsxWritePipeline.Run(new BufferWriterStream(output), data, configure, options);

用户的 IBufferWriter<byte>(比如某个响应缓冲区)直接收到字节,不经过 MemoryStream 这种中间层。

结尾

IBufferWriter<byte> 是 .NET 高性能 I/O 的一个基础抽象。它的价值不在于三个方法本身,而在于把"取得可写区域"和"提交已写长度"这两个动作固定下来,让调用方可以复用目标缓冲区,少做临时数组和中间拷贝。

Magicodes.IE.IO 里有几层写入围绕这个接口组织:ByteBufferWriter 实现它,BufferWriterStream 负责适配,上层的 XML 拼接和 ZIP 写入最终通过这些缓冲区落地。它不显眼,但能减少写入过程中的中间物化。