WPF

Arquitetura para a hospedagem de plug-ins do .NET de terceiros

Gennady Slobodsky
Levi Haskell

Baixar o código de exemplo

Em novembro passado, a Bloomberg L.P. lançou o App Portal, uma plataforma de aplicativos que permite que os desenvolvedores de software de terceiros independentes vendam seus aplicativos baseados no Microsoft .NET Framework Windows Presentation Foundation (WPF) para mais de 300.000 usuários do serviço Bloomberg Professional.

Neste artigo, apresentaremos uma arquitetura de finalidade geral semelhante à empregado pelo Bloomberg App Portal para hospedar aplicativos .NET "não confiáveis" de terceiros. O código-fonte acompanhante (archive.msdn.microsoft.com/mag201308Plugins) contém uma implementação de referência de um host plug-in .NET e um plug-in de demonstração usando a API da Bloomberg para traçar informações de preços históricas para certa segurança.

Arquitetura da hospedagem de plug-ins

A arquitetura apresentada na Figura1 consiste em um processo de aplicativo principal e um processo de hospedagem de plug-ins.

Architecture for Hosting .NET Plug-Ins
Figura 1 Arquitetura para a hospedagem de plug-ins do .NET

Os desenvolvedores que implementam uma infraestrutura de hospedagem de plug-ins devem considerar cuidadosamente os prós e contras da implementação de um host de plug-in como um processo separado. No caso do cenário do App Portal, acreditamos que os prós dessa abordagem compensam significativamente os contras, mas listamos os fatores mais importantes que você deve considerar.

Os prós de implementar um host de plug-in como um processo separado são:

  • Isso dissocia o processo do aplicativo principal dos plug-ins e, em consequência, reduz a possibilidade de impacto negativo que os plug-ins podem ter sobre o desempenho ou a usabilidade do aplicativo. Diminui o risco de os plug-ins bloquearem o thread da interface de usuário do aplicativo principal. Além disso, é menos propenso a causar vazamento de memória ou de outros recursos essenciais no processo principal. Essa abordagem também reduz a possibilidade de um plug-in mal-escrito interromper o processo principal do aplicativo, causando exceções não tratadas "gerenciadas" ou "não gerenciadas".
  • Pode melhorar potencialmente a segurança de toda a solução, aplicando tecnologias de área restrita similares às usadas pelo The Chromium Projects (consulte bit.ly/k4V3wq para obter detalhes).
  • Deixa mais espaço de memória virtual disponível para o processo do aplicativo principal (isso é mais importante para os processos de 32 bits, que são limitados a 2 GB de espaço de memória virtual disponível para processar código no modo de usuário).
  • Ele permite que você estenda a funcionalidade de aplicativos não .NET usando plug-ins do .NET.

Os contras estão principalmente relacionados ao aumento da complexidade da implementação geral:

  • Você precisa implementar um mecanismo de comunicação entre processos (IPC) separado (atenção especial deve ser dedicada ao controle de versão da interface do IPC quando o processo do aplicativo principal e o host de plug-in têm diferentes ciclos de lançamento ou de implantação).
  • Você deve gerenciar o ciclo de vida do processo de hospedagem de plug-ins.         

Cuidar da segurança do usuário é uma das principais preocupações na elaboração do processo de hospedagem para plug-ins de terceiros não confiáveis. Definir uma arquitetura de segurança adequada é digna de uma conversa à parte e está além do escopo deste artigo.

O domínio do aplicativo .NET (classe System.AppDomain) fornece uma solução abrangente e robusta para hospedar plug-ins .NET.

Um AppDomain tem os seguintes recursos eficientes:

  • Objetos fortemente tipados em um AppDomain não podem acessar objetos diretamente em outro AppDomain, permitindo que o host imponha o isolamento de um plug-in de outro.
  • Um AppDomain pode ser configurado individualmente, permitindo que o host ajuste o AppDomain para diferentes tipos de plug-ins, fornecendo parâmetros de configuração diferentes.
  • Um AppDomain pode ser descarregado, permitindo que o host descarregue os plug-ins e todos os assemblies associados, com exceção dos assemblies carregados como domínio neutro (usando as opções de otimização de carregador LoaderOptimization.Multi­Domain ou LoaderOptimization.MultiDomainHost). Esse recurso torna o processo de hospedagem mais robusto, permitindo que o host descarregue os plug-ins que falham no código gerenciado.

Os processos do aplicativo principal e do host de plug-in podem se comunicar entre si usando um dos vários mecanismos IPC disponíveis, como COM, pipes nomeados, WCF (Windows Communication Foundation) e assim por diante. Em nossa arquitetura proposta, o papel do processo do aplicativo principal é gerenciar a criação de uma interface de usuário composta e fornecer vários serviços de aplicativos para plug-ins. A Figura2 mostra a exibição do Bloomberg Launchpad, que representa essa interface de usuário composta. Um componente "Stealth Analytics" é criado e processado por um plug-in baseado no WPF hospedado pelo Bloomberg App Portal, enquanto todos os outros componentes são criados e renderizados por um aplicativo Bloomberg Terminal baseado no Win32. O processo do aplicativo principal envia comandos para um processo de hospedagem de plug-ins por meio do plug-in Controller Proxy.

