使用Newtonsoft.Json接受部分资源

目录

介绍

背景

问题描述

方法:解决Newtonsoft.Json中的问题

解决方案:正确封装是关键

奖励0:有用的扩展方法

奖励1:忽略和命名属性

奖励2:使用Swashbuckle的Swagger生成

使用代码

兴趣点


介绍

随着复杂后端服务在云端的兴起,对高效(即开发人员友好)API的需求也激增。幸运的是,我们有非常棒的API示例。不幸的是,以类似的方式构建我们自己的API是一项艰苦的工作。

事实证明,正确应用REST并不乏味,但却是一项艰巨的任务。需要考虑很多事情,每个端点都需要一整套审核,甚至远程的认为是稳定或完整的。

如果我们真的想要考虑资源而不是动作/操作,我们需要完全理解HTTP动词和状态代码及其含义。两个非常有趣的动词是PUTPATCH。从理论上讲,它们的区别很简单:两者都对现有资源执行更新(因此需要适当的标识符才能够定位特定资源)。但是,虽然PUT可以认为是标准的replace,但PATCH只考虑提供的属性,操作才会执行逐值更新。

我们也可以将PATCH操作称为部分PUT

在本文中,我们想探索如何有效地创建自己的部分PUT端点。事实证明,ASP.NET已经为这种部分更新提供了一个对象,不过,该对象带有自己的资源定义,并不能完全反映我们想要的RESTfulness。因此,在本文中,我们从头开始。

背景

在我们自己的API中,我们没有任何PATCH端点。原因很简单:PUT根据定义,我们所有的端点都被认为是部分的。有人可能不喜欢这种选择,但考虑到API只有在相应使用时才有用,我们决定采用这种方法。我们所有的开发人员都与PUT部分更新相关联,而没有人听说过PATCH操作。因此,我们简化了设计。

为什么部分PUT如此有用呢?首先,它避免了拥堵。如果我们有一个大型资源,我们只需要将包含更新值的相应字段发送回服务器。隐式地,我们不会覆盖我们不关心的值。最后,如果我们知道资源的id和某些字段的新值,我们不需要获取完整资源来执行更新。

总而言之,部分更新而不是完整更新可以带来真正的好处。唯一的主要缺点是可能希望对已经提供给客户端的完整资源进行一致性检查/保证,以避免来自服务器的意外。一个示例场景可能是,API根据属性A检查属性B是否具有某个值。假设某人对BA进行了有效更改,而我们也只对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现在忘记我们的定制转换器。问题只是这不是一种处理事物的robost(即未来证明)方式,而且我们肯定会遇到跨线程问题。竞争条件或代码是非确定性的,并且会根据机器的状态不正确地崩溃/表现,这不是我们想要的。

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属性,以及引用原始JSONused / found键的属性。如上所述,键仅指顶级键。

[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中,这只是一个围绕标准JsonReader实例的包装器,newtonSoft已经给了我们此实例。优点是我们可以使用此阅读器跟踪已经看到的键。

事实上,这看起来如下:

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:忽略和命名属性

到现在为止还挺好。潜在地,我们希望我们的(重复使用的)DTO也有特殊条目被故意省略,以便被部分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;
}

这两种扩展方法(IsJsonIgnoredIsJsonForbidden)几乎是不言自明的。它们只在给定的属性信息上查找相应属性的出现。

奖励2:使用SwashbuckleSwagger生成

到目前为止一切都那么好,但我们尚未完成。最后,我们的API应该很好地记录下来,并且正确的Swagger生成是完成这一过程的绝对必要条件。

有很多选择可以实现这个目标,在我们的例子中,我们会选择Swashbuckle没有特别的原因,只因为我们可以。

要向Swashbuckle讲解如何为我们的API生成Swagger文档/ JSON模式,我们需要对其进行配置。在我们的示例中,配置类似于以下行:

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模式。

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>)中的所有属性复制到我们的模式中。执行深度克隆,以便可以修改属性,而无需更改原始属性。

我们必须确保:

  • 不再需要任何属性(部分PUT不需要)。
  • 我们的架构描述(来自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注入。它们通过IgnoredInPartialPut 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存储库中提供拉取请求(或打开问题)。您可以在以下网址找到该存储库:github.com/FlorianRappl/Partial.Newtonsoft.JsonSwashbuckle助手不是这个库的一部分,因为它与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,在这方面真的很棒。

 

原文地址:https://www.codeproject.com/Articles/1282080/Accepting-Partial-Resources-with-Newtonsoft-Json

猜你喜欢

转载自blog.csdn.net/mzl87/article/details/88830928
今日推荐