ASP.NET Web API 2和部分更新

时间:2017-07-05 13:46:49

标签: c# asp.net asp.net-web-api asp.net-web-api2 patch

我们正在使用ASP.NET Web API 2并希望以下列方式公开部分编辑某个对象的能力:

HTTP PATCH /customers/1
{
  "firstName": "John",
  "lastName": null
}

...将firstName设为"John",将lastName设为null

HTTP PATCH /customers/1
{
  "firstName": "John"
}

...只是为了将firstName更新为"John"而根本不触及lastName。假设我们有很多属性需要用这种语义进行更新。

这是一种非常方便的行为,例如由OData执行。

问题是默认的JSON序列化程序在这两种情况下都会提出null,因此无法区分。

我正在寻找一些方法来使用某种包装器(带有值和标志设置/未设置)来注释模型,这样可以看到这种差异。任何现有的解决方案吗?

4 个答案:

答案 0 :(得分:2)

起初我误解了这个问题。当我使用Xml时,我认为这很容易。只需向属性添加属性,并将属性保留为空。但正如我发现的那样,Json并没有这样做。由于我正在寻找适用于xml和json的解决方案,因此您将在此答案中找到xml引用。另一件事,我用C#客户端编写了这个。

第一步是创建两个序列化类。

public class ChangeType
{
    [JsonProperty("#text")]
    [XmlText]
    public string Text { get; set; }
}

public class GenericChangeType<T> : ChangeType
{
}

我选择了泛型和非泛型类,因为很难将其转换为泛型类型,而这并不重要。此外,对于xml实现,XmlText必须是字符串。

XmlText是属性的实际值。优点是您可以向此对象添加属性以及这是一个对象,而不仅仅是字符串。在Xml中,它看起来像:<Firstname>John</Firstname>

对于Json来说,这不起作用。 Json不了解属性。所以对于Json来说,这只是一个具有属性的类。为了实现xml值的概念(我将在稍后讨论),我已将该属性重命名为 #text 。这只是一个惯例。

由于XmlText是字符串(我们希望序列化为字符串),因此可以存储忽略该类型的值。但是在序列化的情况下,我想知道实际的类型。

缺点是viewmodel需要引用这些类型,优点是属性是强类型的序列化:

public class CustomerViewModel
{
    public GenericChangeType<int> Id { get; set; }
    public ChangeType Firstname { get; set; }
    public ChangeType Lastname { get; set; }
    public ChangeType Reference { get; set; }
}

假设我设置了值:

var customerViewModel = new CustomerViewModel
{
    // Where int needs to be saved as string.
    Id = new GenericeChangeType<int> { Text = "12" },
    Firstname = new ChangeType { Text = "John" },
    Lastname = new ChangeType { },
    Reference = null // May also be omitted.
}

在xml中,这将如下所示:

<CustomerViewModel>
  <Id>12</Id>
  <Firstname>John</Firstname>
  <Lastname />
</CustomerViewModel>

这足以让服务器检测到更改。但是使用json会产生以下结果:

{
    "id": { "#text": "12" },
    "firstname": { "#text": "John" },
    "lastname": { "#text": null }
}

它可以工作,因为在我的实现中,接收视图模型具有相同的定义。但是,由于您只讨论序列化,如果您使用其他实现,您需要:

{
    "id": 12,
    "firstname": "John",
    "lastname": null
}

这是我们需要添加自定义json转换器以产生此结果的地方。相关代码在WriteJson中,假设您只将此转换器添加到序列化器设置中。但为了完整起见,我还添加了readJson代码。

public class ChangeTypeConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        // This is important, we can use this converter for ChangeType only
        return typeof(ChangeType).IsAssignableFrom(objectType);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var value = JToken.Load(reader);

        // Types match, it can be deserialized without problems.
        if (value.Type == JTokenType.Object)
            return JsonConvert.DeserializeObject(value.ToString(), objectType);

        // Convert to ChangeType and set the value, if not null:
        var t = (ChangeType)Activator.CreateInstance(objectType);
        if (value.Type != JTokenType.Null)
            t.Text = value.ToString();
        return t;
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var d = value.GetType();

        if (typeof(ChangeType).IsAssignableFrom(d))
        {
            var changeObject = (ChangeType)value;

            // e.g. GenericChangeType<int>
            if (value.GetType().IsGenericType)
            {
                try
                {
                    // type - int
                    var type = value.GetType().GetGenericArguments()[0];
                    var c = Convert.ChangeType(changeObject.Text, type);
                    // write the int value
                    writer.WriteValue(c);
                }
                catch
                {
                    // Ignore the exception, just write null.
                    writer.WriteNull();
                }
            }
            else
            {
                // ChangeType object. Write the inner string (like xmlText value)
                writer.WriteValue(changeObject.Text);
            }
            // Done writing.
            return;
        }
        // Another object that is derived from ChangeType.
        // Do not add the current converter here because this will result in a loop.
        var s = new JsonSerializer
        {
            NullValueHandling = serializer.NullValueHandling,
            DefaultValueHandling = serializer.DefaultValueHandling,
            ContractResolver = serializer.ContractResolver
        };
        JToken.FromObject(value, s).WriteTo(writer);
    }
}

