ファイル

【C#】構造体をJSONに変換する方法と逆変換のコツ

C#では構造体もJsonSerializer.SerializeでそのままJSON文字列へ変換でき、JsonSerializer.Deserialize<StructType>で復元できます。

プロパティをpublicのget/setにしておけばOKで、書式やエンコードはJsonSerializerOptionsで自在に調整できます。

目次から探す
  1. 構造体とクラスの違いをおさらい
  2. System.Text.Jsonを使った基本的なシリアル化手順
  3. プロパティの可視性とシリアル化対象の制御
  4. JsonSerializerOptionsで変換をカスタマイズ
  5. 属性による詳細設定
  6. ネストした構造体と複合コレクション
  7. 既存JSONを構造体へマッピングするテクニック
  8. 変換エラーの診断と例外ハンドリング
  9. 速度とメモリ効率を向上させるポイント
  10. カスタムコンバータの実装例
  11. 他ライブラリとの比較
  12. ユニットテストで変換結果を保証する
  13. セキュリティの観点
  14. よくあるトラブルシューティング
  15. バージョン互換性と将来の展望
  16. まとめ

構造体とクラスの違いをおさらい

C#において、構造体structとクラスclassはどちらもデータをまとめるための型ですが、その性質や使い方には大きな違いがあります。

JSONへの変換を考える際にも、この違いを理解しておくことが重要です。

ここでは、構造体とクラスの基本的な違いをおさらいし、特にメモリ配置の観点から解説します。

参照型と値型のメモリ配置

C#の型は大きく分けて「参照型」と「値型」に分類されます。

クラスは参照型、構造体は値型に該当します。

参照型(クラス)

クラスは参照型であり、インスタンスはヒープ領域に確保されます。

変数はオブジェクトの実体ではなく、そのヒープ上のメモリアドレス(参照)を保持します。

複数の変数が同じオブジェクトを参照することが可能で、オブジェクトの共有や変更が反映されやすい特徴があります。

例えば、以下のようなコードを考えます。

class PersonClass
{
    public string Name { get; set; }
    public int Age { get; set; }
}
void Example()
{
    PersonClass p1 = new PersonClass { Name = "Alice", Age = 25 };
    PersonClass p2 = p1;
    p2.Age = 30;
    Console.WriteLine(p1.Age); // 30 と表示される
}

この例では、p1p2は同じオブジェクトを参照しているため、p2の変更がp1にも反映されます。

値型(構造体)

一方、構造体は値型であり、変数が直接データの実体を保持します。

構造体のインスタンスはスタック領域に割り当てられることが多く、変数間でコピーが行われるとデータの複製が発生します。

これにより、値の独立性が保たれます。

以下の例を見てみましょう。

struct PersonStruct
{
    public string Name { get; set; }
    public int Age { get; set; }
}
void Example()
{
    PersonStruct p1 = new PersonStruct { Name = "Bob", Age = 40 };
    PersonStruct p2 = p1;
    p2.Age = 45;
    Console.WriteLine(p1.Age); // 40 と表示される
}

この場合、p2p1のコピーであり、p2の変更はp1に影響しません。

メモリ配置のまとめ

特徴クラス(参照型)構造体(値型)
メモリ領域ヒープスタック(または埋め込み)
変数の中身オブジェクトの参照(ポインタ)データの実体
コピー時の挙動参照のコピー(同じオブジェクト)データのコピー(独立した値)
ガベージコレクション対象対象外(スタック上のため)

この違いは、パフォーマンスやメモリ効率に影響を与えます。

構造体は小さくて頻繁に生成・破棄されるデータに向いていますが、大きなデータや複雑な継承が必要な場合はクラスが適しています。

JSONシリアル化への影響

構造体とクラスの違いは、JSONシリアル化(オブジェクトをJSON文字列に変換すること)や逆シリアル化(JSON文字列からオブジェクトに変換すること)にも影響を与えます。

ここでは、主にSystem.Text.Jsonを使った場合の挙動を中心に説明します。

シリアル化の基本動作は同じ

System.Text.Json.JsonSerializerは、構造体とクラスのどちらもシリアル化対象として扱います。

プロパティやフィールドの値をJSONのキーと値のペアに変換します。

以下のように、構造体でもクラスでも同様にJSON文字列が生成されます。

struct PersonStruct
{
    public string Name { get; set; }
    public int Age { get; set; }
}
class PersonClass
{
    public string Name { get; set; }
    public int Age { get; set; }
}
void Example()
{
    var structObj = new PersonStruct { Name = "山田太郎", Age = 30 };
    var classObj = new PersonClass { Name = "山田太郎", Age = 30 };
    string jsonStruct = JsonSerializer.Serialize(structObj);
    string jsonClass = JsonSerializer.Serialize(classObj);
    Console.WriteLine(jsonStruct);
    Console.WriteLine(jsonClass);
}

出力はどちらも

{"Name":"山田太郎","Age":30}

となります。

逆シリアル化時の注意点

逆シリアル化JsonSerializer.Deserialize<T>()では、クラスと構造体で挙動に違いが出ることがあります。

  • クラスの場合

デフォルトコンストラクタ(引数なしのコンストラクタ)が呼ばれ、プロパティに値がセットされます。

クラスは参照型なので、nullを許容することも可能です。

  • 構造体の場合

構造体は値型であり、デフォルトで引数なしコンストラクタが自動生成されますが、明示的なコンストラクタを定義できるのはC# 10以降です。

逆シリアル化時は、まずデフォルト値で初期化され、その後にJSONの値がセットされます。

nullを許容しないため、JSONに存在しないプロパティはデフォルト値になります。

このため、構造体の逆シリアル化では、JSONに含まれないプロパティがあると、構造体のフィールドは初期値(例えばintなら0、stringならnull)になります。

クラスの場合は、null許容型を使うことでより柔軟に対応できます。

イミュータブル構造体の扱い

構造体はイミュータブル(不変)に設計されることが多いですが、System.Text.Jsonはデフォルトでパラメータなしコンストラクタとsetアクセサを使って値を設定します。

イミュータブル構造体の場合、setアクセサがないため逆シリアル化が失敗することがあります。

C# 9以降ではinitアクセサを使うことでイミュータブルなプロパティを定義できますが、System.Text.Jsoninitアクセサにも対応しています。

もしイミュータブル構造体を使う場合は、initアクセサを活用すると良いでしょう。

ボックス化の影響

構造体は値型なので、object型やインターフェース型に代入するとボックス化が発生します。

シリアル化の内部処理でボックス化が起こるとパフォーマンスに影響が出ることがあります。

可能な限り具体的な型でシリアル化・逆シリアル化を行うことが望ましいです。

項目クラス構造体
シリアル化プロパティをJSONに変換同様にプロパティをJSONに変換
逆シリアル化null許容、柔軟な初期化デフォルト値で初期化
イミュータブル対応initアクセサ対応initアクセサ対応(C# 9以降)
パフォーマンス参照型のためボックス化なしボックス化に注意

構造体とクラスの違いを理解し、JSONシリアル化の要件に応じて適切な型を選択することが、効率的で安全なデータ処理につながります。

System.Text.Jsonを使った基本的なシリアル化手順

Serializeメソッドの概要

System.Text.Json 名前空間に含まれる JsonSerializerクラスの Serializeメソッドは、C#のオブジェクトをJSON形式の文字列に変換するための基本的な手段です。

構造体やクラスのインスタンスを簡単にJSONに変換でき、設定次第で出力のフォーマットやエンコーディングも調整可能です。

以下は、構造体をJSONにシリアル化するシンプルな例です。

using System;
using System.Text.Json;
using System.Text.Encodings.Web;

public struct Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

class Program
{
    static void Main()
    {
        Person person = new Person { Name = "山田太郎", Age = 30 };

        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
        };

        string jsonString = JsonSerializer.Serialize(person, options);
        Console.WriteLine(jsonString);
    }
}
{"Name":"山田太郎","Age":30}

この例では、Serializeメソッドに構造体のインスタンスを渡すだけで、プロパティ名をキー、値を値としたJSON文字列が生成されます。

デフォルトでは、改行やインデントはなくコンパクトな形式で出力されます。

Serializeメソッドはオーバーロードが複数あり、第二引数に JsonSerializerOptions を渡すことで細かい挙動を制御できます。

例えば、インデント付きの見やすいJSONを出力したい場合は以下のようにします。

