系统上线半年,产品经理走过来说:"能不能加个新功能,不重新部署?"
你盯着那一堆 if-else 和硬编码的类型判断,心里默默叹了口气。每次新增一个插件,就要改一遍核心代码,重新编译、测试、部署——整个流程走下来少则半天,多则两三天。更头疼的是,插件之间的耦合像一团乱麻,改了 A 影响 B,改了 B 又牵连 C。
根据一些中大型项目的实际统计,插件扩展相关的改动占据了迭代周期中约 30%~40% 的维护成本,而其中大部分时间并不是在写新逻辑,而是在"拆线头"。
读完这篇文章,你将掌握:
DynamicObject 的底层机制与适用边界咱们先把问题说清楚。C# 是强类型语言,这是优势,但在插件化场景下,它也是一堵墙。
1. 接口版本爆炸
最常见的做法是定义一个 IPlugin 接口,所有插件实现它。听起来很优雅,但现实是:随着业务演进,接口要加方法,旧插件要跟着改,要么用 default interface method 打补丁,要么版本号一路飙升——IPlugin、IPlugin2、IPluginV3……
2. 类型强耦合
插件宿主(Host)需要知道插件的具体类型才能调用,这意味着宿主程序集必须引用插件程序集,或者通过反射做大量的 Type.GetMethod + MethodInfo.Invoke,性能和可读性都不理想。
3. 元数据扩展困难
每个插件可能携带不同的配置参数,比如插件 A 需要 Timeout,插件 B 需要 RetryCount。用静态类型来描述这些差异,要么搞一个巨大的配置类把所有字段都塞进去,要么用 Dictionary<string, object> 凑合——后者其实已经在向动态迈步了。
这些问题的根源在于:静态类型系统要求在编译期确定所有契约,而插件化的本质是运行期的动态扩展。两者存在结构性矛盾。
DynamicObject 是 System.Dynamic 命名空间下的一个抽象类,它配合 C# 的 dynamic 关键字工作。当你用 dynamic 变量调用一个方法或访问一个属性时,编译器不做类型检查,而是在运行时通过 DLR(Dynamic Language Runtime) 分发调用。
DynamicObject 提供了一系列可重写的虚方法,让你拦截这些运行时调用:
| 可重写方法 | 触发时机 |
|---|---|
TryGetMember | 读取属性时 |
TrySetMember | 设置属性时 |
TryInvokeMember | 调用方法时 |
TryInvoke | 直接调用对象时 |
TryBinaryOperation | 二元运算时 |
关键理解:DynamicObject 不是反射的替代品,它是一个行为代理层。你可以在这一层做任何事——转发调用、记录日志、做权限校验、动态路由到不同的实现。
动态对象不是银弹,用错了反而是灾难。它适合的场景是:
不适合的场景:核心业务逻辑、高频热路径(动态分发有额外开销)、需要 IDE 强类型提示的协作代码。
下面咱们用三个渐进式方案,从原理验证到生产落地,一步步把架构搭起来。
应用场景:每个插件携带不同的配置参数,宿主需要统一读写,但不想为每种插件单独定义配置类。
这是最简单的起点,用 DynamicObject 包装一个字典,让它看起来像一个"真实对象"。
csharpusing System.Dynamic;
using System.Collections.Generic;
/// <summary>
/// 动态属性包:用于存储插件的任意元数据
/// </summary>
public class DynamicPropertyBag : DynamicObject
{
private readonly Dictionary<string, object?> _store = new();
// 拦截属性读取:bag.Timeout
public override bool TryGetMember(GetMemberBinder binder, out object? result)
{
return _store.TryGetValue(binder.Name, out result);
}
// 拦截属性写入:bag.Timeout = 3000
public override bool TrySetMember(SetMemberBinder binder, object? value)
{
_store[binder.Name] = value;
return true;
}
// 支持枚举所有动态属性名
public override IEnumerable<string> GetDynamicMemberNames() => _store.Keys;
}
使用起来像这样:
csharpdynamic config = new DynamicPropertyBag();
// 插件 A 的配置
config.Timeout = 3000;
config.RetryCount = 3;
config.EndpointUrl = "https://api.example.com";
// 插件 B 的配置(完全不同的字段)
dynamic configB = new DynamicPropertyBag();
configB.BufferSize = 4096;
configB.Encoding = "UTF-8";
// 宿主统一处理,不需要知道具体类型
Console.WriteLine($"Timeout: {config.Timeout}");
Console.WriteLine($"BufferSize: {configB.BufferSize}");

