微软官方博客在去年发布了一篇 System.IO.Pipelines: High performance IO in .NET,这个东西高不高效我是比较不出来,但是它着实解决了以往在接收 NetworkStream 时算位置的困扰,用它写出来的程序简洁清晰许多。
以往我们接收 NetworkStream 时,由于 Buffer 的大小通常是固定的,而串流数据的长度是不固定的,所以我们在解析的时候,每一种协定就有一套计算读取位置的逻辑,这种逻辑通常脑袋是记不住的,当协定久久异动一次的时候,我们就要开始担心原本的解析逻辑是不是又要改了,相当痛苦。
下面是一个简单的范例,假定 Server 与 Client 之间约定了一组协定,数据的处理逻辑以 “包” 为基本的处理单位,Server 会将数据一包一包地传给 Client,以换行符号 n
作为分隔符号,遇到换行符号时就代表一包数据结束,一包数据的长度则不固定,过去我们处理这类需求的程序大概就像下面这样,需要去考虑到一个 Buffer 会有 0 ~ n 个换行符号。
// 取得 NetworkStream
var stream = client.GetStream();
// 声明数据缓冲区
var line = new List();
while (true)
{
// 设定 Buffer 长度
var buffer = new byte[10];
// 读取一个 Buffer 长度的数据
var numBytesRead = stream.Read(buffer, 0, buffer.Length);
int newlinePosition;
int numBytesConsumed = 0;
do
{
// 搜寻换行符号的位置
newlinePosition = Array.IndexOf(buffer, (byte)'n', numBytesConsumed);
if (newlinePosition >= 0)
{
// 将换行符号之间的数据放进缓冲区
line.Add(buffer.Skip(numBytesConsumed).Take(newlinePosition - numBytesConsumed).ToArray());
// 标记已经处理的数据长度
numBytesConsumed = newlinePosition + 1;
// 缓冲区内的数据已成一包,送给逻辑进程处理。
ProcessData(line.SelectMany(x => x).ToArray());
// 清空缓冲区
line.Clear();
}
else
{
// 将剩余的数据放进缓冲区
line.Add(buffer.Skip(numBytesConsumed).Take(numBytesRead - numBytesConsumed).ToArray());
}
}
while (newlinePosition >= 0);
}
如果没有注解,过一阵子之后,这段程序就变天书了,需要通灵才能唤回过去记忆,写类似这种按照协定读取串流数据的程序是挺痛苦的,但是 System.IO.Pipelines 把这件事变简单了,我们来看一下要怎么使用它?
顾名思义,Pipeline 就是管道,我们可以这样想像,有一根管子,管子的一头不停地往里面塞东西,管子的另外一头,可以依照自己想要的长度,一次地把东西从管子里面拿出来,因此我们的程序会分成两段,一段是不停地往管子里面写数据,一段是依照我们想要的逻辑从管子中读数据。
使用 System.IO.Pipelines 我们从 new 一个 Pipe
开始,Pipe 里面会有一个 Writer
属性及 Reader
属性,Writer 就是往管子写数据,Reader 就是从管子读数据。
var pipe = new Pipe();
var writer = pipe.Writer;
var reader = pipe.Reader;
往管子写数据
// 取得 NetworkStream
var stream = client.GetStream();
while (true)
{
// 设定 Buffer 长度
var buffer = new byte[10];
// 读取一个 Buffer 长度的数据
var numBytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (numBytesRead == 0) continue;
// 将数据写进 Pipe
var flushResult = await writer.WriteAsync(new ReadOnlyMemory(buffer.Take(numBytesRead).ToArray()));
if (flushResult.IsCompleted) break;
}
从 NetworkStream 读取数据的部分保持不变,然后调用 PipeWriter.WriteAsync()
方法将数据写入管子内。
从管子读数据
while (true)
{
// 读取管子内目前的数据状况
var result = await reader.ReadAsync();
var buffer = result.Buffer;
do
{
// 搜寻换行符号的位置
var position = buffer.PositionOf((byte)'n');
if (position == null) break;
// 将管子内的换行符号前的数据,送给逻辑进程处理。
ProcessData(buffer.Slice(0, position.Value));
// 从目前搜寻到的换行符号的下一个位置,再搜寻换行符号。
var next = buffer.GetPosition(1, position.Value);
buffer = buffer.Slice(next);
}
while (true);
// 标记管子内有多少数据已经被读取并处理,主要是释放管子的空间,让 Writer 可以重复利用。
// 有调用 ReadAsync() 就一定要调用 AdvanceTo(),即使没有处理到任何数据也是一样。
reader.AdvanceTo(buffer.Start, buffer.End);
}
这边就可以撰写我们解析数据的逻辑,可以看到我们不用再去算位置了,当符合规则时,直接把整段数据取出来使用,大大降低了程序的复杂性,而且 Pipe 是 Thread-Safe 的,Writer 跟 Reader 可以分开两个线程执行,不用担心同步的问题。
眉角:ReadResult.Buffer 的解读方式
如果我们的解析逻辑不是那么的单纯,难免需要对 ReadResult.Buffer
进行裁切,它的类型是 ReadOnlySequence
,里面有一个 Slice()
方法可以让我们来对 Buffer 做裁切,而且 Slice() 方法提供 9 个重载,已经足够我们使用了,而我常用到的是这两个方法:
public ReadOnlySequence Slice(SequencePosition start, SequencePosition end);
public ReadOnlySequence Slice(SequencePosition start, int length);
Buffer 有两个属性 Start
跟 End
代表 Buffer 的起迄位置,Start 跟 End 的类型是 SequencePosition
,这个东西跟以往我们熟悉的数组索引不一样,一开始没有弄清楚,结果裁切出来的数据不是我要的。
我们熟悉的数组及其索引是长这样的:
而 SequencePosition 这个东西要这样看:
如果上图是一个 ReadResult.Buffer,那么它的 Start 就是 0,End 就是 8,也因为这样的特性,所以有一些方法的参数或回传值是 SequencePosition 类型的,就要特别注意一下,像是 PositionOf()
扩充方法,以上图为例,如果我调用 PositionOf((byte)'d') 得到的 SequencePosition 值会是 3。
还有像是 PipeReader.AdvanceTo()
方法,它其实是帮我们在 Buffer 中,将标记已处理数据的位置移到参数所指定的位置,以上图为例,假设 SequencePosition 的值是 3,丢进 AdvanceTo() 方法之后,那么下一次 PipeReader 读取的结果 d
就会是第一个元素。
PositionOf() 扩充方法有用到 C# 7.2 才新增的语言特性,所以要调整项目的建置语言版本至少到 C# 7.2。