WPF

用于承载第三方 .NET 插件的体系结构

Gennady Slobodsky
Levi Haskell

下载代码示例

去年 11 月,Bloomberg L.P. 发布了应用程序门户,它是一个应用程序平台,独立的第三方软件开发者可借助该平台面向 Bloomberg 专业服务的 300,000 多名用户销售其基于 Microsoft .NET Framework Windows Presentation Foundation (WPF) 的应用程序。

在本文中,我们将介绍一个用来承载第三方“不受信任的”.NET 应用程序的通用体系结构,它与 Bloomberg 应用程序门户使用的体系结构类似。 附带的源代码 (archive.msdn.microsoft.com/mag201308Plugins) 包含对 .NET 插件宿主和演示插件的引用实现,该演示插件使用 Bloomberg API 来绘制给定安全性的历史定价信息图表。

插件宿主体系结构

图1 中显示的体系结构包含主应用程序进程和插件宿主进程。

Architecture for Hosting .NET Plug-Ins
图 1 用于承载 .NET 插件的体系结构

实现插件宿主基础结构的开发人员应仔细考虑将插件宿主作为独立进程来实现的优缺点。 就应用程序门户而言,我们坚信此方法的利远大于弊,但我们会列出您需要注意的最重要因素。

将插件宿主作为独立进程实现的优点包括:

  • 它将主应用程序进程与插件分离开来,这将减少插件对应用程序的性能或可用性产生任何负面影响的可能。 它减小了插件阻止主应用程序的 UI 线程的风险。 此外,它不太可能导致主进程中出现内存或任何其他关键资源泄漏的情况。 此方法还降低了因编写得非常糟糕的插件导致出现“托管”或“未托管”未处理异常,从而减慢主应用程序进程的可能性。
  • 通过应用与 The Chromium Projects(有关详细信息,请参阅 bit.ly/k4V3wq)使用的沙盒技术类似的沙盒技术,有可能提高整个解决方案的安全性。
  • 它为主应用程序进程留出了更多的虚拟内存空间(这对于 32 位进程来说更为重要,此类进程受到进程用户模式代码的 2GB 可用虚拟内存空间的限制)。
  • 它允许您使用 .NET 插件扩展非 .NET 应用程序的功能。

缺点主要与整体实施复杂性的增加相关:

  • 您需要实现单独的进程间通信 (IPC) 机制(当主应用程序进程和插件宿主具有不同的版本或部署循环时,应特别注意 IPC 接口的版本控制)。
  • 您必须管理插件宿主进程的生存期。         

在为不受信任的第三方插件设计宿主进程时,实现用户安全性是需要考虑的主要问题之一。 定义适当的安全体系结构这一主题有必要单独进行讨论,不在本文的范围内。

.NET 应用程序域(System.AppDomain 类)提供了用于承载 .NET 插件的全面而可靠的解决方案。

AppDomain 具有以下强大功能:

  • 一个 AppDomain 中的类型安全的对象无法直接访问另一个 AppDomain 中的对象,从而允许宿主强制将一个插件与另一个插件隔离开。
  • 可以单独配置 AppDomain,从而允许宿主通过提供不同的配置设置来为不同类型的插件微调 AppDomain。
  • 可以卸载 AppDomain,从而允许宿主卸载插件和所有关联的程序集,以非特定于域的形式加载的程序集除外(使用加载程序优化选项 LoaderOptimization.Multi­Domain 或 LoaderOptimization.MultiDomainHost)。 此功能通过允许宿主卸载在托管代码中失败的插件来让宿主进程变得更可靠。

