Assemblies - Pakete einer .NET Anwendung (Teil 1)

Veröffentlicht: 16. Dez 2001 | Aktualisiert: 14. Jun 2004

Von Michael Willers

Anwendungen basieren unter .NET auf Assemblies. Unter einem Assembly versteht man vereinfacht gesagt alle Komponenten, die eine Anwendung referenziert und benutzt.

Wichtigster Punkt dabei: Das Konzept der Versionierung innerhalb der Common Language Runtime basiert auf Assemblies. Es ermöglicht u. a., dass unterschiedliche Versionen eines Assemblies parallel von mehreren Anwendungen benutzt werden können. So können beispielsweise die Anwendungen A und B mit der Version 2.0 arbeiten und die Anwendungen C und D gleichzeitig mit der Version 3.0!

Auf dieser Seite

 Überblick
 Assemblies und Module – Praxis!
 Signieren Sie Assemblies!
 Private und hared Assemblies

Dieses Kapitel erläutert den Begriff Assembly und erklärt das Versionierungskonzept. In diesem Zusammenhang werden folgende Aspekte beleuchtet:

  • Module und Assemblies

  • Assembly-Manifest

  • Signieren von Assemblies

  • Private und Shared Assemblies

  • Finden und Laden von Assemblies durch die CLR

  • Assemblies und Mehrsprachigkeit

Überblick

In der Regel bestehen Anwendungen aus mehreren Komponenten: Einer ausführbaren Datei und mehreren DLLs. Wenn man nun eine Versionsnummer für die Anwendung vergeben möchte, muss man Buch über die Versionen der einzelnen DLLs als auch der EXE-Datei führen, damit man diesen Versionsstand später wiederherstellen kann (etwa zur Fehlersuche). Dies wird bei Anwendungen, die aus vielen Komponenten bestehen schnell unübersichtlich und erschwert damit Wartung und Pflege der Anwendung.

Die CLR erlaubt es nun, EXE-Datei und DLLs zu einem Paket zu gruppieren und die Versionierung gegen dieses Paket durchzuführen. Ein solches Paket wird mit Assembly bezeichnet und die darin enthalten Dateien nennt man Module. Für die Zuordnung zwischen Modulen und Assemblies sorgen meistens die .NET
Compiler. Standardmäßig wird beim Kompilieren für jedes Modul ein eigenes Assembly erstellt (Bild 5.1 zeigt diesen Zusammenhang).

Bild01

Abbildung 1: Die .NET Compiler erzeugen standardmäßig ein Assembly, das aus genau einem Modul besteht

Ein Assembly wird ebenso wie Typen anhand von Metadaten beschrieben. Diese Metadaten werden Manifest genannt, wobei jedes Assembly genau ein Manifest
enthält. Es beinhaltet folgende Bestandteile:

  • Die Identität des Assemblies. Sie setzt sich aus Name, Versionsnummer und einem Ländercode zusammen

  • Liste der Module, aus denen das Assembly besteht

  • Liste der Assemblies, die von diesem Assembly benutzt werden inklusive deren Versionsnummer

Der Ländercode wird dabei gemäß IETF RFC 1766 beschrieben. Weitere Informationen zu den Länderkennungen finden Sie im Abschnitt »Assemblies und
Mehrsprachigkeit«.

Assemblies können auch aus mehreren Modulen bestehen (siehe Bild 5.2). Dies geschieht allerdings nicht automatisch und die Zuordnung zwischen Moduln
und Assemblies muss über die Kommandozeile des Compilers gesteuert werden.

Bild02

Abbildung 2: Assemblies können auch aus mehreren Modulen bestehen

Der C# Compiler bietet insgesamt vier Möglichkeiten, um Module zu erzeugen. Sie sind nachfolgend aufgeführt.

Bild03

Tabelle 1: Modul-generierung mit dem C# Compiler

Alternativ können bereits kompilierte Module auch mit dem Assembly Linker (AL.EXE) aus dem Framework SDK zu Assemblies gruppiert werden.

 

Assemblies und Module – Praxis!

Schauen wir uns ein Beispiel an. Wenn Sie den folgenden Code übersetzen, erhalten Sie direkt ein lauffähiges Programm.

Listing 5.1: Alles in einem Programm: Schnittstellen, Klassen und Hauptprogramm

