查找 JSON 中的拼写错误和已弃用属性的使用





5.00/5 (3投票s)
在本文中,我将介绍如何查找必须反序列化为 .NET 对象的 JSON 文档中的拼写错误。此外,我还会展示如何处理过时的属性。
引言
JSON 格式现在非常普遍。许多 Web API 以此格式返回结果。同样,许多 API 也接受以此格式传入的请求。传入的 JSON 请求的结构可能非常复杂。在此类文档中出现拼写错误并不罕见。在本文中,我想讨论如何检测这些拼写错误并以友好的方式通知用户。
让我们从一个简单的例子开始。我有一个类
public class Range
{
public int? From { get; set; }
public int? To { get; set; }
}
我想将 JSON 字符串
形式的用户请求反序列化到此对象中
var settings = new JsonSerializerSettings
{
Converters =
{
new StringEnumConverter {CamelCaseText = false}
},
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
var result = JsonConvert.DeserializeObject<Range>(jsonString, settings);
Console.WriteLine("Range is from: " + result.From);
Console.WriteLine("Range is to: " + result.To);
您认为,如果 jsonString
是这样,这段代码的执行结果会是什么?
{
form: 3,
to: 5
}
结果如下
Range is from:
Range is to: 5
这个奇怪结果的原因是,我们把 FROM
写成了 FORM
。
在这个简单的例子中,很容易弄清楚结果为何与预期不符。但是,如果有一个嵌套很深的、非常长的 JSON,情况就不那么容易了。我建议通过在出现拼写错误时提供有用的警告消息来帮助用户查找这些问题。
查找拼写错误
我们如何判断某些内容是否是拼写错误?总的来说,如果在反序列化过程中,我们遇到 JSON 中的某个属性,而该属性在对象模型中没有对应的属性,我们就可以称之为拼写错误。
默认情况下,Json.Net 会忽略这些问题。但是,我们可以通过修改序列化器设置的 MissingMemberHandling
属性来更改此行为。如果我们将此属性的值设置为 MissingMemberHandling.Error
,当 JSON 属性没有对应成员时,序列化器将抛出异常。我们可以使用序列化器设置的 Error
事件来处理此异常。
var settings = new JsonSerializerSettings
{
Converters =
{
new StringEnumConverter {CamelCaseText = false}
},
ContractResolver = new CamelCasePropertyNamesContractResolver(),
MissingMemberHandling = MissingMemberHandling.Error,
Error = (sender, args) =>
{
...
}
};
我们在这里需要做的就是区分因缺少成员而引发的错误与其他所有类型的错误。不幸的是,Json.NET 在这方面并没有提供太多帮助。我们唯一能做的就是检查异常中的消息。
var discriminator = new Regex("^Could not find member '[^']*' on object of type '[^']*'");
var messages = new List<string>();
var settings = new JsonSerializerSettings
{
Converters =
{
new StringEnumConverter {CamelCaseText = false}
},
ContractResolver = new CamelCasePropertyNamesContractResolver(),
MissingMemberHandling = MissingMemberHandling.Error,
Error = (sender, args) =>
{
if (discriminator.IsMatch(args.ErrorContext.Error.Message))
{
args.ErrorContext.Handled = true;
messages.Add($"Property {args.ErrorContext.Member}
({args.ErrorContext.Path}) is not defined on objects of
'{args.CurrentObject.GetType().Name}' class.");
}
}
};
var result = JsonConvert.DeserializeObject<Range>(jsonString, settings);
foreach (var message in messages)
{
Console.WriteLine(message);
}
Console.WriteLine("-----------------------------------");
Console.WriteLine("Range is from: " + result.From);
Console.WriteLine("Range is to: " + result.To);
请注意,我们将 args.ErrorContext.Handled
设置为 true
。这允许序列化器继续工作。
我想强调的是,这是一个非常脆弱的区分错误类型的方法。如果 Json.NET 团队决定更改错误消息或实现国际化支持,此代码将失效。
尽管如此,我们现在有了错误消息。
Property form (form) is not defined on objects of 'Range' class.
更妙的是,我们还可以从 args.ErrorContext.Path
属性中获得有关拼写错误确切位置的信息。尝试反序列化以下范围数组(现在您应该使用 JsonConvert.DeserializeObject<Range[]>
)。
[
{
from: 1,
to: 3
},
{
form: 3,
to: 5
},
{
from: 5,
to: 10
}
]
您将收到以下警告消息。
Property form ([1].form) is not defined on objects of 'Range' class.
正如您所见,我们获得了拼写错误的精确路径:根数组中的第二个元素。
这看起来很棒!我们完成了吗?还没有。还有一些事情要做。
鉴别器字段
让我们看一个稍微复杂一些的例子。我想反序列化属于类层次结构的各个对象。
public abstract class Value
{
public Value[] Values { get; set; }
}
public class IntValue : Value
{
public int Value { get; set; }
}
public class StringValue : Value
{
public string Value { get; set; }
}
要做到这一点,我必须实现一个自定义转换器。
public enum ValueType
{
Integer,
String
}
public class ValueJsonConverter : JsonConverter
{
public override bool CanWrite => false;
public override bool CanConvert(Type objectType)
{
return typeof(Value).IsAssignableFrom(objectType);
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
throw new NotSupportedException("Custom converter should only be used while deserializing.");
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
return null;
// Load JObject from stream
JObject jObject = JObject.Load(reader);
if (jObject == null)
return null;
ValueType valueType;
if (Enum.TryParse(jObject.Value<string>("type"), true, out valueType))
{
switch (valueType)
{
case ValueType.String:
var stringValueModel = new StringValue();
serializer.Populate(jObject.CreateReader(), stringValueModel);
return stringValueModel;
case ValueType.Integer:
var intValueModel = new IntValue();
serializer.Populate(jObject.CreateReader(), intValueModel);
return intValueModel;
default:
throw new ArgumentException($"Unknown value type '{valueType}'");
}
}
throw new ArgumentException("Unable to parse value object");
}
}
现在,我可以使用它来反序列化 Value
类的对象。
var jsonString = @"
[
{
type: 'integer',
value: 3
},
{
type: 'string',
value: 'aaa'
}
]
";
var discriminator = new Regex("^Could not find member '[^']*' on object of type '[^']*'");
var messages = new List<string>();
var settings = new JsonSerializerSettings
{
Converters =
{
new StringEnumConverter {CamelCaseText = false},
new ValueJsonConverter()
},
ContractResolver = new CamelCasePropertyNamesContractResolver(),
MissingMemberHandling = MissingMemberHandling.Error,
Error = (sender, args) =>
{
if (discriminator.IsMatch(args.ErrorContext.Error.Message))
{
args.ErrorContext.Handled = true;
messages.Add($"Property {args.ErrorContext.Member}
({args.ErrorContext.Path}) is not defined on objects of
'{args.CurrentObject.GetType().Name}' class.");
}
}
};
var result = JsonConvert.DeserializeObject<Value[]>(jsonString, settings);
foreach (var message in messages)
{
Console.WriteLine(message);
}
您认为此方法的执行结果是什么?结果如下。
Property type (type) is not defined on objects of 'IntValue' class.
Property type (type) is not defined on objects of 'StringValue' class.
确实,'type
' 属性不是 Value
类或其派生类的成员。我们只将其用于区分不同的类。
因此,必须有一种方法可以从我们的警告消息中排除某些属性。我们将这样来实现。
首先,我将处理拼写错误的逻辑提取到一个单独的类中。
public class TyposHandler
{
private static readonly Regex Discriminator =
new Regex("^Could not find member '[^']*' on object of type '[^']*'");
private readonly List<string> _messages = new List<string>();
private readonly List<Predicate<ErrorEventArgs>> _ignored = new List<Predicate<ErrorEventArgs>>();
public IReadOnlyList<string> Messages => _messages;
public void Handle(object sender, ErrorEventArgs args)
{
if (!Discriminator.IsMatch(args.ErrorContext.Error?.Message ?? ""))
return;
args.ErrorContext.Handled = true;
if (!_ignored.Any(p => p(args)))
{
_messages.Add($"Property {args.ErrorContext.Member}
({args.ErrorContext.Path}) is not defined on objects of
'{args.CurrentObject.GetType().Name}' class.");
}
}
public void Ignore(Predicate<ErrorEventArgs> selector)
{
if (selector == null) throw new ArgumentNullException(nameof(selector));
_ignored.Add(selector);
}
}
它有一个 Ignore
方法,允许为要忽略的缺失值定义一个谓词。以下是如何使用它。
var jsonString = @"
[
{
type: 'integer',
value: 3
},
{
type: 'string',
value: 'aaa'
}
]
";
var handler = new TyposHandler();
handler.Ignore(e => e.CurrentObject is Value && e.ErrorContext.Member.ToString() == "type");
var settings = new JsonSerializerSettings
{
Converters =
{
new StringEnumConverter {CamelCaseText = false},
new ValueJsonConverter()
},
ContractResolver = new CamelCasePropertyNamesContractResolver(),
MissingMemberHandling = MissingMemberHandling.Error,
Error = handler.Handle
};
var result = JsonConvert.DeserializeObject<Value[]>(jsonString, settings);
foreach (var message in handler.Messages)
{
Console.WriteLine(message);
}
现在,我们不会收到有关 'type
' 属性的警告消息。
路径不正确
我将在要反序列化的 JSON 中添加一个缺失的属性。
[
{
type: 'integer',
value: 3,
unknown: 'aaa'
},
{
type: 'string',
value: 'aaa'
}
]
现在,我将收到以下警告消息。
Property unknown (unknown) is not defined on objects of 'IntValue' class.
您看到问题了吗?路径 (unknown)
不正确。它应该是 ([0].unknown)
。问题的原因是什么?
原因在我们 ValueJsonConverter
类中。在那里,我们创建了一个新的独立 JObject
。
JObject jObject = JObject.Load(reader);
然后从该对象的属性中填充模型。
serializer.Populate(jObject.CreateReader(), model);
如果您查看 JToken
对象的 Path
属性的实现,您会发现它依赖于父令牌的路径。但是我们使用 JObject.Load
创建的对象没有父对象。它是独立的。这意味着我们在这里丢失了上下文。
为了解决这个问题,我们将引入一个路径堆栈。
var jsonString = @"
[
{
type: 'integer',
value: 3,
unknown: 'aaa'
},
{
type: 'string',
value: 'aaa'
}
]
";
var paths = new Stack<string>();
var handler = new TyposHandlerWithPath(paths);
handler.Ignore(e => e.CurrentObject is Value && e.ErrorContext.Member.ToString() == "type");
var settings = new JsonSerializerSettings
{
Converters =
{
new StringEnumConverter {CamelCaseText = false},
new ValueJsonConverterWithPath(paths)
},
ContractResolver = new CamelCasePropertyNamesContractResolver(),
MissingMemberHandling = MissingMemberHandling.Error,
Error = handler.Handle
};
var result = JsonConvert.DeserializeObject<Value[]>(jsonString, settings);
foreach (var message in handler.Messages)
{
Console.WriteLine(message);
}
我们将把此堆栈传递给我们的拼写错误处理程序以及我们使用的任何值转换器。以下是我们如何在值转换器的 ReadJson
方法中使用此堆栈。
if (reader.TokenType == JsonToken.Null)
return null;
var path = reader.Path;
// Load JObject from stream
JObject jObject = JObject.Load(reader);
if (jObject == null)
return null;
ValueType valueType;
if (Enum.TryParse(jObject.Value<string>("type"), true, out valueType))
{
switch (valueType)
{
case ValueType.String:
var stringValueModel = new StringValue();
_pathsStack.Push(path);
serializer.Populate(jObject.CreateReader(), stringValueModel);
_pathsStack.Pop();
return stringValueModel;
case ValueType.Integer:
var intValueModel = new IntValue();
_pathsStack.Push(path);
serializer.Populate(jObject.CreateReader(), intValueModel);
_pathsStack.Pop();
return intValueModel;
default:
throw new ArgumentException($"Unknown value type '{valueType}'");
}
}
throw new ArgumentException($"Unable to parse value object");
在调用 serializer.Populate
之前,我们将当前路径推入堆栈,并在调用之后将其弹出。现在,堆栈将包含从 JSON 根目录到所有路径的部分。
以下是我们在拼写错误处理程序中的使用方式。看看 GetPath
方法。
public class TyposHandlerWithPath
{
private readonly Stack<string> _paths;
private static readonly Regex Discriminator = new Regex
("^Could not find member '[^']*' on object of type '[^']*'");
private readonly List<string> _messages = new List<string>();
private readonly List<Predicate<ErrorEventArgs>> _ignored = new List<Predicate<ErrorEventArgs>>();
public IReadOnlyList<string> Messages => _messages;
public TyposHandlerWithPath(Stack<string> paths)
{
_paths = paths;
}
public void Handle(object sender, ErrorEventArgs args)
{
if (!Discriminator.IsMatch(args.ErrorContext.Error?.Message ?? ""))
return;
args.ErrorContext.Handled = true;
if (!_ignored.Any(p => p(args)))
{
_messages.Add($"Property {args.ErrorContext.Member}
({GetPath(args.ErrorContext.Path)}) is not defined on objects of
'{args.CurrentObject.GetType().Name}' class.");
}
}
private string GetPath(string path)
{
var pathBuilder = new StringBuilder();
foreach (var pathPart in _paths.Reverse())
{
AddPathPart(pathBuilder, pathPart);
}
if (!string.IsNullOrWhiteSpace(path))
{
AddPathPart(pathBuilder, path);
}
return pathBuilder.ToString();
}
private void AddPathPart(StringBuilder pathBuilder, string pathPart)
{
if (pathBuilder.Length == 0)
pathBuilder.Append(pathPart);
else if (pathPart.StartsWith("["))
pathBuilder.Append(@"\" + pathPart);
else
pathBuilder.Append(@"." + pathPart);
}
public void Ignore(Predicate<ErrorEventArgs> selector)
{
if (selector == null) throw new ArgumentNullException(nameof(selector));
_ignored.Add(selector);
}
}
在这里,我们将当前路径与堆栈中所有先前的路径组合起来。这使我们能够重建到任何 JSON 属性的正确路径。在我们的例子中,我们将收到以下警告消息。
Property unknown ([0].unknown) is not defined on objects of 'IntValue' class.
现在是时候考虑我们遇到的最后一个问题了。
过时属性
我能说什么呢?事物在变化。即使是 API。一些交互方式变得过时。在 .NET 中,有一个 ObsoleteAttribute
,您可以用来标记不再使用的成员。如何在 JSON 中做到这一点?
这里的问题是,使用过时的属性不是拼写错误。我们想要反序列化的 .NET 类型中已经存在一个属性。如何告知序列化器不允许使用此属性?我们将抛出异常。
JsonSerializerSettings
类的 Error
属性允许我们为所有异常(至少是 JsonSerializationException
异常)设置处理程序。如果序列化器尝试为过时的属性设置值,我们将抛出我们派生自 JsonSerializationException
的异常。然后,我们将在 Error
处理程序中捕获此异常并进行处理。
但是如何在设置属性时抛出异常?我们将在此处使用 ContractResolver
。现在我们将其设置为标准的。
ContractResolver = new CamelCasePropertyNamesContractResolver()
但是,让我们创建我们自己的合同解析器实现。
public class ObsoletePropertiesContractResolver : CamelCasePropertyNamesContractResolver
{
protected override IValueProvider CreateMemberValueProvider(MemberInfo member)
{
var provider = base.CreateMemberValueProvider(member);
if (member.GetCustomAttributes(typeof(ObsoleteAttribute)).Any())
return new ObsoletePropertyValueProvider(provider, member);
return provider;
}
}
public class ObsoletePropertyValueProvider : IValueProvider
{
private readonly IValueProvider _valueProvider;
private readonly MemberInfo _memberInfo;
public ObsoletePropertyValueProvider(
IValueProvider valueProvider,
MemberInfo memberInfo)
{
_valueProvider = valueProvider;
_memberInfo = memberInfo;
}
public void SetValue(object target, object value)
{
_valueProvider.SetValue(target, value);
throw new ObsoletePropertyException(_memberInfo.DeclaringType, _memberInfo.Name);
}
public object GetValue(object target)
{
return _valueProvider.GetValue(target);
}
}
[Serializable]
public class ObsoletePropertyException : JsonSerializationException
{
public Type MemberType { get; }
public string PropertyName { get; }
public ObsoletePropertyException(Type memberType, string propertyName)
{
MemberType = memberType;
PropertyName = propertyName;
}
}
如您所见,我们为所有用 Obsolete
属性标记的属性返回我们自己的值提供程序。此提供程序在为属性设置值后会抛出我们的异常。现在,我们可以捕获它。
public class TyposAndObsoleteHandlerWithPath
{
private static readonly Regex Discriminator =
new Regex("^Could not find member '[^']*' on object of type '[^']*'");
private readonly Stack<string> _paths;
private readonly List<string> _messages = new List<string>();
private readonly List<Predicate<ErrorEventArgs>> _ignored = new List<Predicate<ErrorEventArgs>>();
private readonly List<Func<Type, string, string>> _obsoleteMessages =
new List<Func<Type, string, string>>();
public TyposAndObsoleteHandlerWithPath(Stack<string> paths)
{
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
}
public IReadOnlyList<string> Messages => _messages;
public void Handle(object sender, ErrorEventArgs args)
{
if (args.ErrorContext.Error is ObsoletePropertyException)
{
HandleObsoleteProperty(args, (ObsoletePropertyException) args.ErrorContext.Error);
args.ErrorContext.Handled = true;
return;
}
if(!Discriminator.IsMatch(args.ErrorContext.Error?.Message ?? ""))
return;
args.ErrorContext.Handled = true;
if (!_ignored.Any(p => p(args)))
{
_messages.Add($"Property {args.ErrorContext.Member}
({GetPath(args.ErrorContext.Path)}) is not defined on objects of
'{args.CurrentObject.GetType().Name}' class.");
}
}
private void HandleObsoleteProperty
(ErrorEventArgs args, ObsoletePropertyException errorContextError)
{
var message = _obsoleteMessages
.Select(p => p(errorContextError.MemberType, errorContextError.PropertyName))
.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m));
if(!string.IsNullOrWhiteSpace(message))
_messages.Add($"Property {args.ErrorContext.Member}
({GetPath(args.ErrorContext.Path)}) is obsolete on objects of
'{args.CurrentObject.GetType().Name}' class. {message}");
else
_messages.Add($"Property {args.ErrorContext.Member}
({GetPath(args.ErrorContext.Path)}) is obsolete on objects of
'{args.CurrentObject.GetType().Name}' class.");
}
private string GetPath(string path)
{
var pathBuilder = new StringBuilder();
foreach (var pathPart in _paths.Reverse())
{
AddPathPart(pathBuilder, pathPart);
}
if (!string.IsNullOrWhiteSpace(path))
{
AddPathPart(pathBuilder, path);
}
return pathBuilder.ToString();
}
private void AddPathPart(StringBuilder pathBuilder, string pathPart)
{
if (pathBuilder.Length == 0)
pathBuilder.Append(pathPart);
else if (pathPart.StartsWith("["))
pathBuilder.Append(@"\" + pathPart);
else
pathBuilder.Append(@"." + pathPart);
}
public void Ignore(Predicate<ErrorEventArgs> selector)
{
if (selector == null) throw new ArgumentNullException(nameof(selector));
_ignored.Add(selector);
}
public void AddObsoleteMessage(Func<Type, string, string> messageProvider)
{
if (messageProvider == null) throw new ArgumentNullException(nameof(messageProvider));
_obsoleteMessages.Add(messageProvider);
}
}
在这里,我们还为过时的属性添加了自定义消息。这些消息必须解释如何实现相同的结果而不使用特定的过时属性。实际上,我们可以从 Obsolete
属性中提取消息。但这通常与 .NET API 相关,而不是与 JSON API 相关。这就是为什么我认为这些消息应该不同。
现在让我们测试我们的代码。我将在 StringValue
类中添加一个过时的属性。
public class StringValue : Value
{
public string Value { get; set; }
[Obsolete]
public string Id { get; set; }
}
现在,我们将反序列化设置了过时属性的 JSON。
var jsonString = @"
[
{
type: 'integer',
value: 3,
},
{
type: 'string',
value: 'aaa',
id: 'bbb'
}
]
";
Stack<string> pathsStack = new Stack<string>();
var handler = new TyposAndObsoleteHandlerWithPath(pathsStack);
handler.Ignore(e => e.CurrentObject is Value && e.ErrorContext.Member.ToString() == "type");
handler.AddObsoleteMessage((type, name) =>
{
if (type == typeof(StringValue) && name == "Id")
return "Use another property here";
return null;
});
var settings = new JsonSerializerSettings
{
Converters =
{
new StringEnumConverter {CamelCaseText = false},
new ValueJsonConverterWithPath(pathsStack)
},
ContractResolver = new ObsoletePropertiesContractResolver(),
MissingMemberHandling = MissingMemberHandling.Error,
Error = handler.Handle
};
var result = JsonConvert.DeserializeObject<Value[]>(jsonString, settings);
foreach (var message in handler.Messages)
{
Console.WriteLine(message);
}
结果,我们将收到以下警告消息。
Property id ([1].id) is obsolete on objects of 'StringValue' class. Use another property here
结论
就是这样。这里的代码尚未为生产做好准备,但我认为这是一个很好的起点。这样的警告消息可以使您的 Web API 对用户更友好。
另一个有趣的问题是如何使其与 ASP.NET Web API 一起工作。在那里,我们无法直接访问 JSON 序列化器,并且所有序列化器实例都使用相同的 JsonSerializerSettings
对象。以某种方式,我们必须区分不同的请求。但这将是另一篇文章的主题。
您可以在我的博客上阅读更多我的文章。