var options = new JsonSerializerOptions
{
    WriteIndented = true
};
string jsonIndented = JsonSerializer.Serialize(person, options);
Console.WriteLine(jsonIndented);

出力結果は次のように整形されます。

{
  "Name": "山田太郎",
  "Age": 30
}

このように、Serializeメソッドは非常にシンプルに使えますが、オプションを活用することで用途に応じた柔軟なJSON生成が可能です。

Deserializeメソッドの概要

JsonSerializerクラスの Deserializeメソッドは、JSON文字列をC#のオブジェクトに変換するために使います。

JSONのキーとC#のプロパティ名が一致していれば、自動的にマッピングされてインスタンスが生成されます。

構造体を逆シリアル化する例を示します。

using System;
using System.Text.Json;
public struct Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}
class Program
{
    static void Main()
    {
        string jsonString = "{\"Name\":\"山田太郎\",\"Age\":30}";
        Person person = JsonSerializer.Deserialize<Person>(jsonString);
        Console.WriteLine($"Name: {person.Name}, Age: {person.Age}");
    }
}
Name: 山田太郎, Age: 30

Deserialize<T>メソッドはジェネリックメソッドで、型パラメータに変換先の型を指定します。

JSONの内容が型のプロパティに対応していれば、正しく値がセットされます。

JSONに存在しないプロパティは、構造体のフィールドではデフォルト値(例えばintなら0、stringならnull)になります。

逆にJSONに余分なキーがあっても無視されます。

Deserializeメソッドもオーバーロードがあり、JsonSerializerOptions を渡すことで、プロパティ名の大文字小文字の区別や、日付フォーマットの指定など細かい挙動を制御できます。

例えば、JSONのキーがキャメルケースnameでC#のプロパティがパスカルケースNameの場合、以下のオプションを使うとマッピングが可能です。

var options = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true
};
Person person = JsonSerializer.Deserialize<Person>(jsonString, options);

このように、DeserializeメソッドはJSON文字列から構造体やクラスのインスタンスを簡単に復元でき、オプションで柔軟に挙動を調整できます。

プロパティの可視性とシリアル化対象の制御

publicフィールドとプロパティの扱い

System.Text.Json のシリアル化・逆シリアル化では、デフォルトでpublicなプロパティが対象となります。

つまり、public修飾子が付いたプロパティのgetおよびsetアクセサが存在する場合に限り、JSONのキーとマッピングされます。

一方、publicフィールドはデフォルトではシリアル化・逆シリアル化の対象外です。

これは、フィールドは直接のデータアクセスであり、プロパティのようにカプセル化やロジックを挟めないため、意図しないデータの公開を防ぐ設計思想に基づいています。

以下の例で挙動を確認しましょう。

using System;
using System.Text.Json;
using System.Text.Encodings.Web;
public struct SampleStruct
{
    public string PublicField; // フィールド
    public string PublicProperty { get; set; } // プロパティ
    private string PrivateProperty { get; set; } // privateプロパティ
}
class Program
{
    static void Main()
    {
        var sample = new SampleStruct
        {
            PublicField = "フィールドの値",
            PublicProperty = "プロパティの値"
        };
        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
        };
        string json = JsonSerializer.Serialize(sample, options);
        Console.WriteLine(json);
    }
}
{"PublicProperty":"プロパティの値"}

このように、PublicFieldはJSONに含まれず、PublicPropertyのみがシリアル化されています。

もしpublicフィールドもシリアル化したい場合は、JsonInclude属性を付与するか、JsonSerializerOptionsIncludeFieldsプロパティをtrueに設定します。

using System.Text.Json.Serialization;
public struct SampleStruct
{
    [JsonInclude]
    public string PublicField;
    public string PublicProperty { get; set; }
}

または

var options = new JsonSerializerOptions
{
    IncludeFields = true
};
string json = JsonSerializer.Serialize(sample, options);

これにより、フィールドもJSONに含まれるようになります。

getのみプロパティの逆変換挙動

System.Text.Jsonは、setアクセサが存在しないgetのみのプロパティをシリアル化の際には値を取得してJSONに含めますが、逆シリアル化時には値を設定できないため無視されます。

以下の例を見てみましょう。

using System;
using System.Text.Json;
using System.Text.Encodings.Web;

public struct ReadOnlyStruct
{
    public string Name { get; } // getのみプロパティ
    public int Age { get; set; }
    public ReadOnlyStruct(string name, int age)
    {
        Name = name;
        Age = age;
    }
}
class Program
{
    static void Main()
    {
        var obj = new ReadOnlyStruct("山田太郎", 30);
        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
        };
        // シリアル化
        string json = JsonSerializer.Serialize(obj, options);
        Console.WriteLine(json);
        // 逆シリアル化
        string jsonInput = "{\"Name\":\"佐藤花子\",\"Age\":25}";

        var deserialized = JsonSerializer.Deserialize<ReadOnlyStruct>(jsonInput);
        Console.WriteLine($"Name: {deserialized.Name}, Age: {deserialized.Age}");
    }
}
{"Name":"山田太郎","Age":30}
Name: , Age: 25

Nameはシリアル化時に含まれていますが、逆シリアル化時にはsetアクセサがないため値が設定されず、デフォルトの空文字列(stringのデフォルトはnullですが、ここはコンストラクタで初期化されていないため空)となっています。

一方、Agesetアクセサがあるため正しく復元されています。

この挙動は、イミュータブルな型を扱う際に注意が必要です。

getのみのプロパティを持つ構造体やクラスは、逆シリアル化で値がセットされないため、期待通りに復元されないことがあります。

init-onlyプロパティを利用したイミュータブル設計

C# 9以降で導入されたinitアクセサを使うと、イミュータブルなオブジェクト設計が可能になります。

initアクセサはオブジェクト初期化時にのみ値を設定でき、その後は読み取り専用となります。

System.Text.Jsoninitアクセサに対応しており、逆シリアル化時にinitアクセサを使って値をセットできます。

これにより、イミュータブルな構造体やクラスでもJSONの逆シリアル化が可能になります。

以下はinitアクセサを使った構造体の例です。

using System;
using System.Text.Json;
using System.Text.Encodings.Web;

public struct ImmutablePerson
{
    public string Name { get; init; }
    public int Age { get; init; }
}
class Program
{
    static void Main()
    {
        var person = new ImmutablePerson { Name = "山田太郎", Age = 30 };
        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
        };
        string json = JsonSerializer.Serialize(person, options);
        Console.WriteLine(json);
        string jsonInput = "{\"Name\":\"佐藤花子\",\"Age\":25}";
        var deserialized = JsonSerializer.Deserialize<ImmutablePerson>(jsonInput);
        Console.WriteLine($"Name: {deserialized.Name}, Age: {deserialized.Age}");
    }
}
{"Name":"山田太郎","Age":30}
Name: 佐藤花子, Age: 25

initアクセサを使うことで、イミュータブルな設計を保ちつつ、JSONの逆シリアル化で値を正しく復元できます。

ただし、initアクセサはC# 9以降の機能であるため、古いバージョンのC#や.NET環境では利用できません。

また、構造体に明示的なコンストラクタを定義している場合は、initアクセサとの組み合わせに注意が必要です。

このように、プロパティの可視性やアクセサの種類によって、System.Text.Jsonのシリアル化・逆シリアル化の挙動が変わります。

特にイミュータブルな設計を行う場合は、initアクセサを活用することで柔軟かつ安全にJSON変換を行えます。

JsonSerializerOptionsで変換をカスタマイズ

System.Text.JsonJsonSerializerOptionsは、JSONのシリアル化・逆シリアル化の挙動を細かく制御できる設定クラスです。

ここでは、よく使われるオプションを中心に、インデントや日時フォーマット、Unicodeエンコーディング、Null値の扱いについて解説します。

インデントと書式設定

JSONを人間が読みやすい形で出力したい場合、WriteIndentedプロパティをtrueに設定します。

これにより、改行やスペースが挿入され、階層構造が見やすくなります。

using System;
using System.Text.Json;
using System.Text.Encodings.Web;

public struct Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}
class Program
{
    static void Main()
    {
        var person = new Person { Name = "山田太郎", Age = 30 };
        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            WriteIndented = true
        };
        string json = JsonSerializer.Serialize(person, options);
        Console.WriteLine(json);
    }
}
{
  "Name": "山田太郎",
  "Age": 30
}

WriteIndentedfalse(デフォルト)にすると、改行や空白がなくコンパクトなJSONが生成されます。

