Coding4Fun-巢狀 JSON 資料結構與 Key/Value 雙向轉換(C# 版)
2 |
工作上有一堆重複性很高的網頁表單欄位處理需求,我想做一個萬用框架搞定它,其中有個欄位映對問題。
C# 端的 ViewModel 包含巢狀階層結構,屬性與陣列交雜,映對到 HTML 端 INPUT、SELECT、TEXTAREA 時,我想為所有屬性唯一識別 ID。例如,ViewModel 的 A 屬性是個物件陣列,因此 A 陣列的第二個元素物件的 B 屬性,就可寫成 Model.A[1].B。
例如這樣的 JSON 結構:
{
"A": [1,2,3],
"B": {"X": 4,"Y": 5},
"C": [
{
"Z": 6,
"D": [
{
"P": true, "Q": false, "B": {"X": 0,"Y": 1}
},
{
"P": true, "Q": true, "B": null
}
]
},
{
"Z": 7, "D": null
}
]
}
要轉成這樣的 Dictionary<string, object>:
{
"A[0]": 1,
"A[1]": 2,
"A[2]": 3,
"B.X": 4,
"B.Y": 5,
"C[0].Z": 6,
"C[0].D[0].P": true,
"C[0].D[0].Q": false,
"C[0].D[0].B.X": 0,
"C[0].D[0].B.Y": 1,
"C[0].D[1].P": true,
"C[0].D[1].Q": true,
"C[0].D[1].B": null,
"C[1].Z": 7,
"C[1].D": null
}
好久沒遇到這種有點難又不會太難的挑戰,當成程式魔人的 Coding4Fun 假日暖身操剛好,順手分享到 Facebook 專頁,得到來自讀者朋友們的迴響,我學到幾件事:
- 這議題不算罕見,有些工具只吃 Key / Value 或 Flat JSON,就需要類似轉換
- 爬文關鍵字是 Flatten JSON,Nested to Flat... 等,可找到很多參考資料
- 它算是 DFS(Depth First Search) Traversal 的經典應用
- 網路上現成程式範例還不少,但幾乎清一色都是 JavaScript。JavaScript 的弱型別跟動態物件特性(obj[propName]=propValue),在此佔盡便宜
- 我沒找到 C# 寫的範例。很好,就不再花時間找了,剛好是練功止手癢的好理由(謎之聲:是有多愛寫程式啦?)
我沒學過 DFS,但腦海大概知道怎麼用 Reflection + Recursive 實現,不到 30 分鐘就寫完列序化轉換,但在還原部分陷入苦戰。
C# 不像 JavaScript 可以 object[propNameA][subPropName][2] = propValue
,甚至用 eval() 動態組程式碼克服刁鑽情境。前後試過多種寫法,試過 Json.NET 客製轉換、先對映成 TreeNode 結構再轉、串接 Dictionary<string, object>、借用 JObject... 砍掉重練多次不是失敗就是程式碼複雜到自己想吐。
最後我決定回歸初心,既然用 JavaScript 動態物件我有把握搞定,何不在 C# 也用動態物件解決? 於是我想起 ExpandoObject! (參考:既然要動態就動個痛快 - ExpandoObject)
前後耗費六個小時(好久沒有想程式想這麼久,這是哪門子暖身? 我暖身到都快虛脫惹),第一個可用版本出爐,轉換邏輯大約一百行左右:
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
namespace FlatJson
{
/// <summary>
/// Flatten nested JSON
/// { "A":[1,2,3], "B":{"X":4,"Y":5},"C":[{"Z":6},{"Z":7}]}
/// to simple key/value dictionary
/// {"A[0]":1,"A[1]":2,"A[2]": 3,"B.X":4,"B.Y":5,"C[0].Z":6,"C[1].Z":7}
/// </summary>
public static class FlatJsonConvert
{
static void ExploreAddProps(Dictionary<string, object> props, string name, Type type, object value)
{
if (value == null || type.IsPrimitive || type == typeof(DateTime) || type == typeof(string))
props.Add(name, value);
else if (type.IsArray)
{
var a = (Array)value;
for (var i = 0; i < a.Length; i++)
ExploreAddProps(props, $"{name}[{i}]", type.GetElementType(), a.GetValue(i));
}
else
{
type.GetProperties().ToList()
.ForEach(p =>
{
var prefix = string.IsNullOrEmpty(name) ? string.Empty : name + ".";
ExploreAddProps(props, $"{prefix}{p.Name}", p.PropertyType, p.GetValue(value));
});
}
}
public static string Serialize<T>(T data)
{
var props = new Dictionary<string, object>();
ExploreAddProps(props, string.Empty, typeof(T), data);
return JsonConvert.SerializeObject(props, Formatting.Indented);
}
public static T Deserialize<T>(string json)
{
var props = JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
ExpandoObject data = new ExpandoObject();
Action<string, object> SetProp = (propName, propValue) =>
{
var seg = propName.Split('.');
object curr = data;
for (var i = 0; i < seg.Length; i++)
{
var n = seg[i];
var isLeaf = i == seg.Length - 1;
if (n.Contains("[")) //for array
{
var pn = n.Split('[').First();
var d = curr as IDictionary<string, object>;
if (!d.ContainsKey(pn)) d[pn] = new List<object>();
if (isLeaf)
(d[pn] as List<object>).Add(propValue);
else
{
var idx = int.Parse(n.Split('[').Last().TrimEnd(']'));
var lst = (d[pn] as List<object>);
if (idx == lst.Count) lst.Add(new ExpandoObject());
if (idx < lst.Count)
curr = lst[idx];
else
throw new NotImplementedException("Skiped index is not supported");
}
}
else //for property
{
if (curr is List<object>)
throw new NotImplementedException("Array of array is not supported");
else
{
var d = curr as IDictionary<string, object>;
if (isLeaf)
d[n] = propValue;
else
{
if (!d.ContainsKey(n)) d[n] = new ExpandoObject();
curr = d[n];
}
}
}
}
};
props.Keys.OrderBy(o => o.Split('.').Length) //upper level first
.ThenBy(o => //prop first, array elements ordered by index
!o.Split('.').Last().Contains("]") ? -1 :
int.Parse(o.Split('.').Last().Split('[').Last().TrimEnd(']')))
.ToList().ForEach(o => SetProp(o, props[o]));
var unflattenJson = JsonConvert.SerializeObject(data);
return JsonConvert.DeserializeObject<T>(unflattenJson);
}
}
}
驗證程式如下:
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace FlatJson
{
class Program
{
static void Main(string[] args)
{
AType data = new AType()
{
A = new int[] { 1, 2, 3 },
B = new BType() { X = 4, Y = 5 },
C = new CType[]
{
new CType() {
Z = 6,
D = new DType[] {
new DType() {
P = true,
Q = false,
B = new BType() { X = 0, Y = 1 }
},
new DType()
{
P = true,
Q = true }
}
},
new CType() { Z = 7}
}
};
var origJson = JsonConvert.SerializeObject(data, Formatting.Indented);
var flattenJson = FlatJsonConvert.Serialize<AType>(data);
var restored = FlatJsonConvert.Deserialize<AType>(flattenJson);
var restoredJson = JsonConvert.SerializeObject(restored, Formatting.Indented);
Console.WriteLine(origJson);
Console.WriteLine(flattenJson);
Console.WriteLine("Check Result = " + (restoredJson == origJson ? "PASS" : "FAIL"));
Console.Read();
}
}
public class AType
{
public int[] A { get; set; }
public BType B { get; set; }
public CType[] C { get; set; }
}
public class BType
{
public int X { get; set; }
public int Y { get; set; }
}
public class CType
{
public int Z { get; set; }
public DType[] D { get; set; }
}
public class DType
{
public bool P { get; set; }
public bool Q { get; set; }
public BType B { get; set; }
}
}
實測過關:
這個演算法有個限制,它假設陣列元素必須是基本型別或其他類別,不能直接是陣列。此外,它跑過的測試案例有限,對更複雜的情境可能存在 Bug,但已符合我眼前要處理的需求。
關鍵元件完成,通過自己設定的小小挑戰,心滿意足~
A implementation of C# to flatten nested JSON structure to key/value dictionary.
Comments
# by Anthony Lee
找到水印了: Deep First Search -> Depth First Search
# by Jeffrey
to Anthony Lee, 噹噹噹,葛來芬多加3分~ (嗚...)