C#で動的にJsonパースしたい!
C#でもクラスを準備することなくjsonを jqや python 、node.js のようにダックタイピングしたいことがあります。たとえば、
- C#クラスを準備するのが面倒。ネストが多くてPOCOクラスだらけになる。
-
int
なのに"NULL"
や"-"
が入ってくるWebAPIがいる。 - ちょっとデータ欲しいだけなのにコンバーター作ったり、属性つけたり面倒。
- APIが適当すぎてクラス定義できない・・・
.NET6 で動的にjsonを扱うクラス JsonNode
が追加されています。その使い勝手をベンチマーク含めて他の方法と比較します。
サンプルのjsonデータ
HeartRails Express から 山手線の駅一覧 を使わせていただきました。このjsonから駅名一覧を抽出するサンプルを作っていきます。
jqコマンドなら jq ".response.station[].name"
になります。
jsonデータ例(2021年11月現在)
{
"response": {
"station": [
{
"name": "品川",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.738999,
"y": 35.62876,
"postal": "1080075",
"prev": null,
"next": "大崎"
},
{
"name": "大崎",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.728439,
"y": 35.619772,
"postal": "1410032",
"prev": "品川",
"next": "五反田"
},
{
"name": "五反田",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.723822,
"y": 35.625974,
"postal": "1410022",
"prev": "大崎",
"next": "目黒"
},
{
"name": "目黒",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.715775,
"y": 35.633923,
"postal": "1410021",
"prev": "五反田",
"next": "恵比寿"
},
{
"name": "恵比寿",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.71007,
"y": 35.646684,
"postal": "1500013",
"prev": "目黒",
"next": "渋谷"
},
{
"name": "渋谷",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.701238,
"y": 35.658871,
"postal": "1500002",
"prev": "恵比寿",
"next": "原宿"
},
{
"name": "原宿",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.702592,
"y": 35.670646,
"postal": "1500001",
"prev": "渋谷",
"next": "代々木"
},
{
"name": "代々木",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.702042,
"y": 35.683061,
"postal": "1510051",
"prev": "原宿",
"next": "新宿"
},
{
"name": "新宿",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.700464,
"y": 35.689729,
"postal": "1600022",
"prev": "代々木",
"next": "新大久保"
},
{
"name": "新大久保",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.700261,
"y": 35.700875,
"postal": "1690073",
"prev": "新宿",
"next": "高田馬場"
},
{
"name": "高田馬場",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.703715,
"y": 35.712677,
"postal": "1690075",
"prev": "新大久保",
"next": "目白"
},
{
"name": "目白",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.706228,
"y": 35.720476,
"postal": "1710031",
"prev": "高田馬場",
"next": "池袋"
},
{
"name": "池袋",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.711085,
"y": 35.730256,
"postal": "1710021",
"prev": "目白",
"next": "大塚"
},
{
"name": "大塚",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.728584,
"y": 35.731412,
"postal": "1700005",
"prev": "池袋",
"next": "巣鴨"
},
{
"name": "巣鴨",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.739303,
"y": 35.733445,
"postal": "1700002",
"prev": "大塚",
"next": "駒込"
},
{
"name": "駒込",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.748053,
"y": 35.736825,
"postal": "1700003",
"prev": "巣鴨",
"next": "田端"
},
{
"name": "田端",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.761229,
"y": 35.737781,
"postal": "1140013",
"prev": "駒込",
"next": "西日暮里"
},
{
"name": "西日暮里",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.766857,
"y": 35.731954,
"postal": "1160013",
"prev": "田端",
"next": "日暮里"
},
{
"name": "日暮里",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.771287,
"y": 35.727908,
"postal": "1100001",
"prev": "西日暮里",
"next": "鶯谷"
},
{
"name": "鶯谷",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.778015,
"y": 35.721484,
"postal": "1100003",
"prev": "日暮里",
"next": "上野"
},
{
"name": "上野",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.777043,
"y": 35.71379,
"postal": "1100005",
"prev": "鶯谷",
"next": "御徒町"
},
{
"name": "御徒町",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.774727,
"y": 35.707282,
"postal": "1100005",
"prev": "上野",
"next": "秋葉原"
},
{
"name": "秋葉原",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.773288,
"y": 35.698619,
"postal": "1010028",
"prev": "御徒町",
"next": "神田"
},
{
"name": "神田",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.770641,
"y": 35.691173,
"postal": "1010044",
"prev": "秋葉原",
"next": "東京"
},
{
"name": "東京",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.766103,
"y": 35.681391,
"postal": "1000005",
"prev": "神田",
"next": "有楽町"
},
{
"name": "有楽町",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.763806,
"y": 35.675441,
"postal": "1000006",
"prev": "東京",
"next": "新橋"
},
{
"name": "新橋",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.758587,
"y": 35.666195,
"postal": "1050004",
"prev": "有楽町",
"next": "浜松町"
},
{
"name": "浜松町",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.757135,
"y": 35.655391,
"postal": "1050022",
"prev": "新橋",
"next": "田町"
},
{
"name": "田町",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.747575,
"y": 35.645737,
"postal": "1080023",
"prev": "浜松町",
"next": "高輪ゲートウェイ"
},
{
"name": "高輪ゲートウェイ",
"prefecture": "東京都",
"line": "JR山手線",
"x": 139.740651,
"y": 35.635476,
"postal": "1080075",
"prev": "田町",
"next": null
}
]
}
}
比較ライブラリ
以下のライブラリと比較します。Json.NETは外しました。
- JsonNode
- System.Text.Json(クラス利用)
- System.Text.Json(動的)
- DynaJson
- DynamicJson
- Utf8Json (dynamic)
- JmesPath.Net
利用例
JsonNode
.NET6で標準ライブラリに入った JsonNode です。jsonを連想配列のように扱うことが可能です。例えば今回のjsonで"品川"を得るには node["response"]["station"][0]["name"]
というコードになります。
駅一覧抽出コード例は以下の通り。"station" を AsArray() で列挙可能にした後 Linqで抽出しています。
using System.Text.Json.Nodes;
var node = JsonNode.Parse(jsonString);
var list = node["response"]["station"].AsArray()
.Select(s => s["name"].ToString());
ToString()
を使っていますが、本来は型で抽出するには GetValue<T>()
を使うようです。
存在しないキーにアクセスした場合はnullが返ってきます。
System.Text.Json(クラス利用)
参考までにクラスを作る標準的な方法も入れておきます。
型を作ってデシリアライズ、駅名一覧をLinqで抽出。クラスを作るのが問題なければ順当な方法です。
using System.Text.Json;
var stationInfo = JsonSerializer.Deserialize<StationInfo>(jsonString);
var list = stationInfo.response.station.Select(s => s.name);
クラス定義は json2charpで作りました。キャメルケースでC#らしくないですが、属性つけたりJsonOption作るのが面倒なので許してください・・・。
public class Station
{
public string name { get; set; }
public string prefecture { get; set; }
public string line { get; set; }
public double x { get; set; }
public double y { get; set; }
public string postal { get; set; }
public string prev { get; set; }
public string next { get; set; }
}
public class Response
{
public List<Station> station { get; set; }
}
public class StationInfo
{
public Response response { get; set; }
}
System.Text.Json(動的)
System.Text.Jsonで動的にJsonを扱うには JsonElement
を使ってプロパティにアクセスする形になります。1つずつJsonElementノードを下っていき、列挙までもっていったら Linq で抽出してます。クラス定義は不要になりましたが、可読性が低いです。ですがパフォーマンスは一番いいです(後述のベンチマーク参照)。
using System.Text.Json;
var doc = JsonDocument.Parse(jsonString);
var list = doc.RootElement
.GetProperty("response")
.GetProperty("station")
.EnumerateArray()
.Select(s => s.GetProperty("name").GetString());
DynaJson
fujiedaさん作の高速な動的Jsonシリアライザ DynaJson ライブラリです。使い勝手は下の DynamicJson 互換、、衝撃的なほど高速で低メモリー消費です。
using DynaJson
var dyna = DynaJson.JsonObject.Parse(jsonString);
var list = new List<string>();
foreach (var station in dyna.response.station)
{
list.Add(station.name);
}
存在しないプロパティ名へアクセスすると「RuntimeBinderException」が発生します。
そのため、IsDefined()
を使ってキーがあるかチェックの必要があるケースもあるでしょう。
DynamicJson
neue.ccさん作のJsonライブラリDynamicJson。サンプルコードはDynaJsonと同じなので省略します。DataContractJsonSerializer しかなかった時代に使わせてもらってました。
作者のneueccさんは下のUTF8Jsonをはじめ、ZeroFormatter や Messagepack for c# などパフォーマンス改善に踏み込んでいてブログも楽しく読ませてもらってます。
Utf8Json
最速と謳われる 同じくneueccさんのライブラリ Utf8Json であえて動的に扱ってみました。
dynamicへデコードし this[]アクセサーを使ってます。
var res = Utf8Json.JsonSerializer.Deserialize<dynamic>(jsonString);
var list = new List<string>();
foreach (var item in res["response"]["station"])
{
list.Add(item["name"]);
}
System.Text.Json
でもこのように dynamic へのデコードが出来るとよいのですが。
JmesPath.Net
AWSやAzureを使っている方ならなじみ深いJsonクエリー言語 JMESPathも参考として使ってみました。駅名一覧はJMESPath記法で "response.station[].name"
というクエリーになります。.NET用のライブラリはこちら。内部的にNewtonsoft.Json.NETを利用しているそうです。
コードはこれまでで最短です。
using DevLab.JmesPath;
var jmes = new JmesPath();
var result = jmes.Transform(jsonString, "response.station[].name");
// "[ "品川","大崎","五反田"... という「文字列」が返ってくる
JmesPath記法を知っていれば簡潔に記載できますが、戻り値は駅一覧のJson文字列です(C#の配列ではなく「文字列」)。そのためベンチマークは参考までとします。
ベンチマーク結果
| Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Allocated |
|-------------------------- |----------:|---------:|---------:|--------:|-------:|----------:|
| JsonNode | 71.85 us | 0.371 us | 0.347 us | 11.5967 | - | 47 KB |
| System.Text.Json | 45.29 us | 0.194 us | 0.172 us | 2.3193 | 0.0610 | 10 KB |
| (dynamic)System.Text.Json | 30.93 us | 0.194 us | 0.162 us | 4.3335 | - | 18 KB |
| Dynajson | 30.56 us | 0.570 us | 0.904 us | 9.5825 | 0.3052 | 39 KB |
| DynamicJson | 175.79 us | 1.029 us | 0.912 us | 16.8457 | 0.9766 | 70 KB |
| (dynamic)Uft8Json | 60.46 us | 0.298 us | 0.249 us | 12.2681 | 0.5493 | 50 KB |
| JmesPath | 322.27 us | 4.488 us | 3.504 us | 65.4297 | 2.4414 | 268 KB |
コード量が少なく直感的なのは JMESPath ですが、結果が利用しずらく速度も遅めです。
最速は DynaJson 又は System.Text.Jsonで動的 ですが、コーディングの容易さも考えると DynaJson もしくは JsonNode でしょうか。 DynaJsonは動的言語っぽく、JsonNodeはC#の言語仕様によくマッチしていると思います。
Utf8Json のほうが速いのですが これを使うときはちゃんとしたものを作るときな気がします・・。
存在しないプロパティへアクセスした場合のエラー処理も選択時のポイントになると思います。DynaJsonは「RuntimeBinderException」が発生、JsonNodeは null
が戻ってきます。