ファイルサイズを小さくしたい場合や通信量を抑えたい場合はfalseのままにします。

タイムゾーンと日時フォーマット

日時型DateTimeDateTimeOffsetのシリアル化は、デフォルトでISO 8601形式の文字列に変換されます。

タイムゾーンはUTCに変換されることが多いですが、JsonSerializerOptionsで細かく制御できます。

using System;
using System.Text.Json;
using System.Text.Encodings.Web;
public struct Event
{
    public string Title { get; set; }
    public DateTime EventDate { get; set; }
}
class Program
{
    static void Main()
    {
        var evt = new Event
        {
            Title = "会議",
            EventDate = new DateTime(2024, 6, 1, 14, 30, 0, DateTimeKind.Local)
        };
        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            WriteIndented = true
        };
        string json = JsonSerializer.Serialize(evt, options);
        Console.WriteLine(json);
    }
}
{
  "Title": "会議",
  "EventDate": "2024-06-01T14:30:00"
}

DateTimeKind.Localの場合、タイムゾーン情報は含まれません。

UTCに変換したい場合は、DateTimeToUniversalTime()で変換してからシリアル化するか、DateTimeOffsetを使う方法があります。

System.Text.Json自体には日時フォーマットを直接指定するオプションはありません。

カスタムフォーマットが必要な場合は、カスタムコンバータを実装する必要があります。

Unicodeエンコーディングの調整

日本語をエスケープしない設定

デフォルトでは、System.Text.JsonはASCII以外の文字をUnicodeエスケープ(\uXXXX形式)で出力することがあります。

日本語などのマルチバイト文字をそのまま出力したい場合は、JsonSerializerOptionsEncoderプロパティにJavaScriptEncoderを指定します。

using System;
using System.Text.Json;
using System.Text.Encodings.Web;
using System.Text.Unicode;
public struct Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}
class Program
{
    static void Main()
    {
        var person = new Person { Name = "山田太郎", Age = 30 };
        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
            WriteIndented = true
        };
        string json = JsonSerializer.Serialize(person, options);
        Console.WriteLine(json);
    }
}
{
  "Name": "山田太郎",
  "Age": 30
}

この設定により、日本語がエスケープされずにそのまま表示されます。

絵文字などサロゲートペアへの対応

絵文字や一部の特殊文字はサロゲートペアとしてUTF-16で表現されます。

JavaScriptEncoder.Create(UnicodeRanges.All)を使うことで、これらの文字もエスケープされずにそのままJSONに含められます。

public struct EmojiSample
{
    public string Message { get; set; }
}
class Program
{
    static void Main()
    {
        var sample = new EmojiSample { Message = "こんにちは😊" };
        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
            WriteIndented = true
        };
        string json = JsonSerializer.Serialize(sample, options);
        Console.WriteLine(json);
    }
}
{
  "Message": "こんにちは😊"
}

このように、絵文字もエスケープされずに出力されます。

Null値の書き出し制御

デフォルトでは、nullのプロパティもJSONに含まれます。

nullの値を持つプロパティをJSONに含めたくない場合は、DefaultIgnoreConditionプロパティを設定します。

using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Encodings.Web;
public struct Person
{
    public string Name { get; set; }
    public string? Nickname { get; set; }
}
class Program
{
    static void Main()
    {
        var person = new Person { Name = "山田太郎", Nickname = null };
        var options = new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
            WriteIndented = true
        };
        string json = JsonSerializer.Serialize(person, options);
        Console.WriteLine(json);
    }
}
{
  "Name": "山田太郎"
}

Nicknamenullのため、JSONには含まれていません。

DefaultIgnoreConditionには以下のような値があります。

説明
Never(デフォルト)nullでもすべてのプロパティを出力
WhenWritingNullnullのプロパティは出力しない
WhenWritingDefaultデフォルト値(0falseなど)も出力しない

この設定を使い分けることで、JSONのサイズを削減したり、API仕様に合わせた出力が可能です。

属性による詳細設定

System.Text.Jsonでは、属性を使ってシリアル化・逆シリアル化の挙動を細かく制御できます。

ここでは、代表的な属性を使った設定方法を解説します。

JsonPropertyNameでキー名を変更

JsonPropertyName属性を使うと、C#のプロパティ名とは異なるJSONのキー名を指定できます。

API仕様や外部システムとの連携でJSONのキー名をカスタマイズしたい場合に便利です。

using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Encodings.Web;
public struct Person
{
    [JsonPropertyName("full_name")]
    public string Name { get; set; }
    [JsonPropertyName("age_years")]
    public int Age { get; set; }
}
class Program
{
    static void Main()
    {
        var person = new Person { Name = "山田太郎", Age = 30 };
        string json = JsonSerializer.Serialize(person, new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            WriteIndented = true
        });
        Console.WriteLine(json);
    }
}
{
  "full_name": "山田太郎",
  "age_years": 30
}

このように、Nameプロパティはfull_nameAgeプロパティはage_yearsというキー名でJSONに出力されます。

JsonIgnoreでフィールドを除外

JsonIgnore属性を付けると、そのプロパティやフィールドはシリアル化・逆シリアル化の対象から除外されます。

機密情報や不要なデータをJSONに含めたくない場合に使います。

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

public struct User
{
    public string Username { get; set; }
    [JsonIgnore]
    public string Password { get; set; }
}
class Program
{
    static void Main()
    {
        var user = new User { Username = "user1", Password = "secret" };
        string json = JsonSerializer.Serialize(user, new JsonSerializerOptions { WriteIndented = true });
        Console.WriteLine(json);
    }
}
{
  "Username": "user1"
}

PasswordはJSONに含まれていません。

JsonIncludeでprivateメンバを対象にする

デフォルトでは、System.Text.Jsonpublicなプロパティやフィールドのみをシリアル化対象とします。

privateinternalのメンバは無視されますが、JsonInclude属性を付けることで、非公開メンバもシリアル化・逆シリアル化の対象にできます。

using System.Text.Json.Serialization;
using System.Text.Json;
using System.Text.Encodings.Web;

public struct Product
{
    [JsonInclude]
    private int Id { get; set; }
    public string Name { get; set; }
    public Product(int id, string name)
    {
        Id = id;
        Name = name;
    }
    public int GetId() => Id;
}
class Program
{
    static void Main()
    {
        var product = new Product(1001, "ノートパソコン");
        string json = JsonSerializer.Serialize(product, new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            WriteIndented = true
        });
        Console.WriteLine(json);
    }
}
{
  "Id": 1001,
  "Name": "ノートパソコン"
}

Idprivateですが、JsonIncludeを付けることでJSONに含まれています。

JsonNumberHandlingでenumを数値にする

enum型のプロパティはデフォルトで名前(文字列)としてシリアル化されますが、JsonNumberHandling属性を使うと数値としてシリアル化・逆シリアル化が可能です。

using System;
using System.Text.Json;
using System.Text.Json.Serialization;
public enum Status
{
    Pending = 0,
    Approved = 1,
    Rejected = 2
}
public struct Request
{
    public string Requester { get; set; }
    [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsNumber)]
    public Status CurrentStatus { get; set; }
}
class Program
{
    static void Main()
    {
        var req = new Request { Requester = "佐藤", CurrentStatus = Status.Approved };
        string json = JsonSerializer.Serialize(req, new JsonSerializerOptions { WriteIndented = true });
        Console.WriteLine(json);
        string jsonInput = "{\"Requester\":\"佐藤\",\"CurrentStatus\":1}";
        var deserialized = JsonSerializer.Deserialize<Request>(jsonInput);
        Console.WriteLine($"Requester: {deserialized.Requester}, Status: {deserialized.CurrentStatus}");
    }
}
{
  "Requester": "佐藤",
  "CurrentStatus": 1
}
Requester: 佐藤, Status: Approved

この例では、CurrentStatusが数値1としてJSONに出力され、逆シリアル化時も数値からenumに正しく変換されています。

これらの属性を活用することで、JSONのキー名や含めるデータ、データ形式を柔軟に制御できます。

API仕様や外部システムとの連携に合わせて適切に設定しましょう。

ネストした構造体と複合コレクション

構造体の入れ子をシリアル化する場合

C#の構造体は他の構造体やクラスのメンバとしてネスト(入れ子)にすることが可能です。

System.Text.Jsonはネストされた構造体も自動的にシリアル化・逆シリアル化できます。

ネスト構造体の各プロパティはJSONの階層構造として表現されます。

