System.Text.Json で Utf8JsonReader を使う方法

この記事では、カスタム パーサーと逆シリアライザーを構築するために Utf8JsonReader 型を使う方法について説明します。

Utf8JsonReader は、UTF-8 でエンコードされた JSON テキスト用の、ハイパフォーマンス、低割り当て、順方向専用のリーダーです。ReadOnlySpan<byte> または ReadOnlySequence<byte> から読み取られます。 Utf8JsonReader は低レベルの型であり、カスタム パーサーとデシリアライザーを構築するために使用できます。 JsonSerializer.Deserialize メソッドの内部では Utf8JsonReader が使用されます。

Utf8JsonReader は Visual Basic コードから直接使用することはできません。 詳細については、「Visual Basic のサポート」をご覧ください。

Utf8JsonReader クラスを使用する方法を示す例を次に示します。

var options = new JsonReaderOptions
{
    AllowTrailingCommas = true,
    CommentHandling = JsonCommentHandling.Skip
};
var reader = new Utf8JsonReader(jsonUtf8Bytes, options);

while (reader.Read())
{
    Console.Write(reader.TokenType);

    switch (reader.TokenType)
    {
        case JsonTokenType.PropertyName:
        case JsonTokenType.String:
            {
                string? text = reader.GetString();
                Console.Write(" ");
                Console.Write(text);
                break;
            }

        case JsonTokenType.Number:
            {
                int intValue = reader.GetInt32();
                Console.Write(" ");
                Console.Write(intValue);
                break;
            }

            // Other token types elided for brevity
    }
    Console.WriteLine();
}
' This code example doesn't apply to Visual Basic. For more information, go to the following URL:
' https://learn.microsoft.com/dotnet/standard/serialization/system-text-json-how-to#visual-basic-support

上記のコードでは、jsonUtf8 変数が、UTF-8 としてエンコードされた有効な JSON を含むバイト配列であることを想定しています。

Utf8JsonReader を使用してデータをフィルター処理する

次の例は、同期的にファイルを読み取り、値を検索する方法を示しています。

using System.Text;
using System.Text.Json;

namespace SystemTextJsonSamples
{
    public class Utf8ReaderFromFile
    {
        private static readonly byte[] s_nameUtf8 = Encoding.UTF8.GetBytes("name");
        private static ReadOnlySpan<byte> Utf8Bom => new byte[] { 0xEF, 0xBB, 0xBF };

        public static void Run()
        {
            // ReadAllBytes if the file encoding is UTF-8:
            string fileName = "UniversitiesUtf8.json";
            ReadOnlySpan<byte> jsonReadOnlySpan = File.ReadAllBytes(fileName);

            // Read past the UTF-8 BOM bytes if a BOM exists.
            if (jsonReadOnlySpan.StartsWith(Utf8Bom))
            {
                jsonReadOnlySpan = jsonReadOnlySpan.Slice(Utf8Bom.Length);
            }

            // Or read as UTF-16 and transcode to UTF-8 to convert to a ReadOnlySpan<byte>
            //string fileName = "Universities.json";
            //string jsonString = File.ReadAllText(fileName);
            //ReadOnlySpan<byte> jsonReadOnlySpan = Encoding.UTF8.GetBytes(jsonString);

            int count = 0;
            int total = 0;

            var reader = new Utf8JsonReader(jsonReadOnlySpan);

            while (reader.Read())
            {
                JsonTokenType tokenType = reader.TokenType;

                switch (tokenType)
                {
                    case JsonTokenType.StartObject:
                        total++;
                        break;
                    case JsonTokenType.PropertyName:
                        if (reader.ValueTextEquals(s_nameUtf8))
                        {
                            // Assume valid JSON, known schema
                            reader.Read();
                            if (reader.GetString()!.EndsWith("University"))
                            {
                                count++;
                            }
                        }
                        break;
                }
            }
            Console.WriteLine($"{count} out of {total} have names that end with 'University'");
        }
    }
}
' This code example doesn't apply to Visual Basic. For more information, go to the following URL:
' https://learn.microsoft.com/dotnet/standard/serialization/system-text-json-how-to#visual-basic-support

この例の非同期バージョンについては、.NET サンプル JSON プロジェクトに関するページを参照してください。

