背景
在实际开发中,我们经常遇到局部更新(Patch Update)的场景。比如前端只传了用户修改过的字段,而后端需要把这些非空字段更新到原有的数据模型上,而未传的字段保持原样。
如果使用 AutoMapper 直接映射,未传的字段会被覆盖成 null。为了解决这个问题,我们可以编写一个通用的**字段合并(Merge)**扩展方法。
优化后的代码实现
这里对代码进行了几个关键优化:
- 去掉对特定
StringExtension的依赖,内置更健壮的判空逻辑(兼容字符串的空格、值类型的默认值)。 - 修正了 Nullable 类型的判断逻辑 ,避免引发
InvalidCastException。 - 增强了类型转换 ,使用
TypeDescriptor支持string到Guid、Enum等复杂转换。 - 封装为扩展方法,调用更优雅。
- 加入属性缓存与过滤,避免遍历索引器或不必要的只读属性。
csharp
using System;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
// 需要安装 AutoMapper 包:Install-Package AutoMapper
using AutoMapper;
public static class ObjectMergeExtensions
{
// 缓存目标类型的可读写属性,提升反射性能
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> PropertyCache = new();
/// <summary>
/// 将 overlay 对象中的非空字段合并到 baseObj 对象中,并返回合并后的新对象
/// </summary>
/// <typeparam name="TDestination">目标类型</typeparam>
/// <param name="baseObj">基础对象(旧数据)</param>
/// <param name="overlay">覆盖对象(新数据,仅覆盖非空字段)</param>
/// <param name="isOverwriteEmptyString">是否覆盖空字符串(默认为 false,即空字符串不会覆盖原值)</param>
public static TDestination MergeTo<TDestination>(this object baseObj, object overlay, bool isOverwriteEmptyString = false)
where TDestination : class, new()
{
if (baseObj == null && overlay == null) return null;
// 1. 克隆 baseObj
var result = baseObj == null ? new TDestination() : baseObj.Adapt<TDestination>();
if (overlay == null) return result;
// 2. 遍历目标类型的所有可写属性
var targetProps = PropertyCache.GetOrAdd(typeof(TDestination), type =>
type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0) // 排除只读和索引器
.ToArray());
var overlayType = overlay.GetType();
foreach (var targetProp in targetProps)
{
// 获取源对象对应属性
var sourceProp = overlayType.GetProperty(targetProp.Name, BindingFlags.Public | BindingFlags.Instance);
if (sourceProp == null || !sourceProp.CanRead) continue;
var srcVal = sourceProp.GetValue(overlay);
// 3. 判断源值是否为空(如果为空,则跳过,保留原值)
if (IsValueEmpty(srcVal, isOverwriteEmptyString)) continue;
try
{
// 4. 类型转换与赋值
var targetType = Nullable.GetUnderlyingType(targetProp.PropertyType) ?? targetProp.PropertyType;
object convertedVal = srcVal;
if (srcVal.GetType() != targetType)
{
// 支持更广泛的类型转换,如 string 转 Guid、Enum
var converter = TypeDescriptor.GetConverter(targetType);
if (converter.CanConvertFrom(srcVal.GetType()))
{
convertedVal = converter.ConvertFrom(srcVal);
}
else
{
convertedVal = Convert.ChangeType(srcVal, targetType);
}
}
targetProp.SetValue(result, convertedVal);
}
catch
{
// 忽略无法转换的字段,防止程序崩溃
}
}
return result;
}
/// <summary>
/// 判断值是否为空或默认值
/// </summary>
private static bool IsValueEmpty(object value, bool isOverwriteEmptyString)
{
if (value == null) return true;
if (value is string str)
{
return string.IsNullOrWhiteSpace(str) || (!isOverwriteEmptyString && str == "");
}
var type = value.GetType();
if (type.IsValueType)
{
// 判断是否为值类型的默认值(如 int 的 0,bool 的 false,DateTime 的 0001-01-01)
return value.Equals(Activator.CreateInstance(type));
}
return false;
}
}
//使用
public class Program
{
public static void Main()
{
// 1. 数据库中的原数据
var dbUser = new User
{
Id = "1001",
Name = "张三",
Age = 25,
Email = "zhangsan@example.com",
RoleId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
CreateTime = new DateTime(2023, 1, 1)
};
// 2. 前端传来的局部更新数据(注意:只填了 Name 和 Email,其他都是默认值)
var patchData = new User
{
Name = "张三丰", // 非空,将被覆盖
Email = "", // 空字符串,默认不覆盖
Age = 0, // 值类型默认值,不覆盖
RoleId = Guid.Parse("22222222-2222-2222-2222-222222222222") // 将被覆盖
};
// 3. 执行合并
var updatedUser = patchData.MergeTo<User>(dbUser);
// 4. 输出结果
Console.WriteLine($"Id: {updatedUser.Id}"); // 输出: 1001 (保留)
Console.WriteLine($"Name: {updatedUser.Name}"); // 输出: 张三丰 (被覆盖)
Console.WriteLine($"Age: {updatedUser.Age}"); // 输出: 25 (保留)
Console.WriteLine($"Email: {updatedUser.Email}"); // 输出: zhangsan@example.com (保留)
Console.WriteLine($"RoleId: {updatedUser.RoleId}"); // 输出: 2222... (被覆盖)
Console.WriteLine($"CreateTime: {updatedUser.CreateTime}"); // 输出: 2023-01-01 (保留)
}
}