65.9K
CodeProject 正在变化。 阅读更多。
Home

Baktun Shell:在另一个进程中托管 WPF 子窗口

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (30投票s)

2012 年 12 月 27 日

CPOL

11分钟阅读

viewsIcon

120774

Baktun Shell 是一个演示应用程序,它在单独的进程中托管其子窗口。

更新:2014 年 3 月 11 日

我已在 MSDN Magazine 上发布了 Baktun Shell 新版本的描述。它也已在 GitHub 上发布。事实上,这是一个全新的项目,它基于与 Baktun Shell 相同的原理从头开始创建的。这个版本更接近于实际的生产系统。它允许宿主和插件相互调用,处理宿主或插件的“突然死亡”,添加了更健壮的错误日志记录等等。

旧的 Baktun Shell 仍然可以在“branches/1.0”文件夹下找到。

目录

什么是 Baktun Shell

Baktun Shell 是一个 WPF 应用程序,它在单独的进程中托管子窗口。它

  1. 在磁盘上定位插件程序集。
  2. 允许用户选择要加载的插件。
  3. 在自己的进程中运行插件。
  4. 从插件中实例化一个 UserControl 并将其显示为选项卡控件的选项卡。

为什么这是一个好主意?

在另一个进程中托管在许多方面都很有用

  • 通过隔离实现可靠性:插件运行在自己的地址空间中,不会干扰其他插件的数据
  • 随时卸载:插件可以随时安全地卸载。
  • 混合 32 位和 64 位代码:由于每个进程要么是 32 位,要么是 64 位,因此无法在单个进程中混合两种类型的代码。

进程隔离的其他好处,这些好处尚未在此演示中实现

  • 为每个插件单独配置:可以为每个插件提供自己的app.config文件。
  • 混合 CLR 版本:通过应用每个插件的配置,应该可以运行一个插件作为 .NET 4.0,而另一个作为 .NET 4.5 等。

什么是 Baktun?

Baktun 是古玛雅历法中的一个时间段,大致相当于 400 年。12th Baktun 结束于 2012 年 12 月 20 日,引发了关于(又一次)世界末日的广泛传闻。当我于 12 月 21 日完成 Shell 代码时,我注意到世界末日并没有发生,第 13 个 Baktun 已经顺利开始。为了纪念这一事件,我决定将我的程序命名为 Baktun Shell。毕竟,我们还要再等大约 400 年才能再次庆祝 Baktun 的开始。

屏幕截图

Screnshot1
在 Baktun Shell 中运行的 3D 分子查看器。该查看器由 InterKnowlogy 创建。
有关更多信息,请参阅 3dmoleculeviewer.codeplex.com
Screnshot2
在 32 位 Shell 中运行的 64 位插件。请注意插件分配的虚拟和物理内存量。

如何使用 Shell

下载并解压项目,然后使用 Visual Studio 2010 或 2012 编译解决方案。启动后,Shell 会分析其二进制目录中的程序集,并允许您选择要加载的程序集和类。只显示派生自 UserControl 的类。Shell 进程本身是 32 位的。插件可以加载为 32 位或 64 位。在 DEBUG 模式下,插件进程会创建带有控制台窗口,显示一些诊断信息。在 RELEASE 模式下,这些窗口会被隐藏。

Shell 核心项目包括:Shell、Interfaces、PluginHost、PluginHost64。

SamplePlugin 项目包含一些简单的插件

SamplePluginControl一个带有渐变背景的用户控件
PluginWithInput一个带有文本框和消息框的插件
BitnessCheck一个显示其是 32 位还是 64 位,并且可以分配大量内存的插件

我还包含了来自 CodePlex 的两个第三方应用程序:3D MoleculeViewer 和 Smith HTML Editor。前者需要稍微修改,将主窗口转换为 UserControl。后者按原样使用,因为它已经是用户控件。

简而言之,它是如何工作的

WPF 控件无法直接在进程之间进行封送。但是,可以使用 INativeHandleContract 接口,并使用 FrameworkElementAdapters 类(来自 MAF 技术堆栈)将其在进程之间进行封送。

Marshalling Diagram

这个原始的方案开箱即用并不完全奏效,但经过一点调整就可以成功地在进程之间封送 WPF 控件。

现有的插件框架和隔离

GUI 应用程序分解为 Shell(宿主)和插件(附加组件、扩展、模块)的场景并非新颖。多年来,已经开发了许多框架来促进这一点:OLECABMAFMEFPrism 等等。其中只有 MAF、MEF 和MOFPrism 与 WPF 应用程序相关。

当 Shell 加载插件时,它有三种合理的插件隔离选择

  • 将插件程序集加载到 Shell 的 AppDomain 中,即无隔离,
  • 在 Shell 进程中运行插件,但在单独的 AppDomain 中,
  • 在自己的进程中运行插件,

也许后面还会出现“在另一台机器上运行插件”、“在另一个国家运行插件”和“在另一个星球运行插件”,但这里跑题了。

更高级别的隔离通常意味着更多的配置和更多的开销,但同时也带来更多的自由度和更高的可靠性。例如,如果我们想随时卸载插件,我们必须使用单独的 AppDomains。AppDomains 提供了一定程度的对数据损坏和故障的保护,但进程提供了更强的保护。如果我们想混合 32 位和 64 位插件,或者混合不同版本的 CLR,我们必须使用单独的进程。

不幸的是,MEF 和 Prism 都不能开箱即用地提供隔离支持。然而,请参阅 Piotr Włodek 的帖子,了解 MEF 可能的隔离解决方案。MAF 支持隔离,甚至还有一个粗略示例,用于跨进程 WPF 组件,部分上下文在此讨论串中给出。

MAF 最大的麻烦在于它非常、非常复杂。Baktun Shell 使用 MAF 的机制来封送 WPF 控件,但绕过了 MAF 管道模型的所有其他部分。这使得它更简单,更容易使用。

Baktun Shell 的内部工作原理

加载插件

Shell 的主窗口包含一个标准的 TabControl,并稍微增强了每个选项卡的“关闭”按钮。当用户单击“加载”按钮时,主窗口会请求 PluginHostProxy 类创建一个新的 Plugin 实例,并为其创建一个选项卡。PluginHostProxy 类负责启动子进程并与之通信,该子进程将托管插件。

Plugin Creation

卸载插件

当用户单击 [x] 按钮或整个应用程序关闭时,MainWindow 会从选项卡控件中移除插件并调用其 Dispose()。这会通知 PluginHostProxy,它会请求插件宿主进程终止自身。

Plugin Disposal

启动插件宿主进程

PluginHostProxy 收到加载插件的请求时,它会启动一个新进程。进程可执行文件是PluginHost.exePluginHost64.exe,具体取决于请求的位宽。进程在其命令行中接收一个基于 GUID 的唯一进程名称,例如

PluginHost64.exe PluginHost.f3287246-6b77-48de-826c-6d383c42124e

插件宿主进程设置了一个类型为 PluginHostLoader 的远程服务,监听 URL ipc://PluginHost.f3287246-6b77-48de-826c-6d383c42124e/PluginHostLoader。当远程服务器就绪后,插件宿主会发出一个名为“ready”的事件。在这种情况下,事件名称将是 "PluginHost.f3287246-6b77-48de-826c-6d383c42124e.Ready"

收到“ready”信号后,Shell 进程中的 PluginHostProxy 实例会在 ipc://PluginHost.f3287246-6b77-48de-826c-6d383c42124e/PluginHostLoader 请求一个类型为 IPluginLoader 的远程对象,其中 IPluginLoader 定义如下

public interface IPluginLoader
{
    INativeHandleContract LoadPlugin(string assembly, string typeName);
 
    [OneWay]
    void Terminate();
}  

PluginHostProxy 然后调用 IPluginLoader.LoadPlugin(),传入插件程序集和类型名称。远程进程中的 PluginLoader 类加载请求的程序集,创建请求类型的实例,将其转换为 INativeHandleContract 并将其返回给 Shell 进程。然后 Shell 进程将 INativeHandleContract 转换为 FrameworkElement,使其成为新的 Plugin 实例的一部分,并将其添加到主选项卡控件中。

Process Spinoff

我曾考虑过一个反向安排,即 Shell 设置远程服务器,插件宿主进行调用。这消除了“ready”事件的需要,但给错误报告带来了困难。插件创建错误(如果有)会报告给插件宿主,而插件宿主无法轻松地将其报告回 Shell。这肯定可以解决,但总的解决方案似乎比 Shell“驱动”的解决方案更复杂。此外,插件宿主进程无论如何都需要实现某种类型的服务器,以便 Shell 可以告诉它终止:创建 TerminateProcess() 并不好。

终止插件进程

这个更容易。当插件被释放时,它会发出 Disposed 事件,其父 PluginHostProxy 已订阅该事件。然后 PluginHostProxy 会调用 IPluginLoader.Terminate(),它会正常结束插件宿主进程。

Process Shutdown

跨进程远程调用的特性

初始化远程服务器

我们被迫使用 Remoting 而不是 WCF,因为我们想封送 INativeHandleContract,它没有标记 [ServiceContract] 属性。因此,WCF 不同意封送它。

到目前为止,我遇到的最常见的远程调用形式是在同一进程中的两个 AppDomain 之间进行远程调用。进程间远程调用在某些方面有所不同。特别是,您必须手动初始化您的通道并注册服务。在进行此操作时,您必须记住,默认情况下不允许从方法调用返回 MarshalByRefObject。要启用它,必须使用二进制格式化程序,并将 TypeFilterLevel 设置为 Full,请参见下面的代码。

另一个障碍是服务器将监听的 URL 是在一个具有魔术名称"portname"的属性哈希表中定义的。在我们的例子中,“portname”是 Shell 传递给宿主进程的唯一名称,例如 PluginHost.f3287246-6b77-48de-826c-6d383c42124e。我们在 URI PluginLoader 处注册了一个类型为 PluginLoader 的已知服务,激活方式为 Singleton。当我们首次调用该服务时,远程系统会代表我们创建一个 PluginLoader 实例。

由于我们的远程通道是 IPC 通道,我们的“portname”是 PluginHost.f3287246-6b77-48de-826c-6d383c42124e,我们的服务 URI 是 PluginLoader,因此 Shell 端使用的完整服务 URI 是

ipc://PluginHost.f3287246-6b77-48de-826c-6d383c42124e/PluginLoader 

这是来自 PluginHost\Program.cs 的完整远程服务器初始化代码

var serverProvider = new BinaryServerFormatterSinkProvider { TypeFilterLevel = TypeFilterLevel.Full };
var clientProvider = new BinaryClientFormatterSinkProvider();
var properties = new Hashtable();
properties["portName"] = name;

var channel = new IpcChannel(properties, clientProvider, serverProvider);
ChannelServices.RegisterChannel(channel, false);

RemotingConfiguration.RegisterWellKnownServiceType(
    typeof(PluginLoader), "PluginLoader", WellKnownObjectMode.Singleton);

客户端类型转换和内部方法调用

进程间远程调用的另一个特性是它以意想不到的方式处理客户端类型转换。为了说明这一点,让我从导致此发现的苦难的开始说起。在 PluginLoader 类中,我曾经有以下代码

class PluginLoader : MarshalByRefObject, IPluginLoader
{
    public INativeHandleContract LoadPlugin(string assembly, string typeName)
    {
        ...
        var contract = (INativeHandleContract)
                       Program.Dispatcher.Invoke(createOnUiThread, assembly, typeName);
        return contract; // does not work as expected!
    }
    ...
} 

这段代码在客户端引发了以下异常

