【C#】System.Text.Jsonでオブジェクトのシリアライズ・デシリアライズ

.NET Core 3.0 から使用可能になった新しい標準ライブラリに含まれるJSONシリアライザーの System.Text.Json の使い方の紹介です。以前取り上げた、【C#】標準機能でJSON をシリアライズ、デシリアライズする - PG日誌 を代替する標準の実装方法になります。

動作環境

新規に.NETに導入されたばかりなので標準で使用できる環境は現状以下の通りです。

  • 使用可能な.NETのバージョン
    • .NET Core3.0
    • .NET Standard 2.1
    • .NET Framework 4.8

それ以前の場合、nuget で System.Text.Json を導入すれば以下のバージョンから使用できるようになっています。

  • nuget すれば使えるバージョン
    • .NET Core2.0
    • .NET Standard 2.0
    • .NET Framework 4.6.1

とはいっても、ある程度最近のバージョンからなので条件に当てはまらなければあきらめましょう。

Unity は .NET Standard 2.0 なので nuget すれば使えるようになりそうです。ただし内部でリフレクションを使っていると思われるためIL2CPPを使用する場合 link.xml に設定を追記しないといけないかもしれません【未検証】

動作速度

手元で適当に計測したら速度は以下の通り

System.Text.Json > Json.NET >> DataContractSerializer

実行速度のおおまかな比率はシリアライズもデシリアライズも大凡以下の通りです。

DataContractSerializer を 「1」 とした場合、JSON.NET「1.2~1.5」、System.Text.Json「2.1~2.9」

あまり複雑な要求が無い新規開発の場合とりあえず System.Text.Json を JSON シリアライザとして選択するのは悪くなさそうです。実際に公式のリファレンスにNewtonsoft.Jsonから移行する方法という結構挑戦的なページが有ったりますw

使い方

早速使い方の紹介です。

まず以下の名前空間を using します。

using System.Text.Json;
using System.Text.Json.Serialization; // こっちは必要に応じて追記する

今回は以下のようなJSONデータを扱います。

