WPF

Erstellen fehlertoleranter zusammengesetzter Anwendungen

Ivan Krivyakov

Beispielcode herunterladen.

Es gibt großen Bedarf an zusammengesetzten Anwendungen mit unterschiedlichen Anforderungen an die Fehlertoleranz. In einigen Szenarien mag es unproblematisch sein, wenn ein einzelnes fehlerhaftes Plug-In zum Ausfall der gesamten Anwendung führt. In anderen Szenarien hingegen ist dies nicht akzeptabel. Ich beschreibe in diesem Artikel eine Architektur für eine fehlertolerante zusammengesetzte Desktopanwendung. Die vorgeschlagene Architektur bietet ein hohes Maß an Isolation, da jedes Plug-In in einem eigenen Windows-Prozess ausgeführt wird. Bei der Erstellung werden die folgenden Entwurfsziele berücksichtigt:

  • Hohe Isolation zwischen Host und Plug-Ins
  • Umfassende visuelle Integration der Plug-In-Steuerelemente in das Hostfenster
  • Einfache Entwicklung neuer Plug-Ins
  • Verhältnismäßig einfache Anpassung vorhandener Anwendungen an Plug-Ins
  • Hostseitig bereitgestellte Dienste lassen sich von Plug-Ins verwenden (und umgekehrt)
  • Verhältnismäßig einfaches Hinzufügen von neuen Diensten und Schnittstellen

Der beigefügte Quellcode (msdn.microsoft.com/magazine/msdnmag0114) enthält zwei Visual Studio 2012-Lösungen: „WpfHost.sln“ und „Plugins.sln“. Kompilieren Sie zunächst den Host und anschließend die Plug-Ins. Die ausführbare Hauptdatei ist „WpfHost.exe“. Die Plug-In-Assemblys werden bei Bedarf geladen. In Abbildung 1 wird die fertige Anwendung dargestellt.

The Host Window Seamlessly Integrates with the Out-of-Process Plug-InsAbbildung 1: Nahtlose Integration des Hostfensters in die prozessexternen Plug-Ins

Übersicht über die Architektur

Der Host zeigt ein Registerkarten-Steuerelement und eine Schaltfläche „+“ in der oberen linken Ecke an, mit der eine Liste der verfügbaren Plug-Ins eingeblendet werden kann. Die Liste der Plug-Ins wird aus der XML-Datei mit dem Namen „plugins.xml“ gelesen, es sind jedoch auch alternative Katalogimplementierungen möglich. Jedes Plug-In wird in einem eigenen Prozess ausgeführt, und es werden keine Plug-In-Assemblys in den Host geladen. In Abbildung 2 wird eine Übersicht über die Architektur dargestellt.

A High-Level View of the Application ArchitectureAbbildung 2: Übersicht über die Anwendungsarchitektur

Intern handelt es sich bei dem Plug-In-Host um eine reguläre WPF(Windows Presentation Foundation)-Anwendung, die dem MVVM(Model-View-ViewModel)-Paradigma folgt. Der Modellteil wird von der PluginController-Klasse dargestellt, die eine Auflistung der geladenen Plug-Ins enthält. Jedes geladene Plug-In wird von einer Instanz der Plugin-Klasse repräsentiert, die ein Plug-In-Steuerelement enthält und mit einem Plug-In-Prozess kommuniziert.

Das Hostsystem besteht aus vier Assemblys, die wie in Abbildung 3 organisiert sind.

The Assemblies of the Hosting SystemAbbildung 3: Die Assemblys des Hostsystems

„WpfHost.exe“ ist die Hostanwendung. „PluginProcess.exe“ ist der Plug-In-Prozess. Eine Instanz dieses Prozesses lädt ein Plug-In. In „WpfHost.Interfaces.dll“ sind die gemeinsamen Schnittstellen enthalten, die vom Host, dem Plug-In-Prozess und den Plug-Ins verwendet werden. „PluginHosting.dll“ umfasst die Typen, die vom Host und dem Plug-In-Prozess zum Hosten der Plug-Ins benötigt werden.

Zum Laden eines Plug-Ins sind einige Aufrufe, die auf dem UI-Thread ausgeführt werden müssen, und einige Aufrufe, die auf einem beliebigen Thread ausführbar sind, erforderlich. Damit die Anwendung reaktionsfähig ist, blockiere ich den UI-Thread nur, wenn dies unbedingt nötig ist. Daher wird die Programmierschnittstelle für die Plugin-Klasse in die beiden Methoden „Load“ und „CreateView“ untergliedert:

class Plugin
{
  public FrameworkElement View { get; private set; }
  public void Load(PluginInfo info); // Can be executed on any thread
  public void CreateView();          // Must execute on UI thread
}

Die Plugin.Load-Methode startet einen Plug-In-Prozess und erstellt die Infrastruktur aufseiten des Plug-In-Prozesses. Sie wird auf einem Arbeitsthread ausgeführt. Die Plugin.CreateView-Methode verbindet die lokale Ansicht mit dem FrameworkElement-Remoteobjekt. Dies muss auf dem UI-Thread ausgeführt werden, um Ausnahmen (wie beispielsweise die InvalidOperationException-Ausnahme) zu verhindern.

Von der Plugin-Klasse wird schließlich eine benutzerdefinierte Plug-In-Klasse innerhalb des Plug-In-Prozesses aufgerufen. Die einzige Anforderung für diese Benutzerklasse besteht darin, dass diese die IPlugin-Schnittstelle aus der WpfHost.Interfaces-Assembly implementiert:

public interface IPlugin : IServiceProvider, IDisposable
{
  FrameworkElement CreateControl();
}

Das vom Plug-In zurückgegebene Frameworkelement kann eine beliebige Komplexität aufweisen. Dabei kann es sich um ein einzelnes Textfeld oder ein aufwendiges Benutzersteuerelement handeln, das eine Branchenanwendung implementiert.

Bedarf an zusammengesetzten Anwendungen

In den letzten Jahren haben viele meiner Kunden dieselbe Geschäftsanforderung gestellt: Sie benötigen Desktopanwendungen, die externe Plug-Ins laden können, damit mehrere Branchenanwendungen unter einem „Dach“ zusammenfassbar sind. Der ursächliche Grund für diesen Bedarf kann unterschiedlich sein. Möglicherweise entwickeln mehrere Teams verschiedene Segmente der Anwendung nach unterschiedlichen Zeitplänen. Vielleicht werden von unterschiedlichen Benutzern verschiedene Features benötigt. Oder die Kunden möchten die Stabilität der Hauptanwendung sicherstellen und gleichzeitig Flexibilität gewährleisten. So unterschiedlich die Gründe auch sein mögen: Die Anforderung, Plug-Ins von Drittanbietern hosten zu können, ist mehr als einmal in verschiedenen Organisationen aufgetreten.

Für dieses Problem sind mehrere herkömmliche Lösungen möglich: der klassische Composite Application Block (CAB), Managed Add-In Framework (MAF), Managed Extensibility Framework (MEF) und Prism. Eine weitere Lösung ist in der MSDN-Ausgabe vom August 2013 von meinen ehemaligen Kollegen Gennady Slobodsky und Levi Haskell veröffentlicht worden (Artikel „Architektur zum Hosten von .NET-Plug-Ins von Drittanbietern“ unter msdn.microsoft.com/magazine/dn342875). Diese Lösungen sind alle sehr gut, und mit ihnen sind viele nützliche Anwendungen erstellt worden. Ich bin ebenfalls aktiver Nutzer dieser Frameworks, allerdings stellt sich mir seit gewisser Zeit ein bestimmtes Problem: Stabilität.

Anwendungen stürzen ab. Das ist nun einmal so. Nullverweise, Ausnahmefehler, gesperrte Dateien und beschädigte Datenbanken werden in naher Zukunft nicht einfach verschwinden. Eine gute Hostanwendung muss in der Lage sein, einen Plug-In-Fehler zu „überstehen“ und weiterzulaufen. Ein fehlerhaftes Plug-In darf nicht zu einem Ausfall des Hosts oder anderer Plug-Ins führen. Dieser Schutz muss nicht unfehlbar sein; ich versuche hier nicht, bösartige Hackerangriffe zu unterbinden. Allerdings dürfen einfache Fehler – wie beispielsweise ein Ausnahmefehler in einem Arbeitsthread – keinen Hostausfall verursachen.

Isolationsstufen

Microsoft .NET Framework-Anwendungen können Plug-Ins von Drittanbietern auf mindestens drei unterschiedliche Arten behandeln:

  • Keine Isolation: Der Host und alle Plug-Ins werden in einem einzigen Prozess mit einer AppDomain ausgeführt.
  • Mittlere Isolation: Jedes Plug-In wird in eine eigene AppDomain geladen.
  • Hohe Isolation: Jedes Plug-In wird in einen eigenen Prozess geladen.

Keine Isolation bietet den niedrigsten Schutz und die geringste Steuerung. Auf alle Daten ist globaler Zugriff möglich, es gibt keinen Fehlerschutz und keine Möglichkeit, problematischen Code zu entfernen. Am häufigsten wird ein Anwendungsabsturz dadurch verursacht, dass ein Plug-In einen Ausnahmefehler in einem Arbeitsthread auslöst.

Sie können versuchen, die Hostthreads mit try/catch-Blöcken zu schützen, aber wenn es um von Plug-Ins erstellte Threads geht, ist alles möglich. Seit .NET Framework 2.0 führt jeder Ausnahmefehler in einem beliebigen Thread zum Prozessabbruch, und das lässt sich nicht verhindern. Für diese Ausnahmslosigkeit gibt es einen guten Grund: Ein Ausnahmefehler bedeutet, dass die Anwendung vermutlich instabil läuft – diese fortzusetzen, ist also gefährlich.

Die mittlere Isolation bietet eine bessere Steuerung der Sicherheit und Konfiguration eines Plug-Ins. Zudem können Plug-Ins entfernt werden, zumindest wenn alles ordnungsgemäß funktioniert und keine Threads mit der Ausführung von nicht verwaltetem Code beschäftigt sind. Allerdings ist der Hostprozess nicht vor Plug-In-Fehlern geschützt, wie in meinem Artikel „Host wird durch AppDomains nicht vor fehlerhaftem Plug-In geschützt“(bit.ly/1fO7spO) veranschaulicht wird. Es ist schwierig, wenn nicht unmöglich, eine zuverlässige Strategie für die Fehlerbehandlung zu entwerfen, und es gibt keine Garantie, dass die fehlerhafte AppDomain damit entfernt wird.