using System; 
namespace devcoach.Samples.Assemblies.DogDemo 
{ 
public interface IDog 
{ 
string Bark(string user); 
string Growl(string user); 
} 
public interface IDog2 
{ 
string Bite(string user); 
} 
class CPoodle :IDog 
{ 
public string Bark(string user) 
{ 
return String.Format("Poodle:Hello <{0}>!",user); 
} 
public string Growl(string user) 
{ 
return String.Format("Poodle:I will bite you,<{0}>!",user); 
} 
} 
class CBeagle :IDog,IDog2 
{ 
public string Bark(string user) 
{ 
return String.Format("Beagle:Hello <{0}>!",user); 
} 
public string Growl(string user) 
{ 
return String.Format("Beagle:I will bite you,<{0}>!",user); 
} 
public string Bite(string user) 
{ 
return String.Format("Beagle:I will bite you,<{0}>!...NOW",user); 
} 
} 
public class CDogFactory 
{ 
public IDog GetDog(string id) 
{ 
if ("Poodle"==id)return new Poodle(); 
if ("Beagle"==id)return new Beagle(); 
throw new ArgumentException(String.Format("Unknown id:'{0}'",id)); 
} 
} 
class CDogApp 
{ 
static void Main(string []args) 
{ 
DogFactory fac =new DogFactory(); 
IDog []dogs ={fac.GetDog("Poodle"),fac.GetDog("Beagle"); 
fac =null; 
foreach (IDog dog in dogs) 
{ 
if (dog is IDog2) 
{ 
Console.WriteLine(((IDog2)dog).Bite("Simon")); 
continue; 
} 
Console.WriteLine(dog.Bark("Mike")); 
} 
} 
} 
}

Bild04

Abbildung 3: »One File Assemby«

Der Nachteil: Alle Bestandteile befinden sich in einen einzigen Assembly. Wiederverwendung und Erweiterung werden daher schwierig. Es hilft nichts, wir
müssen den vorhandenen Code neu organisieren. Zunächst verlagern wir die Schnittstellen in ein eigenes Assembly. So können neue Klassen, die eine oder
mehrere dieser Schnittstellen implementieren, unabhängig entwickelt werden.

Dazu erstellen wir die Datei dog_interfaces.cs und übersetzen diese Datei. Die Schnittstellen befinden sich nun in einem Assembly, das aus genau einem Modul besteht. Dann verschieben wir die Klassen CPoodle und CBeagle in die Datei dog_server.cs und übersetzen auch diese Datei. Die implementierten Klassen befinden sich nun in einem eigenen Assembly. Und dieses Assembly wiederum referenziert die Schnittstellen.

Bild05

Abbildung 4: Arbeiten mit Schnittstellen-Assemblies

Der Client ist nun immer noch von Server abhängig. Also führen wir die Aufteilung weiter. Zunächst verlagern wir die Dog-Klassen in eigene Module, übersetzen Sie als Modul und fügen sie dann zu einem Assembly zusammen.

Bild06

Abbildung 5: Arbeiten mit Modulen und »Multi-File-Assemblies«

Um noch die ClassFactory vom Assembly Dog_Server zu entkoppeln, müssen wir eine kleine Codeänderung ausführen:

Listing 5.2: Neue Dog-Module können zur Laufzeit geladen werden

using System; 
using System.Reflection; 
namespace devcoach.Samples.Assemblies.DogDemo 
{ 
public class CDogFactory 
{ 
public IDog GetDog(string id) 
{ 
try 
{ 
return (IDog)Activator.CreateInstanceFrom("dog_server.dll",id); 
//alternativ auf der Basis des Namens laden 
//CPoodle -->Poodle.dll 
//return (IDog)Activator.CreateInstanceForm( 
//String.Format("C{0}.dll",id)); 
} 
catch 
{ 
throw new ArgumentException(String.Format("Unknown id:'{0}'",id)); 
} 
} 
} 
}

Der Client selbst ist nun völlig unabhängig von der Implementation der einzelnen »Dogs«. Implementiert man allerdings neue »Dog-Module« muss dass Assembly dog_server.dll neu übersetzt werden. Das kann man umgehen, indem man dieses Assembly aufspaltet und jede Dog-Klasse in ein eigenes Assembly einbettet (Die erforderlichen Änderungen in der ClassFactory sind in Listing 5.2 auskommentiert).

Nachteil dieser Aufsplittung: Wenn eines der beiden Assemblies benutzt werden soll, müssen immer beide Dateien vorhanden sein. Sowohl die DLL-Datei mit dem Assembly-Manifest als auch die Modul-Datei mit dem IL-Code. Möchte man dies vermeiden, kann man auch beide in eine DLL-Datei kompileren (csc /t:library / out:poodle.dll dog_poodle.cs bzw. csc /t:library /out:beagle.dll dog_beagle.cs). Allerdings geht dann wieder ein Stückchen Flexibilität verloren: Moduldateien können dann nicht mehr zu verschiedenen Assemblies gruppiert werden.

Der Vorteil, die Anwendung der ClassFactory und die Aufteilung in mehrere Asssemblies führt im Ergebnis zur Entkopplung des Codes und macht die Anwendung sehr flexibel. Es können neue »Dog-Klassen« hinzugefügt werden, ohne vorhandenen Code zu modifizieren.

Bild07

Abbildung 6: Aufsplittung der Klassen in eigene Assemblies

Allgemein ausgedrückt: Assemblies und Namespaces dienen zur Organisation von Code. Assemblies repräsentieren dabei die physikalische Aufteilung und
Namespaces die logische Aufteilung. Bild 5.7 verdeutlicht diesen Zusammenhang.

Bild08

Abbildung 7: Physikalische und logische Codeaufteilung über Assemblies und Namespaces

 

Signieren Sie Assemblies!

Stellen Sie sich folgendes Szenario vor: Sie entwickeln eine kaufmännische Anwendung zu der mehrere DLLs gehören. Es gibt zwei Arten von DLLs: Normale
Windows-DLLs und COM-DLLs, die Ihre Funktionalität über COM-Objekte breitstellen. Letztere müssen in der Registry eingetragen werden, bevor Sie benutzt
werden können.

Stellen wir uns weiter vor, das die DLLs Methoden für die Berechnung bestimmter Kosten bereitstellen. Sie installieren die Anwendung und arbeiten eine zeitlang damit.

Nun kommt eine böswillige Person und überschreibt eine DLL dieser Anwendung. Sofern die Signaturen der Methoden nicht verändert wurden, reicht ein
schlichtes Kopieren aus – auch im Falle einer COM-DLL.

Sofern die geänderte DLL nun radikal vorgeht und etwa die Festplatte formatiert wurde, haben Sie noch Glück im Unglück. So etwas merken Sie in der Regel sofort. Was aber, wenn die DLL einfach nur falsch rechnet und die ermittelten Kosten nicht mehr stimmen?

Wenn etwa durch falsches Runden Fehler im Cent-Bereich (der Euro grüsst) entstehen? Das merken Sie vielleicht erst nach einigen Monaten. Oder denken Sie
an Abrechnungssoftware von Mobilfunknetzen. Da kommt durch die hohen Teilnehmerzahlen schnell ein 5-stelliger Betrag zusammen.

Die CLR bietet die Möglichkeit sich gegen diese oder ähnliche Katastrophenszenarien besser zu schützen: Assemblies können mit Hilfe des Public-Key-Verfahrens signiert werden. Damit kann die Herkunft eines Assemblies eindeutig ermittelt werden. Darüber hinaus blockt die CLR Aufrufe von vornherein ab und wirft beim Laden des Assemblies eine Exception, wenn »etwas faul« ist.

Wie funktioniert das nun im Detail? Im SDK finden Sie das Programm Strong Name Utility (SN.EXE), mit dem Public- und Private Key erzeugt werden. Der
Aufruf mit dem Parameter –k <Dateiname> erzeugt die Datei <Dateiname>. In dieser Datei befinden sich Public- und Private Key.
Damit Ihr Assembly signiert wird, müssen Sie dies in einer der zu Ihrem Assembly gehörenden Sourcedateien mit dem Attribut [assembly: AssemblyKey-File(<
Dateiname>)] bekannt machen.

Beim Übersetzen durch den Compiler wird dann der Public Key in das Manifest des Assemblies eingetragen und das Modul, in dessen Sourcen das Attribut eingetragen ist, wird mit einer digitalen Signatur versehen. Die Signatur selbst basiert auf dem Private Key. Die Bilder 5.8 und 5.9 zeigen diesen Sachverhalt an einem Beispiel.

Bild09

Abbildung 8: Erzeugen von Public- und Private Key

Bild10

Abbildung 9: Signieren eines Assemblies

Beim Übersetzen eines Clients, der das signierte Assembly referenziert, wird automatisch vom Compiler ein Hash des Public Keys in das Manifest des Client-Assemblies eingtragen. Dieser Hash wird auch mit public key token bezeichnet (siehe Bild 5.10).

Bild11

Abbildung 10: Im Client wird ein Hash des Public Keys eingetragen

Sobald das signierte Assembly geladen wird, ermittelt die CLR mit dem public key token den tatsächlichen public key und prüft diesen Key gegen die Signatur. Passen beide zusammen, wird das Assembly in den Speicher geladen.

Andernfalls blockt die CLR das Laden ab und wirft eine TypeLoad-Exception.
Probieren Sie es aus: Erstellen Sie ein Client- und ein Serverassembly und übersetzen Sie beide. Erzeugen Sie dann ein neues Keyfile und geben sie dieses File in der Sourcedatei des Serverassemblies an. Übersetzen Sie abschließend das Serverassembly neu. Sobald der Client aufgerufen wird und das Serverassembly geladen werden soll, wirft die CLR eine Exception aus.

Das geschilderte Verfahren bringt allerdings einen Nachteil mit sich: Assemblies müssen bereits bei der Entwicklung signiert werden. Das sogenannte Delay Sign schafft Abhilfe. In diesem Fall wird ein Assembly zur Entwicklungszeit nur mit dem Public Key versehen und entsprechender Platz für die Signatur bereitgestellt.

Assemblies können so nach Abschluss der Entwicklung »nachsigniert« werden und die Compiler werden für das Erstellen der Signatur nicht gebraucht. Das Strong Name Untility (SN.EXE) reicht aus.

Um diese Methode zu benutzen, müssen Sie dies in einer der zu Ihrem Assembly gehörenden Sourcedateien mit demAttribut [assembly: AssemblyDelaySign(true)] bekannt machen. Bild 5.11 zeigt das Delay-Sign-Verfahren im Detail.

Bild12

Abbildung 11: Assemblies können über das Delay-Sign-Verfahren nachträglich signiert werden.

Aber Achtung: Per Delay Sign erstellte Assemblies können nicht als Shared Assembly benutzt werden! Behalten Sie diese Tatsache bitte im Hinterkopf.
Lange Rede, kurzer Sinn: Signieren Sie alle Assemblies, die im produktiven Betrieb eingesetzt werden sollen!

 

Private und hared Assemblies

Die CLR unterscheidet grundsätzlich zwischen zwei Arten von Assemblies. Es gibt Private und Shared Assemblies.

Private Assemblies können immer nur von genau einer Anwendung benutzt werden. Shared Assemblies stehen hingegen systemweit allen Anwendungen zur
Verfügung.

Im ersten Fall kopieren Sie alle Assemblies einer Anwendung in ein Verzeichnis, starten die EXE-Datei und fertig.

Kein Eintragen in der Registry. Filecopy genügt. Falls sich einige Assemblies in fest vordefinierten Verzeichnissen befinden sollen, können Sie diese in der Konfigurationsdatei der Anwendung angeben (siehe Listing 5.3).

Listing 5.3: Assemblies können auch in separaten Unterverzeichnissen abgelegt werden.

<configuration> 
<runtime> 
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> 
<probing privatePath="[Verzeichnis1 ];[Verzeichnis2 ]"/> 
</assemblyBinding> 
</runtime> 
</configuration>

Die Identifikation eines Private Assemblies erfolgt anhand des Namens der im Manifest eingetragen ist. Standardmäßig ist dies immer der Dateiname ohne die Endung DLL bzw. EXE. Da das Assembly nur seiner rufenden Anwendung bekannt ist, führt die CLR hier keine Versionsüberprüfung durch.

Mich erinnert das immer an das gute alte LoadLibrary. EXE und DLLs in ein Verzeichnis kopieren und in der EXE die DLLs mit der Win32-API-Funktion Load-Library in den Speicher laden ;-.)