以下は、ネストした構造体の例です。

using System;
using System.Text.Json;
using System.Text.Encodings.Web;
public struct Address
{
    public string City { get; set; }
    public string Street { get; set; }
}
public struct Person
{
    public string Name { get; set; }
    public Address HomeAddress { get; set; }
}
class Program
{
    static void Main()
    {
        var person = new Person
        {
            Name = "山田太郎",
            HomeAddress = new Address
            {
                City = "東京",
                Street = "千代田区1-1-1"
            }
        };
        string json = JsonSerializer.Serialize(person, new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            WriteIndented = true
        });
        Console.WriteLine(json);
    }
}
{
  "Name": "山田太郎",
  "HomeAddress": {
    "City": "東京",
    "Street": "千代田区1-1-1"
  }
}

このように、HomeAddressというキーの中にさらにオブジェクトがネストされている形でJSONが生成されます。

逆シリアル化も同様に、JSONの階層構造に対応したネストした構造体に正しくマッピングされます。

ネストが深くなっても、System.Text.Jsonは再帰的に処理するため特別な設定は不要です。

ただし、循環参照がある場合は例外が発生するため注意が必要です。

List<T>や配列との組み合わせ

構造体のメンバとしてList<T>や配列を持つこともよくあります。

System.Text.Jsonはこれらのコレクションもシリアル化・逆シリアル化に対応しています。

以下は、構造体の中にList<T>を持つ例です。

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Encodings.Web;
public struct Item
{
    public string Name { get; set; }
    public int Quantity { get; set; }
}
public struct Order
{
    public int OrderId { get; set; }
    public List<Item> Items { get; set; }
}
class Program
{
    static void Main()
    {
        var order = new Order
        {
            OrderId = 123,
            Items = new List<Item>
            {
                new Item { Name = "リンゴ", Quantity = 3 },
                new Item { Name = "バナナ", Quantity = 5 }
            }
        };
        string json = JsonSerializer.Serialize(order, new JsonSerializerOptions
        {
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            WriteIndented = true
        });
        Console.WriteLine(json);
    }
}
{
  "OrderId": 123,
  "Items": [
    {
      "Name": "リンゴ",
      "Quantity": 3
    },
    {
      "Name": "バナナ",
      "Quantity": 5
    }
  ]
}

配列も同様に扱えます。

public struct OrderWithArray
{
    public int OrderId { get; set; }
    public Item[] Items { get; set; }
}

List<T>と配列はJSONの配列として表現され、逆シリアル化も問題なく行えます。

多次元配列とジャグ配列の注意点

多次元配列(例えばint[,])やジャグ配列(配列の配列、int[][])もJSONに変換できますが、扱いに注意が必要です。

多次元配列(int[,])

多次元配列はJSONの配列の配列としてシリアル化されますが、System.Text.Jsonは多次元配列の逆シリアル化をサポートしていません。

つまり、シリアル化は可能ですが、逆シリアル化は例外が発生します。

int[,] matrix = new int[,] { { 1, 2 }, { 3, 4 } };
string json = JsonSerializer.Serialize(matrix);
Console.WriteLine(json);
[[1,2],[3,4]]

しかし、以下のように逆シリアル化すると例外になります。

var deserialized = JsonSerializer.Deserialize<int[,]>(json); // 例外発生

ジャグ配列(int[][])

ジャグ配列は配列の配列であり、System.Text.Jsonはこれを正しくシリアル化・逆シリアル化できます。

int[][] jaggedArray = new int[][]
{
    new int[] { 1, 2 },
    new int[] { 3, 4, 5 }
};
string json = JsonSerializer.Serialize(jaggedArray);
Console.WriteLine(json);
var deserialized = JsonSerializer.Deserialize<int[][]>(json);
Console.WriteLine($"Length of first array: {deserialized[0].Length}");
[[1,2],[3,4,5]]
Length of first array: 2

ジャグ配列は各要素の配列の長さが異なっても問題なく扱えます。

配列の種類シリアル化逆シリアル化備考
一次元配列一般的な配列
ジャグ配列配列の配列
多次元配列×逆シリアル化はサポート外

多次元配列を使う場合は、逆シリアル化が必要ならジャグ配列に置き換えることを検討してください。

このように、ネストした構造体や複合的なコレクションもSystem.Text.Jsonで柔軟に扱えますが、多次元配列の逆シリアル化には制限があるため注意が必要です。

既存JSONを構造体へマッピングするテクニック

プロパティ名のキャメルケース/パスカルケース変換

C#のプロパティ名は一般的にパスカルケース(例:UserName)で記述されますが、JSONのキーはキャメルケース(例:userName)で表現されることが多いです。

この命名規則の違いにより、逆シリアル化時にプロパティ名が一致せず、値が正しくマッピングされないことがあります。

System.Text.Jsonでは、JsonSerializerOptionsPropertyNamingPolicyプロパティを使って、JSONのキー名とC#のプロパティ名の変換ルールを指定できます。

キャメルケースに変換するには、JsonNamingPolicy.CamelCaseを設定します。

以下の例を見てみましょう。

using System;
using System.Text.Json;
public struct User
{
    public string UserName { get; set; }
    public int Age { get; set; }
}
class Program
{
    static void Main()
    {
        string json = "{\"userName\":\"山田太郎\",\"age\":30}";
        var options = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            PropertyNameCaseInsensitive = true // 大文字小文字を無視する設定も推奨
        };
        User user = JsonSerializer.Deserialize<User>(json, options);
        Console.WriteLine($"UserName: {user.UserName}, Age: {user.Age}");
    }
}
UserName: 山田太郎, Age: 30

このように、PropertyNamingPolicyCamelCaseに設定することで、JSONのキャメルケースのキー名がC#のパスカルケースのプロパティに正しくマッピングされます。

さらにPropertyNameCaseInsensitivetrueにすると、大文字小文字の違いも無視されるため、より柔軟に対応できます。

不足プロパティと余剰プロパティの処理

JSONとC#の構造体のプロパティが完全に一致しない場合、以下のような状況が発生します。

  • JSONに存在しないプロパティ(不足プロパティ)

逆シリアル化時にC#の構造体に存在するが、JSONに含まれていないプロパティは、構造体のデフォルト値で初期化されます。

例えば、int型なら0string型ならnullになります。

  • JSONに余分なプロパティ(余剰プロパティ)

JSONに存在するが、C#の構造体に対応するプロパティがない場合は、デフォルトで無視されます。

例外は発生しません。

以下の例で確認します。

using System;
using System.Text.Json;
public struct Product
{
    public string Name { get; set; }
    public int Price { get; set; }
    public string? Description { get; set; } // JSONにない場合はnullになる
}
class Program
{
    static void Main()
    {
        string json = "{\"Name\":\"ノートパソコン\",\"Price\":100000,\"Stock\":50}";
        Product product = JsonSerializer.Deserialize<Product>(json);
        Console.WriteLine($"Name: {product.Name}, Price: {product.Price}, Description: {product.Description ?? "null"}");
    }
}
Name: ノートパソコン, Price: 100000, Description: null

この例では、JSONにStockという余剰プロパティがありますが無視され、DescriptionはJSONに存在しないためnullとなっています。

もし、余剰プロパティを検出したい場合は、JsonSerializerOptionsUnknownTypeHandlingやカスタムコンバータを使う必要がありますが、標準機能では無視されるのが基本です。

部分読み込みに便利なJsonDocument

大量のJSONデータや複雑な構造のJSONを扱う場合、すべてを一度に逆シリアル化するのは非効率なことがあります。

JsonDocumentはJSONをDOM(Document Object Model)として読み込み、必要な部分だけを抽出・操作できる機能です。

JsonDocumentを使うと、JSONの一部だけを取り出して構造体にマッピングすることが可能です。

以下は、JSONの一部だけを読み込む例です。

using System;
using System.Text.Json;
public struct User
{
    public string UserName { get; set; }
    public int Age { get; set; }
}
class Program
{
    static void Main()
    {
        string json = @"
        {
            ""user"": {
                ""userName"": ""山田太郎"",
                ""age"": 30
            },
            ""metadata"": {
                ""requestId"": ""abc123""
            }
        }";
        using JsonDocument doc = JsonDocument.Parse(json);
        JsonElement root = doc.RootElement;
        // "user"オブジェクトだけを取り出す
        JsonElement userElement = root.GetProperty("user");
        // JsonElementから構造体に逆シリアル化
        User user = JsonSerializer.Deserialize<User>(userElement.GetRawText(), new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        });
        Console.WriteLine($"UserName: {user.UserName}, Age: {user.Age}");
    }
}
UserName: 山田太郎, Age: 30

