本文讲述了如何在ASP.NET Core中实现使用EF Core实现部分更新实体记录,就实现的背景、方式、设计思路进行了详细描述。
项目环境说明
在笔者项目实践中,通常采用Controller、Service的分层形式,为开发方便,一般会定义BaseService作为所有Service的基类,并在其中定义实现一些数据库操作快捷方法。此外,所有Controller的Action接收的参数都被分别封装为xxxRequest类,Controller层调用Service时会将Request转换为xxxServiceDto,在Service层中,会根据需要将ServiceDto转换为xxxEntity(EF Core的实体类)。
方法定义
在BaseService中,有两个更新实体的方法:
protected async Task<int> UpdateAsync<t>(T updatedEntity, List<string>? setNullFields = null);
protected async Task<int> UpdatePropertiesAsync<t>(T updatedEntity, List<string> canUpdateProperties);
具体实现见文末方法定义。
设计思路
这两个方法都是用于更新实体的,但设计思路和使用方式是不太一样的,这里简单做一下解释。
首先给一个完整的学期实体定义:
public class Semester : Entity
{
//Id已经在Entity中定义了,这里只是为了说明有该属性
[Required]
[Column("id", TypeName = "bigint")]
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public virtual long Id { get; set; }
[Required]
[Column("name", TypeName = "character varying(80)")]
[Comment("学期名称")]
public string? Name { get; set; }
[Required]
[Column("start_time", TypeName = "timestamp without time zone")]
[Comment("学期开始时间")]
public DateTime? StartTime { get; set; }
[Column("end_time", TypeName = "timestamp without time zone")]
[Comment("学期结束时间")]
public DateTime? EndTime { get; set; }
}
UpdateAsync方法在设计之初是希望能够做到,只更新需要更新的属性。例如,假设前端传递的更新请求为:
{
"id":3,
"name":"2024-2025学年第一学期"
}
在Controller和Service层,会被转换为:
{
"Id":3,
"Name":"2024-2025学年第一学期",
"StartTime":null,
"EndTime":null
}
UpdateAsync方法在接收到这个实体后,就只会根据Id更新非null的字段,这个例子中就只会更新Name属性。
这么做也有一个比较大的问题,就是没办法确定一个属性是否要被更新为null。例如,数据库中一条记录为:
{
"Id":4,
"Name":"2024-2025学年第二学期",
"StartTime":"2025-02-15T00:00:00",
"EndTime":"2025-06-15T00:00:00"
}
假设现在不确定期末时间,需要将其修改为空,那么前端传递的为:
{
"id":4,
"endTime":null
}
但在Controller和Service层,会被转换为:
{
"Id":4,
"Name":null,
"StartTime":null,
"EndTime":null
}
这个时候,代码就无法分辨哪个是需要修改的了,因为除了Id外,其余的所有属性均为null,UpdateAsync方法就无能为力了。为了解决这个问题,为其引入第二个参数setNullFields,该参数显式指示要将哪个属性设置为空,这样UpdateAsync就可以根据该参数修改实体了。
虽然UpdateAsync方法现在支持了显式设置需要置空的属性,但是对于调用方来说,仍然不知道要如何调用,因为在Controller和Service层的时候,就已经无法判断需要将哪个属性置空了。
从类型系统的视角分析,JavaScript与C#等静态类型语言在空值处理上存在本质差异。在JS语言中,null表示显式空值,undefined则具有双重语义:既可表征未定义的属性访问,也可作为对象属性的有效赋值。这种设计常导致开发者将undefined与null混用,且实践中undefined的使用频率显著高于null。
而在C#等编译型语言中,对象属性在编译期即完成存在性验证,语言规范中不存在与undefined对等的概念。这种类型系统的差异在JSON序列化/反序化过程中会产生语义损耗:当C#处理JSON时,undefined值会被统一映射为null,同时JSON中缺失的属性在映射后也会被赋值为null。这种转换机制导致两个关键信息的丢失:
- 属性存在性信息(究竟是未传递属性还是显式赋空属性)
- 操作意图信息(是要保持未传递属性不变还是要显式置空属性)
这正是UpdateAsync问题的根源所在——JSON原本携带的细粒度更新语义(保持未传递属性不变、显式置空特定属性)在类型转换过程中被平化为统一的null赋值,最终在数据更新操作中引发歧义。
也就是说,如果我们可以知道具体哪些属性被传递了,我们就可以严格按照语义来进行更新。如何收集被传递的属性呢?自然是在json转换的时候收集。项目中使用的是Newtonsoft.Json库,因此可以利用其可自定义JsonConverter的特性来实现收集传递的属性。代码如下:
public class EntityUpdateRequest
{
[NotMapped]
public List<string> CanUpdateProperties { get; set; } = new();
}
public class UpdateEntityRequestConverter<T> : JsonConverter where T : EntityUpdateRequest, new()
{
public override bool CanWrite => false;
public override bool CanConvert(Type objectType)
{
return typeof(EntityUpdateRequest) == objectType;
}
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue,
JsonSerializer serializer)
{
JObject jObject = JObject.Load(reader);
T target = new();
List<string> targetPropertyNames = target.GetType()
.GetProperties()
.Select(p => p.Name.ToLowerInvariant())
.ToList();
foreach (JProperty property in jObject.Properties())
{
if (targetPropertyNames.Contains(property.Name, StringComparer.InvariantCultureIgnoreCase))
{
target.CanUpdateProperties.Add(property.Name);
}
}
serializer.Populate(jObject.CreateReader(), target);
return target;
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new NotSupportedException("仅支持接口入参转换!");
}
}
使用方式:
[JsonConverter(typeof(UpdateEntityRequestConverter<UpdateSemesterRequest>))]
public class UpdateSemesterRequest : EntityUpdateRequest
{
/// <summary>
/// 学期ID
/// </summary>
[Required]
public long Id { get; set; }
/// <summary>
/// 学期名称
/// </summary>
[StringLength(80)]
public string? Name { get; set; }
/// <summary>
/// 学期开始时间
/// </summary>
public DateTime? StartTime { get; set; }
/// <summary>
/// 学期结束时间
/// </summary>
public DateTime? EndTime { get; set; }
}
这样,在“更新请求2”发来后,Controller和Service层的更新实体信息如下:
{
"CanUpdateProperties":["id","endTime"],
"Id":4,
"EndTime":null,
"Name":null,
"StartTime":null
}
此时就完整记录下来了所有传递的信息。这时,只要根据“CanUpdateProperties”和实体属性就可以获得需要置空的属性列表传递给UpdateAsync方法。
不过完全可以采用另一个方法UpdatePropertiesAsync,该方法也接收两个参数,第一个参数是更新实体,第二个参数是可更新的属性,第二个参数显式限制了要更新哪些属性,可直接配合“CanUpdateProperties”属性进行使用。
UpdateAsync和UpdatePropertiesAsync这两个方法,设计初衷都是一样的,只是解决问题的能力有所不同,后者是针对前者的问题追根溯源进行彻底解决的优化方案,前者是后者产生的基础。研发就是这样动态演进不断更新的过程。
方法实现
注:笔者项目中所有数据库实体均继承自Database.Entity.Entity类,其中包含了主键Id,项目中约定实体Id永远不变。
/// <summary>
/// 根据ID更新一个实体
/// </summary>
/// <param name="updatedEntity">一个包含ID和要修改的属性的实体。
/// 无需修改的属性需要置空,否则将会覆盖数据库中数据</param>
/// <param name="setNullFields">需要设置为空的属性</param>
/// <returns>数据库更改行数</returns>
protected async Task<int> UpdateAsync<T>(T updatedEntity, List<string>? setNullFields = null)
where T : Database.Entity.Entity
{
List<PropertyInfo> propertyInfos = [..updatedEntity.GetType().GetProperties()];
long? id = (long?)updatedEntity.GetType().GetProperty("Id")?.GetValue(updatedEntity);
if (id is null)
{
throw new BusinessNotAllowedException("必须提供数据对应ID!", 400000, 400);
}
T? existPo = await TryGetByIdAsync<T>(id.Value, noTracking: false);
if (existPo == null)
{
_logger.LogError("更新数据时未查询到相关数据!");
throw new BusinessNotAllowedException("未找到对应数据进行更新!", 404000, 401);
}
setNullFields ??= [];
// 非空属性进行更新
foreach (PropertyInfo propertyInfo in propertyInfos
.Where(propertyInfo => propertyInfo.GetValue(updatedEntity) != null))
{
PropertyInfo? existPoProperty = existPo.GetType().GetProperty(propertyInfo.Name);
if (existPoProperty == null)
{
continue;
}
//非映射属性不更新
if (existPoProperty.GetCustomAttribute<NotMappedAttribute>() != null)
{
continue;
}
//仅可写属性进行更新
if (existPoProperty.CanWrite)
{
existPoProperty.SetValue(existPo, propertyInfo.GetValue(updatedEntity));
}
}
foreach (string nullField in setNullFields)
{
PropertyInfo? existPoProperty = existPo.GetType().GetProperty(nullField);
if (existPoProperty == null)
{
continue;
}
existPoProperty.SetValue(existPo, null);
}
try
{
return await Repository.SaveChangesAsync();
}
catch (UniqueConstraintException)
{
throw new BusinessNotAllowedException("提交的数据已有部分存在!", 500002);
}
catch (ReferenceConstraintException)
{
throw new BusinessNotAllowedException("提交的数据与现有数据无法对应!", 500002);
}
}
/// <summary>
/// 根据ID更新一个实体
/// </summary>
/// <param name="updatedEntity">一个包含ID和要修改的属性的实体。</param>
/// <param name="canUpdateProperties">可以更新的属性,仅在该列表中的属性可以被更新,并且会更新为实体对象的值,无论其是否为null</param>
/// <returns>数据库更改行数</returns>
protected async Task<int> UpdatePropertiesAsync<T>(T updatedEntity, List<string> canUpdateProperties)
where T : Database.Entity.Entity
{
List<PropertyInfo> propertyInfos = [.. updatedEntity.GetType().GetProperties()];
long? id = (long?)updatedEntity.GetType().GetProperty("Id")?.GetValue(updatedEntity);
if (id is null)
{
throw new BusinessNotAllowedException("必须提供数据对应ID!", 400000, 400);
}
T? existPo = await TryGetByIdAsync<T>(id.Value, noTracking: false);
if (existPo == null)
{
_logger.LogError("更新数据时未查询到相关数据!");
throw new BusinessNotAllowedException("未找到对应数据进行更新!", 404000, 401);
}
foreach (PropertyInfo propertyInfo in propertyInfos
.Where(propertyInfo =>
canUpdateProperties.Contains(propertyInfo.Name, StringComparer.InvariantCultureIgnoreCase)))
{
PropertyInfo? existPoProperty = existPo.GetType().GetProperty(propertyInfo.Name);
if (existPoProperty == null)
{
continue;
}
//非映射属性不更新
if (existPoProperty.GetCustomAttribute<NotMappedAttribute>() != null)
{
continue;
}
//仅可写属性进行更新
if (existPoProperty.CanWrite)
{
existPoProperty.SetValue(existPo, propertyInfo.GetValue(updatedEntity));
}
}
try
{
return await Repository.SaveChangesAsync();
}
catch (UniqueConstraintException)
{
throw new BusinessNotAllowedException("提交的数据已有部分存在!", 500002);
}
catch (ReferenceConstraintException)
{
throw new BusinessNotAllowedException("提交的数据与现有数据无法对应!", 500002);
}
}