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

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

重現問題的步驟

以下皆使用 Visual Studio Code 完成專案開發,實作前請先安裝 .NET Core Extension Pack 擴充套件!

如果你的 VSCode 同時安裝了 Paste JSON as Code (Refresh) 與 Paste JSON as Code 擴充套件,請記得移除 Paste JSON as Code 這個已經不再維護的擴充套件。

  1. 建立 Console 應用程式

    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 套件(俗稱 Json.NET),所以我們先安裝起來。

  2. 建立 Model 類別

    先連到 臺北市政府行政機關辦公日曆表 的 API 連結,並複製完整的 JSON 內容到剪貼簿。

    回到 VSCode 建立 Holiday.cs 檔案,執行 Paste JSON as Type 命令,看到 Top-level type name? 請輸入 Holiday,接著 VSCode 就會全自動幫你產生模型類別!

    注意: 預設命名空間為 QuickType

  3. 嘗試使用 System.Net.Http.Json 提供的擴充方法取得強型別的結果

    dotnet add package System.Net.Http.Json
    

    完成 Program.cs 程式碼如下:

    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,也是本文想要分享的主要問題

    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 套件為主,而且與 System.Text.Json 不太相容,所以你透過 Paste JSON as Code (Refresh) 套件所產生的程式碼,如果遇到稍微複雜的 JSON 格式,很有可能就沒辦法讓 Serialize/Deserialize 正常運作!

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

這裡我用了一個 GetFromJsonAsync 方法,非常的好用,可惜並無法跟 Newtonsoft.Json 混合使用。

其實要為了 Newtonsoft.Json 實作另一份 HttpClientJsonExtensions 並不是特別困難,你可以到 System.Net.Http.Json 參考一下寫法,改成 Newtonsoft.Json 的版本即可。

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

  1. 先移除 System.Net.Http.Json 套件參考

    dotnet remove package System.Net.Http.Json
    
  2. 移除引用 System.Net.Http.Json 命名空間

    using System.Net.Http.Json;

  3. 加入 HttpClientJsonExtensions.cs 類別

    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:

    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 的方式解決問題

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

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

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

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

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

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

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

還好 Json.NET 有內建一個 StringEnumConverter 字串到 Enum 的轉換器,你只要在列舉的項目加上一個 EnumMember Attribute 就可以幫助我們輕鬆的解決問題,請把 enum HolidayCategory 修改成以下定義即可:

using System.Runtime.Serialization;

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

最終版的 Program.cs 內容如下:

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

有沒有 System.Text.Json 的解法?

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

如果各位有興趣進一步研究的話,可以到這個網址找到大量的範例,看能不能如法炮製,做出好用的 StringEnumConverter 實作,如果有寫出來,請記得留言告訴我! 😄

相關連結

留言評論