search
LoginSignup
25

More than 1 year has passed since last update.

posted at

C# の Json.NET を理解する

C# を使うときに、今までふわっと理解して適当に使っていた Json.NETだけど、一度体系的にしっかり理解してみようと思った。Introductionやそのドキュメントを読んで、サンプルを書いて理解してみよう。

現在では、C# の JSON を操作するライブラリとしては、一択感のある Json.NET だが、大きく分けると2つの機能を有する。

  • Json を C#のオブジェクトにシリアライズ、デシリアライズする。
  • Json を 手動で書いたり、読んだり、クエリーしたりする。LINQ to JSON という名前で呼ばれている。

の大きく2つに分類される。

シリアライズとデシリアライズ

SerializeObjectDeserializeObject<T> が最も頻繁に使うメソッドになります。C# のオブジェクトに対して、JSONをシリアライズしたり、デシリアライズします。

        public static void Execute()
        {
            var product = new Product()
            {
                Name = "Apple",
                ExpiryDate = DateTimeOffset.Parse("2008/12/28"),
                Price = 3.99M,
                Sizes = new string[] { "Small", "Medium", "Large" }
            };
            string output = JsonConvert.SerializeObject(product);
            Console.WriteLine(output);
            var deserializedProduct = JsonConvert.DeserializeObject<Product>(output);
        }
実行結果
{"Name":"Apple","ExpiryDate":"2008-12-28T00:00:00-08:00","Price":3.99,"Sizes":["Small","Medium","Large"]}

おそらく 90% ぐらいのユースケースでは、この文法を知っていると対応できると思います。しかし、たまに対応できないケースがあります。
例えば私は durabletaskにコントリビュートしたときに、次の3つのシリアライズの設定をしています。この TraceContextBase というオブジェクトはサブクラスがあり、自分の子のオブジェクトのリファレンスを持っています。このオブジェクトをシリアライズして、デシリアライズするためには、どのTraceContextBase のサブクラスがシリアライズされたのかという情報を持つ必要があります。それは、TypeNameHandling という設定で実現されています。具体的には、型名がシリアライズされて保存されます。また、PreserveReferenceHandling によって、参照を持てるようになります。また、ReferenceLoopHandling によって、リファレンスが循環するようなケースも対応されます。それぞれについて解説してみましょう。

TraceContextBase.cs
        static TraceContextBase()
        {
            CustomJsonSerializerSettings = new JsonSerializerSettings()
            {
                TypeNameHandling = TypeNameHandling.Objects,
                PreserveReferencesHandling = PreserveReferencesHandling.Objects,
                ReferenceLoopHandling = ReferenceLoopHandling.Serialize,
            };
        }

TypeNameHandling

Json にシリアライズを行うときに型情報を追加します。例えば、このようなクラスを作成します。インターフェイスとそのサブクラスがあります。

Node.cs
    public interface Node
    {
        string Name { get; }
        void Print();
    }

    public class NodeLeaf : Node
    {
        public string Name { get; set; }

        public void Print()
        {
            Console.WriteLine($"I am {this.GetType()}");
        }
    }

    public class NodeComposite : Node
    {
        public string Name { get; set;}

        public void Print()
        {
            Console.WriteLine($"I am {this.GetType()}");
        }
    }

このオブジェクトをシリアライズしてみましょう。同じインターフェイスですが実装の型が違います。

            var nodes = new List<Node>
            {
                new NodeLeaf { Name = "I'm file."},
                new NodeComposite { Name = "I'm directory."}
            };

これをTypeNameHandlingをつけてシリアライズしてみます。

            var typeNameList = JsonConvert.SerializeObject(nodes, Formatting.Indented, new JsonSerializerSettings
            {
                TypeNameHandling = TypeNameHandling.Objects
            });
            Console.WriteLine("typeNameHandling: " + typeNameList);

