使用 Json.NET 定義一個混合強型別與弱型別的 JSON 資料回應 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

使用 Json.NET 定義一個混合強型別與弱型別的 JSON 資料回應

我們最近有個專案需求特別複雜,由於是個已經持續維護 10 年的系統要改版,很多商業邏輯已經不可考,只能從程式碼中找尋蛛絲馬跡。不過,有些資料的欄位來自於程式碼,但更多來自於一組極其複雜的動態資料表設計。我們除了從現有的頁面上進行新系統設計外,有時候還會意外的多出幾個莫名的欄位,因此對於資料模型類別的規劃變的異常困難。本篇文章我將分享一種罕見的 Json.NET 資料序列化技巧,幫助你可以做到動態的 JSON 資料回應格式,同時又能保有強型別的設計。

建立 Console 應用程式

我打算用一個簡單的例子來說明這個過程,但事實上你在撰寫 Web API 的時候,只要是使用 Json.NET (Newtonsoft.Json) 來進行序列化/反序列化,其過程都是完全相同的!

  1. 我們先建立一個 .NET 5 的 Console 應用程式專案

    mkdir c1 && cd c1
    dotnet new globaljson  --sdk-version 5.0.301
    dotnet new console
    dotnet add package Newtonsoft.Json
    
  2. 在專案中建立一個 sample.json 檔案,其內容如下

    [
        {
            "_id": 959,
            "date": "2021/1/1",
            "name": "中華民國開國紀念日",
            "description": "全國各機關學校放假一日",
            "holidayCategory": "放假之紀念日及節日",
            "isHoliday": "是"
        },
        {
            "_id": 960,
            "date": "2021/1/2",
            "holidayCategory": "星期六、星期日",
            "isHoliday": "是"
        },
        {
            "_id": 961,
            "date": "2021/1/3",
            "holidayCategory": "星期六、星期日",
            "isHoliday": "是"
        }
    ]
    

    你可以從上述資料看出,其實 JSON 資料中的 namedescription 並不是每一筆資料都有!

  3. 建立一個名為 Holiday 的資料模型類別

    using Newtonsoft.Json;
    
    namespace c1
    {
        public partial class Holiday
        {
            [JsonProperty("_id")]
            public long Id { get; set; }
    
            [JsonProperty("date")]
            public string Date { get; set; }
    
            [JsonProperty("name")]
            public string Name { get; set; }
    
            [JsonProperty("description")]
            public string Description { get; set; }
    
            [JsonProperty("holidayCategory")]
            public string HolidayCategory { get; set; }
    
            [JsonProperty("isHoliday")]
            public string IsHoliday { get; set; }
        }
    }
    
  4. 建立主程式

    using System;
    using System.IO;
    using Newtonsoft.Json;
    
    namespace c1
    {
        class Program
        {
            static void Main(string[] args)
            {
                var data = JsonConvert.DeserializeObject<Holiday[]>(File.ReadAllText("./sample.json"));
    
                foreach (var item in data)
                {
                    Console.WriteLine(item.Id + "\t" + item.Date + "\t" + item.IsHoliday);
                    Console.WriteLine("假期名稱: " + item.Name);
                    Console.WriteLine("假期說明: " + item.Description);
                }
            }
        }
    }
    
  5. 輸出結果如下

    959     2021/1/1        是
    假期名稱: 中華民國開國紀念日
    假期說明: 全國各機關學校放假一日
    960     2021/1/2        是
    假期名稱:
    假期說明:
    961     2021/1/3        是
    假期名稱:
    假期說明:
    

重新調整資料模型類別

我們想把這兩個欄位定義成「非必要」的欄位,一般的時候不需要回應給用戶端知道,當前端需要的時候才需要序列化給用戶端。

事實上我們專案中的欄位有數十到數百個動態欄位,設計之初可以從需求訪談得知一些「必要」的欄位,並設計到強型別的模型類別中,但其他的擴充欄位,我們打算當成「額外的」附加資料,有資料的時候就回傳,沒資料的時候就完全看不到。

