【C#】 圧縮ストリームの読み取りに苦戦した話

# はじめに

`System.IO.Compression.ZipArchive` などの**圧縮ストリーム**の `Read()` メソッドは、１回でバッファを読み取りきれないことがあります。

```cs
var want = (stackalloc byte[8192]);
// 乱数で適当なデータを生成
new Random(0).NextBytes(want);

using var stream = new MemoryStream();
using (var writeZip = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
    var entry = writeZip.CreateEntry("data");
    using var entryStream = entry.Open();
    entryStream.Write(want);
}

stream.Position = 0;

var got = (stackalloc byte[want.Length]);
using (var readZip = new ZipArchive(stream, ZipArchiveMode.Read))
{
    var entry = readZip.GetEntry("data");
    using var entryStream = entry!.Open();
    // Read() の戻り値は読み取ったバイト数
    // ストリームの終わりに達した場合は 0 が返る
    // 途中でストリームの終わりに達した場合は、読み取ったバイト数が返る
    // ストリームの種類によっては読み取りの途中でもバッファ長以外の数値が返る
    _ = entryStream.Read(got);
}

// データが一致しないことを確認
Assert.False(want.SequenceEqual(got));
```

## サンプルコード

<details>
<summary>サンプルコード</summary>

```cs
using System.IO.Compression;
using Xunit;

public class _CompressionStreamReadTest
{
    /// <summary>
    /// ストリームからバッファを読み取る<br/>
    /// バッファの長さ分を読み取るまで反復的に読み取る
    /// </summary>
    /// <param name="stream"></param>
    /// <param name="buffer"></param>
    /// <returns></returns>
    public static int ReadBuffer(Stream stream, Span<byte> buffer)
    {
        var read = stream.Read(buffer);
        if (read <= 0 || read == buffer.Length)
            return read;

        var written = read;
        for (int n = 0; n < 10; ++n)
        {
            var buffer2 = buffer[written..];
            read = stream.Read(buffer2);
            if (read <= 0)
                return written;

            written += read;
            if (written == buffer.Length)
                return written;
        }

        return written;
    }

    [Fact]
    void CompressionRead()
    {
        var want = (stackalloc byte[8192]);
        // 乱数で適当なデータを生成
        new Random(0).NextBytes(want);

        using var stream = new MemoryStream();
        using (var writeZip = new ZipArchive(stream, ZipArchiveMode.Create, true))
        {
            var entry = writeZip.CreateEntry("data");
            using var entryStream = entry.Open();
            entryStream.Write(want);
        }

        stream.Position = 0;

        var got = (stackalloc byte[want.Length]);
        using (var readZip = new ZipArchive(stream, ZipArchiveMode.Read))
        {
            var entry = readZip.GetEntry("data");
            using var entryStream = entry!.Open();
            _ = entryStream.Read(got);
        }

        // データが一致しないことを確認
        Assert.False(want.SequenceEqual(got));
    }

    [Fact]
    void CompressionReadBuffer()
    {
        var want = (stackalloc byte[8192]);
        // 乱数で適当なデータを生成
        new Random(0).NextBytes(want);

        using var stream = new MemoryStream();
        using (var writeZip = new ZipArchive(stream, ZipArchiveMode.Create, true))
        {
            var entry = writeZip.CreateEntry("data");
            using var entryStream = entry.Open();
            entryStream.Write(want);
        }

        stream.Position = 0;

        var got = (stackalloc byte[want.Length]);
        using (var readZip = new ZipArchive(stream, ZipArchiveMode.Read))
        {
            var entry = readZip.GetEntry("data");
            using var entryStream = entry!.Open();
            ReadBuffer(entryStream, got);
        }

        Assert.Equal(want, got);
    }
}
```

</details>

## ストリームからバッファを読み取るコード

```cs
/// <summary>
/// ストリームからバッファを読み取る<br/>
/// バッファの長さ分を読み取るまで反復的に読み取る
/// </summary>
/// <param name="stream"></param>
/// <param name="buffer"></param>
/// <returns></returns>
public static int ReadBuffer(this Stream stream, Span<byte> buffer)
{
    var read = stream.Read(buffer);
    if (read <= 0 || read == buffer.Length)
        return read;

    var written = read;
    for (int n = 0; n < 10; ++n)
    {
        var buffer2 = buffer[written..];
        read = stream.Read(buffer2);
        if (read <= 0)
            return written;

        written += read;
        if (written == buffer.Length)
            return written;
    }

    return written;
}
```

## 指定したバッファ分を全部読み取ると思い込んでいた

```cs
// ストリームの種類によっては読み取りの途中でもバッファ長以外の数値が返る
```

これが肝要です。普段テキスト等をストリームから読み取るとき、ストリームから直接バイトを取得するのではなくストリームリーダーに任せることが多く、詳細な挙動を把握していませんでした。  
一応 `Read()` に関するヒントは存在し、

```cs
// 戻り値を読み取っていないと警告が出る
// 'System.IO.Stream.Read(System.Span<byte>)' による不正確な読み取りを避ける(CA2022)
stream.Read(buffer);
```

これを見て今回の解決方法にたどり着きました。

## .NET Framework 時代と挙動が変わった気がする

自分の記憶の限り .NET Framework 時代はバッファを全部読み取る挙動だったと思います。.NET に移行するときにパフォーマンス向上等の理由で仕様が変更されたのかもしれません。  
このことに気づくまで **`MemoryStream` にコピーを経由して読み取る**荒業でごまかしていました。`MemoryStream` だと、`Read()` １回で全部読み取ってくれます。

## おわりに

今回この挙動に**５年越し**に気づきました。先送りにしていた謎のバグが解消して、大変よろしいです。