Anwendungsdomänen und dynamisches Laden

Veröffentlicht: 15. Sep 2002 | Aktualisiert: 22. Jun 2004

Von Eric Gunnerson

Das .NET Framework erlaubt das dynamische Laden und Entladen von Assemblies zwar, allerdings nur über einen Umweg. Der Artikel zeigt Schritt für Schritt, wie Sie einer Anwendung diese Funktionalität hinzufügen.

Auf dieser Seite

Anwendungsarchitektur
Anlegen einer Anwendungsdomäne
Dynamisches Laden von Assemblys
Erneutes Laden von Assemblys
Erkennen neuer Assemblys
Drag & Drop
Status
Sonstige Informationsquellen
Nächster Monat

Gerade sitze ich in einer Abflughalle am internationalen Flughafen von Palm Springs und warte auf den Rückflug nach Seattle, nachdem ich an einer ASP.NET-Konferenz teilgenommen habe.

Mein ursprünglicher Plan für diesen Monat - soweit ich überhaupt einen Plan habe - bestand darin, an dem Teil der SuperGraph-Anwendung aus dem letzten Monat weiterzuarbeiten, der sich mit der Auswertung von Ausdrücken beschäftigt. In den letzten Wochen habe ich jedoch einige E-Mails erhalten, in denen ich gefragt wurde, wann ich mich um das Laden und Entladen von Assemblys in Anwendungsdomänen kümmern kann. Daher habe ich entschieden, mich stattdessen auf diesen Punkt zu konzentrieren.

Anwendungsarchitektur

Bevor ich den Code vorstelle, möchte ich zunächst meine Zielsetzung erläutern. Wie Sie sich vielleicht erinnern, können Sie bei SuperGraph aus einer Liste mit Funktionen auswählen. Ich möchte Add-In-Assemblys in ein bestimmtes Verzeichnis legen, in dem sie von SuperGraph entdeckt, geladen und die darin enthaltenen Funktionen erkannt werden.

Für diese Aufgabe ist keine separate Anwendungsdomäne erforderlich. Assembly.Load() funktioniert in der Regel fehlerfrei. Leider können Assemblys jedoch nicht separat entladen werden - nur Anwendungsdomänen können entladen werden. Wenn Sie also einen Server schreiben und die Benutzer sollen in der Lage sein, ihre Add-Ins zu aktualisieren, ohne dass der Server angehalten und gestartet werden muss, können Sie hierzu nicht die Standard-AppDomain (Anwendungsdomäne) verwenden.

Aus diesem Grund laden wir alle Add-In-Assemblys in eine separaten Anwendungsdomäne. Wenn eine Datei hinzugefügt oder geändert wird, werden wir die Anwendungsdomäne entladen, eine neue erzeugen, und die aktuellen Dateien in diese Anwendungsdomäne laden. Danach ist die Welt wieder in Ordnung.
Ich habe zur weiteren Erläuterung dieses Sachverhaltes ein typisches Szenario erstellt (siehe Abbildung 1).

Bild01

Abbildung 1. Typisches Anwendungsdomänenszenario

In diesem Diagramm erzeugt die Loader-Klasse eine neue Anwendungsdomäne mit dem Namen Functions. Nachdem die Anwendungsdomäne erzeugt wurde, erstellt Loader eine Instanz von RemoteLoader in dieser neuen Anwendungsdomäne.

Zum Laden einer Assembly wird eine Ladefunktion für die RemoteLoader-Klasse aufgerufen. Sie öffnet eine neue Assembly, findet alle Funktionen darin und packt sie in ein FunctionList-Objekt. Dann gibt sie dieses Objekt an die Loader-Klasse zurück. Die Function-Objekte in diesem FunctionList-Objekt können dann von der Graph-Funktion verwendet werden.

Anlegen einer Anwendungsdomäne