踩坑预警:TryGetMember 返回 false 时,DLR 会抛出 RuntimeBinderException,而不是返回 null。如果你希望访问不存在的属性时得到 null 而非异常,把 TryGetMember 改成始终返回 true,并在 result 为空时赋 null:
csharppublic override bool TryGetMember(GetMemberBinder binder, out object? result)
{
_store.TryGetValue(binder.Name, out result);
return true; // 始终返回 true,避免 RuntimeBinderException
}
应用场景:宿主需要调用插件的方法,但插件的方法签名各不相同,无法用统一接口约束。
这是架构的核心层。我在一个工业数据采集项目中用过类似的设计——不同厂商的设备驱动暴露的接口千奇百怪,用这一层统一包装后,上层代码完全感知不到差异。
csharpusing System.Dynamic;
using System.Collections.Generic;
using System.Reflection;
/// <summary>
/// 动态插件代理:将方法调用路由到实际的插件实例
/// </summary>
public class DynamicPluginProxy : DynamicObject
{
private readonly object _pluginInstance;
private readonly Type _pluginType;
// 方法缓存,避免每次调用都做反射查找(性能关键)
private readonly Dictionary<string, MethodInfo> _methodCache = new();
public DynamicPluginProxy(object pluginInstance)
{
_pluginInstance = pluginInstance;
_pluginType = pluginInstance.GetType();
}
public override bool TryInvokeMember(
InvokeMemberBinder binder,
object?[]? args,
out object? result)
{
// 优先从缓存取,避免重复反射
if (!_methodCache.TryGetValue(binder.Name, out var method))
{
method = _pluginType.GetMethod(binder.Name,
BindingFlags.Public | BindingFlags.Instance);
if (method == null)
{
result = null;
return false; // 方法不存在,返回 false 触发异常
}
_methodCache[binder.Name] = method;
}
// 调用前可以插入横切关注点:日志、权限、计时等
var sw = System.Diagnostics.Stopwatch.StartNew();
result = method.Invoke(_pluginInstance, args);
sw.Stop();
Console.WriteLine($"[PluginProxy] {_pluginType.Name}.{binder.Name} " +
$"执行耗时: {sw.ElapsedMilliseconds}ms");
return true;
}
// 属性读取同样路由到实际实例
public override bool TryGetMember(GetMemberBinder binder, out object? result)
{
var prop = _pluginType.GetProperty(binder.Name);
if (prop == null) { result = null; return false; }
result = prop.GetValue(_pluginInstance);
return true;
}
}
配合一个简单的插件注册中心:
csharp/// <summary>
/// 插件注册中心:管理插件的注册与动态调用
/// </summary>
public class PluginRegistry
{
private readonly Dictionary<string, dynamic> _plugins = new();
public void Register(string name, object pluginInstance)
{
_plugins[name] = new DynamicPluginProxy(pluginInstance);
}
public dynamic? Get(string name)
{
return _plugins.TryGetValue(name, out var plugin) ? plugin : null;
}
}
实际使用:
csharp// 两个完全不同签名的插件实现
public class EmailPlugin
{
public string Send(string to, string subject)
=> $"Email sent to {to}: {subject}";
}
public class SmsPlugin
{
public string Send(string phone, string message)
=> $"SMS sent to {phone}: {message}";
public bool CheckBalance() => true;
}
internal class Program
{
static void Main(string[] args)
{
// 注册插件
var registry = new PluginRegistry();
registry.Register("email", new EmailPlugin());
registry.Register("sms", new SmsPlugin());
// 统一调用,宿主不需要知道具体类型
dynamic emailPlugin = registry.Get("email");
dynamic smsPlugin = registry.Get("sms");
Console.WriteLine(emailPlugin.Send("dev@example.com", "部署通知"));
Console.WriteLine(smsPlugin.Send("13800138000", "验证码:1234"));
Console.WriteLine(smsPlugin.CheckBalance());
}
}