上記のコードでは次の操作が行われます。

  • JSON にはオブジェクトの配列が含まれ、各オブジェクトには string 型の "name" プロパティが含まれている可能性があると想定されています。

  • オブジェクトと "University" で終わる "name" プロパティ値をカウントします。

  • ファイルが UTF-16 としてエンコードされ、UTF-8 にトランスコードされるものと想定します。 UTF-8 としてエンコードされたファイルは、次のコードを使用して、ReadOnlySpan<byte> に直接読み取ることができます。

    ReadOnlySpan<byte> jsonReadOnlySpan = File.ReadAllBytes(fileName);
    

    リーダーではテキストが想定されるため、ファイルに UTF-8 バイト オーダー マーク (BOM) が含まれている場合は、バイトを Utf8JsonReader に渡す前にそれを削除します。 そうしないと、BOM は無効な JSON と見なされ、リーダーによって例外がスローされます。

上記のコードで読み取ることができる JSON のサンプルを次に示します。 結果として生成される概要メッセージは、"2 out of 4 have names that end with 'University'" です。

[
  {
    "web_pages": [ "https://contoso.edu/" ],
    "alpha_two_code": "US",
    "state-province": null,
    "country": "United States",
    "domains": [ "contoso.edu" ],
    "name": "Contoso Community College"
  },
  {
    "web_pages": [ "http://fabrikam.edu/" ],
    "alpha_two_code": "US",
    "state-province": null,
    "country": "United States",
    "domains": [ "fabrikam.edu" ],
    "name": "Fabrikam Community College"
  },
  {
    "web_pages": [ "http://www.contosouniversity.edu/" ],
    "alpha_two_code": "US",
    "state-province": null,
    "country": "United States",
    "domains": [ "contosouniversity.edu" ],
    "name": "Contoso University"
  },
  {
    "web_pages": [ "http://www.fabrikamuniversity.edu/" ],
    "alpha_two_code": "US",
    "state-province": null,
    "country": "United States",
    "domains": [ "fabrikamuniversity.edu" ],
    "name": "Fabrikam University"
  }
]

Utf8JsonReader を使用してストリームから読み取る

大きなファイル (ギガバイト以上のサイズなど) を読み取る場合、一度にファイル全体をメモリに読み込む必要を回避することができます。 このシナリオでは、FileStream を使用できます。

Utf8JsonReader を使用してストリームから読み取る場合、次の規則が適用されます。

  • 部分的な JSON ペイロードが格納されるバッファーは、リーダーが処理を進めることができるように、少なくともその中で最大の JSON トークンと同じ大きさにする必要があります。
  • バッファーは、少なくとも JSON 内の空白の最大シーケンスと同じ大きさである必要があります。
  • リーダーでは、JSON ペイロード内の次の TokenType が完全に読み取られるまで、読み取られたデータが追跡されません。 そのため、バッファー内にバイトが残っている場合は、再びリーダーに渡す必要があります。 BytesConsumed を使用して、残っているバイト数を確認できます。

次のコードは、ストリームから読み取る方法を示しています。 この例は、MemoryStream を示しています。 同様のコードが FileStream で機能しますが、開始時に UTF-8 BOM が FileStream に含まれている場合を除きます。 その場合は、残りのバイトを Utf8JsonReader に渡す前に、バッファーからこれらの 3 バイトを取り除く必要があります。 そうしないと、BOM は JSON の有効な部分と見なされないため、リーダーによって例外がスローされます。

このサンプル コードでは、4 KB のバッファーから開始し、サイズが完全な JSON トークンに対応するのに十分な大きさではないことが判明するたびにバッファー サイズを 2 倍にします。これは、リーダーが JSON ペイロードの処理を進めるために必要です。 スニペットに用意されている JSON サンプルでは、非常に小さい初期バッファー サイズ (たとえば、10 バイト) を設定した場合にのみ、バッファー サイズが増加します。 初期バッファー サイズを 10 に設定すると、Console.WriteLine ステートメントによって、バッファー サイズの増加の原因と影響が示されます。 4 KB の初期バッファー サイズで、サンプルの JSON 全体が各 Console.WriteLine によって表示され、バッファー サイズを増やす必要はありません。

using System.Text;
using System.Text.Json;

namespace SystemTextJsonSamples
{
    public class Utf8ReaderPartialRead
    {
        public static void Run()
        {
            var jsonString = @"{
                ""Date"": ""2019-08-01T00:00:00-07:00"",
                ""Temperature"": 25,
                ""TemperatureRanges"": {
                    ""Cold"": { ""High"": 20, ""Low"": -10 },
                    ""Hot"": { ""High"": 60, ""Low"": 20 }
                },
                ""Summary"": ""Hot"",
            }";

            byte[] bytes = Encoding.UTF8.GetBytes(jsonString);
            var stream = new MemoryStream(bytes);

