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:Type GetPluginTypeFromFile(FileInfo file){if (file.Exists){Assembly asm = Assembly.LoadFrom(file.FullName);foreach (Type type in asm.GetExportedTypes())if (type.IsSubclassOf(typeof(Plugin)))return type;}return null;}
Type type = GetPluginTypeFromFile(file);if (type != null){Plugin plugin = (Plugin) Activator.CreateInstance(type);plugin.Start();}
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.//Erzeugen von AppDomain-Setup-InformationenAppDomainSetup adSetup = new AppDomainSetup();adSetup.ShadowCopyFiles = "true"; // (sic!)adSetup.ShadowCopyDirectories = PluginFileInfo.DirectoryName;adSetup.CachePath = Path.Combine(PluginFileInfo.DirectoryName, "shadow");//Erzeugen der AppDomainAppDomain PluginDomain = AppDomain.CreateDomain("plugin_1", null, adSetup);//Setzen eines eigenen PermissionSets für die AppDomainPermissionSet ps = new PermissionSet(PermissionState.None);ps.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));ps.AddPermission(new FileIOPermission(PermissionState.Unrestricted));//Der AppDomain über eine Policy das PermissionSet zuweisenPolicyLevel policy = PolicyLevel.CreateAppDomainLevel();policy.RootCodeGroup.PolicyStatement = new PolicyStatement(ps);PluginDomain.SetAppDomainPolicy(policy);//Erzeugen eines PluginStarter-ObjektesAssembly myOwnAssembly = this.GetType().Assembly;ObjectHandle PluginStarterHandle = PluginDomain.CreateInstance(myOwnAssembly.FullName, typeof(PluginStarter).FullName);//Transparenten Proxy für das referenzierte PluginStarter-Objekt erzeugenPluginStarter Starter = (PluginStarter) PluginStarterHandle.Unwrap();//Das in der neuen AppDomain erzeugte Objekt verwendenif (Starter.ContainsPlugin(PluginFileInfo))Starter.Start();
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:public override object InitializeLifetimeService(){//Das Lease wird nie ablaufen.return null;}
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


