工作上有一堆重複性很高的網頁表單欄位處理需求,我想做一個萬用框架搞定它,其中有個欄位映對問題。

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 專頁,得到來自讀者朋友們的迴響,我學到幾件事:

  1. 這議題不算罕見,有些工具只吃 Key / Value 或 Flat JSON,就需要類似轉換
  2. 爬文關鍵字是 Flatten JSON,Nested to Flat... 等,可找到很多參考資料
  3. 它算是 DFS(Depth First Search) Traversal 的經典應用
  4. 網路上現成程式範例還不少,但幾乎清一色都是 JavaScript。JavaScript 的弱型別跟動態物件特性(obj[propName]=propValue),在此佔盡便宜
  5. 我沒找到 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分~ (嗚...)

Post a comment