An Example Composite UI
Figura 2 Exemplo de interface de usuário composta

Um controlador de plug-ins está em execução no AppDomain padrão do processo do host de plug-in e é responsável por processar os comandos recebidos do processo do aplicativo principal, carregar plug-ins em AppDomains dedicados e gerenciar seus tempos de vida útil.

O código-fonte de exemplo fornece uma implementação de referência da nossa arquitetura e consiste na infraestrutura de hospedagem e nos plug-ins SAPP e DEMO.

Estrutura de diretórios do aplicativo

Conforme mostrado na Figura 3, o diretório base do aplicativo contém três assemblies:

  • Main.exe representa o processo do aplicativo principal e fornece a interface de usuário para inicializar plug-ins.
  • PluginHost.exe representa do processo de hospedagem de plug-ins.
  • Hosting.dll contém o PluginController, responsável por instanciar plug-ins e gerenciar seus tempos de vida útil.

The Base Application Directory Structure
Figura 3 Estrutura do diretório base do aplicativo

Os assemblies de API fornecidos para uso pelos plug-ins são implantados em um subdiretório separado chamado PAC, que significa Private Assembly Cache, um conceito semelhante ao .NET Global Assembly Cache (GAC), mas que, como o nome sugere, contém itens que são particulares ao aplicativo.

Cada plug-in é implantado em seu próprio subdiretório sob a pasta Plugins. O nome da pasta corresponde à mnemônica de quatro letras do plug-in usada para iniciá-lo a partir da linha de comando da interface de usuário. A implementação de referência contém dois plug-ins. O primeiro, associado com à mnemônica SAPP, é um WPF UserControl vazio que simplesmente imprime seu nome. O segundo, associado à mnemônica DEMO, mostra um gráfico de histórico de preços para certa segurança usando a API de desktop (DAPI) da Bloomberg.

Dentro de cada subdiretório de plug-ins há um arquivo Metadata.xml e um ou mais assemblies .NET. O Metadata.xml do SAPP contém o título do plug-in (usado como o título da janela do plug-in) e os nomes de MainAssembly e MainClass do plug-in, implementando o ponto de entrada do plug-in:

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

Iniciando os plug-ins

Um processo de hospedagem de plug-ins cria uma única instância do PluginController no AppDomain padrão no momento da inicialização. O processo principal do aplicativo usa o .NET Remoting para chamar o método PluginController.Launch(string[] args) para iniciar o plug-in associado à mnemônico especificada pelo usuário (SAPP ou DEMO na implementação de referência de exemplo). A instância de PluginController deve substituir o método InitializeLifetimeService herdado de System.MarshalByRefObject para estender sua própria vida útil. Caso contrário, o objeto será destruído após cinco minutos (a vida útil padrão do MarshalByRefObject):

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

O PluginController define o diretório base para o novo App­Domain conforme a estrutura do diretório mostrada na Figura 3:

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

Usar um diretório base diferente para o App­Domain tem as seguintes ramificações importantes:

  • Melhora o isolamento entre os plug-ins.
  • Simplifica o processo de desenvolvimento usando o local do assembly principal do plug-in como um diretório base da mesma forma que um aplicativo .NET autônomo.
  • Requer uma lógica de infraestrutura de hospedagem especial para localizar e carregar a infraestrutura e os assemblies PAC localizados fora diretório base do plug-in.

Ao iniciar um plug-in, primeiramente criamos um novo AppDomain de hospedagem de plug-ins:

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

Em seguida, carregamos o Hosting.dll no novo AppDomain recém-criado, criamos uma instância da classe PluginContainer e chamamos o método Launch para instanciar o plug-in.

Aparentemente, a maneira mais fácil de realizar essas tarefas seria usar o método AppDomain.CreateInstanceFromAndUnwrap, porque essa abordagem permite que você especifique diretamente o local do assembly. No entanto, essa abordagem fará com que Hosting.dll seja carregado no contexto de carga, não no contexto padrão. Usando o contexto de carga terá um número de efeitos colaterais sutis, como a incapacidade de usar imagens nativas ou carregar assemblies como domínio neutro. O tempo de inicialização maior do plug-in será o mais óbvio resultado negativo usando o contexto de carga. Mais informações sobre contextos de carga de assembly podem ser encontradas na página da biblioteca do MSDN, “Best Practices for Assembly Loading” bit.ly/2Kwz8u.

Uma abordagem muito melhor é usar AppDomain.CreateInstanceAndUnwrap e especificar o local do Hosting.dll e dos assemblies dependentes nas informações de configuração XML do AppDomain, usando o elemento <codeBase>. Na implementação de referência, geramos dinamicamente a configuração XML e o atribuímos ao novo AppDomain usando o método AppDomainSetup.SetConfigurationBytes. Um exemplo do XML gerado é mostrado na Figura 4.

Figura 4 Um exemplo de configuração de AppDomain gerada

<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>

