使用 Newtonsoft.Json 接受部分资源





5.00/5 (2投票s)
使用 Newtonsoft.Json 在 ASP.NET Core 中支持部分 PUT(或 PATCH)操作
- 引言
- 背景
- 问题描述
- 方法:在 Newtonsoft.Json 中解决问题
- 解决方案:正确包装是关键
- 奖励 0:有用的扩展方法
- 奖励 1:忽略和命名属性
- 奖励 2:使用 Swashbuckle 生成 Swagger
- Using the Code
- 关注点
- 历史
引言
随着云端日益复杂的后端服务的兴起,对高效(即对开发人员友好)API 的需求也呈爆炸式增长。幸运的是,我们有很多优秀的 API 范例。不幸的是,以类似的方式构建自己的 API 是项艰巨的任务。
事实证明,正确应用 REST 并非难事,而是一项相当浩大的工程。需要考虑许多因素,并且每个端点都需要进行一系列审计才能被认为在某种程度上是稳定或完整的。
如果我们真的想从资源的角度而不是从操作的角度来思考,我们就需要完全理解 HTTP 动词和状态码及其含义。两个非常有趣的动词是 PUT
和 PATCH
。理论上,它们的区别很简单:两者都对现有资源执行更新(因此需要一个合适的标识符来定位特定资源)。然而,虽然 PUT
可以被视为标准的“替换
”,但 PATCH
操作执行的是逐个属性的更新,只考虑提供的属性。
我们也可以将 PATCH
操作称为“PUT
”的部分操作。
在本文中,我们想探讨如何自己高效地创建一个部分 PUT
端点。事实证明,ASP.NET 已经提供了一个用于此类部分更新的对象,但该对象带有自己的资源定义,并未完全反映我们想要的 RESTful 特性。因此,在本文中,我们将从零开始。
背景
在我们自己的 API 中,我们没有任何 PATCH
端点。原因很简单:我们所有的 PUT
端点在定义上都被认为是部分更新。有人可能不喜欢这个选择,但考虑到 API 只有在被相应地使用时才有意义,所以我们选择了这个方案。我们所有的开发人员都将 PUT
与部分更新联系起来,而没有人听说过 PATCH
操作。因此,我们简化了设计。
为什么部分 PUT
如此有用?首先,它可以节省流量。如果我们有一个大型资源,我们只需要将包含更新值的相应字段发送回服务器。隐含地,我们不会覆盖我们不关心的值。最后,如果我们知道资源的 ID 和某些字段的新值,我们就不需要获取完整的资源来执行更新。
总而言之,处理部分更新而不是完全更新可以带来实实在在的好处。唯一的主要缺点是,您可能希望由客户端提供完整资源的一致性检查/保证,以避免来自服务器的意外。一个典型的场景是,API 执行一个检查,即属性 B 的值取决于属性 A 的值。假设有人对 B 和 A 进行了有效的更改,而我们也只对 B 进行了更改,这在当前 A 的情况下是有效的,但在即将更改的 A 下则无效。当我们收到错误时,我们会感到惊讶,因为我们的更改(因此我们的资源)看起来没问题,而我们却进行了检查。这种情况下的竞争条件肯定没有消除,但通过完全更新确实可以减少。但请注意,这些只是在某些非常特殊的验证规则下才会出现的特殊情况。
问题描述
那么我们想达到什么目标呢?假设我们从以下控制器开始
public class SampleController : controller
{
public IActionResult Put(Model model)
{
// ...
}
}
显然,这个控制器只有一个操作,会导致对资源进行完全/正常的 PUT
,即操作性更新。这个操作在生成的 Swagger 文档中也得到了妥善反映。此外,框架的验证会在无效值进入我们的操作之前生效。
现在我们想转向部分 PUT
。我们想在这里获得什么?
- 生成的 Swagger 应该反映操作的部分性(即每个属性都应该是可选的)
- 验证应该尊重操作的部分性(即,如果指定了某个属性,它必须符合模型)
- 我们需要知道哪个属性被设置了,哪个被省略了——仅仅到处都是
null
是不够的 - 已指定但不在模型中的属性应导致输入无效
- 我们仍然可以处理/操作部分模型
可选地,我们希望能够将我们的完整模型中的某些属性排除在部分更新之外。我们希望能够用原始(即完整)数据模型上的属性来表达这一点。
总而言之,这种方法应该感觉像这样
public class SampleController : controller
{
public IActionResult Get()
{
// returns a Model instance, e.g., via ObjectResult
}
public IActionResult Post(Model model)
{
// ...
}
public IActionResult Put(Partial<Model> model)
{
// ...
}
}
这样我们就可以反复重用同一个模型——真正反映基于资源的方法。对于 GET
,我们返回模型的一个实例;对于 POST
,我们期望传入一个完整的模型定义;而(部分)PUT
则使用该模型的特殊版本,允许只传入一部分。
方法:在 Newtonsoft.Json 中解决问题
编写一个能够指示使用了哪些键的简单 JSON 反序列化器有什么难的?毕竟,一个简单的 JObject
加上一些额外的代码就可以完成这项工作,对吧?那么,让我们尝试一些方法。
我们可以尝试在模型之上放置一个不同的转换器。大致如下
[JsonConverter(typeof(MyJsonConverter))]
public class Model
{
// ...
}
现在,这个转换器将(总是)用于将 JSON 字符串反序列化为 Model
实例。由于我们只希望为部分端点这样做,我们可以改为这样做
public class Model
{
// ...
}
[JsonConverter(typeof(MyJsonConverter))]
public class PartialModel : Model {}
当我们要实现实际的转换器时,这个方法的缺点就开始显现了。我们想要什么?首先,我们可能需要获取一些关于 JSON 中提供了哪些键的信息。最简单的解决方案是先将其转换为 JObject
(本质上是一个字典),然后以此为基础创建实际的模型实例。
public class MyJsonConverter : JsonConverter
{
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
}
public override object ReadJson(JsonReader reader, Type objectType,
object existingValue, JsonSerializer serializer)
{
var data = serializer.Deserialize(reader, typeof(JObject));
var raw = JsonConvert.SerializeObject(data);
// unfortunately, there is no way to do another conversion directly without much trouble
// ideally, we would just convert from JObject to [objectType]
return JsonConvert.DeserializeObject(raw, objectType);
}
public override bool CanWrite => false;
public override bool CanRead => true;
public override bool CanConvert(Type objectType) => true;
}
然而,结果是,我们将遇到 Newtonsoft.Json 的一个障碍。原始转换器无法删除,我们将始终尝试重用当前转换器,这实际上会导致堆栈溢出异常(由于递归条件未得到正确解决)。
显然,这个问题不能轻易解决。看来,没有办法直接对抗“基本反序列化器”。一个潜在的解决方案是执行一些非常棘手的技巧来修改 Newtonsoft 的内部机制。
通过修改“已知”转换器,我们可以简单地让 Newtonsoft“忘记”我们的自定义转换器。但问题是,这不是一个健壮的(即面向未来的)处理方式,而且我们肯定会遇到跨线程问题。存在竞争条件或非确定性的代码,其行为不当或崩溃,这取决于机器的状态,是我们不希望的。
serializer.Converters.Remove(this);
var result = serializer.Deserialize(reader);
serializer.Converters.Add(this);
return result;
即使这可能在某个版本的 Newtonsoft 中有效,一旦内部结构发生变化,这种方法就会很快过时。即使是补丁版本,也不能保证内部 API 保持稳定。此外,所示的方法不是线程安全的,因此不适合任何生产系统,尤其是 Web 应用程序。
现在我们已经尝试了显而易见且有创意的方法但失败了,是时候更结构化地处理这个问题了。
解决方案:正确包装是关键
我们已经看到,最终,我们需要自己实现整个反序列化器。当然,这实在太多了,也不是我们想要的。何不重用一些内部机制呢?确实,我们尝试了,但遇到了许多其他挑战。尽管如此,还是有一个折衷的方法。
解决问题的潜在方案是一个作用于包含另一个对象引用的对象的转换器。 “外部”对象将存储引用以及反序列化内部对象时看到的所有键。可以这样想:
public class Part<T>
{
public Part(T data, String[] keys)
{
Data = data;
Keys = keys;
}
public T Data { get; }
public String[] Keys { get; }
}
这种方法的优点是我们只为外部对象定义一个自定义转换器,该转换器可以使用内部类型的标准转换器。但是,为了获取键,我们需要另一种机制。
由于转换器还需要一个 JsonReader
实例,我们可以编写一个对属性(名称)令牌敏感的包装器。假设我们会得到所有名称令牌,因此我们需要集成一个顶层检查(我们不希望有任何嵌套的部分更新)。
以下代码片段显示了 Part
类,它是所有相关信息的容器。此类型具有 Data
属性,代表已反序列化的 .NET 对象,以及 Keys
属性,引用原始 JSON 中使用的/找到的键。如前所述,键仅指顶层键。
[JsonConverter(typeof(PartialJsonConverter))]
public class Part<T>
{
public Part(T data, IEnumerable<string> keys)
{
Data = data;
Keys = keys.ToArray();
}
public T Data { get; }
public string[] Keys { get; }
public bool IsSet<TProperty>(Expression<Func<T, TProperty>> property,
Action<TProperty> onAvailable = null)
{
var info = GetPropertyInfo(Data, property);
var name = info.Name;
var attr = info.GetCustomAttribute<JsonPropertyAttribute>();
var available = Keys.Contains(attr?.PropertyName ?? name);
if (available)
{
onAvailable?.Invoke((TProperty)info.GetValue(Data));
}
return available;
}
private static PropertyInfo GetPropertyInfo<TProperty>
(T source, Expression<Func<T, TProperty>> propertyLambda)
{
var type = typeof(T);
var member = propertyLambda.Body as MemberExpression ??
throw new ArgumentException($"Expression
'{propertyLambda.ToString()}' refers to a method, not a property.");
var propInfo = member.Member as PropertyInfo ??
throw new ArgumentException($"Expression
'{propertyLambda.ToString()}' refers to a field, not a property.");
if (type != propInfo.ReflectedType && !type.IsSubclassOf(propInfo.ReflectedType))
throw new ArgumentException($"Expression
'{propertyLambda.ToString()}' refers to a property that is not from type {type}.");
return propInfo;
}
}
我们在以下代码中定义了转换器(PartialJsonConverter
)
public class PartialJsonConverter : JsonConverter
{
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
// Should only be used for deserialization, not serialization
}
public override object ReadJson(JsonReader reader, Type objectType,
object existingValue, JsonSerializer serializer)
{
var innerType = objectType.GetGenericArguments()[0];
var wrapper = new JsonReaderWrapper(reader);
var obj = serializer.Deserialize(wrapper, innerType);
return Activator.CreateInstance(objectType, new [] { obj, wrapper.Keys });
}
public override bool CanWrite => false;
public override bool CanRead => true;
public override bool CanConvert(Type objectType) => objectType == typeof(Partial<>);
}
现在所有的魔法都包含在 JsonReaderWrapper
中,它只是对 Newtonsoft 已经提供给我们的标准 JsonReader
实例的包装。优点是我们可以在“跟踪”已看到的键时使用此读取器。
实际应用如下
public class JsonReaderWrapper : JsonReader
{
private readonly JsonReader _reader;
private int _level = 0;
public JsonReaderWrapper(JsonReader reader)
{
_reader = reader;
}
public List<string> Keys { get; } = new List<string>();
public override bool Read()
{
var result = _reader.Read();
if (_reader.TokenType == JsonToken.StartObject)
{
_level++;
}
else if (_reader.TokenType == JsonToken.EndObject)
{
_level--;
}
else if (_level == 0 && _reader.TokenType == JsonToken.PropertyName)
{
Keys.Add(Value as string);
}
return result;
}
public override char QuoteChar => _reader.QuoteChar;
public override JsonToken TokenType => _reader.TokenType;
public override object Value => _reader.Value;
public override Type ValueType => _reader.ValueType;
public override int Depth => _reader.Depth;
public override string Path => _reader.Path;
public override int? ReadAsInt32() => _reader.ReadAsInt32();
public override string ReadAsString() => _reader.ReadAsString();
public override byte[] ReadAsBytes() => _reader.ReadAsBytes();
public override double? ReadAsDouble() => _reader.ReadAsDouble();
public override bool? ReadAsBoolean() => _reader.ReadAsBoolean();
public override decimal? ReadAsDecimal() => _reader.ReadAsDecimal();
public override DateTime? ReadAsDateTime() => _reader.ReadAsDateTime();
public override DateTimeOffset? ReadAsDateTimeOffset() => _reader.ReadAsDateTimeOffset();
public override void Close() => _reader.Close();
}
由于我们继承自标准的 JsonReader
,我们需要将所有调用重定向到包装的 JsonReader
。显然,这是一个很长的列表(很多这些在标准操作中可能不需要,但谁知道呢),但它们都遵循相同的模式。
唯一需要特别注意的是 Read
方法。它在整个解决方案中受到最多的关注。让我们再次查看代码并详细分析。
// Read the next token
var result = _reader.Read();
if (_reader.TokenType == JsonToken.StartObject)
{
// If we start (another) object increase the nesting level
_level++;
}
else if (_reader.TokenType == JsonToken.EndObject)
{
// If we end an existing object decrease the nesting level
_level--;
}
else if (_level == 0 && _reader.TokenType == JsonToken.PropertyName)
{
// If we encounter a property name at the "base" level
// we should add its value (i.e., the name) to the keys
Keys.Add(Value as string);
}
// Act as the "normal" Read - return the seen token
return result;
本质上,我们引入了处理(嵌套)对象及其属性的特殊逻辑。如果我们遇到“基本”对象(部分对象)的属性,我们还会存储其名称。
奖励 0:有用的扩展方法
为了处理给定的键,我们可以引入两个扩展方法。一个方法从属性获取 JSON 属性名,另一个方法从 JSON 属性名获取属性。
public static class JsonExtensions
{
public static string GetJsonPropertyName(this PropertyInfo info)
{
var name = info.Name;
var attr = info.GetCustomAttribute<JsonPropertyAttribute>();
return attr?.PropertyName ?? name;
}
public static PropertyInfo GetPropertyFromJson(this Type type, string jsonPropertyName)
{
foreach (var property in type.GetProperties())
{
if (property.GetJsonPropertyName().Is(jsonPropertyName))
{
return property;
}
}
return null;
}
}
由于使用的键的可枚举集合引用 JSON 属性名,因此我们需要有这样的转换器来映射名称(或从 JSON 名称到 POCO 属性)。
奖励 1:忽略和命名属性
到目前为止一切顺利。可能,我们希望我们的(重用的)DTOs 也有特殊的条目,以便有意地将其排除在部分 PUT 之外。以下属性应该可以做到
[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class IgnoreInPartialPutAttribute : Attribute
{
public IgnoreInPartialPutAttribute()
{
}
}
仅靠属性本身当然是不够的。我们将使用该属性来修饰仅在正常(例如 POST
)操作期间设置的属性。但是,使用该属性,我们目前没有任何关联的逻辑。因此,我们需要另一个有用的扩展方法。我们称之为 Validate
,其职责是验证任何 Part
对象。
public static bool Validate<T>(this Part<T> partialInput)
{
foreach (var key in partialInput.Keys)
{
var type = typeof(T);
var info = type.GetPropertyFromJson(key);
if (info == null || !partialInput.IsSet(info))
{
return false;
}
}
return true;
}
此实用函数遍历所有已设置的键,并获取它们对应的 .NET 属性信息,如前所述。然后,我们检查是否存在这样的映射,或者是否我们获得的信息表明它不能通过部分输入设置。后者直接与我们的属性(或其他属性,如 Newtonsoft.Json 的通用 JsonIgnore
属性)相关联。
private static bool IsSet<T>(this Part<T> partialInput, PropertyInfo info)
{
if (!info.IsJsonIgnored() && !info.IsJsonForbidden())
{
var key = info.GetJsonPropertyName();
return partialInput.Keys.Contains(key);
}
return false;
}
这两个扩展方法(IsJsonIgnored
和 IsJsonForbidden
)基本上是自 explanatory 的。它们只查找给定属性信息上相应属性的出现。
奖励 2:使用 Swashbuckle 生成 Swagger
到目前为止一切顺利,但我们还没有完成。最终,我们的 API 应该得到很好的文档记录,拥有一个合适的 Swagger 生成是实现这一目标的首要任务。
有许多选项可以实现这一点,在我们这里,我们选择 Swashbuckle 并非特别的原因,仅仅是因为我们可以。
为了让 Swashbuckle 了解如何为我们的 API 生成 Swagger 文档/JSON schema,我们需要对其进行配置。在我们这里,配置看起来类似于以下几行
public static IServiceCollection AddSwaggerDoc(this IServiceCollection services)
{
services.AddSwaggerGen(config =>
{
config.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Awesome Service",
Description = "Description of the awesome service",
});
foreach (var path in GetXmlDocPathsOfAssemblies())
{
config.IncludeXmlComments(path);
}
config.EnableAnnotations();
config.DocumentFilter<PartFilter>();
config.SchemaFilter<PartFilter>();
});
return services;
}
关键部分是注册 PartFilter
。这些过滤器由 Swashbuckle 使用,以确定如何转换某些类型。我们添加了两个过滤器——一个用于整个 Swagger 文档,一个用于特定的 JSON schema。
public sealed class PartFilter : IDocumentFilter, ISchemaFilter
{
private static readonly string PartOfTName =
Regex.Replace(typeof(Part<>).Name, @"`.+", string.Empty);
private static readonly string PartOfTSchemaKeyPattern = $@"{PartOfTName}\[(?<Model>(.+))\]";
public void Apply(OpenApiDocument doc, DocumentFilterContext context)
{
foreach (var schemaPair in doc.Components.Schemas)
{
if (Regex.IsMatch(schemaPair.Key, PartOfTSchemaKeyPattern))
{
try
{
ModifyPartOfTSchema(context, schemaPair);
}
catch
{
// Don't crash if this fails for one schema.
// In the worst case, our Swagger doc. contains a few additional information.
}
}
}
}
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (context.SystemType.IsGenericType &&
context.SystemType.GetGenericTypeDefinition() == typeof(Part<>))
{
var wrappedType = context.SystemType.GetGenericArguments().First();
var ignoredPropertyNames = wrappedType
.GetProperties()
.Where(prop => Attribute.IsDefined(prop, typeof(IgnoreInPartialPutAttribute)))
.Select(GetJsonPropertyName)
.ToList();
schema.Extensions.Add(
nameof(IgnoredInPartialPutExtension),
new IgnoredInPartialPutExtension { PropertyNames = ignoredPropertyNames }
);
}
}
}
Swagger 声明泛型模型,如:GenericClass[TypeParam]
。因此,这意味着每个包装在 Part<T>
类中的模型都具有类似 Part[MyModel]
的名称。使用的正则表达式会检测到这一点。
由于我们不希望 Part
模型出现在最终的 Swagger 文档中,因此我们移除了名称与正则表达式匹配的每个模型。
对于文档,我们移除了出现在可移除属性列表中的任何属性。
private static void ModifyPartOfTSchema
(DocumentFilterContext context, KeyValuePair<string, OpenApiSchema> schemaPair)
{
var mySchema = schemaPair.Value;
var partDataProperty = mySchema.Properties.First(p => p.Key == "data").Value;
var referencedSchemaSchemaId = partDataProperty.Reference.Id;
var referencedSchema = context.SchemaRegistry.Schemas[referencedSchemaSchemaId];
var referencedSchemaProperties = referencedSchema.Properties;
var propertiesClone = DeepClone(referencedSchemaProperties);
mySchema.Properties = new Dictionary<string, OpenApiSchema>(referencedSchemaProperties);
mySchema.Description = referencedSchema.Description;
var ignoredPropertyNames = mySchema.Extensions.Values
.OfType<IgnoredInPartialPutExtension>()
.Select(ext => ext.PropertyNames)
.FirstOrDefault();
if (ignoredPropertyNames != null)
{
foreach (var ignoredPropertyName in ignoredPropertyNames)
{
var associatedKey = mySchema.Properties.Keys
.FirstOrDefault(key => key.Equals
(ignoredPropertyName, StringComparison.OrdinalIgnoreCase));
if (associatedKey != null)
{
mySchema.Properties.Remove(associatedKey);
}
}
}
mySchema.Extensions.Remove(nameof(IgnoredInPartialPutExtension));
}
上述代码中的算法如下
首先,从模型(Part<T>
)中克隆并复制所有属性到我们的 schema 中。进行深克隆,以便可以修改属性,而不改变原始属性。
我们必须确保
- 没有属性是必需的(在部分
PUT
中不是必需的) - 我们的 schema 描述(来自
Part<T>
)被清除 - 带有
IgnoreInPartialPut
属性的属性不会出现在列表中
使用的辅助函数定义如下
private static T DeepClone<T>(T original)
{
var serialized = JsonConvert.SerializeObject(original);
return JsonConvert.DeserializeObject<T>(serialized);
}
private static string GetJsonPropertyName(PropertyInfo property)
{
var jsonPropertyAttr = property
.GetCustomAttributes<JsonPropertyAttribute>()
.FirstOrDefault();
return jsonPropertyAttr?.PropertyName ?? property.Name;
}
忽略的属性名称由下面的 ISchemaFilter
注入。它们通过 IgnoreInPartialPut
OpenApi 扩展注入,我们可以在这里直接读取。
模型中的某些属性可能带有 IgnoreInPartialPutAttribute
属性。如果是这种情况,我们就获取这些属性的名称并将它们作为自定义 OpenApi
扩展注入,以便以后可以再次读取它们。
private class IgnoredInPartialPutExtension : IOpenApiExtension, IOpenApiElement
{
public IEnumerable<string> PropertyNames { get; set; }
public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion)
{
writer.WriteStartArray();
foreach (var propName in PropertyNames)
{
writer.WriteValue(propName);
}
writer.WriteEndArray();
}
}
使用代码
代码可以很容易地被复制和修改。为了简化整个过程,我发布了一个非常小的库,名为 Partial.Newtonsoft.Json
,它带来了所有这些小辅助功能等等。如果您觉得缺少了什么有用的东西,请在 GitHub 存储库上提供一个拉取请求(或打开一个 issue)。您可以在以下位置找到存储库:github.com/FlorianRappl/Partial.Newtonsoft.Json。Swashbuckle 辅助功能不是此库的一部分,因为它与 Newtonsoft.Json 没有关系,并且可能不是您选择的 Swagger 生成器。
nuget install Partial.Newtonsoft.Json
使用该库与使用 Newtonsoft.Json.Partial
命名空间中的 Part
类一样简单。
兴趣点
有趣的是,微软(或其他人?)还没有实现部分 PUT
。其他框架/社区已经内置了此功能或提供了现有库来处理这些场景。我们在 .NET 中唯一拥有的就是 PatchDocument
,它不是一个真正的资源,而且非常处于“RPC 参数”阵营,而不是 RESTful。
提供的代码仅说明了一种处理部分 PUT
(或 PATCH
)场景的特定方法。还有许多其他方法。有趣的部分是能够继续使用与 POST
相同的 DTO。最终,.NET 中有限的类型系统是不得不迁移到运行时机制(如反射/自定义反序列化器)来支持这些场景的根本原因。
我希望 C# / .NET 的未来能够拥有一个更强大的类型系统,能够进行编译时增强和类型操作。TypeScript 是一个很好的榜样,在这方面它确实表现出色。
历史
- v1.0.0 | 初始发布 | 2019.03.24
- v1.1.0 | 添加了目录 | 2019.03.28