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

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

### 建立 Console 應用程式

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

1.  我們先建立一個 .NET 5 的 Console 應用程式專案
    
    ```bash
    mkdir c1 && cd c1
    dotnet new globaljson  --sdk-version 5.0.301
    dotnet new console
    dotnet add package Newtonsoft.Json
    ```
    
2.  在專案中建立一個 `sample.json` 檔案，其內容如下
    
    ```csharp
    [
        {
            "_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 資料中的 `name` 與 `description` 並不是每一筆資料都有！
    
3.  建立一個名為 `Holiday` 的資料模型類別
    
    ```csharp
    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.  建立主程式
    
    ```csharp
    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.  輸出結果如下
    
    ```json
    959     2021/1/1        是
    假期名稱: 中華民國開國紀念日
    假期說明: 全國各機關學校放假一日
    960     2021/1/2        是
    假期名稱:
    假期說明:
    961     2021/1/3        是
    假期名稱:
    假期說明:
    ```
    

### 重新調整資料模型類別

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

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

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

1.  加入 `AdditionalData` 屬性並標示 `[JsonExtensionData]` 屬性(Attribute)
    
    ```csharp
    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.  然後將主程式修改成如下
    
    ```csharp
    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.  輸出結果如下
    
    ```json
    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.  主程式修改如下
    
    ```csharp
    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.  輸出結果如下
    
    ```json
    {"_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 的鍵值，然後拆解到其他的強型別屬性中

```csharp
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`, ... 這類的資料，更可以利用這個技巧，將這些資料轉成強型別的陣列型別，讓你更有效率的操作資料！

### 相關連結

-   [Deserialize ExtensionData](https://www.newtonsoft.com/json/help/html/DeserializeExtensionData.htm)
-   [How to handle overflow JSON with System.Text.Json | Microsoft Docs](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-handle-overflow?WT.mc_id=DT-MVP-4015686)
-   [JsonExtensionDataAttribute 類別 (System.Text.Json.Serialization) | Microsoft Docs](https://learn.microsoft.com/zh-tw/dotnet/api/system.text.json.serialization.jsonextensiondataattribute?view=net-5.0&WT.mc_id=DT-MVP-4015686)
-   [Using JsonExtensionData with JSON.NET](https://web.archive.org/web/20220828034108/https://www.jerriepelser.com/blog/using-jsonextensiondata-with-jsonnet/)
-   [c# - Deserializing JSON objects to an array - Stack Overflow](https://stackoverflow.com/a/20447673/910074)
-   [robertwray.co.uk - Deserializing awkward JSON using Json.NET](https://robertwray.co.uk/blog/deserializing-awkward-json-using-json-net)
-   [Issues on `JsonExtensionData` · JamesNK/Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json/issues?q=is%3Aissue+JsonExtensionData+is%3Aclosed)
-   [Json.NET - Newtonsoft](https://www.newtonsoft.com/json)
-   [Newtonsoft.Json/JsonExtensionDataAttribute.cs at master · JamesNK/Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json/blob/master/Src/Newtonsoft.Json/JsonExtensionDataAttribute.cs)
    -   [\-Added support for serializing extension data · JamesNK/Newtonsoft.Json@3536c76](https://github.com/JamesNK/Newtonsoft.Json/commit/3536c768730d7dbfe642874404ab43b2ce6b2256#diff-cf046f236de2657a23c1430dcc87a8c2c7185bfb6cd6ba94913be3d1dc4ceb72)