[
    {
        "id": 0,
        "name": "Taka",
        "numbers": [ 0, 1, 2, 3 ],
        "list": [ 0, 1, 2, 3 ],
        "map": [
            "key1": "value1",
            "key2": "value2",
            "key3": "value3",
            "key4": "value4",
            "key5": "value5",
        ]
    },
    {
        "id": 1,
        "name": "PG",
        "numbers": [ 10, 11, 12, 13 ],
        "list": [10,
        // 以下略

次に、シリアライズ・デシリアライズするクラスを宣言します。基本的にはクラス ⇔ JSON の相互変換を行うクラス宣言が無いと操作を開始できません。

public class JsonItem
{
    // ★★★ プロパティが自動的にシリアライズの対象に選ばれる
    //        オブジェクトに別名を付けたい場合以下のように PropertyName 属性を付ける
    [JsonPropertyName("id")]
    public int ID { get; set; }

    // ★★★ フィールドはシリアライズの対象にできない
    //        プロパティだけシリアライズの対象できる
    [JsonPropertyName("asdf")]
    public string sample = "";

    [JsonPropertyName("name")]
    public string Name { get; set; }

    [JsonPropertyName("numbers")]
    public int[] Numbers { get; set; }

    [JsonPropertyName("list")]
    public List<float> NumberList { get; private set; } = new List<float>();

    [JsonPropertyName("asdf")]
    public IDictionary<string, string> Attributes { get; private set; } = new Dictionary<string, string>();
    
    // ★★★ 除外するプロパティには Ignore 属性を付ける
    [JsonIgnore]
    public string PrivacyInfo { get; set; }
}

ひとつ注意があって System.Text.Json のシリアライズはプロパティのみを対象にしているため、フィールドはシリアライズできません。Unity で JsonUtility を使用していた層からすると互換が無いため以降が大変かもしれません。

プロパティ名と異なる JSON のオブジェクト名前を使用したい場合「JsonPropertyName (System.Text.Json.Serialization名前空間)」を上記コード例のように付与します。また、デフォルトでは自動的にプロパティが全部出力されるのでシリアライズ対象にしたくないプロパティには「JsonIgnore」を同じく付与します。

補足:

プロパティ名をダイレクトにJSONのオブジェクト名として使用しているとプロパティの名前を変えるとJSONが読めなくなるためオブジェクト名JsonPropetyName属性を使って必ず指定しましょう。

リスト も Dictionary も配列もすべてサポートしているので一般的な .NET の型はほぼすべてカバーしています。

注意:

シリアライズできるオブジェクトの階層の深さは最大64までなので深すぎるオブジェクトは使用しないようにしましょう。

シリアライズ

シリアライズ方法は以下の通りです。

// あらかじめ適当なデータを生成しておく
List<JsonItem> item = DataJenerator.CreateTestData(DataLength);

// ★★★ 必要に応じて以下のオプションを指定する
var op = new JsonSerializerOptions
{
    // ★★★ (1) 文字列にマルチバイト文字(≒日本語)が含まれる
    //  → 指定しないと文字コードが出力される
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),

    // ★★★ (2) 出力結果をインデントするかどうか
    // true : 空白文字列で整形される / false : されない
    WriteIndented = true,

    // ★★★ (3) null を無視するかどうか
    // true : nullならJSONに出力されない
    // false : 出力する(既定はこっち)
    IgnoreNullValues = true,

    // ★★★ (4) 読み取り専用プロパティを無視するかどうか
    // true : 読み取り専用(getのみ)のプロパティはJSONに出力しない
    // false : 出力する(既定はこっち)
    IgnoreReadOnlyProperties = true,
    
    // ★★★ (5) 出力をCamelケースでするしてい
    // JsonPropertyName 指定があるとそっちが優先される
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

// オブジェクトをJSON文字列にシリアライズする
string jsonStr = JsonSerializer.Serialize(item, op);

必要に応じてコード例のコメントの通りオプションを指定します。特に Encoder の指定は必須のため Utility 化するときに必ず上記のように指定するようにしたほうが良いと思います。他のオプションはお好みでお願いします。他にもいくつかオプションがあるので必要があればMSDNのリファレンスを参照してください。

以下のようなデータ生成を行っているので

public static class DataJenerator
{
    private static readonly Random r = new Random();

    // 指定した件数分のJSONに変換するデータを生成する
    public static List<JsonItem> CreateTestData(int count)
    {
        IEnumerable<JsonItem> f()
        {
            for (int i = 0; i < count; i++)
            {
                var item = new JsonItem()
                {
                    ID = r.Next(),
                    Name = Guid.NewGuid().ToString(),
                    Numbers = new int[]
                    {
                    r.Next(),
                    r.Next(),
                    r.Next(),
                    r.Next(),
                    r.Next(),
                    },
                };

                item.NumberList.Add(r.Next());
                item.NumberList.Add(r.Next());
                item.NumberList.Add(r.Next());
                item.NumberList.Add(r.Next());
                item.NumberList.Add(r.Next());

                item.Attributes[Guid.NewGuid().ToString()] = Guid.NewGuid().ToString();
                item.Attributes[Guid.NewGuid().ToString()] = Guid.NewGuid().ToString();
                item.Attributes[Guid.NewGuid().ToString()] = Guid.NewGuid().ToString();
                item.Attributes[Guid.NewGuid().ToString()] = Guid.NewGuid().ToString();
                item.Attributes[Guid.NewGuid().ToString()] = Guid.NewGuid().ToString();

                yield return item;
            }
        }

        return f().ToList();
    }
}

JSON化したデータは以下のようなデータとして jsonStr 変数に入っています。

// jsonStr 変数の中身
[
  {
    "time": "2020-08-21T00:14:58.0850867+09:00",
    "span": "10200",
    "id": 1905413078,
    "name": "2d9f9caa-9f05-4213-a992-3e5f6672c575",
    "numbers": [
      1512193737,
      300677771,
      1625274804,
      1946273800,
      251410219
    ],
    "list": [
      263971600,
      1.634365E+09,
      550383800,
      1.4658991E+09,
      203363660
    ],
    "asdf": {
      "feb9c436-6e85-453b-98ce-395f549d7b2e": "b1763a54-c41c-4b46-b424-cebf87271469",
      "c23bf690-33ae-4096-998d-0901b09e4ec9": "ef737ce1-5185-4e57-a009-edd538c7ae57",
      "44598508-d4e1-44d8-89a3-4ce3ce241215": "c33f3104-3a8e-46fc-90cb-8d9fcb0952de",
      "4b1a697c-c17e-4458-9e61-ec0f222df2cf": "4bc7eac1-3160-4496-8667-dc11e67979e6",
      "c1d3f90f-64ba-4f9e-a43b-bf97b20d144c": "47fd7ae3-a194-46c4-b299-e60bc9d83ea2"
    }
  }
]

デシリアライズ

デシリアライズの方法は以下の通りです。

// string jsonStr = JsonSerializer.Serialize(item, op);

op = new JsonSerializerOptions()
{
    // ★★★ (1) デシリアライズするときに大文字と小文字を区別するかしないかのフラグ
    // true : 区別しない
    // fa;se : 区別する(既定)
    PropertyNameCaseInsensitive = true,

    // ★★★ (2) JSONにコメントが含まれる場合の扱い方
    // Allow : 許可する ← なぜかエラーが出る…たぶん使えない
    // Disallow : 許可しない(検出した場合例外:これが既定値)
    // Skip : その行はスキップする
    ReadCommentHandling = JsonCommentHandling.Skip,

    // 以下の2種類の標準日準拠のコメントが対象にできる
    // {
    //    "TemperatureCelsius": 25, // Fahrenheit 77
    //    "Summary": "Hot" /* Zharko */
    // }

    // ★★★ (3) 末尾にコンマ記号がついているJSONを許可するかどうか
    // true : 許可する
    // false : 許可しない(既定)
    AllowTrailingCommas = true,

    // 本来以下のように末尾にコンマ記号がある標準非準拠のJSONを扱うかどうか
    // {
    //    "Item1": "a",
    //    "Item2": "b", ★★★末尾にコンマ
    // }
};

// オブジェクトにデシリアライズする
List<JsonItem> list = JsonSerializer.Deserialize<List<JsonItem>>(jsonStr, op);

これもオプションを指定することができます。標準に準拠しないJSONをどう扱うかのオプションがいくつか用意されています。

ストリームに読み書きする

書き込み方は以下の通りです。

// ファイルに書き出す

var options = new JsonSerializerOptions
{
    // UTF-8が確実かつエスケープ規則が既知ならこれを指定する
    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};

// 出力先の指定
using var stream = new FileStream(@"c:\tmp\sample.json", FileMode.Create, FileAccess.Write);
// ストリームに対してシリアライズした結果を書き出す
await JsonSerializer.SerializeAsync(stream/*出力するストリーム*/, 
                                    soutceItem/*出力するオブジェクト*/, op/*オプション(任意)*/);

// WebRequest のレスポンスに書きこむこともできる
// Stream dataStream = request.GetRequestStream();
// await JsonSerializer.SerializeAsync(dataStream, sourceItem, op);

読み込み方法は以下の通り

// ファイルから読み込む

var options = new JsonSerializerOptions
{
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
};

// 読み取り先の指定
using var stream = new FileStream(@"c:\tmp\sample.json", FileMode.Open, FileAccess.Read);
// ストリームから読み取った内容をオブジェクトに復元する
var itemList =  
    await JsonSerializer.DeserializeAsync<List<JsonItem>>(stream/*入力ストリーム*/, options/*オプション(任意)*/);

そのほか

各型のシリアライズ出力結果(DateTime 、TimeSpan)

以下、標準の状態で DateTime と TimeSpan の変換を見てみます。

// ★ DateTime 型をシリアライズしたときのJSON
public DateTime Time { get; set; } = DateTime.Now;
// > "Time": "2020-08-20T11:41:03.7557392+09:00",

// ★ TimeSpan 型をシリアライズしたときのJSON
public TimeSpan Span { get; set; } = TimeSpan.FromSeconds(10.2);
// > "Span": {},
//  → 変換できない!

TimeSpan 型は変換できないみたいです。

派生クラスのシリアライズ

派生クラスの場合以下のようにひと手間必要です。ちょっと違和感がありますがお約束だと思ってあきらめましょう。

// 基底クラスを出力する場合工夫が必要
public class A { }
public class B : A { }

B b = new B();
JsonSerializer.Serialize(b); // これだとBの階層しか出力されない
JsonSerializer.Serialize<object>(b); // ジェネリックに<object>を指定すると基底クラスも出力される

カスタムコンバーターを使う

上記で出力されない型を独自の規則で入出力するには Converter が必要です。

先ほどシリアライズできなかった TimeSpan 型をシリアライズ・デシリアライズできるようにするためには以下のようにコンバーターを実装します。

// TimeSpan ⇔ JSONに変換するコンバーター
public class TimeSpanConverter : JsonConverter<TimeSpan>
{
    public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return TimeSpan.FromMilliseconds(double.Parse(reader.GetString())); // 文字列の数値をミリ秒で変換
        // return TimeSpan.FromMilliseconds(reader.GetDouble()); // 数値の場合こう
    }

    public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.TotalMilliseconds.ToString()); // ミリ秒で書き出し
        // writer.WriteNumberValue(value.TotalMilliseconds); // 数値で書き出すときはこう
    }
}

で、プロパティに以下のように記述します。

[JsonConverter(typeof(TimeSpanConverter))]
public TimeSpan Span { get; set; } 

他にもいろいろ変換方法を指定する方法はありますがこれだけ覚えておけば大抵大丈夫だと思います(他にもやり方があるということだけ覚えておいてこれで対応できそうもなければリファレンス見程度で大丈夫です)

DateTime 型は表現方法が色々あるので標準でなくカスタムコンバーターを記述することもありそうです。