在 ORM、JSON 序列化、对象映射等场景中,我们经常需要动态读写对象属性。传统反射方案虽然灵活,但性能堪忧。本文将从 SwitchData 项目的真实代码出发,深入讲解如何利用 表达式树(Expression Tree)编译 技术,把反射式的属性访问编译成原生委托,实现接近直接调用的性能。

一、问题背景:反射访问的性能瓶颈

先看一个典型的 ORM 映射场景:从数据库 IDataReader 读取数据,填充到实体对象。最直接的写法是使用反射:

csharp // 传统反射方式 —— 性能较差 public static void SetValue<T>(T entity, string propertyName, object value) { PropertyInfo prop = typeof(T).GetProperty(propertyName); prop.SetValue(entity, value); }

反射的问题在于: - GetProperty 每次都在元数据中查找,开销大 - SetValue 内部涉及运行时类型检查、装箱拆箱 - 没有任何 JIT 优化机会

Benchmark 显示,反射 SetValue 的速度大约是直接调用 entity.Name = value 的 1/100 ~ 1/1000。当需要映射上百条记录、几十万个属性时,这个差距会被放大到不可接受。

二、核心思路:用表达式树编译委托

解决方案的核心思想很简单:在首次访问某个类型的某个属性时,用表达式树构建一段”直接赋值”的代码,编译成委托缓存起来,后续调用直接走委托

首次访问 PropertyInfo | v 构建 Expression Tree | v Expression.Compile 编译 | v 委托缓存到 ConcurrentDictionary | v 后续调用直接从缓存取委托

表达式树的优势在于它能被 JIT 编译成与手写代码等价的 IL,性能几乎与直接调用一致。

三、实战代码:从零构建 Getter/Setter

以下代码来自 SwitchData 项目的 MetadataCache 类,是一个完整可用的生产级实现。

3.1 Getter 编译:属性读取委托

目标:将 PropertyInfo.GetValue(obj) 编译成 Func<object, object>。

`csharp using System.Linq.Expressions;

/// /// 编译属性 Getter 委托 ///

private static Func<object, object> CreateGetter(PropertyInfo property) { // 参数:object obj ParameterExpression instance = Expression.Parameter(typeof(object), “obj”);

// 转型:(T)obj
UnaryExpression cast = Expression.Convert(instance, property.DeclaringType);

// 访问属性:((T)obj).Property
MemberExpression propertyAccess = Expression.Property(cast, property);

// 装箱:object 是引用类型或值类型都要转成 object
UnaryExpression box = Expression.Convert(propertyAccess, typeof(object));

// 构建 Lambda:obj => (object)((T)obj).Property
Expression<Func<object, object>> lambda =
    Expression.Lambda<Func<object, object>>(box, instance);

return lambda.Compile();

} `

来看这段代码做了什么。假设实体类型是 User,属性是 Name(string 类型),那么构建出的表达式树等价于:

csharp Func<object, object> getter = (obj) => (object)((User)obj).Name;

这就是一段可被 JIT 完全优化的原生代码——没有反射查找,没有运行时类型检查,就是一次类型转换 + 一次属性访问 + 一次装箱(如果是值类型)。

3.2 Setter 编译:属性写入委托

Setter 稍微复杂一点,涉及到写入操作。目标:将 PropertyInfo.SetValue(obj, value) 编译成 Action<object, object>。

`csharp /// /// 编译属性 Setter 委托 ///

private static Action<object, object> CreateSetter(PropertyInfo property) { // init-only 属性(C# 9 init 关键字)不能写 if (!property.CanWrite || IsInitOnly(property)) { return (, ) => { }; // 返回空操作委托 }

// 参数:object obj, object value
ParameterExpression instance = Expression.Parameter(typeof(object), "obj");
ParameterExpression value = Expression.Parameter(typeof(object), "value");

// 转型:(T)obj
UnaryExpression instanceCast = Expression.Convert(instance, property.DeclaringType);

// 属性访问:((T)obj).Property
MemberExpression propertyAccess = Expression.Property(instanceCast, property);

// 转型:(TProperty)value
UnaryExpression valueCast = Expression.Convert(value, property.PropertyType);

// 赋值:((T)obj).Property = (TProperty)value
BinaryExpression assign = Expression.Assign(propertyAccess, valueCast);

// 构建 Lambda:(obj, value) => ((T)obj).Property = (TProperty)value
Expression<Action<object, object>> lambda =
    Expression.Lambda<Action<object, object>>(assign, instance, value);

return lambda.Compile();

}

/// /// 检测属性是否是 init-only ///

private static bool IsInitOnly(PropertyInfo property) { MethodInfo setter = property.SetMethod; if (setter == null) return false;

// init-only setter 的 ReturnParameter 带有 IsExternalInit 修饰符
return setter.ReturnParameter
    .GetRequiredCustomModifiers()
    .Contains(typeof(System.Runtime.CompilerServices.IsExternalInit));

} `

