使用 AppDomain 加载和卸载程序集的细微差别






4.96/5 (72投票s)
在考虑编写带有热插拔模块的应用程序时应该了解的事情
目录
引言
有很多关于使用 AppDomain
加载和卸载程序集的帖子和文章,但我还没有找到一个地方能将所有内容整合起来,使其有意义,并探索使用应用程序域的细微差别,这就是写这篇文章的原因。
对我来说,在运行时加载和卸载程序集是为了实现一个程序集与另一个程序集的热插拔,而无需关闭整个应用程序。这在运行 Web 服务器或 ATM 等应用程序时非常重要,或者您希望保留应用程序状态而无需持久化所有内容、重新启动应用程序,然后恢复状态。即使这意味着应用程序暂时(几百毫秒)无响应,这也比必须关闭整个应用程序、返回 Windows 屏幕然后重新启动它要好得多。
入门
让一个工作示例运行起来并不困难。主要的技巧是:
- 对您希望在运行时加载的程序集中公开的类使用
Serializable
属性。 - 将公开的类从
MarshalByRefObject
派生(这可能会导致一些有趣的痛点)。 - 使用一个单独的程序集,该程序集在您的应用程序和运行时加载的程序集之间共享,以定义一个接口,通过该接口您的应用程序调用公开的运行时加载类中的方法和属性。
细微差别主要出现在 MarshalByRefObject
的使用中,关于实例参数如何传递,因为这决定了实例是按值传递还是按引用传递。稍后会详细介绍。
为了演示在自己的应用程序域中加载/卸载程序集,我们需要三个项目:
- 一个定义前两个项目之间共享接口的项目
- 一个要加载的程序集项目
- 主应用程序项目
定义应用程序和要加载的程序集之间共享的接口
我们将从一个非常简单的接口开始
using System;
namespace CommonInterface
{
public interface IPlugIn
{
string Name { get; }
void Initialize();
}
}
在这里,我们将演示调用一个方法和读取一个属性。
要加载的程序集
第二个项目,即要加载的程序集,包含一个实现插件接口的类
using System;
using CommonInterface;
namespace PlugIn1
{
[Serializable]
public class PlugIn : MarshalByRefObject, IPlugIn
{
private string name;
public string Name { get { return name; } }
public void Initialize()
{
name = "PlugIn 1";
}
}
}
请注意,该类被标记为 Serializable
并派生自 MarshalByRefObject
。稍后会详细介绍。
加载程序集的应用程序
第三个项目是应用程序本身。这是核心部分:
using System;
using System.Reflection;
using CommonInterface;
namespace AppDomainTests
{
class Program
{
static void Main(string[] args)
{
AppDomain appDomain1 = CreateAppDomain("PlugIn1");
IPlugIn plugin1 = InstantiatePlugin("PlugIn1", appDomain1);
plugin1.Initialize();
Console.WriteLine(plugin1.Name+"\r\n");
UnloadPlugin(appDomain1);
TestIfUnloaded(plugin1);
}
}
}
这段代码
- 将插件程序集加载到与主应用程序域分离的应用程序域中
- 实例化实现
IPlugIn
的类 - 初始化类
- 读取
Name
属性的值 - 卸载程序集
- 验证在程序集卸载后是否抛出了
AppDomainUnloadedException
使用了三个辅助方法。
创建 AppDomain
static AppDomain CreateAppDomain(string dllName)
{
AppDomainSetup setup = new AppDomainSetup()
{
ApplicationName = dllName,
ConfigurationFile = dllName + ".dll.config",
ApplicationBase = AppDomain.CurrentDomain.BaseDirectory
};
AppDomain appDomain = AppDomain.CreateDomain(
setup.ApplicationName,
AppDomain.CurrentDomain.Evidence,
setup);
return appDomain;
}
实例化插件
static IPlugIn InstantiatePlugin(string dllName, AppDomain domain)
{
IPlugIn plugIn = domain.CreateInstanceAndUnwrap(dllName, dllName + ".PlugIn") as IPlugIn;
return plugIn;
}
测试是否已卸载
static void TestIfUnloaded(IPlugIn plugin)
{
bool unloaded = false;
try
{
Console.WriteLine(plugin.Name);
}
catch (AppDomainUnloadedException)
{
unloaded = true;
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
if (!unloaded)
{
Console.WriteLine("It does not appear that the app domain successfully unloaded.");
}
}
此测试验证了,如果我们在插件卸载后尝试访问它的方法(或属性,实际上是方法),我们是否会得到 AppDomainUnloadedException
。
运行这个简单的测试
当我们运行这个测试时,我们看到:
我们注意到没有产生其他错误,因此我们知道自定义应用程序域正在正确卸载程序集——换句话说,插件程序集没有附加到我们应用程序的 app-domain。
为什么...
...我们为什么要添加 Serializable 属性?
当您创建一个应用程序域(当您启动任何 .NET 程序时,都会为您创建一个)时,您正在创建一个隔离的进程(通常称为“程序”),该进程管理静态变量、其他必需的程序集等。应用程序域不共享任何东西。.NET 使用“远程处理”在应用程序域之间进行通信,但只有当需要在域之间共享的类被标记为可序列化时才能做到这一点,否则远程处理机制将不会序列化该类。
当然,当您实例化一个类时,这可能看起来很奇怪——为什么我们只调用方法(甚至属性都是 get
/set
方法的语法糖)时,它需要被标记为可序列化?当然,.NET 不“知道”您只访问方法——您也很可能访问字段,因此您在插件程序集中实例化的类必须是可序列化的。
WCF 呢?
微软关于 AppDomain 远程处理的文档指出:
此主题特定于为现有应用程序提供向后兼容性而保留的旧技术,不建议用于新开发。分布式应用程序现在应使用 Windows Communication Foundation (WCF) 进行开发。
问题是 WCF 不是一个轻量级解决方案——使用 WCF 设置和配置分布式应用程序非常复杂。您可以从这里阅读了解相关问题。当然,对于本文来说,WCF 超出了“保持简单,愚蠢”的范畴。
(一篇关于通用 WCF 托管的非常有趣的文章在这里。)
...我们为什么要从 MarshalByRefObject 派生?
这是一个有趣的问题。让我们添加一个方法,列出我们应用程序 AppDomain
中已加载的程序集:
static void PrintLoadedAssemblies()
{
Assembly[] assys = AppDomain.CurrentDomain.GetAssemblies();
Console.WriteLine("----------------------------------");
foreach (Assembly assy in assys)
{
Console.WriteLine(assy.FullName.LeftOf(','));
}
Console.WriteLine("----------------------------------");
}
我们将调用 PrintLoadedAssemblies
- 在将插件加载到其自己的
AppDomain
之前 - 在加载插件之后
- 以及在卸载
AppDomain
之后
static void Main(string[] args)
{
PrintLoadedAssemblies();
AppDomain appDomain1 = CreateAppDomain("PlugIn1");
IPlugIn plugin1 = InstantiatePlugin("PlugIn1", appDomain);
PrintLoadedAssemblies();
plugin1.Initialize();
Console.WriteLine(plugin1.Name+"\r\n");
UnloadPlugin(appDomain1);
PrintLoadedAssemblies();
TestIfUnloaded(plugin1);
}
结果如下:
注意,插件程序集从未出现在此列表中!MarshalByRefObject
所做的是将我们对象的代理(而不是实际对象)传递回主应用程序。通过使用代理,插件程序集永远不会加载到我们应用程序的 AppDomain
中。
现在让我们修改我们的插件,使其不再从 MarshalByRefObject
派生
public class PlugIn : IPlugIn
...etc...
然后再次运行测试
请注意三点:
- 插件突然出现在我们应用程序的程序集列表中。
- 包含(据称)我们插件的
AppDomain
实际上并没有——卸载它并不能移除程序集,因为程序集在我们的应用程序的AppDomain
中! - 在据称卸载程序集后,我们仍然可以访问该对象。
发生这种情况是因为插件类不再通过代理返回给我们——相反,它是“按值”返回的,在类实例的情况下,这意味着该对象在跨越 AppDomain
时为了反序列化,是在 AppDomain
“我们这边”实例化的。
思考 MarshalByRefObject
的一种方式是,通过从这个基类派生,您正在两个应用程序域世界之间创建一个“锚点”,其中有一个共同的、已知的实现,类似于接口如何通过共同的行为“锚定”类,但实际实现可以有所不同。
MarshalByRefObject 的更多细微差别
让我们进一步探讨 MarshalByRefObject
的行为。首先,我们将定义一个派生自 MarshalByRefObject
的“Thing
”类:
using System;
namespace AThing
{
// A wrapper. Must be serializable.
[Serializable]
public class Thing : MarshalByRefObject
{
public string Value { get; set; }
public Thing(string val)
{
Value = val;
}
}
}
我们还将向插件接口添加一些行为
public interface IPlugIn
{
string Name { get; }
void Initialize();
void SetThings(List<Thing> things);
void PrintThings();
}
我们新的插件实现现在看起来像这样
[Serializable]
public class PlugIn : MarshalByRefObject, IPlugIn
{
private string name;
private List<Thing> things;
public string Name { get { return name; } }
public void Initialize()
{
name = "PlugIn 1";
}
public void SetThings(List<Thing> things)
{
this.things = things;
}
public void PrintThings()
{
foreach (Thing thing in things)
{
Console.WriteLine(thing.Value);
}
}
}
引用还是值?
让我们看看当我们传入一个 List<Thing>
,然后改变集合本身以及集合中的一个项目(记住,Thing
派生自 MarshalByRefObject
)时会发生什么。你能预测会发生什么吗?这是代码:
static void Demo3()
{
PrintLoadedAssemblies();
AppDomain appDomain1 = CreateAppDomain("PlugIn1");
IPlugIn plugin1 = InstantiatePlugin("PlugIn1", appDomain1);
appDomain1.DomainUnload += OnDomainUnload;
PrintLoadedAssemblies();
plugin1.Initialize();
Console.WriteLine(plugin1.Name+"\r\n");
List<Thing> things = new List<Thing>() { new Thing("A"), new Thing("B"), new Thing("C") };
plugin1.SetThings(things);
plugin1.PrintThings();
Console.WriteLine("\r\n");
// Now see what happens when we manipulate things.
things[0].Value = "AA";
things.Add(new Thing("D"));
plugin1.PrintThings();
Console.WriteLine("\r\n");
UnloadPlugin(appDomain1);
PrintLoadedAssemblies();
// Try accessing the plug after it has been unloaded.
// This should result in an AppDomainUnloadedException.
TestIfUnloaded(plugin1);
}
太棒了!
注意到
- 集合没有改变
- 但“
A
”的值已更改为“AA
”
为什么?
List<T>
不是从MarshalByRefObject
派生的,因此当它跨越AppDomain
时,它以值传递(即序列化)。- 实际的
Thing
条目,其中Thing
派生自MarshalByRefObject
,是按引用传递的,因此在AppDomain
的一侧更改值会影响另一侧。
如果我们不从 MarshalByRefObject
派生 Thing
会发生什么?
[Serializable]
public class Thing // : MarshalByRefObject <-- removed!
{
public string Value { get; set; }
public Thing(string val)
{
Value = val;
}
}
集合条目“A
”没有变为“AA
”,因为现在 Thing
也是按值传递的!
如果插件更改了派生自 MarshalByRefObject 的对象会发生什么?
让我们将 MarshalByRefObject
重新作为 Thing
的基类
public class Thing : MarshalByRefObject
并在插件中添加一个方法(及其接口)
public void ChangeThings()
{
things[2].Value = "Mwahaha!";
}
我们将编写一个简短的测试
static void Demo5()
{
AppDomain appDomain1 = CreateAppDomain("PlugIn1");
IPlugIn plugin1 = InstantiatePlugin("PlugIn1", appDomain1);
List<Thing> things = new List<Thing>() { new Thing("A"), new Thing("B"), new Thing("C") };
plugin1.SetThings(things);
plugin1.ChangeThings();
foreach (Thing thing in things)
{
Console.WriteLine(thing.Value);
}
}
结果是:
天哪——这是预期的行为吗,我们的对象在应用程序域之间是可变的?也许是,也许不是!
在单独的 AppDomain 中动态加载程序集
让我们再尝试一件事——我们将在插件中动态加载一个程序集,以验证该程序集是否加载到插件的 AppDomain
中,而不是我们的。这是完整的插件类(我不会费心显示接口):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using AThing;
using CommonInterface;
namespace PlugIn1
{
[Serializable]
public class PlugIn : MarshalByRefObject, IPlugIn
{
private string name;
private List<Thing> things;
public string Name { get { return name; } }
public void Initialize()
{
name = "PlugIn 1";
}
public void SetThings(List<Thing> things)
{
this.things = things;
}
public void PrintThings()
{
foreach (Thing thing in things)
{
Console.WriteLine(thing.Value);
}
}
public void PrintLoadedAssemblies()
{
Helpers.PrintLoadedAssemblies();
}
public void LoadRuntimeAssembly()
{
IDynamicAssembly dassy = DynamicAssemblyLoad();
dassy.HelloWorld();
}
private IDynamicAssembly DynamicAssemblyLoad()
{
Assembly assy = AppDomain.CurrentDomain.Load("DynamicallyLoadedByPlugin");
Type t = assy.GetTypes().SingleOrDefault(assyt => assyt.Name == "LoadMe");
return Activator.CreateInstance(t) as IDynamicAssembly;
}
}
}
这是我们的测试
static void Demo4()
{
AppDomain appDomain1 = CreateAppDomain("PlugIn1");
IPlugIn plugin1 = InstantiatePlugin("PlugIn1", appDomain1);
Console.WriteLine("Our assemblies:");
Helpers.PrintLoadedAssemblies();
plugin1.LoadRuntimeAssembly();
Console.WriteLine("Their assemblies:");
plugin1.PrintLoadedAssemblies();
}
我们得到了预期的结果,即由插件加载的动态加载的程序集在其 AppDomain
中,而不是我们的
实际使用中
使用应用程序域并非易事,尤其是在实现热插拔模块时。您需要考虑:
所有类、它们的成员类、它们的成员的成员类等都带有 Serializable 属性吗?
如果不是,您将无法(无论是按值还是按引用)跨 AppDomain
传输您的类的实例。
您确定任何第三方类(.NET 等)都指定为 Serializable 吗?
例如,泛型集合类 List<T>
是可序列化的——请注意文档的“语法”部分。但是泛型 <T>
是可序列化的吗?其他类,例如 SqlConnection
,则不可序列化。您需要确切知道您打算跨应用程序域传递什么。
您想按值传递还是按引用传递?
这在您的应用程序设计中具有重要意义——如果您期望应用程序对任何 AppDomain
中的对象所做的更改会影响其他 AppDomain
中那些“相同”对象的实例,那么您必须将您的类派生自 MarshalByRefObject
。然而,这种行为可能是危险的,并且具有副作用,因为对象在应用程序域之间是可变的。
您可以按引用传递吗?
另一个重要因素是,您真的可以按引用传递吗?我们看到 List<T>
不能按引用传递,因为它不派生自 MarshalByRefObject
。期望一个具有所有光荣可变性的对象在跨应用程序域传递后行为相同,这是一个非常非常危险的期望,除非您确切知道类的定义以及所有类成员及其成员等。
如果您不能从 MarshalByRefObject 派生怎么办?
如果您不能从 MarshalByRefObject
派生,但您希望您的对象在应用程序域之间可变,那么您必须编写一个包装器,通过接口实现您想要的行为。考虑这个包装器:
using System;
using System.Collections.Generic;
using AThing;
namespace CommonInterface
{
public class MutableListOfThings : MarshalByRefObject
{
private List<Thing> things;
public int Count { get { return things.Count; } }
public MutableListOfThings()
{
things = new List<Thing>();
}
public void Add(Thing thing)
{
things.Add(thing);
}
public Thing this[int n]
{
get { return things[n]; }
set { things[n] = value; }
}
}
}
我们的插件中还有一些与 MutableListOfThings
一起工作的额外方法
public void SetThings(MutableListOfThings mutable)
{
this.mutable = mutable;
}
public void PrintMutableThings()
{
for (int i=0; i<mutable.Count; i++)
{
Console.WriteLine(mutable[i].Value);
}
}
public void ChangeMutableThings()
{
mutable[2].Value = "Mutable!";
mutable.Add(new Thing("D"));
}
以及我们的测试方法
static void Demo6()
{
MutableListOfThings mutable = new MutableListOfThings();
AppDomain appDomain1 = CreateAppDomain("PlugIn1");
IPlugIn plugin1 = InstantiatePlugin("PlugIn1", appDomain1);
plugin1.SetThings(mutable);
mutable.Add(new Thing("A"));
mutable.Add(new Thing("B"));
mutable.Add(new Thing("C"));
plugin1.PrintMutableThings();
plugin1.ChangeMutableThings();
Console.WriteLine("\r\n");
for (int i = 0; i < mutable.Count; i++)
{
Console.WriteLine(mutable[i].Value);
}
}
现在看看发生了什么
为什么这会起作用?它之所以起作用,是因为 MutableListOfThings
是按引用传递的,所以即使它包含一个未按引用传递的 List<Thing>
对象,我们也始终通过我们的单一引用来操作列表。
当然,当 Thing
不派生自 MarshalByRefObject
时,事情就会变得非常奇怪
现在,Thing
是按值传递的,所以“C
”的条目没有改变,但是对列表的更改(添加“D
”),被我们的包装类封装,在两个域中都可见!
这应该有助于(或阻碍)认识到跨应用程序域工作并非易事。
一个应用程序域一个程序集?
您希望在运行时替换的每个程序集都需要加载到其自己的应用程序域中,这样您就不会意外卸载带有不应移除的其他程序集的应用程序域。这会产生大量需要管理的应用程序域!
性能如何?
这是测试代码:
static void Demo7()
{
DateTime now = DateTime.Now;
int n = 0;
IPlugIn plugin1 = new PlugIn1.PlugIn(); // Instantiate in our app domain.
List<Thing> things = new List<Thing>() { new Thing("A"), new Thing("B"), new Thing("C") };
while ((DateTime.Now - now).TotalMilliseconds < 1000)
{
plugin1.SetThings(things);
++n;
}
Console.WriteLine("Called SetThings {0} times.", n);
// In a separate appdomain:
AppDomain appDomain1 = CreateAppDomain("PlugIn1");
plugin1 = InstantiatePlugin("PlugIn1", appDomain1);
now = DateTime.Now;
n = 0;
while ((DateTime.Now - now).TotalMilliseconds < 1000)
{
plugin1.SetThings(things);
++n;
}
Console.WriteLine("Called SetThings across AppDomain {0} times.", n);
}
跨应用程序域进行序列化会导致糟糕的性能
不跨应用程序域时超过 120 万次调用,跨应用程序域时不到 6000 次调用。
即使我们传递的是引用(测试用例已更改为使用 MutableListOfThings
对象)
static void Demo8()
{
DateTime now = DateTime.Now;
int n = 0;
IPlugIn plugin1 = new PlugIn1.PlugIn(); // Instantiate in our app domain.
MutableListOfThings mutable = new MutableListOfThings();
mutable.Add(new Thing("A"));
mutable.Add(new Thing("B"));
mutable.Add(new Thing("C"));
while ((DateTime.Now - now).TotalMilliseconds < 1000)
{
plugin1.SetThings(mutable);
++n;
}
Console.WriteLine("Called SetThings {0} times.", n);
// In a separate appdomain:
AppDomain appDomain1 = CreateAppDomain("PlugIn1");
plugin1 = InstantiatePlugin("PlugIn1", appDomain1);
now = DateTime.Now;
n = 0;
while ((DateTime.Now - now).TotalMilliseconds < 1000)
{
plugin1.SetThings(mutable);
++n;
}
Console.WriteLine("Called SetThings across AppDomain {0} times.", n);
}
我们注意到性能仍然很糟糕
结论
使用应用程序域并非易事——类必须是可序列化的,关于按值传递还是按引用传递存在设计考虑和限制,而且性能也很差。那么,为了能够热插拔程序集,确保一切井井有条是否值得呢?嗯,“视情况而定”就是答案!
历史
- 2016 年 4 月 11 日:初始版本