性能说明(测试环境:.NET 10,Intel i7-12700,Release 模式):
应用场景:生产级插件系统,支持运行时热加载 DLL,插件无需实现固定接口。
这是完整版,把前两个方案整合起来,加上 AssemblyLoadContext 实现真正的热插拔。
csharpusing System.Dynamic;
using System.Reflection;
using System.Runtime.Loader;
namespace AppDynamicObject
{
/// <summary>
/// 动态插件代理:将方法调用路由到实际的插件实例
/// </summary>
public class DynamicPluginProxy : DynamicObject
{
private readonly object _pluginInstance;
private readonly Type _pluginType;
// 方法缓存,避免每次调用都做反射查找(性能关键)
private readonly Dictionary<string, MethodInfo> _methodCache = new();
public DynamicPluginProxy(object pluginInstance)
{
_pluginInstance = pluginInstance;
_pluginType = pluginInstance.GetType();
}
public override bool TryInvokeMember(
InvokeMemberBinder binder,
object?[]? args,
out object? result)
{
// 优先从缓存取,避免重复反射
if (!_methodCache.TryGetValue(binder.Name, out var method))
{
method = _pluginType.GetMethod(binder.Name,
BindingFlags.Public | BindingFlags.Instance);
if (method == null)
{
result = null;
return false; // 方法不存在,返回 false 触发异常
}
_methodCache[binder.Name] = method;
}
// 调用前可以插入横切关注点:日志、权限、计时等
var sw = System.Diagnostics.Stopwatch.StartNew();
result = method.Invoke(_pluginInstance, args);
sw.Stop();
Console.WriteLine($"[PluginProxy] {_pluginType.Name}.{binder.Name} " +
$"执行耗时: {sw.ElapsedMilliseconds}ms");
return true;
}
// 属性读取同样路由到实际实例
public override bool TryGetMember(GetMemberBinder binder, out object? result)
{
var prop = _pluginType.GetProperty(binder.Name);
if (prop == null) { result = null; return false; }
result = prop.GetValue(_pluginInstance);
return true;
}
}
/// <summary>
/// 可卸载的插件加载上下文:支持热插拔
/// </summary>
public class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath)
: base(isCollectible: true) // isCollectible=true 才能卸载
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
return assemblyPath != null ? LoadFromAssemblyPath(assemblyPath) : null;
}
}
/// <summary>
/// 热插拔插件宿主:支持运行时加载、卸载插件
/// </summary>
public class HotPluginHost
{
// 用 WeakReference 持有 LoadContext,便于 GC 回收
private readonly Dictionary<string, (WeakReference<PluginLoadContext> Context, dynamic Proxy)>
_loadedPlugins = new();
public dynamic LoadPlugin(string pluginPath, string typeName)
{
var context = new PluginLoadContext(pluginPath);
var assembly = context.LoadFromAssemblyPath(pluginPath);
var type = assembly.GetType(typeName)
?? throw new InvalidOperationException($"找不到类型: {typeName}");
var instance = Activator.CreateInstance(type)
?? throw new InvalidOperationException($"无法实例化: {typeName}");
dynamic proxy = new DynamicPluginProxy(instance);
var pluginName = Path.GetFileNameWithoutExtension(pluginPath);
_loadedPlugins[pluginName] = (new WeakReference<PluginLoadContext>(context), proxy);
Console.WriteLine($"[HotPluginHost] 插件 '{pluginName}' 加载成功");
return proxy;
}
public bool UnloadPlugin(string pluginName)
{
if (!_loadedPlugins.TryGetValue(pluginName, out var entry))
return false;
_loadedPlugins.Remove(pluginName);
// 触发卸载(实际回收依赖 GC)
if (entry.Context.TryGetTarget(out var context))
{
context.Unload();
}
Console.WriteLine($"[HotPluginHost] 插件 '{pluginName}' 已卸载");
return true;
}
public dynamic? GetPlugin(string pluginName)
{
return _loadedPlugins.TryGetValue(pluginName, out var entry)
? entry.Proxy
: null;
}
}
internal class Program
{
static void Main(string[] args)
{
var host = new HotPluginHost();
var pluginDirectory = Path.Combine(AppContext.BaseDirectory, "plugins");
var pluginV1Path = Path.Combine(pluginDirectory, "MyPlugin.dll");
var pluginV2Path = Path.Combine(pluginDirectory, "MyPlugin_v2.dll");
// 运行时加载插件 DLL(无需重启宿主进程)
dynamic? plugin = host.LoadPlugin(
pluginV1Path,
"MyPlugin.DataProcessorPlugin");
// 调用插件方法——宿主完全不知道 DataProcessorPlugin 的具体定义
var result = plugin.Process("raw_data_input");
Console.WriteLine($"[Host] V1 Result: {result}");
Console.WriteLine($"[Host] V1 Version: {plugin.Version}");
// 热更新:卸载旧版本,加载新版本
host.UnloadPlugin("MyPlugin");
plugin = null;
GC.Collect(); // 建议显式触发 GC 确保卸载
GC.WaitForPendingFinalizers();
dynamic newPlugin = host.LoadPlugin(
pluginV2Path,
"MyPlugin.DataProcessorPlugin");
var newResult = newPlugin.Process("raw_data_input");
Console.WriteLine($"[Host] V2 Result: {newResult}");
Console.WriteLine($"[Host] V2 Version: {newPlugin.Version}");
Console.WriteLine("\n测试完成,按任意键退出...");
Console.ReadKey();
}
}
}