このように、JsonDocumentを使うとJSON全体をオブジェクトにマッピングせずに、必要な部分だけを抽出して効率的に処理できます。

特に大規模なJSONや部分的にしか使わないデータがある場合に有効です。

これらのテクニックを活用することで、既存のJSONデータを柔軟かつ効率的にC#の構造体にマッピングできます。

命名規則の違いや不完全なデータにも対応しやすくなります。

変換エラーの診断と例外ハンドリング

JSONのシリアル化や逆シリアル化を行う際、データの不整合やフォーマットの誤りによってエラーが発生することがあります。

ここでは、System.Text.Jsonで発生する代表的な例外であるJsonExceptionの読み解き方や、デバッグログの活用方法、さらに安全に変換処理を行うためのガード節やTryParseパターンについて解説します。

JsonExceptionの読み解き方

JsonExceptionは、JSONの解析やマッピング処理中に問題が発生した場合にスローされる例外です。

例えば、JSONの構文エラー、型の不一致、期待されるプロパティが欠落している場合などに発生します。

例外のメッセージには、エラーの原因や発生箇所が記載されているため、まずはメッセージをよく確認することが重要です。

典型的なメッセージ例を挙げます。

  • 「The JSON value could not be converted to System.Int32」

→ JSONの値が整数に変換できない場合。

例えば、文字列が数値に変換できないときに発生します。

  • 「Expected start of object ‘{‘」

→ JSONの構文が期待と異なる場合。

例えば、オブジェクトの開始が{でないとき。

  • 「Required property ‘Name’ not found」

→ 逆シリアル化時に必須のプロパティがJSONに存在しない場合。

例外のPathプロパティを見ると、どのJSONパスでエラーが起きたかがわかります。

これを手掛かりに、問題のあるJSON部分を特定しましょう。

try
{
    var obj = JsonSerializer.Deserialize<Person>(jsonString);
}
catch (JsonException ex)
{
    Console.WriteLine($"JSON変換エラー: {ex.Message}");
    Console.WriteLine($"エラー発生箇所: {ex.Path}");
}

このように例外情報をログに出すことで、原因の特定が容易になります。

デバッグログの活用

変換エラーの原因を調査する際は、JSON文字列の内容や変換対象の型情報をログに出力することが有効です。

特に大規模なJSONや複雑な構造体の場合、どの部分で問題が起きているかを把握しやすくなります。

  • JSON文字列のログ出力

変換前のJSONをログに残すことで、入力データの問題を確認できます。

  • 変換対象の型の状態確認

逆シリアル化後にオブジェクトの状態をログに出すことで、どのプロパティが正しくセットされているかを把握できます。

  • 例外発生時のスタックトレース

例外のスタックトレースをログに記録し、どのコード行でエラーが発生したかを特定します。

以下は例外発生時に詳細ログを出す例です。

try
{
    var obj = JsonSerializer.Deserialize<Person>(jsonString);
}
catch (JsonException ex)
{
    Console.WriteLine("JSON変換に失敗しました。");
    Console.WriteLine($"メッセージ: {ex.Message}");
    Console.WriteLine($"パス: {ex.Path}");
    Console.WriteLine($"スタックトレース: {ex.StackTrace}");
    Console.WriteLine($"入力JSON: {jsonString}");
}

ログを活用して問題の切り分けを行い、JSONの形式や構造体の定義を見直しましょう。

ガード節とTryParseパターン

JSONの逆シリアル化は例外が発生しやすいため、例外処理を適切に行うことが重要です。

例外をキャッチして処理を継続する方法のほかに、変換前に入力の妥当性をチェックするガード節や、例外を使わずに安全に変換を試みるTryParseパターンを活用する方法があります。

ガード節の例

JSON文字列が空やnullでないか、基本的な形式を満たしているかを事前にチェックします。

if (string.IsNullOrWhiteSpace(jsonString))
{
    Console.WriteLine("JSON文字列が空です。処理を中断します。");
    return;
}
if (!jsonString.TrimStart().StartsWith("{"))
{
    Console.WriteLine("JSONの形式が不正です。");
    return;
}
try
{
    var obj = JsonSerializer.Deserialize<Person>(jsonString);
    // 正常処理
}
catch (JsonException ex)
{
    // 例外処理
}

TryParseパターンの実装例

System.Text.Jsonには標準でTryDeserializeのようなメソッドはありませんが、try-catchをラップして安全に変換を試みるメソッドを自作できます。

public static bool TryDeserialize<T>(string json, out T? result)
{
    try
    {
        result = JsonSerializer.Deserialize<T>(json);
        return true;
    }
    catch (JsonException)
    {
        result = default;
        return false;
    }
}
if (TryDeserialize<Person>(jsonString, out var person))
{
    Console.WriteLine($"Name: {person.Name}");
}
else
{
    Console.WriteLine("JSONの逆シリアル化に失敗しました。");
}

この方法で例外を呼び出し元に伝えずに安全に処理を進められます。

これらの方法を組み合わせて、JSON変換時のエラーを効率的に診断し、堅牢な例外処理を実装しましょう。

特に外部からの入力を扱う場合は、エラー発生時の情報を適切にログに残し、ユーザーや開発者が原因を特定しやすい設計が重要です。

速度とメモリ効率を向上させるポイント

JSONのシリアル化・逆シリアル化は便利ですが、大量のデータや高頻度の処理ではパフォーマンスやメモリ使用量が問題になることがあります。

ここでは、System.Text.Jsonを使った際に速度とメモリ効率を向上させるためのポイントを解説します。

既定のシリアル化とカスタムコンバータ比較

System.Text.Jsonの既定のシリアル化は、リフレクションを使ってオブジェクトのプロパティを動的に読み書きします。

この方法は汎用性が高い反面、リフレクションのオーバーヘッドがあり、特に大量のオブジェクトを処理する場合はパフォーマンスに影響します。

一方、カスタムコンバータを実装すると、特定の型に対して専用のシリアル化・逆シリアル化ロジックを記述でき、リフレクションを使わずに高速化が可能です。

例えば、構造体のフィールドを直接Utf8JsonWriterで書き出すことで、余計な処理を省けます。

以下はカスタムコンバータの簡単な例です。

using System;
using System.Text.Json;
using System.Text.Json.Serialization;
public struct Point
{
    public int X { get; set; }
    public int Y { get; set; }
}
public class PointConverter : JsonConverter<Point>
{
    public override Point Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        int x = 0, y = 0;
        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
                break;
            if (reader.TokenType == JsonTokenType.PropertyName)
            {
                string propName = reader.GetString();
                reader.Read();
                if (propName == "X")
                    x = reader.GetInt32();
                else if (propName == "Y")
                    y = reader.GetInt32();
            }
        }
        return new Point { X = x, Y = y };
    }
    public override void Write(Utf8JsonWriter writer, Point value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        writer.WriteNumber("X", value.X);
        writer.WriteNumber("Y", value.Y);
        writer.WriteEndObject();
    }
}

このコンバータを登録すると、Point型のシリアル化・逆シリアル化が高速かつ効率的に行えます。

var options = new JsonSerializerOptions();
options.Converters.Add(new PointConverter());
var point = new Point { X = 10, Y = 20 };
string json = JsonSerializer.Serialize(point, options);
Console.WriteLine(json);

カスタムコンバータは、特に複雑な型やパフォーマンスが重要な場面で効果的です。

Utf8JsonWriterとSpan型を使った高速化

Utf8JsonWriterは、System.Text.Jsonの低レベルAPIで、UTF-8エンコードされたJSONを高速かつ効率的に書き出せます。

Serializeメソッドの内部でも使われていますが、直接利用することでメモリ割り当てを最小限に抑え、パフォーマンスを向上させられます。

また、Span<T>ReadOnlySpan<T>を活用すると、配列や文字列のコピーを避けてメモリ効率を高められます。

これらはスタック上の連続したメモリ領域を表し、GCの負担を減らします。

以下はUtf8JsonWriterを使ったシンプルな例です。

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

public struct Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