この箇所はつぎのように表示されます。きれいにシリアライズされています。オブジェクトに $typeが追加されていて、型情報が追加されているのがわかると思います。このシリアライズの設定もTypeNameAssemblyFormatによって実施することが可能です。これをFull に設定すると、アセンブリ名やバージョン名などを付加することも可能です。

実行結果
typeNameHandling: [
  {
    "$type": "JTokenSPike.NodeLeaf, JTokenSPike",
    "Name": "I'm file."
  },
  {
    "$type": "JTokenSPike.NodeComposite, JTokenSPike",
    "Name": "I'm directory."
  }
]

デシリアライズするときも設定を追加します。JsonSerializerSettingsと、TypeNameHandling を設定しないと、この場合は、Exception になります。型情報を読み取らなければ、Node はインターフェイスなのでデシリアライズできないからです。

            var deserializedNodeList = JsonConvert.DeserializeObject<IList<Node>>(typeNameList, new JsonSerializerSettings
            {
                TypeNameHandling = TypeNameHandling.Objects
            });

            foreach (var node in deserializedNodeList)
            {
                node.Print();
            }

実行結果も思った通りですね。

実行結果
I am JTokenSPike.NodeLeaf
I am JTokenSPike.NodeComposite

PreserveReferenceHandling

次に、参照を保持したいときにどうしたらいいかを見てみましょう。

DirectoryAndFile
    public class Directory
    {
        public string Name { get; set; }
        public Directory Parent { get; set; }
        public IList<File>? Files { get; set; }

    }

    public class File
    {
        public string Name { get; set; }
        public Directory Parent { get; set; }
    }

Directory と File は参照を持っています。これを普通にシリアライズすると、Exception になります。Directory が File
を持っていて、Fileが、Directoryを持っているからです。

            try
            {
                JsonConvert.SerializeObject(document, Formatting.Indented);
            } catch (JsonSerializationException ex)
            {
                Console.WriteLine("Expected: " + ex.ToString());
            }
実行結果
Expected: Newtonsoft.Json.JsonSerializationException: Self referencing loop detected for property 'Parent' with type 'JTokenSPike.Directory'. Path 'Files[0]'.
   at Newtonsoft.Json.Serialization.JsonSerializerInternalWriter.CheckForCircularReference(JsonWriter writer, Object value, JsonProperty property, JsonContract contract, JsonContainerContract containerContract, JsonProperty containerProperty)

次のように設定します。

PreserveRerferenceHandling.All
            var preserveReferenceAll = JsonConvert.SerializeObject(document, Formatting.Indented, new JsonSerializerSettings {
                PreserveReferencesHandling = PreserveReferencesHandling.All
            });

            Console.WriteLine("All: " + preserveReferenceAll);

$id が作成され、参照されているケースはリファレンスとして表現されています。

実行結果
All: {
  "$id": "1",
  "Name": "My Documents",
  "Parent": {
    "$id": "2",
    "Name": "Root",
    "Parent": null,
    "Files": null
  },
  "Files": {
    "$id": "3",
    "$values": [
      {
        "$id": "4",
        "Name": "Important Legal Document.docs",
        "Parent": {
          "$ref": "1"
        }
      }
    ]
  }
}

シリアライズの方法は、All以外にもあります。Object は、オブジェクトしてシリアライズを行います。

Object
            var preserveReferenceObject = JsonConvert.SerializeObject(document, Formatting.Indented, new JsonSerializerSettings
            {
                PreserveReferencesHandling = PreserveReferencesHandling.Objects
            });

            Console.WriteLine("Object: " + preserveReferenceObject);

先ほどと異なって、Files 部分の下が Array として表現されています。

実行結果
Object: {
  "$id": "1",
  "Name": "My Documents",
  "Parent": {
    "$id": "2",
    "Name": "Root",
    "Parent": null,
    "Files": null
  },
  "Files": [
    {
      "$id": "3",
      "Name": "Important Legal Document.docs",
      "Parent": {
        "$ref": "1"
      }
    }
  ]
}

