Erweitern Sie Ihre Assemblies nach dem Kompilieren

Veröffentlicht: 24. Mai 2003 | Aktualisiert: 24. Jun 2004
Von Mirko Matytschak

Mit Hilfe der Werkzeuge ILAsm und ILDasm können vorhandene Assemblies relativ einfach mit neuen Funktionalitäten erweitert werden. Die Anwendungsmöglichkeiten dieser Technik sind praktisch unbegrenzt. In diesem Artikel wird dieses sog. Enhancing näher beschrieben und ein beispielhafter Enhancer vorgestellt, der Protokollierungsaufrufe in alle Funktionen eines Assemblies einfügt.

Auf dieser Seite

 Wozu kann man Enhancer einsetzen?
 Grundzüge eines Enhancers
 ILAsm und ILDasm
 Ein IL-Code-Baum
 Das eigentliche Enhancement
 IL-Stolperfallen
 Implementierung des Trace-Enhancers
 Reflection, Emit und IL-Toolkit
 Debug-Informationen und signierte Assemblies
 Fazit
 Literatur

Viele Entwickler denken beim Thema IL-Code zuerst einmal an einen Nachteil, nämlich dass der Code für andere relativ leicht zu entziffern ist. Mittlerweile gibt es jedoch Werkzeuge, die das Lesen des Codes erschweren, so genannte Obfuscators. Ein solcher Obfuscator ist Bestandteil von Visual Studio 2003. Er lässt sich aber auch von der Web Site von PreEmptive Solutions kostenlos herunterladen und mit Visual Studio 7.0 verwenden. Der Nachteil des IL-Codes ist damit beseitigt: der vom Obfuscator erzeugte Code ist fast genau so schwer zu lesen, wie Assembler-Code.

Der Nachteil kann aber auch in einen Vorteil umgemünzt werden: IL Code lässt sich disassemblieren und der dabei erzeugte IL-Assemblercode ist leicht zu verstehen. An disassemblierten Assemblies, das heißt in Textdateien, können relativ einfach Änderungen vorgenommen werden. Das Resultat wird dann mit IL-Assembler ILAsm wieder assemblilert - damit entsteht ein neues Assembly mit erweiterter Funktionalität. Der gesamte Vorgang nennt sich Enhancing, ein Tool das dies ermöglicht, ist ein Enhancer.

Wozu kann man Enhancer einsetzen?

Das Enhancing ist eine Technologie, deren Anwendungsgebiete praktisch grenzenlos sind. Sie erweitert die Möglichkeiten der .NET-Sprachen, ohne die Sprachen mit Schlüsselworten zu überfrachten.

Zum Beispiel ließe sich mit Enhancern eine Art Mehrfachvererbung implementieren - das Zusammenstellen neuer Klassen aus bereits existierenden Klassen. Dies ist vor allem in den Fällen sinnvoll, wenn die Vererbungshierarchie in .NET bereits vorgegeben ist, zum Beispiel wenn man von ServicedComponent ableiten muss. Oder man könnte einen Aspect Weaver für die aspektorientierte Programmierung (AOP) schreiben. Auch der Obfuscator, der mit Visual Studio 2003 mitgeliefert wurde, arbeitet nach diesem Prinzip. Einen besonders interessanten Anwendungsfall, den wir entwickelt haben, wollen wir etwas näher beschreiben.

NDO (.NET Data Objects) ist ein Tool, mit dem ganz normale Klassen persistent gemacht werden können. Die Abspeicherung der Informationen beruht auf ADO.NET. Alles, was der Entwickler tun muss, ist, seine Klassen mit Hilfe des Attributs NDOPersistent zu markieren, wenn sie persistent sein sollen. Beziehungen zu anderen persistenten Klassen werden mit Hilfe des Attributs NDORelation markiert.

Der Enhancer, ein Add-in für Visual Studio, legt eine XML-Datei an, in der Mapping-Informationen stehen. Diese Informationen können vom Entwickler mit Hilfe eines visuellen Mapping-Tools angepasst werden, wenn Klassen an vorhandene Datenbestände gekoppelt werden sollen. Wer eine Anwendung von null an entwickelt, kann die automatisch generierten Sql-Scripts verwenden, um Datenbanken zu erstellen, die zu den Mapping-Informationen passen.

Der Enhancer fängt die Build Events von Visual Studio ab und erweitert nach dem Kompilieren ein erstelltes Assembly um zusätzliche Funktionalität: Die als persistent markierten Klassen implementieren nach dem Enhancement ein zusätzliches Interface, das vom Persistenz-Framework angesprochen wird, um Daten aus den Objekten zu lesen bzw. neu angelegte Objekte mit Daten aus der Datenbank zu initialisieren. Hierzu werden neue Felder und Properties angelegt, jede persistente Klasse erhält sogar zusätzliche eingebettete Klassen, die als Factories für Objekte oder ObjektIds dienen, sowie eine Hilfestellung beim Erzeugen von Abfragen leisten.

Angenommen, Sie hätten im Quelltext folgende kleine Klasse:

[NDOPersistent] 
public class MyPersistentClass 
{ 
 public MyPersistentClass() 
 { 
 } 
 // Persistente Felder 
 int intfield; 
 string stringfield;   
}

