自定義Key類型的字典無法序列化的N種解決方案

當我們使用System.Text.Json.JsonSerializer對一個字典對象進行序列化的時候,默認情況下字典的Key不能是一個自定義的類型,本文介紹幾種解決方案。

一、問題重現
二、自定義JsonConverter能解決嗎?
三、自定義TypeConverter能解決問題嗎?
四、以鍵值對集合的形式序列化
五、轉換成合法的字典
六、自定義讀寫

一、問題重現

我們先通過如下這個簡單的例子來重現上述這個問題。如代碼片段所示,我們定義了一個名爲Point(代表二維座標點)的只讀結構體作爲待序列化字典的Key。Point可以通過結構化的表達式來表示,我們同時還定義了Parse方法將表達式轉換成Point對象。

using System.Diagnostics;
using System.Text.Json;

var dictionary = new Dictionary<Point, int>
{
    { new Point(1.0, 1.0), 1 },
    { new Point(2.0, 2.0), 2 },
    { new Point(3.0, 3.0), 3 }
};

try
{
    var json = JsonSerializer.Serialize(dictionary);
    Console.WriteLine(json);

    var dictionary2 = JsonSerializer.Deserialize<Dictionary<Point, int>>(json)!;
    Debug.Assert(dictionary2[new Point(1.0, 1.0)] == 1);
    Debug.Assert(dictionary2[new Point(2.0, 2.0)] == 2);
    Debug.Assert(dictionary2[new Point(3.0, 3.0)] == 3);
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}


public readonly record struct Point(double X, double Y)
{
    public override string ToString()=> $"({X}, {Y})";
    public static Point Parse(string s)
    {
        var tokens = s.Trim('(',')').Split(',', StringSplitOptions.TrimEntries);
        if (tokens.Length != 2)
        {
            throw new FormatException("Invalid format");
        }
        return new Point(double.Parse(tokens[0]), double.Parse(tokens[1]));
    }
}

當我們使用JsonSerializer序列化多一個Dictionary<Point, int>類型的對象時,會拋出一個NotSupportedException異常,如下所示的信息解釋了錯誤的根源:Point類型不能作爲被序列化字典對象的Key。順便說一下,如果使用Newtonsoft.Json,這樣的字典可以序列化成功,但是反序列化會失敗。

image

二、自定義JsonConverter<Point>能解決嗎?

遇到這樣的問題我們首先想到的是:既然不執行鍼對Point的序列化/反序列化,那麼我們可以對應相應的JsonConverter自行完成序列化/反序列化工作。爲此我們定義瞭如下這個PointConverter,將Point的表達式作爲序列化輸出結果,同時調用Parse方法生成反序列化的結果。

public class PointConverter : JsonConverter<Point>
{
    public override Point Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)=> Point.Parse(reader.GetString()!);
    public override void Write(Utf8JsonWriter writer, Point value, JsonSerializerOptions options) => writer.WriteStringValue(value.ToString());
}

我們將這個PointConverter對象添加到創建的JsonSerializerOptions配置選項中,並將後者傳入序列化和反序列化方法中。

var options = new JsonSerializerOptions
{
    WriteIndented = true,
    Converters = { new PointConverter() }
};
var json = JsonSerializer.Serialize(dictionary, options);
Console.WriteLine(json);

var dictionary2 = JsonSerializer.Deserialize<Dictionary<Point, int>>(json, options)!;
Debug.Assert(dictionary2[new Point(1.0, 1.0)] == 1);
Debug.Assert(dictionary2[new Point(2.0, 2.0)] == 2);
Debug.Assert(dictionary2[new Point(3.0, 3.0)] == 3);

不幸的是,這樣的解決方案無效,序列化時依然會拋出相同的異常。

image

三、自定義TypeConverter能解決問題嗎?

JsonConverter的目的本質上就是希望將Point對象視爲字符串進行處理,既然自定義JsonConverter無法解決這個問題,我們是否可以註冊相應的類型轉換其來解決它呢?爲此我們定義瞭如下這個PointTypeConverter 類型,使它來完成針對Point和字符串之間的類型轉換。

public class PointTypeConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => sourceType == typeof(string);
    public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => destinationType == typeof(string);
    public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => Point.Parse((string)value);
    public override object ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => value?.ToString()!;
}

我們利用標註的TypeConverterAttribute特性將PointTypeConverter註冊到Point類型上。

[TypeConverter(typeof(PointTypeConverter))]
public readonly record struct Point(double X, double Y)
{
    public override string ToString() => $"({X}, {Y})";
    public static Point Parse(string s)
    {
        var tokens = s.Trim('(',')').Split(',', StringSplitOptions.TrimEntries);
        if (tokens.Length != 2)
        {
            throw new FormatException("Invalid format");
        }
        return new Point(double.Parse(tokens[0]), double.Parse(tokens[1]));
    }
}

實驗證明,這種解決方案依然無效,序列化時還是會拋出相同的異常。順便說一下,這種解決方案對於Newtonsoft.Json是適用的。

image

四、以鍵值對集合的形式序列化

爲Point定義JsonConverter之所以不能解決我們的問題,是因爲異常並不是在試圖序列化Point對象時拋出來的,而是在在默認的規則序列化字典對象時,不合法的Key類型沒有通過驗證。如果希望通過自定義JsonConverter的方式來解決,目標類型不應該時Point類型,而應該時字典類型,爲此我們定義瞭如下這個PointKeyedDictionaryConverter<TValue>類型。

我們知道字典本質上就是鍵值對的集合,而集合針對元素類型並沒有特殊的約束,所以我們完全可以按照鍵值對集合的方式來進行序列化和反序列化。如代碼把片段所示,用於序列化的Write方法中,我們利用作爲參數的JsonSerializerOptions 得到針對IEnumerable<KeyValuePair<Point, TValue>>類型的JsonConverter,並利用它以鍵值對的形式對字典進行序列化。