他にも Array というのがあります。実行結果は、Allと同じように見えます。ただし、Allと違って、後で解説するReferenceLoopHandling を設定してあげないと、正しく動作しません。

Array
            var preserveReferenceArrays = JsonConvert.SerializeObject(document, Formatting.Indented, new JsonSerializerSettings
            {
                PreserveReferencesHandling = PreserveReferencesHandling.Arrays,
                ReferenceLoopHandling = ReferenceLoopHandling.Serialize
            });

            Console.WriteLine("Array: " + preserveReferenceArrays);
実行結果
Array: {
  "Name": "My Documents",
  "Parent": {
    "Name": "Root",
    "Parent": null,
    "Files": null
  },
  "Files": {
    "$id": "1",
    "$values": [
      {
        "Name": "Important Legal Document.docs",
        "Parent": {
          "Name": "My Documents",
          "Parent": {
            "Name": "Root",
            "Parent": null,
            "Files": null
          },
          "Files": {
            "$ref": "1"
          }
        }
      }
    ]
  }
}

Object と、Arrayの違いは何なのでしょう?私の推測でしかないのですが、小さいコードを書いてみます。

ObjectAndArray
            var files = new List<File>
            {
                new File {Name = "doc 1"}, new File {Name = "doc 2"}
            };

            var filesObject = JsonConvert.SerializeObject(files, Formatting.Indented, new JsonSerializerSettings
            {
                PreserveReferencesHandling = PreserveReferencesHandling.Objects
            });

            Console.WriteLine("Object: " + filesObject);

            var arrayObject = JsonConvert.SerializeObject(files, Formatting.Indented, new JsonSerializerSettings
            {
                PreserveReferencesHandling = PreserveReferencesHandling.Arrays
            });

            Console.WriteLine("Array: " + arrayObject);

List だけを作ってシンプルにしました。実行結果の違いは、配列の中としてオブジェクトが表現されているか、オブジェクトの中のValueとして、配列が表現されているかの違いです。ここからはマニュアルから読み取れないので私の推測ですが、JToken という最上位の概念の下に、JObject, JArray という概念が存在します。それと対比していると考えると、Object としてシリアライズするとは、配列をオブジェクトとして解釈しないので、オブジェクトの配列として認識し、Arrayとしてシリアライズするなら、もし Arrayがあったとしたら、JArrayのオブジェクトつまり、配列を表現できるオブジェクトとしてシリアライズするために下記のような仕様になっているのではないでしょうか?

実行結果
Object: [
  {
    "$id": "1",
    "Name": "doc 1",
    "Parent": null
  },
  {
    "$id": "2",
    "Name": "doc 2",
    "Parent": null
  }
]
Array: {
  "$id": "1",
  "$values": [
    {
      "Name": "doc 1",
      "Parent": null
    },
    {
      "Name": "doc 2",
      "Parent": null
    }
  ]
}

ReferenceLoopHandling

既に出てきていますが、循環参照をどのように解決するかです。この設定を入れないと、永遠にシリアライズし続けてしまいます。

Employee.cs
    public class Employee
    {
        public string Name { get; set; }
        public Employee Manager { get; set; }
    }

ReferenceLoopHandling の設定付きでシリアライズすると下記のような循環参照もシリアライズ可能です。その際は、PreserveReferenceHandlingと併用します。

ReferenceLoopHandling
            var joe = new Employee { Name = "Joe User" };
            var mike = new Employee { Name = "Mike Manager" };
            joe.Manager = mike;
            mike.Manager = mike;


            var loopReferenceHandling = JsonConvert.SerializeObject(joe, Formatting.Indented, new JsonSerializerSettings
            {
                PreserveReferencesHandling = PreserveReferencesHandling.Objects,
                ReferenceLoopHandling = ReferenceLoopHandling.Serialize
            });
            Console.WriteLine("Loop: " + loopReferenceHandling);

