【.NET 9.0-rc.1】Brotli 圧縮形式がサポートされました

# はじめに

[.NET 9.0-rc.1](https://dotnet.microsoft.com/ja-jp/download/dotnet/9.0) が公開され、[Brotli 圧縮形式がサポート](https://github.com/dotnet/core/blob/main/release-notes/9.0/preview/rc1/libraries.md#add-zlib-and-brotli-compression-options)されました。

Brotli <https://ja.wikipedia.org/wiki/Brotli> は比較的最近（2017 年）に登場した圧縮形式です。

- GZip より 19% 圧縮率が高い（ファイルサイズが小さくなる）
- GZip より **483% 圧縮時間が長い**（圧縮に時間がかかる）
- GZip より **17% 解凍時間が長い**（解凍に時間がかかる）

数値はご参考程度の触れ込みです。圧縮時間が**とても**長い性質があるため、主な用途は web リソースのように読み取りメインのリソースになりそうです。もともとの開発経緯も web フォントの圧縮を目的にしてのことだそうです。

## 使い方

```cs
using System.IO.Compression;

ReadOnlySpan<byte> want = "祇園精舍の鐘の声、諸行無常の響きあり。娑羅双樹の花の色、盛者必衰の理をあらはす。驕れる人も久しからず、ただ春の夜の夢のごとし。猛き者もつひにはほろびぬ、ひとへに風の前の塵に同じ。"u8;
var memory = new MemoryStream();
using (var brotli = new BrotliStream(memory, CompressionMode.Compress, true))
{
    brotli.Write(want);
}

Assert.Equal(267, want.Length);
Assert.Equal(188, memory.Length); // 圧縮率 70.4%

memory.Position = 0;
using (var brotli = new BrotliStream(memory, CompressionMode.Decompress, true))
{
    var got = (stackalloc byte[want.Length]);
    _ = brotli.Read(got);
    Assert.Equal(want, got);
}
```

## パフォーマンス比較

例のページ <https://www.example.com/> を utf8 形式のテキストで圧縮してみました。  
圧縮レベルが４種類存在し、それぞれを見てみます。

圧縮サイズの比較（もともとのサイズ: 1300 bytes）

|圧縮方法|Brotli|圧縮率|GZip|圧縮率|
|---|---:|---:|---:|---:|
|SmallestSize（最小）|445|34.2%|650|50.0%|
|Optimal（最適）|574|44.2%|658|50.6%|
|Fastest（最速）|706|54.3%|837|64.3%|
|NoCompression（未圧縮）|767|59.0%|1323|101.8%|

圧縮のスコアの比較

|Test|Score|%|CG0|
|---|---:|---:|---:|
|BrotliCompressionTime (4)||||  
|SmallestSize|52|100.0%|0|
|Optimal|510|980.8%|0|
|Fastest|11,314|21,757.7%|0|
|NoCompression|11,929|22,940.4%|0|
|GZipCompressionTime (4)||||    
|SmallestSize|1,908|100.0%|0|
|Optimal|3,348|175.5%|0|
|Fastest|7,274|381.2%|0|
|NoCompression|14,000|733.8%|0|

解凍のスコアの比較

|Test|Score|%|CG0|
|---|---:|---:|---:|
|BrotliDecompressionTime (4)||||
|SmallestSize|9,377|100.0%|0|
|Optimal|9,414|100.4%|0|
|Fastest|11,214|119.6%|0|
|NoCompression|10,771|114.9%|0|
|GZipDecompressionTime (4)||||  
|SmallestSize|7,606|100.0%|0|
|Optimal|8,663|113.9%|0|
|Fastest|8,508|111.9%|0|
|NoCompression|16,106|211.8%|0

実行環境: Windows11 x64 .NET Runtime 9.0.0  
**Score** は高いほどパフォーマンスがよいです。  
**GC0** はガベージコレクション回数を表します（少ないほどパフォーマンスがよい）。

- Brotli は GZip と比較して圧縮ファイルのサイズが少し小さくなる
- Brotli は GZip と比較して**圧縮がとても遅い**
- Brotli は GZip と比較して解凍が少し早い
- Brotli は圧縮レベルによって圧縮にかかる時間が大きく変わる

## おわりに

Brotli は圧縮がとても遅いため用途は限定されますが、より小さなファイルサイズを実現します。  
複数ファイルをまとめたりフォルダ階層を持ったりはできないため、Zip 圧縮に置き換わるものではありません。  
C# で見た場合 `System.IO.Stream` を継承するクラスで実装されているため、既存のコードにも馴染みやすそうです。

<details>
<summary>テストコード</summary>

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

public class _BrotliPerformanceTest
{
    static ReadOnlySpan<byte> ExampleText =>
"""
<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
        background-color: #f0f0f2;
        margin: 0;
        padding: 0;
        font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
        
    }
    div {
        width: 600px;
        margin: 5em auto;
        padding: 2em;
        background-color: #fdfdff;
        border-radius: 0.5em;
        box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
    }
    a:link, a:visited {
        color: #38488f;
        text-decoration: none;
    }
    @media (max-width: 700px) {
        div {
            margin: 0 auto;
            width: auto;
        }
    }
    </style>    
</head>

<body>
<div>
    <h1>Example Domain</h1>
    <p>This domain is for use in illustrative examples in documents. You may use this
    domain in literature without prior coordination or asking for permission.</p>
    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>
"""u8;

    [Fact]
    void HowToUse()
    {
        ReadOnlySpan<byte> want = "祇園精舍の鐘の声、諸行無常の響きあり。娑羅双樹の花の色、盛者必衰の理をあらはす。驕れる人も久しからず、ただ春の夜の夢のごとし。猛き者もつひにはほろびぬ、ひとへに風の前の塵に同じ。"u8;
        var memory = new MemoryStream();
        using (var brotli = new BrotliStream(memory, CompressionMode.Compress, true))
        {
            brotli.Write(want);
        }

        Assert.Equal(267, want.Length);
        Assert.Equal(188, memory.Length); // 圧縮率 70.4%

        memory.Position = 0;
        using (var brotli = new BrotliStream(memory, CompressionMode.Decompress, true))
        {
            var got = (stackalloc byte[want.Length]);
            _ = brotli.Read(got);
            Assert.Equal(want, got);
        }
    }

    [Fact]
    void HowToUseGZip()
    {
        ReadOnlySpan<byte> want = "祇園精舍の鐘の声、諸行無常の響きあり。娑羅双樹の花の色、盛者必衰の理をあらはす。驕れる人も久しからず、ただ春の夜の夢のごとし。猛き者もつひにはほろびぬ、ひとへに風の前の塵に同じ。"u8;
        var memory = new MemoryStream();
        using (var brotli = new GZipStream(memory, CompressionMode.Compress, true))
        {
            brotli.Write(want);
        }

        Assert.Equal(267, want.Length);
        Assert.Equal(212, memory.Length); // 圧縮率 79.4%

        memory.Position = 0;
        using (var brotli = new GZipStream(memory, CompressionMode.Decompress, true))
        {
            var got = (stackalloc byte[want.Length]);
            _ = brotli.Read(got);
            Assert.Equal(want, got);
        }
    }

    [Fact]
    void BrotliCompressionBytes()
    {
        Assert.Equal(1300, ExampleText.Length);
        ReadOnlySpan<(CompressionLevel level, int size)> tests =
        [
            (CompressionLevel.SmallestSize, 445),
            (CompressionLevel.Optimal, 574),
            (CompressionLevel.Fastest, 706),
            (CompressionLevel.NoCompression, 767),
        ];
        var memory = new MemoryStream();
        foreach (var (level, size) in tests)
        {
            memory.SetLength(0);
            memory.Position = 0;
            using (var brotli = new BrotliStream(memory, level, true))
            {
                brotli.Write(ExampleText);
            }
            Assert.Equal(size, memory.Length);
        }
    }

    [Fact]
    void GZipCompressionBytes()
    {
        Assert.Equal(1300, ExampleText.Length);

        ReadOnlySpan<(CompressionLevel level, int size)> tests =
        [
            (CompressionLevel.SmallestSize, 650),
            (CompressionLevel.Optimal, 658),
            (CompressionLevel.Fastest, 837),
            (CompressionLevel.NoCompression, 1323),
        ];
        var memory = new MemoryStream();
        foreach (var (level, size) in tests)
        {
            memory.SetLength(0);
            memory.Position = 0;
            using (var gzip = new GZipStream(memory, level, true))
            {
                gzip.Write(ExampleText);
            }
            Assert.Equal(size, memory.Length);
        }
    }

    static void BrotliCompressionTime(Performance p)
    {
        ReadOnlySpan<CompressionLevel> testCases = [
            CompressionLevel.SmallestSize,
            CompressionLevel.Optimal,
            CompressionLevel.Fastest,
            CompressionLevel.NoCompression
        ];
        var memory = new MemoryStream();
        foreach (var n in testCases)
        {
            p.AddTest(n.ToString(), () =>
            {
                memory.SetLength(0);
                memory.Position = 0;
                using (var brotli = new BrotliStream(memory, n, true))
                {
                    brotli.Write(ExampleText);
                }
            });
        }
    }

    static void GZipCompressionTime(Performance p)
    {
        ReadOnlySpan<CompressionLevel> testCases = [
            CompressionLevel.SmallestSize,
            CompressionLevel.Optimal,
            CompressionLevel.Fastest,
            CompressionLevel.NoCompression
        ];
        var memory = new MemoryStream();
        foreach (var n in testCases)
        {
            p.AddTest(n.ToString(), () =>
            {
                memory.SetLength(0);
                memory.Position = 0;
                using (var gzip = new GZipStream(memory, n, true))
                {
                    gzip.Write(ExampleText);
                }
            });
        }
    }

    static void BrotliDecompressionTime(Performance p)
    {
        ReadOnlySpan<CompressionLevel> testCases = [
            CompressionLevel.SmallestSize,
            CompressionLevel.Optimal,
            CompressionLevel.Fastest,
            CompressionLevel.NoCompression
        ];
        foreach (var n in testCases)
        {
            var compressed = new MemoryStream();
            using (var brotli = new BrotliStream(compressed, n, true))
            {
                brotli.Write(ExampleText);
            }

            p.AddTest(n.ToString(), () =>
            {
                compressed.Position = 0;
                using (var brotli = new BrotliStream(compressed, CompressionMode.Decompress, true))
                {
                    var buf = (stackalloc byte[16]);
                    while (brotli.Read(buf) > 0) ;
                }
            });
        }
    }

    static void GZipDecompressionTime(Performance p)
    {
        ReadOnlySpan<CompressionLevel> testCases = [
            CompressionLevel.SmallestSize,
            CompressionLevel.Optimal,
            CompressionLevel.Fastest,
            CompressionLevel.NoCompression
        ];
        var memory = new MemoryStream();
        foreach (var n in testCases)
        {
            var compressed = new MemoryStream();
            using (var gzip = new GZipStream(compressed, n, true))
            {
                gzip.Write(ExampleText);
            }

            p.AddTest(n.ToString(), () =>
            {
                compressed.Position = 0;
                using (var gzip = new GZipStream(compressed, CompressionMode.Decompress, true))
                {
                    var buf = (stackalloc byte[16]);
                    while (gzip.Read(buf) > 0) ;
                }
            });
        }
    }
}

```

</details>