我们正在使用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
,因此无法区分。
我正在寻找一些方法来使用某种包装器(带有值和标志设置/未设置)来注释模型,这样可以看到这种差异。任何现有的解决方案?
起初我误解了这个问题。当我使用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": {}
}
我知道我对这个答案有点迟,但我认为我有一个解决方案,不需要更改序列化,也不包括反射(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; }
}
然后你的Web Api方法看起来像
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等。
我知道已经给出的答案已经涵盖了所有方面,但只想分享我们最终做的事情的简明总结以及对我们来说似乎有用的东西。
创建了通用数据合同
[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
}
属性
这是我快速而廉价的解决方案......
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;
}