接手一个商城项目的时候,业务方在周五下班前丢给我一句话:“下周要接两个新的配送渠道,物流模块做成可插拔的。”我当时的第一个反应不是去写一堆 if-else,而是知道这次必须认真用一次 .NET Core 里的反射(Reflection)了。反射这个词听起来玄,很多人把它当成“面试题里的神秘魔法”,真到项目里反而不敢用。但真正理解它的机制以后,你会发现它就是让代码在运行时“长出”新能力的常规手段:类型可以被扫描、方法可以被动态调用、特性可以变成业务规则,主流程一行不用改,就能接住未来的变化。
这篇文章的目标读者,是那些已经写过一段 C#,想理解反射到底怎么落地、性能是不是真的那么差、在 .NET Core(以及现在的 .NET 5/6/8)里能不能放心用的人。我会讲清楚它背后的原理、实用的 API 组合、完整的插件化实战,以及我踩过几次坑之后总结出的使用边界。你不需要一次性消化所有概念,重点是先跟着把能跑的代码搭出来,再去理解它为什么这样工作。
1. 先从一块“编译期不存在”的代码说起
1.1 为什么会有反射这种反常规机制
平时我们写代码,都是先有类型,再 new 一个对象,然后调用它的方法。编译器在生成 IL 的时候就已经把方法调用关系写死了,这种形态的好处是安全、明确、快,缺点是“太死板”。程序编译完那一瞬间,它能调用的范围就固定了,之后业务上想加新的处理规则、新的插件、新的渠道,你就得重新改代码、重新编译、重新发布。
反射解决的是另一个问题:当类型在编译期根本不存在,只有运行时拿到一个 DLL 名称或者一个字符串类名时,你还能不能把它加载进来、创建实例、调用方法?
答案是可以,因为 .NET 程序在编译时会把元数据(Metadata)完整地写进程序集里。元数据记录了每一个类型叫什么、有哪些属性、方法长什么样、方法的参数是什么类型。反射本质上就是一套读取这套元数据并操作的 API。你不需要在代码里提前 using 那个类型,只需要通过 Assembly 找到它,通过 Type 描述它,再通过 MethodInfo / PropertyInfo 去触碰它的成员。
我习惯用一个比喻来理解:普通调用像“写死的电话本”,代码里已经存好了某人的号码,直接打过去;反射则像拿着记事本去访问一个机构,先查这个机构有哪些部门、每层的门牌号,再敲开门找对应的人办事。后者多了一步“查找”和“解释”的过程,因此比直接调用慢,但它能处理的场景,前者根本碰不到。
1.2 .NET Core 时代反射的生存土壤
在老 .NET Framework 时代,反射也是核心能力,但那时候 AppDomain 可以随便创建和卸载,反射加载进来的程序集管理相对“放纵”。到了 .NET Core 之后,底层改动不小,名称上大家还习惯叫它 .NET Core,其实就是现在的 .NET 5+。新的运行时里程序集加载通过 AssemblyLoadContext 管理,反射本身依然可用,但需要理解几个变化。
第一,默认上下文(Default ALC)负责加载应用启动目录里的程序集,你用 Assembly.LoadFrom 或 Assembly.LoadFile 加载外部 DLL 时,要注意它到底进了哪个加载上下文,这会影响类型比较和依赖解析。第二,如果发布时开了 PublishTrimmed 或使用 Native AOT,编译器会裁剪掉那些它认为“没有用”的元数据,反射在这种环境下并不是完全自由的,该保留的东西需要提前配置。第三,.NET Core 里你可以在运行时通过 MetadataLoadContext 只读取元数据而不执行代码,这对编写分析工具特别有用。
所以严格说,反射不是某个版本的专属“魔法”,它更像是一套延续至今的运行时能力。你只要掌握核心 API,再规避几个版本差异下的坑,在 .NET Core 项目里就能稳定使用。
1.3 什么场景才值得“动”反射
反射确实强大,但不是所有地方都该无脑用。我自己的判断标准比较简单:如果这个变化点可以通过接口、泛型、依赖注入或者一个字典配置解决,就不上反射;只有变化点出现在“程序集、类型、成员”这三个层级时,反射才是最优解。
适合反射的典型场景包括插件系统、模块化框架、ORM 的实体映射、Web 框架的路由发现、单元测试框架的用例枚举、AOP 代理中对拦截方法的动态调用等等。这些场景共同的特点是:扩展方不是你当前主程序里的代码,你无法在编译期直接引用对方写的类型,但又希望对方遵守某种约定后,你的系统就能自动识别并运行它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心肌肉记忆:类型、实例、成员与方法的反射操作
2.1 拿到 Type 的三条路径和实例化姿势
反射的第一步通常都是拿到 Type 对象。三条最常见路径如下:
typeof(SomeType):编译期就知道具体类型,拿类型最直接。obj.GetType():运行期拿到某个对象的真实类型,常用于多态场景。Type.GetType("命名空间.类名, 程序集名")或assembly.GetType("命名空间.类名"):只知道字符串或已有程序集时使用。
csharp复制using System;
using System.Reflection;
Type typeA = typeof(LogisticsOrder);
Type typeB = new LogisticsOrder().GetType();
Assembly pluginAssembly = Assembly.LoadFrom("/app/plugins/YundaLogistics.dll");
Type? pluginType = pluginAssembly.GetType("Shop.Plugin.Logistics.YundaLogisticsService");
拿到 Type 以后,常规操作是用 Activator.CreateInstance 创建实例。如果需要给构造函数传参数,就传入一个 object 数组,顺序必须和构造函数参数顺序一致。.NET Core 里我也推荐过用 ConstructorInfo.Invoke 显式调用,Activator 更多是图省事,两者底层逻辑类似。
csharp复制Type? serviceType = pluginAssembly.GetType("Shop.Plugin.Logistics.YundaLogisticsService");
if (serviceType == null) return;
object? serviceInstance = Activator.CreateInstance(
serviceType,
new object[] { "配置参数一", "配置参数二" });
有个容易被忽略的细节:只给了 assembly.GetType 全名却找不到类型时,返回的是 null 而不是抛异常。我早期调试插件,经常因命名空间写错,结果程序静默执行什么都不做。后来习惯是先拿 pluginAssembly.GetTypes() 把程序集里所有类型遍历打印一遍,看到真实命名空间之后再写查找逻辑。
2.2 查找属性、字段和方法时的 BindingFlags
Type 上的 GetProperties()、GetFields()、GetMethods() 可以一口气返回所有成员。默认只查公共实例成员,但很多业务场景下你需要查静态成员、私有成员,或者父类中的成员,此时必须传入 BindingFlags 枚举。
csharp复制BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
PropertyInfo[] allProperties = serviceType.GetProperties(flags);
FieldInfo[] allFields = serviceType.GetFields(flags);
MethodInfo[] allMethods = serviceType.GetMethods(flags);
BindingFlags.Instance 和 BindingFlags.Static 至少要指定其中一个,否则查不到任何成员。这个坑几乎每个人都有:想要私有方法,但又忘了加 BindingFlags.NonPublic,代码检查半天,最后发现是过滤条件写错了,不是方法不存在。
实际工程里,我一般不直接遍历所有方法,而是先把方法名和签名打印出来做一次“侦查”,确定好目标长什么样后,再用精确的 GetMethod 去取。反射虽然叫“魔法”,但开发过程其实跟调试普通代码一样,也可以先用最简单的循环把所有元数据打出来,确认自己的预期。
2.3 动态调用方法的常见细节
拿到了 MethodInfo,调用走 Invoke。示例代码如下:
csharp复制MethodInfo? sendMethod = serviceType.GetMethod("TrySend", new Type[] { typeof(string), typeof(string) });
if (sendMethod == null) return;
object? result = sendMethod.Invoke(serviceInstance, new object[] { "SF1234567890", "北京市朝阳区某某路 1 号" });
bool sendResult = result is bool ok && ok;
这里比较容易踩坑的是方法重载。GetMethod("TrySend") 在方法存在重载时,会抛出 AmbiguousMatchException。所以需要精确匹配时,一定要传入参数类型数组,让运行库知道你要找哪个版本的 TrySend(string, string)。
另一个细节是可选参数。假如方法签名是 TrySend(string orderNo, string address, bool sync = true),你用 GetMethod 拿到 MethodInfo 后,Invoke 时只传两个参数是不会自动帮你补上默认值 true 的,必须把第三个参数显式传进去。原因是反射调用绕过了 C# 编译器的默认参数填充逻辑,它只忠实地执行 IL 层面的方法签名。
2.4 读写属性和字段
动态读属性是很多框架的刚需。比如把数据库一行数据塞给一个实体对象,你没有提前知道实体的类型,只能遍历属性然后 SetValue。
csharp复制object? instance = Activator.CreateInstance(type);
PropertyInfo? nameProp = type.GetProperty("Name");
nameProp?.SetValue(instance, "订单备注");
object? value = nameProp?.GetValue(instance);
对性能敏感的场景,直接 GetValue / SetValue 每次调用都会有装箱拆箱和参数检查,下面第 4 章会专门讲优化。但小规模配置绑定、测试桩、临时工具里,直接使用完全没问题。
值得留意:属性的 GetValue / SetValue 访问的底层是 getter/setter 方法,如果属性是只读的,SetValue 会抛 ArgumentException。所以写通用工具时,你得先检查 prop.CanWrite。另外索引器也表现为属性,GetValue 方法有一个 index 参数可以传索引值,我自己很少直接在反射里操控索引器,因为这种代码可读性太差,非要动态访问集合元素时,更愿意直接把整个对象转成 IEnumerable 再处理。
2.5 反射能“触发 Click 事件”吗
很多人问过这个问题:“我想通过反射触发 WinForms 里某个按钮的 Click 事件,怎么做?”
先说结论:技术上能测,但生产代码里要非常谨慎。C# 的事件本质是“受限的委托字段”,编译器对外只暴露 add/remove 访问器,让你用 += 和 -= 订阅或退订,不允许外部随意触发,目的是让控件自己决定什么时机触发事件。可反射恰恰能把访问限制绕过,把你的处理逻辑塞进去,也能把事件背后的委托链拿出来逐个调用。
在合法订阅场景下,用 EventInfo 就够了:
csharp复制EventInfo? clickEvent = button.GetType().GetEvent("Click");
MethodInfo handler = typeof(YourForm).GetMethod("ButtonOnClick", BindingFlags.Instance | BindingFlags.NonPublic)!;
Delegate d = Delegate.CreateDelegate(clickEvent!.EventHandlerType!, this, handler);
clickEvent.AddEventHandler(button, d);
如果只是想模拟“点击”动作来测试 UI 逻辑,WinForms 的 Button 类其实自带 button.PerformClick(),根本不需要反射。而如果你拿到的控件没有公开的触发方法,只能用反射去取私有委托字段并手动调用时,你需要明白:这事本质是在破坏封装,而且不同控件的时间存储方式差异很大,要么存在同名私有字段,要么存在 EventHandlerList 里。一旦控件内部实现变了,你的反射逻辑就崩了。我的态度是:可以用在自动化测试辅助工具中,但不要用来替代正常的业务流程。
3. 让反射价值翻倍的是“自定义特性 + 约定”
3.1 Attribute:反射给代码贴上的“解释标签”
反射单独使用已经能解决动态调用问题,但真正让它变成框架级能力的,是自定义特性(Attribute)和反射的组合。你可以在类上、方法上、属性上打一个标签,然后在程序启动时扫描这些标签,自动生成路由、自动注册服务、自动识别插件。
csharp复制[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class PluginRouteAttribute : Attribute
{
public string RoutePrefix { get; }
public PluginRouteAttribute(string routePrefix) => RoutePrefix = routePrefix;
}
解释一下:AttributeUsage 告诉编译器这个特性的使用范围是类,以及是否允许继承。构造参数会在运行反射读取时被还原。比如插件作者给自己的类写上 [PluginRoute("yunda")],主程序就能扫到这个标签,拿到 yunda 字符串作为路由前缀。
3.2 扫描程序集,构建一张自动发现的“插件注册表”
假设现在要做一个商城物流模块,扩展方式是这样:任何人写好一个物流插件 DLL 后,丢进 /plugins 目录,系统启动时自动加载这个 DLL,扫描里面所有加了 [PluginRoute] 特性的类型,并注册到内部字典里。
csharp复制public class PluginRegistry
{
private readonly Dictionary<string, Type> _routes = new(StringComparer.OrdinalIgnoreCase);
public void ScanPlugin(string assemblyPath)
{
Assembly assembly = Assembly.LoadFrom(assemblyPath);
foreach (Type type in assembly.GetTypes())
{
PluginRouteAttribute? routeAttr = type.GetCustomAttribute<PluginRouteAttribute>();
if (routeAttr != null && typeof(ILogisticsPlugin).IsAssignableFrom(type))
{
_routes[routeAttr.RoutePrefix] = type;
}
}
}
public object? CreatePlugin(string routePrefix)
{
if (_routes.TryGetValue(routePrefix, out Type? pluginType))
{
return Activator.CreateInstance(pluginType);
}
return null;
}
}
这段代码里有个易错的点:直接 assembly.GetTypes() 在目标程序集依赖不完整时,会抛出 ReflectionTypeLoadException,正确做法是捕获这个异常并读取 ex.Types 里能加载的那部分。这是一个非常经典的生产环境坑,后面第 6 章会展开说。
3.3 别把注册逻辑写死:约定优于配置
扫描特性的意义不在于省几行注册代码,而是让“主框架”和“业务插件”彻底解耦。如果每加一个渠道,都要回到主项目里加一行 services.AddSingleton<I..., ...>(),那插件机制就名存实亡。
“约定”这个词在这里很重要。你和插件开发者之间不需要把所有细节写进一个巨型配置文件,只需要约定清楚:程序集里哪些类型是要被发现的,它要实现什么接口,它身上要打什么标签。这些约束是可编程的、可检查的,普通开发者照着示例就能写插件,而主程序则在运行时扫描这些约束并加载。
有了“特性 + 接口 + 反射扫描”这套组合,新增扩展就会变得非常舒服:发布一个新的 DLL 文件,重启服务,新能力自动出现。这也是标题里“让代码活起来”最实际的一种表达:代码不再只是发布时那一份固定形态,它能在运行时识别并接纳它从未在编译期见过的成员。
4. 反射被诟病最多的是性能:如何用工程手段把差距追回来
4.1 反射慢到底慢在哪里
反射常被说成“性能毒药”,这个说法一半对一半不对。直接拿 MethodInfo.Invoke 和普通的 obj.Method() 比,性能差距确实可能上百倍。慢的原因主要有几个:
Type.GetMethod/GetProperty这类查找操作要遍历元数据,全名匹配、参数匹配都有开销,但通常只做一次,可接受。MethodInfo.Invoke每次调用要执行参数数组的包装和拆解、访问权限校验、调用约定检查,属于重复开销。- 值类型参数要装箱成
object,产生 GC 压力。 - 反射调用无法像普通虚方法那样做 JIT 内联,CPU 缓存命中率也受影响。
从数据看,反射调用一次方法经常是微秒级别,直接调用是几十纳秒级别。这个量级在低频反射里根本感觉不到,高频循环里却会立刻成为瓶颈。所以要讨论“反射性能”,必须先说清楚查询一次和使用一万次的区别。
4.2 第一招:缓存元数据查询结果
最无脑也最有效的优化是:不要每次需要时都 GetMethod / GetProperty,而是把这些元数据对象缓存在静态字典里。因为 MethodInfo / PropertyInfo 本身是安全的托管对象,查询一次之后可以反复使用,没必要重复扫描元数据。
csharp复制public static class ReflectionCache<T>
{
public static readonly IReadOnlyDictionary<string, PropertyInfo> PropertiesIndex =
typeof(T)
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.ToDictionary(p => p.Name, StringComparer.Ordinal);
}
应用层使用时,先从字典拿 PropertyInfo,再 GetValue。这一招对属性绑定的提升非常直接,因为真正昂贵的是字符串到成员信息的映射解析,缓存后相当于把动态查找变成了字典查询。
不过只缓存 PropertyInfo 还不够,GetValue 本身的动态调用开销依然存在。所以对热点代码,真正管用的是缓存“委托化之后的函数”。
4.3 第二招:用表达式树把动态调用编译成强类型委托
我们的目标是让第一次动态获取成员时的额外开销只出现一次,后续每次调用都接近原生调用。实现思路是:拿到 MethodInfo 后,不直接用它反复 Invoke,而是基于它生成一棵表达式树,Compile() 成强类型委托,缓存下来,以后每次只走委托。
给字符串属性做 getter 的示例:
csharp复制using System.Linq.Expressions;
public static class PropertyAccessorBuilder
{
public static Func<T, object> BuildGetter<T>(PropertyInfo property)
{
ParameterExpression instance = Expression.Parameter(typeof(T), "instance");
// 属性所在的类不一定是 T,但通常是 T,这里我们按最常见情况演示。
Expression propertyValue = Expression.Property(instance, property);
UnaryExpression boxed = Expression.Convert(propertyValue, typeof(object));
return Expression.Lambda<Func<T, object>>(boxed, instance).Compile();
}
}
如果你需要一个“通用 setter”,也可以构建 Action<T, object?>,内部把 object 参数拆箱成属性类型。方法调用同样可以委托化:
csharp复制public static class MethodInvokerBuilder
{
public static Func<T, object?[], object?> Build<T>(MethodInfo method)
{
ParameterExpression instance = Expression.Parameter(typeof(T), "instance");
ParameterExpression args = Expression.Parameter(typeof(object?[]), "args");
ParameterInfo[] parameters = method.GetParameters();
Expression[] callArgs = new Expression[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
callArgs[i] = Expression.Convert(
Expression.ArrayIndex(args, Expression.Constant(i)),
parameters[i].ParameterType);
}
Expression call = Expression.Call(
Expression.Convert(instance, method.DeclaringType!),
method,
callArgs);
// 方法返回 void 时需要特殊处理,让委托统一返回 null
if (method.ReturnType == typeof(void))
{
BinaryExpression body = Expression.Assign(
Expression.Variable(typeof(object), "unused"),
Expression.Constant(null));
// 更简洁的写法是直接返回 null 的 block
return Expression.Lambda<Func<T, object?[], object?>>(
Expression.Block(
Expression.Call(
Expression.Convert(instance, method.DeclaringType!),
method,
callArgs),
Expression.Constant(null)),
instance,
args).Compile();
}
UnaryExpression convertResult = Expression.Convert(call, typeof(object));
return Expression.Lambda<Func<T, object?[], object?>>(convertResult, instance, args).Compile();
}
}
这个类的作用是只编译一次:系统启动时对所有要动态调用的插件方法执行 Build,把结果放进字典;业务调用时从字典取出 Func<T, object?[], object?>,直接执行,避开了 Invoke 的重复检查与参数包装。实测下来,这种优化后的调用耗时和原生调用的差距已经非常小,尤其在插件数量固定、调用频繁的场景下性价比极高。
4.4 第三招:能不每次动态操作就别动态操作
缓存和委托化解决了“重复调用”的开销,但没有解决“你本来就不该动态调用”的问题。如果调用点可以被设计成一个接口,就应该用接口或抽象基类来承载,让插件实现接口,主程序在扫描到类型后直接转换成接口调用。反射只负责“发现类型”和“创建实例”,接口负责“后续调用”。
csharp复制public interface ILogisticsPlugin
{
string ProviderName { get; }
bool TrySend(string orderNo, string address);
}
// 运行时扫描到类型后,直接转换并调用
if (typeof(ILogisticsPlugin).IsAssignableFrom(type))
{
ILogisticsPlugin? plugin = Activator.CreateInstance(type) as ILogisticsPlugin;
plugin?.TrySend(orderNo, address);
}
很多说“反射性能不好、别用反射”的建议,其实是在说“用反射代替了本应存在的接口调用”。当你把“动态发现”和“静态契约调用”混在一起,性能当然难看。反射真正需要承载的动态性只是负责连接的哪根线,找到插件后立刻转成接口,调用成本几乎为零。
5. 实例拆解:给商城物流模块做一个反射驱动的插件调度器
5.1 先看完整需求
我在实际项目里遇到过一个典型诉求:一个 .NET Core 商城后台,有多个物流合作方,每个合作方的接口协议不同,有的传 XML,有的传 JSON,有的要求走签名。后面要上新的物流商时,主要代码不能改,只需要新增一个 DLL。
这个场景的模型我一般这样设计:
- 插件目录:
/plugins,每个插件一个独立 DLL。 - 插件接口:定义统一的提交订单方法。
- 插件元数据:用
[LogisticsProvider("yunda")]标记,便于主程序识别和路由。 - 调度器:程序启动时扫描目录,动态加载 DLL,构建
字典 providerName -> Func映射,后续请求来时直接查字典调用。
5.2 接口、特性与示例插件
先定义一个精简接口:
csharp复制public interface ILogisticsProvider
{
string Name { get; }
bool TrySendOrder(LogisticsOrder order);
}
public sealed class LogisticsOrder
{
public string OrderNo { get; set; } = string.Empty;
public string ReceiverAddress { get; set; } = string.Empty;
public string ReceiverPhone { get; set; } = string.Empty;
}
定义特性:
csharp复制[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class LogisticsProviderAttribute : Attribute
{
public string ProviderName { get; }
public LogisticsProviderAttribute(string providerName) => ProviderName = providerName;
}
示例插件类这是另一个开发组或物流服务商写的,它的类完全引用主程序发布的基础包:
csharp复制[LogisticsProvider("yunda")]
public sealed class YundaLogisticsProvider : ILogisticsProvider
{
private readonly string _apiKey;
public YundaLogisticsProvider(string apiKey)
{
_apiKey = apiKey;
}
public string Name => "韵达测试";
public bool TrySendOrder(LogisticsOrder order)
{
Console.WriteLine($"调用韵达接口,订单号:{order.OrderNo}");
return true;
}
}
5.3 调度器的扫描过程
主程序里的调度器负责三件事:加载程序集、扫描类型、建立调用映射。
csharp复制public sealed class LogisticsDispatcher
{
private readonly Dictionary<string, Func<LogisticsOrder, bool>> _handlers
= new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Func<LogisticsOrder, bool>> _lazyHandlers
= new(StringComparer.OrdinalIgnoreCase);
public void LoadPluginFromDirectory(string pluginDirectory)
{
if (!Directory.Exists(pluginDirectory)) return;
foreach (string dllPath in Directory.GetFiles(pluginDirectory, "*.dll"))
{
Assembly assembly = Assembly.LoadFrom(dllPath);
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
// 如果有类型因为依赖缺失加载失败,尽量保留能用的部分
types = ex.Types.Where(t => t != null).Select(t => t!).ToArray();
}
foreach (Type type in types)
{
LogisticsProviderAttribute? attr =
type.GetCustomAttribute<LogisticsProviderAttribute>();
if (attr == null) continue;
if (!typeof(ILogisticsProvider).IsAssignableFrom(type)) continue;
// 这里不能直接 new,因为构造函数可能带参数,我们放到 DoSend 时再创建。
// 但可以把 Type 和构造函数参数缓存下来。示例用简单的 CreatePlugin 表达。
_handlers[attr.ProviderName] = order =>
{
object? instance = Activator.CreateInstance(type, new object[] { "configured-key" });
return instance is ILogisticsProvider provider
&& provider.TrySendOrder(order);
};
}
}
}
public bool Send(string providerName, LogisticsOrder order)
{
if (_handlers.TryGetValue(providerName, out Func<LogisticsOrder, bool>? handler))
{
return handler(order);
}
return false;
}
}
这个版本为了展示思路,在每次发送时才创建插件实例,显然还可以优化成单例池。对于无状态或有简单配置的插件,我更倾向于在扫描阶段就把实例创建好,再缓存 ILogisticsProvider。有状态、需要作用域的插件再考虑每次创建,但那已经和反射关系不大,而是生命周期管理问题。
调用入口非常简洁:
csharp复制LogisticsDispatcher dispatcher = new();
dispatcher.LoadPluginFromDirectory("/app/plugins");
var order = new LogisticsOrder
{
OrderNo = "SF202501100001",
ReceiverAddress = "上海市浦东新区 xx 路 xx 号",
ReceiverPhone = "13800000000"
};
bool ok = dispatcher.Send("yunda", order);
Console.WriteLine(ok ? "订单已推送给物流商" : "推送失败");
5.4 运行时动态识别的核心意义
这套代码的好处不是减少了类与类之间的 new,而是“插件添加在主程序编译后依然可能发生”。你可以在系统运行一段时间后把新 DLL 放进插件目录,重启应用(或者使用专门的程序集加载管理机制),新物流商就自动出现在可用列表里。对运营团队来说,这个体验比发版本友好得多。
我特意保留了 _handlers 和 _lazyHandlers 两个字典演示另一种思路,生产里并不需要同时存在。你完全可以只保留一个 Dictionary,value 直接存 Type,创建实例放到统一工厂里。不同团队会根据自己的生命周期要求做取舍,反射负责解决的始终是“发现与绑定”这一段。
5.5 依赖冲突与程序集加载失败的提醒
插件化开发中,主程序引用了一套底层库版本,插件可能引用另一套版本。如果插件的依赖和主程序完全不一致,Assembly.LoadFrom 后经常会出现 FileNotFoundException 或 TypeLoadException。这时要检查被加载程序集依赖的所有程序集是否都在运行目录或插件目录下。
如果你需要做一个正经的插件框架,建议进一步研究 AssemblyDependencyResolver,它可以根据插件 DLL 本身的 .deps.json 去解析依赖,而不再只依赖应用主目录。反射只是给了你“动态加载代码”的入口,真正让动态加载稳定的,是运行时对程序集依赖的解析策略。
6. 我踩过的反射深坑与逐渐成型的几条使用守则
6.1 方法找不到、属性找不到:先打印成员列表
反射代码出问题时,报错往往不会特别精确,更多是我的代码逻辑里没判断返回值为 null,然后空引用。常规排查手段不是看堆栈猜,而是先把目标类型的成员列表完整打印出来,用肉眼核对名称、大小写、参数类型。
我遇到过一次:插件发布时用了混淆工具,把公开方法名改成了随机字符,我的反射 GetMethod("TrySend") 自然找不到。这不是代码写得不对,而是插件作者混淆之后就破坏了反射字符串匹配。解决方式是约定接口并优先将类型转换成接口,而不是靠名字去找方法。如果必须按名字反射,就别对插件启用方法名混淆,或者约定一个常量方法名。
基于这些经历,我给自己定了一条规则:反射查找任何成员,都要先判断结果是否为 null,不要天真地认为类型存在则成员一定存在。发布环境不同、依赖版本不同、混淆策略不同,成员是可能“消失”的。
6.2 GetTypes 抛 ReflectionTypeLoadException 的崩溃时刻
使用 Assembly.GetTypes() 时,一个类型依赖的程序集如果加载不全,整个方法会抛出 ReflectionTypeLoadException,表面上像是“程序集损坏”,实际上可能是插件目录里少放了一个依赖 DLL。异常对象里带有 Types 属性,会把你未加载的类型对应位置留成 null,但已经能正常加载的类型还会保留。我处理插件扫描的代码会捕捉这个异常,过滤出非空类型,并记录警告日志,而不是直接让启动流程崩溃。
这个坑在普通的业务开发里不常见,一旦做了动态加载,几乎必然遇到。建议所有写反射扫描的代码都把这个异常处理写成基础模板,避免上线后因为某个插件依赖缺失把整个系统拖垮。宁可跳过有问题的插件,也要保证其余功能可用。
6.3 用户输入别直接成为反射条件
有些场景看起来很适合反射:前端传一个类型名给后端,后端反射加载这个类型并调用。我不建议你把用户可控的字符串直接拼接成类型名或程序集名,因为:
- 对方可能传入任意程序集名称,诱导系统加载不可信代码。
- 如果程序内部有敏感的内部服务类被反射到,可能被意外调用。
- 错误信息会泄露程序集结构,成为攻击者探测内部实现的窗口。
如果业务确实需要根据标识符动态选择实现,正确做法是把用户传入的标识符限定在预设枚举或字典中,只把“系统内部允许的类型”映射到反射目标。插件开发者的代码是可信的,但普通用户输入不可信,这条边界必须拉清楚。
6.4 裁剪与 AOT 环境下反射会“失灵”
如果你发布 .NET 程序时启用了 PublishTrimmed,或者干脆用 Native AOT 发布,运行时会将未被引用的类型当成“无用代码”裁剪掉。反射按字符串查找类型的代码因为无法被静态分析器感知,目标类型极可能被裁掉,运行时就报找不到类型。
这时解决方案有两个:一是通过 DynamicDependencyAttribute 或预编译的 .rd.xml 文件告诉裁剪器需要保留哪些类型;二是放弃字符串反射,改用在代码里能静态看到的接口方案。前者复杂,后者更可持续。我去年做一个打包体积敏感的 CLI 工具时,就放弃了“远端配置驱动反射”的设计,改为在启动时根据一个明确注册表完成类型映射,这样裁剪器能看出来哪些类被使用,不会裁错。
6.5 在接口、源生成器与反射之间的取舍
现代 .NET 提供了源生成器(Source Generator),它能在编译期扫描代码并生成强类型的注册逻辑,很大程度上可以取代“启动时反射扫描”这种模式。比如 MediatR 的请求处理注册、ASP.NET Core Minimal API 的部分映射,底层都有反射,但如果你自己有特殊需求,源生成器是一个性能更好、裁剪更安全的替代方案。
我的选择标准是分层的:如果项目阶段还在快速迭代,插件/模块列表频繁变动,反射 + 缓存委托是最灵活的,能快速验证业务落点;如果已经进入稳定期,追求极致启动速度和低内存,就会考虑源生成器。反射和源生成器不是敌人,反射适合描述“无法在编译期预知的问题”,源生成器适合描述“能在编译期扫描出但想生成强类型代码的问题”。
6.6 我给自己的几条反射使用准则
长期使用反射以后,我沉淀下来几条非常务实的准则,也是我现在看别人代码时快速判断是否合理的依据:
- 反射只承担“发现与创建”,尽量把后续业务调用切换到底层接口或编译出的委托上。
- 所有一次性匹配出来的 Type / MethodInfo / PropertyInfo,能缓存就缓存,绝不每次现场 lookup。
- 所有扫描程序集的代码,默认捕获
ReflectionTypeLoadException,没有例外。 - 新增能力时,优先问自己:编译期能不能预见这个变化?如果能预见,就少用反射;只有编译期预见不到,反射才是合理答案。
- 操作非公有成员前,先想清楚是不是在破坏不该破坏的封装。测试代码里偶尔可以,生产路径尽量不用。
如果你能把反射当成“程序集发现器”而非“万能的调用器”,就会发现它并没有那么夸张,也不会让代码“失控”。真正失控的代码从来不是用了反射,而是没有边界、没有约定、没有失败处理地滥用反射。
这套玩法我在不止一个项目里验证过,最明显的一个收益是:主程序发布频率明显下降,新增一个渠道、一个插件、一个规则,大多数时候只需要扩展方提供一个新的 DLL。而你作为主框架维护者,要保证的就是把发现规则定清楚、失败处理做扎实、性能关键路径的委托尽早编译好。能做到这几点,反射就不需要包装成所谓“秘密武器”,它就是一个基本功。