Setter 里有几个要点:

  1. Expression.Assign 是表达式树中唯一能构建赋值操作的 API,它的 Left 必须是可写的表达式(属性访问、字段访问、索引器)
  2. init-only 检测:C# 9 的 init 关键字生成的 setter 不能被普通委托调用,必须跳过
  3. 同样,最终生成的代码等价于手写:((User)obj).Name = (string)value

3.3 元数据结构体设计

仅仅编译委托还不够,我们需要一个结构体来承载完整的字段元数据:

`csharp public sealed class ColumnMetadata { // 属性名(C# 属性) public required string PropertyName { get; init; }

// 字段名(数据库列)
public required string ColumnName { get; init; }

// 属性类型
public required Type PropertyType { get; init; }

// 数据库类型
public required DbType DbType { get; init; }

// 是否可空
public required bool IsNullable { get; init; }

// 是否主键
public required bool IsPrimaryKey { get; init; }

// 是否自增长
public bool IsIdentity { get; init; }

// Getter 委托:Func<object, object>
public required Func<object, object> Getter { get; init; }

// Setter 委托:Action<object, object>
public required Action<object, object> Setter { get; init; }

} `

3.4 缓存设计:ConcurrentDictionary + 单例初始化

最后,用 ConcurrentDictionary 按类型缓存元数据,确保线程安全且只编译一次:

`csharp public static class MetadataCache { private static readonly ConcurrentDictionary<Type, TableMetadata> Cache = new();

public static TableMetadata GetTableMetadata(Type type)
{
    // GetOrAdd 保证多线程下只编译一次
    return Cache.GetOrAdd(type, CreateMetadata);
}

private static TableMetadata CreateMetadata(Type type)
{
    // 读取 DbTable / DbColumn 特性
    DbTableAttribute tableAttr = type.GetCustomAttribute<DbTableAttribute>();
    List<ColumnMetadata> columns = [];

    foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
    {
        DbColumnAttribute colAttr = property.GetCustomAttribute<DbColumnAttribute>();
        if (colAttr == null) continue; // 无特性跳过

        columns.Add(new ColumnMetadata
        {
            PropertyName = property.Name,
            ColumnName = colAttr.Name,
            PropertyType = property.PropertyType,
            DbType = Database.ConvertToDbType(property.PropertyType),
            IsNullable = property.PropertyType.IsNullable(),
            IsPrimaryKey = property.IsDefined(typeof(DbPrimaryKeyAttribute)),
            IsIdentity = property.IsDefined(typeof(DbIdentityAttribute)),
            Getter = CreateGetter(property),  // 编译
            Setter = CreateSetter(property)   // 编译
        });
    }

    return new TableMetadata
    {
        Type = type,
        Columns = columns,
        PropertyMap = columns.ToDictionary(x => x.PropertyName, StringComparer.OrdinalIgnoreCase),
        ColumnMap = columns.ToDictionary(x => x.ColumnName, StringComparer.OrdinalIgnoreCase)
    };
}

} `

四、实际使用:ORM 对象映射

有了 Getter/Setter 委托,对象映射就变得极其高效。以下是 SwitchData 中 ObjectMapper.SetFrom 方法的实现:

`csharp public static T SetFrom(T entity, IDataRecord record) where T : class, new() { // 从缓存获取预编译元数据 TableMetadata metadata = MetadataCache.GetTableMetadata();

for (int i = 0; i < record.FieldCount; i++)
{
    string columnName = record.GetName(i);
    object value = record.GetValue(i);

    // 从 ColumnMap 找到对应的 ColumnMetadata,直接调用 Setter 委托
    if (metadata.ColumnMap.TryGetValue(columnName, out ColumnMetadata column))
    {
        if (value is DBNull)
        {
            if (!column.IsNullable)
                throw new InvalidOperationException($"字段 {column.ColumnName} 不允许为空");
            column.Setter(entity, null);
        }
        else if (!column.PropertyType.IsInstanceOfType(value))
        {
            // 类型不匹配时做一次 Convert
            column.Setter(entity, Convert.ChangeType(value, column.PropertyType));
        }
        else
        {
            column.Setter(entity, value);
        }
    }
}

// 回调钩子
if (entity is IDbObject callback) callback.OnLoaded();

return entity;

} `

整个映射过程中,没有一次反射调用。属性访问全部走预编译的委托。

五、性能对比

用 Benchmark 验证一下。假设有一个 10 属性的 User 实体,做 1000000 次属性读写:

方案 100万次 SetValue 100万次 GetValue 特点
直接调用 user.Name = value ~20ms ~15ms 无反射,最快
表达式树编译委托 ~25ms ~20ms 接近直接调用
反射 prop.SetValue ~2000ms ~1200ms 慢 80-100 倍
FastMember 库 ~40ms ~35ms 第三方库,较快

表达式树编译的委托性能几乎与直接调用持平,这是因为 JIT 把它当成普通方法调用优化了。

六、常见坑点与避坑指南

6.1 init-only 属性检测不完整

只判断 !property.CanWrite 不够——C# 9 的 init 关键字生成的 setter 是 CanWrite == true 的,但在外部代码中无法赋值。正确检测方式:

`csharp // 错误:漏了 init-only if (!property.CanWrite) return (, ) => { };

// 正确:检测 IsExternalInit 修饰符 MethodInfo setter = property.SetMethod; bool isInitOnly = setter?.ReturnParameter .GetRequiredCustomModifiers() .Contains(typeof(IsExternalInit)) ?? false; `

6.2 struct 值类型的装箱

Getter 返回 object 时,如果属性是值类型(int、ool 等),表达式树会自动插入 Convert 产生装箱。这是必要的——因为委托签名固定了返回 object。

csharp // int 属性的 getter 实际上是: (int value) => (object)value // 装箱

如果需要极致性能,可以为值类型单独编译非装箱的委托。但在大多数 ORM 场景中,这种装箱开销完全可以接受。

6.3 ConcurrentDictionary 内存泄漏

ConcurrentDictionary<Type, TableMetadata> 用 Type 做 key 是安全的,因为 CLR 保证每个 AppDomain 中 Type 实例不会被回收。但要注意:

  • 不要用字符串类型名做 key(不同 Assembly 可能重名)
  • 不要用 AssemblyQualifiedName 做 key(会导致同一类型被多次缓存)

6.4 泛型类型的元数据隔离

List 和 List 是不同的 Type,会分别编译、分别缓存。这是正确行为,因为它们的属性签名不同。但如果有大量泛型实例化,缓存会膨胀。

七、进阶拓展

SwitchData 还把表达式树编译拓展到了更多场景。比如 Database.ConvertToDbType 用 switch expression 做类型映射:

`csharp public static DbType ConvertToDbType(Type type) { type = Nullable.GetUnderlyingType(type) ?? type; if (type.IsEnum) type = Enum.GetUnderlyingType(type);

return type switch
{
    Type t when t == typeof(int) => DbType.Int32,
    Type t when t == typeof(string) => DbType.String,
    Type t when t == typeof(DateTime) => DbType.DateTime,
    _ => throw new NotSupportedException()
};

} `

以及 ObjectMapper 里 IsInstanceOfType + Convert.ChangeType 的容错组合,配合预编译 Setter,就能在保持高性能的同时应对数据库类型与 CLR 类型的细微差异。

八、总结

表达式树编译属性访问器是一个经典的 “用空间换时间” 优化策略:

  • 首次成本:扫描特性、构建表达式树、编译委托(毫秒级)
  • 持续收益:所有后续属性访问走 JIT 优化后的原生代码,无反射开销
  • 实现复杂度:中等,核心代码不到 100 行

在 ORM、序列化、动态配置等场景中,这个模式几乎是标配。理解它不仅能帮你写出高性能的动态代码,更能深入体会 .NET 表达式树和编译器的协作方式。