這時我打算調整一下我們的模型類別,將 NameDescription 欄位給刪除,並加入另一個特殊的 AdditionalData 屬性。

  1. 加入 AdditionalData 屬性並標示 [JsonExtensionData] 屬性(Attribute)

    using System.Collections.Generic;
    using Newtonsoft.Json;
    
    namespace c1
    {
        public partial class Holiday
        {
            [JsonProperty("_id")]
            public long Id { get; set; }
    
            [JsonProperty("date")]
            public string Date { get; set; }
    
            // [JsonProperty("name")]
            // public string Name { get; set; }
    
            // [JsonProperty("description")]
            // public string Description { get; set; }
    
            [JsonProperty("holidayCategory")]
            public string HolidayCategory { get; set; }
    
            [JsonProperty("isHoliday")]
            public string IsHoliday { get; set; }
    
            [JsonExtensionData]
            public IDictionary<string, object> AdditionalData { get; set; } = new Dictionary<string, object>();
        }
    }
    

    這裡的 AdditionalData 屬性要標示 [JsonExtensionData] 屬性(Attribute),就必須要宣告型別為 IDictionary<string, object>IDictionary<string, JToken> 才行!

  2. 然後將主程式修改成如下

    using System;
    using System.IO;
    using Newtonsoft.Json;
    
    namespace c1
    {
        class Program
        {
            static void Main(string[] args)
            {
                var data = JsonConvert.DeserializeObject<Holiday[]>(File.ReadAllText("./sample.json"));
    
                foreach (var item in data)
                {
                    Console.WriteLine(item.Id + "\t" + item.Date + "\t" + item.IsHoliday);
                    if (item.AdditionalData.Keys.Contains("name"))
                    {
                        Console.WriteLine("假期名稱: " + item.AdditionalData["name"]);
                    }
                    if (item.AdditionalData.Keys.Contains("description"))
                    {
                        Console.WriteLine("假期說明: " + item.AdditionalData["description"]);
                    }
                }
            }
        }
    }
    

    簡單來說,這些所謂的「擴充欄位」全部都會自動放在 AdditionalData 這個 IDictionary<string, object> 字典型別的屬性下!

  3. 輸出結果如下

    959     2021/1/1        是
    假期名稱: 中華民國開國紀念日
    假期說明: 全國各機關學校放假一日
    960     2021/1/2        是
    961     2021/1/3        是
    

    注意:如果試圖存取一個不存在的 Key 將會導致 Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key 'description1' was not present in the dictionary. 例外發生!

序列化強型別物件到可彈性擴充屬性的 JSON 輸出

假設我們想將 Holiday 物件外加一些額外的屬性到序列化後的 JSON 資料中,就可以參考以下寫法:

  1. 主程式修改如下

    using System;
    using Newtonsoft.Json;
    
    namespace c1
    {
        class Program
        {
            static void Main(string[] args)
            {
                var data = new Holiday()
                {
                    Id = 959,
                    Date = "2021/1/1",
                    IsHoliday = "是"
                };
    
                Console.WriteLine(JsonConvert.SerializeObject(data));
    
                data.AdditionalData.Add("name", "中華民國開國紀念日");
                data.AdditionalData.Add("description", "全國各機關學校放假一日");
    
                Console.WriteLine(JsonConvert.SerializeObject(data));
            }
        }
    }
    
  2. 輸出結果如下

    {"_id":959,"date":"2021/1/1","holidayCategory":null,"isHoliday":"是"}
    {"_id":959,"date":"2021/1/1","holidayCategory":null,"isHoliday":"是","name":"中華民國開國紀念日","description":"全國各機關學校放假一日"}
    

合併多個欄位到另一個強型別的屬性中

本篇文章講解的「擴充屬性」技巧,也可以套用在 反序列化 (Deserialize) 的過程中,將兩個或多個屬性合併成一個或多個屬性,相當實用!

如下程式碼範例,你只要先定義出以下 DTO 模型物件,就可以做到更多反序列化過程的客製化行為:

  1. 定義一個 private void OnDeserialized(StreamingContext context) 方法,並套用 [OnDeserialized] 屬性(Attribute)
  2. 你可以從私有的 _additionalData 屬性取出 JSON 的鍵值,然後拆解到其他的強型別屬性中
public class DirectoryAccount
{
    // normal deserialization
    public string DisplayName { get; set; }

    // these properties are set in OnDeserialized
    public string UserName { get; set; }
    public string Domain { get; set; }

    [JsonExtensionData]
    private IDictionary<string, JToken> _additionalData;

    [OnDeserialized]
    private void OnDeserialized(StreamingContext context)
    {
        // SAMAccountName is not deserialized to any property
        // and so it is added to the extension data dictionary
        string samAccountName = (string)_additionalData["SAMAccountName"];

        Domain = samAccountName.Split('\\')[0];
        UserName = samAccountName.Split('\\')[1];
    }

    public DirectoryAccount()
    {
        _additionalData = new Dictionary<string, JToken>();
    }
}

當然,如果你的擴充屬性是 type1, type2, type3, ... 這類的資料,更可以利用這個技巧,將這些資料轉成強型別的陣列型別,讓你更有效率的操作資料!

相關連結