大量のオブジェクトを持ったjson配列をシリアライズ/デシリアライズする

# 始めに

現状の.NETにおけるJSONの取り扱いについてスタンダードとなっているのはSystem.Text.Jsonとなる。
今回これを使ってGB超えのJSON配列を取り扱う方法について調べたので記事にしておく。

なお、今回のような大きなデータを扱う場合、許されるならば[JSON Lines](https://jsonlines.org/)を使えば、素直に処理できるかもしれない。標準のmime型が整備されてないという弱点はあるけど。

# 対象となるデータ

今回取り扱うデータはタイトルにもある通り、「大量のオブジェクトを持ったJSON配列」というものになる。
これがどういうものかというと、具体的には以下のような型のデータとなる。

```jsonc
// 最初は配列
[
  // 見やすくするためインデントをつけているが、実際は圧縮されている
  // 個々のオブジェクトは全て同じ型で、一つ当たりのサイズ自体はそれ程大きくはない
  {
    "a":"a1",
    "b":1
  },
  {
    "a":"a1",
    "b":1
  }
  // この後JSONオブジェクトの配列が続く。
]
```


# シリアライズ(オブジェクト→JSONフォーマット)

System.Text.Jsonでのシリアライズは [`JsonSerializer.Serialize`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer.serialize?view=net-8.0) を使用する。(他にもJsonSerializer.SerializeAsync等あるが今回の記事の範囲で言える事は特に変わらない)

## まとめてシリアライズ

素直に考えるならば下記のような方法となるが、注意点がある。

```csharp
static void CreateJsonFile2(string filePath, int num)
{
    using var fstm = File.Create(filePath);
    using var jw = new Utf8JsonWriter(fstm, new JsonWriterOptions() { Indented = true });
    JsonSerializer.Serialize(jw, Create(num));
    static IEnumerable<Hoge> Create(int num)
    {
        for (int i = 0; i < num; i++)
        {
            yield return new Hoge() { A = $"hoge{i}", B = i };
        }
    }
}
```

上記のようなコードで実際に実行すると、 **IEnumerableを一回全てメモリ上に乗せてから実行する**という挙動をするので、実行時に要素数により相応のメモリ消費がされる。

## 個別にシリアライズ

メモリ消費を抑えたい場合は下記のようにする。

```csharp
void CreateJsonFile(string filePath, int num)
{
    using var fstm = File.Create(filePath);
    using var jw = new Utf8JsonWriter(fstm, new JsonWriterOptions() { Indented = true });
    jw.WriteStartArray();
    for (int i = 0; i < num; i++)
    {
        JsonSerializer.Serialize(jw, new Hoge() { A = $"hoge{i}", B = i });
    }
    jw.WriteEndArray();
}
```

## ベンチマーク

両者の手法でベンチマークを取ると以下のような値になる。(Streamはファイルではなくて `Stream.Null` にしている)

```

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2033)
Intel Core i7-9700 CPU 3.00GHz, 1 CPU, 8 logical and 8 physical cores
.NET SDK 9.0.100
  [Host]   : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
  ShortRun : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2

Job=ShortRun  IterationCount=3  LaunchCount=1  
WarmupCount=3  

```
| Method     | Num   | Mean         | Error       | StdDev     | Gen0     | Gen1     | Gen2     | Allocated  |
|----------- |------ |-------------:|------------:|-----------:|---------:|---------:|---------:|-----------:|
| **HighMemory** | **10**    |     **2.126 μs** |   **0.6063 μs** |  **0.0332 μs** |   **0.9651** |   **0.0153** |        **-** |    **5.92 KB** |
| LowMemory  | 10    |     1.666 μs |   0.2116 μs |  0.0116 μs |   0.1717 |        - |        - |    1.05 KB |
| **HighMemory** | **10000** | **2,037.394 μs** | **384.9997 μs** | **21.1031 μs** | **285.1563** | **285.1563** | **285.1563** | **1752.51 KB** |
| LowMemory  | 10000 | 1,617.280 μs |  73.2370 μs |  4.0144 μs | 113.2813 |        - |        - |  703.48 KB |

HighMemoryがまとめてシリアライズメソッドに入れる手法で、LowMemoryが一つ一つシリアライズする手法となる。
今回は10000要素までのベンチマークだが、メモリアロケーションの数値が異なるのが分かると思う。これがさらに1000万要素のように単位が大きくなると、差が更に顕著になる。
速度についてはブレも考慮する必要があるが、今回は条件を余り詰めてないので余り参考にならないことに注意。
ただ、特化実装という形になるので汎用処理よりも速度に対して優位性はありそう。

# デシリアライズ(JSONフォーマット→オブジェクト)

基本的には [`JsonSerializer.Deserialize`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer.deserialize?view=net-8.0) を使用することになるが、シリアライズとは異なり少々面倒な仕様がある。
シリアライズと比べて厄介なのは、データが全てオンメモリに載っていることが前提となっている。
[`JsonSerializer.Deserialize`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer.deserialize?view=net-8.0) はStreamを引数に取らないし、
Deserializeが引数に取る [`Utf8JsonReader`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.utf8jsonreader?view=net-8.0) もStream等は引数に取らない。
更に、Utf8JsonReaderはref structなので外に持って行けず、asyncメソッドの中でも使えない。

## 普通のデシリアライズ

素直なやり方としては下記のようになる。

```csharp
void ReadJson(string filePath)
{
    using var stm = File.OpenRead(filePath);
    foreach(var item in JsonSerializer.Deserialize<IEnumerable<Hoge>>(stm))
    {
        //何かする
    }
}
```

しかしこの手法だと、JsonSerializerは内部的に`List<T>`としてデシリアライズするので、要素数に応じてメモリが消費される。
数百KB程度ならば問題にならないが、数百MB～数GBになるとこれは無視できないメモリ負荷となる。

## メモリ消費を抑えるやり方

このようにメモリに乗り切らない場合の救済措置として、[`System.Text.Json.JsonReaderState`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonreaderstate?view=net-8.0)が用意されているので、これを使用する。

大まかな手順としては下記のようになる

1. JsonReaderStateの初期化
2. ファイルからのある程度の読み出し
    確実に一要素が収まるサイズの倍以上
3. Utf8JsonReaderをJsonReaderState付でnew
4. Utf8JsonReaderをStartObjectまで読み進める
    他に要素が入らないことが前提
5. `JsonSerializer.Deserialize<T>(Utf8JsonReader)` でデシリアライズ
6. 成否で以下のように分岐
    * 成功した場合 
        1. 成功した時点での`Utf8JsonReader.CurrentState`を新しいJsonReaderStateとする
        2. `Utf8JsonReader.BytesConsumed`を記録
        3. 次のStartObjectまで読み進める
    * "データが足りない"という内容の例外が発生した場合
        1. ループを中断
7. 成功した時点のBytesConsumedより前のデータを破棄して更にバッファに読み足す

"データが足りない"というのは具体的には下記のような内容。

```
System.Text.Json.JsonException: There is not enough data to read through the entire JSON array or object.
```

### JsonReaderStateとは

[JsonReaderState](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonreaderstate?view=net-8.0) とは、Utf8JsonReaderがどの程度まで読み進めたかという状態を記録している。
定義を見るとわかると思うが、公開データはほとんどないに等しいので、ブラックボックスとして扱う必要がある。
作成時に以下のオプションを指定することができる

* AllowTrailingCommas: 配列の最後の","を許容するかどうか
* CommentHandling: json中にあるコメントを許容するか、許容するとして、トークンとして認識させるか単に無視するか
* MaxDepth: 入力するJSONフォーマットの最大深度、デフォルトは64

内部的には今の階層がどの辺りか、今オブジェクトの中か、あるいは配列の中にいるかどうかというのが記録される。
注意点として、**データそのものは保持してない**ので、そこは使う側が保証する必要がある。
[参考: JsonReaderStateのソース](https://github.com/dotnet/runtime/blob/v9.0.0/src/libraries/System.Text.Json/src/System/Text/Json/Reader/JsonReaderState.cs)

ユーザーが作成時にnewして、ループの度にUtf8JsonReaderに渡し、一つのバッファの処理が終わったらUtf8JsonReader.CurrentStateを今保持しているJsonReaderStateと入れ替えて使用するのが想定される運用らしい。
デシリアライズに成功した後は、JsonReaderStateはシリアライズ対象オブジェクト終わりの次の要素を指すようになる。
なので、データが足りずにデシリアライズが失敗した場合は、最後に成功したJsonReaderStateの情報と、その時までに消費したバッファデータのすぐ後から再開すれば、もう一度デシリアライズを再試行できる。

### 実際のコード

読み出し済みバッファのデータ破棄という手順を簡略化させるため、Pipelinesを使用したコードが以下。

```csharp
async Task ReadJsonFile(string filePath)
{
    using var fstm = File.OpenRead(filePath);
    var pipe = new Pipe();
    // FileStreamとPipelinesで並行して読み出している
    // PipeReader.Create(stm)という選択肢もあったが、意図通り動かなかったためこちらを採用
    await Task.WhenAll(WriteTask(pipe.Writer, fstm), ReadTask(pipe.Reader));
    static async Task WriteTask(PipeWriter writer, Stream stm)
    {
        using var wstm = writer.AsStream();
        await stm.CopyToAsync(wstm);
        await wstm.FlushAsync();
    }
}

async Task ReadTask(PipeReader reader)
{
    // そもそも元データが壊れていた場合の考慮等、エラー処理部分が不十分なコードなので注意
    var readerState = new JsonReaderState(new JsonReaderOptions() { AllowMultipleValues = true });
    while (true)
    {
        var readResult = await reader.ReadAtLeastAsync(1024);
        if (readResult.Buffer.IsEmpty && readResult.IsCompleted)
        {
            break;
        }
        ReadJson(readResult, ref readerState, reader);
        static void ReadJson(ReadResult readResult, ref JsonReaderState readerState, PipeReader reader)
        {
            // Utf8JsonReaderはref structなのでasyncメソッドの中では使えない
            var jr = new Utf8JsonReader(readResult.Buffer, false, readerState);
            long totalRead = 0;
            while (jr.Read())
            {
                if (jr.TokenType == JsonTokenType.StartObject)
                {
                    try
                    {
                        var hoge = JsonSerializer.Deserialize<Hoge>(ref jr);
                        // デシリアライズが成功すると、jsonreaderがシリアライズ成功したオブジェクトのすぐ後を指すようになる
                        if (hoge != null)
                        {
                            // 読み出し成功
                            // 何かする
                            totalRead = jr.BytesConsumed;
                            // 構造体なのでコピーが発生する
                            readerState = jr.CurrentState;
                        }
                    }
                    catch (JsonException je)
                    {
                        // バッファの中のデータが足りない場合にこちらに来る
                        break;
                    }
                }
                else
                {
                    // 要素間のカンマ等を読み飛ばす
                    totalRead = jr.BytesConsumed;
                    readerState = jr.CurrentState;
                }
            }
            // 読みだして不要となった領域を破棄する
            reader.AdvanceTo(readResult.Buffer.Slice(0, totalRead).End);
        }
    }
}
```

少々複雑だが、メモリ消費は抑えられている。
欠点として、**データ欠け等の不正なデータに対するエラーハンドリングが難しい**というのがある。
また、見た目でもう察すると思うが、まとめてデシリアライズするより明らかに遅くなる。
バッファサイズ等のパラメーター調整をすれば差は縮まるかもしれないが、今回はそこまでは検証しない。

## ベンチマーク

二つの手法のベンチマーク結果は以下。要素は10000、シリアライズしたバイトデータをMemoryStreamで使用している。

```

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2033)
Intel Core i7-9700 CPU 3.00GHz, 1 CPU, 8 logical and 8 physical cores
.NET SDK 9.0.100
  [Host]   : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
  ShortRun : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2

Job=ShortRun  IterationCount=3  LaunchCount=1  
WarmupCount=3  

```
| Method             | Mean     | Error     | StdDev    | Gen0     | Gen1     | Gen2    | Allocated  |
|------------------- |---------:|----------:|----------:|---------:|---------:|--------:|-----------:|
| DeserializeBulk    | 2.763 ms | 2.0077 ms | 0.1100 ms | 171.8750 | 109.3750 | 39.0625 | 1038.25 KB |
| DeserializePartial | 4.114 ms | 0.3076 ms | 0.0169 ms | 109.3750 |        - |       - |  712.53 KB |

速さは誤差を考慮してもやはりまとめてデシリアライズした方が早い。
しかし、メモリアロケーションに関しては個別にデシリアライズした方が消費量は抑えられている。
実際、1000万要素数というような大きな数になってくるとこれが顕著になる。

# まとめ

巨大なJSONデータをメモリに全て載せずに処理するというのは余り需要が無いのか、調べても中々これといった方法が出てこなかった。しかし、昨今大きいデータを扱う機会も増えてきたので、役立つ時が来るかもしれない。

コメントでも書いたが、エラーハンドリングが余り簡単ではないのでそこは注意が必要。
今回使用したコードは下記リポジトリに保存しておく。
https://github.com/itn3000/jsonreaderstatetest