public class PointKeyedDictionaryConverter<TValue> : JsonConverter<Dictionary<Point, TValue>>
{
    public override Dictionary<Point, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var enumerableConverter = (JsonConverter<IEnumerable<KeyValuePair<Point, TValue>>>)options.GetConverter(typeof(IEnumerable<KeyValuePair<Point, TValue>>));
        return enumerableConverter.Read(ref reader, typeof(IEnumerable<KeyValuePair<Point, TValue>>), options)?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
    }
    public override void Write(Utf8JsonWriter writer, Dictionary<Point, TValue> value, JsonSerializerOptions options)
    {
        var enumerableConverter = (JsonConverter<IEnumerable<KeyValuePair<Point, TValue>>>)options.GetConverter(typeof(IEnumerable<KeyValuePair<Point, TValue>>));
        enumerableConverter.Write(writer, value, options);
    }
}

用於反序列化的Read方法中,我們採用相同的方式得到這個針對IEnumerable<KeyValuePair<Point, TValue>>類型的JsonConverter,並將其反序列化成鍵值對集合,在轉換成返回的字典。

var options = new JsonSerializerOptions
{
    WriteIndented = true,
    Converters = { new PointConverter(), new PointKeyedDictionaryConverter<int>()}
};

我們將PointKeyedDictionaryConverter<int>添加到創建的JsonSerializerOptions配置選項的JsonConverter列表中。從如下所示的輸出結果可以看出,我們創建的字典確實是以鍵值對集合的形式進行序列化的。

image

五、轉換成合法的字典

既然作爲字典Key的Point可以轉換成字符串,那麼可以還有另一種解法,那就是將以Point爲Key的字典轉換成以字符串爲Key的字典,爲此我們按照如下的方式重寫的PointKeyedDictionaryConverter<TValue>。如代碼片段所示,重寫的Writer方法利用傳入的JsonSerializerOptions配置選項得到針對Dictionary<string, TValue>的JsonConverter,然後將待序列化的Dictionary<Point, TValue> 對象轉換成Dictionary<string, TValue> 交給它進行序列化。

public class PointKeyedDictionaryConverter<TValue> : JsonConverter<Dictionary<Point, TValue>>
{
    public override Dictionary<Point, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var converter = (JsonConverter<Dictionary<string, TValue>>)options.GetConverter(typeof(Dictionary<string, TValue>))!;
        return converter.Read(ref reader, typeof(Dictionary<string, TValue>), options)
            ?.ToDictionary(kv => Point.Parse(kv.Key), kv=> kv.Value);
    }
    public override void Write(Utf8JsonWriter writer, Dictionary<Point, TValue> value, JsonSerializerOptions options)
    {
        var converter = (JsonConverter<Dictionary<string, TValue>>)options.GetConverter(typeof(Dictionary<string, TValue>))!;
        converter.Write(writer, value.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value), options);
    }
}

重寫的Read方法採用相同的方式得到JsonConverter<Dictionary<string, TValue>>對象,並利用它執行反序列化生成Dictionary<string, TValue> 對象。我們最終將它轉換成需要的Dictionary<Point, TValue> 對象。從如下所示的輸出可以看出,這次的序列化生成的JSON會更加精煉,因爲這次是以字典類型輸出JSON字符串的。

image

六、自定義讀寫

雖然以上兩種方式都能解決我們的問題,而且從最終JSON字符串輸出的長度來看,第二種具有更好的性能,但是它們都有一個問題,那麼就是需要創建中間對象。第一種方案需要創建一個鍵值對集合,第二種方案則需要創建一個Dictionary<string, TValue> 對象,如果對性能有更高的追求,它們都不是一種好的解決方案。既讓我們都已經在自定義JsonConverter,完全可以自行可控制JSON內容的讀寫,爲此我們再次重寫了PointKeyedDictionaryConverter<TValue>。

public class PointKeyedDictionaryConverter<TValue> : JsonConverter<Dictionary<Point, TValue>>
{
    public override Dictionary<Point, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        JsonConverter<TValue>? valueConverter = null;
        Dictionary<Point, TValue>? dictionary = null;
        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                return dictionary;
            }
            valueConverter ??= (JsonConverter<TValue>)options.GetConverter(typeof(TValue))!;
            dictionary ??= [];
            var key = Point.Parse(reader.GetString()!);
            reader.Read();
            var value = valueConverter.Read(ref reader, typeof(TValue), options)!;
            dictionary.Add(key, value);
        }
        return dictionary;
    }
    public override void Write(Utf8JsonWriter writer, Dictionary<Point, TValue> value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        JsonConverter<TValue>? valueConverter = null;
        foreach (var (k, v) in value)
        {
            valueConverter ??= (JsonConverter<TValue>)options.GetConverter(typeof(TValue))!;
            writer.WritePropertyName(k.ToString());
            valueConverter.Write(writer, v, options);
        }
        writer.WriteEndObject();
    }
}

如上面的代碼片段所示,在重寫的Write方法中,我們調用Utf8JsonWriter 的WriteStartObject和 WriteEndObject方法以對象的形式輸出字典。在這中間,我們便利字典的每個鍵值對,並以“屬性”的形式對它們進行輸出(Key和Value分別是屬性名和值)。在Read方法中,我們創建一個空的Dictionary<Point, TValue> 對象,在一個循環中利用Utf8JsonReader先後讀取作爲Key的字符串和Value值,最終將Key轉換成Point類型,並添加到創建的字典中。從如下所示的輸出結果可以看出,這次生成的JSON具有與上面相同的結構。

image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章