起初我尝试将转换器添加到班级:[JsonConverter(ChangeTypeConverter)]。但问题是转换器将一直使用,这会创建一个参考循环(如上面代码中的注释中所述)。此外,您可能只想使用此转换器进行序列化。这就是为什么我只将它添加到序列化程序中:

var serializerSettings = new JsonSerializerSettings
{
    NullValueHandling = NullValueHandling.Ignore,
    DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate,
    Converters = new List<JsonConverter> { new ChangeTypeConverter() },
    ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
};
var s = JsonConvert.SerializeObject(customerViewModel, serializerSettings);

这将生成我正在寻找的json,并且应该足以让服务器检测到更改。

- 更新 -

由于这个答案主要关注序列化,最重要的是lastname是序列化字符串的一部分。然后,它取决于接收方如何再次将字符串反序列化为对象。

序列化和反序列化使用不同的设置。要再次反序列化,您可以使用:

var deserializerSettings = new JsonSerializerSettings
{
    //NullValueHandling = NullValueHandling.Ignore,
    DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate,
    Converters = new List<JsonConverter> { new Converters.NoChangeTypeConverter() },
    ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
};
var obj = JsonConvert.DeserializeObject<CustomerViewModel>(s, deserializerSettings);

如果使用相同的类进行反序列化,则Request.Lastname应为ChangeType,Text = null。

我不确定为什么从反序列化设置中删除NullValueHandling会导致问题。但是你可以通过将空对象写为值而不是null来克服这个问题。在转换器中,当前的ReadJson已经可以处理这个问题。但是在WriteJson中必须有一个修改。而不是writer.WriteValue(changeObject.Text);你需要类似的东西:

if (changeObject.Text == null)
    JToken.FromObject(new ChangeType(), s).WriteTo(writer);
else
    writer.WriteValue(changeObject.Text);

这将导致:

{
    "id": 12,
    "firstname": "John",
    "lastname": {}
}

答案 1 :(得分:1)

这是我快速而廉价的解决方案...

public static ObjectType Patch<ObjectType>(ObjectType source, JObject document)
    where ObjectType : class
{
    JsonSerializerSettings settings = new JsonSerializerSettings
    {
        ContractResolver = new CamelCasePropertyNamesContractResolver()
    };

    try
    {
        String currentEntry = JsonConvert.SerializeObject(source, settings);

        JObject currentObj = JObject.Parse(currentEntry);

        foreach (KeyValuePair<String, JToken> property in document)
        {    
            currentObj[property.Key] = property.Value;
        }

        String updatedObj = currentObj.ToString();

        return JsonConvert.DeserializeObject<ObjectType>(updatedObj);
    }
    catch (Exception ex)
    {
        throw ex;
    }
}

从基于PATCH的方法中获取请求正文时,请确保将参数作为JObject之类的类型。 JObject在迭代过程中返回KeyValuePair结构,从本质上简化了修改过程。这样,您就可以获取请求正文内容,而不会收到所需类型的反序列化结果。

这是有益的,因为您不需要为已无效的属性进行任何其他验证。如果您希望您的值被无效,那也是可行的,因为Patch<ObjectType>()方法仅循环遍历部分JSON文档中给出的属性。

使用Patch<ObjectType>()方法,您只需要传递源或目标实例,以及将更新对象的部分JSON文档。此方法将应用基于camelCase的合同解析器,以防止生成不兼容和不正确的属性名称。然后,此方法将序列化您传递的某种类型的实例,并将其转换为JObject。

然后该方法将所有属性从新JSON文档替换为当前的序列化文档,而无需任何不必要的 if 语句。

该方法对当前已修改的当前文档进行字符串化处理,然后将修改后的JSON文档反序列化为所需的通用类型。

如果发生异常,该方法将简单地将其抛出。是的,它不是很具体,但是您是程序员,您需要知道期望什么...

这可以通过一种简单的语法完成,并具有以下内容:

Entity entity = AtomicModifier.Patch<Entity>(entity, partialDocument);

此操作通常如下所示:

// Partial JSON document (originates from controller).
JObject newData = new { role = 9001 };

// Current entity from EF persistence medium.
User user = await context.Users.FindAsync(id);

// Output:
//
//     Username : engineer-186f
//     Role     : 1
//
Debug.WriteLine($"Username : {0}", user.Username);
Debug.WriteLine($"Role     : {0}", user.Role);