            var buffer = new byte[4096];

            // Fill the buffer.
            // For this snippet, we're assuming the stream is open and has data.
            // If it might be closed or empty, check if the return value is 0.
            stream.Read(buffer);

            // We set isFinalBlock to false since we expect more data in a subsequent read from the stream.
            var reader = new Utf8JsonReader(buffer, isFinalBlock: false, state: default);
            Console.WriteLine($"String in buffer is: {Encoding.UTF8.GetString(buffer)}");

            // Search for "Summary" property name
            while (reader.TokenType != JsonTokenType.PropertyName || !reader.ValueTextEquals("Summary"))
            {
                if (!reader.Read())
                {
                    // Not enough of the JSON is in the buffer to complete a read.
                    GetMoreBytesFromStream(stream, ref buffer, ref reader);
                }
            }

            // Found the "Summary" property name.
            Console.WriteLine($"String in buffer is: {Encoding.UTF8.GetString(buffer)}");
            while (!reader.Read())
            {
                // Not enough of the JSON is in the buffer to complete a read.
                GetMoreBytesFromStream(stream, ref buffer, ref reader);
            }
            // Display value of Summary property, that is, "Hot".
            Console.WriteLine($"Got property value: {reader.GetString()}");
        }

        private static void GetMoreBytesFromStream(
            MemoryStream stream, ref byte[] buffer, ref Utf8JsonReader reader)
        {
            int bytesRead;
            if (reader.BytesConsumed < buffer.Length)
            {
                ReadOnlySpan<byte> leftover = buffer.AsSpan((int)reader.BytesConsumed);

                if (leftover.Length == buffer.Length)
                {
                    Array.Resize(ref buffer, buffer.Length * 2);
                    Console.WriteLine($"Increased buffer size to {buffer.Length}");
                }

                leftover.CopyTo(buffer);
                bytesRead = stream.Read(buffer.AsSpan(leftover.Length));
            }
            else
            {
                bytesRead = stream.Read(buffer);
            }
            Console.WriteLine($"String in buffer is: {Encoding.UTF8.GetString(buffer)}");
            reader = new Utf8JsonReader(buffer, isFinalBlock: bytesRead == 0, reader.CurrentState);
        }
    }
}
' This code example doesn't apply to Visual Basic. For more information, go to the following URL:
' https://learn.microsoft.com/dotnet/standard/serialization/system-text-json-how-to#visual-basic-support

前の例では、バッファーを拡大できる最大の大きさを無制限に設定しています。 トークン サイズが大きすぎる場合、コードは OutOfMemoryException 例外で失敗する可能性があります。 これは、JSON にサイズが約 1 GB 以上のトークンが含まれている場合に発生する可能性があります。1 GB のサイズを 2 倍にすると、サイズが大きすぎて int32 バッファーに入り切らないためです。

ref 構造体の制限事項

Utf8JsonReader 型は "ref 構造体" であるため、特定の制限があります。 たとえば、ref 構造体以外のクラスまたは構造体にフィールドとして格納することはできません。

ハイ パフォーマンスを実現するには、この型を ref struct にする必要があります。これは、入力の ReadOnlySpan<byte> (これ自体が ref 構造体です) をキャッシュする必要があるためです。 さらに、Utf8JsonReader 型は状態を保持するため変更可能です。 そのため、これは値ではなく参照渡しで渡してください。 値で渡すと、構造体のコピーが生成され、呼び出し元が状態の変更を確認できません。

ref 構造体の使用方法の詳細については、「割り当てを回避する」を参照してください。

UTF-8 テキストを読み取る

Utf8JsonReader を使用しているときに最大限のパフォーマンスを達成するには、UTF-16 文字列としてではなく、UTF-8 テキストとして既にエンコードされている JSON ペイロードを読み取ります。 コード例については、「Utf8JsonReader を使用してデータをフィルター処理する」を参照してください。

マルチセグメントの ReadOnlySequence で読み取る

JSON 入力が ReadOnlySpan<byte> の場合、各 JSON 要素には、読み取りループを実行するときにリーダーで ValueSpan プロパティからアクセスできます。 ただし、入力が ReadOnlySequence<byte> (これは、PipeReader からの読み取りの結果です) の場合、一部の JSON 要素が ReadOnlySequence<byte> オブジェクトの複数のセグメントにまたがることがあります。 これらの要素には、連続メモリ ブロック内で ValueSpan からアクセスすることはできません。 代わりに、マルチセグメントの ReadOnlySequence<byte> を入力として使用する場合は必ず、リーダーで HasValueSequence プロパティをポーリングして、現在の JSON 要素へのアクセス方法を確認します。 推奨されるパターンを次に示します。

