Hintergrund
Vor dem Zeitalter von .NET, also in den 1990er Jahren und davor, wurden bei der Installation von Windows-Anwendungen vielfach verwendete Bibliotheken (DLLs) der Anwendungen im Windows-Systemverzeichnis des PCs abgelegt und in die Windows-Registry eingetragen. Der Grund, warum viele Software-Hersteller dies taten, war u.a. um Speicherplatz zu sparen, der zu der Zeit noch in der Regel sehr wertvoll war. Durch diese einfache Ablage diverser DLL-Dateien im Systemverzeichnis waren allerdings die Möglichkeiten für die Verwaltung von verschiedenen Versionen einer Bibliothek sehr beschränkt, da sich während ihres Lebenszyklus beispielsweise ihre Schnittstelle bzw. der Einstiegspunkt zwischenzeitlich geändert haben konnte. Eine Windows-Anwendung hatte nämlich keine Möglichkeit herauszufinden, ob die in einer DLL-Bibliothek enthaltenen Datentypen wirklich die selben Typen waren, mit denen die Anwendung erstellt wurde. Infolgedessen konnte eine neue Version einer Software-Komponente eine ältere Version davon überschreiben und damit die Anwendung zum Absturz bringen.
Um so größer eine entwickelte Anwendung durch das Hinzufügen neuer Funktionen usw. über die Jahre selbst wurde und diese zum Beispiel darüber hinaus noch DLL-Bibliotheken von Dritt-Herstellern einsetzte, war man als Software-Hersteller - rein technisch betrachtet - seit Ende der 1990er Jahre eigentlich nicht mehr in der Lage zu sagen, dass die installierte Anwendung wirklich zu 100% frei von Seiteneffekten mit anderen installierten Applikationen auf dem verwendeten Windows-System waren. Da schuf auch u.a. der Einsatz von dedizierten Merge-Modulen bei der Installation von Bibliotheken nur bedingt Abhilfe, weil sich auch hier nicht alle Software-Hersteller an das Vorgehen gehalten haben.
Dieses Problem ist in der Entwicklerwelt besser bekannt als die berühmt-berüchtigte DLL-Hell, welche mit Einführung und Nutzung des .NET-Frameworks der Vergangenheit angehören sollte.
Ausweg aus der Misere
Eines der Design-Ziele bei der Entwicklung der .NET-Laufzeitumgebung und Klassenbibliothek von Microsoft war also, neben Plattformunabhängigkeit und Sprachunabhängigkeit (und eine Alternative zu Java), die Unzulänglichkeiten des zugrunde liegenden Component-Object-Models (COM / ActiveX) beiseite zu legen. Das Component-Object-Modell war schlichtweg dahingehend nicht ausgelegt worden, um das beschriebene Problem damit in den Griff zu bekommen.
Warum das Ganze?
Die Motivation für diesen Artikel ging daraus hervor, dass auch heute noch, sowohl in vielen Lehrbüchern, als auch in Online-Tutorien zum Thema C# und .NET, nach wie vor viele wichtige Basis-Themen abgedeckt werden - und das ist auch gut so! Leider bleibt dabei das Thema der Versionierung der Software bzw. deren Komponenten etwas auf der Strecke, geschweige denn komplett außen vor. Ich finde das etwas schade, denn neben der Entwicklung der Software-Architektur spielt gerade das Thema der Versionierung mit den technischen Möglichkeiten von .NET für die spätere Pflege und Wartung der entwickelten Software dabei eine wichtige Rolle.
Im Folgenden wird neben dem generellen Aufbau von .NET-Assemblies, auf ein paar grundlegende, relevante Mechanismen der .NET-Runtiime eingegangen, welche für die saubere Auflösung der passenden Bibliotheken in unterschiedlichen Versionen sorgt. Da die implementierten Mechanismen im .NET-Framework Anforderungen für unterschiedlichste Anwendungsarchitekturen abdecken, fokussieren wir uns für den Einsatz von typischen .NET-Rahmenapplikationen mit hierarchischem Architekturstil. Des Weiteren werden moderne Aspekte, wie z.B. der Einsatz von Paketmanagern wie nuget oder anderen strukturellen Indirektionen in der Anwendung ausgeblendet, um den Rahmen nicht zu sprengen.
Aufbau von .NET-Assemblies
Eine .NET-Applikation besteht aus einer oder mehreren Assemblies, also aus einzeln installierberen Einheiten in Form von EXE-Dateien und DLL-Dateien. Speziell die Bibliothek-Assemblies bestehen aus einem Manifest und einem oder mehreren Modulen (kompilierter IL-Code + Metadaten). Die Common-Language-Runtime (CLR) von .NET ist für die Sicherheits- und Versionsprüfung der angezogenen .NET-Assemblies zuständig.
Eine einzelne .NET-Assembly besteht grundsätzlich aus vier Teilen:
- Assembly Manifest welches Assembly-Metadaten enthält
- Metadaten-Type
- Microsoft intermediate language (MSIL) Code, der mit den Typen arbeitet und implementiert
- Eine oder mehrere eingebettete Ressourcen
- Eine Assemby-Verweisliste, die eine Liste von DLL-Dateien und anderen Abhängigkeiten beinhaltet und für dessen Ausführung relevant sind
Diese Bestandteile allein führen allerdings noch nicht automatisch dazu, um die berüchtigte DLL-Hell aufzulösen. Um dieses Problem zu lösen, wurde in der .NET-Laufzeitumgebung die sogenannte Side-by-Side-Ausführung von .NET-Komponenten umgesetzt und eine Signierung von Assemblies eingeführt. Dies bedeutet, dass .NET-Applikationen von der Laufzeitumgebung unterschieden werden können, einmal durch:
- Signierte Assemblies mit einem sogenannten Token-Schlüssel
- Versionsabhängige Speicherung (GAC)
- Weitgehend isolierte Ausführung von mehreren .NET-Anwendungen zur selben Zeit
Beim öffentlichen Token einer signierten Assembly handelt es sich um einen eindeutigen Teil des generierten Public-Keys für Assemblies mit starkem Namen, auf die ich noch eingehe. Dieser Token wird beim Geneieren der signierten Assembly mit einkompiliert. Mit diesem Schritt ist sichergestellt, dass die .NET-Laufzeitumgebung anhand des Tokens unter anderem feststellen kann, ob es sich wirklich um die genannte Assembly handelt, welche geladen werden soll. Auch wenn eine Assembly eine andere signierte Assembly benutzen möchte, sollte die betroffene Assembly ebenfalls signiert werden, um Sicherheitsüberprüfungen nicht zu unterbrechen und um Uneindeutigkeiten zu vermeiden. Um eine versionsabhängige Speicherung einer Assemby zu ermöglichen, sind neben dem Token und anderen Metadaten, drei zusätzliche Attribute in die Assembly einkompiliert, die letztendlich für die eindeutige Version und Identität sorgen.
- AssemblyVersionAttribute: Legt hier die eindeutige Version der Assembly fest in dem Format MAJOR.MINOR.BUILD.REVISION
- AssemblyCultureAttribute: Gibt die Kultur der Assembly an, die unterstützt werden soll
- AssemblyFlagsAttribute: legt fest, ob die Assembly parallel ausgeführt werden kann / darf
Die .NET-Laufzeitumgebung verwendet den angegebenen Wert des Attributs AssemblyVersionAttribute bei der Auflösung von Abhängigkeiten zu anderen Assemblies bzw. bei den Bindungsvorgängen in Assemblies. Detailliertere Informationen dazu gibt es bei Microsoft. [1] [2]
Assemblies mit starkem Namen
Da Assemblies von mehreren Anwendungen gleichzeitig ausgeführt und genutzt werden können (Side-By-Side-Execution), reicht eine eindeutige Versionskennzeichnung durch den Software-Hersteller oft nicht mehr aus. Die Assembly benötigt deshalb einen sogenannten "starken Namen", also einen eindeutigen Identifizierer, welcher die Eindeutigkeit der .NET-Assembly belegt und für Versions- und Namensschutz in der Ausführungsumgebung sorgt. Für eine Applikation ist der Zugriff auf eine Assembly, welche in unterschiedlichen Versionen auf dem System vorliegt, absolut üblich und ist auch ein reales Szenario in der beschriebenen Beispiel-FrameApp weiter unten. Die kompilierte Assembly muss sogar für folgende Szenarien mit einem starken Namen signiert werden:
- Die Assembly wird in den Global Assembly Cache (GAC) installiert
- Die Assembly wird in unterschiedlichen Versionen (z.B. V1.0.0 und V1.1.0) von ihr innerhalb eines .NET-Prozessraums geladen und z.B. von der Hauptapplikation verwendet
- Der Verweis auf die Assembly von anderen Assemblies mit starkem Namen (sog. friend-Zugriff)
Um eine Assembly mit starkem Namen zu erstellen, kann dies einmal über die Projekteigenschaften -> "Signierung" aus dem Visual Studio erfolgen, alternativ aber auch über das Kommandozeilen-Tool sn.exe und einen Eintrag in die betroffene Projektdatei:
sn.exe -k AppKey.snk
<PropertyGroup>
<AssemblyOriginatorKeyFile>AppKey.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>
Für die Komplierung der Assembly mit dem starkem Namen unter Visual Studio ist jetzt nichts weiter zu tun, außer zu kompilieren. Über den Kommandozeilen-Linker al.exe ist die Angabe des Schlüsselpaars eben dann erforderlich.
Was passiert nun beim Signieren einer .NET-Assembly?
Bei der Signierung wird einmal der öffentliche Schlüssel des zuvor generierten Schlüsselpaars im Manifest der Assebmly eingebettet. Als Zweites wird der öffentliche Token ebenfalls in der Assembly einkompiliert. Der Token wird oft als Ersatz für den eigentlichen öffentlichen Schlüssel verwendet, weil dieser nämlich wesentlich kürzer ist. Damit der Mechansimus nun funktioniert, wird die Assembly mit Hilfe des privaten Schlüssels aus dem Schlüsselpaar signiert. Über den Framework Disassembler ildasm.exe kann man komfortabel das Manifest der kompilierten und signierten Assembly einsehen.
Lokalisierung von Assemblies in .NET
Beim Start einer .NET-Applikation beginnt der Prozess zum Auffinden und Bindung einer angebundenen Assembly, wenn die .NET-Runtime versucht, eine verwendete Referenz (z.B. Klassen..) innerhalb des ablaufenden Programms zu einer anderen Assembly aufzulösen. Die Auflösung kann dabei einmal statisch oder dynamisch erfolgen. Bei der dynamischen Bindung von Assemblies können diese direkt über Assembly.Load() aus dem Reflection-Namepsace geladen werden, oder über gefeuerte AppDomain-Events („AssemblyResolve“) aus dem .NET-Prozess. Wenn andere Anforderungen nicht dagegen sprechen, benutzt man gemeinhin die statische Auflösung von Assemblies und ist defacto auch das Standardverhalten. Bei statisch gebundenen DLL-Dateien (Assemblies) geht die .NET-Runtime in einer vorgeschriebenen, definierten Weise bei der Auflösung vor.
Statische Auflösung von Abhängigkeiten
1. Über eine Konfigurationsdatei der Applikation / Rahmenapplikation:
- "app.config" - Ermittlung des Speicherorts über die Konfgurationsdatei "app.config" der Applikation. Dies ist grundsätzlich möglich. Dem Anbieter von entsprechenden PlugIns oder Treibern ist es jedoch in der Regel verboten, da solche Dateien von Dritt-Herstellern nicht einfach verändert werden dürfen, abgesehen davon das es sich um ein schlechtes Software-Design handeln würde.
- "machine.config" - Ermittlung Speicherorts über die "machine.config"-Konfigurationsdatei. Diese Dateien wurden bzw. werden von manchen Softwarekomponenten-Herstellern noch verwendet, um die Runtime anzuweisen, dass es ein oder mehrere Bugfixes bzw. Updates für gemeinsam zu nutzende Komponente(n) gibt. Diese Policy muss jedoch wiederum selbst als Assembly in den GAC installiert werden, damit der Bindungsmechanismus funktioniert. Dieses Verfahren ist allerdings kein Best-Practice mehr heutzutage und wird scheinbar nur noch für webbasierte Dienste eingesetzt.
- Publisher Policy File - Ermittlung des Speicherorts über ein entsprechendes Policy-File. Dies ist nach wie vor ein Standard-Verfahren, um der .NET-Runtime anzuzeigen, dass es zum Beispiel Bugfixes bzw. Updates für gemeinsam zu nutzende Komponente(n) gibt. Diese Policy muss nicht, kann jedoch wiederum selbst als Assembly in den GAC installiert werden, damit der Bindungsmechanismus der Runtime funktioniert. Der Hersteller der anzubietenen Software-Komponente ist für das Anlegen der Policy-Assembly selbst verantwortlich.
2. Prüfung, ob der volle Name der Assembly (inkl. Version, Public Key Token…) bereits in den .NET-Prozessraum geladen wurde. Falls ja, wird die bereits geladene Assembly aus dem Prozessraum bei der Auflösung der Referenz verwendet.
3. Überprüfung im Global Assembly Cache, ob entsprechende Assemblies in der Version verfügbar sind - Falls die benötigten Assemblies im 1. und 2. Schritt nicht gefunden wurden, wird nun im GAC nachgesehen und die Assemblies ggf. von dort geladen.
4. Ermittlung und Einbindung der Assembly über Auflösung von definierten Code Bases.
Je nach Architektur-Konzept der Applikation ist nur ein oder ggf. zwei der genannten Punkte sinnvoll in der Praxis einzusetzen. Pauschal kann man nur schwer eine Antworrt zu der Frage finden, welche der 4 Punkte nun das beste Konzept für die eigene Applikation ist. Um es mit der typischen Antwort eines Beraters zu sagen: es kommt darauf an! Weitere bzw. detaillierte Informationen zu den beschriebenen 4 Schritten gibt es bei Microsoft. [3]
Der Global Assembly Cache - Vorteile und Nachteile beim Einsatz
Als einer der Vorteile den GAC einzusetzen, ist sicherlich, dass alle Assemblies für alle klassischen .NET-Desktop-Applikationen angeboten werden, auf dessen System sie auch installiert wurden. Die Versionierung selbst ist einfach und komfortabel, da der Global Assembly Cache diese übernimmt und die entsprechende Verzeichnisstruktur im Katalog aufbaut. Eine lokale Kopie der im GAC installierten Assembly ist somit nicht mehr notwendig und wirkt gerade bei eingesetzten Komponenten von Drittherstellern, zum Beispiel für eine UI-Komponentenbibliothek im WPF-Umfeld, platzsparend und übersichtlich. Darüber hinaus ist eine volle Vertrauenswürdigkeit der Assembly gegeben, welche einmal in den GAC installiert wurde.
Auf der anderen Seite müssen diese Assemblies explizit in den GAC z.B. durch ein dediziertes Merge-Modul installiert werden, welches eher als Nachteil zu sehen ist. Microsoft empfiehlt generell für .NET-Anwendungen, dass der Global Assembly Cache nach Möglichkeit nicht verwendet werden soll. Eine weitere Voraussetzung für die Installation in den GAC ist, dass der Nutzer bei der Installation der Anwendung in jedem Fall Administrator-Rechte benötigt, um diee Assemblies dorthin installieren zu dürfen. Dies liegt ganz einfach daran, dass der GAC letztendlich unterhalb vom Windows-Systemverzeichnis lebt. Klassische Rahmenapplikationen, bei denen Plugins oder Treiber jedoch in anderen Unterverzeichnissen leben, als in dem des Programmverzeichnisses der Hauptapplikation, kommen in der Regel jedoch gar um die Nutzung des GAC herum. Meine persönliche Meinung dazu ist, dass es aus meiner Sicht kein Problem darstellt, solange es sich um Assemblies handelt, die ausschließlich für den Anwendungskontext gedacht sind, für die sie den Global Assembly Cache installiert werden - also wirklich keine Zweckentfremdung betrieben wird.
Durch intelligente Installations-Mechanismen z.B. von der MSI-Installerengine von Microsoft funktioniert generell die Deinstallation von Assemblies aus dem GAC bei klassischen Desktop-Anwendungen auch, schließt jedoch eine Fehlbenutzung von anderen Software-Anbietern nach wie vor nicht aus. Als Best-Practice hat sich, zumindest meiner Erfahrung nach herausgestellt, dass Assemblies, welche nicht unbedingt als Plugins gegenüber einer oder mehreren anderen öffentlichen Schnittstellen einer Rahmen-Applikation gelten, nicht in den lokalen GAC installiert werden sollten. Es reicht hier eine reguläre Installation in die entsprechenden Anwendungsverzeichnisse. Falls zukünftig parallel mehrere Versionen einer Assembly in einem lokalen Verzeichnis installiert werden sollen, empfehle ich eher, die Versionierung dieser Assemblies nach dem semantischem Versionierungsschema durchzuführen und diese Version in den Dateinamen der Assembly mit einzubetten.
Auflösung von dynamisch gebundenen Assemblies
Wie bereits erwähnt, ist die Vorgehensweise der dynamischen Bindung einer Bibliothek an eine Applikation generell empfehlenswert, wenn zur Laufzeit der Rahmenapplikation eine oder mehrere Bibliotheken geladen werden sollen, welche außerhalb des Ausführungsverzeichnisses des Frames liegen. Dies gilt gleichermaßen für PlugIns bzw. AddIns. Die Auflösung und vorherige Bestimmung des Ablageorts einer Assembly kann, wie bereits beschrieben auch über eine oder mehere Konfigurationsdateien oder individuellen Manifest-Dateien ausgelagert werden. Mit dieser Möglichkeit ist keine "harte" Bindung im Code nötig.
Beispiel-FrameApp
Grau ist alle Theorie, deshalb gibt es dazu eine kleine Beispiel-Applikation, welche zum Ausprobieren weiter unten als ZIP-Paket heruntergeladen werden kann. Der Beispiel-Frame dient nur als grobes Gerüst für den Rahmen, welcher eben als Anwendungs-Kontext dienen soll. Das Beispiel besteht aus folgenden Assemblies bzw. C#-Projekten:
Name | Beschreibung |
ClassicFrameApp | Test-Rahmenapplikation, lädt Treiber-Plugins und führt Aktionen aus |
FramePluginInterfaces | Einfache Schnittstellen-Assembly für Plugins / Treiber (Version 1.0) |
FramePluginInterfaces2 | Einfache Schnittstellen-Assembly für Plugins / Treiber (Version 1.1) mit Erweiterung |
ModelTest | Eine prototypische Datenmodell-Assembly /(Version) |
TestDriver1 | Test-Plugin (Treiber 1), Benutzung des Plugin-Interface in V1.0 |
TestDriver2 | Test-Plugin (Treiber 2), Benutzung des Plugin-Interface in V1.1 |
Drivers (Verzeichnis) | Ausgabeverzeichnis der Treiber-Kompilate von TestDriver 1 + 2 |
policy (Verzeichnis) | Enthält exemplarisch eine Policy-Assembly mit benötigten Ressourcen |
Generell geht es um die Implementierung der folgenden, einfach gehalten Plugin-Schnittstelle, welche in den Projekten FramePluginInterfaces + FramePluginInterfaces2 enthalten ist:
public interface ILib
{
void Initialize();
string GetName();
string GetInformation();
}
public interface ILib2
{
/// <summary>
/// Gets the additional informations (Interface Version 2).
/// </summary>
/// <returns>additional informations.</returns>
string GetAdditionalInformations();
}
Aus den FramePluginInterfaces-Projekten habe ich bewusst einmal zwei Projekte daraus gemacht, um die Versionierung hier zu verdeutlichen (V1.0.0 und V1.1.0). Dies ist jedoch generell NICHT zwingend erforderlich. In der ersten Version ist die Schnitstelle ILib in seiner ursprünglichen Form enthalten. Im zweiten Projekt ist die Schnittstelle ILib immer noch enthalten. Hier kann man sich nun eine Bugfix-Version seitens des Frame-Herstellers vorstellen, der darüber hinaus noch ein neues Feature eingeführt hat und damit eine neue Schnittstelle mit dem Namen ILib2. Dadurch wurde die Version der Schnittstellen-Assembly auf Version 1.1 erhöht.
Der folgende Codeausschnitt stammt aus dem Beispiel-Frame und zeigt grob, wie die Treiberplugins gesucht und eingeladen werden:
private void AddLibs()
{
var files = Directory.GetFiles(Path.Combine(TextBoxStartPath.Text), "TestDriver*.dll", SearchOption.AllDirectories).ToList();
AddLog("Found following files:");
files.ForEach(x => AddLog(x));
foreach (var file in files)
{
AddDriverLib<ILib>(file, out var lib);
if (lib == null) continue;
lib.Initialize();
_Assemblies.Add(lib);
AddLog($"Added and initialized lib '{lib.GetName()}'");
AddLog($"Information about lib: {lib.GetInformation()} ");
if (lib is ILib2 lib2)
{
_Assemblies2.Add(lib2);
AddLog($"Information about lib2: {lib2.GetAdditionalInformations()} ");
}
}
}
Der Frame sucht im Beispiel nach DLLs, die sich unterhalb des Verzeichnisses "Drivers" befinden. Unterhalb gibt es weitere Verzeichnisse, welche die Kompilate von TestDriver1 und TestDriver2 beinhalten. Schaut man sich die Unterverzeichnisse einmal genauer an, fehlt hier die Referenz zur Schnittstellen-Bibliothek FramePluginInterfaces. Dies ist bewusst so gemacht, weil die benötigte Schnittellen-Bibliothek in der passenden Version aus dem Global Assembly Cache aufgelöst werden soll.
Nach Kompilierung der Projektmappe (es reicht der Debug-Build) muss die Bibliothek FramePluginInterfaces.dll (V1.1) nun zuerst im GAC registriert werden. Dies kann man am Einfachsten über den Kommandozeilenbefehl durchführen:
gacutil.exe /i .\FramePluginInterfaces2\bin\Debug\FramePluginInterfaces.dll
Nachdem die Bibliothek erfolgreich registriert wurde, kann man exemplarisch einmal den Frame starten und nach Start den Button "Load Libraries" betätigen. Alle Aktionsausgaben werden mitprotokolliert und man erhält zunächst einmal folgendes Bild:
Wie auf dem Bild zu erkennen ist, befinden sich im Drivers-Verzeichnis Plugin-Treiber, aber da offensichtlich nur der zweite Treiber geladen wurd, gibt es ein Problem mit der Bindung der Schnittstelle ILib in der Version 1.1.0. Der Treiber1 referenziert noch das Interface ILib in der Version 1.0.0. Dies ist ein typisches Szenario aus der Praxis, weil der Frame-Hersteller zwischenzeitlich eine neue Version der Software auf dem System bereitgestellt hat und dort noch andere Treiber installiert sind, welche noch eine alte Version einer Frame-Schnittstelle verwenden. Genau dafür es die Möglichkeit über eine sogenannte Herausgeberrichtlinien-Datei, auch als Publisher Policy bekannt, eine entsprechende Umleitung zu definieren. Diese Policy kann über eine entsprechende Richtlinien-Konfigurationsdatei für die Anwendung eingestellt werden, wobei es sich um folgende XML-Einträge handelt:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="FramePluginInterfaces" publicKeyToken="6a57d333ac8dedc3" culture="neutral" />
<!-- oldVersion="existing assembly version" newVersion="new assembly version" -->
<bindingRedirect oldVersion="1.0.0.0-1.1.0.0" newVersion="1.1.0.0"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
Damit wird konkret eine Bindungsumleitung für die Assembly definiert. Diese Umleitung überschreibt die generelle Bindung, die sonst erfolgen würde und im Beispiel zu einer Reflection-Ausnahme führt, wenn die Policy nicht definiert ist. Weitere Informationen für Bindungs-Umleitungen von Assemblies und mehr gibt es bei Microsoft [4]
Da FramePluginInterfaces zuvor in den GAC installiert wurde, muss die Policy ebenfalls dorthin installiert werden. Folgende Schritte sind hier erforderlich:
- 1.: Extraktion des öffentlichen Public-Key-Tokens, der im Attribut publicKeytoken angegeben wird. Im Beispiel wurde bei der Implementierung ein Schlüsselpaar AppKey.snk angelegt. Hier muss nun mit dem .NET-SDK Tool "sn.exe" für starke Namen der öffentliche Token aus dem Schlüsselpaar, welches in der SNK-Datei steht, extrahiert werden:
sn.exe -p AppKey.snk AppKey.PublicKey
sn.exe -tp AppKey.PublicKey > public_key.txt
- 2.: Übernahme des in der Textdatei generierten öffentlichen Tokens in die XML-Konfiguration (publicKeyToken).
- 3.: Erzeugen der Publisher Policy Assembly für FramePluginInterfaces
Mit Hilfe des Assembly-Linker Tools aus dem .NET-SDK wird nun das XML-Manifest zu der bereits kompilierten Schnittstellen-Bibliothek FramePluginInterfaces.dll hinzu gelinkt. Die zu grunder liegende Version der Bibliothek spiellt dabei keine Rolle, entweder kann die V1.0 oder V1.1 dafür verwendet werden.
al.exe /link:FramePluginInterfaces.config /out:policy.1.0.FramePluginInterfaces.dll /keyfile:AppKey.snk
- 4.: Hinzufügen der generierten Publisher Policy Assembly zum Global Assemby Cache
gacutil.exe /i policy.1.0.FramePluginInterfaces.dll
Nach dem alle Schritte erfolgreich durchführt sind, lohnt ein weiterer Start des Frames, welcher nun folgendes Bild ergeben sollte:
Es werden nun alle referenzierten Treiber-PlugIns vom Frame angezoegen und deren Infromationen angezeigt. Mit Hilfe des Umleitungsmechanismus lassen sich noch weitere Szenarien abdecken, die in dem Rahmen jedoch nicht mehr behandelt werden.
Versionierung von Assemblies nach Semantic Versioning
Gerade in größeren Softwaresystemen kann die Veröffentlichung von neuen Versionen von einer oder mehreren Software-Komponenten mitunter sehr komplex werden und in einem Schlamassel enden (siehe die "DLL-Hölle"). Daher hat man sich in der Software-Welt überlegt, wie man diesen Problemen entgegen treten kann, und hat ein Manifest für die Versionierung von Software verabschiedet. Diese Versionierung kann aber auch für andere abstrakte Bereiche übertragen werden, u.a. in der Architektur. Diese Regeln basieren auf Best-Practices aus der Open-Source Welt, sind aber keineswegs darauf beschränkt. Ein Vorteil, als Software-Hersteller seine Software-Komponenten so zu versionieren, ist aus Sicht des Kunden, dass die Angabe der Version eine eindeutige Aussage zur Kompatibilität des Programms macht. D.h. das der Anwender einer Software sich darauf vorbereiten kann, wenn es beispielsweise inkompatible Änderungen bei Updates gibt.
Eine Versionskennzeichnung nach der semantischen Versionierung sieht z.B. wie folgt aus: 1.3.0
Sie besteht aus drei Zifffern, welche durch einen Punkt voneinander getrennt sind: MAJOR.MINOR.BUILD.(REVISION)
- MAJOR zeigt die generelle API-Kompatibilität an und wird ggf. erhöht, wenn eine inkompatible Änderung zur letzten Version erfolgt.
- MINOR zeigt (indirekt) an, in wie fern neue Funktionen in die Komponente implementiert wurden und wird entsprechend bei neuen Features erhöht
- BUILD zeigt den aktuellen Patch-Level an wird ggf. erhöht, wenn Bugs behoben werden.
- REVISION kann im Kontext von .NET optional noch die Revisionsnummer in die Versionierung eingebettet werden, hat jedoch keine Auswirkung bezüglich der eindeutigen Assembly-Version
Durch diese klare Struktur in der Versionierung wird jedem Entwickler, wenn er sich einmal daran gewöhnt hat, sofort ersichtlich auf welchem Stand die Software oder eine Komponente gerade ist und gibt Sicherheit im Alltag. Weitere Informationen über die semantische Versionierung gibt es unter [5].
Fazit
Die Mechanismen zur Auflösung der DLL-Hell, welche sich Microsoft mit der Entwicklung des .NET-Frameworks Anfang der 2000er Jahre überlegt hatte, funktionieren, sind recht umfassend und decken die meisten Szenarien für die klassische Desktop-Welt gut ab. Jedoch sind die bereitgestellten Funktionen für die saubere Auflösung von Assemblies nur ein Teil der Lösung. Erst eine sauber gestaltete Versionierung der Assemblies durch die Entwickler schafft Klarheit, sowohl für den Frame-Hersteller, als auch für den Hersteller von PlugIns / Treibern. Spätestens wenn eigens entwickelte nuget-Pakete in der Applikation zum Einsatz kommen, kommt dieses Thema schlagartig auf den Tisch.
Die .NET-Welt bewegt sich jedoch seit der Veröffentlichung der ersten Version von .NET Core seit 2016 stark in Richtung modulare bzw. self-contained Apps ohne installiertem .NET Framework mit den einhergehenden Konsequenzen. Gerade die Modularisierung von .NET, welche mittlerweile das Konzept der Isolation von Anwendungen oder Teilen davon z.B. mit Docker nun stark unterstützt, treibt die daraus resultierende Software-Architektur weg aus der Ecke, wo z.B. ein GAC zum Einsatz kommt. Unabhängig davon ist man sowieso schon seit ein paar Jahren dazu über gegangen, dass man im Design einer Anwendung das Auffinden und die Referenzierung von Abhängigkeiten über einen Paketmanager wie nuget und mit Hilfe von DI-Frameworks wie Autofac oder Prism zur Laufzeit auflösen lässt. Die Empfehlung, die eigens erstellten Assemblies sauber zu versionieren, sei es nun als nuget-Paket oder nicht, gilt jedoch nach wie vor.
Downloads:
Quellen:
[1] Assembly-Manifest (Microsoft)
[2] Side-bySide Execution (Microsoft)
[3] How the .NET runtime locates assemblies (Microsoft)
[4] Umleiten von Assembly-Versionen (Microsoft)