我们正在使用 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
,因此无法区分。
我正在寻找某种方法来使用某种包装器(内部设置/取消设置值)来注释模型,以便看到这种差异。对此有任何现有的解决方案吗?
我知道已经给出的答案已经涵盖了所有方面,但只是想分享我们最终所做的以及似乎对我们有用的简明摘要。
已创建通用数据协定
[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
}
性能
简单的- 合约,简单的逻辑
- 无序列化自定义
- 支持空值赋值
- 涵盖任何类型:值、引用、复杂自定义类型等
起初我误解了这个问题。当我使用 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 是字符串(并且我们希望序列化为字符串),因此可以存储值而不考虑类型。但是在序列化的情况下,我想知道实际类型。
缺点是视图模型需要引用这些类型,优点是属性是强类型的序列化:
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": {}
}
这是我快速且便宜的解决方案...
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;
}
我知道我在这个答案上有点晚了,但我认为我有一个解决方案,它不需要更改序列化,也不包括反射(本文向您介绍了某人编写的使用反射的 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等。