﻿# 使用 Json.NET 與 QuickType 搭配字串轉 Enum 的絕佳解決方案

我每次需要呼叫遠端 Web API 的時候，都會盡量使用強型別的方式建立模型類別，但我基本上都不自己寫 Code，而是透過工具從 JSON 直接轉成模型類別。雖然說方便歸方便，但難免還是會遇到惱人的地雷，今天這篇文章描述問題的份量會比解決方案來的多，請大家一步一步的看下去，就可以理解整個來龍去脈。

### 重現問題的步驟

以下皆使用 Visual Studio Code 完成專案開發，實作前請先安裝 [.NET Core Extension Pack](https://marketplace.visualstudio.com/items?itemName=doggy8088.netcore-extension-pack) 擴充套件！

> 如果你的 VSCode 同時安裝了 [Paste JSON as Code (Refresh)](https://marketplace.visualstudio.com/items?itemName=doggy8088.quicktype-refresh) 與 [Paste JSON as Code](https://marketplace.visualstudio.com/items?itemName=quicktype.quicktype) 擴充套件，請記得移除 **Paste JSON as Code** 這個已經不再維護的擴充套件。

1.  建立 Console 應用程式
    
    ```bash
    mkdir c1
    cd c1
    
    dotnet new globaljson --sdk-version 5.0.301
    dotnet new console
    
    dotnet add package System.Net.Http
    dotnet add package Newtonsoft.Json
    
    code .
    ```
    
    > 因為 QuickType 會用到 [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json) 套件(俗稱 **Json.NET**)，所以我們先安裝起來。
    
2.  建立 Model 類別
    
    先連到 [臺北市政府行政機關辦公日曆表](https://data.taipei/#/dataset/detail?id=c30ca421-d935-4faa-b523-9c175c8de738) 的 [API 連結](https://data.taipei/api/v1/dataset/29d9771d-c0ee-40d4-8dfb-3866b0b7adaa?scope=resourceAquire&offset=958&limit=1000)，並複製完整的 JSON 內容到剪貼簿。
    
    回到 VSCode 建立 `Holiday.cs` 檔案，執行 **Paste JSON as Type** 命令，看到 **Top-level type name?** 請輸入 `Holiday`，接著 VSCode 就會全自動幫你產生模型類別！
    
    > 注意: 預設命名空間為 `QuickType`
    
3.  嘗試使用 [System.Net.Http.Json](https://www.nuget.org/packages/System.Net.Http.Json) 提供的擴充方法取得強型別的結果
    
    ```bash
    dotnet add package System.Net.Http.Json
    ```
    
    完成 `Program.cs` 程式碼如下：
    
    ```csharp
    using System.Net.Http;
    using System.Net.Http.Json;
    using QuickType;
    using System.Threading.Tasks;
    
    namespace c1
    {
        class Program
        {
            static async Task Main(string[] args)
            {
                using var client = new HttpClient();
    
                var result = await client.GetFromJsonAsync<Holiday>("https://data.taipei/api/v1/dataset/29d9771d-c0ee-40d4-8dfb-3866b0b7adaa?scope=resourceAquire&offset=958&limit=1000");
            }
        }
    }
    ```
    
4.  此時你透過 `dotnet run` 就會發現，你會得到一個序列化過程的例外狀況 `System.Text.Json.JsonException`，也是本文想要分享的主要問題
    
    ```log
    Unhandled exception. System.Text.Json.JsonException: The JSON value could not be converted to QuickType.HolidayCategory. Path: $.result.results[0].holidayCategory | LineNumber: 0 | BytePositionInLine: 169.
      at System.Text.Json.ThrowHelper.ThrowJsonException(String message)
      at System.Text.Json.Serialization.Converters.EnumConverter`1.Read(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options)
      at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
      at System.Text.Json.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
      at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
      at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
      at System.Text.Json.Serialization.Converters.IEnumerableDefaultConverter`2.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, TCollection& value)
      at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
      at System.Text.Json.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
      at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
      at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
      at System.Text.Json.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
      at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
      at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
      at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
      at System.Text.Json.JsonSerializer.ReadCore[TValue](JsonConverter jsonConverter, Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
      at System.Text.Json.JsonSerializer.ReadCore[TValue](JsonReaderState& readerState, Boolean isFinalBlock, ReadOnlySpan`1 buffer, JsonSerializerOptions options, ReadStack& state, JsonConverter converterBase)
      at System.Text.Json.JsonSerializer.ReadAsync[TValue](Stream utf8Json, Type returnType, JsonSerializerOptions options, CancellationToken cancellationToken)
      at System.Net.Http.Json.HttpContentJsonExtensions.ReadFromJsonAsyncCore[T](HttpContent content, Encoding sourceEncoding, JsonSerializerOptions options, CancellationToken cancellationToken)
      at System.Net.Http.Json.HttpClientJsonExtensions.GetFromJsonAsyncCore[T](Task`1 taskResponse, JsonSerializerOptions options, CancellationToken cancellationToken)
      at c1.Program.Main(String[] args) in G:\Projects\c1\Program.cs:line 14
      at c1.Program.<Main>(String[] args)
    ```
    

### 自製 HttpClientJsonExtensions 擴充方法 (Newtonsoft.Json)

由於透過 QuickType 所產生的模型類別，全部都是以 [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json) 套件為主，而且與 [System.Text.Json](https://github.com/dotnet/corefx/tree/master/src/System.Text.Json/src/System/Text/Json) 不太相容，所以你透過 [Paste JSON as Code (Refresh)](https://marketplace.visualstudio.com/items?itemName=doggy8088.quicktype-refresh) 套件所產生的程式碼，如果遇到稍微複雜的 JSON 格式，很有可能就沒辦法讓 **Serialize**/**Deserialize** 正常運作！

> 這裡主要是 **JsonConverter** 的實作方式不同，所以你不能混合使用這兩個不同的套件！

這裡我用了一個 [GetFromJsonAsync 方法](https://learn.microsoft.com/zh-tw/dotnet/api/system.net.http.json.httpclientjsonextensions.getfromjsonasync?view=net-5.0&WT.mc_id=DT-MVP-4015686)，非常的好用，可惜並無法跟 [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json) 混合使用。

其實要為了 [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json) 實作另一份 `HttpClientJsonExtensions` 並不是特別困難，你可以到 [System.Net.Http.Json](https://github.com/dotnet/runtime/tree/main/src/libraries/System.Net.Http.Json/src/System/Net/Http/Json) 參考一下寫法，改成 Newtonsoft.Json 的版本即可。

以下就是我的一份簡易實作版本：

1.  先移除 `System.Net.Http.Json` 套件參考
    
    ```bash
    dotnet remove package System.Net.Http.Json
    ```
    
2.  移除引用 System.Net.Http.Json 命名空間
    
    ~~using System.Net.Http.Json;~~
    
3.  加入 `HttpClientJsonExtensions.cs` 類別
    
    ```csharp
    using System.Text;
    using System.Net.Http;
    using Newtonsoft.Json;
    
    namespace c1
    {
        public static class HttpClientJsonExtensions
        {
            public static async System.Threading.Tasks.Task<HttpResponseMessage> PostAsJsonAsync<T>(this HttpClient client, string requestUrl, T theObj)
            {
                return await client.PostAsync(requestUrl, new StringContent(JsonConvert.SerializeObject(theObj), Encoding.UTF8, "application/json"));
            }
    
            public static async System.Threading.Tasks.Task<T> GetFromJsonAsync<T>(this HttpClient client, string requestUrl)
            {
                return JsonConvert.DeserializeObject<T>(await client.GetStringAsync(requestUrl));
            }
        }
    }
    ```
    
4.  現在，我們已經全部都改成用 Newtonsoft.Json 來轉換 JSON 資料了，我們再 `dotnet run` 測試一次，你會發現這次變得不太一樣了！但我們這次得到的依然是序列化過程的例外狀況 `Newtonsoft.Json.JsonSerializationException`：
    
    ```log
    Unhandled exception. Newtonsoft.Json.JsonSerializationException: Error converting value "星期六、星期日" to type 'QuickType.HolidayCategory'. Path 'result.results[1].holidayCategory', line 1, position 241.
    ---> System.ArgumentException: Requested value '星期六、星期日' was not found.
      at Newtonsoft.Json.Utilities.EnumUtils.ParseEnum(Type enumType, NamingStrategy namingStrategy, String value, Boolean disallowNumber)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.EnsureType(JsonReader reader, Object value, CultureInfo culture, JsonContract contract, Type targetType)
      --- End of inner exception stack trace ---
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.EnsureType(JsonReader reader, Object value, CultureInfo culture, JsonContract contract, Type targetType)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateValueInternal(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.SetPropertyValue(JsonProperty property, JsonConverter propertyConverter, JsonContainerContract containerContract, JsonProperty containerProperty, JsonReader reader, Object target)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.PopulateObject(Object newObject, JsonReader reader, JsonObjectContract contract, JsonProperty member, String id)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateObject(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateValueInternal(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.PopulateList(IList list, JsonReader reader, JsonArrayContract contract, JsonProperty containerProperty, String id)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateList(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, Object existingValue, String id)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateValueInternal(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.SetPropertyValue(JsonProperty property, JsonConverter propertyConverter, JsonContainerContract containerContract, JsonProperty containerProperty, JsonReader reader, Object target)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.PopulateObject(Object newObject, JsonReader reader, JsonObjectContract contract, JsonProperty member, String id)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateObject(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateValueInternal(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.SetPropertyValue(JsonProperty property, JsonConverter propertyConverter, JsonContainerContract containerContract, JsonProperty containerProperty, JsonReader reader, Object target)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.PopulateObject(Object newObject, JsonReader reader, JsonObjectContract contract, JsonProperty member, String id)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateObject(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateValueInternal(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
      at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.Deserialize(JsonReader reader, Type objectType, Boolean checkAdditionalContent)
      at Newtonsoft.Json.JsonSerializer.DeserializeInternal(JsonReader reader, Type objectType)
      at Newtonsoft.Json.JsonSerializer.Deserialize(JsonReader reader, Type objectType)
      at Newtonsoft.Json.JsonConvert.DeserializeObject(String value, Type type, JsonSerializerSettings settings)
      at Newtonsoft.Json.JsonConvert.DeserializeObject[T](String value, JsonSerializerSettings settings)
      at Newtonsoft.Json.JsonConvert.DeserializeObject[T](String value)
      at c1.HttpClientJsonExtensions.GetFromJsonAsync[T](HttpClient client, String requestUrl) in G:\Projects\c1\HttpClientJsonExtensions.cs:line 16
      at c1.Program.Main(String[] args) in G:\Projects\c1\Program.cs:line 13
      at c1.Program.<Main>(String[] args)
    ```
    

### 使用 Newtonsoft.Json 的方式解決問題

我們先來看一下這個錯誤訊息：

```log
Error converting value "星期六、星期日" to type 'QuickType.HolidayCategory'.
Path 'result.results[1].holidayCategory', line 1, position 241.
```

首先，這很明顯是 `QuickType.HolidayCategory` 這個型別無法進行轉換，其原始碼如下：

```csharp
public enum HolidayCategory
{
    放假之紀念日及節日,
    星期六星期日,
    特定節日,
    補假,
    補行上班日,
    調整放假日
};
```

其次，則是我們的第 2 筆資料有問題 (`result.results[1]`)，我特別把 JSON 撈出來看一下：

```json
{
  "description": "",
  "holidayCategory": "星期六、星期日",
  "isHoliday": "是",
  "date": "2021/1/2",
  "_id": 960,
  "name": ""
}
```

看來是因為 Newtonsoft.Json 預設的轉換器無法將 `星期六、星期日` 這個值，對應到 `enum HolidayCategory` 型別的 `星期六星期日` 項目。兩者之間確實不太一樣，這當中差了一個頓號（`、`）！

還好 Json.NET 有內建一個 [StringEnumConverter](https://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_Converters_StringEnumConverter.htm) 字串到 Enum 的轉換器，你只要在列舉的項目加上一個 `EnumMember` Attribute 就可以幫助我們輕鬆的解決問題，請把 `enum HolidayCategory` 修改成以下定義即可：

```csharp
using System.Runtime.Serialization;

public enum HolidayCategory {
    放假之紀念日及節日,
    [EnumMember(Value = "星期六、星期日")]
    星期六星期日,
    特定節日,
    補假,
    補行上班日,
    調整放假日
};
```

最終版的 `Program.cs` 內容如下：

```csharp
using System.Net.Http;
using QuickType;
using System.Threading.Tasks;
using System.Net.Http.Json;

namespace c1
{
    class Program
    {
        static async Task Main(string[] args)
        {
            using var client = new HttpClient();

            var result = await client.GetFromJsonAsync<Holiday>("https://data.taipei/api/v1/dataset/29d9771d-c0ee-40d4-8dfb-3866b0b7adaa?scope=resourceAquire&offset=958&limit=1000");

            foreach (var item in result.Result.Results)
            {
                System.Console.WriteLine(item.Date + " 假日: " + item.IsHoliday );
            }
        }
    }
}
```

> 完整範例: [https://github.com/doggy8088/NewtonsoftJsonStringEnumConverterDemo/commits/main](https://github.com/doggy8088/NewtonsoftJsonStringEnumConverterDemo/commits/main)

### 有沒有 **System.Text.Json** 的解法？

網路上我找不到，自己寫肯定是寫的出來的，花時間下去，沒有解決不了的問題。但我還是熱愛 Json.NET 的成熟穩重，所以短期內並沒有打算撰寫 System.Text.Json 的 `StringEnumConverter` 實作。

如果各位有興趣進一步研究的話，可以到[這個網址](https://github.com/dotnet/runtime/tree/81bf79fd9aa75305e55abe2f7e9ef3f60624a3a1/src/libraries/System.Text.Json/tests/Serialization)找到大量的範例，看能不能如法炮製，做出好用的 `StringEnumConverter` 實作，如果有寫出來，請記得留言告訴我！ 😄

### 相關連結

-   [Convert JSON to Swift, C#, TypeScript, Objective-C, Go, Java, C++ and more • quicktype](https://quicktype.io/)
-   [Serializing and Deserializing Enumerations with Json.NET](https://www.bytefish.de/blog/enums_json_net.html)
-   [Json.NET 技巧兩則: 忽略屬性及列舉轉字串](https://blog.darkthread.net/blog/jsonnet-tips/)
-   [黑暗執行緒 分類檢視：json](https://blog.darkthread.net/blog/category/json)
-   原始碼: [System.Net.Http](https://github.com/dotnet/corefx/tree/master/src/System.Net.Http/src/System/Net/Http)
-   原始碼: [System.Net.Http.Json](https://github.com/dotnet/runtime/tree/main/src/libraries/System.Net.Http.Json/src/System/Net/Http/Json)
-   [How to write custom converters for JSON serialization (marshalling) in .NET](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-5-0&WT.mc_id=DT-MVP-4015686)
-   Visual Studio Code
    -   [Paste JSON as Code (Refresh) - Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=doggy8088.quicktype-refresh)
-   NuGet Packages
    -   [NuGet Gallery | System.Net.Http 4.3.4](https://www.nuget.org/packages/System.Net.Http)
    -   [NuGet Gallery | System.Net.Http.Json 5.0.0](https://www.nuget.org/packages/System.Net.Http.Json)
    -   [NuGet Gallery | Newtonsoft.Json 13.0.1](https://www.nuget.org/packages/Newtonsoft.Json)