AppDomains wurden entworfen, um ASP.NET-Anwendungen zu hosten. Sie gelten als einfache Alternative zu Prozessen (siehe auch den Blogbeitrag von Chris Brumme aus dem Jahr 2003, „AppDomains („Anwendungsdomänen“)“, unter bit.ly/PoIX1r). ASP.NET wendet einen relativ automatisierten Ansatz für die Fehlertoleranz an. Eine abstürzende Webanwendung kann problemlos zum Ausfall des gesamten Arbeitsprozesses mit mehreren Anwendungen führen. In diesem Fall wird der Arbeitsprozess von ASP.NET neu gestartet, und sämtliche ausstehenden Webanforderungen werden neu initialisiert. Das ist eine sinnvolle Entwurfsentscheidung für einen Serverprozess, der keine an den Benutzer gerichteten Fenster enthält, aber das funktioniert vermutlich nicht bei einer Desktopanwendung.

Eine hohe Isolation bietet den ultimativen Ausfallschutz. Da jedes Plug-In in einem eigenen Prozess ausgeführt wird, können die Plug-Ins keinen Hostabsturz verursachen, zudem lassen sie sich beliebig beenden. Diese Lösung benötigt jedoch einen recht komplexen Entwurf. Die Anwendung muss viel Kommunikation und Synchronisierung zwischen den Prozessen verarbeiten. Außerdem muss sie das prozessübergreifende Marshalling für WPF-Steuerelemente ausführen, das ist keine einfache Aufgabe.

Wie bei vielen anderen Punkten in der Softwareentwicklung ist auch die Auswahl der Isolationsstufe ein Kompromiss. Eine hohe Isolation bietet bessere Steuerung und höhere Flexibilität, dies bringt jedoch eine größere Anwendungskomplexität sowie eine verlangsamte Leistung mit sich.

Bei einigen Frameworks wird die Fehlertoleranz ignoriert, und sie arbeiten mit der Stufe „keine Isolation“. Gute Beispiele für diesen Ansatz sind MEF und Prism. Sofern Fehlertoleranz und die Feinabstimmung der Plug-In-Konfiguration nicht erforderlich sind, ist dies die einfachste funktionierende und somit zu wählende Lösung.

Zahlreiche Plug-In-Architekturen, darunter auch die von Slobodsky und Haskell vorgeschlagene, verwenden die mittlere Isolation. Sie erzielen die Isolation über AppDomains. Mit AppDomains erhalten die Hostentwickler ein beachtliches Maß an Steuerung über die Sicherheit und Konfiguration von Plug-Ins. Ich selbst habe in den vergangenen Jahren etliche AppDomain-basierte Lösungen erstellt. Werden für die Anwendung das Entfernen von Code, Sandbox und Konfigurationssteuerung benötigt, aber keine Fehlertoleranz, sind AppDomains definitiv die richtige Wahl.

MAF sticht aus den Add-In-Frameworks hervor, da die Hostentwickler eine der drei Isolationsstufen auswählen können. Mithilfe der AddInProcess-Klasse kann ein Add-In in einem eigenen Prozess ausgeführt werden. Leider ist AddInProcess für visuelle Komponenten nicht vorkonfiguriert. Möglicherweise kann MAF für das prozessübergreifende Marshalling von visuellen Komponenten erweitert werden, jedoch müsste einem bereits komplexen Framework dafür eine weitere Ebene hinzugefügt werden. Das Erstellen von Add-Ins für MAF ist nicht einfach, und mit einer weiteren Ebene auf MAF wird die Komplexität wahrscheinlich nicht mehr beherrschbar sein.

Die von mir vorgeschlagene Architektur zielt darauf ab, die Lücke zu füllen und eine stabile Hostinglösung zu bieten, bei der Plug-Ins in eigene Prozesse geladen werden und eine visuelle Integration zwischen den Plug-Ins und dem Host besteht.

Hohe Isolation von visuellen Komponenten

Wird ein Ladevorgang für ein Plug-In angefordert, erzeugt der Hostprozess einen neuen untergeordneten Prozess. Mit diesem untergeordneten Prozess wird eine Benutzer-Plug-In-Klasse geladen, die wiederum ein FrameworkElement-Objekt erzeugt, das im Host angezeigt wird (siehe Abbildung 4).

Marshaling a FrameworkElement Between the Plug-In Process and the Host ProcessAbbildung 4: Marshalling eines FrameworkElement-Objekts zwischen Plug-In-Prozess und Hostprozess

Das direkte Marshalling von „FrameworkElement“ zwischen den Prozessen ist nicht möglich. Weder erbt es von „MarshalByRefObject“, noch ist es als „[Serialisierbar]“ gekennzeichnet, daher ist über .NET Remoting kein Marshalling ausführbar. Da es nicht mit dem [ServiceContract]-Attribut gekennzeichnet ist, erfolgt auch kein Marshalling über Windows Communication Foundation (WCF). Zur Lösung dieses Problems verwende ich die System.Addin.FrameworkElementAdapters-Klasse aus der System.Windows.Presentation-Assembly, die Bestandteil von MAF ist. Mit dieser Klasse werden zwei Methoden definiert:

  • Die ViewToContractAdapter-Methode konvertiert ein FrameworkElement-Objekt in eine INativeHandleContract-Schnittstelle, für die das Marshalling über .NET Remoting ausgeführt werden kann. Diese Methode wird innerhalb des Plug-In-Prozesses aufgerufen.
  • Die ContractToViewAdapter-Methode konvertiert eine INativeHandleContract-Instanz zurück in ein FrameworkElement-Objekt. Diese Methode wird innerhalb des Hostprozesses aufgerufen.

Leider ist die Verwendung einer einfachen Kombination dieser beiden Methoden nicht vorkonfiguriert. Offensichtlich ist MAF für das Marshalling von WPF-Komponenten zwischen AppDomains konzipiert, nicht zwischen Prozessen. Auf dem Client schlägt die ContractToViewAdapter-Methode mit folgender Fehlermeldung fehl:

System.Runtime.Remoting.RemotingException:
Permission denied: cannot call non-public or static methods remotely

Die Ursache liegt darin, dass die ContractToViewAdapter-Methode den Konstruktor der Klasse aufruft, „MS.Internal.Controls.AddInHost“. Dieser versucht, den INativeHandleContract-Remoteproxy in den Typ „AddInHwndSourceWrapper“ umzuwandeln. Ist diese Umwandlung erfolgreich, erfolgt auf dem Remoteproxy der Aufruf der internen Methode „RegisterKeyboardInputSite“. Der Aufruf von internen Methoden auf prozessübergreifenden Proxys ist jedoch nicht zulässig. Im AddInHost-Klassenkonstruktur geschieht Folgendes:

// From Reflector
_addInHwndSourceWrapper = contract as AddInHwndSourceWrapper;
if (_addInHwndSourceWrapper != null)
{
  _addInHwndSourceWrapper.RegisterKeyboardInputSite(
    new AddInHostSite(this)); // Internal method call!
}

Um diesen Fehler zu beheben, habe ich die NativeContractInsulator-Klasse erstellt. Diese Klasse befindet sich auf der Serverseite (Plug-In). Sie implementiert die INativeHandleContract-Schnittstelle, indem sie alle Aufrufe an das ursprüngliche INativeHandleContract-Objekt weiterleitet, das von der ViewToContractAdapter-Methode zurückgegeben wird. Im Gegensatz zur ursprünglichen Implementierung ist keine Umwandlung in „AddInHwndSourceWrapper“ möglich. Folglich ist die Umwandlung auf der Clientseite (Host) nicht erfolgreich, und der unzulässige interne Methodenaufruf wird nicht ausgeführt.

Untersuchen der Plug-In-Architektur im Detail

Die Methoden „Plugin.Load“ und „Plugin.CreateView“ erstellen alle benötigten beweglichen Elemente für die Plug-In-Integration.

In Abbildung 5 wird das resultierende Objektdiagramm veranschaulicht. Es ist relativ kompliziert, aber jeder Teil ist für eine bestimmte Rolle zuständig. Zusammen sichern sie den nahtlosen und stabilen Betrieb des Plug-In-Hostsystems.

Object Diagram of a Loaded Plug-In
Abbildung 5: Objektdiagramm eines geladenen Plug-Ins

Die Plugin-Klasse kennzeichnet eine einzelne Plug-In-Instanz im Host. Sie hält die View-Eigenschaft, die der visuellen Darstellung des Plug-Ins im Hostprozess dient. Die Plugin-Klasse erstellt eine Instanz von „PluginProcessProxy“ und ruft diese über ein IRemotePlugin-Objekt ab. Das IRemotePlugin-Objekt umfasst ein Plug-In-Remotesteuerelement des Typs „INativeHandleContract“. Dieses wird von der Plugin-Klasse in das hier dargestellte FrameworkElement-Objekt umgewandelt (der Code ist aus Gründen der Übersichtlichkeit gekürzt):

public interface IRemotePlugin : IServiceProvider, IDisposable
{
  INativeHandleContract Contract { get; }
}
class Plugin
{
  public void CreateView()
  {
    View = FrameworkElementAdapters.ContractToViewAdapter(
      _remoteProcess.RemotePlugin.Contract);
  }}

Von der PluginProcessProxy-Klasse wird der Lebenszyklus des Plug-In-Prozesses aus dem Host heraus gesteuert. Sie übernimmt das Starten des Plug-In-Prozesses, erstellt einen Remotechannel und überwacht den Zustand des Plug-In-Prozesses. Zudem startet die Klasse den PluginLoader-Dienst und ruft von diesem ein IRemotePlugin-Objekt ab.

Die PluginLoader-Klasse wird innerhalb des Plug-In-Prozesses ausgeführt und implementiert den Lebenszyklus desselben. Sie etabliert einen Remotechannel, startet einen WPF-Nachrichtenverteiler, lädt ein Benutzer-Plug-In, erstellt eine RemotePlugin-Instanz und leitet diese an das PluginProcessProxy-Objekt auf dem Host weiter.

Mithilfe der RemotePlugin-Klasse wird das prozessübergreifende Marshalling für das Benutzer-Plug-In ermöglicht. Sie konvertiert das FrameworkElement-Objekt des Benutzers in ein INativeHandleContract-Objekt und umschließt dieses mit einem NativeHandleContractInsulator-Objekt, um die zuvor beschriebene Problematik des unzulässigen Methodenaufrufs zu umgehen.

Abschließend implementiert die Benutzer-Plug-In-Klasse die IPlugin-Schnittstelle. Deren Hauptaufgabe besteht darin, ein Plug-In-Steuerelement innerhalb des Plug-In-Prozesses zu erstellen. In der Regel handelt es sich dabei um ein WPF-Benutzersteuerelement („UserControl“), es kann aber jedes beliebige FrameworkElement-Objekt sein.

Wird ein Ladevorgang für ein Plug-In angefordert, erzeugt die PluginProcessProxy-Klasse einen neuen untergeordneten Prozess. Abhängig davon, ob es sich um ein 32-Bit- oder ein 64-Bit-Plug-In handelt, lautet der ausführbare untergeordnete Prozess entweder „PluginProcess.exe“ oder „PluginProcess64.exe“. Jeder Plug-In-Prozess erhält eine eindeutige GUID in der Befehlszeile und das Plug-In-Basisverzeichnis:

PluginProcess.exe
  PluginProcess.0DAA530F-DCE4-4351-8D0F-36B0E334FF18
  c:\plug-in\assembly.dll

Der Plug-In-Prozess richtet einen Remotedienst des Typs „IPluginLoader“ ein und löst ein benanntes ready-Ereignis aus, in diesem Fall„PluginProcess.0DAA530F-DCE4-4351-8D0F-36B0E334FF18.Ready“. Anschließend kann der Host die IPluginLoader-Methoden zum Laden des Plug-Ins verwenden.

Als alternative Lösung könnte der Plug-In-Prozess im Host aufgerufen werden, sobald er fertig ist. Dadurch wird das ready-Ereignis nicht mehr benötigt, aber die Fehlerbehandlung ist deutlich komplizierter. Falls der Ladevorgang des Plug-Ins aus dem Plug-In-Prozess stammt, verbleiben auch die Fehlerinformationen im Plug-In-Prozess. Im Falle eines Fehlers kann es also sein, dass der Host diesen nicht erkennt. Daher habe ich mich für einen Entwurf mit dem ready-Ereignis entschieden.

Ein weiteres Entwurfsproblem stellt sich mit der Frage, ob Plug-Ins berücksichtigt werden sollen, die nicht unter dem WPF-Hostverzeichnis bereitgestellt werden. Einerseits verursacht das Laden von Assemblys, die sich nicht im Anwendungsverzeichnis befinden, in .NET Framework bestimmte Schwierigkeiten. Andererseits ist mir bekannt, dass die Plug-Ins möglicherweise eigene Bereitstellungsaspekte aufweisen und es daher nicht immer möglich ist, ein Plug-In unter dem WPF-Hostverzeichnis bereitzustellen. Zudem verhalten sich einige komplexe Anwendungen nur ordnungsgemäß, wenn sie von ihren Basisverzeichnissen aus ausgeführt werden.

Aufgrund dieser Aspekte können mit dem WPF-Host Plug-Ins von jedem Ort auf dem lokalen Dateisystem geladen werden. Um dies zu ermöglichen, führt der Plug-In-Prozess praktisch alle Vorgänge in einer sekundären AppDomain aus, deren Anwendungsbasisverzeichnis auf das Plug-In-Basisverzeichnis festgelegt ist. Damit entsteht die Notwendigkeit, WPF-Hostassemblys in dieser AppDomain laden zu müssen. Dies lässt sich mit mindestens vier Möglichkeiten erreichen:

  • Hinzufügen der WPF-Hostassemblys zum globalen Assemblycache (GAC).
  • Verwenden von Assemblyumleitungen in der Datei „app.config“ des Plug-In-Prozesses.
  • Laden der WPF-Hostassemblys mit einer der LoadFrom/CreateInstanceFrom-Überschreibungen.
  • Verwenden der nicht verwalteten Hosting-API für den CLR-Start im Plug-In-Prozess mit der gewünschten Konfiguration.

Jede dieser Lösungen hat Vor- und Nachteile. Um die WPF-Hostassemblys zum globalen Assemblycache hinzufügen zu können, werden Administratorrechte benötigt. Der globale Assemblycache ist eine saubere Lösung, aber die benötigten Administratorrechte zur Installation können in einer Unternehmensumgebung zum Problem werden. Daher habe ich versucht, dies zu vermeiden. Umleitungen von Assemblys sind ebenfalls eine gute Lösung, jedoch sind dann die Konfigurationsdateien vom Speicherort des WPF-Hosts abhängig. Somit ist eine xcopy-Installation unmöglich. Das Erstellen eines nicht verwalteten Hostingprojekts schien ein zu hohes Wartungsrisiko zu bergen.

Also entschied ich mich für den LoadFrom-Ansatz. Der Nachteil bei diesem Ansatz ist, dass sich die WPF-Hostassemblys im LoadFrom-Kontext befinden (siehe dazu auch den Blogbeitrag „Auswählen eines Bindungskontexts“ von Suzanne Cook unter bit.ly/cZmVuz). Um Bindungsprobleme zu vermeiden, muss ich das AssemblyResolve-Ereignis in der AppDomain des Plug-Ins überschreiben, damit der Plug-In-Code die WPF-Hostassemblys einfacher findet.

Entwickeln von Plug-Ins

Sie können ein Plug-In als Klassenbibliothek (DLL) oder als ausführbare Datei (EXE) implementieren. Im DLL-Szenario sind folgende Schritte auszuführen:

  1. Erstellen Sie ein neues Klassenbibliotheksprojekt.
  2. Fügen Sie Verweise auf die WPF-Assemblys „PresentationCore“, „PresentationFramework“, „System.Xaml“ und „WindowsBase“ hinzu.
  3. Fügen Sie einen Verweis auf die WpfHost.Interfaces-Assembly hinzu. Achten Sie darauf, dass die Option für eine lokale Kopie auf „False“ gesetzt ist.
  4. Erstellen Sie ein neues WPF-Benutzersteuerelement, wie z. B. „MainUserControl“.
  5. Erstellen Sie eine Klasse mit der Bezeichnung „Plugin“, die aus „IKriv.WpfHost.Interfaces.PluginBase“ abgeleitet ist.
  6. Fügen Sie der Datei „plugins.xml“ des Hosts einen Eintrag für Ihr Plug-In hinzu.
  7. Kompilieren Sie Ihr Plug-In, und führen Sie den Host aus.

Eine minimale Plug-In-Klasse sieht folgendermaßen aus:

public class Plugin : PluginBase
{
  public override FrameworkElement CreateControl()
  {
    return new MainUserControl();
  }
}

Alternativ lässt sich ein Plug-In als ausführbare Datei implementieren. In diesem Falle sind folgende Schritte auszuführen:

  1. Erstellen Sie eine WPF-Anwendung.
  2. Erstellen Sie ein WPF-Benutzersteuerelement, wie z. B. „MainUserControl“.
  3. Fügen Sie „MainUserControl“ zum Hauptfenster der Anwendung hinzu.
  4. Fügen Sie einen Verweis auf die WpfHost.Interfaces-Assembly hinzu. Achten Sie darauf, dass die Option für eine lokale Kopie auf „False“ gesetzt ist.
  5. Erstellen Sie eine Klasse mit der Bezeichnung „Plugin“, die aus „IKriv.WpfHost.Interfaces.PluginBase“ abgeleitet ist.
  6. Fügen Sie der Datei „plugins.xml“ des Hosts einen Eintrag für Ihr Plug-In hinzu.

Die Plug-In-Klasse sieht genauso aus wie im vorigen Beispiel, und die Hauptfenster-XAML sollte nur einen Verweis auf „MainUserControl“ enthalten:

<Window x:Class="MyPlugin.MainWindow"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="clr-namespace:MyProject"
  Title="My Plugin" Height="600" Width="766" >
  <Grid>
    <local:MainUserControl />
  </Grid>
</Window>

Ein so implementiertes Plug-In kann als eigenständige Anwendung oder innerhalb des Hosts ausgeführt werden. Das ermöglicht ein vereinfachtes Debuggen von Plug-In-Code, der keinen Bezug zur Hostintegration hat. Das Klassendiagramm für ein solches „Dual Head“-Plug-In wird in Abbildung 6 veranschaulicht.

The Class Diagram for a Dual-Head Plug-In
Abbildung 6: Das Klassendiagramm für ein „Dual Head“-Plug-In

Diese Methode bietet zudem die Möglichkeit, vorhandene Anwendungen schnell in Plug-Ins zu konvertieren. Dazu müssen Sie nur das Hauptfenster der Anwendung in ein Benutzersteuerelement umwandeln. Dann instanziieren Sie das Benutzersteuerelement wie zuvor gezeigt in eine Plug-In-Klasse. Das SolarSystem-Plug-In im Codedownload zu diesem Artikel ist ein Beispiel für eine solche Konvertierung. Der gesamte Konvertierungsprozess dauerte weniger als eine Stunde.

Da das Plug-In keine unabhängige Anwendung ist, sondern vom Host gestartet wird, kann das Debuggen möglicherweise etwas komplizierter sein. Sie können mit dem Debuggen des Hosts beginnen, aber mit Visual Studio ist derzeit kein automatisches Anfügen der untergeordneten Prozesse möglich. Sie können also den Debugger manuell an den Plug-In-Prozess anfügen, sobald dieser ausgeführt wird, oder Sie lassen den Plug-In-Prozess beim Starten vom Debugger unterbrechen, indem Sie in der Datei „app.config“ von „PluginProcess“ die Zeile 4 folgendermaßen ändern:

<add key="BreakIntoDebugger" value="True" />

Eine weitere Möglichkeit ist, das Plug-In wie zuvor beschrieben als eigenständige Anwendung zu erstellen. Dann können Sie den Großteil des Plug-Ins als eigenständige Anwendung debuggen und brauchen nur regelmäßig zu überprüfen, ob die Integration in den WPF-Host ordnungsgemäß funktioniert.

Wenn der Plug-In-Prozess beim Starten vom Debugger unterbrochen werden soll, sollten Sie das Zeitlimit für das ready-Ereignis erhöhen. Ändern Sie dazu in der Datei „app.config“ von „WpfHost“ die Zeile 4 folgendermaßen ab:

<add key="PluginProcess.ReadyTimeoutMs" value="500000" />

In Abbildung 7 finden Sie eine Liste mit Plug-In-Beispielen, die im Codedownload zu diesem Artikel enthalten sind, sowie eine entsprechende Funktionsbeschreibung.

Abbildung 7: Im Codedownload zu diesem Artikel verfügbare Plug-In-Beispiele

Plug-In-Projekt Funktionsbeschreibung
BitnessCheck Veranschaulicht das Ausführen eines Plug-Ins als 32-Bit- oder als 64-Bit-Variante
SolarSystem Veranschaulicht das Konvertieren einer alten WPF-Demoanwendung in ein Plug-In
TestExceptions Veranschaulicht die Ausnahmebehandlung für Benutzer- und Arbeitsthreadausnahmen
UseLogServices Veranschaulicht das Verwenden von Host- und Plug-In-Diensten

Hostdienste und Plug-In-Dienste

In der Praxis müssen Plug-Ins häufig vom Host bereitgestellte Dienste verwenden. Ich veranschauliche dieses Szenario im UseLogService-Plug-In, das im Codedownload zu diesem Artikel enthalten ist. Eine Plug-In-Klasse kann einen Standardkonstruktor aufweisen oder über einen Konstruktor verfügen, der einen Parameter von „IWpfHost“ nutzt. Im letzteren Fall wird vom Ladeprogramm des Plug-Ins eine Instanz des WPF-Hosts an das Plug-In übergeben. Die IWpfHost-Schnittstelle wird wie folgt definiert:

public interface IWpfHost : IServiceProvider
{
  void ReportFatalError(string userMessage,
     string fullExceptionText);
  int HostProcessId { get; }
}

Ich nutze in meinem Plug-In den IServerProvider-Abschnitt. Bei „IServiceProvider“ handelt es sich um eine Standardschnittstelle von .NET Framework, die in „mscorlib.dll“ definiert ist:

public interface IServiceProvider
{
  object GetService(Type serviceType);
}

Ich verwende sie in meinem Plug-In, um den ILog-Dienst vom Host abzurufen:

class Plugin : PluginBase
{
  private readonly ILog _log;
  private MainUserControl _control;
  public Plugin(IWpfHost host)
  {
    _log = host.GetService<ILog>();
  }
  public override FrameworkElement CreateControl()
  {
    return new MainUserControl { Log = _log };
  }
}

Mithilfe des ILog-Hostdiensts kann das Steuerelement dann in die Protokolldatei des Hosts schreiben.

Der Host kann auch von Plug-Ins bereitgestellte Dienste verwenden. Ich habe einen solchen Dienst definiert, er heißt „IUnsavedData“ und hat sich in der Praxis als nützlich erwiesen. Durch die Implementierung dieser Schnittstelle kann ein Plug-In eine Liste mit ungespeicherten Arbeitsaufgaben definieren. Wird das Plug-In oder die gesamte Hostanwendung geschlossen, wird der Benutzer vom Host in einer Meldung gefragt, ob nicht gespeicherte Daten verworfen werden sollen, wie in Abbildung 8 dargestellt.

Using the IUnsavedData Service
Abbildung 8: Verwenden des IUnsavedData-Diensts

Die IUnsavedData-Schnittstelle wird wie folgt definiert:

public interface IUnsavedData
{
  string[] GetNamesOfUnsavedItems();
}

Der Ersteller eines Plug-Ins muss die IServiceProvider-Schnittstelle nicht explizit implementieren. Es ist ausreichend, wenn die IUnsavedData-Schnittstelle im Plug-In implementiert wird. Die PluginBase.GetService-Methode sorgt dafür, dass diese an den Host zurückgegeben wird. Das UseLogService-Projekt im Codedownload zu diesem Artikel bietet eine beispielhafte IUnsavedData-Implementierung, der relevante Code lautet folgendermaßen:

class Plugin : PluginBase, IUnsavedData
{
  private MainUserControl _control;
  public string[] GetNamesOfUnsavedItems()
  {
    if (_control == null) return null;
    return _control.GetNamesOfUnsavedItems();
  }
}

Protokollierung und Fehlerbehandlung

Die WPF-Hostprozesse und Plug-In-Prozesse erstellen Protokolle im %TMP%\WpfHost-Verzeichnis. Der WPF-Host schreibt in die Datei „WpfHost.log“, die einzelnen Plug-In-Hostprozesse schreiben in die Datei „PluginProcess.Guid.log“ (die Zeichenfolge „Guid“ ist nicht tatsächlicher Bestandteil des Namens, sondern es wird der tatsächliche Guid-Wert ergänzt). Der Protokollierungsdienst ist benutzerdefiniert. Ich habe es vermieden, gängige Protokollierungsdienste wie „log4net“ oder „NLog“ zu verwenden, damit das Beispiel eigenständig bleibt.

Ein Plug-In-Prozess schreibt die Ergebnisse auch in sein Konsolenfenster, das Sie anzeigen können, indem Sie in der Datei „app.config“ von „WpfHost“ die Zeile drei folgendermaßen ändern:

<add key="PluginProcess.ShowConsole" value="True" />

Ich habe sehr darauf geachtet, dass alle Fehler an den Host gemeldet und ordnungsgemäß behandelt werden. Der Host überwacht die Plug-In-Prozesse und schließt das Plug-In-Fenster, wenn ein Plug-In-Prozess ausfällt. Ebenso überwacht ein Plug-In-Prozess seinen Host und schließt sich, wenn der Host ausfällt. Sämtliche Fehler werden protokolliert, folglich hilft es erheblich bei der Fehlerbehandlung, die Protokolldateien durchzugehen.

Es gilt unbedingt zu bedenken, dass alles, was zwischen dem Host und den Plug-Ins übermittelt wird, entweder „[Serialisierbar]“ sein oder einen aus „MarshalByRefObject“ abgeleiteten Typ aufweisen muss. Andernfalls kann .NET Remoting kein Marshalling für das Objekt zwischen den Parteien ausführen. Die Typen und Schnittstellen müssen beiden Parteien bekannt sein, das heißt, in der Regel sind nur integrierte Typen sowie Typen aus WpfHost.Interfaces- oder PluginHosting-Assemblys für sicheres Marshalling geeignet.

Versionsverwaltung

„WpfHost.exe“, „PluginProcess.exe“ und „PluginHosting.dll“ sind eng gekoppelt und sollten gleichzeitig veröffentlicht werden. Glücklicherweise ist der Plug-In-Code von keiner dieser drei Assemblys abhängig, daher können sie auf nahezu jede Art und Weise geändert werden. Beispielsweise können Sie einfach die Synchronisierungsmethode oder den Namen des ready-Ereignisses ändern, ohne dass die Plug-Ins davon betroffen sind.

Die Versionsverwaltung der Komponente „WpfHost.Interfaces.dll“ muss mit extremer Sorgfalt geschehen. Sie sollte im Plug-In-Code referenziert werden, aber nicht darin enthalten sein (CopyLocal = False), der Binärwert für diese Assembly stammt also immer nur vom Host. Ich habe dieser Assembly keinen starken Namen gegeben, da ich explizit keine parallele Ausführung möchte. Auf dem gesamten System soll nur eine Version von „WpfHost.Interfaces.dll“ vorhanden sein.

Generell sollten Sie Plug-Ins als Drittanbietercode betrachten, der nicht von den Hosterstellern gesteuert wird. Das Ändern oder sogar Neukompilieren von allen Plug-Ins auf einmal kann schwierig oder sogar unmöglich sein. Daher müssen neue Versionen der Schnittstellenassembly binärkompatibel mit den vorigen Versionen sein, grundlegende Änderungen sind auf das absolute Minimum zu beschränken.

Das Hinzufügen neuer Typen und Schnittstellen zur Assembly ist im Allgemeinen sicher. Alle anderen Änderungen – dazu zählt das Hinzufügen neuer Methoden zu Schnittstellen bzw. neuer Werte zu Enumerationen – können potenziell die Binärkompatibilität beeinträchtigen und sollten daher vermieden werden.

Auch wenn die Hostassemblys keine starken Namen haben, sollten die Versionsnummern nach jeder (auch noch so kleinen) Änderung erhöht werden, damit nicht zwei Assemblys mit gleicher Versionsnummer unterschiedlichen Code aufweisen.

Ein guter Ausgangspunkt

Meine hier bereitgestellte Referenzarchitektur ist kein Framework in Produktionsqualität für die Plug-In-Hostintegration, aber es kommt dem recht nahe und kann als wertvoller Ausgangspunkt für Ihre Anwendung dienen.

Die Architektur berücksichtigt standardmäßige, aber dennoch schwierige Aspekte, wie z. B. den Lebenszyklus des Plug-In-Prozesses, das prozessübergreifende Marshalling von Plug-In-Steuerelementen, eine Austauschmethode sowie die Diensterkennung zwischen Host und Plug-Ins. Die meisten Entwurfslösungen und Problemumgehungen sind nicht willkürlich. Sie basieren auf tatsächlichen Erfahrungen beim Erstellen von zusammengesetzten Anwendungen für WPF.

Vermutlich möchten Sie die visuelle Darstellung des Hosts anpassen, die Protokollierungsmethode durch den in Ihrem Unternehmen üblichen Standard ersetzen, neue Dienste hinzufügen und möglicherweise die Art und Weise der Plug-In-Erkennung ändern. Zahlreiche weitere Änderungen und Verbesserungen sind möglich.

Auch wenn Sie keine zusammengesetzten Anwendungen für WPF erstellen, lässt sich diese Architektur als gutes Beispiel dafür heranziehen, wie leistungsstark und flexibel .NET Framework sein kann. Außerdem sehen Sie, wie Sie bekannte Komponenten auf interessante, unerwartete und produktive Art und Weise miteinander kombinieren können.

Ivan Krivyakov ist Technical Lead bei Thomson Reuters. Der praktische Entwickler und Architekt hat sich auf die Erstellung und Optimierung von komplexen WPF-Branchenanwendungen spezialisiert.

Unser Dank gilt den folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: Dr. James McCaffrey, Daniel Plaisted und Kevin Ransom
Kevin Ransom ist seit 14 Jahren bei Microsoft beschäftigt und war an zahlreichen Projekten beteiligt, darunter: Common Language Runtime, Microsoft Business Framework, Windows Vista und Windows 7, Managed Extensibility Framework und Basisklassenbibliotheken. Zurzeit arbeitet er im Bereich Verwaltete Sprachen für Visual FSharp.

Dr. James McCaffrey arbeitet im amerikanischen Redmond (Washington) für Microsoft. Er hat an verschiedenen Microsoft-Produkten mitgearbeitet, unter anderem an Internet Explorer und MSN Search. Dr. McCaffrey ist der Autor von „.NET Test Automation Recipes“, Apress 2006, und kann unter jammc@microsoft.com erreicht werden.

Daniel Plaisted ist seit 2008 bei Microsoft tätig und hat an Managed Extensibility Framework (MEF), Portable Class Libraries (PCL) und Microsoft .NET Framework für Windows Store-Apps mitgearbeitet. Er hat Beiträge geliefert für MS TechEd, BUILD und etliche lokale Gruppen, Code-Camps und Konferenzen. In seiner Freizeit beschäftigt er sich mit Computerspielen, Lesen, Wandern, Jonglieren und Footbag (Hackey-Sack). Sie finden seinen Blog unter blogs.msdn.com/b/dsplaisted/, und er ist unter daplaist@microsoft.com zu erreichen.