while (reader.Read())
{
    switch (reader.TokenType)
    {
        // ...
        ReadOnlySpan<byte> jsonElement = reader.HasValueSequence ?
            reader.ValueSequence.ToArray() :
            reader.ValueSpan;
        // ...
    }
}

プロパティ名の検索に ValueTextEquals を使用する

プロパティ名の検索用に SequenceEqual を呼び出してバイト単位の比較を実行する場合は、ValueSpan を使用しないでください。 代わりに ValueTextEquals を呼び出してください。このメソッドにより、JSON でエスケープされた文字がエスケープ解除されるためです。 以下に示すのは、"name" という名前のプロパティの検索方法の例です。

private static readonly byte[] s_nameUtf8 = Encoding.UTF8.GetBytes("name");
while (reader.Read())
{
    switch (reader.TokenType)
    {
        case JsonTokenType.StartObject:
            total++;
            break;
        case JsonTokenType.PropertyName:
            if (reader.ValueTextEquals(s_nameUtf8))
            {
                count++;
            }
            break;
    }
}

null 許容値型に null 値を読み込む

組み込みの System.Text.Json の API では、null 非許容値型のみが返されます。 たとえば、Utf8JsonReader.GetBoolean では bool が返されます。 JSON で Null が見つかると、例外がスローされます。 次の例は、null を処理する 2 つの方法を示しています。1 つは null 許容値型を返す方法で、もう 1 つは既定値を返す方法です。

public bool? ReadAsNullableBoolean()
{
    _reader.Read();
    if (_reader.TokenType == JsonTokenType.Null)
    {
        return null;
    }
    if (_reader.TokenType != JsonTokenType.True && _reader.TokenType != JsonTokenType.False)
    {
        throw new JsonException();
    }
    return _reader.GetBoolean();
}
public bool ReadAsBoolean(bool defaultValue)
{
    _reader.Read();
    if (_reader.TokenType == JsonTokenType.Null)
    {
        return defaultValue;
    }
    if (_reader.TokenType != JsonTokenType.True && _reader.TokenType != JsonTokenType.False)
    {
        throw new JsonException();
    }
    return _reader.GetBoolean();
}

トークンの子をスキップする

現在の JSON トークンの子のスキップするには、Utf8JsonReader.Skip() メソッドを使います。 トークンの種類が JsonTokenType.PropertyName である場合、リーダーはプロパティ値に移動します。 次のコード スニペットでは、Utf8JsonReader.Skip() を使ってリーダーをプロパティの値に移動する例を示します。

var weatherForecast = new WeatherForecast
{
    Date = DateTime.Parse("2019-08-01"),
    TemperatureCelsius = 25,
    Summary = "Hot"
};

byte[] jsonUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(weatherForecast);

var reader = new Utf8JsonReader(jsonUtf8Bytes);

int temp;
while (reader.Read())
{
    switch (reader.TokenType)
    {
        case JsonTokenType.PropertyName:
            {
                if (reader.ValueTextEquals("TemperatureCelsius"))
                {
                    reader.Skip();
                    temp = reader.GetInt32();

                    Console.WriteLine($"Temperature is {temp} degrees.");
                }
                continue;
            }
        default:
            continue;
    }
}

デコードされた JSON 文字列を使用する

.NET 7 以降では、デコードされた JSON 文字列を使用する場合に、Utf8JsonReader.GetString() の代わりに Utf8JsonReader.CopyString メソッドを使用できます。 常に新しい文字列を割り当てる GetString() とは異なり、CopyString ではエスケープされていない文字列を自分が所有するバッファにコピーできます。 次のコード スニペットは、CopyString を使用して UTF-16 文字列を使用する例を示しています。

var reader = new Utf8JsonReader( /* jsonReadOnlySpan */ );

int valueLength = reader.HasValueSequence
    ? checked((int)reader.ValueSequence.Length)
    : reader.ValueSpan.Length;

char[] buffer = ArrayPool<char>.Shared.Rent(valueLength);
int charsRead = reader.CopyString(buffer);
ReadOnlySpan<char> source = buffer.AsSpan(0, charsRead);

// Handle the unescaped JSON string.
ParseUnescapedString(source);
ArrayPool<char>.Shared.Return(buffer, clearArray: true);

void ParseUnescapedString(ReadOnlySpan<char> source)
{
    // ...
}

こちらもご覧ください