class Program
{
    static void Main()
    {
        var person = new Person { Name = "山田太郎", Age = 30 };
        var buffer = new ArrayBufferWriter<byte>(); // using 削除
        using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true }))
        {
            writer.WriteStartObject();
            writer.WriteString("Name", person.Name);
            writer.WriteNumber("Age", person.Age);
            writer.WriteEndObject();
        }
        string json = Encoding.UTF8.GetString(buffer.WrittenSpan);
        Console.WriteLine(json);
    }
}
{
  "Name": "山田太郎",
  "Age": 30
}

この方法は、特に大量のデータを高速にシリアル化したい場合や、メモリ割り当てを抑えたい場合に有効です。

構造体をreadonlyにしてボックス化を防ぐ

構造体は値型であるため、object型やインターフェース型に代入するとボックス化が発生します。

ボックス化は値型をヒープ上にコピーする処理で、パフォーマンス低下やGC負荷増加の原因になります。

readonly structとして構造体を定義すると、以下のメリットがあります。

  • イミュータブル設計が促進される

フィールドの変更を防ぎ、安全な設計が可能です。

  • ボックス化の抑制

readonly structinパラメータとして渡す際にボックス化を防ぎやすくなります。

例えば、以下のように定義します。

public readonly struct Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

readonlyを付けることで、Pointのインスタンスは不変となり、inキーワードを使ったメソッド呼び出しでボックス化を回避できます。

public static void PrintPoint(in Point p)
{
    Console.WriteLine($"X: {p.X}, Y: {p.Y}");
}

このように、構造体をreadonlyにすることは、パフォーマンス最適化の一環として有効です。

これらのポイントを踏まえ、用途に応じて既定のシリアル化とカスタムコンバータを使い分けたり、低レベルAPIやreadonly structを活用することで、System.Text.Jsonのパフォーマンスとメモリ効率を大幅に向上させられます。

カスタムコンバータの実装例

System.Text.Jsonでは、標準のシリアル化・逆シリアル化の挙動をカスタマイズしたい場合に、JsonConverter<T>を継承したカスタムコンバータを作成できます。

ここでは、カスタムコンバータの作成手順を具体的に解説し、読み込み(Read)と書き込み(Write)の実装方法を示します。

また、属性を使って特定の型にだけコンバータを適用する方法も紹介します。

JsonConverter<T>の派生クラス作成手順

カスタムコンバータは、JsonConverter<T>を継承し、ReadメソッドとWriteメソッドをオーバーライドして実装します。

Tは変換対象の型です。

読み込み(Read)の実装ステップ

Readメソッドは、Utf8JsonReaderを使ってJSONを解析し、T型のオブジェクトを生成します。

主なステップは以下の通りです。

  1. JSONトークンの開始を確認

通常はオブジェクトの開始{を期待します。

reader.Read()でトークンを進めながら確認します。

  1. プロパティ名の取得と値の読み込み

reader.TokenTypePropertyNameのときにreader.GetString()でプロパティ名を取得し、次のトークンで値を読み取ります。

  1. 値の格納

読み取った値をローカル変数に格納します。

  1. オブジェクトの終了を確認

}で終了するまでループします。

  1. オブジェクトの生成

収集した値を使ってT型のインスタンスを生成し、返します。

以下は、Point構造体のカスタムコンバータのReadメソッド例です。

public override Point Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
    int x = 0, y = 0;
    if (reader.TokenType != JsonTokenType.StartObject)
        throw new JsonException("Expected start of object.");
    while (reader.Read())
    {
        if (reader.TokenType == JsonTokenType.EndObject)
            break;
        if (reader.TokenType == JsonTokenType.PropertyName)
        {
            string propertyName = reader.GetString();
            reader.Read();
            if (propertyName == "X")
                x = reader.GetInt32();
            else if (propertyName == "Y")
                y = reader.GetInt32();
            else
                reader.Skip();
        }
    }
    return new Point { X = x, Y = y };
}

書き込み(Write)の実装ステップ

Writeメソッドは、Utf8JsonWriterを使ってT型のオブジェクトをJSONに書き出します。

主なステップは以下の通りです。

  1. オブジェクトの開始を書き込む

writer.WriteStartObject()を呼びます。

  1. プロパティ名と値を書き込む

writer.WriteNumberwriter.WriteStringなどのメソッドでプロパティ名と値を出力します。

  1. オブジェクトの終了を書き込む

writer.WriteEndObject()を呼びます。

以下は、Point構造体のWriteメソッド例です。

public override void Write(Utf8JsonWriter writer, Point value, JsonSerializerOptions options)
{
    writer.WriteStartObject();
    writer.WriteNumber("X", value.X);
    writer.WriteNumber("Y", value.Y);
    writer.WriteEndObject();
}

属性でコンバータを部分的に指定

カスタムコンバータは、JsonSerializerOptionsに登録してグローバルに適用する方法のほか、特定の型やプロパティにだけ適用することも可能です。

これには[JsonConverter]属性を使います。

以下の例では、Point構造体に直接属性を付けてコンバータを指定しています。

using System.Text.Json.Serialization;
[JsonConverter(typeof(PointConverter))]
public struct Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

このようにすると、Point型のシリアル化・逆シリアル化時に自動的にPointConverterが使われます。

呼び出し側でJsonSerializerOptionsにコンバータを登録する必要がなくなり、コードの見通しが良くなります。

また、クラスや構造体のプロパティ単位で指定することも可能です。

public class Shape
{
    [JsonConverter(typeof(PointConverter))]
    public Point Center { get; set; }
}

この場合、ShapeCenterプロパティだけにカスタムコンバータが適用されます。

カスタムコンバータを活用することで、標準のシリアル化では対応できない特殊なフォーマットや独自の変換ルールを柔軟に実装できます。

ReadWriteのメソッドを正しく実装し、必要に応じて属性で適用範囲を制御しましょう。

他ライブラリとの比較

C#でJSONのシリアル化・逆シリアル化を行う際、System.Text.Json以外にも代表的なライブラリが存在します。

ここでは、特に広く使われているNewtonsoft.Json(Json.NET)と、高速化を追求したUtf8Jsonについて、特徴や性能面での違いを解説します。

Newtonsoft.Json (Json.NET)

Newtonsoft.Jsonは長年にわたりC#のJSON処理でデファクトスタンダードとなっているライブラリです。

豊富な機能と柔軟なカスタマイズ性が特徴ですが、System.Text.Jsonの登場により標準化が進んでいます。

属性の相互運用性

Newtonsoft.Jsonは独自の属性を多数提供しており、例えば[JsonProperty][JsonIgnore]などが代表的です。

一方、System.Text.Json[JsonPropertyName][JsonIgnore]など似た機能の属性を持ちますが、両者は互換性がありません。

そのため、同じクラスに両方の属性を付けることはできず、どちらか一方のライブラリに合わせて属性を付与する必要があります。

既存のNewtonsoft.JsonベースのコードをSystem.Text.Jsonに移行する際は、属性の書き換えが必要になることが多いです。

ただし、Newtonsoft.JsonSystem.Text.Jsonよりも多機能で、複雑なシナリオ(例えばカスタムコンバータの柔軟な実装や、循環参照のサポートなど)に対応しやすい点が強みです。

パフォーマンスの違い

System.Text.Jsonは.NET Core 3.0以降に標準搭載され、Span<T>Utf8JsonWriterなどの最新技術を活用して高速化と低メモリ消費を実現しています。

これに対し、Newtonsoft.Jsonはリフレクション中心の設計であり、パフォーマンス面ではやや劣ります。

ベンチマークでは、System.Text.JsonNewtonsoft.Jsonよりも最大で数倍高速かつメモリ効率が良いケースが多いです。

ただし、機能の豊富さや互換性の面でNewtonsoft.Jsonが選ばれることも依然として多いです。

Utf8Json

Utf8Jsonは、System.Text.Jsonよりもさらに高速かつ低メモリでのJSON処理を目指したサードパーティ製のライブラリです。

ゼロアロケーションやコードジェネレーションを特徴としています。

コードジェネレーションモデル

Utf8Jsonは、コンパイル時にJSONシリアル化・逆シリアル化のコードを自動生成する仕組みを持っています。

これにより、リフレクションを一切使わずに高速な処理が可能です。

コードジェネレーションは、ビルド時に型ごとの専用コードを生成するため、実行時のオーバーヘッドがほぼなく、特に大量のデータを扱う場合に大きなパフォーマンス向上が期待できます。

ただし、コードジェネレーションのセットアップやビルド環境の整備が必要であり、開発の初期コストがやや高い点に注意が必要です。