Man kann aber auch Assemblies erstellen, die systemweit allen Anwendungen zur Verfügung stehen. Solche Assemblies werden mit Shared Assembly bezeichnet. Sie müssen übrigens grundsätzlich signiert werden!

Um eine Eindeutigkeit zu gewährleisten, werden Shared Assemblies immer über einen sogenannten Strong Name identifiziert. Er setzt sich aus der Assembly-Identität und dem im Manifest eingetragenen public key token zusammen.

BEISPIEL: »dog_server.dll, Version=1.0.0.0,Culture=neutral, PublicKeyToken=9e0fbde047643206«

Die Angabe der Versionsnummer ist dabei Pflicht. Sie muss in einer der zum Assembly gehörenden Sourcedateien mit dem Attribut [assembly: AssemblyVer-sion(»x.x.x.x«)] angegeben werden. Die Angabe einer Länderkennung ist hingeben optional. Weitere Informationen zu den Länderkennungen finden Sie im Abschnitt »Assemblies und Mehrsprachigkeit«.

Shared Assemblies müssen in einem systemweiten »Speicherbereich« abgelegt werden, auf den die CLR zugreifen kann. Dieser Bereich wird Global Assembly

Cache, kurz GAC, genannt. Aus Sicherheitsgründen können dort nur vollständig signierte Assemblies abgelegt werden!