きれいにシリアライズされていますね。

実行結果
Loop: {
  "$id": "1",
  "Name": "Joe User",
  "Manager": {
    "$id": "2",
    "Name": "Mike Manager",
    "Manager": {
      "$ref": "2"
    }
  }
}

Linq to Json

いくつかのケースで、自分で Json のオブジェクトを作って、フィルタをかけたいケースがあります。その場合は、Linq to Json という機能を使います。Json のオブジェクトは主要なものには次のものがあります。

JTokenとサブクラス
JToken             - abstract base class     
   JContainer      - abstract base class of JTokens that can contain other JTokens
       JArray      - represents a JSON array (contains an ordered list of JTokens)
       JObject     - represents a JSON object (contains a collection of JProperties)
       JProperty   - represents a JSON property (a name/JToken pair inside a JObject)
   JValue          - represents a primitive JSON value (string, number, boolean, null)

上記のクラス階層になっていますので、Json をパースすると、上記のオブジェクトのツリーが作られます。サンプルを見てみましょう。Newtonsoft.Json.Linq namespace を using すると使用できるようになります。JObject.Parseメソッドを使うと、オブジェクトがパースできるようになります。

LinqToJson
            JObject o = JObject.Parse(@"{
  'CPU': 'Intel',
  'Drives': [
    'DVD read/writer',
    '500 gigabyte hard drive'
  ]
}");

            string cpu = (string)o["CPU"];
            Console.WriteLine($"CPU: {cpu}");

            string firstDrive = (string)o["Drives"][0];
            Console.WriteLine($"First Drive: {firstDrive}");

            IList<string> allDrives = o["Drives"].Select(t => (string)t).ToList();
            foreach (var drive in allDrives)
            {
                Console.WriteLine("Drive: " + drive);
            }

Arrayの場合、JArrayでパースすることもできます。

Array
            string json = @"[
  'Small',
  'Medium',
  'Large'
]";
            JArray a = JArray.Parse(json);
            Console.WriteLine($"0: {a[0]}, 1: {a[1]}, 2: {a[2]}");
実行結果
0: Small, 1: Medium, 2: Large

StreamReader の ReadFrom メソッドを直接読むことも可能です。computer.json のファイル名で、先ほどの、CPUと同じJsonを格納しています。インテリセンスによると、Async メソッドも用意されていました。

           using(StreamReader reader = System.IO.File.OpenText(@"computer.json"))
            {
                JObject obj = (JObject)JToken.ReadFrom(new JsonTextReader(reader));
                Console.WriteLine($"CPU: {obj["CPU"]}");
            }
実行結果
CPU: Intel

Linq を使用する

普通に、JObject を使う次のようなコードになり、煩雑ですね。

string json = @"{
  'channel': {
    'title': 'James Newton-King',
    'link': 'http://james.newtonking.com',
    'description': 'James Newton-King\'s blog.',
    'item': [
      {
        'title': 'Json.NET 1.3 + New license + Now on CodePlex',
        'description': 'Announcing the release of Json.NET 1.3, the MIT license and the source on CodePlex',
        'link': 'http://james.newtonking.com/projects/json-net.aspx',
        'categories': [
          'Json.NET',
          'CodePlex'
        ]
      },
      {
        'title': 'LINQ to JSON beta',
        'description': 'Announcing LINQ to JSON',
        'link': 'http://james.newtonking.com/projects/json-net.aspx',
        'categories': [
          'Json.NET',
          'LINQ'
        ]
      }
    ]
  }
}";

JObject rss = JObject.Parse(json);

string rssTitle = (string)rss["channel"]["title"];
// James Newton-King

string itemTitle = (string)rss["channel"]["item"][0]["title"];
// Json.NET 1.3 + New license + Now on CodePlex

