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 (保留)
    }
}
相关推荐
灯澜忆梦1 小时前
【面向对象编程c++】| 指针与自定义数据类型
开发语言·c++·算法
Brilliantwxx1 小时前
【Linux】 自主shell编写巩固复习篇
linux·运维·服务器·开发语言
十年Java程序媛1 小时前
Java 多线程基础与锁机制详解|进程线程、锁升级、synchronized 与 Lock 对比
java·开发语言·spring boot
弯_弯1 小时前
FDE 的工程能力画像:数据接入、PoC 与可复用资产的落地口径
java·服务器·开发语言
雾沉川2 小时前
Visual C++运行库合集 v105.0 安装教程
开发语言·c++·dll
niucloud-admin2 小时前
NIUCLOUD LITE AI开发文档—模块开发:支付管理
开发语言
dear_bi_MyOnly3 小时前
栈和队列:从原理到应用全解析
c语言·开发语言·c++·学习·分类
Want5953 小时前
Python大雪纷飞①
开发语言·python·数学建模
yujunl3 小时前
重写了一个BOM查询的脚本
开发语言