A classe PluginContainer derivada de System.MarshalBy­RefObject não precisa substituir o gerenciamento da vida útil do padrão como no caso da classe PluginController, pois lida com apenas uma única chamada remota (o método Launch) imediatamente após a criação:

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

O método Launch da classe PluginContainer cria um thread da interface de usuário para o plug-in e define o estado Apartment COM como STA (Single-Threaded Apartment) como o WPF exige:

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

O método Run da classe PluginContainer (veja a Figura 5) é o método de inicialização de thread da interface de usuário do plug-in. Ele extrai os nomes de assembly principal do plug-in e a classe principal especificada pelo MainAssembly, bem como os elementos MainClass do arquivo Metadata.xml, carrega o assembly principal e usa a reflexão para encontrar o ponto de entrada na classe principal.

Figura 5 Método Run da classe PluginContainer

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);
}

Na implementação de referência, o ponto de entrada é definido como um método público estático da classe principal chamada CreateRootElement, aceitando uma matriz de cadeias de caracteres como um argumento de inicialização e retornando uma instância de System.Windows.UIElement.

Depois de chamar o método de ponto de entrada, podemos encapsular seu valor de retorno em um objeto de janela do WPF e iniciar o plug-in. O método Run da classe System.Windows.Application, mostrado na Figura 5, entra em um loop de mensagens e não retorna até que janela principal do plug-in seja fechada. Depois disso, nós programamos o descarregamento do AppDomain do plug-in e apagamos todos os recursos que ele estava usando.

O plug-in DEMO

O aplicativo do plug-in DEMO fornecido como parte da implementação de referência pode ser iniciado usando o comando DEMO IBM Equity. Ele demonstra como é fácil iniciar a criação de aplicativos atraentes voltados para profissionais do setor financeiro usando nossa arquitetura proposta, a API da Bloomberg e o WPF.

O plug-in DEMO exibe as informações históricas de preços para certa segurança, que é a funcionalidade encontrada em qualquer aplicativo financeiro (veja a Figura 6). O plug-in DEMO usa a DAPI da Bloomberg e requer uma assinatura válida para o serviço Bloomberg Professional. Há mais informações sobre a ordem da API da Bloomberg disponíveis em openbloomberg.com/open-api.

The DEMO Plug-In
Figura 6 O plug-in DEMO

O XAML mostrado na Figura 7 define a interface de usuário do plug-in DEMO. Os pontos de interesse são a instanciação das classes Chart, LinearAxis, DateTimeAxis e LineSeries, bem como a configuração de associação para LineSeries DependentValuePath e IndependentValuePath. Decidimos usar o WpfToolkit para a visualização de dados, pois funciona bem em um ambiente parcialmente confiável, fornece a funcionalidade necessária e está licenciado sob a Licença Pública da Microsoft (MS-PL).

Figura 7 O XAML do plug-in DEMO

<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>

Para acessar a API da Bloomberg, uma referência ao assembly Bloomberglp.Blpapi deve ser adicionada à lista de referências do projeto, e o código a seguir deve ser adicionado à lista de instruções usando:

using Bloomberglp.Blpapi;

O aplicativo é iniciado estabelecendo uma nova sessão de API e obtendo um objeto RDS (Serviços de Dados de Referência), usado para preços estáticos, dados históricos, solicitações de tique e barra de intraday, como mostrado na Figura 8.

Figura 8 Obtendo um objeto do Serviço de Dados de Referência

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

O próximo passo é solicitar as informações históricas sobre preços para certa segurança de mercado.

Crie um objeto Request do tipo HistoricalDataRequest e construa a solicitação especificando a segurança, o campo (PX_LAST -last price), periodicidade e datas de início e término no formato AAAAMMDD (veja a Figura 9).

Figura 9 Solicitando informações históricas sobre preços

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);
}

Os últimos passos, mostrados na Figura 10, são processar a mensagem de resposta do RDS assincronicamente, construir uma série de tempo e visualizar os dados configurando a propriedade _chart.DataContext.

Figura 10 Processando a mensagem de resposta do Serviço de Dados de Referência

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;
  });
}

Crie o seu próprio

Apresentamos uma arquitetura genérica para hospedar plug-ins baseados no WPF do .NET não confiáveis que foi usada com sucesso na implementação da plataforma Bloomberg App Portal. O código para download que acompanha este artigo pode ajudá-lo a criar sua própria solução de hospedagem de plug-ins ou pode inspirá-lo a criar um aplicativo para o Bloomberg App Portal usando a API da Bloomberg.

Gennady Slobodsky *é gerente de pesquisa e desenvolvimento e arquiteto na Bloomberg L.P. Ele é especialista na criação de produtos utilizando tecnologias da Microsoft e de código-fonte aberto. Morador de Gotham, ele gosta de fazer longas caminhadas no Central Park e ao longo do Museum Mile. *

Levi Haskell é líder da equipe de pesquisa e desenvolvimento e arquiteto na Bloomberg L.P. Ele adora dissecar os componentes internos do .NET e criar sistemas para empresas.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: Reid Borsuk (Microsoft) e David Wrighton (Microsoft)