写点什么

自定义 Key 类型的字典无法序列化的 N 种解决方案

作者:EquatorCoco
  • 2024-03-19
    福建
  • 本文字数:5199 字

    阅读完需:约 17 分钟

当我们使用 System.Text.Json.JsonSerializer 对一个字典对象进行序列化的时候,默认情况下字典的 Key 不能是一个自定义的类型,本文介绍几种解决方案。


一、问题重现


我们先通过如下这个简单的例子来重现上述这个问题。如代码片段所示,我们定义了一个名为 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.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。


image


二、自定义 JsonConverter 能解决吗?


遇到这样的问题我们首先想到的是:既然不执行针对 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]));    }}
复制代码


实验证明,这种解决方案依然无效,序列化时还是会抛出相同的异常。


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


文章转载自:Artech

原文链接:https://www.cnblogs.com/artech/p/18075402/dictionary_key_serialization

体验地址:http://www.jnpfsoft.com/?from=001

用户头像

EquatorCoco

关注

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
自定义Key类型的字典无法序列化的N种解决方案_Java_EquatorCoco_InfoQ写作社区