![](/img/trans.png)
[英]Explicit vs. implicit null in ASP.NET Web API during partial updates
[英]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
,因此无法区分。
我正在寻找某种方法来使用某种包装器(在内部设置/取消设置值和标志)来注释模型,这将允许看到这种差异。 任何现有的解决方案?
起初我误解了这个问题。 当我使用 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,应该足以让服务器检测到更改。
- 更新 -
由于此答案侧重于序列化,因此最重要的是姓氏是序列化字符串的一部分。 然后取决于接收方如何再次将字符串反序列化为对象。
序列化和反序列化使用不同的设置。 为了再次反序列化,您可以使用:
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": {}
}
我知道已经给出的答案已经涵盖了所有方面,但只想分享我们最终做了什么以及对我们来说似乎很有效的简要总结。
创建通用数据合约
[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 文档。 此方法将应用基于驼峰命名法的合约解析器来防止生成不兼容和不准确的属性名称。 然后,此方法将序列化您传递的某种类型的实例并转换为 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”,因此该值应该可以为空。 不过,我很想知道这是否真的有效。 最坏的情况是,您可以为所有属性类型实现 StringPatchProperty、IntPatchProperty 等。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.