Die erste Aufgabe besteht darin, die AppDomain anzulegen.. Um sie in der passenden Weise anzulegen, müssen wir ihr ein AppDomainSetup-Objekt übergeben. Die dazu vorhandene Dokumentation ist nützlich, wenn Sie die Funktionsweise des Ganzen bereits verstanden haben. Sie ist jedoch keine große Hilfe, wenn Sie erst mal erfahren möchten, wie dies alles funktioniert. Wenn eine Google-Suche zu diesem Thema die Kolumne des letzten Monats zurückgibt, bekomme ich wohl Ärger.
Das Hauptproblem liegt in der Art und Weise, wie Assemblys in die Laufzeitumgebung geladen werden. Die Laufzeitumgebung wird standardmäßig entweder im globalen Assemblycache oder in der aktuellen Anwendungsverzeichnisstruktur nachsehen. Wir möchten jedoch die Add-In-Anwendungen aus einem ganz anderen Verzeichnis laden.

Wenn Sie sich die Dokumentation zu AppDomainSetup ansehen, erfahren Sie, dass Sie die ApplicationBase-Eigenschaft auf das Verzeichnis setzen können, in dem nach Assemblys gesucht werden soll. Leider muss auch auf das ursprüngliche Programmverzeichnis verwiesen werden, da sich dort die RemoteLoader-Klasse befindet.

Die Programmierer von Anwendungsdomänen haben aus diesem Grund einen zusätzlichen Speicherort bereitgestellt, in dem nach Assemblys gesucht wird. Wir verwenden ApplicationBase, um auf das Add-In-Verzeichnis zu verweisen, und legen dann PrivateBinPath fest, um auf das Hauptanwendungsverzeichnis zu zeigen.

Es folgt der Code der Loader-Klasse, die diese Aufgabe ausführt:

AppDomainSetup setup = new AppDomainSetup();
setup.ApplicationBase = functionDirectory;
setup.PrivateBinPath = AppDomain.CurrentDomain.BaseDirectory;
setup.ApplicationName = "Graph";
appDomain = AppDomain.CreateDomain("Functions", null, setup);
remoteLoader = (RemoteLoader)  
    appDomain.CreateInstanceFromAndUnwrap("SuperGraph.exe", 
        "SuperGraphInterface.RemoteLoader");

Nachdem die Anwendungsdomäne angelegt wurde, wird mithilfe der CreateInstanceFromAndUnwrap()-Funktion eine Instanz der RemoteLoader-Klasse in der neuen Anwendungsdomäne erzeugt. Beachten Sie, dass der Dateiname der Assembly, in der sich die Klasse befindet, bekannt sein muss und auch der vollständige Name der Klasse.

Wenn dieser Aufruf ausgeführt wird, erhalten wir eine Instanz, die wie RemoteLoader aussieht. Tatsächlich handelt es sich um eine kleine Proxyklasse, die Aufrufe der RemoteLoader-Instanz in der anderen Anwendungsdomäne weiterleitet. Dies ist dieselbe Infrastruktur, die auch .NET Remoting verwendet.

Assembly Binding Log ViewerBeim Schreiben des Codes zum Ausführen dieser Aufgabe können Fehler auftreten. Die Dokumentation enthält wenig Hilfestellung zum Debuggen Ihrer Anwendung. Wenn Sie jedoch wissen, wen Sie fragen müssen, werden sie Sie auf den Assembly Binding Log Viewer hinweisen (Assemblybindungs-Protokollanzeige sie heißt fuslogvw.exe, da das Ladesubsystem auch als fusion bezeichnet wird).

Wenn Sie den Betrachter ausführen, können Sie ihn beauftragen, Fehler zu protokollieren. Wenn dann während der Ausführung der Anwendung Probleme beim Laden einer Assembly auftreten, können Sie die Anzeige aktualisieren, um weitere Einzelheiten zu den aufgetretenen Problemen anzuzeigen.

Mithilfe der Protokollanzeige können Sie zum Beispiel herausfinden, dass Sie für Assembly.Load() nicht die Endung DLL an den Dateinamen anhängen müssen. Das Protokoll enthält den Hinweis, dass versucht wurde, f.dll.dll zu laden.

Dynamisches Laden von Assemblys

Nachdem nun die Anwendungsdomäne angelegt wurde, muss geklärt werden, auf welche Art eine Assembly geladen wird und wie ihre Funktionen extrahiert werden. Dazu ist Code in zwei separaten Bereichen erforderlich. Der erste Code sucht in einem Verzeichnis nach den Dateien und lädt dann die einzelnen Dateien:

void LoadUserAssemblies()
{
    availableFunctions = new FunctionList();
    LoadBuiltInFunctions();
    DirectoryInfo d = new DirectoryInfo(functionAssemblyDirectory);
    foreach (FileInfo file in d.GetFiles("*.dll"))
    {   
        string filename = file.Name.Replace(file.Extension, "");
        FunctionList functionList = loader.LoadAssembly(filename);
        availableFunctions.Merge(functionList);
    }
}

Diese Funktion in der Graph-Klasse findet alle DLL-Dateien im Add-In-Verzeichnis, entfernt deren Erweiterung und fordert dann das Ladeprogramm auf, diese zu laden. Die zurückgegebene Liste mit Funktionen wird in die aktuelle Liste der Funktionen eingefügt.

Der zweite Teil des Codes befindet sich in der RemoteLoader-Klasse. Er lädt die Assembly und sucht nach den Funktionen:

public FunctionList LoadAssembly(string filename)
{
    FunctionList functionList = new FunctionList();
    Assembly assembly = AppDomain.CurrentDomain.Load(filename);
    foreach (Type t in assembly.GetTypes())
    {
        functionList.AddAllFromType(t);
    }    
    return functionList;
}

Dieser Code ruft Assembly.Load() für den übergebenen Dateinamen (eigentlich den Assemblynamen) auf und lädt alle geeigneten Funktionen in eine FunctionList-Instanz, die an den Aufrufenden zurückgegeben wird.

Zu diesem Zeitpunkt kann die Anwendung gestartet werden, und die Add-In-Assemblys werden geladen. Der Benutzer kann dann auf diese verweisen.

Erneutes Laden von Assemblys

Als Nächstes möchten wir diese Assemblys bei Bedarf erneut laden können. Dies soll natürlich automatisch ausgeführt werden, aber zum Testen habe ich dem Formular eine Reload-Schaltfläche hinzugefügt, mit deren Hilfe die Assemblys neu geladen werden. Der Handler für diese Schaltfläche ruft die Funktion Graph.Reload() auf, die die folgenden Aktionen ausführen muss:

  1. Entladen der Anwendungsdomäne.

  2. Erstellen einer neuen Anwendungsdomäne.

  3. Neuladen der Assemblys in der neuen Anwendungsdomäne.

  4. Einbinden der GraphLine-Objekte in die neu erstellte Anwendungsdomäne.

Schritt 4 ist erforderlich, da die GraphLine-Objekte Function-Objekte aus der alten Anwendungsdomäne enthalten. Nachdem diese Anwendungsdomäne entladen wurde, können die Funktionsobjekte nicht mehr verwendet werden.

Zur Lösung dieses Problems ändert HookupFunctions() die GraphLine-Objekte, so dass sie auf die richtigen Funktionen aus der aktuellen Anwendungsdomäne zeigen.
So sieht der Code aus:

loader.Unload();
loader = new Loader(functionAssemblyDirectory);
LoadUserAssemblies();
HookupFunctions();
reloadCount++;
if (this.ReloadCountChanged != null)
    ReloadCountChanged(this, new ReloadEventArgs(reloadCount));

Die letzten beiden Zeilen lösen ein Ereignis aus, sobald eine erneute Ladeoperation ausgeführt wird. Damit wird ein Zähler für erneute Ladevorgänge im Formular aktualisiert.

Erkennen neuer Assemblys

Im nächsten Schritt sollen neue oder geänderte Assemblys im Add-In-Verzeichnis erkannt werden. Die Frameworks stellen hierzu die FileSystemWatcher-Klasse bereit. Dies ist der Code, den ich dem Graph-Klassenkonstruktor hinzugefügt habe:

watcher = new FileSystemWatcher(functionAssemblyDirectory, "*.dll");
watcher.EnableRaisingEvents = true;
watcher.Changed += new FileSystemEventHandler(FunctionFileChanged);
watcher.Created += new FileSystemEventHandler(FunctionFileChanged);
watcher.Deleted += new FileSystemEventHandler(FunctionFileChanged);

Beim Erstellen der FileSystemWatcher-Klasse legen wir fest, in welchem Verzeichnis und nach welchen Dateien gesucht werden soll. Die EnableRaisingEvents-Eigenschaft gibt an, ob beim Feststellen von Änderungen Ereignisse gesendet werden sollen, und die letzten 3 Zeilen binden die Ereignisse in eine Funktion unserer Klasse ein. Die Funktion ruft nur Reload() zum Neuladen der Assemblys auf.