Wenn Sie diese Klasse kompilieren, erhalten Sie in Anakrino eine Darstellung wie in Bild 1. (Für diejenigen, die Anakrino nicht kennen: Es ist ein Tool, das Assemblies disassembliert und als C#-Code darstellt.) Nachdem der NDO-Enhancer die Klasse bearbeitet hat, sieht sie aus, wie in Bild 2.

TraceEnhancer01.gif

Bild 1: Darstellung der normal kompilierten Klasse in Anakrino


TraceEnhancer02.gif

Bild 2: Die gleiche Klasse nach dem Enhancer-Lauf

Es wird also jede Menge Code erzeugt, den ein Objekt braucht, um mit der Persistenzschicht zusammenarbeiten. Diesen Code muss der Anwender des NDO-Frameworks nun nicht mehr tippen - keine Arbeit, keine Bugs. Natürlich hätte man diesen Code auch als C#-Code in den Quelltext einfügen können. Dann würde der Quelltext aber ziemlich schnell unübersichtlich, die Klassen würden mit dem Aspekt der Persistenz überfrachtet werden. Ganz davon abgesehen, dass man zweierlei Code-Generatoren für VB und C# schreiben müsste. Dank des Enhancers bleiben die Klassen vom Aspekt der Persistenz weitgehend frei, die Business-Logik wird im Quelltext klarer herausgestellt. Und er leistet seine Dienste, selbst wenn die Assemblies mit Python oder Delphi erzeugt wurden.

Fassen wir zusammen: Enhancer bieten die Möglichkeiten:

  • Code zu generieren ohne die Quelltexte zu überfrachten

  • Code mit Attributen zu markieren und die markierten Stellen auszuwerten

  • Aspekte zu trennen und nachträglich zu verweben

  • Das Re-engineering von Assemblies einzuschränken


Im Augenblick beginnt die Branche erst, die Möglichkeiten von Enhancern zu entdecken. In den nächsten Jahren werden Enhancer wohl eine große Rolle in der Entwicklung von Software-Tools spielen. Richtig eingesetzt können sie zu einem Quantensprung in der Produktivität der Software-Entwicklung führen.

Grundzüge eines Enhancers

Die grobe Architektur eines Enhancers ist ziemlich einfach: Sie schreiben eine Konsolenanwendung, die das Enhancing vornimmt. Als Konsolen-Anwendung lässt sich Ihr Enhancer äußerst einfach entwickeln und testen. Dann installieren Sie das "Build Rules Add-in", das wir bereits an anderer Stelle in MSDN-Online beschrieben haben. Sie geben in diesem Add-in als Post Build Step ihren Enhancer an. Damit läuft er nach jedem Build.

Im Rahmen dieses Artikels wollen wir nun einen Enhancer vorstellen, der in jede Funktion eine Debug.WriteLine-Anweisung einbaut, die den Namen der Funktion im Debug-Fenster ausgibt. Damit verschachtelte Aufrufe nachvollzogen werden können, wird nach der Ausgabe des Funktionsnamens die Funktion Debug.Indent aufgerufen. Vor Verlassen der Funktion wird Debug.Unindent aufgerufen. Der Enhancer ist in C# codiert. Mit seiner Hilfe können Sie den Aufruf von Funktionen tracen, ohne Ihren Source-Code antasten zu müssen.

Wenn Sie den Enhancer zusammen mit anderen Enhancern betreiben, ist es sinnvoll, ihn ans Ende der Kette zu stellen (vor den Obfuscator). Damit werden auch von anderen Enhancern erzeugte Funktionen protokolliert.

ILAsm und ILDasm

Der Enhancer benutzt die Process-Klasse aus dem Namespace System.Diagnostics, um die Programme ILAsm und ILDasm zu starten. Am besten kapselt man die beiden Tools in zwei Klassen; in unserem Projekt heißen diese Asm und Dasm. Diese Klassen übernehmen unter anderem die Aufgabe, die Exe-Dateien der beiden Tools zu suchen. Dies ist nämlich gar nicht so einfach, weil die beiden Dateien an unterschiedlichen Orten liegen.

ILAsm ist Bestandteil des .NET-Frameworks und liegt im Verzeichnis des Frameworks - zum Beispiel in „C:\Windows\Microsoft.NET\Framework\v1.0.3705". Da Sie nie sicher gehen können, dass ein Anwender eine bestimmte Version des Frameworks installiert hat, benötigen Sie eine versionsunabhängige Pfadangabe. Diese erhalten Sie, wenn Sie in der Registry nach CLSIDs bestimmter .NET-Dlls suchen. Es gibt sicher andere Methoden, aber wir haben den Weg über den folgenden Registry-Key gewählt:

HKEY_CLASSES_ROOT\CLSID\05EBA309-0164-11D3-8729-00C04F79ED0D\InprocServer32

Wenn Sie der Key in irgend einer Weise an COM erinnert, liegen Sie richtig. Er führt Sie zum Pfad der Mscorld.dll, die den Assembly-Lader von .NET beherbergt. Diese Dll liegt im .NET-Verzeichnis, also dort, wo auch ILAsm liegt.

Um sicher zu gehen, dass ILAsm auch dann gefunden werden kann, wenn die Suche nach diesem Key fehlschlägt, werden noch alle Pfade der Environment-Variablen PATH durchsucht. Der Anwender Ihres Enhancers kann also zur Not den PATH so einstellen, dass ILAsm an anderen Plätzen als dem .NET-Verzeichnis gefunden wird.

ILDasm ist Bestandteil von Visual Studio, liegt also im Visual Studio-Verzeichnisbaum. Bei der Installation von Visual Studio wird der folgende Registry-Key erzeugt:

HKEY_CLASSES_ROOT\Applications\ildasm.exe\shell\open\command

Der dort abgelegte String enthält das Kommando zum Aufruf von ILDasm, eingefasst in Anführungsstriche und mit dem obligatorischen %1 danach. Wer den String zwischen den Anführungsstrichen extrahiert, verfügt über den Pfad zu ILDasm. Auch hier haben wir als Notnagel den Weg über PATH implementiert. Sollten Sie eine Version von Visual Studio haben, die ILDasm nicht enthält, können Sie das Tool (ebenso wie ILAsm) in der Shared Source-Implementierung von .NET finden, die sich Rotor bzw. SSCLI nennt.

Die beiden Klassen Asm und Dasm weisen jeweils eine Funktion DoIt auf, die das Assemblieren und Disassemblieren vornehmen. Bei Dasm ist der Fall sehr einfach. Die Funktion erwartet zwei Strings als Parameter: Den Namen der DLL, die es zu disassemblieren gilt und den Namen der IL-Datei, in die das Resultat geschrieben werden soll. Den Quellcode können wir uns hier sparen, weil die Funktion eigentlich nur ein Wrapper um den Aufruf von Process.Start ist. Einen solchen Aufruf bekommen wir bei der Implementierung von Asm.DoIt zu sehen (Listing 1).

Asm.DoIt erwartet einige Parameter mehr: Den Namen der zu assemblierenden Datei, den Namen der DLL, den Namen eines Key-Files zum Signieren des Assemblies, und ein bool-Flag, das angibt, ob die Debug-Information mit erzeugt werden soll. In Listing 1 sehen Sie die Implementierung.

ILAsm kann durch eine Menge an Switches gesteuert werden. Diese Switches werden am Anfang der Funktion zusammengestellt. Interessant dabei ist die Erzeugung des Switches "/resource". ILDasm extrahiert nämlich .Res-Dateien aus den Assemblies, wenn in den Assemblies Ressourcen existieren. Diese .Res-Dateien müssen bei ILAsm als Parameter angegeben werden, damit das Assembly hinterher auch wieder funktioniert. Es wird also getestet, ob es eine Datei gibt, die wie die Dll heißt, nur eben die Endung ".res" aufweist. Dabei hilft uns die Funktion ChangeExtension der Path-Klasse aus dem .NET-Framework. Die Ressourcen sind selbst dann vollständig, wenn Sie lokalisierte Assemblies vorliegen haben, die aus unterschiedlichen .Res-Dateien stammen.

Wenn Sie sich Listing 1 näher ansehen, werden Sie sich vielleicht darüber wundern, dass wir nach dem Erzeugen sämtliche Zeitangaben der neu erzeugten Datei auf die Angaben der alten Datei setzen. Wenn wir dies nicht tun, bringen wir in bestimmten Situationen die Build-Logik von Visual Studio durcheinander. Auf diese Weise denkt Visual Studio, die erweiterte Dll sei die original erzeugte Dll.

Ganz aufmerksame Leser werden feststellen, dass wir RedirectStandardOutput auf True gesetzt haben, uns aber für das Ergebnis nicht interessieren. Dies ist so, damit die Konsolenausgaben von ILAsm nicht im Build-Fenster von Visual Studio landen. Sie können das natürlich jederzeit ändern. Die Fehlermeldungen von ILAsm werden über StandardError ausgegeben. Sie sehen in Listing 1, dass StandardOut und StandardError zwei Streams sind, die von der Process-Klasse zur Verfügung gestellt werden. StandardError wird vom Enhancer ausgelesen und ausgewertet.

Das Auslesen von StandardOut ist zwar theoretisch ebenso möglich, wie es bei StandardError gezeigt wird, allerdings haben wir die Erfahrung gemacht, dass der Aufruf von ReadToEnd manchmal hängen bleibt. (Lesen Sie dazu auch die Dokumentation zu RedirectStandardOutput in der .NET Framework 1.1 Dokumentation.) Sollten Sie einmal die Ausgaben von ILAsm oder einem anderen Programm auslesen müssen, verwenden Sie die asynchronen Lesefunktionen.


Listing 1: Die Implementierung von Asm.DoIt

public void DoIt(string ilFileName, string dllFileName, string keyFileName, bool debug) 
{ 
 if (!File.Exists(ilFileName)) 
  throw new Exception("Assembling: File not found: " + ilFileName); 
 if (!File.Exists(dllFileName)) 
  throw new Exception("Assembling: File not found" + dllFileName); 
 DateTime ct = File.GetCreationTime(dllFileName); 
 DateTime at = File.GetLastAccessTime(dllFileName); 
 DateTime wt = File.GetLastWriteTime(dllFileName); 
 // Dll or Exe? 
 string libMode = "/" + Path.GetExtension(dllFileName).Substring(1).ToUpper(); 
 string debugMode = debug ? " /DEBUG" : string.Empty; 
 string key = keyFileName != null ? " /KEY=\"" + keyFileName + '"' : string.Empty; 
 string resourceFile = Path.ChangeExtension(dllFileName, ".res"); 
 string resource = File.Exists(resourceFile) ? " /RESOURCE=\"" + resourceFile + '"' : string.Empty; 
 string parameters =  
  libMode 
  + debugMode 
  + key 
  + resource 
  + " /OUTFILE=\"" + dllFileName + "\"" 
  + " \"" + ilFileName + "\"" ; 
 ProcessStartInfo psi = new ProcessStartInfo(ilAsmPath, parameters); 
 psi.CreateNoWindow   = true; 
 psi.UseShellExecute  = false; 
 psi.WorkingDirectory = Path.GetDirectoryName(dllFileName); 
 psi.RedirectStandardOutput = true; 
 psi.RedirectStandardError  = true; 
 System.Diagnostics.Process proc = System.Diagnostics.Process.Start( psi ); 
 proc.WaitForExit(); 
 string stderr = proc.StandardError.ReadToEnd(); 
 if ( stderr != null && 0 < stderr.Length ) 
 { 
  throw new Exception ("ILAsm: " + stderr); 
 } 
 else 
 { 
  File.SetCreationTime(dllFileName, ct); 
  File.SetLastAccessTime(dllFileName, at); 
  File.SetLastWriteTime(dllFileName, wt);  
 } 
}

Mit dieser Information ausgerüstet, können Sie bereits einen funktionslosen Enhancer schreiben, der Ihr Assembly in eine temporäre Datei disassembliert und danach wieder assembliert. Das Ergebnis ist allerdings noch nicht berauschend: Im Idealfall kann das neue Assembly genau das Gleiche, wie Ihr altes.

Ein IL-Code-Baum

Damit Sie den IL-Code manipulieren können, müssen Sie den Code aufbereiten. IL-Code weist eine hierarchische Struktur auf, ähnlich wie C#-Code. Sie finden dort Namespaces, Klassen, Unterklassen, Methoden, Try-Catch-Blöcke etc. Eine der zentralen Aufgaben eines Enhancers ist es also, den IL-Code zu analysieren und in einem Baum aufzubereiten, ähnlich wie es beim DOM mit XML passiert.

Je nachdem, wie viel Funktionalität Ihr Enhancer benötigt, werden Sie mehr oder weniger Information in Ihrem IL-Baum aufbereiten müssen. Für die .NET Data Objects war ziemlich viel Code vonnöten. Für den Enhancer, den wir in diesem Beitrag vorstellen, ist die Struktur viel einfacher.

Die oberste Klasse der Hierarchie ist die Klasse ILFile. Sie enthält eine ArrayList namens lines für alle Zeilen des IL-Codes. Für jede Zeile erzeugen wir ein Objekt vom Typ ILLineElement, das dann an die ArrayList angehängt wird. Warum wir nicht einfach die Strings in der ArrayList speichern, wird gleich klar werden.

Es wird also Zeile für Zeile gelesen, bis ein IL-Befehl auftaucht, der den Beginn eines Elements kennzeichnet, das in der Hierarchie unter ILFile liegt. In unserem Beispiel sind das ausschließlich Methoden, die in IL durch das Schlüsselwort .method gekennzeichnet sind (wir gehen auf die IL-Sprache gleich noch etwas näher ein). In einem leistungsfähigeren Enhancer müsste man noch Namespaces und Klassen auflösen, die beide in der Hierarchie über den Methoden liegen können. Vereinfacht dargestellt, passiert in ILFile folgendes:

StreamReader sr = new StreamReader(fileName, System.Text.Encoding.Unicode); 
string s; 
ILLineElement lineElement; 
while ((s = sr.ReadLine()) != null) 
{ 
 s = s.Trim(); 
 lines.Add(lineElement = new ILLineElement(s)); 
 if (s.StartsWith(".method")) 
 { 
  ILMethodElement methodElement = new ILMethodElement(lineElement, lines); 
  methodElement.ReadToEnd(sr); 
  methods.Add(methodElement); 
 } 
 if (s.StartsWith(".assembly extern")) 
 { 
  ILAssemblyExternElement assextElement = new  
   ILAssemblyExternElement(lineElement, lines); 
  assextElement.ReadToEnd(sr); 
  externAssemblies.Add(assextElement); 
 } 
 //... weitere Elemente 
} 
sr.Close();

Sobald ein Element beginnt, das in der Hierarchie tiefer liegt - zum Beispiel eine Methode - fährt dieses Element fort, den Input-Stream zu bearbeiten. Um sich das zu vergegenwärtigen, müssen wir kurz den Aufbau einer Methode in IL beschreiben:

.method verschiedene Schlüsselworte 
 in mehreren Zeilen Methodenname(Parameterliste) nochmal Schlüsselworte 
{ 
 Variablendeklarationen 
 .maxstack x 
 ... Befehlszeilen 
 ... ggf. weitere Blöcke 
 { 
  ... weitere Befehlszeilen 
 } 
 ... Befehlszeilen 
}

Unser Methodenelement merkt sich jetzt einfach das ILLineElement, bei dem es beginnt, das Element, bei dem die geschweifte Klammer aufgeht und das Element, bei dem die letzte geschweifte Klammer wieder geschlossen wird. Es erhält von ILFile die Liste, in die es brav die ILLineElemente einhängt, bis es auf sein Endelement stößt. Dann gibt es den Kontrollfluss wieder an den Aufrufer zurück - in unserem Fall an das ILFile-Objekt.

Bei diesem Vorgang findet noch keine Interpretation der Zeilen statt. Sie ist an dieser Stelle auch gar nicht nötig. Zu einem späteren Zeitpunkt, wenn zum Beispiel der Methodenname gebraucht wird, durchsucht das Methodenelement alle in Frage kommenden Zeilen und extrahiert die gewünschte Information. Damit das nicht bei jeder Anforderung passiert, werden die ermittelten Daten gecacht. Wir zeigen das einmal anhand des Methodennamens:

public string Name 
{ 
 get  
 { 
  if (name == null) 
  { 
   string[] tokens = this.Declaration.Split(new char[]{' '}); 
   string token = null; 
   int pos = 0; 
   foreach(string tok in tokens) 
   { 
 if ((pos = tok.IndexOf("(")) > -1) 
 { 
  token = tok; 
  break; 
 } 
   } 
   if (token == null) 
 throw new Exception("..."); 
   name = token.Substring(0, pos);   
  } 
  return name;  
 } 
}

Die Methodendeklaration (also alles, was vor der ersten geschweiften Klammer steht) wird in einzelne Tokens untertei< der erste Token mit einer geöffneten runden Klammer enthält vor der Klammer den Methodennamen. Die Berechnung findet nur einmal statt, nämlich wenn der Name null ist. Die Berechnung des Namens nutzt ihrerseits ein ähnlich aufgebautes Property Declaration, das die Zeilen vor der ersten geschweiften Klammer zu einem einzigen String zusammenbaut.

Nun sind wir noch eine Erklärung schuldig, nämlich, warum wir ILLineElemente und nicht einfach Strings speichern. Die Methoden-Objekte und andere Elemente merken sich die ILLineElemente, mit denen sie beginnen und enden. Würden sie sich an den String-Objekten orientieren, dürfte man die Strings nie ändern - ein geänderter String ist in .NET ein neues Objekt, und damit könnten die Elemente ihren Anfang und ihr Ende nicht mehr finden.

Um die Arbeit mit den ILLineElementen zu demonstrieren, zeigen wir einmal, wie die Maxstack-Zeile gefunden wird. Über die Bedeutung dieser Zeile werden wir später noch sprechen. Im Augenblick sei nur gesagt, dass diese Zeile mit dem Schlüsselwort .maxstack anfängt.

public ILLineElement MaxStackElement 
{ 
 get  
 {  
  ... 
  if (maxStackElement == null) 
  { 
   int indx = lines.IndexOf(this.startBracketElement); 
   if (indx == -1) 
 throw new Exception("..."); 
   ILLineElement le; 
   while ((le = (ILLineElement) lines[++indx]) != this.endElement) 
   { 
 if (le.Line.StartsWith(".maxstack")) 
 { 
  maxStackElement = le; 
  break; 
 } 
   } 
   if (maxStackElement == null) 
 throw new Exception("..."); 
  } 
  return maxStackElement;  
 } 
}

Der Trick liegt in der Zeile lines.IndexOf(...). Hiermit erhält man einen Index auf die ArrayList mit den Zeilen der IL-Datei und kann damit vor- und rückwärts navigieren. Diese lineare Navigation ist in vielen Szenarien eines Enhancers nötig. Mit lines.Insert können Sie an beliebigen Stellen (sprich: Indizes) neue Elemente einfügen. Die Indizes sollten nicht längere Zeit gecacht werden, da sie sich permanent ändern können; ein Enhancer fügt meist eine Menge Codezeilen hinzu.

Wir müssen an dieser Stelle gestehen, dass die Aufbereitung des IL-Baums in diesem Beispiel ein stark vereinfachtes Modell dessen ist, was wir für die .NET Data Objects implementiert haben. Ohne Anspruch auf Vollständigkeit geben wir einmal drei Punkte an, die Sie für Ihren eigenen Enhancer möglicherweise implementieren müssen.

  • In unserem Beispiel werden Zeilen als Objekte vom Typ ILLineElement gespeichert. Bereits bei nur wenig mehr Komplexität lohnt es sich, eine Basisklasse ILElement zu bilden, von der alle anderen Elemente abgeleitet werden. ILFile enthält dann nicht mehr ein Array von ILLineElementen, sondern ein Array von Subelementen, die ihrerseits Subelemente enthalten können. Das Speichern der einzelnen Zeilen in den Output-Stream delegiert man an die Subelemente. Um linear zu navigieren, muss man dann eine spezielle Iterator-Klasse schreiben.

  • Bestimmte Elemente, wie zum Beispiel Klassen, können bestimmte Subelemente wie Klassen, Methoden oder Properties aufweisen. Es lohnt sich, für diese Elemente spezielle Iteratoren zu schreiben, so dass Sie zum Beispiel über alle Methodenelemente einer Klasse iterieren können.

  • Wenn jedes Property eines Elements jeweils die gesamten Zeilen des Elements durchsuchen muss, um seinen Wert zu ermitteln, kann dies den Enhancer ziemlich herunterbremsen. Es lohnt sich ab einer bestimmten Ausbaustufe, in jeder Elementklasse eine zentrale Funktion zu installieren, die einmal alle Zeilen durchläuft und dabei die Werte sämtlicher Properties ausrechnet. Die Klasse erhält eine Mitgliedsvariable, die angibt, ob dies bereits geschehen ist, oder nicht. Sie müssen allerdings berücksichtigen, dass es Properties gibt, die vom Enhancer geändert werden. Diese müssen Sie immer neu berechnen.


IL-Dateien sind eine beliebig komplexe Angelegenheit - je nachdem wie genau man sie betrachtet. Es ist jedoch nicht für alle Enhancer-Szenarien nötig, die Aufbereitung des IL-Baums bis ins letzte Detail (und sich selbst in den Wahnsinn) zu treiben; dies kann nämlich sehr aufwändig werden. Deshalb raten wir, sich in kleinen Schritten durch die Features eines Enhancers durchzuarbeiten und immer jeweils die Subelemente und Properties zu implementieren, die für ein bestimmtes Feature gerade nötig sind.

Das eigentliche Enhancement

Wir haben nun alle Werkzeuge parat, um mit der eigentlichen Aufgabe eines Enhancers zu beginnen: Dem Einfügen oder Abändern von Code. Mit den Klassen Asm und Dasm werden IL-Dateien erzeugt und wieder reassembliert. Mit ILLineElement und Konsorten wurde der IL-Code so aufbereitet, dass Sie an beliebigen Stellen neue Zeilen einfügen können.

Nun heißt es, tatsächlich IL-Assemblercode zu erzeugen. Müssen wir dazu die IL-Sprache lernen? Die Antwort lautet Ja und Nein. Ja, weil Sie um gewisse Grundkenntnisse nicht umhinkommen, um korrekten IL-Code zu erzeugen. Nein, weil Sie die einzelnen Befehle gar nicht im Detail kennen müssen. Als Vorgehensweise raten wir, in einer Hochsprache, zum Beispiel in C#, ein Testprogramm zu schreiben, in dem der Code, den Sie enhancen wollen, zwei mal enthalten ist. Einmal in der Grundversion und einmal in der Version, wie er nach dem Enhancement aussehen soll. Danach disassemblieren Sie das kompilierte Assembly mit ILDasm und vergleichen die beiden Versionen des Codes.

Wir zeigen das einmal anhand des Codes, den unser Trace-Enhancer erzeugen soll. Zunächst eine ganz harmlose Testfunktion:

void foo() 
{ 
 Console.WriteLine("dkdkdkdk"); 
}

Nun die Funktion mit dem Code, den der Enhancer erzeugen soll:

void bar() 
{ 
   Debug.WriteLine("bar"); 
   Debug.Indent(); 
   Console.WriteLine("dkdkdkdk"); 
   Debug.Unindent(); 
}

Normalerweise empfehlen wir, einen Release-Build zu erstellen, da der IL-Code dann etwas straffer ist. In unserem Fall ist dies jedoch nicht ratsam, weil dann die Debug.WriteLine-Befehle verschluckt werden. Nun lassen wir das erzeugte Assembly durch den ILAsm laufen (Menüpunkt File|Dump und dann alle Default-Einstellungen übernehmen) und sehen uns das Ergebnis an. Wir ignorieren im Augenblick den gesamten erzeugten Code bis auf die Methodendeklarationen, die sich leicht erkennen lassen:

.method private hidebysig instance void  
  foo() cil managed 
{ 
  // Code size 11 (0xb) 
  .maxstack  1 
  IL_0000:  ldstr   "dkdkdkdk" 
  IL_0005:  call void [mscorlib]System.Console::WriteLine(string) 
  IL_000a:  ret 
} // end of method Class1::foo 
.method private hidebysig instance void  
  bar() cil managed 
{ 
  // Code size 31 (0x1f) 
  .maxstack  1 
  IL_0000:  ldstr   "bar" 
  IL_0005:  call void [System]System.Diagnostics.Debug::WriteLine(string) 
  IL_000a:  call void [System]System.Diagnostics.Debug::Indent() 
  IL_000f:  ldstr   "dkdkdkdk" 
  IL_0014:  call void [mscorlib]System.Console::WriteLine(string) 
  IL_0019:  call void [System]System.Diagnostics.Debug::Unindent() 
  IL_001e:  ret 
} // end of method Class1::bar

Nun dürfte auch denen, die noch nie IL-Code gesehen haben, klar sein, dass die Sache wohl so kompliziert nicht ist. Man sieht in foo eindeutig, wo der String-Parameter geladen wird, man sieht den Aufruf von Console.WriteLine und das Verlassen der Funktion mit dem Befehl ret. Exakt diese Elemente findet man in bar wieder, ergänzt durch die Zeilen, die nötig sind, um die Debug-Befehle aufzurufen. Diese zusätzlichen Zeilen müssen wir in jede Methode eines Assemblies einfügen.

Die Aufgabe unseres Enhancers lautet also im Detail:

  • Suche alle Methoden

  • Suche den ersten Befehl in der Methode

  • Füge einen ldstr-Befehl ein, der als Parameter den Methodennamen in Anführungsstrichen erhält

  • Füge die beiden call-Zeilen für WriteLine und Indent ein

  • Suche alle ret-Zeilen in der Methode - es könnte mehrere davon geben

  • Füge vor den ret-Zeilen den Aufruf von Unindent ein.


Auch wenn Sie noch nie IL-Code gesehen haben, werden Sie diese Aufgabe höchstwahrscheinlich meistern können. Aber als Software-Entwickler fühlen wir uns wohler, wenn wir wissen, was die Dinge bedeuten, mit denen wir zu tun haben. Deshalb gehen wir einmal kurz den IL-Code der beiden Funktionen durch.

Die Methodendeklaration beginnt mit dem Schlüsselwort .method. Danach kommen einige Schlüsselwörter, von denen Sie die meisten nicht zu kennen brauchen. Die wichtigsten sind public und private, die Sie ja schon von C# gewohnt sind. Das IL-Schlüsselwort family steht für protected, assembly für internal. Das Schlüsselwort instance bezeichnet eine Mitgliedsmethode, im Gegensatz zu einer statischen Methode, wo das Schlüsselwort wie in C# static heißt. In unserem Enhancer benötigen wir außerdem das Schlüsselwort pinvokeimpl, um Methoden auszufiltern, die man nicht mit dem Enhancer erweitern kann. Tabelle 1 zeigt alle Schlüsselwörter, die die Eigenschaften von Methoden beschreiben.

Tabelle 1: Schlüsselworte, die die Eigenschaften von Methoden beschreiben

Schlüsselwort

Beschreibung

abstract

Abstrakte Klasse - entspricht abstract in C#

assembly

Entspricht internal in C#

famandassem

Die Methode kann nur von abgeleiteten Klassen innerhalb des Assemblies aufgerufen werden - keine Entsprechung in C#

Family

Entspricht protected in C#

famorassem

Entspricht protected internal in C#

Final

Entspricht sealed in C#

hidebysig

Die Methode verdeckt gleichnamige Methoden aus Basisklassen nur dann, wenn auch die Signatur übereinstimmt. Ist dieses Flag nicht angegeben, reicht der Name zum Verdecken.

memberaccessmask

Legt fest, ob Reflection auf unsichtbare Elemente möglich ist

newslot

Die Methode belegt grundsätzlich einen neuen Slot in der vtable

pinvokeimpl

Die Methode ist extern und wird via Pinvoke aufgerufen

private

Entspricht dem Schlüsselwort private in C#

privatescope

Die Methode kann nicht referenziert werden (bezieht sich auf den &-Operator in C++)

public

Wie public in C#

reuseslot

Die Methode kann einen existierenden Slot in der vtable verwenden

rtspecialname

Zeigt an, dass die Common Language Runtime die Namensauflösung übernimmt

specialname

Spezielle Methode; in diesem Fall zeigt der Name an, in welcher Weise die Methode speziell ist; .ctor steht zum Beispiel für Konstruktor

static

entspricht dem Schlüsselwort static in C#

unmanagedexport

Die Methode wird über einen "thunk" von unmanaged Code aufgerufen

virtual

Entspricht dem Schlüsselwort virtual in C#

vtablelayoutmask

Erlaubt es, Eigenschaften der Vtable zu spezifizieren


Konstruktoren haben übrigens den festgelegten Namen .ctor. Darüber hinaus gibt es noch statische Konstruktoren, die die Initialisierung statischer Elemente übernehmen. Diese heißen .cctor. Der statische Konstruktor wird aufgerufen, wenn das erste mal ein Objekt der Klasse erzeugt wird - also nicht gleich beim Programmstart, wie es in C++ üblich war.

Im Funktionskörper ist der Befehl .maxstack sehr wichtig. IL-Code ist stackorientiert, ähnlich wie der Maschinencode von Prozessoren. Es gibt Befehle, die Elemente auf den Stack legen können, zum Beispiel alle Befehle, die mit ld anfangen. Der Befehl ldstr legt ein String-Element auf den Stack, ldarg legt ein Funktionsargument auf den Stack etc. Sie müssen nun für jede Funktion vor dem ersten Befehl mit .maxstack mitteilen, wie viele Elemente in der Funktion maximal auf dem Stack liegen werden. Ist dieser Wert zu klein, wird Ihre Anwendung mit einer Exception ("Invalid Code") abgeschossen. Auf dieses Thema kommen wir noch einmal zurück, wenn wir unsere Enhancer-Implementierung beschreiben.

Wir können die IL-Sprache im Rahmen dieses Artikels leider nur ganz grob anreißen; als letztes zeigen wir noch kurz den Umgang mit Methodenargumenten und lokalen Variablen. Angenommen, wir haben folgende Funktion in C# vorliegen:

public static void statfoo(int y) // Zur Abwechslung mal statisch 
{ 
 int x = y; 
 Console.WriteLine(x.ToString()); 
}

<br>

Diese Funktion sieht in IL folgendermaßen aus:

.method public hidebysig static void  
statfoo(int32 y) cil managed 
{ 
  // Code size 15 (0xf) 
  .maxstack  1 
  .locals init ([0] int32 x) 
  IL_0000:  ldarg.0 
  IL_0001:  stloc.0 
  IL_0002:  ldloca.s   x 
  IL_0004:  call instance string [mscorlib]System.Int32::ToString() 
  IL_0009:  call void [mscorlib]System.Console::WriteLine(string) 
  IL_000e:  ret 
} // end of method Class1::statfoo

Die .locals-Anweisung enthält in der runden Klammer eine Liste aller lokalen Variablen, mit ihrem Index, ihrem Typ und ihrem Namen. Die Variablen können sowohl über den Index als auch über ihren Namen angesprochen werden. Im Beispiel sehen wir beides: stloc.0 speichert das aktuelle Element auf dem Stack in die Variable mit dem Index 0. Das Element wird übrigens mit ldarg.0 auf den Stack gehievt; dieser Befehl lädt den Parameter mit Index 0 auf den Stack. Bei nicht statischen Methoden erhalten Sie mit ldarg.0 den Wert von this (in VB heißt er Me) - er ist also wie in C++ als verstecktes Argument implementiert.

Der Befehl ldloca.s x lädt die Variable mit dem Namen x auf den Stack. Der Befehl ldloca.s 0 hätte die gleiche Wirkung erzeugt. Wäre die Variable ein Objekt (also ein Referenztyp), würde der Befehl ldloc.0 ausreichen, da Objektvariablen letztlich Zeiger sind. Unsere int-Variable wird als Wert abgespeichert, ToString() erfordert jedoch eine Adresse als Parameter, die erst noch ausgerechnet werden muss. Ldloca ist daher mit dem &-Operator in C++ und unsaved C# vergleichbar.

Mit ein paar Experimenten können Sie ziemlich schnell lernen, was bestimmte Codezeilen bedeuten. Sollte Ihnen ein IL-Befehl einmal spanisch vorkommen, dann hilft Ihnen die MSDN Library (suchen Sie doch gleich einmal nach dem Begriff ldloca_s). Die IL-Befehle sind dort ausführlich dokumentiert; ansonsten ist die Lektüre von [Lidin02] zu empfehlen.

IL-Stolperfallen

Auf ein paar Besonderheiten von IL-Dateien, über die Sie sicher bald stolpern werden, möchten wir an dieser Stelle noch eingehen:

  • ILAsm erzeugt sogenannte Forward-Deklarationen von Klassen. Diese Deklarationen weisen darauf hin, dass später die Definition der Klassen folgt. Sie sehen aus, wie normale Deklarationen, nur dass in den geschweiften Klammern nichts steht - es sei denn, eine Klasse enthält eine geschachtelte Klasse. Diese Forward-Deklarationen können beim Enhancen etwas lästig sein, da Sie beim Suchen von Klassen zunächst einmal nur die Forward-Deklarationen finden. Spezielle Klassen-Iteratoren können Ihnen helfen, die Unterscheidung zwischen echter und Forward-Klasse transparent vorzunehmen. Die Forward-Klassen sind Bestandteil eines allgemeineren Mechanismus, den Sie vielleicht einmal gebrauchen können: Die Deklaration eines Typs kann auf mehrere Stellen im IL-Code verteilt werden.

  • Obwohl Sie UTF-8-codierte und sogar Unicode-Dateien assemblieren können, akzeptiert ILAsm keine Zeichen in Namen, die Umlaute enthalten - es sei denn, Sie fassen den Namen in einfache Anführungsstriche, zum Beispiel [MyAssembly]MyClass::'Büro', im Gegensatz zu [MyAssembly]MyClass::Buero. ILDasm erzeugt die IL-Datei entsprechend. Denken Sie also beim Verarbeiten von Klassen-, Methoden- oder Feldnamen daran, dass diese eventuell in Anführungsstrichen stehen können.

  • ILAsm kennt eigene Grunddatentypen, die denen von C# ähneln. Sie heißen zum Beispiel string oder int32. Diese Typbezeichnungen können jedoch nicht an allen Stellen verwendet werden. Manchmal ist es erforderlich, statt string zu schreiben: [mscorlib]System.String. Wenn Sie ihren beabsichtigten Code in C# codieren und danach die Assemblies disassemblieren, haben Sie die Typangaben immer im richtigen Format und müssen sich mit dem Phänomen nicht herumschlagen.

  • Eine Typangabe in IL besteht aus dem Schlüsselwort class oder valuetype, danach kommt der Assemblyname in eckigen Klammern (zum Beispiel [mscorlib]) danach der Typ in Objektschreibweise, wie Sie ihn auch in C# codieren würden. Dummerweise gibt es manchmal Situationen, in denen class oder valuetype nicht benötigt wird, zum Beispiel beim Aufruf von Funktionen. Hier muss die Typbezeichnung mit dem Assemblynamen beginnen. Sie können beim Funktionsaufruf auch nicht die internen Typen von ILAsm verwenden, also zum Beispiel string::Substring.

  • Attribute sind als sogenannte .custom-Befehle abgelegt. Sie befinden sich aber nicht, wie in C# gewohnt, vor den Elementen, denen sie zugeordnet sind, sondern nach der Anweisung, die ein Element einleitet. Ist eine Klasse zum Beispiel mit dem Attribut [NDOPersistent] markiert, findet sich im IL-Code gleich nach der geschweiften Klammer nach der .method-Anweisung eine .custom-Anweisung. Bestandteil dieser Anweisung ist ein hexcodierter Byteblock, der die Parameter enthält. Am Anfang des Blocks kommt die Anzahl der Parameter als 16-Bit-Wert, danach 8 Bits, die die Länge des Parameters bestimmen, sofern das nötig ist, und unmittelbar darauf folgend die Bytes des Parameters. Bei Parametern mit fester Länge, wie zum Beispiel int, fällt die Längenangabe weg. Ist der Parameter ein String, müssen Sie den String mit new System.Text.UTF8Encoding().GetString decodieren, um die Umlaute zu erhalten. Dies gilt auch dann, wenn Sie ILDasm mit dem Switch "/Unicode" aufgerufen haben.

  • ILDasm erzeugt vor jeder Befehlszeile ein Label ("IL_wxyz:"), um gegebenenfalls Sprungmarken zur Verfügung zu haben. Sie müssen in Ihrem erzeugten Code keine solchen Labels erzeugen, es sei denn, Sie benötigen eine Sprungmarke. Als Label taugt jeder Name mit einem Doppelpunkt.

Implementierung des Trace-Enhancers

Mit dem bis hier erworbenen Wissen lässt sich jetzt der eigentliche Enhancer codieren. Auf der obersten Ebene sieht das etwa so aus:

Dasm dasm = new Dasm(); 
string ilFileName = Path.ChangeExtension(dllName, ".il"); 
dasm.DoIt(dllName, ilFileName);  
ILFile ilFile = new ILFile(ilFileName); 
if (!IsEnhanced(ilFile)) 
{ 
 string keyFileName = ReadKeyFileName(ilFile); 
 EnhanceMethods(ilFile); 
 ilFile.Write(); 
 Asm asm = new Asm(); 
 asm.DoIt(ilFileName, args[0], keyFileName, debug); 
}

Die Dll wird disassembliert, die erzeugte Datei wird in den Speicher gelesen. Dann wird getestet, ob der Enhancer die Datei schon bearbeitet hat. Dazu wird die erste Methode gesucht und nachgesehen, ob am Anfang ein Debug.WriteLine-Aufruf steht. Ist dies der Fall, lässt der Enhancer die Finger von der Dll. Ist dies nicht der Fall, wird in EnhanceMethods der Code erweitert. Danach wird der Code wieder in die IL-Datei geschrieben und assembliert.

Die Funktion EnhanceMethods sehen Sie in Listing 3. Wir haben an einigen Stellen zur besseren Lesbarkeit innerhalb von Strings Zeilenvorschübe eingebaut, die Sie in der Praxis natürlich nicht verwenden dürfen, da Sie sonst Compilerfehler erhalten.

Zunächst wird einmal getestet, ob es sich bei der Methodendeklaration nicht um eine P/Invoke-Methode handelt. Diese ähneln den Forward-Deklarationen der Klassen - sie sind leer. Sie können natürlich nicht erweitert werden, da ihre Implementierung ja in einer Win32-Dll liegt. P/Invoke-Methoden erkennen Sie am Schlüsselwort pinvokeimpl in der Methodendeklaration.


Listing 3: Die Funktion EnhanceMethods

private void EnhanceMethods(ILFile ilFile) 
{ 
 foreach(ILMethodElement me in ilFile.Methods) 
 { 
  if (me.IsPInvoke) 
   continue; 
  // Sicherstellen, dass .maxstack >= 1 
  me.AdjustMaxStack(1);  
  // Anfang der Funktion ist direkt nach .maxstack 
  int indx = ilFile.Lines.IndexOf(me.MaxStackElement) + 1; 
  // Name der Funktion auf den Stack 
  ILLineElement le = new ILLineElement(@"ldstr """ + me.Name + @""""); 
  ilFile.Lines.Insert(indx, le); 
  // Mit Debug.WriteLine ausgeben 
  le = new ILLineElement("call void  
   [System]System.Diagnostics.Debug::WriteLine(string)"); 
  ilFile.Lines.Insert(indx + 1, le); 
  // Debug einrücken 
  le = new ILLineElement("call void  
   [System]System.Diagnostics.Debug::Indent()"); 
  ilFile.Lines.Insert(indx + 2, le); 
  le = new ILLineElement("call void  
   [System]System.Diagnostics.Debug::Unindent()"); 
  int endIndex = ilFile.Lines.IndexOf(me.EndElement); 
  // Durchlaufe alle Zeilen, suche ret-Befehle 
  for (int ix = ilFile.Lines.IndexOf(me.MaxStackElement) + 1;  
   ix < endIndex; ix++) 
  { 
   string l = StripLabel(((ILLineElement)ilFile.Lines[ix]).Line); 
   if (l == "ret") 
   { 
 // Debug nach außen rücken 
 ilFile.Lines.Insert(ix, le); 
 endIndex++;  // Jetzt ist's eine Zeile mehr... 
 ix++; 
   } 
  } 
 } 
}

Dann kommt ein wichtiger Schritt, den Sie nicht vergessen dürfen, wenn Sie Methoden enhancen. Sie müssen dafür sorgen, dass Maxstack mindestens auf den Wert gesetzt ist, den Ihr Code benötigt. Die Funktion AdjustMaxStack der Klasse ILMethodElement sorgt dafür, die MaxStack-Zeile gegebenenfalls auszutauschen. Unmittelbar nach der Maxstack-Anweisung beginnt der eigentliche Code der Funktion. Wir beschaffen uns also den aktuellen Index der Maxstack-Zeile mit IndexOf und fügen unmittelbar danach den Debug.WriteLine und Debug.Indent-Code ein. Danach wird in einer Schleife der Rest des Codes nach Zeilen mit dem ret-Befehl durchsucht. Unmittelbar vor diesen Zeilen wird der Debug.Unindent-Code eingefügt.

Das war der ganze Enhancer. Die Funktion EnhanceMethods zeigt, dass ein Enhancer ziemlich einfach sein kann, wenn die Infrastruktur für das Durchsuchen des Codes und das Einfügen oder Abändern von Zeilen richtig strukturiert ist. Wie Sie diese Infrastruktur leistungsfähiger machen können, haben wir ja bereits erwähnt, denn die Implementierung in diesem Beispiel hat ihre Grenzen.

Sie werden bald feststellen, dass Enhancer dazu tendieren, sehr viel Codezeilen zu benötigen. Diese Codezeilen sind zwar nicht schwer zu verstehen, wirken aber durch ihre schiere Menge. Deshalb empfehlen wir dringend, immer nur kleine Schritte auf einmal zu implementieren und dann komplett zu testen. Dazu sehen Sie sich die neu erzeugten Assemblies mit Anakrino an (Anakrino gibt es kostenlos auf der Web Site www.saurik.com). Wenn Anakrino beim Öffnen einer Funktion abstürzt, ist es sehr wahrscheinlich, dass Sie einen Fehler gemacht haben. Auch Unit-Tests sind eine empfehlenswerte Methode, um Enhancer zu testen - vor allem auf das kostenlose NUnit-Tool sei hier noch einmal hingewiesen.

Reflection, Emit und IL-Toolkit

Reflection ist ein extrem leistungsfähiger Mechanismus, mit dem Sie Assemblies und Typen untersuchen können. Manchmal kann es sinnvoll sein, sich Informationen über ein Assembly durch Reflection zu beschaffen, statt die gleichen Informationen umständlich aus dem IL-Code heraus zu pfriemeln.

Wenn Sie Ihren Enhancer, wie vorgeschlagen, als Konsolenanwendung codieren und mit dem Build Rules Add-in anstoßen, können Sie ohne Probleme Reflection verwenden. Wenn Sie sich jedoch dazu entschließen, Ihren Enhancer direkt in ein eigenes Add-in für Visual Studio zu codieren, müssen Sie mit Reflection aufpassen. Normalerweise lädt man ein zu durchsuchendes Assembly mit Assembly.LoadFrom. Dieses Assembly bleibt dann aber geladen, solange der Enhancer läuft. Die Folge ist, dass Sie spätestens beim zweiten Build Probleme bekommen, da der Compiler das Assembly nicht überschreiben darf, so lange es geladen ist. Die Lösung ist, den Reflection-Code in eine eigene Exe oder AppDomain auszulagern.

Sie müssen sich nur einen Weg überlegen, wie Sie die erhaltenen Informationen wieder in ihr Add-in zurück bekommen. Möglich wäre die Verwendung von Remoting oder das Anlegen von Temporärdateien. (Hat hier jemand XML gesagt?)

Sie könnten theoretisch mit Reflection.Emit auch direkt IL-Bytecode erzeugen, ohne Umweg über ILAsm. Sie können damit aber keine Erweiterungen an vorhandenen Assemblies vornehmen. Sie müssen die neuen Assemblies von Grund auf neu codieren. Wie das funktioniert, können Sie in der MSDN-Library anhand des Projekts ACGEN nachvollziehen. Der einfachste Weg zur Erweiterung von Assemblies führt auf jeden Fall über ILAsm und ILDasm.

Kurz erwähnen sollte man auch noch das IL-Toolkit, das Sie bei Microsoft Research herunterladen können. Das Toolkit ist in der Sprache F# codiert, die sehr schwer zu verstehen ist. Es liegt als Assembly vor, das man zumindest theoretisch von C# aus ansprechen kann. Die Auseinandersetzung damit lohnt jedoch nach unserer Auffassung nicht. Sie erhalten aus C#-Sicht eine völlig chaotische Klassenstruktur, mit der man nicht richtig arbeiten kann. Dies hat technische Gründe, die mit der Implementierung von F# zu tun haben. Mehr Informationen darüber finden Sie unter http://research.microsoft.com.

Debug-Informationen und signierte Assemblies

Wenn Sie den Artikel bis hierher gelesen haben, dann haben Sie offensichtlich konkreteres Interesse am Entwickeln eines eigenen Enhancers. Und damit kommen ein paar typische Fragen auf. Die erste Frage ist, ob man nun beim Debuggen durch den IL-Code steppen muss, statt wie bisher durch C# oder VB?

Dem ist gottseidank nicht so. Die IL-Sprache kennt die .line-Anweisung. Solche Anweisungen werden erzeugt, wenn Sie beim Aufruf von ILDasm den Parameter /LINENUM angeben. Eine .line-Anweisung hat folgendes Format:

.line Zeile:Spalte `d:\Pfad\SourceDateiname´

In diesen Anweisungen wird akribisch festgehalten, welcher C#- oder VB-Code den folgenden IL-Code erzeugt hat. Damit kann ILAsm Debug-Informationen erzeugen, die auf die ursprünglichen Sourcen verweisen. Das erweiterte Assembly kann daher mit dem ursprünglichen Source-Code debuggt werden. Komischerweise aber geht die Spalteninformation verloren, obwohl sie in den .line-Anweisungen enthalten ist. Sie sehen also nicht mehr genau, an welcher Stelle der Zeile sich der Codeablauf gerade befindet; Sie sehen nur noch, in welcher Zeile Sie sich befinden. Mit dieser Einschränkung kann man jedoch leben.

Es versteht sich von selbst, dass man keine Breakpoints auf den neu erzeugen Code setzen kann - es sei denn, Sie schalten /LINENUM ab und debuggen in IL. Wenn Sie Bugs in den erzeugten Code bringen, stehen Sie also erst einmal auf dem Schlauch. Um dem vorzubeugen, sollten Sie die gesamte Funktionalität, die der Enhancer später erzeugen soll, erst einmal in C# auscodieren und gründlich testen - am besten in kleinen Schritten.

Die zweite Frage, die schon bald auftaucht, wenn man an die Praxis schreitet, widmet sich signierten Assemblies. Können signierte Assemblies erweitert werden? Sie können - sofern das Keyfile vorliegt, was bei Ihren eigenen Assemblies ja der Fall ist. In der Assemblyinfo.cs (bzw. Assemblyinfo.vb) wird das Keyfile über das Attribut AssemblyKeyFile angegeben. Dieses Attribut liegt wie alle anderen Attribute als .custom-Anweisung im IL-Code vor. Wir haben zum Test einmal einen künstlich verlängerten Pfadnamen angegeben, damit besser sichtbar wird, wie solche Attribute in IL aussehen:

// Sehen Sie bitte von E-Mails ab, in denen Sie uns darauf hinweisen,  
// dass die Pfadangabe optimiert werden könnte ;-) 
.custom instance void  
[mscorlib]System.Reflection.AssemblyKeyFileAttribute::.ctor(string) = ( 01 00 4C  
2E 2E 5C 2E 2E 5C 2E 2E 5C 57 69 6E 64   // ..L..\..\..\Wind 
6F 77 73 41 70 70 6C 69 63 61 74 69 6F 6E 31 5C   // owsApplication1\ 
62 69 6E 5C 64 65 62 75 67 5C 2E 2E 5C 2E 2E 5C   // bin\debug\..\..\ 
2E 2E 5C 57 69 6E 64 6F 77 73 41 70 70 6C 69 63   // ..\WindowsApplic 
61 74 69 6F 6E 31 5C 74 65 73 74 2E 73 6E 6B 00   // ation1\test.snk. 
00 )

Sie müssen also nur nach der richtigen .custom-Anweisung suchen und aus dem Bytestrom für den Parameter den Dateinamen extrahieren. Das Extrahieren des Pfades aus dem Kommentar empfehlen wir nicht, weil hier die Umlaute nicht richtig repräsentiert werden.

Die Implementierung wurde in der Funktion ReadKeyFileName der Klasse TraceEnhancer vorgenommen. Für unser Beispiel-Szenario geht das in Ordnung, in komplexeren Umgebungen wird man eine eigene Klasse ILCustomElement schreiben, die in generischer Weise eine Liste aller Parameterwerte erstellt. Man iteriert dann durch alle ILCustomElemente auf Assembly-Ebene und kann den Pfad über ein Property als Parameter[0] abrufen.

Wer es ganz genau nimmt, sollte neben dem Attribut AssemblyKeyFile auch das Attribut AssemblyKeyName auswerten, da die Signierung ja auch über Devices wie Chipkartenleser etc. vorgenommen werden kann. In diesem Fall lautet der ILAsm-Parameter /KEY=@Keyname. Es wird also der gleiche Switch verwendet, wie für das Key File, nur dass ein Klammeraffe vor dem Key-Namen steht. Dies haben wir im Beispiel-Enhancer nicht implementiert.

Um das Delay Signing muss man sich nicht kümmern, es reicht, das Attribut AssembyDelaySign mit dem Parameter true gesetzt zu haben, damit ein Assembly nachträglich signiert werden kann.

Fazit

Enhancer werden ist nicht schwer... es ist allenfalls mit etwas Codierungsaufwand verbunden. Dafür locken praktisch unbegrenzte Möglichkeiten. Die Markierung von Code mit Attributen und die Auswertung dieser Markierungen mit dem Enhancer gibt den Entwicklern ein elegantes Werkzeug zur Code-Erzeugung an die Hand.

Das A&O eines guten Enhancers ist die saubere Aufbereitung des IL-Baums. Hierin lässt sich eine Menge Know-How und Zeit versenken. Die eigentliche Code-Erzeugung dagegen ist nach einer kurzen Übungsphase sehr einfach.

Aus unserer Sicht ist das Enhancen von Assemblies eine Technologie, der eine große Zukunft winkt. Lassen Sie es uns wissen, wenn Sie einen Enhancer schreiben. Wir sind an Ihren Ideen interessiert - schreiben Sie an enhancer@advanced-developers.de. Vielleicht kommt dabei einmal eine Community-Site der Enhancer-Autoren heraus.

Literatur

[Lidin02] S. Lidin, Inside Microsoft .NET: IL Assembler, MSPress 2002, ISBN 0735615470


Anzeigen: