The Will Will Web

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

使用 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 實作,如果有寫出來,請記得留言告訴我! 😄

相關連結

留言評論