📚 **严老师 Day 6:反射与元数据(CLR Metadata + Reflection AP…
5推送消息数
13925字符数
2026-08-05教学日期
<!-- message_id: om_x100b681399465d00b04cee02b867897 -->
📚 **严老师 Day 6:反射与元数据(CLR Metadata + Reflection API + 现代替代方案)**
早上好,主人。今天我们直入 .NET 运行时底层——**反射**和它依赖的 **CLR 元数据**。这是 Phase 1 的第 6 个知识点,也是 async/await、LINQ 之后,资深 C# 必须掌握的"运行时类型系统"核心。
1. 今日知识点:反射与元数据
是什么
**CLR 元数据(Metadata)** 是描述程序集自身的二进制数据,由编译器在生成 PE 文件时与 IL 代码一起写入。位于 PE 文件的特定段,结构为多张表(TypeDef / MethodDef / FieldDef / Param / Property / Event / AssemblyRef / TypeRef 等 **40+ 张**),表与表之间通过 **RID(Record ID)或 token** 互相引用。
**反射(Reflection)** = 读取这些元数据表的运行时 API(System.Reflection / System.Reflection.Metadata)。换句话说:
- **元数据 = 数据**(描述程序集"是什么")
- **反射 = API**(查询并操作这些数据)
CLR 头(CIL 头 / CLI Header)描述了元数据表的位置和布局,运行时通过它把类型信息装入方法表(MethodTable)。
💡 一个常见误解:"元数据就是反射"——错。元数据是 PE 文件里的二进制表,反射是遍历这些表并构造托管对象的 API(Type / MethodInfo / PropertyInfo)。二者是"数据"和"访问器"的关系。
为什么
没有反射,很多企业级框架根本不存在:
- **依赖注入容器**:Microsoft.Extensions.DependencyInjection 扫描 services.AddSingleton<TInterface, TImpl>() 时,通过反射创建实例
- **ORM**:EF Core 的 DbSet<T>.Find(primaryKey) 反射获取主键列
- **AOP 切面**:Castle.DynamicProxy / DispatchProxy 生成代理类时反射获取方法签名
- **序列化器**:System.Text.Json 反射读取 [JsonPropertyName] 和构造函数
- **MVVM**:CommunityToolkit.Mvvm 源生成器分析 [ObservableProperty] 字段生成 INotifyPropertyChanged 代码
可以说,**反射是 .NET 框架生态的"动态地基"**——所有"魔法"都建立在它之上。
怎么用(一):基础反射
// 1. Type 三种获取方式
Type t1 = typeof(string); // 编译期已知类型
Type t2 = "hello".GetType(); // 实例获取
Type t3 = Type.GetType("System.String"); // 程序集限定名字符串查找
// 2. 反射调用方法(注意装箱性能开销)
MethodInfo method = typeof(Math).GetMethod("Abs", new[] { typeof(int) });
int result = (int)method.Invoke(null, new object[] { -42 }); // 装箱 -42 到 object[]
// 3. 反射读写属性
PropertyInfo prop = typeof(Person).GetProperty("Name");
string name = (string)prop.GetValue(person);
prop.SetValue(person, "Alice");
// 4. 反射创建实例
object instance = Activator.CreateInstance(typeof(Person));
这是最基础的反反射——但**性能差、GC 压力大**。下面看怎么优化。
继续听下条,我会讲性能优化、常见误区、关联知识。
---
<!-- message_id: om_x100b681396ef98a0b3e889eb0c3053b -->
📚 **怎么用(二):从 MethodInfo.Invoke 到 Delegate.CreateDelegate 的性能飞跃**
资深 C# 必修课——反射调用的 3 种性能层级:
// ❌ 第一层(慢):MethodInfo.Invoke
// 每次调用都装箱所有值类型 + 数组分配 + 反射虚调用
MethodInfo method = typeof(Person).GetMethod("Greet");
string r1 = (string)method.Invoke(person, new object[] { "Alice" });
// 性能基准:~1000-2000 ns / 次
// ✅ 第二层(快):Delegate.CreateDelegate
// 一次性创建强类型委托,缓存后接近原生调用
MethodInfo method = typeof(Person).GetMethod("Greet");
Func<Person, string, string> greet = (Func<Person, string, string>)
Delegate.CreateDelegate(typeof(Func<Person, string, string>), person, method);
string r2 = greet(person, "Alice");
// 性能基准:~15 ns / 次(首次 ~5-10 μs 创建委托)
// ✅ 第三层(最快):Expression.Compile
// 生成 IL 动态方法,JIT 完全内联展开
ParameterExpression target = Expression.Parameter(typeof(Person), "p");
ParameterExpression nameArg = Expression.Parameter(typeof(string), "n");
MethodCallExpression call = Expression.Call(target, method, nameArg);
Func<Person, string, string> greetExpr =
Expression.Lambda<Func<Person, string, string>>(call, target, nameArg).Compile();
string r3 = greetExpr(person, "Alice");
// 性能基准:~20 ns / 次(首次 ~50-100 μs 编译)
**IL 层本质区别**:
MethodInfo.Invoke 编译为:
callvirt instance object [System.Reflection]System.Reflection.MethodBase::Invoke(object, object[])
背后要做 5 件事:① 装箱所有值类型参数 ② 反射检查方法签名 ③ 通过 RuntimeMethodHandle 找方法入口 ④ 反汇编 object[] 还原栈帧 ⑤ 调用 + 装箱返回值。
而 Delegate.CreateDelegate 内部用 Reflection.Emit 或 Expression.Compile **生成新的动态方法**,签名匹配强类型委托,调用时等价于静态方法调用——JIT 能完全内联展开,参数从栈寄存器直接传入(**无装箱**)。
怎么用(三):.NET 7+ 源生成 + AOT 注解
传统反射的最大问题:**AOT 剪裁器无法追踪反射可达性**。比如 Activator.CreateInstance(typeof(T))——剪裁器看不到 T 被使用,会把整个类型删掉,运行时 MissingMethodException。
.NET 7 引入 [DynamicallyAccessedMembers] 解决:
public JsonSerializerOptions AddContext<TContext>(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TContext context)
where TContext : class
{
// Roslyn 编译期读懂这个注解,写入 PE 元数据
// IL Trimmer 读到注解 → 保留 TContext 的 PublicMethods
// ...
}
本质是**把"运行时反射需求"前移到编译期**,让 AOT 工具链能正确处理。
怎么用(四):只读元数据的轻量方案 —— MetadataLoadContext
如果只想扫描程序集类型而不执行代码,用 MetadataLoadContext(不会把程序集加载到主 ALC,避免强引用泄漏):
using System.Reflection.PortableExecutable;
using System.Reflection.Metadata;
using FileStream fs = File.OpenRead("Plugin.dll");
using PEReader peReader = new(fs);
MetadataReader mr = peReader.GetMetadataReader();
foreach (TypeDefinitionHandle typeDefHandle in mr.TypeDefinitions)
{
TypeDefinition typeDef = mr.GetTypeDefinition(typeDefHandle);
Console.WriteLine(mr.GetString(typeDef.Name));
}
这是插件系统、扫描器、依赖分析工具的标准做法。
常见误区(5 个)
1. **MethodInfo.Invoke 装箱值类型**——参数数组 object[] 强制装箱 int/enum/struct,GC 压力大
2. **不缓存 MemberInfo**——GetProperties() / GetMethods() 每次遍历元数据表,慢且 GC 多
3. **Assembly.LoadFrom 强引用泄漏**——被加载的程序集永远不会被卸载(直到进程退出),应优先用 AssemblyLoadContext + WeakReference
4. **反射破坏 AOT 剪裁**——Native AOT 不会为反射用到的类型生成元数据,需 [DynamicallyAccessedMembers] 或源生成
5. **BindingFlags 漏写**——GetMethod("Foo") 只查 public 实例方法,internal / static / protected 会返回 null,运行时才暴露
关联知识
- **依赖属性(DP)** 的 Register 内部用 DependencyPropertyDescriptor 元数据表
- **MVVM 源生成**:CommunityToolkit.Mvvm.SourceGenerators 在**编译期**生成 INotifyPropertyChanged 代码——零反射
- **AOT 编译**:.NET 8 PublishAot=true 后反射代码需 [RequiresDynamicCode] / [RequiresUnreferencedCode] 标注
- **表达式树**(昨天内容):Expression.Compile 内部其实**也走反射 + IL 生成**,但只编译一次,缓存后接近原生
下条发 3 道递进面试题。
---
<!-- message_id: om_x100b681396a944a4b303974e5c1a22e -->
📚 **3 道递进面试题(从记忆到应用)**
Q1(基础概念)
**请简述 CLR 元数据(Metadata)的本质与作用。它与 .NET 程序集的可执行代码(IL)是什么关系?为什么说元数据是"自描述"的?**
Q2(原理与辨析)
**MethodInfo.Invoke(obj, args) 与 Delegate.CreateDelegate(method, obj)(args) 在 IL 层和性能上有什么本质区别?为什么后者能达到接近原生调用的性能?.NET 7+ 引入的 [DynamicallyAccessedMembers] 注解又解决了反射的什么根本问题?**
Q3(实战与深度)
WPF 企业级应用经常需要一个**通用属性映射器**——给定 Source / Target 对象和 Dictionary<string, string> 字段映射规则(如 "UserName" -> "Name"),把源对象的属性值拷贝到目标对象的对应属性上。
**请设计一个满足以下需求的实现**:
1. 高性能(>10 万次/秒)
2. 类型安全
3. 支持嵌套属性(如 "Address.Street" -> "HomeStreet")
4. 兼容 AOT 剪裁(trim-friendly)
5. **至少给出 3 种备选方案的性能/适用场景对比**,并说明最终选择哪个
下条发全面复盘讲解(Q1 + Q2)。
---
<!-- message_id: om_x100b6813963574a4b2bbedb5842443a -->
📚 **全面复盘(一):Q1 + Q2 详解**
───
Q1 参考答案
**答案要点**:
1. **CLR 元数据 = 程序集自带的二进制表集合**(TypeDef / MethodDef / FieldDef / Param 等 40+ 张)
2. **本质**:描述程序集内类型、字段、方法、属性、事件、引用等所有**声明性信息**
3. **与 IL 的关系**:元数据**与 IL 一起**写入 PE 文件的 .text 段;IL 中每条指令操作数(如 callvirt 0A000003)的 token 解析**依赖**元数据表
4. **自描述**:PE 文件不再需要额外的 .h / .idl 头文件,所有类型信息都在文件内部——IDE 智能提示、运行时类型发现、跨语言互操作全都依赖它
**深度解析**:
为什么是"自描述"?对比 C++:编译器生成的 .dll 只有指令,**没有类型信息**;想查 MyClass::Method(int, double) 的签名,要么看头文件,要么用 dumpbin /headers。
而 .NET 的 .dll 完全自包含:
• ildasm MyLib.dll 能反编译出**完整**的 C# 代码
• 跨程序集引用(AssemblyRef 表)也记录在元数据中
• 泛型、接口、attribute、事件都在元数据里有专属表
运行时机制:
• JIT 编译方法时,先从元数据表查出方法签名、参数类型、局部变量类型
• 类型转换 isinst / castclass 通过元数据查继承链
• 反射 API(Type.GetMethods())本质就是**遍历元数据表并构造托管对象**
**易错点提醒**:
• ❌ "元数据就是反射"——元数据是数据,反射是 API
• ❌ "IL 调用通过名字"——IL 用 **token**(4 字节,3 字节表索引 + 1 字节表标识)
• ❌ "元数据可以删除"——元数据是 PE 文件结构一部分,无法剥离(除非用 ILLink 实验性剥离)
───
Q2 参考答案
**Invoke vs CreateDelegate 本质区别**:
| 维度 | MethodInfo.Invoke | Delegate.CreateDelegate |
| ------ | --------------------------------- | ----------------------- |
| 调用机制 | 反射虚调用(RuntimeMethodHandle.Invoke) | 直接生成强类型委托 |
| 参数处理 | object[] 装箱(值类型 100% 装箱) | 强类型参数栈传递(零装箱) |
| 每次开销 | ~1000-2000 ns | ~10-50 ns(接近原生) |
| 首次开销 | 低 | 高(生成委托) |
| AOT 友好 | ❌ 需保留大量元数据 | ⚠️ 仍需保留被调用方法元数据 |
**为什么 CreateDelegate 快**:
• 生成强类型 Func<T1, T2, ...> 委托对象,调用时直接走委托 → 方法指针,**不经过反射层**
• 委托的 Invoke() 在 JIT 编译期内联展开,参数从栈寄存器直接传入(无装箱)
**[DynamicallyAccessedMembers] 解决的根本问题**:
• **AOT 剪裁器无法通过反射追踪可达性**
• Activator.CreateInstance(typeof(T))——剪裁器看不到 T 被使用,会把 T 整个删除,导致运行时 MissingMethodException
• [DynamicallyAccessedMembers] 告诉剪裁器:"我要在运行时反射用到这个类型的某些成员,请保留它们"
• 本质是**把"运行时反射需求"前移到编译期**,让 AOT 工具链能正确处理
**深度解析**:
Delegate.CreateDelegate 内部用 Reflection.Emit 或 Expression.Compile 生成**新的动态方法**,签名匹配强类型委托。调用时等价于一次静态方法调用,JIT 能完全内联。
**性能基准**(典型 64-bit .NET 8,循环 100 万次):
• 原生调用:~5 ns
• Delegate.CreateDelegate 缓存后:~15 ns
• Expression.Compile 缓存后:~20 ns
• MethodInfo.Invoke:~1000 ns
[DynamicallyAccessedMembers] 是 **Roslyn + IL 链接器的协作**:
1. Roslyn 编译时读懂注解,记录到 PE 元数据
2. IL 链接器(IL Trimmer)读 PE 元数据,发现 T 的某些成员被"动态访问",保留这些成员
**易错点提醒**:
• ❌ "CreateDelegate 总是更快"——首次创建慢(~1-10 μs),**必须缓存**
• ❌ "DynamicallyAccessedMembers 是性能优化"——它是**正确性**保证,不是性能优化(性能反而可能下降,因为剪裁掉的代码更多)
• ❌ "元数据可以动态生成"——Reflection.Emit 可以动态生成**类型和方法**,但仍依赖 CLR 元数据机制
下条发 Q3 实战详解(3 种方案 + 代码 + 对比 + 易错点)。
---
<!-- message_id: om_x100b681397040ca0b3289eb5cdbd734 -->
📚 **全面复盘(二):Q3 实战与深度(通用属性映射器)**
───
方案 1:纯反射版(Baseline)
public static class Mapper
{
private static readonly ConcurrentDictionary<(Type, Type), Action<object, object>> _cache = new();
public static void Map<TSource, TTarget>(TSource src, TTarget tgt)
{
var key = (typeof(TSource), typeof(TTarget));
if (!_cache.TryGetValue(key, out var mapper))
{
mapper = BuildMapper<TSource, TTarget>();
_cache[key] = mapper;
}
mapper(src, tgt);
}
private static Action<object, object> BuildMapper<TSource, TTarget>()
{
var srcParam = Expression.Parameter(typeof(TSource), "src");
var tgtParam = Expression.Parameter(typeof(TTarget), "tgt");
var assignments = new List<Expression>();
foreach (var srcProp in typeof(TSource).GetProperties())
{
var tgtProp = typeof(TTarget).GetProperty(srcProp.Name);
if (tgtProp != null && tgtProp.CanWrite)
{
var srcAccess = Expression.Property(srcParam, srcProp);
var tgtAccess = Expression.Property(tgtParam, tgtProp);
assignments.Add(Expression.Assign(tgtAccess, srcAccess));
}
}
var block = Expression.Block(assignments);
return Expression.Lambda<Action<object, object>>(block, srcParam, tgtParam).Compile();
}
}
**性能**:~50 万次/秒(首次编译慢)
**问题**:字段映射硬编码(同名匹配),不支持 Dictionary<string, string> 规则,不支持嵌套属性。
───
方案 2:表达式树 + 规则驱动(推荐企业级)
// 嵌套属性处理:split "Address.Street" → ["Address", "Street"]
Expression BuildAccess(ParameterExpression param, string path)
{
Expression current = param;
foreach (var segment in path.Split('.'))
{
var prop = current.Type.GetProperty(segment);
if (prop == null)
throw new ArgumentException($"Property {segment} not found on {current.Type.Name}");
current = Expression.Property(current, prop);
}
return current;
}
// 主调用
public static void Map<TSource, TTarget>(TSource src, TTarget tgt, Dictionary<string, string> rules)
{
var srcParam = Expression.Parameter(typeof(TSource), "src");
var tgtParam = Expression.Parameter(typeof(TTarget), "tgt");
var assignments = new List<Expression>();
foreach (var (srcPath, tgtName) in rules)
{
var srcAccess = BuildAccess(srcParam, srcPath); // 递归构造嵌套
var tgtProp = typeof(TTarget).GetProperty(tgtName);
if (tgtProp != null && tgtProp.CanWrite)
{
var tgtAccess = Expression.Property(tgtParam, tgtProp);
assignments.Add(Expression.Assign(tgtAccess, srcAccess));
}
}
var block = Expression.Block(assignments);
var lambda = Expression.Lambda<Action<TSource, TTarget>>(block, srcParam, tgtParam).Compile();
// 缓存 + 调用(生产环境加锁或 Memoization)
lambda(src, tgt);
}
**性能**:~30-50 万次/秒
**AOT 兼容**:⚠️ 中等——GetProperty 仍需保留元数据,需 [DynamicallyAccessedMembers] 注解
───
方案 3:源生成(trim-friendly 终极方案)
[Mapper(typeof(User), typeof(UserDto))]
public partial class UserMapper
{
// 源生成器在编译期扫描 [Mapper] 特性
// 生成强类型 Map 方法(零反射、零表达式树)
public static partial UserDto MapToDto(User source, Dictionary<string, string> rules);
}
源生成器分析 User / UserDto 的所有属性,编译期生成最优代码。
**性能**:~1000 万次/秒(接近手写)
**AOT 兼容**:✅ 完全兼容
───
3 种方案对比
| 维度 | 方案 1:纯反射 | 方案 2:表达式树 + 规则 | 方案 3:源生成 |
| ------ | ------------------- | --------------------------------- | ---------- |
| 性能 | ~50 万次/秒 | ~30-50 万次/秒 | ~1000 万次/秒 |
| 首次开销 | 中(反射 GetProperties) | 高(Expression.Compile) | 零(编译期) |
| AOT 兼容 | ❌ 需大量元数据 | ⚠️ 需 [DynamicallyAccessedMembers] | ✅ 完全 |
| 类型安全 | ⚠️ 运行期检查 | ⚠️ 运行期检查 | ✅ 编译期 |
| 嵌套属性 | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
| 动态规则 | ❌ 编译期固化 | ✅ 运行时规则 | ❌ 需重新生成 |
| 适用场景 | 一次性脚本、内部工具 | 企业级动态配置 | 性能敏感的固定映射 |
**最终选择**:
• **快速原型 / 简单映射** → 方案 1(AutoMapper 早期风格)
• **WPF / 企业级 / 需要动态规则** → **方案 2(推荐,配 AOT 注解)**
• **极致性能 / AOT 强制** → 方案 3(类似 Mapperly、CommunityToolkit.Mvvm)
💡 业界现状:AutoMapper 已逐渐式微,Mapster 和 Mapperly 是当前主流。Mapperly 就是源生成方案的代表——性能比 AutoMapper 快 5-10 倍,零反射。
───
Q3 易错点提醒
1. ❌ **每次 Map 都重新编译**——必须缓存 lambda / 委托到 ConcurrentDictionary
2. ❌ **表达式树未考虑线程安全**——Expression.Compile() 本身线程安全,但编译结果共享 OK
3. ❌ **嵌套属性忘了 null 检查**——Address 可能为 null,应在生成代码时插入空检查 (Address != null ? Address.Street : null)
4. ❌ **AOT 编译时用 typeof(T) 但 T 被剪掉**——需 [DynamicallyAccessedMembers] 或显式 Type.GetType("FullName")
5. ❌ **值类型装箱**——如果 TSource 是 struct,反射路径会装箱 src,破坏性能。方案 3 源生成可避免
───
4. 扩展思考(引导问题)
5. **如果你要为社区写一个开源的"高性能 Mapper"库,你会选择哪个方案?调研 AutoMapper、Mapster、Mapperly 的实现差异和性能基准**
6. **AOT 时代,反射会消亡吗?源生成 + 编译期元数据能完全替代吗?思考 Reflection.Emit 和 Source Generators 的边界**
7. **如果让你重新设计 CLR 元数据表,会怎么优化?当前 40+ 张表是否合理?思考性能(解析速度)和元数据大小的权衡**
───
主人,今日教学完成 ✅。请回答上面 3 道面试题(可以只回答 Q1 + Q2,或全答)。我会根据你的反馈:
• 评估掌握度(完全 / 部分 / 卡住)
• 更新 MEMORY-csharp-wpf.md
• 决定 Day 7 是推进 Phase 1 第 7 个知识点(**Span / Memory**),还是降维拆解本主题的卡点
期待你的回复 📚