C# 技巧:优雅实现对象的“局部更新”与“字段合并”

背景

在实际开发中,我们经常遇到局部更新(Patch Update)的场景。比如前端只传了用户修改过的字段,而后端需要把这些非空字段更新到原有的数据模型上,而未传的字段保持原样。

如果使用 AutoMapper 直接映射,未传的字段会被覆盖成 null。为了解决这个问题,我们可以编写一个通用的**字段合并(Merge)**扩展方法。

优化后的代码实现

这里对代码进行了几个关键优化:

  1. 去掉对特定 StringExtension 的依赖,内置更健壮的判空逻辑(兼容字符串的空格、值类型的默认值)。
  2. 修正了 Nullable 类型的判断逻辑 ,避免引发 InvalidCastException。
  3. 增强了类型转换 ,使用 TypeDescriptor 支持 string 到 Guid、Enum 等复杂转换。
  4. 封装为扩展方法,调用更优雅。
  5. 加入属性缓存与过滤,避免遍历索引器或不必要的只读属性。
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 (保留)
    }
}
相关推荐
心祭丫丫14 分钟前
【每天一个 Python 小案例17】Python 实现通讯录
开发语言·人工智能·笔记·python·学习
露天赏雪40 分钟前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
yeccheng3 小时前
MySQL 索引超详解:从底层 B+ 树到面试高频考点
java·开发语言·jvm·mysql·面试
联犀3 小时前
纯 Go 的 CAD 开源库 go-cad:直接解析 DWG/DXF,现在也能导出 PDF
开发语言·golang·pdf
zjxtxdy3 小时前
C++面向对象程序开发-类与对象
开发语言·c++
淡海水5 小时前
09-04-内存-Unity原生对象生命周期
unity·c#·游戏引擎·gc
qq_406981036 小时前
c++拷贝构造函数
开发语言·c++
爱喝雪碧的可乐6 小时前
MATLAB
开发语言·算法·matlab
H.莓飛8 小时前
【C++】【STL】vector_OJ题
linux·服务器·开发语言·数据结构·c++·visual studio
吴可可1238 小时前
富怡CAD二次开发指南
c#