Assemblies, die mit dem Delay-Sign-Verfahren erstellt wurden, können also nicht als Shared Assemblies eingesetzt werden.

Die bei der ECMA zur Standardisierung eingereichte Spezifikation der Common Language Infrastructure (CLI) bietet hier einige Freiheitsgrade, da sie zwar das Verhalten des GAC, nicht jedoch dessen Implementation vorschreibt. Die CLR-Versionen für die Windows-Plattformen bilden den GAC über eine Filestruktur ab. Damit werden mögliche Portierungen erheblich erleichtert, da Zugriffe über normale I/O-Funktionen erfolgen können. Es sind kein speziellen Funktionen, wie beispielsweise die Registry-Funktionen aus dem Win32-API notwendig.

Die Verwaltung des GAC erfolgt über eine eigens dafür implementierte DLL mit der Bezeichnung FUSION.DLL. Sie verwaltet die Filestruktur und sorgt für das ordnungsgemäße Ablegen und Löschen von Assemblies.

Warum kann man nicht einfach die Struktur per Hand verwalten und Assemblies einfach reinkoperen oder löschen?

So einfach ist es leider nicht, da Assemblies ja auch aus mehreren Dateien bestehen können.

Sie können den GAC ganz einfach einsehen. Öffnen Sie dazu eine Command-Shell und wechseln Sie in das Systemverzeichnis (in der Regel WINNT). Sie finden
hier ein Verzeichnis Assembly\GAC. Die Verzeichnisstruktur des GAC ist folgendermaßen aufgebaut (siehe auch Bild 5.c):
<AssemblyName>\<Versionsnummer>__<publickeytoken>
BEISPIEL: System.Xml\ 1.0.2411.0__b77a5c561934e089