ゼロアロケーションの利点

Utf8Jsonは、JSONの読み書き時にメモリ割り当て(アロケーション)を極力抑える設計がなされています。

これにより、GC(ガベージコレクション)の発生頻度が減り、リアルタイム性が求められるアプリケーションや高負荷環境でのパフォーマンスが向上します。

具体的には、Span<T>Memory<T>を活用し、バッファの再利用やスタック上のメモリ操作を多用しています。

これにより、メモリ断片化や不要なコピーを防ぎます。

これらのライブラリはそれぞれ特徴があり、用途や環境に応じて使い分けることが重要です。

Newtonsoft.Jsonは機能の豊富さと互換性が強み、System.Text.Jsonは標準搭載で高速かつ軽量、Utf8Jsonはさらに高速・低メモリを追求した選択肢として注目されています。

ユニットテストで変換結果を保証する

JSONへのシリアル化・逆シリアル化は、データの整合性を保つために正確に動作していることを確認する必要があります。

ユニットテストを活用して変換結果を保証することで、バグの早期発見やリファクタリング時の安全性向上につながります。

ここでは、xUnitを使った基本的なテスト例、Snapshotテストの導入方法、そして構造体に対するDeepEqualアサーションの活用法を解説します。

xUnitを利用したサンプル

xUnitは.NETで広く使われているテストフレームワークの一つです。

System.Text.Jsonを使ったシリアル化・逆シリアル化のテストも簡単に書けます。

以下は、構造体のJSON変換をテストするシンプルな例です。

using System.Text.Json;
using Xunit;
public struct Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}
public class JsonSerializationTests
{
    [Fact]
    public void Serialize_ShouldProduceExpectedJson()
    {
        var person = new Person { Name = "山田太郎", Age = 30 };
        string expectedJson = "{\"Name\":\"山田太郎\",\"Age\":30}";
        string actualJson = JsonSerializer.Serialize(person);
        Assert.Equal(expectedJson, actualJson);
    }
    [Fact]
    public void Deserialize_ShouldProduceExpectedObject()
    {
        string json = "{\"Name\":\"山田太郎\",\"Age\":30}";
        Person person = JsonSerializer.Deserialize<Person>(json);
        Assert.Equal("山田太郎", person.Name);
        Assert.Equal(30, person.Age);
    }
}

この例では、Serialize_ShouldProduceExpectedJsonテストでシリアル化結果のJSON文字列が期待通りかを検証し、Deserialize_ShouldProduceExpectedObjectテストでJSONから正しく構造体が復元されるかを確認しています。

Snapshotテストの導入

Snapshotテストは、シリアル化結果などの出力をファイルとして保存し、将来のテスト実行時に差分を検出する手法です。

JSONのような文字列データの変化を簡単に検知できるため、UIやAPIレスポンスのテストでよく使われます。

.NETではVerifySnapperなどのライブラリがSnapshotテストをサポートしています。

以下はVerifyを使った例です。

using System.Text.Json;
using System.Threading.Tasks;
using VerifyXunit;
using Xunit;
[UsesVerify]
public class JsonSnapshotTests
{
    [Fact]
    public async Task PersonSerialization_MatchesSnapshot()
    {
        var person = new Person { Name = "山田太郎", Age = 30 };
        string json = JsonSerializer.Serialize(person, new JsonSerializerOptions { WriteIndented = true });
        await Verifier.Verify(json);
    }
}

初回実行時にJSONがスナップショットファイルとして保存され、以降の実行で差分があればテストが失敗します。

これにより、意図しないJSONの変更を検出できます。

構造体に対するDeepEqualアサーション

JSONの逆シリアル化結果が期待通りかを検証する際、単純なプロパティごとの比較ではなく、オブジェクト全体の等価性をチェックしたい場合があります。

特にネストした構造体やコレクションを含む場合は、DeepEqual(深い比較)を使うと便利です。

xUnit単体にはDeepEqual機能はありませんが、FluentAssertionsなどのライブラリを使うと簡単に実装できます。

using FluentAssertions;
using System.Text.Json;
using Xunit;
public struct Address
{
    public string City { get; set; }
    public string Street { get; set; }
}
public struct Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public Address HomeAddress { get; set; }
}
public class DeepEqualTests
{
    [Fact]
    public void Deserialize_ShouldMatchExpectedObject()
    {
        string json = @"
        {
            ""Name"": ""山田太郎"",
            ""Age"": 30,
            ""HomeAddress"": {
                ""City"": ""東京"",
                ""Street"": ""千代田区1-1-1""
            }
        }";
        var expected = new Person
        {
            Name = "山田太郎",
            Age = 30,
            HomeAddress = new Address { City = "東京", Street = "千代田区1-1-1" }
        };
        Person actual = JsonSerializer.Deserialize<Person>(json);
        actual.Should().BeEquivalentTo(expected);
    }
}

BeEquivalentToはプロパティの値を再帰的に比較し、差異があればテストを失敗させます。

これにより、複雑な構造体の等価性を簡潔に検証できます。

これらのテスト手法を組み合わせることで、JSON変換の正確性を高い信頼性で保証できます。

特にAPIの仕様変更やコードのリファクタリング時に、変換結果の不整合を早期に検出できるため、品質向上に役立ちます。

セキュリティの観点

JSONのシリアル化・逆シリアル化は便利な機能ですが、特に逆シリアル化(デシリアライズ)時にはセキュリティリスクが潜んでいます。

悪意のある入力を受け取る可能性がある場合は、適切な対策を講じることが重要です。

ここでは、デシリアライズ攻撃への対策、型制限による安全性の確保、不正入力のサニタイズ方法について解説します。

デシリアライズ攻撃対策

デシリアライズ攻撃とは、悪意のあるJSONデータを送り込むことで、アプリケーションの挙動を不正に操作したり、情報漏洩やサービス停止を引き起こす攻撃手法です。

特に、信頼できない外部からのJSON入力をそのまま逆シリアル化する場合にリスクが高まります。

System.Text.Jsonは、Newtonsoft.Jsonに比べてデフォルトで安全性が高い設計ですが、以下のポイントに注意が必要です。

  • 型の制限

逆シリアル化時に任意の型を指定できるため、攻撃者が予期しない型を注入するリスクがあります。

これを防ぐために、逆シリアル化の型は明示的に指定し、動的な型解決を避けることが重要です。

  • 循環参照や深いネストの制限

極端に深いネストや循環参照を含むJSONは、スタックオーバーフローやDoS攻撃の原因となるため、JsonSerializerOptionsMaxDepthプロパティで最大深度を制限しましょう。

  • 不要な型の逆シリアル化を禁止

例えば、object型やdynamic型への逆シリアル化は避け、具体的な型を指定することで安全性を高めます。

var options = new JsonSerializerOptions
{
    MaxDepth = 64 // 適切な深度に設定
};

型制限による安全性確保

逆シリアル化時に安全性を確保するためには、以下のように型を限定することが効果的です。

  • 明示的な型指定

JsonSerializer.Deserialize<T>()Tに具体的な構造体やクラスを指定し、任意の型を受け入れないようにします。

  • カスタムコンバータで検証を追加

カスタムコンバータを実装し、受け入れる値の範囲や形式を検証して不正なデータを弾くことができます。

  • インターフェースや抽象クラスの使用を避ける

これらは逆シリアル化時に具象型の解決が必要となり、攻撃の入り口になることがあります。

  • JsonSerializerOptionsAllowTrailingCommasReadCommentHandlingの設定

不要な柔軟性を制限し、予期しないJSON構造を防ぎます。

不正入力をサニタイズする方法

JSONの入力値に対してサニタイズ(無害化)を行うことで、XSS(クロスサイトスクリプティング)やSQLインジェクションなどの二次的な攻撃を防止できます。

特に文字列データを扱う場合は注意が必要です。

  • 入力値の検証

文字列の長さ制限や正規表現によるパターンチェックを行い、不正な文字列を排除します。

  • エスケープ処理

JSONシリアル化時にJavaScriptEncoderを適切に設定し、HTMLやJavaScriptで問題となる文字をエスケープします。

var options = new JsonSerializerOptions
{
    Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};

ただし、UnsafeRelaxedJsonEscapingはエスケープを緩和するため、XSS対策としては逆効果になる場合もあるため、用途に応じて使い分けます。

  • ホワイトリスト方式の入力制限

受け入れる文字や値の範囲を限定し、想定外の入力を拒否します。

  • サニタイズライブラリの活用

