Artikel

Juli 2003 | Artikel

Hin und weg

(Link zum Artikel: http://www.it-republik.de/dotnet/artikel/0390)

Dynamisches Laden und Entladen von Plug-ins mit Hilfe von AppDomains

Text: von Jochen Reinelt
  • Teilen
  • kommentieren
  • empfehlen
  • Bookmark and Share
Die Verwendung von Plug-ins ist eine praktische Sache: Zur Laufzeit lässt sich vom Anwender nachrüsten, was der Entwickler niemals für notwendig gehalten hatte. Plug-ins erweisen sich grundsätzlich dann als geeigneter Lösungsansatz, wenn komplexe Anwendungen flexible Funktionalität besitzen müssen. Der Bildbetrachter wird so zum Beispiel um neue Grafikformate erweitert und das Office-Paket prüft sämtliche Eingaben auf Rechtschreibung. Der folgende Artikel zeigt am Beispiel einer Systemüberwachung, wie unter .NET einzelne Programmteile aus DLLs dynamisch nachgeladen und auch wieder entladen werden können.

Im vorliegenden Szenario sollen eine beliebige Anzahl von Systemen von innen überwacht werden. Sind alle Dienste verfügbar? Funktioniert die Hardware einwandfrei? Die einzelnen Systeme können sehr heterogen konfiguriert sein. Außerdem sollte sich die Anwendung zur Laufzeit um weitere Funktionalität für bislang unbekannte Aufgaben erweitern lassen. Das beschriebene Szenario schreit förmlich danach, eine stark modularisierte Applikation zu entwickeln, bei der die eigentliche Überwachung in Plug-ins ausgelagert ist. Der Kern der Systemüberwachung übernimmt hierbei lediglich zentrale Aufgaben wie Persistenz der gesammelten Daten und das Laden bzw. Starten der Plug-ins. Für jedes zu überwachende System lässt sich so aus den verfügbaren Plug-ins eine individuelle Lösung komponieren.

Da die Systemüberwachung unabhängig von einem eingeloggten Benutzer auf jeder Maschine aktiv sein soll, fällt die Entscheidung auf die Entwicklung eines Windows-Dienstes, der automatisch mit dem Betriebssystem aktiv wird. Ein ständiges Starten und Beenden dieses Dienstes, um beispielsweise seine Konfiguration zu ändern oder die neue Version eines Plug-ins einzuspielen, ist nicht wünschenswert. Vielmehr soll die Anwendung ständig verfügbar und im laufenden Betrieb flexibel erweiterbar sein.
Wer suchet, der findet
Damit ein Plug-in dynamisch geladen werden kann, muss es in einer eigenen Assembly erzeugt werden, was in der Regel gleichbedeutend mit einer eigenen DLL ist. Zunächst einmal werden Informationen benötigt, worum genau es sich bei einer neuen Assembly überhaupt handelt. Das .NET Framework bietet in System.Reflection alles Notwendige, um zur Laufzeit aus den Metadaten einer Assembly zu ermitteln, welche Typen darin enthalten sind [1]. Zu einem Typ sind dann weitere Informationen vorhanden: Methoden und Properties, implementierte Interfaces, Basistypen, Zugriffsmodifikatoren und vieles mehr. Da man üblicherweise keinen Code aus beliebigen DLLs ausführen möchte, lädt man nur Klassen, die von einem bestimmten Basistyp erben oder die ein bestimmtes Interface implementieren. Mit der Assembly-Klasse und etwas Reflection lässt sich leicht prüfen, ob eine über System.IO gefundene DLL dem gewünschten Plug-in-Typ entspricht:
  1. Type GetPluginTypeFromFile(FileInfo file)
  2. {
  3. if (file.Exists)
  4. {
  5. Assembly asm = Assembly.LoadFrom(file.FullName);
  6. foreach (Type type in asm.GetExportedTypes())
  7. if (type.IsSubclassOf(typeof(Plugin)))
  8. return type;
  9. }
  10. return null;
  11. }
Wer sich gegen das Unterschieben fremder DLLs schützen möchte, kann seine eigenen Assemblies digital durch die Vergabe eines so genannten Strong Names signieren und später authentifizieren. Der gefundene Typ - egal ob mit oder ohne Signatur - kann nun leicht instanziiert werden. Anschließend ruft man die gewünschten Methoden auf, wie sie in einem Interface, das der Typ implementiert, oder in seinem abstrakten Basistypen vereinbart sind. Im folgenden Beispiel handelt es sich um die Basisklasse Plugin:
  1. Type type = GetPluginTypeFromFile(file);
  2. if (type != null)
  3. {
  4. Plugin plugin = (Plugin) Activator.CreateInstance(type);
  5. plugin.Start();
  6. }
Natürlich lassen sich mit einigen Änderungen auf diese Art auch mehrere Plug-ins aus einer DLL laden. Die gezeigte Methode Activator.CreateInstance() ist zudem überladen, um auch Parameter für den Konstruktor übergeben zu können.
Licht und Schatten
Der erste Teil der Problemstellung wäre damit bereits gelöst: Plug-ins können aus DLLs geladen und gestartet werden. In Verbindung mit einem FileSystemWatcher aus System.IO erkennt unsere Systemüberwachung sogar neu hinzugekommene Dateien und startet die entsprechende Funktionalität automatisch. Komplizierter wird es jedoch, wenn man eine einmal angezogene DLL auch wieder los werden will.

Bereits mit Assembly.LoadFrom() wird die DLL in den Speicher geladen und für alle Schreibzugriffe im Betriebssystem gesperrt. Es wird somit zunächst unmöglich, das Plug-in zu entfernen oder zu aktualisieren, bis die Systemüberwachung bzw. ihr Betriebssystemprozess beendet ist - genau das sollte aber vermieden werden. Eine entsprechende Unload-Methode für eine Assembly oder einen Typen besteht leider nicht - einsichtig, wenn man bedenkt, dass bei managed Code allein die Garbage Collection kontrolliert, zu welchem Zeitpunkt ein Objekt entladen wird.
Saubere Trennung
In Win32 sind Prozesse voneinander separiert und isoliert, was Speicherverwaltung und andere sicherheitsrelevante Zugriffe betrifft. Dieses Konzept wird im .NET Framework durch weitere logische Einheiten fortgeführt: Eine so genannte AppDomain bietet einen Grad an Isolation, der mit Prozessen durchaus vergleichbar ist. Mehrere AppDomains können innerhalb einer .NET-Anwendung und innerhalb eines Prozesses nebeneinander betrieben werden, ohne sich gegenseitig zu beeinflussen. Im Vergleich zu Prozessen sind sie relativ leichtgewichtig was die benötigten Ressourcen betrifft. Eine einzelne AppDomain kann geladen und entladen werden, ohne den gesamten Prozess zu beenden. Eine AppDomain lässt sich zwar zusätzlich unterteilen (in abgeschirmte, logische Gruppen, die als Kontexte bezeichnet werden), bezüglich eines freien Ladens und Entladens sind AppDomains jedoch atomar. Das weitere Vorgehen steht damit fest: Die Plug-ins müssen in eine eigene AppDomain - oder besser noch: in eine AppDomain pro Plug-in - geladen werden, um unabhängig voneinander aktualisiert und entfernt werden zu können. Was sich trivial anhört, macht die Architektur des Gesamtsystems durchaus um einiges komplexer, wie im Weiteren noch gezeigt wird.

Doch die Auslagerung in AppDomains bringt auch klare Vorteile: Jede AppDomain stellt eine eigene Sandbox dar, in der sicherheitsrelevante Zugriffe sehr feingranular per Code Access Security (CAS) definiert werden können (siehe Überblick zur .NET Security in [2]). Wer beispielsweise fremde Plug-ins in seiner eigenen Anwendung ausführt, kann diesen je nach Bedarf Zugriffe auf unmanaged Code, das Dateisystem, die Registry usw. explizit verweigern. Ein entsprechendes Permission Set lässt sich über das Snap-in .NET Configuration in der Management Console anlegen. Als Kennzeichen für die entsprechende Code-Gruppe könnte wiederum der Strong Name von signierten Plug-in-Assemblies eingesetzt werden. Eine Alternative hierzu ist die Definition eines speziellen PermissionSets innerhalb des Quellcodes.
Die Lücke schließen
Sowohl AppDomains als auch darin enthaltene Kontexte stellen so genannte Remoting-Grenzen dar, d.h. jegliche Kommunikation zwischen verschiedenen Kontexten oder AppDomains erfordert jeweils ein bestimmtes Niveau von .NET Remoting (siehe Einführung zu Remoting in [3]). Jede .NET-Anwendung besitzt zunächst einmal eine Default-AppDomain und darin einen Default-Kontext. Erstellt der Entwickler weitere AppDomains oder Kontexte, werden sämtliche Aufrufe zwischen ihnen über Remoting-Nachrichten realisiert. Vergleichbar ist das Ganze dann mit herkömmlicher Kommunikation zwischen Prozessen (IPC) - nur dass der Zugriff zwischen logischen Einheiten innerhalb eines Prozesses stattfindet. Für den .NET-Entwickler ist es aber im Übrigen irrelevant, ob sich die beteiligten AppDomains innerhalb eines Prozesses befinden, in verschiedenen Prozessen auf dem selben Rechner oder auf verschiedenen Maschinen. Die vorgestellte Plug-in-Architektur entspricht daher im Kern einer verteilten Anwendung und erfordert somit ein besonderes Augenmerk auf das Design. Die Komplexität der Systemüberwachung steigt erheblich. Eine vereinfachte Darstellung der vorgeschlagenen Architektur zeigt Abpictureung 1.
Für den Zugriff auf die entfernten Objekte in einer anderen AppDomain existieren grundsätzlich zwei Möglichkeiten: Ein entferntes Objekt kann als Kopie von einer anderen AppDomain in die eigene übergeben werden, um dann ausschließlich lokal manipuliert zu werden. Man spricht in diesem Fall von MBV (marshal-by-value), weil die Werte in die andere AppDomain kopiert werden. Im anderen Fall wird ein Objekt innerhalb der fremden AppDomain erzeugt und auch dort manipuliert. Da lediglich eine Referenz auf das Objekt übergeben wird, spricht man von MBR (marshal-by-reference).

Werden zum Beispiel gesammelte Informationen von Plug-ins an die Systemüberwachung zurückgeliefert, damit sie in die Datenbank geschrieben werden können, so können die entsprechenden Daten in Kopie übergeben werden. Die übergebenen Typen (und alle darin enthaltenen Typen) müssen hierzu mit dem Attribut Serializable markiert werden, um MBV zu ermöglichen.

Die Verwaltung der Plug-ins muss dagegen innerhalb ihrer eigenen AppDomain erfolgen. Die zur Steuerung erzeugte Klasse PluginStarter ist daher von MarshalByRefObject abgeleitet - einer speziellen Basisklasse im .NET Framework, die den Zugriff per MBR auf von ihr abgeleitete Klassen bzw. deren Instanzen gestattet. Kommen wir nun zur Praxis - dem Erzeugen eigener AppDomains für jedes Plug-in. Wie bereits erwähnt, bindet bereits das Laden einer Assembly die zugehörige DLL an die ladende AppDomain. Das Laden einer DLL und Prüfen auf vorhandene Plug-ins geschieht also innerhalb der Klasse PluginStarter. Die Optionen für das Erzeugen einer neuen AppDomain werden zunächst innerhalb eines AppDomainSetup-Objekts konfiguriert (siehe Listing 1). Es bietet sich so beispielsweise die Möglichkeit, mit Schattenkopien der Original-DLLs zu arbeiten. Sämtliche Operationen innerhalb der AppDomain laufen dann nur gegen eine Kopie der DLL, sodass die ursprüngliche Datei jederzeit verändert werden kann. Innerhalb der Systemüberwachung wird es so möglich, eine neue Version des Plug-ins zu installieren und zu aktivieren, indem man die DLL überschreibt: Nachdem etwa ein FileSystemWatcher die Änderung festgestellt hat, kann die entsprechende AppDomain entladen und mit der neuen Version wieder geladen werden. Die Property ShadowCopyDirectories gibt hierbei die Verzeichnisse an, für die Schattenkopien zu erstellen sind. Im CachePath werden die Dateien dann entsprechend angelegt.

Listing 1
  1. //Erzeugen von AppDomain-Setup-Informationen
  2. AppDomainSetup adSetup = new AppDomainSetup();
  3. adSetup.ShadowCopyFiles = "true"; // (sic!)
  4. adSetup.ShadowCopyDirectories = PluginFileInfo.DirectoryName;
  5. adSetup.CachePath = Path.Combine(PluginFileInfo.DirectoryName, "shadow");
  6. //Erzeugen der AppDomain
  7. AppDomain PluginDomain = AppDomain.CreateDomain("plugin_1", null, adSetup);
  8. //Setzen eines eigenen PermissionSets für die AppDomain
  9. PermissionSet ps = new PermissionSet(PermissionState.None);
  10. ps.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
  11. ps.AddPermission(new FileIOPermission(PermissionState.Unrestricted));
  12. //Der AppDomain über eine Policy das PermissionSet zuweisen
  13. PolicyLevel policy = PolicyLevel.CreateAppDomainLevel();
  14. policy.RootCodeGroup.PolicyStatement = new PolicyStatement(ps);
  15. PluginDomain.SetAppDomainPolicy(policy);
  16. //Erzeugen eines PluginStarter-Objektes
  17. Assembly myOwnAssembly = this.GetType().Assembly;
  18. ObjectHandle PluginStarterHandle = PluginDomain.CreateInstance(myOwnAssembly.FullName, typeof(PluginStarter).FullName);
  19. //Transparenten Proxy für das referenzierte PluginStarter-Objekt erzeugen
  20. PluginStarter Starter = (PluginStarter) PluginStarterHandle.Unwrap();
  21. //Das in der neuen AppDomain erzeugte Objekt verwenden
  22. if (Starter.ContainsPlugin(PluginFileInfo))
  23. Starter.Start();
Um die geladenen Plug-ins nur mit den tatsächlich notwendigen Rechten auszustatten, wird ihre Handlungsfreiheit per Code Access Security beschränkt. Im Beispiel wird hierzu ein eigenes PermissionSet erzeugt und per AppDomain-Policy zugewiesen. Ein FileIOPermission-Objekt lässt sich für beliebige Zugriffsrechte auf einzelne Pfade konfigurieren, in Listing 1 wird dagegen ein unbeschränkter Dateizugriff erlaubt. Unbedingt notwendige Rechte innerhalb der Plug-in-AppDomain sind das Ausführen von managed Code (Execution) und das Laden von Dateien in dem Pfad, in dem sich die Plug-ins befinden. Mit der gezeigten Policy ist unter anderem die Verwendung von unmanaged Code und von Registry-Zugriffen innerhalb der Plug-ins ausgeschlossen. Weitere Informationen zu CAS in eigens erzeugten AppDomains enthält [4].

Innerhalb der gesicherten Plug-in-AppDomain können mit CreateInstance nun Objekte erzeugt werden. Im Beispiel wird auf diese Weise zunächst die Klasse PluginStarter geladen. Da diese Klasse in der selben Assembly definiert ist wie die aufrufende Klasse, wird in Listing 1 die eigene Assembly als Bezugsquelle mit angegeben. Auf das erzeugte Objekt verweist ein zurückgeliefertes ObjectHandle. Für den eigentlichen Zugriff wird ein transparenter Objekt-Proxy benötigt, den die Laufzeitumgebung mit der Unwrap-Methode automatisch generiert.
Neue Hürden
Die Kernaufgabe ist nun geschafft: Die für die Plug-ins erzeugten AppDomains lassen sich mit AppDomain.Unload auch wieder vollständig entladen. Probeme können hierbei entstehen, wenn in der AppDomain laufende Threads zum Beispiel aufgrund von Designfehlern nicht sofort beendet werden können. Auch alte Referenzen, die in bereits entladene AppDomains zeigen, könnten für Schwierigkeiten sorgen. Das hinter den Kulissen stattfindende Remoting mit seinen klaren Isolationsgrenzen hält weitere Überraschungen bereit.

Die verteilte Garbage Collection von .NET arbeitet mit dem Konzept von Leases, d.h. regelmäßige Zugriffe erhalten Objekte am Leben, wohingegen nicht mehr genutzte Objekte nach Ablauf des Lease-Zeitraums beseitigt werden. So genannte Sponsoren können in diesen Ablauf eingreifen und die Lebenszeit eines bestimmten Objekts gezielt steuern. Je nachdem, welchen Zweck eine Anwendung verfolgt, kann es sinnvoll sein, das Lease-Konzept zu ändern oder sich als Sponsor zu registrieren. Für die gezeigte Systemüberwachung besteht eine einfache Lösung darin, das automatische Lebenszeit-Management von PluginStarter-Objekten völlig zu stoppen. Man überschreibt hierzu eine von MarshalByRefObject geerbte Methode:
  1. public override object InitializeLifetimeService()
  2. {
  3. //Das Lease wird nie ablaufen.
  4. return null;
  5. }
Da jeweils nur eine Instanz dieses Objekts pro AppDomain benötigt wird und auch während ihrer gesamten Lebenszeit existieren sollte, stellt dieser Eingriff kein Problem dar. Er verhindert zumindest, dass die vorhandenen Referenzen auf Objekte in Plug-in-AppDomains durch längere Nichtbenutzung verloren gehen.

Weitere Fehlerquellen ergeben sich, wenn man vergisst, dass es sich bei verschiedenen AppDomains tatsächlich um vollkommen isolierte Bereiche handelt, die genauso gut auf verschiedenen Rechnern laufen könnten. Insbesondere die automatische Erzeugung transparenter Objekt-Proxies verführt zu solchen Fehlern: Die Speicherbereiche sind getrennt - alle globalen oder statischen Variablen existieren daher jeweils einmal pro AppDomain. Auch Singleton-Objekte können innerhalb der Anwendung so mehrfach auftreten. Die Konfiguration von Klassen wie Debug oder SmtpMail über statische Variablen muss gegebenenfalls mehrfach erfolgen - oder man erledigt entsprechende Aufrufe zentral innerhalb einer AppDomain. Die Synchronisation auf das vermeintlich gleiche Objekt innerhalb verschiedener AppDomains wird fehlschlagen, wie z.B. ein lock(typeof(Class)). Auch hier kann das Problem eventuell über ein zentrales Locking innerhalb einer einzigen AppDomain umgangen werden.
Fazit
Die Liste der möglichen Probleme und Schwierigkeiten bei Verwendung mehrerer AppDomains ist lang. Insbesondere steigt die Anforderung an die Systemarchitektur. Eine gründliche Designphase mit Augenmerk auf die neuen Einschränkungen muss der Entwicklung zwingend vorausgehen. Dennoch stellen Plug-ins innerhalb von AppDomains eine faszinierende Möglichkeit dar, maximal flexible Anwendungen zu entwickeln. Der Aufwand lohnt sich und ist beherrschbar, wenn man die Konzepte von Remoting über AppDomain-Grenzen verinnerlicht.
Links und Literatur
  • [1] Frank Eller, Datenspione, dot.net magazin 2.2002
  • [2] Oliver Grasl, Code-Wächter, dot.net magazin 2.2002
  • [3] Ingo Rammer, Fernbedienung, dot.net magazin 3.2002
  • [4] Hosting the Common Language Runtime, .NET Framework 1.1 SDK


Anzeige

Kommentare

zurück zum Seitenanfang