Auf diese Weise können alle Dateien, die zu einem Assembly gehören, in ein gemeinsames Verzeichnis kopiert werden und es können mehrere (!) Versionen des Assemblies im GAC installiert werden. Mehr dazu im Abschnitt »Versionierung«.

Eine entsprechende Verzeichnisstruktur wird automatisch erzeugt, wenn Sie mit dem Global Assembly Cache Utility (GACUTIL.EXE) aus dem Framework
SDK ein Assembly im GAC ablegen. Ebenso können Sie mit GACUTIL ein Assembly auch wieder entfernen.

Bild13

Abbildung 12: Die physikalische Sicht auf den Global Assembly Cache

Man kann den GAC aber auch bequem mit dem Explorer bearbeiten. Zu diesem Zweck bringt das SDK eine Shell-Erweiterung mit, die in der Datei SH-FUSION.
DLL implementiert ist. Sie bietet eine logische Sicht auf die Verzeichnisstruktur und erlaubt das Ablegen von Assemblies per Drag & Drop (Bild 5.13).

Bild14

Abbildung 13: Die logische Sicht auf den Global Assembly Cache

Bleibt zum Schluss ein kleiner Schönheitsfehler: Der programmatische Zugriff auf den GAC ist nur auf Umwegen möglich. Die Funktionen der FUSION.DLL
werden (noch) nicht über Framework-Klassen abgebildet. Bei näherer Betrachtung stellt sich aber heraus, das die DLL als normale COM-DLL implementiert
ist und so kann man mit Hilfe der COM-Interop-Klassen recht einfach selbst einen Wrapper schreiben. Sie finden diesen Wrapper auf der Buch-CD (Fusion-Wrapper.cs).

Alternativ können Sie aber auch das Assembly System.EnterpriseServices.dll verwenden. Es stellt über die Klasse GACInstaller einen rudimentären Wrapper für den Zugriff auf den GAC zu Verfügung.