主应用程序和插件宿主进程可使用各种可用的 IPC 机制之一(例如,COM、命名管道、Windows Communication Foundation (WCF) 等)来进行交互。 在建议的体系结构中,主应用程序进程的角色是管理复合 UI 的创建,并提供针对插件的各种应用程序服务。 图2 显示 Bloomberg Launchpad 视图,该视图表示此类复合 UI。 “Stealth Analytics”组件由 Bloomberg 应用程序门户托管的基于 WPF 的插件创建和呈现,所有其他组件由基于 Win32 的 Bloomberg Terminal 应用程序创建和呈现。 主应用程序进程通过插件控制器代理将命令发送到插件宿主进程。

An Example Composite UI
图 2 示例复合 UI

插件控制器正在插件宿主进程的默认 AppDomain 中运行,它负责处理从主应用程序进程接收的命令,从而将插件加载到专用 AppDomains 并管理其生存期。

示例源代码提供了对体系结构的引用实现,并包含托管基础结构以及 SAPP 和 DEMO 插件。

应用程序目录结构

图 3 所示,基本应用程序目录包含三个程序集:

  • Main.exe 表示主应用程序进程并提供用于启动插件的 UI。
  • PluginHost.exe 表示插件宿主进程。
  • Hosting.dll 包含 PluginController,后者负责实例化插件并管理其生存期。

The Base Application Directory Structure
图 3 基本应用程序目录结构

供插件使用的 API 程序集将部署到一个称作 PAC 的单独子目录中,该子目录代表专用程序集缓存,顾名思义,这是一个与 .NET 全局程序集缓存 (GAC) 类似的概念,不过它包含应用程序专用的项目。

每个插件将部署到 Plugins 文件夹下其自身的子目录中。 文件夹的名称与用于从 UI 命令行启动插件的其四字母助记键对应。 引用实现包含两个插件。 第一个插件与助记键 SAPP 关联,它是一个只打印其名称的空 WPF UserControl。 第二个插件与助记键 DEMO 关联,它使用 Bloomberg 桌面 API (DAPI) 显示给定安全性的价格历史记录图表。

每个插件子目录中包含一个 Metadata.xml 文件以及一个或多个 .NET 程序集。 SAPP 的 Metadata.xml 包含插件的标题(用作插件的窗口标题)以及插件 MainAssembly 和 MainClass 的名称,以便实现插件的入口点:

<?xml version="1.0" encoding="utf-8" ?>
<Plugin>
  <Title>Simple App</Titlte>
  <MainAssembly>SimpleApp</MainAssembly>
  <MainClass>SimpleApp.Main</MainClass>
</Plugin>

启动插件

启动时,插件宿主进程将在默认的 AppDomain 中创建单个 PluginController 实例。 应用程序的主进程使用 .NET Remoting 调用 PluginController.Launch(string[] args) 方法来启动与用户输入的助记键(示例引用实现中的 SAPP 或 DEMO)关联的插件。 PluginController 实例必须重写从 System.MarshalByRefObject 继承的 InitializeLifetimeService 方法来扩展自己的生存期,否则该对象将在 5 分钟(MarshalByRefObject 的默认生存期)后被销毁:

public override object InitializeLifetimeService()
{
  return null;
}
Public class PluginController : MarshalByRefObject
{
  // ...
public void Launch(string commandLine)
  {
    // ...
}
}

PluginController 将按照图 3 中显示的目录结构为新的 App­Domain 设置基本目录:

var appPath = Path.Combine(_appsRoot, mnemonic);
var setup = new AppDomainSetup {ApplicationBase = appPath};

对 App­Domain 使用不同的基本目录将产生以下重大影响:

  • 改善各个插件的隔离情况。
  • 通过采用与独立 .NET 应用程序相同的方式将插件主程序集的位置用作基本目录来简化开发过程。
  • 需要特殊的托管基础结构逻辑来查找和加载位于插件基本目录外部的基础结构和 PAC 程序集。

启动插件时,我们会先创建新的插件宿主 AppDomain:

var domain =
    AppDomain.CreateDomain(
    mnemonic, null, setup);

接下来,我们将 Hosting.dll 加载到新创建的 AppDomain 中,再创建 PluginContainer 类的实例,然后调用 Launch 方法来实例化插件。

表面上看,完成这些任务的最简单方式是使用 AppDomain.CreateInstanceFromAndUnwrap 方法,因为可通过此方法直接指定程序集的位置。 但是,使用此方法将导致 Hosting.dll 被加载到 load-from 上下文而非默认上下文中。 使用 load-from 上下文会产生各种细微的负面影响,例如,无法使用本机映像或以非特定于域的形式加载程序集。 插件启动时间增加是使用 load-from 上下文所导致的最明显负面影响。 有关程序集加载上下文的详细信息,请参阅 MSDN 库页面上的“程序集加载的最佳方法”,网址为 bit.ly/2Kwz8u

更好的方法是使用 AppDomain.CreateInstanceAndUnwrap,并使用 <codeBase> 元素指定 Hosting.dll 和依赖程序集在 AppDomain 的 XML 配置信息中的位置。 在引用实现中,我们动态生成配置 XML,并使用 AppDomainSetup.SetConfigurationBytes 方法将其分配给新的 AppDomain。 图 4 显示生成的 XML 的示例。

图 4 生成的 AppDomain 配置的示例

<configuration>
  <runtime>
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
      <dependentAssembly>
        <assemblyIdentity
          name="PluginHost.Hosting"
          publicKeyToken="537053e4e27e3679" culture="neutral"/>
        <codeBase version="1.0.0.0" href="Hosting.dll"/>
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="Bloomberglp.Blpapi"
          publicKeyToken="ec3efa8c033c2bc5" culture="neutral"/>
        <codeBase version="3.6.1.0" href="PAC/Bloomberglp.Blpapi.dll"/>
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="WPFToolkit"
          publicKeyToken="51f5d93763bdb58e" culture="neutral"/>
        <codeBase version="3.5.40128.4" href="PAC/WPFToolkit.dll"/>
      </dependentAssembly>
    </assemblyBinding>
  </runtime>
</configuration>

从 System.MarshalBy­RefObject 派生的 PluginContainer 类不需要重写默认生存期管理(就像 PluginController 类一样),因为它在创建后仅会立即处理单个远程调用(Launch 方法):

var host = (PluginContainer) domain.CreateInstanceAndUnwrap(
  pluginContType.Assembly.FullName, pluginContType.FullName);
host.Launch(args);

PluginContainer 类的 Launch 方法将创建插件的 UI 线程,并将 COM 单元状态设置为 WPF 所需的单线程单元 (STA):

[SecurityCritical]
public void Launch(string[] args)
{
  _args = args;
  var thread = new Thread(Run);
  thread.TrySetApartmentState(ApartmentState.STA);
  thread.Start();
}

PluginContainer 类的 Run 方法(见图 5)是插件的 UI 线程的启动方法。 它提取插件的主程序集、MainAssembly 所指定的主类和 Metadata.xml 文件的 MainClass 元素的名称,加载主程序集,并使用反射来查找主类中的入口点。

图 5 PluginContainer 类的 Run 方法

private void Run()
{
  var metadata = new XPathDocument(
    Path.Combine(AppDomain.CurrentDomain.BaseDirectory, 
      "Metadata.xml"))
    .CreateNavigator().SelectSingleNode("/Plugin");
  Debug.Assert(metadata != null);
  var mainAssembly = (string) metadata.Evaluate("string(MainAssembly)");
  var mainClass = (string) metadata.Evaluate("string(MainClass)");
  var title = (string) metadata.Evaluate("string(Title)");
  Debug.Assert(!string.IsNullOrEmpty(mainAssembly));
  Debug.Assert(!string.IsNullOrEmpty(mainClass));
  Debug.Assert(!string.IsNullOrEmpty(title));
  var rootElement = ((Func<string[], UIElement>) 
    Delegate.CreateDelegate(
    typeof (Func<string[], UIElement>),
    Assembly.Load(mainAssembly).GetType(mainClass),
    "CreateRootElement"))(_args);
  var window =
    new Window
    {
      SizeToContent = SizeToContent.WidthAndHeight,
      Title = title,
      Content = rootElement
    };
  new Application().Run(window);
  AppDomain.Unload(AppDomain.CurrentDomain);
}

在引用实现中,入口点定义为名为 CreateRootElement 的主类的公共静态方法,从而将字符串数组接受为启动参数并返回 System.Windows.UIElement 的实例。

调用入口点方法后,我们将其返回值包装到 WPF 窗口对象中并启动插件。 System.Windows.Application 类的 Run 方法(如图 5 中所示)进入一个消息循环,并且在插件的主窗口关闭前不会返回。 之后,我们将安排卸载插件的 AppDomain 并清理它正在使用的所有资源。

DEMO 插件

可使用命令 DEMO IBM Equity 启动作为引用实现的一部分提供的 DEMO 插件应用程序。 它展示了使用我们建议的体系结构(Bloomberg API 和 WPF)来创建适用于金融专业人士的引人注目的应用程序是多么的容易。

DEMO 插件显示了给定安全性的历史定价信息,任意财务应用程序中都提供了这项功能(见图 6)。 DEMO 插件使用 Bloomberg DAPI 并要求有效订阅 Bloomberg 专业服务。 有关 Bloomberg API 的详细信息,请参阅 openbloomberg.com/open-api

The DEMO Plug-In
图 6 DEMO 插件

图 7 中显示的 XAML 定义了 DEMO 插件的 UI。 需要注意的地方是 Chart、LinearAxis、DateTimeAxis 和 LineSeries 类的实例化以及 LineSeries DependentValuePath 和 IndependentValuePath 的绑定的设置。 我们决定使用 WpfToolkit 进行数据可视化,因为它适用于部分受信任的环境,可提供所需的功能并且在 Microsoft 公共许可 (MS-PL) 下获得许可。

图 7 DEMO 插件 XAML

<UserControl x:Class="DapiSample.MainView"
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:c=
    "clr-namespace:System.Windows.Controls.DataVisualization.Charting;
    assembly=System.Windows.Controls.DataVisualization.Toolkit"
  xmlns:v=
    "clr-namespace:System.Windows.Controls.DataVisualization;
    assembly=System.Windows.Controls.DataVisualization.Toolkit"
  Height="800" Width="1000">
    <Grid>
      <Grid.RowDefinitions>
        <RowDefinition Height="30"/>
        <RowDefinition/>
      </Grid.RowDefinitions>
      <c:Chart x:Name=
        "_chart" Background="White" Grid.Row="1" Visibility="Hidden">
        <c:Chart.Axes>
          <c:LinearAxis x:Name=
            "_linearAxis" Orientation="Y" ShowGridLines="True"/>
          <c:DateTimeAxis x:Name=
            "_DateAxis" Orientation="X" ShowGridLines=
            "True" Interval="1" IntervalType="Months" />
        </c:Chart.Axes>
        <c:LineSeries x:Name=
          "_lineSeries" DependentValuePath="Value"
          IndependentValuePath="Date" ItemsSource="{Binding}"/>
        <c:Chart.LegendStyle>
          <Style TargetType="{x:Type v:Legend}">
            <Setter Property="Width" Value="0"></Setter>
            <Setter Property="Height" Value="0"></Setter>
          </Style>
        </c:Chart.LegendStyle>
      </c:Chart>
    <TextBox Grid.Row="0" x:Name=
      "_security" IsReadOnly="True" TextAlignment="Center"/>
  </Grid>
</UserControl>

若要访问 Bloomberg API,应将对 Bloomberglp.Blpapi 程序集的引用添加到项目引用列表中,并且必须将以下代码添加到 using 语句的列表中:

using Bloomberglp.Blpapi;

该应用程序首先会建立新的 API 会话并获取 Reference Data Service (RDS) 对象开始,用于静态定价、历史数据和当日刻度线和柱状图请求,如图 8 中所示。

图 8 获取 Reference Data Service 对象

private Session _session;
private Service _refDataService;
var sessionOptions = new SessionOptions
  {
    ServerHost = "localhost",
    ServerPort = 8194,
    ClientMode = SessionOptions.ClientModeType.DAPI
  };
_session = new Session(sessionOptions, ProcessEventCallBack);
if (_session.Start())
{
  // Open service
  if (_session.OpenService("//blp/refdata"))
  {
    _refDataService = _session.GetService("//blp/refdata");
  }
}

下一步是请求给定市场安全性的历史定价信息。

创建类型为 HistoricalDataRequest 的 Request 对象,并通过指定安全性、字段(PX_LAST - 最新价格)、周期性以及格式为 YYYYMMDD 的开始日期和结束日期来构建请求(见图 9)。

图 9 请求历史定价信息

public void RequestReferenceData(
  string security, DateTime start, DateTime end, string periodicity)
{
  Request request = _refDataService.CreateRequest("HistoricalDataRequest");
  Element securities = request.GetElement("securities");
  securities.AppendValue(security);
  Element fields = request.GetElement("fields");
  fields.AppendValue("PX_LAST");
  request.Set("periodicityAdjustment", "ACTUAL");
  request.Set("periodicitySelection", periodicity);
  request.Set("startDate", string.Format(
    "{0}{1:D2}{2:D2}", start.Year, start.Month, start.Day));
  request.Set("endDate", string.Format(
    "{0}{1:D2}{2:D2}", end.Year, end.Month, end.Day));
  _session.SendRequest(request, null);
}

最后的步骤(如图 10 中所示)是异步处理 RDS 响应消息,构建时间序列并通过设置 _chart.DataContext 属性来可视化数据。

图 10 处理 Reference Data Service 响应消息

private void ProcessEventCallBack(Event eventObject, 
    Session session)
{
  if (eventObject.Type == Event.EventType.RESPONSE)
  {
    List<DataPoint> series = new List<DataPoint>();
    foreach (Message msg in eventObject)
    {
      var element = msg.AsElement;
      var sd = element.GetElement("securityData");
      var fd = sd.GetElement("fieldData");
      for (int i = 0; i < fd.NumValues; i++)
      {
        Element val = (Element)fd.GetValue(i);
        var price = (double)val.GetElement("PX_LAST").GetValue();
        var dt = (Datetime)val.GetElement("date").GetValue();
        series.Add(new DataPoint(
          new DateTime(dt.Year, dt.Month, dt.DayOfMonth),
          price));
      }
      if (MarketDataEventHandler != null)
        MarketDataEventHandler(series);
    }
  }
}
private void OnMarketDataHandler(List<DataPoint> series)
{
  Dispatcher.BeginInvoke((Action)delegate
  {
    _chart.DataContext = series;
  });
}

量身构建

我们介绍了成功用于 Bloomberg 应用程序门户平台的实现的不受信任的基于 .NET WPF 的主机插件的通用体系结构。 本文附带的代码下载可帮助您构建您自己的插件宿主解决方案,或者它可能促使您使用 Bloomberg API 来生成面向 Bloomberg 应用程序门户的应用程序。

Gennady Slobodsky 是 Bloomberg L.P. 的研发经理和架构师, 他擅长于使用 Microsoft 技术和开放源代码技术来构建产品。 他居住在纽约,喜欢中央公园和博物馆大道这种长距离的散步。

Levi Haskell 是 Bloomberg L.P. 的研发团队主管和架构师, 他喜欢分析 .NET 内部信息并构建企业系统。

衷心感谢以下技术专家对本文的审阅: Reid Borsuk (Microsoft) 和 David Wrighton (Microsoft)