编辑
2026-06-01
C#
0

目录

🤔 你有没有遇到过这种情况?
🔍 问题深度剖析:静态类型的"天花板"
传统插件架构的三大痛点
💡 核心要点提炼:DynamicObject 是什么,能做什么
底层机制
适用边界
🛠️ 解决方案设计
方案一:动态属性包——插件元数据的灵活容器
方案二:动态方法分发器——插件调用的统一入口
方案三:完整的万能插件架构——动态加载 + 生命周期管理
🎯 三个"一句话洞察"
📌 可复用代码模板速查
🏁 总结与学习路径
💬 互动话题
#动态编程 #插件架构 #DynamicObject #性能优化 #设计模式 #.NET`

🤔 你有没有遇到过这种情况?

系统上线半年,产品经理走过来说:"能不能加个新功能,不重新部署?"

你盯着那一堆 if-else 和硬编码的类型判断,心里默默叹了口气。每次新增一个插件,就要改一遍核心代码,重新编译、测试、部署——整个流程走下来少则半天,多则两三天。更头疼的是,插件之间的耦合像一团乱麻,改了 A 影响 B,改了 B 又牵连 C。

根据一些中大型项目的实际统计,插件扩展相关的改动占据了迭代周期中约 30%~40% 的维护成本,而其中大部分时间并不是在写新逻辑,而是在"拆线头"。

读完这篇文章,你将掌握:

  • DynamicObject 的底层机制与适用边界
  • 如何用动态对象构建一套零侵入、热插拔的插件分发架构
  • 三个渐进式的落地方案,从简单 Demo 到生产可用

🔍 问题深度剖析:静态类型的"天花板"

咱们先把问题说清楚。C# 是强类型语言,这是优势,但在插件化场景下,它也是一堵墙。

传统插件架构的三大痛点

1. 接口版本爆炸

最常见的做法是定义一个 IPlugin 接口,所有插件实现它。听起来很优雅,但现实是:随着业务演进,接口要加方法,旧插件要跟着改,要么用 default interface method 打补丁,要么版本号一路飙升——IPluginIPlugin2IPluginV3……

2. 类型强耦合

插件宿主(Host)需要知道插件的具体类型才能调用,这意味着宿主程序集必须引用插件程序集,或者通过反射做大量的 Type.GetMethod + MethodInfo.Invoke,性能和可读性都不理想。

3. 元数据扩展困难

每个插件可能携带不同的配置参数,比如插件 A 需要 Timeout,插件 B 需要 RetryCount。用静态类型来描述这些差异,要么搞一个巨大的配置类把所有字段都塞进去,要么用 Dictionary<string, object> 凑合——后者其实已经在向动态迈步了。

这些问题的根源在于:静态类型系统要求在编译期确定所有契约,而插件化的本质是运行期的动态扩展。两者存在结构性矛盾。


💡 核心要点提炼:DynamicObject 是什么,能做什么

底层机制

DynamicObjectSystem.Dynamic 命名空间下的一个抽象类,它配合 C# 的 dynamic 关键字工作。当你用 dynamic 变量调用一个方法或访问一个属性时,编译器不做类型检查,而是在运行时通过 DLR(Dynamic Language Runtime) 分发调用。

DynamicObject 提供了一系列可重写的虚方法,让你拦截这些运行时调用:

可重写方法触发时机
TryGetMember读取属性时
TrySetMember设置属性时
TryInvokeMember调用方法时
TryInvoke直接调用对象时
TryBinaryOperation二元运算时

关键理解DynamicObject 不是反射的替代品,它是一个行为代理层。你可以在这一层做任何事——转发调用、记录日志、做权限校验、动态路由到不同的实现。

适用边界

动态对象不是银弹,用错了反而是灾难。它适合的场景是:

  • 插件/脚本宿主,需要在运行时动态分发调用
  • DSL(领域特定语言)的构建
  • 跨语言互操作(如与 Python、JavaScript 引擎交互)
  • 配置/元数据的动态访问层

不适合的场景:核心业务逻辑、高频热路径(动态分发有额外开销)、需要 IDE 强类型提示的协作代码。


🛠️ 解决方案设计

