ASP.NET Web API 2 和部分更新

ASP.NET Web API 2 and partial updates

我们正在使用 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,所以无法区分。

我正在寻找某种方法来使用某种包装器(内部带有值和标志 set/unset)来注释模型,以便看到这种差异。对此有任何现有的解决方案吗?

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

第一步是创建两个class用于序列化。

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

public class GenericChangeType<T> : ChangeType
{
}

我选择了泛型和非泛型 class 因为很难转换为泛型类型,而这并不重要。此外,对于 xml 实施,Xml文本必须是字符串。

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

对于 Json 这不起作用。 Json 不知道属性。所以对于 Json 这只是一个具有属性的 class。为了实现 xml 值的想法(我稍后会谈到),我将 属性 重命名为 #text。这只是一个约定。

由于Xml文本是字符串(我们想序列化为字符串),不管类型如何,存储值都可以。但是在序列化的情况下,我想知道实际类型。

缺点是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);
    }
}

起初我尝试将转换器添加到 class: [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 并且应该足以让服务器检测到更改。

-- 更新--

由于此答案侧重于序列化,因此最重要的是姓氏是序列化字符串的一部分。然后就看接收方怎么把字符串反序列化成对象了。

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

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);

如果您使用相同的 classes 进行反序列化,那么 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": {}
}

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

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 returns 一个 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;
}

我知道我回答这个问题有点晚了,但我想我有一个解决方案,它不需要更改序列化,也不包含反射(This article 请您参考Json某人编写的使用反射的补丁库)。

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

    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",因此值应该可以为空。我很想知道这是否真的有效。最坏的情况是,您可以为所有 属性 类型实施 StringPatchProperty、IntPatchProperty 等。

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

创建了通用数据协定

[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 Patch" 标准那么广泛,但涵盖了我们所有的需求。

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

属性

  • 简单的合约,简单的逻辑
  • 无序列化定制
  • 支持空值赋值
  • 涵盖任何类型:值、引用、复杂的自定义类型等等