JArray categories = (JArray)rss["channel"]["item"][0]["categories"];
// ["Json.NET", "CodePlex"]

IList<string> categoriesText = categories.Select(c => (string)c).ToList();
// Json.NET
// CodePlex

Linq を使った例

上記のものを Linq を使ってクエリーしてみます。 シンプルに Select で、タイトルを抽出してみます。素直に実行できていますね。

            JObject rss = JObject.Parse(channel);
            var postTitles = rss["channel"]["item"].Select(p => p["title"]);
            foreach(var title in postTitles)
            {
                Console.WriteLine($"Title: {title}");
            };
実行結果
Title: Json.NET 1.3 + New license + Now on CodePlex
Title: LINQ to JSON beta

もう少し複雑なケースで、上記の Json にある、category の部分をクエリーします。二重配列になってしまうので、SelectManyを使って、flatten します。その後、GroupByを使って、グループ化した値を使って新しいオブジェクトを作ります。GroupBy の第一引数が、グループ化したい対象を表すための Function で、2つ目の Function は、Functionを実行した結果が、各グループのリストとして、3つ目の Function に渡されます。ここでは、その値を使って、Count() でグループに属するメンバーの数を数えています。最後に降順ソートを実施して終了。

            var categories = rss["channel"]["item"].SelectMany(p => p["categories"]).Values<string>()
                .GroupBy(k => k, v => v, (k, vs) => new { Key = k, Value = vs.Count() }).OrderByDescending(p => p.Value);
            foreach(var c in categories) {
                Console.WriteLine($"{c.Key}: {c.Value}");
            }

これは楽ちんですね。

実行結果
Json.NET: 2
CodePlex: 1
LINQ: 1

手動で強引にデシリアライズ

本来は、下記の Parse はうまく動作しません。なぜかというと、.NET Ojbect のプロパティと、JSON が一致していないからです。ケースセンシティブだからです。ところが、Parseすると、lower case ではアクセスできるのでそれを利用して強引にデシリアライズを実行しているサンプルです。

Shortie
        public class Shortie
        {
            public string Original { get; set; }
            public string Shortened { get; set; }
            public string Short { get; set; }
            public ShortieException Error { get; set; }
        }

        public class ShortieException
        {
            public int Code { get; set; }
            public string ErrorMessage { get; set; }
        }
string jsonText = @"{
  'short': {
    'original': 'http://www.foo.com/',
    'short': 'krehqk',
    'error': {
      'code': 0,
      'msg': 'No action taken'
    }
  }
}";

JObject json = JObject.Parse(jsonText);

Shortie shortie = new Shortie
{
    Original = (string)json["short"]["original"],
    Short = (string)json["short"]["short"],
    Error = new ShortieException
    {
        Code = (int)json["short"]["error"]["code"],
        ErrorMessage = (string)json["short"]["error"]["msg"]
    }
};

Console.WriteLine(shortie.Original);
// http://www.foo.com/

Console.WriteLine(shortie.Error.ErrorMessage);
// No action taken

SelectToken

SelectToken を書くと、クエリをダイナミックに定義することも可能です。ちなみに、SeelctTokenは、JsonPathもサポートしていますので、かなり複雑なことが出来そうですが、それはそれで頭が混乱しそうなので、SelectTokenと、Linq の組み合わせで十分な気がしますので、あまり調べないことにします。LinqToJsonの最初のサンプルを使ってクエリしてみましょう。

SelectToken
            var link = (string)rss.SelectToken("channel.item[1].link");
            Console.WriteLine($"SelectToken: Link: {link}");
実行結果
SelectToken: Link: http://james.newtonking.com/projects/json-net.aspx

まとめ

これでざっくり知りたかったことはわかったので、このブログは一旦終了です。Json.NET のページに行くと Performance Tips とか SerializationAttributesとか面白そうなところは残っていますが、今回のブログは一旦ここまで。また次のお楽しみにしておきます。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
25