踩坑预警:
isCollectible: true 的 AssemblyLoadContext 卸载后,所有从该上下文加载的类型都会失效。如果你的 DynamicPluginProxy 还持有对这些类型的引用(比如 _methodCache 里的 MethodInfo),会阻止 GC 回收,造成内存泄漏。解决方案:卸载时同时清空代理的缓存,或者让代理跟随插件一起被丢弃。
插件 DLL 与宿主共享依赖(如 Newtonsoft.Json)时,版本冲突是常见问题。建议在 PluginLoadContext.Load 中先检查默认上下文是否已有该程序集,有则复用,避免重复加载。
动态不是逃避类型系统,而是在类型系统无法触达的边界处优雅地接管。
插件架构的核心不是技术选型,而是把"变化"和"稳定"分离到不同的层次。
DynamicObject最大的价值不是"能做什么",而是"把什么横切关注点挡在了调用路径上"。
模板 1:带默认值的安全动态属性包(防止访问不存在属性时抛异常)
csharppublic class SafeDynamicBag : DynamicObject
{
private readonly Dictionary<string, object?> _store = new();
public override bool TryGetMember(GetMemberBinder binder, out object? result)
{
_store.TryGetValue(binder.Name, out result);
return true; // 不存在时返回 null,不抛异常
}
public override bool TrySetMember(SetMemberBinder binder, object? value)
{
_store[binder.Name] = value;
return true;
}
}
模板 2:带调用日志的插件代理基类(直接继承扩展)
csharppublic abstract class LoggingDynamicProxy : DynamicObject
{
protected abstract object? DispatchMethod(string methodName, object?[]? args);
public override bool TryInvokeMember(
InvokeMemberBinder binder, object?[]? args, out object? result)
{
Console.WriteLine($"[Call] {binder.Name}({string.Join(", ", args ?? Array.Empty<object?>())})");
result = DispatchMethod(binder.Name, args);
Console.WriteLine($"[Return] {result}");
return true;
}
}
这篇文章覆盖了三个核心落地点:用 DynamicPropertyBag 解决插件元数据的灵活存储,用 DynamicPluginProxy 统一方法分发并内置横切关注点,再用 HotPluginHost + AssemblyLoadContext 实现真正的运行时热插拔。
三个方案是递进关系,实际项目中不必一步到位——从方案一开始,感受动态对象的边界,再逐步引入方案二和三。
如果你想继续深挖这个方向,推荐的学习路径是:DynamicObject → ExpandoObject(内置实现,适合简单场景)→ AssemblyLoadContext(.NET Core 的插件隔离机制)→ MEF(Managed Extensibility Framework,微软官方插件框架)→ Roslyn 脚本 API(真正的运行时代码编译与执行)。
你在项目里有没有遇到过"插件越加越乱"的情况?当时是怎么处理的——是用反射、接口版本管理,还是别的方案?欢迎在评论区聊聊你的实践经验,说不定能碰出新思路。
另外一个小挑战:如果要给 DynamicPluginProxy 加上异步方法支持(即插件方法返回 Task 或 Task<T>),你会怎么改 TryInvokeMember?可以把思路写在评论里。
#C# #动态编程 #插件架构 #DynamicObject #性能优化 #设计模式 #.NET


本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!