从 ReadOnlySequence<byte> 解析 UTF8 字符串

11
如何从ReadOnlySequence解析UTF8字符串?
ReadOnlySequence是由多个部分组成的,由于UTF8字符的长度可变,因此在部分中断时可能正好断在某个字符的中间。 因此,仅仅在这些部分上使用Encoding.UTF8.GetString()并将其组合到StringBuilder中将不起作用。
是否可能在不先将其组合成数组的情况下从ReadOnlySequence解析UTF8字符串?我希望避免内存分配。

你尝试过Utf8Parser吗?https://learn.microsoft.com/en-us/dotnet/api/system.buffers.text.utf8parser?view=netstandard-2.1 - JohanP
是的,我听到你了。我想一个选择是从数组池租用一个共享数组,将序列复制到其中,然后进行转换。至少你不需要为数组分配内存空间。 - JohanP
你可以这样写:var originalString = Encoding.UTF8.GetString([ReadOnlySequence].ToArray()); 或者对于 C# < 7.2,可以这样写:var seq = [ReadOnlySequence].GetPosition(0); var sOK = [ReadOnlySequence].TryGet(ref seq, out ReadOnlyMemory<byte> bytes); string originalString = Encoding.UTF8.GetString(bytes.ToArray()); - Jimi
1
@jimi 当然可以使用ToArray(),如果你想要分配一个巨大的数组。但我不能承受这样做带来的性能损失。ReadOnlySequence的整个意义在于避免内存分配。 - trampster
4个回答

10

看起来.NET 5.0引入了EncodingExtensions.GetString来解决这个问题。

使用指定的编码将指定的ReadOnlySequence解码为字符串。

using System.Text;

string message = EncodingExtensions.GetString(Encoding.UTF8, buffer);

谢谢,这个很有用!它是扩展方法,所以可以像任何类型的扩展一样调用:string message = Encoding.UTF8.GetString(buffer); - Rafał Kopczyński

6
我们需要做的第一件事是测试该序列是否实际上是一个单一的跨度; 如果是,我们可以极大地简化和优化。
一旦我们知道我们有一个多段(不连续)缓冲区,我们可以采取两种方式:
1.将段线性化为连续缓冲区,可能会从ArrayPool.Shared租用一个超大的缓冲区,并在租用的缓冲区的正确部分上使用UTF8.GetString,或者 2. 使用编码上的 GetDecoder() API,并使用它来填充一个新字符串,在较旧的框架中这意味着覆盖新分配的字符串,在较新的框架中则使用 string.Create API
第一种选择非常简单,但涉及一些内存复制操作(除了字符串之外没有其他额外的分配):
public static string GetString(in this ReadOnlySequence<byte> payload,
    Encoding encoding = null)
{
    encoding ??= Encoding.UTF8;
    return payload.IsSingleSegment ? encoding.GetString(payload.FirstSpan)
        : GetStringSlow(payload, encoding);

    static string GetStringSlow(in ReadOnlySequence<byte> payload, Encoding encoding)
    {
        // linearize
        int length = checked((int)payload.Length);
        var oversized = ArrayPool<byte>.Shared.Rent(length);
        try
        {
            payload.CopyTo(oversized);
            return encoding.GetString(oversized, 0, length);
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(oversized);
        }
    }
}

如果payload.Length大于2GB怎么办?还有值得注意的是,这将是摊销分配免费的,仅适用于payload.Length小于2MB的情况。超出此范围,ArrayPool(至少目前的实现)仍然会分配一个数组。 - ahsonkhan
1
@ahsonkhan 如果有效载荷超过2GiB,那么你需要创建的字符串可能超过4GiB(由于多字节UTF8与UTF16带代理对等原因,确切长度难以确定)。所以...祝你好运!至于超过数组池大小的情况:“选项2”。 - Marc Gravell

2
您可以选择使用一个解码器(Decoder)。具体实现方式如下:
var decoder = Encoding.UTF8.GetDecoder();
var sb = new StringBuilder();
var processed = 0L;
var total = bytes.Length;
foreach (var i in bytes)
{
    processed += i.Length;
    var isLast = processed == total;
    var span = i.Span;
    var charCount = decoder.GetCharCount(span, isLast);
    Span<char> buffer = stackalloc char[charCount];
    decoder.GetChars(span, buffer, isLast);
    sb.Append(buffer);
}

来自文档:

Decoder.GetChars方法将字节的连续块转换为字符的连续块,类似于该类的GetChars方法。但是,解码器在调用之间保持状态信息,因此它可以正确地解码跨越块的字节序列。解码器还保留数据块末尾的尾随字节,并在下一次解码操作中使用这些尾随字节。因此,GetDecoder和GetEncoder对于网络传输和文件操作非常有用,因为这些操作通常涉及数据块而不是完整的数据流。

当然,使用StringBuilder会引入新的内存分配来源,但如果有问题,您可以用其他类型的缓冲区替换它。


3
这是一个非常糟糕的实现,坦率地说;它使用stackalloc(a)未限制大小,(b)在循环中 - 任何一个(a)或(b)都足以引起警惕 - 并且它分配了大量不必要的中间数组,带有大量额外的复制(在数组和StringBuilder内部选择执行的操作之间)。 - Marc Gravell

0

警告:未经测试

我对官方答案进行了改进:

  • 打包为扩展方法
  • 通过预先分配超估计的字符数组,消除了对StringBuilder的需求
  • 通过使用单个大数组,总结由GetChars发现的总字符数,并移动目标跨度片段,消除了额外的GetCharCount步骤
  • 重命名一些变量。对我来说,preProcessedBytes特别重要,在我的看法中,在派生字符之后才被处理。
  • 使用stringLengthEstimate参数,以便在协议存储了字符串长度(以字符计数为单位)作为UTF8字节之前的头部时可以使用此参数

以下是源代码:

/// <summary>
/// Parses UTF8 characters in the ReadOnlySequence
/// </summary>
/// <param name="slice">Aligned slice of ReadOnlySequence that contains the UTF8 string bytes. Use slice before calling this function to ensure you have an aligned slice.</param>
/// <param name="stringLengthEstimate">The amount of characters in the final string. You should use a header before the string bytes for the best accuracy. If you are not sure -1 means that the most pessimistic estimate will be used: slice.Length</param>
/// <returns>a string parsed from the bytes in the ReadOnlySequence</returns>
public static string ParseAsUTF8String(this ReadOnlySequence<byte> slice, int stringLengthEstimate = -1)
{
    if (stringLengthEstimate == -1)
        stringLengthEstimate = (int)slice.Length; //overestimate
    var decoder = Encoding.UTF8.GetDecoder();
    var preProcessedBytes = 0;
    var processedCharacters = 0;
    Span<char> characterSpan = stackalloc char[stringLengthEstimate]; 
    foreach (var memory in slice)
    {
        preProcessedBytes += memory.Length;
        var isLast = (preProcessedBytes == slice.Length);
        var emptyCharSlice = characterSpan.Slice(processedCharacters, characterSpan.Length - processedCharacters);
        var charCount = decoder.GetChars(memory.Span, emptyCharSlice, isLast);
        processedCharacters += charCount;
    }
    var finalCharacters = characterSpan.Slice(0, processedCharacters);
    return new string(finalCharacters);
}

2
stackalloc 一个可能无限大小的分配是一个非常糟糕的想法。 - Marc Gravell
@marcgravell 同意。可以进行最大边界检查并回退到基于堆的字符数组。 - Kind Contributor

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接