下面咱们用三个渐进式方案,从原理验证到生产落地,一步步把架构搭起来。


方案一:动态属性包——插件元数据的灵活容器

应用场景:每个插件携带不同的配置参数,宿主需要统一读写,但不想为每种插件单独定义配置类。

这是最简单的起点,用 DynamicObject 包装一个字典,让它看起来像一个"真实对象"。

csharp
using 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; }

使用起来像这样:

csharp
dynamic 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}");

image.png

踩坑预警TryGetMember 返回 false 时,DLR 会抛出 RuntimeBinderException,而不是返回 null。如果你希望访问不存在的属性时得到 null 而非异常,把 TryGetMember 改成始终返回 true,并在 result 为空时赋 null

csharp
public override bool TryGetMember(GetMemberBinder binder, out object? result) { _store.TryGetValue(binder.Name, out result); return true; // 始终返回 true,避免 RuntimeBinderException }

方案二:动态方法分发器——插件调用的统一入口

应用场景:宿主需要调用插件的方法,但插件的方法签名各不相同,无法用统一接口约束。

这是架构的核心层。我在一个工业数据采集项目中用过类似的设计——不同厂商的设备驱动暴露的接口千奇百怪,用这一层统一包装后,上层代码完全感知不到差异。

csharp
using 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()); } }

image.png

性能说明(测试环境:.NET 10,Intel i7-12700,Release 模式):

  • 首次调用(含反射查找):约 0.8ms
  • 缓存后的后续调用:约 0.05ms(与直接反射调用相当)
  • 相比直接方法调用(~0.001ms):有约 50 倍开销,仅适合非热路径

方案三:完整的万能插件架构——动态加载 + 生命周期管理

应用场景:生产级插件系统,支持运行时热加载 DLL,插件无需实现固定接口。

这是完整版,把前两个方案整合起来,加上 AssemblyLoadContext 实现真正的热插拔。

csharp
using 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(); } } }

image.png

踩坑预警

  1. isCollectible: trueAssemblyLoadContext 卸载后,所有从该上下文加载的类型都会失效。如果你的 DynamicPluginProxy 还持有对这些类型的引用(比如 _methodCache 里的 MethodInfo),会阻止 GC 回收,造成内存泄漏。解决方案:卸载时同时清空代理的缓存,或者让代理跟随插件一起被丢弃。

  2. 插件 DLL 与宿主共享依赖(如 Newtonsoft.Json)时,版本冲突是常见问题。建议在 PluginLoadContext.Load 中先检查默认上下文是否已有该程序集,有则复用,避免重复加载。


🎯 三个"一句话洞察"

动态不是逃避类型系统,而是在类型系统无法触达的边界处优雅地接管。

插件架构的核心不是技术选型,而是把"变化"和"稳定"分离到不同的层次。

DynamicObject 最大的价值不是"能做什么",而是"把什么横切关注点挡在了调用路径上"。


📌 可复用代码模板速查

模板 1:带默认值的安全动态属性包(防止访问不存在属性时抛异常)

csharp
public 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:带调用日志的插件代理基类(直接继承扩展)

csharp
public 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 实现真正的运行时热插拔。

三个方案是递进关系,实际项目中不必一步到位——从方案一开始,感受动态对象的边界,再逐步引入方案二和三。

如果你想继续深挖这个方向,推荐的学习路径是:DynamicObjectExpandoObject(内置实现,适合简单场景)→ AssemblyLoadContext(.NET Core 的插件隔离机制)→ MEF(Managed Extensibility Framework,微软官方插件框架)→ Roslyn 脚本 API(真正的运行时代码编译与执行)。


💬 互动话题

你在项目里有没有遇到过"插件越加越乱"的情况?当时是怎么处理的——是用反射、接口版本管理,还是别的方案?欢迎在评论区聊聊你的实践经验,说不定能碰出新思路。

另外一个小挑战:如果要给 DynamicPluginProxy 加上异步方法支持(即插件方法返回 TaskTask<T>),你会怎么改 TryInvokeMember?可以把思路写在评论里。


#C# #动态编程 #插件架构 #DynamicObject #性能优化 #设计模式 #.NET

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:技术老小子

本文链接:

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