// Partially updated entity.
user = AtomicModifier.Patch<User>(user, newData);

// Output:
//
//     Username : engineer-186f
//     Role     : 9001
//
Debug.WriteLine($"Username : {0}", user.Username);
Debug.WriteLine($"Role     : {0}", user.Role);

// Setting the new values to the context.
context.Entry(user).State = EntityState.Modified;

如果您可以使用camelCase合同解析器正确映射两个文档,则此方法会很好用。

享受...

更新

我用以下代码更新了Patch<T>()方法...

public static T PatchObject<T>(T source, JObject document) where T : class
{
    Type type = typeof(T);

    IDictionary<String, Object> dict = 
        type
            .GetProperties()
            .ToDictionary(e => e.Name, e => e.GetValue(source));

    string json = document.ToString();

    var patchedObject = JsonConvert.DeserializeObject<T>(json);

    foreach (KeyValuePair<String, Object> pair in dict)
    {
        foreach (KeyValuePair<String, JToken> node in document)
        {
            string propertyName =   char.ToUpper(node.Key[0]) + 
                                    node.Key.Substring(1);

            if (propertyName == pair.Key)
            {
                PropertyInfo property = type.GetProperty(propertyName);

                property.SetValue(source, property.GetValue(patchedObject));

                break;
            }
        }
    }

    return source;
}

答案 2 :(得分:1)

我知道我对这个答案有点晚了,但是我认为我有一个解决方案,不需要更改序列化,也不需要反射(This article指的是JsonPatch有人写的使用反射的库)。

基本上创建一个表示可以修补的属性的通用类

    public class PatchProperty<T> where T : class
    {
        public bool Include { get; set; }
        public T Value { get; set; }
    }

然后创建表示要修补的对象的模型,其中每个属性都是PatchProperty

    public class CustomerPatchModel
    {
        public PatchProperty<string> FirstName { get; set; }
        public PatchProperty<string> LastName { get; set; }
        public PatchProperty<int> IntProperty { get; set; }
    }

然后您的WebApi方法看起来像

    public void PatchCustomer(CustomerPatchModel customerPatchModel)
    {
        if (customerPatchModel.FirstName?.Include == true)
        {
            // update first name 
            string firstName = customerPatchModel.FirstName.Value;
        }
        if (customerPatchModel.LastName?.Include == true)
        {
            // update last name
            string lastName = customerPatchModel.LastName.Value;
        }
        if (customerPatchModel.IntProperty?.Include == true)
        {
            // update int property
            int intProperty = customerPatchModel.IntProperty.Value;
        }
    }

您可以发送带有一些类似Json的请求

{
    "LastName": { "Include": true, "Value": null },
    "OtherProperty": { "Include": true, "Value": 7 }
}

然后我们会忽略FirstName,但仍将其他属性分别设置为null和7。

请注意,我尚未对此进行测试,而且我不是100%确信它会工作。它基本上将依赖于.NET序列化通用PatchProperty的能力。但是由于模型上的属性指定了通用T的类型,所以我认为它可以。同样,由于我们在PatchProperty声明中具有“ where T:class”,因此Value应该为可空。我想知道这是否真的有效。最坏的情况是您可以为所有属性类型实现StringPatchProperty,IntPatchProperty等。

答案 3 :(得分:1)

我知道已经给出的答案已经涵盖了所有方面,但是只想分享我们最终所做的工作以及对我们似乎行之有效的简明摘要。

已创建通用数据合同

[DataContract]
public class RQFieldPatch<T>
{
    [DataMember(Name = "value")]
    public T Value { get; set; }
}

为补丁请求创建临时数据规范

示例如下。

[DataContract]
public class PatchSomethingRequest
{
    [DataMember(Name = "prop1")]
    public RQFieldPatch<EnumTypeHere> Prop1 { get; set; }

    [DataMember(Name = "prop2")]
    public RQFieldPatch<ComplexTypeContractHere> Prop2 { get; set; }

    [DataMember(Name = "prop3")]
    public RQFieldPatch<string> Prop3 { get; set; }

    [DataMember(Name = "prop4")]
    public RQFieldPatch<int> Prop4 { get; set; }

    [DataMember(Name = "prop5")]
    public RQFieldPatch<int?> Prop5 { get; set; }
}

业务逻辑

简单。

if (request.Prop1 != null)
{
    // update code for Prop1, the value is stored in request.Prop1.Value
}

Json格式

简单。没有“ JSON补丁”标准那么广泛,但是可以满足我们的所有需求。

{
  "prop1": null, // will be skipped
  // "prop2": null // skipped props also skipped as they will get default (null) value
  "prop3": { "value": "test" } // value update requested
}

属性

  • 简单合同,简单逻辑
  • 没有序列化定制
  • 支持空值分配
  • 涵盖任何类型:值,引用,复杂的自定义类型,等等