System.Runtime.Remoting.RemotingException: 
Permission denied: cannot call non-public or static methods remotely.
Server stack trace: 
   ...
   at System.AddIn.Pipeline.AddInHwndSourceWrapper.RegisterKeyboardInputSite(AddInHostSite hwndHost)
   at MS.Internal.Controls.AddInHost..ctor(INativeHandleContract contract)
   at System.AddIn.Pipeline.FrameworkElementAdapters.ContractToViewAdapter(INativeHandleContract nativeHandleContract)
   at Shell.PluginHostProxy.LoadPlugin(String assemblyName, String typeName) 
   at Shell.MainViewModel.Load()

这里发生的情况是,我们从插件宿主进程返回了一个 INativeHandleContract,它无法在客户端转换回 FrameworkElement。失败发生在内部类 MS.Internal.Controls.AddInHost 的构造函数中。借助 .NET Reflector 的帮助,发现在这段代码看起来像这样

// from Reflector
1: internal AddInHost(INativeHandleContract contract) : base(true)
2: {
3:    _contractHandle = new ContractHandle(contract);
4:    _addInHwndSourceWrapper = contract as AddInHwndSourceWrapper;
5:    if (_addInHwndSourceWrapper != null)
6:    {
7:        _addInHwndSourceWrapper.RegisterKeyboardInputSite(new AddInHostSite(this));
8:    }
9: }

这段代码失败的原因如下。当 Shell 通过远程调用 PluginLoader.LoadPlugin() 时,它收到了一个 INativeHandleContract 引用。但是,远程系统保留了它在远程进程中真实类型的相关信息。“as”转换(第 4 行)成功,并且“if”条件(第 5 行)为真。

当我们在第 7 行调用 AddInHwndSourceManager.RegisterKeyboardInputSite() 时,远程系统意识到这是一个内部方法调用,出于安全原因,不允许跨进程进行此类调用。换句话说,客户端可以将代理转换为内部类型是可以的,但调用任何内部方法是不允许的。请注意,当调用同一进程中的另一个 AppDomain 时,此限制不适用。这就是为什么这段代码在 AppDomains 中可以正常工作的原因。

解决此问题的方法是返回一个实现 INativeHandleContract 但不继承自 AddInHwndSourceWrapper 的对象。为此,我创建了 NativeHandleContractInsulator 类。这是一个简单的装饰器,它将其所有方法转发给真实的 INativeHandleContract。它唯一存在的目的是防止客户端发生不期望的类型转换。

因此,PluginLoader.LoadPlugin() 的工作实现如下所示

class PluginLoader : MarshalByRefObject, IPluginLoader
{
    public INativeHandleContract LoadPlugin(string assembly, string typeName)
    {
        ...
        var contract = (INativeHandleContract)
                       Program.Dispatcher.Invoke(createOnUiThread, assembly, typeName);
        var insulator = new NativeHandleContractInsulator(contract);
        return insulator; 
    }
    ...
}

修订后的封送对象图

Marshalling Revised

Baktun Shell 的状态和待办事项

尽管 Baktun Shell 代码正在运行,但仍有很大的改进空间

  • 为孤立的插件宿主进程添加“自毁”功能,也许可以使用远程 ISponsor 机制。
  • 添加为插件指定配置文件功能。
  • 将插件宿主的 CLR 版本降低到 3.5,并允许指定插件的 CLR 版本:3.5、4.0、4.5 等。
  • 添加从磁盘任意位置加载插件的功能。
  • 改进插件宿主进程崩溃时的错误报告。

结论

在另一个进程中托管 WPF 窗口需要一些配置,但它效果出奇地好。如果您的任务仅限于视觉集成,Baktun Shell 可能就是您所需要的,或许还需要对上一节中提到的某些改进。如果您的应用程序由于冲突的需求(例如,“模块 X 必须与此旧的 32 位代码交互,但模块 Y 需要 10G 内存”)而无法容纳在单个进程中,那么这种多进程解决方案可能是唯一合理的出路。

© . All rights reserved.