Dieser Ansatz ist nicht sehr effizient. Wenn eine Assembly aktualisiert wird, muss die Assembly entladen werden, damit eine neue Version geladen werden kann. Dies ist jedoch beim Hinzufügen oder Entfernen einer Datei nicht erforderlich. In diesem Fall ist der Aufwand, dies für alle Änderungen durchzuführen, nicht sehr hoch, und der Code wird dadurch einfacher.

Nachdem dieser Code erstellt wurde, wird die Anwendung ausgeführt. Dann versuchen wir, eine neue Assembly in das Add-In-Verzeichnis zu kopieren. Wie erhofft erhalten wir ein Dateiänderungsereignis, und nach dem Neuladen stehen die neuen Funktionen zur Verfügung.

Leider tritt bei dem Versuch, eine vorhandene Assembly zu aktualisieren, ein Problem auf. Die Datei wurde durch die Laufzeit gesperrt, d.h. die neue Assembly kann nicht in das Add-In-Verzeichnis kopiert werden, und ein Fehler wird ausgegeben.

Die Entwickler der AppDomain-Klasse kannten dieses Problem, daher haben sie eine Lösung zur Verfügung gestellt. Wenn die ShadowCopyFiles-Eigenschaft auf true festgelegt ist (die Zeichenfolge true, nicht die Boolesche Konstante true. Warum das so ist, weiß ich nicht.), wird die Assembly von der Laufzeit in ein Cacheverzeichnis kopiert und dort geöffnet. Dadurch wird die ursprüngliche Datei nicht gesperrt, so dass eine gerade verwendete Assembly aktualisiert werden kann. ASP.NET verwendet diese Möglichkeit.
Um dieses Feature zu aktivieren, habe ich die folgende Zeile dem Konstruktor für die Loader-Klasse hinzugefügt:

setup.ShadowCopyFiles = "true";

Dann habe ich die Anwendung neu compiliert und den gleichen Fehler erhalten. Ich habe in der Dokumentation zur ShadowCopyDirectories-Eigenschaft nachgesehen, wo eindeutig nachzulesen ist, dass für alle in PrivateBinPath angegebenen Verzeichnisse, einschließlich des durch ApplicationBase angegebenen Verzeichnisses, eine Schattenkopie erzeugt wird, wenn diese Eigenschaft nicht festgelegt ist. Wie ich bereits erwähnt habe, ist die Dokumentation in diesem Bereich nicht sehr gut.

Die Dokumentation für diese Eigenschaft ist schlichtweg falsch. Ich habe zwar nicht die exakte Verhaltensweise nachgeprüft, konnte jedoch feststellen, dass von den Dateien im ApplicationBase-Verzeichnis standardmäßig keine Schattenkopien erstellt werden. Durch die explizite Angabe des Verzeichnisses wird das Problem gelöst:

setup.ShadowCopyDirectories = functionDirectory;

Diese Erkenntnis hat mich mindestens eine halbe Stunde gekostet.

Nun kann eine vorhandene Datei aktualisiert und richtig geladen werden. Nachdem ich dieses Problem gelöst hatte, sah ich mich mit einem anderen kleinen Problem konfrontiert. Beim Ausführen der Funktion zum Neuladen über die Schaltfläche im Formular wurde das Neuladen immer in demselben Thread wie das Zeichnen ausgeführt, so dass während des Ladevorgangs keine Zeile gezeichnet werden konnte.

Nach dem Wechsel zu den Dateiänderungsereignissen ist das Zeichnen möglich, nachdem die Anwendungsdomäne entladen wurde und bevor die neue Anwendungsdomäne geladen wird. In diesem Fall tritt eine Ausnahme auf.

Hierbei handelt es sich um ein bekanntes Problem der Multithread-Programmierung, das mithilfe der C#-Anweisung lock auf einfache Art gelöst werden kann. Ich habe der Zeichenfunktion und der Funktion zum erneuten Laden eine lock-Anweisung hinzugefügt, so dass sichergestellt ist, dass beide Funktionen gleichzeitig ausgeführt werden können. Dadurch wurde das Problem gelöst. Wenn eine aktualisierte Version einer Assembly hinzugefügt wird, wechselt das Programm automatisch zu einer neuen Version der Funktion. Das funktioniert klasse.

Mir ist jedoch noch eine weitere seltsame Verhaltensweise aufgefallen. Wenn Win32-Funktionen Dateiänderungen feststellen, sind sie bei der Anzahl von gesendeten Änderungen sehr großzügig, so dass bei einer einzelnen Aktualisierung einer Datei fünf Änderungsereignisse gesendet werden, und die Assemblys werden fünf Mal neu geladen. Dieses Problem kann durch das Erstellen einer intelligenteren FileSystemWatcher-Klasse gelöst werden, die diese Ereignisse gruppieren kann. In dieser Version ist sie jedoch noch nicht enthalten.

Drag & Drop

Das Kopieren von Dateien in ein Verzeichnis war nicht gerade benutzerfreundlich, daher habe ich eine Drag-&-Drop-Funktion zur Anwendung hinzugefügt. Dazu lege ich zunächst die AllowDrop-Eigenschaft des Formulars auf TRUE fest, wodurch die Drag-&-Drop-Unterstützung aktiviert wird. Dann habe ich eine Routine mit dem DragEnter-Ereignis verknüpft.

Sie wird aufgerufen, wenn der Cursor bei einer Drag-&-Drop-Operation in ein Objekt verschoben wird, und sie ermittelt, ob das aktuelle Objekt für eine Drag-&-Drop-Operation zulässig ist.

private void Form1_DragEnter(
    object sender, System.Windows.Forms.DragEventArgs e)
{
    object o = e.Data.GetData(DataFormats.FileDrop);
    if (o != null)
    {
        e.Effect = DragDropEffects.Copy;
    }
    string[] formats = e.Data.GetFormats();
}

In diesem Handler überprüfe ich, ob FileDrop-Daten verfügbar sind (d.h., eine Datei wird in das Fenster gezogen). Falls dies zutrifft, lege ich den Effekt auf Kopieren fest, wodurch der Cursor entsprechend gesetzt wird und das DragDrop-Ereignis gesendet wird, wenn der Benutzer die Maustaste loslässt. Die letzte Zeile in der Funktion dient ausschließlich dem Debuggen, um zu sehen, welche Informationen in der Operation verfügbar sind.

Im nächsten Schritt muss der Handler für das DragDrop-Ereignis geschrieben werden:

private void Form1_DragDrop(
    object sender, System.Windows.Forms.DragEventArgs e)
{
    string[] filenames = (string[]) e.Data.GetData(DataFormats.FileDrop);
    graph.CopyFiles(filenames);
}

Diese Routine ruft die mit dieser Operation verbundenen Daten ab - eine Gruppe von Dateinamen - und übergibt diese an die Graph-Funktion, die die Dateien in das Add-In-Verzeichnis kopiert, wodurch wiederum deren Neuladen durch die Dateiänderungsereignisse ausgelöst wird.

Status

Zu diesem Zeitpunkt können Sie die Anwendung ausführen und neue Assemblys hineinziehen, die dann sofort geladen und weiter ausgeführt werden. Ich bin mit dem Ergebnis zufrieden.

Sonstige Informationsquellen

C# Community-Site
Ich habe einen Visual-C#-Community-Newsletter eingerichtet, so dass die Kommunikation des C#-Produktteams mit den Benutzern nun leichter ist. Über diesen Newsletter werde ich auch neuen Inhalt auf der Community-Site unter https://www.gotdotnet.com/team/csharp (in Englisch) ankündigen. Zudem können Sie dort erfahren, ob wir uns in einer Konferenz oder einem Benutzergruppenmeeting befinden. Sie können sich auf der Community-Site für den Newsletter anmelden.

Nächster Monat

Falls mir mehr Zeit für SuperGraph bleibt, werde ich wohl an einer Version der FileSystemWatcher-Klasse arbeiten, die nicht so viele überflüssige Ereignisse sendet, und vielleicht an der Ausdruckauswertung. Ich habe auch noch ein weiteres kleines Codebeispiel, das ich vielleicht stattdessen erläutern werde.

Download

supergraphfiles.exe