HTMLやSQLなど特定のコンテキストに応じたサニタイズライブラリを使い、入力値を安全な形に変換します。

これらの対策を組み合わせることで、JSONの逆シリアル化に伴うセキュリティリスクを大幅に低減できます。

特に外部からの入力を扱うAPIやサービスでは、必ず安全性を考慮した設計と実装を心がけましょう。

よくあるトラブルシューティング

JSONのシリアル化・逆シリアル化を行う際には、さまざまなトラブルが発生することがあります。

ここでは、特に多く見られる問題である循環参照、自己参照プロパティによるスタックオーバーフロー、そして.NETやSystem.Text.Jsonのバージョンアップに伴う挙動変更について解説します。

循環参照が発生した場合

循環参照とは、オブジェクトのプロパティが自身を直接または間接的に参照している状態を指します。

例えば、親オブジェクトが子オブジェクトを持ち、子オブジェクトが再び親オブジェクトを参照している場合などです。

System.Text.Jsonのデフォルト設定では、循環参照があるとシリアル化時に例外が発生します。

これは無限ループを防ぐための安全措置です。

public class Node
{
    public string Name { get; set; }
    public Node? Parent { get; set; }
}

上記のようなクラスで、Parentが自身や祖先を参照していると、JsonSerializer.SerializeJsonExceptionをスローします。

対処法

  • 循環参照の除去や設計見直し

可能であれば、循環参照を持たない設計に変更します。

  • ReferenceHandlerの利用

.NET 5以降では、JsonSerializerOptionsReferenceHandlerReferenceHandler.Preserveを設定することで、循環参照をサポートできます。

これにより、参照のIDを付与して循環を回避します。

var options = new JsonSerializerOptions
{
    ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve,
    WriteIndented = true
};

ただし、この機能はJSONの形式が特殊になるため、相手側のシステムが対応しているか確認が必要です。

自己参照プロパティによるスタックオーバーフロー

自己参照プロパティとは、クラスや構造体のプロパティが自身の型を持ち、直接的に自身を参照するケースです。

これも循環参照の一種ですが、特に注意が必要です。

public class SelfRef
{
    public SelfRef? Self { get; set; }
}

このような型をシリアル化しようとすると、System.Text.Jsonは無限ループに陥り、最終的にStackOverflowExceptionが発生することがあります。

対処法

  • ReferenceHandler.Preserveの利用

循環参照と同様に、ReferenceHandler.Preserveを設定して対応します。

  • [JsonIgnore]属性の活用

自己参照のプロパティに[JsonIgnore]を付けてシリアル化対象から除外する方法もあります。

  • 設計の見直し

自己参照が本当に必要か検討し、可能ならば別の設計に変更します。

バージョンアップでの挙動変更

System.Text.Jsonは.NET Core 3.0で導入されて以来、.NET 5、.NET 6、.NET 7とバージョンアップを重ねるごとに機能追加や挙動の変更が行われています。

これにより、同じコードでも.NETのバージョンによって動作が異なることがあります。

代表的な変更例

  • ReferenceHandlerの追加

.NET 5で循環参照対応のためのReferenceHandler.Preserveが追加されました。

  • initアクセサ対応

C# 9のinitアクセサに対応し、イミュータブルな型の逆シリアル化が可能になりました。

  • デフォルトの大文字小文字の扱い

バージョンによってPropertyNameCaseInsensitiveのデフォルト値が変わることがあります。

  • JsonSerializerOptionsの新しいプロパティ追加

例えば、DefaultIgnoreConditionNumberHandlingなどの追加により、既存コードの挙動が変わる場合があります。

対処法

  • バージョンアップ時のリリースノート確認

Microsoftの公式ドキュメントやGitHubのリリースノートを必ず確認し、変更点を把握します。

  • テストの充実

ユニットテストや統合テストを充実させ、バージョンアップによる影響を早期に検出します。

  • 明示的なオプション設定

デフォルト値に依存せず、必要なオプションは明示的に設定することで挙動の安定化を図ります。

これらのトラブルは、設計や設定の工夫で回避可能なものが多いです。

特に循環参照や自己参照はJSONシリアル化の基本的な制約として理解し、適切な対策を講じることが重要です。

また、バージョンアップに伴う挙動変更には常に注意を払い、テストとドキュメントの確認を怠らないようにしましょう。

バージョン互換性と将来の展望

System.Text.Jsonは.NETの進化とともに機能が拡充されており、特に.NET 5以降で多くの改善や新機能が追加されています。

ここでは、.NET 5以降の主な機能追加と、C#のNullable Reference Types(NRT)やRequiredメタデータを活用した最新の設計手法について解説します。

.NET 5以降の機能追加

.NET 5は.NET Coreの後継としてリリースされ、System.Text.Jsonにも多くの機能強化が行われました。

主な追加機能は以下の通りです。

  • 循環参照のサポート(ReferenceHandler.Preserve)

循環参照を含むオブジェクトグラフのシリアル化・逆シリアル化が可能になりました。

JsonSerializerOptions.ReferenceHandlerReferenceHandler.Preserveを設定することで、参照IDを付与し、無限ループを防ぎつつ正しく処理できます。

  • initアクセサ対応

C# 9で導入されたinitアクセサを持つイミュータブルな型の逆シリアル化がサポートされました。

これにより、イミュータブルな構造体やクラスを安全にJSONから復元できます。

  • JsonSerializerOptions.DefaultIgnoreConditionの追加

nullやデフォルト値のプロパティをシリアル化から除外する設定が簡単にできるようになりました。

これにより、JSONのサイズ削減やAPI仕様への柔軟な対応が可能です。

  • JsonNumberHandlingの追加

数値の文字列化やenumの数値化など、数値のシリアル化挙動を細かく制御できるようになりました。

  • パフォーマンス改善

内部的な最適化が継続的に行われ、特に大規模データの処理速度やメモリ効率が向上しています。

これらの機能追加により、System.Text.Jsonはより多様なシナリオに対応できるようになり、従来のNewtonsoft.Jsonに匹敵する柔軟性を持つようになりました。

RequiredメタデータとNullable Reference Types活用

C# 8以降で導入されたNullable Reference Types(NRT)は、参照型のnull許容性を型システムで明示的に扱う機能です。

これにより、null参照例外のリスクをコンパイル時に検出しやすくなります。

さらに、C# 11で導入されたrequired修飾子を使うと、オブジェクト初期化時に必須のプロパティを強制できます。

これらを組み合わせることで、JSON逆シリアル化時の必須項目の検証や安全なオブジェクト生成が可能です。

Nullable Reference Typesの活用例

public class Person
{
    public required string Name { get; init; }
    public int? Age { get; init; } // null許容型
}
  • Namerequiredで必須プロパティとなり、初期化時に必ず値を設定しなければコンパイルエラーになります
  • Ageint?でnullを許容し、省略可能な値として扱えます

JSON逆シリアル化との連携

System.Text.Jsonrequiredプロパティを認識し、JSONに必須のキーが存在しない場合は例外をスローします。

これにより、JSONの不完全なデータを早期に検出できます。

string json = "{}";
try
{
    var person = JsonSerializer.Deserialize<Person>(json);
}
catch (JsonException ex)
{
    Console.WriteLine("必須プロパティが不足しています。");
}

メリット

  • 安全なオブジェクト生成

必須プロパティの未設定を防ぎ、null参照例外のリスクを減らせます。

  • API仕様の明確化

必須項目と任意項目を型レベルで区別でき、ドキュメントやコードの可読性が向上します。

  • コンパイル時チェック

実行時エラーを減らし、開発効率を高めます。

今後もSystem.Text.Jsonは.NETの進化に合わせて機能強化が続く見込みであり、Nullable Reference Typesやrequired修飾子など最新のC#機能と連携することで、より安全で効率的なJSON処理が可能になるでしょう。

開発者はこれらの新機能を積極的に取り入れ、堅牢なアプリケーション設計を目指すことが推奨されます。

まとめ

この記事では、C#の構造体をJSONに変換し、逆にJSONから構造体へマッピングする基本から応用までを解説しました。

System.Text.Jsonの使い方やカスタマイズ方法、パフォーマンス向上のポイント、セキュリティ対策、トラブルシューティング、最新の.NET機能との連携まで幅広く理解できます。

これにより、安全かつ効率的にJSON処理を実装し、堅牢なアプリケーション開発に役立てられます。

関連記事

Back to top button