frank
Goto Top

Metadaten in der .NET-Umgebung

Das Ende der DLL-Hell
Quelle: Microsoft.de, Autor: Matt Pietrek

.NET bietet eine ganze Reihe von neuen Diensten und Leistungen an. Ein Großteil des Angebots ist auf genaue, vollständige und sprachunabhängige Informationen über Anwendungs- und Systemcode angewiesen. Die Metadaten liefern genau diese Informationen.

.NET bietet eine ganze Reihe von neuen Diensten und Leistungen an. Ein Großteil des Angebots ist auf genaue, vollständige und sprachunabhängige Informationen über Anwendungs- und Systemcode angewiesen. Die Metadaten liefern genau diese Informationen.

Vermutlich haben Sie inzwischen vom .NET-Framework von Microsoft gehört. Es gibt viele verschiedene Facetten, die am .NET erwähnenswert wären, aber mich hat es direkt zu den Metadaten gezogen, mit denen die Programmkomponenten beschrieben werden. Also möchte ich Ihnen in diesem Artikel einen Überblick über die Metadaten bieten, wie er sich aus ungefähr 12 Kilometern Höhe bietet. Bei klarer Sicht, versteht sich. Ich möchte auf die wichtigsten Eigenschaften und Details dieser entscheidenden Neuentwicklung im .NET eingehen. Leider reicht selbst ein längerer Artikel nicht aus, um die Fülle der Informationen zu bewältigen. Andererseits verbleiben so eine Menge Winkel und Ritzen, die Sie selbst erkunden können.

Im folgenden gehe ich davon aus, dass Sie schon ein wenig mit der .NET-Plattform vertraut sind. Falls nicht, finden Sie in dem Artikel "Eine Einführung in .NET" von Jeffrey Richter die ersten Grundlagen zu dem Thema. Was diesen Artikel anbetrifft, kann man ".NET-Assemblies" als die entscheidende Baugruppe für Programmkomponenten betrachten. Solche Baugruppen (oder Assemblies) können vollständig in einer einzelnen DLL enthalten sein oder sich auch aus vielen DLLs und Ressourcen zusammensetzen, die über mehrere Dateien verteilt sind. Wie auch immer solch eine Baugruppe aussieht, die Metadaten bilden den Schlüssel für den Zugang zu den guten Sachen, die darin enthalten sind.

Beachten Sie bitte, dass die Informationen, die ich Ihnen in diesem Artikel bieten kann, noch auf einer Vorabversion der Software beruhen. So manches Detail dürfte sich bis zur fertigen Version noch kräftig ändern.


Wozu braucht ein normaler Mensch Metadaten?


Metadaten sind der Klebstoff, der viele Dinge im .NET-Framework erst möglich macht. Sie stellen eine sehr umfangreiche Beschreibung der Bestandteile dar, die es in einer Baugruppe gibt. Sie enthalten Details wie zum Beispiel Typbeschreibungen und Informationen über die externen Baugruppen, die von einer Baugruppe benutzt werden. Zudem enthalten die Metadaten Versionsinformationen, beschreiben die in der Baugruppe vorhandenen Ressourcen und ermöglichen .NET noch allerlei weitere Leistungen.

Stellen Sie sich eine Typdefinition vor, etwa eine C++-Klasse. Die Metadaten für diesen Typ würden die Klasse vollständig beschreiben, einschließlich der Methoden samt Parameter, Aufrufkonvention, Datenelementen der Klasse und Sichtbarkeit aller Elemente, die zur Klasse gehören. In Visual Basic würde man diese Beschreibung auch auf die Ereignisse ausdehnen, die eine Klasse melden kann. In diesem Sinne sollen die Metadaten die Vereinigung von allen solchen Attributen aus jeder Sprache sein. Wenn Sie schon einmal in Java programmiert haben, wird Ihnen vielleicht schon aufgefallen sein, dass die .class-Dateien einen Großteil derselben Informationen anbieten wie die Metadaten.

Mit solch einer vollständigen Beschreibung der Programmkomponenten schiebt die gemeinsame Laufzeitschicht für alle Sprachen (common language runtime), die .NET anbietet, einen Großteil der Arbeit, die ein traditioneller Linker verrichtet, bis zur Ausführung des Programms auf. Im Gegenzug zu der zusätzlichen Arbeit, die beim Laden des Programms anfällt, machen die Metadaten erst viele Leistungen von .NET möglich, wie zum Beispiel die parallele Ausführung und den Schutz des Programmcodes vor Veränderungen.

Zu den größten Vorteilen vom .NET zählt die wesentlich verbesserte Kombinierbarkeit von Codeteilen, die in verschiedenen Sprachen entwickelt wurden. So ist es mit .NET geradezu trivial, von Visual C++ aus Funktionen aufzurufen, die in Visual Basic codiert sind - zumindest, wenn man die entsprechenden .NET-fähigen Compiler hat. Damit das funktionieren kann, müssen die Compiler, die Code für .NET generieren, natürlich für die allgemein zugänglichen Beschreibungen der Datentypen ein gemeinsames Format benutzen. Dieses gemeinsame Format sind die Metadaten.

Ein weiterer Vorteil der Metadaten liegt darin, dass die .NET-Sprachen keine sprachspezifischen Mechanismen für den Import von Informationen über externe Komponenten mehr brauchen. Nehmen wir zum Beispiel an, Sie wollten von C++ aus eine Funktion aufrufen, die in einer externen DLL liegt. Nach der herkömmlichen Art und Weise, solche Dinge zu erledigen, müssen Sie die entsprechenden Kopfdateien per include in Ihren Quellcode einbinden und dürfen nicht vergessen, den Linker mit der entsprechenden Importbibliothek zu füttern. Um dieselbe Sache auf herkömmliche Weise mit Visual Basic zu erledigen, würden Sie eine Declare-Anweisung einsetzen und strikt darauf hoffen, die Parameter korrekt angegeben zu haben, denn der Compiler kann die Parameter nicht überprüfen. (Kleine Bonus-Gemeinheit: Müssen Sie für einen gegebenen Parameter eigentlich ByRef benutzen oder ByVal?)

In der neuen .NET-Welt kann sich jeder .NET-Compiler oder jede Skriptsprache die Metadaten reinziehen und sich so den Zugang zu denselben Informationen verschaffen. Verschiedene Sprachen werden die Metadaten mit verschiedenen Syntaxen importieren, aber letztlich werden sie alle dieselben Informationen erhalten. Um zum Beispiel die Metadaten in eine C++-Baugruppe einzubinden, ist die Direktive #using die richtige Fahrkarte. So importiert zum Beispiel


#using


aus den .NET-Systemklassen die verschiedenen Typen und Methoden für XML.

Wenn Sie die traditionelle COM-Programmierung schon mit Visual C++ 6.0 betrieben haben, dann haben Sie vielleicht schon die Importdirektive benutzt, um eine Typbibliothek einzulesen und in .TLH- und .TLI-Dateien ein paar ATL-Hüllklassen zu implementieren. Der Compiler liest diese generierten Dateien automatisch, um sich die ATL-Sicht der COM-Schnittstellen anzuschauen, die in der Typbibliothek beschrieben werden. Obwohl die Idee eigentlich nicht schlecht war, war sie andererseits auch nicht richtig gut und zwang dem Entwickler den Einsatz der ATL auf. Im Gegensatz dazu ist #import für einen .NET-Compiler eine der natürlichsten Direktiven der Welt. Vom Konzept her könnte man sie vielleicht mit der Leichtigkeit vergleichen, mit der sich in Visual Basic-Projekten der Menüpunkt Projekt / Verweise einsetzen lässt. Aber die Importdirektive ist wesentlich leistungsfähiger.

Metadaten sind für .NET auch wichtig, um mit vorhandenen Win32-Funktionen und COM-Servern arbeiten zu können. Wenn "verwalteter Code" (managed code) eine nicht verwaltete Systemfunktion aufruft, etwa CreateProcess oder eine COM-Schnittstelle, dann benutzt er den PInvoke-Mechanismus (Platform Invoke). Die .NET-Laufzeitschicht muss für PInvoke-Aufrufe spezielle Vorbereitungen treffen. Dazu gehört meistens die Weiterleitung von Argumenten per Marshaling, wie zum Beispiel die Umwandlung eines String* in den LPSTR oder LPWSTR, den der unverwaltete Code erwartet. Jede Funktion und Methode, die vom verwalteten Code aus aufrufbar ist, muss durch entsprechende Metadaten beschrieben werden, und zwar unabhängig davon, ob es sich um einen verwalteten oder einen PInvoke-Aufruf handelt. Ein Blick in die Metadaten einer Funktion sagt der Laufzeitmaschine, welche Art von Aufruf angebracht wäre.

Metadaten bieten nicht nur einen einheitlichen Weg zum Import von Daten über externe Komponenten an, sondern auch eine ebenso einheitliche Lösung zur Beschreibung der Baugruppen, von denen eine gegebene Komponente abhängig ist. In manchen Sprachen wie zum Beispiel C++ können Sie sich im Importabschnitt der ausführbaren Datei anschauen, welche DLLs und Funktionen sie importiert. Andere Sprachen wie Visual Basic importieren Funktionen nicht in derselben Weise. Außerdem enthält eine auf Visual Basic beruhende EXE oder DLL eine Liste mit dem COM-Steuerelementen, die sie zur Laufzeit lädt. Aber das spezielle Format dieser Liste gilt nur für Visual Basic und wurde nicht offiziell dokumentiert.

Die resultierenden EXEs und DLLs jedenfalls enthalten keine Informationen über die spezifischen Versionen der importierten DLLs oder OCXe, mit denen das Programm gelinkt wurde. Bisher gab es einfach keine einheitliche, zuverlässige und - wichtiger noch - allgemein anwendbare Methode, um genau auszudrücken, von welchen anderen Komponenten das Programm abhängig ist. Das ist einer der Gründe, warum unschuldige Leute so schnell in der DLL-Hölle schmoren, wenn eine von mehreren Anwendungen gemeinsam benutzte Komponente aktualisiert oder gelöscht wird. Das .NET mit seiner Baugruppenversionierung via Metadaten wird diese Probleme hoffentlich in den Tiefen der vergangenen Zeit versenken.


Metadaten und die Bindung beim Laden


In einer traditionellen Umgebung, wie sie Visual C++ 6.0 oder Visual Basic 6.0 darstellen, ist der Linker (oder vergleichbarer Code) für solche Details verantwortlich, wie zum Beispiel die Verbindung zwischen einem Funktionsaufruf und der aufgerufenen Funktion. Nehmen wir zum Beispiel eine Funktion in A.CPP:

int foo(char *, int){...}

Und hier ist der Code aus einer anderen Quelltextdatei namens B.CPP, der diese Funktion aufruft:

int myFoo = foo( "Hello", 42 );

Beim Linken untersucht der Linker den Aufruf der Funktion foo in A.OBJ und sucht dann die dazugehörige Implementierung von foo in B.OBJ. Wenn er die Ausgabedatei schreibt, sorgt der Linker dafür, dass der CALL-Befehl aus der A.OBJ in den foo-Code von B.OBJ zielt. Sollte foo nicht zu finden sein oder die Parameter, soweit für den Linker erkennbar, nicht zum Aufruf passen, meldet der Linker einen Fehler. Anders gesagt, der Linker überprüft in gewissem Umfang, ob es sich bei der aufgerufenen Funktion tatsächlich um die Funktion handelt, die Sie aufrufen wollen.

In diesem Beispiel habe ich zwar C++ benutzt, aber dieselben Grundregeln gelten im Prinzip auch für andere kompilierte Sprachen, etwa für Visual Basic. Wenn Sie zum Beispiel in Visual Basic externe COM-Objekte benutzen, etwa die ActiveX Data Objects, müssen Sie im Verweise-Dialog die entsprechende Datei wählen. Dadurch wird Visual Basic dazu veranlasst, hinter der Bühne die Informationen für die gewählten Objekte zu lesen, die in den Typbibliotheken zu finden sind. Anschließend weiß Visual Basic, wie diese Objekte aufgerufen werden, welche Ereignisse sie melden und so weiter.

Wie steht es mit den Skriptsprachen? Auch hier gelten dieselben Grundregeln. Irgendwie muss der aufrufende Code mit der Implementierung der Funktion verbunden werden und die Argumente, die der Aufrufer verwendet, müssen zu den Parametern in der Implementierung passen. Da solche Systeme aber dynamisch generiert werden, lassen sich falsche Datentypen oder fehlende Funktionen erst beim Aufruf der betreffenden Funktion erkennen.

Wie passt nun ein .NET-Programm in diese Welt? Nun, ich stelle es mir als eine Art Hybridlösung vor. Der eigentliche Binärcode wird zwar erst zur Laufzeit generiert, die Überprüfung auf syntaktische Korrektheit eines Programms erfolgt jedoch schon beim Linken der Baugruppe. Der MSIL-Code (Microsoft Intermediate Language), den die .NET-Compiler generieren, enthält keine fest eincodierten Bezüge auf bestimmte Instanzen, Methoden oder Datenelemente in den Klassen. Statt dessen stellt MSIL die Verbindung zwischen Aufrufer und Aufgerufenem mit speziellen Symbolen oder "Token" dar. Diese Token sind die kleinen Helfer des .NET-Laders, mit deren Unterstützung er zum Beispiel herausfindet, wo eine bestimmte Klassenmethode in den Speicher geJITted wurde, oder wo ein bestimmtes Datenelement von einer bestimmten Klasseninstanz zu finden ist. (geJITted: Just in Time kompiliert)


Metadaten als Weiterentwicklung von IDL


Als kleinste, geradezu minimalistische Inhaltsangabe könnte man sagen, die Metadaten seien IDL und Typbibliothek in einem. Wenn Sie bisher irgendwelche Codeteile in anderen Prozessen oder über den Draht auch in anderen Maschinen aufrufen wollten, mussten Sie relativ ausführliche Angaben über die betreffende Funktion machen, einschließlich deren Parameter, der Parametertypen und der Übertragungsrichtung der Parameter. Das System war auf diese Informationen angewiesen, um die Daten korrekt von einem Prozess zum anderen weiterleiten zu können. Das Werkzeug, mit dem man diese Informationen angab, war IDL. Und das Programm von Microsoft, das diese IDL-Dateien verarbeitete, war der Compiler MIDL.EXE.

Funktionen und Schnittstellen in IDL zu definieren kann ziemlich frustrierend sein. Der MDIL-Compiler legt einfach Wert auf jedes Detail. Außerdem musste man nur allzu oft dieselben Angaben in jeder Sprache wiederholen, mit der man gearbeitet hat. So manche Sprachkonzepte lassen sich aber nur schwer oder gar nicht in IDL ausdrücken. Die Metadaten sorgen nun dafür, dass Sie den ganzen Ärger vergessen können. Definieren Sie einfach ihren Code und die Typen in einer Sprache, für die es einen .NET-Compiler gibt, und die Metadaten bieten die Daten in einer Art Esperanto für alle Sprachen an.

Irgendwann hat man bei Microsoft gemerkt, dass die Informationen in den IDL-Dateien eigentlich ausreichen, um COM-Automation anbieten zu können. Mit "Automation" ist in diesem Zusammenhang die Fähigkeit eines Programms gemeint, sich zur Laufzeit über Methoden und Attribute eines Objekts zu informieren. Mit Hilfe dieser Informationen kann das Programm Methoden des Objekts aufrufen und die Attribute und Properties des Objekts auslesen oder ändern. Schlüssel zur COM-Automation ist die Typbibliothek. Der MIDL-Compiler liest eine .IDL-Datei ein und wirft eine Typbibliothek aus (im Normalfall eine .TLB-Datei). Im wesentlichen ist eine Typbibliothek einfach nur die binäre Version der für Menschen leichter lesbaren .IDL-Dateien.

Nun bieten auch die Metadaten die Informationen, die nötig sind, um ein Äquivalent der COM-Automation zu ermöglichen. Sie versetzen also ein Programm in die Lage, zur Laufzeit den Code einer anderen Komponente aufzurufen. Diese Fähigkeit wird durch die Reflection-APIs geboten, auf die ich später noch eingehen möchte. Diese Schnittstellen ermöglichen das Einlesen der Metadaten. Ihren Namen hat diese Schnittstellengruppe wohl daher, dass sie in gewisser Weise die Objekte "widerspiegelt". Anwendern der Programmiersprache Java wird es geläufig sein, dass auch Java solch eine Reflection-Funktionalität hat.

Wie schon erwähnt, liest Visual Basic die Typbibliotheken ein, um die darin beschriebenen Objekte und Schnittstellen im Programmcode verfügbar zu machen. Als hübscher Nebeneffekt ergibt sich dabei die Möglichkeit, in der Entwicklungsumgebung so etwas wie IntelliSense zu implementieren. Ich habe mich schon richtig daran gewöhnt, dass mir die IDE ständig irgendwelche Listen mit einsetzbaren Methoden oder Properties aufdrängt, die genau mit dem Objekt zu tun haben, an dem ich gerade arbeite. Mit Hilfe der Metadaten lässt sich solch eine Funktonalität in allen .NET-Sprachen anbieten. Stellen Sie sich das einmal vor. Ich könnte eine Visual Basic-Klasse schreiben und später, nachdem ich ein simples #using in meinen C++-Code geschrieben habe, steht mir für die Visual Basic-Komponenten IntelliSense zur Verfügung.

Metadaten mit ihrer leichten Importierbarkeit und solchen Verlockungen wie IntelliSense sollten eine sprachübergreifende Programmierung besonders für Leute wie mich vereinfachen, die sich derzeit davon abschrecken lassen mehrere Sprachen zu kombinieren. Vor .NET war die Kombination von Komponenten, die in verschiedenen Sprachen entwickelt wurden, nicht gerade einfach. Eine Lösung bestand zum Beispiel darin, sich auf den Aufruf von exportierten Funktionen zu beschränken (sofern die verwendete Sprache überhaupt etwas mit exportierten Funktionen anfangen kann) und die Parameter so simpel wie möglich zu halten. Als Alternative konnte man den Code mit einer COM-Schnittstelle anbieten, wodurch man automatisch an die leicht antiquierten Regeln von COM oder ATL gebunden war. Oder beides.


Metadaten - oder: man wühlt sich so durch


Nachdem nun aus der anfänglich zitierten großen Höhe deutlich geworden sein sollte, was Metadaten sind und was sie ermöglichen, sollten wir etwas genauer hinschauen und uns betrachten, wie die Daten aussehen. Die ersten Frage, die mir bei meiner Auseinandersetzung mit Metadaten in den Sinn kam, war: "wo werden die eigentlich gespeichert?", gefolgt, von einem "was haben die für ein Dateiformat?" Die kurze Antwort lautet: Metadaten werden im PE-Format (Portable Executable) als schreibgeschützte Daten abgespeichert. Wer es noch etwas genauer wissen möchte, dem sei gesagt, dass die Metadaten anscheinend immer in einem der schreibgeschützten Abschnitte .rdata oder .text untergebracht werden. Im PE-Kopf erhalten Sie mit dem Feld

DataDirectory[IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR].VirtualAddress

die relative virtuelle Adresse (RVA) von einer IMAGE_COR20_HEADER-Struktur, die in CORHDR.H definiert wird. Leider ist diese Struktur praktisch schon das Ende der Fahnenstange. Keine der interessanten Metadaten-Strukturen werden angeboten. Statt dessen hat Microsoft nicht eine, sondern zwei verschiedene Programmierschnittstellen für den Zugriff auf die Metadaten implementiert, auf die ich später noch näher eingehen möchte. Da Microsoft die rohen Metadaten nicht offiziell öffentlich zugänglich macht, kann das Metadatenformat im Prinzip noch geändert und erweitert werden, ohne vorhandenen Code unbrauchbar zu machen.

Bevor ich mich nun zu weit ins interessante Neuland begebe, möchte ich aber sagen, dass ich mich im folgenden nicht mit dem Generieren der Metadaten beschäftigen kann. So wie es zwei Programmierschnittstellen gibt, mit denen sich die Metadaten lesen lassen, gibt es auch zwei zum Schreiben der Metadaten. Allerdings sind diese Schnittstellen für den harten Kern der Compilerbauer vorgesehen. Wenn Sie Metadaten generieren müssen, finden Sie in der Doku eine Menge der so nötigen und lästigen Details. Damit können Sie sich schon intensiv austoben. Ich dagegen werde mich im folgenden auf die viel einfacheren Umstände konzentrieren, die sich beim Lesen der fertigen Metadaten aus einer Baugruppe ergeben.

Die Metadaten-Hierarchie

An der Wurzel der Metadaten-Hierarchie finden Sie die Baugruppe (oder "Assembly"). Solch eine Baugruppe ist der Ausgangspunkt, von dem Metadaten importiert werden. Jede Baugruppe enthält zwei oder mehr Module, auch wenn die meisten Baugruppen nur aus einem Modul bestehen. Jedes Modul enthält eine Menge von null oder mehr Typen. Ein Modul kann auch globale Methoden enthalten. Wenn Sie reguläre C++-Funktionen kompilieren, die zu keiner Klasse gehören, werden die Funktionen in den Metadaten als globale Methoden erscheinen. Zu den Metadaten der Baugruppe (Assembly) gehört auch eine unverwechselbare GUID, die auch Modulversionskennung (MVID) genannt wird. Die MVID ändert sich bei jedem Build-Lauf der Baugruppe.

Wenn wir die globalen Methoden für den Augenblick ignorieren, führt der nächste logische Schritt in der Metadaten-Hierarchie zu den Typen. Die typische Aufgabe eines Typs in C++, Visual Basic oder Java ist, eine Klasse zu repräsentieren. Weitere Typen wären Aufzählungen und Schnittstellen. Eine .NET-Schnittstelle ist eine logisch zusammengehörige Menge von Methoden, die nicht an eine bestimmte Implementierung gebunden ist. Im Prinzip ist sie dasselbe wie eine COM-Schnittstelle. Vielleicht nützt es dem Verständnis, wenn man sich in der folgenden Beschreibung für jeden "Typ" den Ausdruck "Klasse" denkt:

.NET-Typen kennen nur die einfache Vererbung. In den Metadaten ist natürlich vermerkt, von welchem Basistyp sich ein Typ ableitet. Der Basistyp kann in derselben Baugruppe (Assembly) oder in einer anderen definiert sein. Sprachen wie C++, die eigentlich schon die Mehrfachvererbung kennen, müssen also nach einem Weg suchen, wie sie ihre zusätzliche Erbschaftsinformation darstellen können. Das ist ein Thema, das den Rahmen dieses Artikels aber bei weitem sprengt.

Jeder Metadatentyp enthält null oder mehr Methoden, Felder, Properties und Ereignisse. Jeder dieser Bestandteile hat seine eigenen Metadaten, die zur genaueren Beschreibung eingelesen werden können. Bei einer Methode kann es sich um eine reguläre Methode mit einem this-Zeiger handeln oder um eine statische Methode, die nicht mit einer bestimmten Instanz eines Typs verknüpft ist. Ein Feld ist ein Datenelement eines bestimmten Typs. Es kann ebenfalls statisch sein, also nicht mit einer bestimmten Instanz eines Typs verknüpft (dann benutzen alle Instanzen dieses Typs das Feld gemeinsam).

Die Methoden und Felder eines Typs heißen in der amerikanischen Dokumentation wieder "Member", und es gibt Metadaten-Schnittstellen, mit denen sich die Eigenschaften der Datenelemente und Methoden in Erfahrung bringen lassen, die beide Gruppen gemeinsam haben, wie zum Beispiel einen Namen. Mit anderen Schnittstellen lassen sich die speziellen Merkmale einer Methode oder eines Felds abfragen. Eine Methode folgt zum Beispiel einer Aufrufkonvention, ein Feld aber nicht. Also erfährt man von den methodenspezifischen Metadaten etwas über die Aufrufkonvention, von den feldspezifischen aber nicht.

Ein Property in .NET lässt sich mit den Properties von Visual Basic vergleichen (und mit "Attribut" übersetzen). Das bedeutet, ein Property repräsentiert Daten. Im Gegensatz zu den normalen Datenfeldern lassen sich Properties aber nicht mit direkten Lesezugriffen auslesen oder durch direkte Schreibzugriffe ändern. Statt dessen werden zum Lesen und Schreiben der Propertywerte spezielle Methoden benutzt, die Namen wie get_Foo und set_Foo tragen. Mit einem .NET-Compiler können Sie ein Property direkt mit seinem Namen ansprechen. Der Compiler setzt dann nach Bedarf automatisch die richtigen get_XXX- und set_XXX-Methoden ein. Optional kann ein Property mit einem Feld verknüpft sein. Dann handelt es sich bei dem Speicher, der von den get_- und set_-Methoden benutzt wird, um denselben Speicher, der auch für das Feld benutzt wird.

Auch Ereignisse (Events) sollten Ihnen geläufig sein, wenn Sie sich mit Visual Basic auskennen. Sobald ein Objekt feststellt, dass ein bestimmtes Ereignis eingetreten ist, kann es dieses Ereignis durch den Aufruf einer bestimmten Funktion melden. Andererseits können Sie mit Ihrem Code natürlich auch dafür sorgen, dass ein Objekt von einem bestimmten Ereignis erfährt. Die entsprechenden Methoden, mit denen sich dieser Informationsbedarf anmelden oder wieder streichen lässt, heißen AddOn und RemoveOn.

Über die Methoden, Felder und Properties eines Typs gibt es noch weitere Informationen, die sich abfragen lassen. So gibt es zum Beispiel für alle eine Art von Typinformation. Damit meine ich den Typ im herkömmlichen Sinne, wie int, bool, Zeiger auf float und struct foo. Für Felder und Properties ist jeweils ein einzelner Typ vorgesehen. Diese Information steckt in einer Typcodierung. Auf Typcodierungen komme ich später noch ausführlicher zurück. Im Moment reicht es aus, die Typcodierung einfach als eine Bytefolge mit variabler Länge zu sehen, die den Typ repräsentiert.

Bei Methoden sind die Verhältnisse aus naheliegenden Gründen komplexer als bei Feldern oder Properties. Es gibt nicht nur einen einzelnen Typ, der mit den Methoden verknüpft ist (zum Beispiel bool). Statt dessen haben Methoden null oder mehr Parameter, von denen jeder wieder seinen eigenen Typ hat. Außerdem kann eine Methode einen Ergebniswert haben, der ebenfalls zu einem bestimmten Typ gehört. Auch mit den Metadaten ist es noch möglich, Methoden zu überladen (zwei Methoden haben denselben Namen, aber verschiedene Parameter). In den Metadaten hat jede Methode eines bestimmten Typs eine unverwechselbare Typcodierung, vielleicht vergleichbar mit der Art, in der C++-Compiler die Typcodierung vornehmen.

Im Zusammenhang mit den Parametern gibt es noch ein kleines Detail, das einer Erwähnung wert ist, denn es hat mich anfangs sehr verwirrt. Zur Typcodierung einer Methode gehören Anzahl und Typen der Parameter. Wenn aber zusätzliche Informationen über einen Parameter gebraucht werden (zum Beispiel dessen Name oder Marshaling-Informationen), kann der Compiler auch eine Parameterdefinition auswerfen. Die Parameterdefinition in den Metadaten und die Typcodierung sind zwei verschiedene Sichtweisen derselben Daten. Sie sollten sich aber nicht darauf verlassen, dass die Parameterdefinitionen für die Methoden immer verfügbar sind. Allerdings können Sie sich darauf verlassen, dass eine Methode auch eine Typcodierung hat. Die Typcodierung ist es schließlich auch, was die Laufzeitschicht zur Auflösung eines Methodenaufrufs verwendet.

Die beiden Metadaten-Programmierschnittstellen


Die Laufzeitschicht von .NET bietet zum Lesen und Schreiben der Metadaten zwei verschiedene Programmierschnittstellen an. Wie schon erwähnt, wird es im folgenden nur um das Auslesen der Metadaten gehen. Bei der ersten Programmierschnittstelle handelt es sich um eine Gruppe aus unverwalteten COM-Schnittstellen, während die zweite auf die .NET-Laufzeitschicht zurückgreift und "Reflection API" genannt wird. Der Einfachheit halber möchte ich die unverwalteten COM-Schnittstellen zum Auslesen der Metadaten im folgenden die "unverwaltete API" nennen, im Gegensatz zur verwalteten Reflection-API.

Welche API sie auch benutzen, Sie werden sofort einen Unterschied gegenüber den Schnittstellen ITypeLib und ITypeInfo feststellen, mit denen die Typbibliotheken von COM ausgelesen werden. Unter COM liefern diese Schnittstellen ihre Informationen gewöhnlich in Strukturen ab. Sie rufen die passende Methode auf und erhalten von ihr einen Zeiger auf eine Struktur, in der die gewünschten Daten zu finden sind. Die Struktur FUNCDESC in der OAIDL.H ist ein schönes Beispiel dafür, wie ITypeInfo Informationen liefert. Die Struktur enthält einen Großteil der Informationen, die über eine gegebene Methode verfügbar sind.

Im Gegensatz dazu liefern die unverwalteten Metadaten-Schnittstellen nur selten eine Struktur, die mit Informationen vollgepackt ist. Statt dessen erhalten Sie die Informationen in vielen kleinen Stückchen. Die unverwalteten Schnittstellen haben meistens ziemlich viele Parameter, von denen jeder ein bestimmtes Informationsstückchen repräsentiert, das ermittelt werden soll (zum Beispiel den Namen eines Properties). Die Reflection-APIs sind ähnlich und arbeiten mit Unmengen an Methoden und Properties, von denen jede eine bestimmte Information repräsentiert.

Die unverwaltete API liegt auf einer tieferen Ebene und liefert mehr Informationen. Sie ist nicht auf die gemeinsame .NET-Laufzeitschicht angewiesen. Sie setzt nur voraus, dass die MSCORWKS.DLL korrekt auf dem System installiert wurde. Sie liest die Metadaten direkt aus der Baugruppendatei aus. Die Reflection-API benutzt dagegen die .NET-Laufzeitschicht und setzt auf der unverwalteten API auf.


Die unverwalteten Metadaten-Schnittstellen


Für die meisten Leute dürfte IMetaDataImport wohl die nützlichste Schnittstelle in der unverwalteten API sein. Falls Sie sich dagegen für Details auf der Assembly-Ebene interessieren, ist IMetaDataAssemblyImport die Alternative der Wahl. Beide werden in der COR.H definiert. Und wo erhält man nun diese hübschen Spielzeuge?

Der Schlüssel zum Königreich der Metadaten liegt in einer weiteren Schnittstelle namens IMetaDataDispenser. Sie ist tatsächlich eine Art Spender für alle Arten von Metadatenschnittstellen, ob Sie nun Metadaten lesen oder schreiben wollen. Außerdem gibt es eine Schnittstelle IMetaDataDispenserEx, die sich von IMetaDataDispenser ableitet, aber deren zusätzliche Funktionalität ist für diesen Artikel nicht wichtig.

Die IMetaDataDispenser erhalten Sie von CoCreateInstance:

#include ...HRESULT hr =CoCreateInstance(CLSID_CorMetaDataDispenser, 0, CLSCTX_INPROC_SERVER, IID_IMetaDataDispenser, (LPVOID *)&pIMetaDataDispenser );

Der nächste Schritt nach der Beschaffung der Schnittstelle IMetaDataDispenser ist der Aufruf ihrer Methode OpenScope mit der IID der gewünschten Schnittstelle. Der "Scope" ist in diesem Fall der Name der Baugruppendatei. Die IID, für die Sie sich hier interessieren, lautet IID_IMetaDataImport:

hr = pIMetaDataDispenser->OpenScope( wszFileName, ofRead, IID_IMetaDataImport, (LPUNKNOWN *)&pIMetaDataImport );

IMetaDataImport ist der Ausgangspunkt für die Abfrage der Informationen über Typen, Methoden, Felder, Parameter und so weiter. Statt nun ein ganzes Bündel von Schnittstellen zu definieren, die IMetaDataType, IMetaDataMethod, IMetaDataField und so weiter heißen könnten, haben die Metadaten-Designer IMetaDataImport Dutzende von Funktionen gegeben. Viele dieser Methoden haben einen Namen nach dem Grundmuster EnumXXX oder GetXXXProps.

Für die meisten Sammlungen in den Metadaten (Typen, Methoden, Felder und so weiter) gibt es eine EnumXXX-Methode, die ein Tokenarray liefert. Jedes Token repräsentiert eine einzelne Instanz des betreffenden Gegenstands (ein Typ, eine Methode, ein Feld und so weiter). Sie können das Token an die entsprechende GetXXXProps-Methode übergeben und von ihr alle Informationen darüber erhalten, was das Token darstellt. Tabelle T1 zeigt die Tokentypen von IMetaDataImport, die dazugehörigen Aufzählungsmethoden und die Methoden, mit denen sich die zugehörigen Attribute ermitteln lassen.

Schauen wir uns an einem Beispiel an, wie man die IMetaDataImport-Methoden zur Anzeige der Namen aller Typen samt ihrer Datenelemente und Funktionen benutzen kann, die es in einer Baugruppe gibt. Für jeden Typ zeigt dieses Beispiel die Namen aller Methoden und Felder. Und für jede Methode die Namen ihrer Parameter. Die im folgenden benutzten Methoden gehören alle zur Schnittstelle IMetaDataImport.

Zuerst rufen Sie die Methode EnumTypeDefs auf. Der erste Parameter ist die Adresse eines HCORENUMs. Es liegt zwar nicht auf der Hand, aber Sie müssen diesen Wert vor dem Aufruf mit null initialisieren. Vergessen Sie nicht, den HCORENUM-Wert später an CloseEnum zu übergeben, wenn Sie die Aufzählung nicht mehr brauchen. Nach der Rückkehr hat EnumTypeDefs ein Array mit mdTypeDefs gefüllt und Ihnen mitgeteilt, wie viele gültige Einträge es in diesem Array gibt. Dann gehen Sie über das Array und übergeben jeden mdTypeDef an die Methode GetTypeDefProps. Diese Methode liefert unter anderem den Namen des TypeDef.

Vielleicht ist Ihnen schon aufgefallen, dass GetTypeDefProps eine ganze Reihe von OUT-Parametern hat. Wenn Sie den Wert nicht brauchen, den ein bestimmter OUT-Parameter liefert, übergeben Sie beim Aufruf einfach eine null statt des betreffenden Zeigers. Und das wird öfter der Fall sein, als man anfangs vermutet. So hat IMetaDataImport::GetPropertyProps zum Beispiel 17 Parameter, von denen 14 als Ergebnisparameter gekennzeichnet sind.

Zur Aufzählung der Methoden eines TypeDefs rufen Sie EnumMethods auf. Wie zuvor wird ein auf null gesetzter HCORENUM-Wert übergeben. Außerdem ist ein mdTypeDef erforderlich, damit EnumMethods weiß, für welchen TypeDef sie mdMethodDefs liefern soll. Das Ergebnis von EnumMethods ist ein Array aus mdMethodDefs. Zur Ermittlung des Namens übergeben Sie ein mdMethodDef an GetMethodProps. Dasselbe Grundmuster gilt auch für die Datenfelder, nur benutzt man für Datenfelder die Methoden EnumFields und GetFieldProps und das Token heißt mdFieldDef.

Zur Aufzählung der Methodenparameter schließlich gibt es die Methode EnumParams. Wie alle anderen EnumXXX-Methoden erwartet auch sie ein HCORENUM. Der zweite Parameter ist ein mdMethodDef. Er nennt die Methode, deren Parameter Sie abfragen möchten. Nach der Rückkehr von EnumParams haben Sie ein Array mit mdParamDefs, die sie zur Ermittlung der Namen an GetParamProps übergeben können. Wie schon erwähnt, ist eine Methode aber nicht verpflichtet, mit formalen Parameterdaten aufzutreten. Die Typcodierung, auf die ich später noch zurückkomme, ist der einzige garantierte Weg zur Bestimmung der Parameteranzahl und der Parametertypen.

Wie die EnumXXX-Methoden Erfolg und Fehlschlag melden, durfte ich wieder auf die harte Tour lernen. Hat eine Aufzählung Erfolg und liefert ein oder mehrere Token, ist der HRESULT -Wert S_OK. Wenn Sie den Aufruf aber vermasselt haben, zum Beispiel durch die Vorgabe unsinniger Daten, erhalten Sie einen einigermaßen brauchbaren Fehlercode. Der Teil, der Neulinge wohl in die Wüste schicken dürfte, zeigt sich dann, wenn der Aufruf zwar korrekt erfolgt, die Methode aber keine Daten findet, die sie liefern könnte. Wenn das geschieht, gibt die Methode eine eins zurück. Wenn Sie also den Erfolg mit dem Makro SUCCEEDED überprüfen, dann bedenken Sie bitte, dass die Methode erfolgreich null Daten geliefert haben könnte.

Im Tabelle T1 werden noch einige interessante Token aufgeführt, auf die ich noch nicht eingegangen bin. Das interessanteste dürfte mdTypeRef sein. Vom Konzept her ähnelt ein TypeRef einem TypeDef, mit dem Unterschied, dass der Typ in einem anderen Modul definiert wird, das entweder zur selben Baugruppe gehört oder in einer ganz anderen Baugruppe definiert wird. An den meisten Stellen, an denen ein mdTypeDef benutzt wird, könnte man auch ein mdTypeRef einsetzen. Allerdings funktioniert ein TypeRef nicht für Typen im selben Modul.

Für jedes TypeRef-Token gibt es in einem anderen Modul ein TypeDef, sei es nun in derselben Baugruppe oder in einer anderen. Wie kann man nun mehr über den Typ herausfinden, auf den sich ein mdTypeRef bezieht? Nun, probieren Sie es mit der Methode ResolveTypeRef. Ist der Aufruf erfolgreich (gelegentlich ist er es nicht), erhalten Sie die IMetaDataImpor-Schnittstelle von der Baugruppe, in der der betreffende Typ definiert wird. Außerdem erhalten Sie ein mdTypeDef für den Typ in der betreffenden Baugruppe. Mit der neuen IMetaDataImport können Sie GetTypeDefProps aufrufen. Stellen Sie sich ResolveTypeRef als eine Art verkürzten Aufruf von IMetaDataDispenser::OpenScope für die importierte Baugruppe vor.

Typcodierungen in den Metadaten


Als ich den Typcodierungen in den Metadaten zum ersten Mal begegnete, versuchte ich noch, eine nähere Bekanntschaft möglichst zu vermeiden. Immerhin handelt es sich nicht um so einfache Dinge wie zum Beispiel Zeichenketten, die für Menschen lesbar sind, um eine Aufzählung oder um ein Bitfeld. Typcodierungen sind statt dessen Datenblöcke variabler Länge, deren korrekte Interpretation auch schon einen recht komplexen Code erfordert. Hat man sie aber erst einmal verstanden, kann man auch die komplizierten Probleme verstehen, die sie lösen sollen. In diesem Artikel möchte ich sie einfach Typcodierungen nennen und nicht die Bezeichnung PCCOR_SIGNATURE verwenden, die in der COR.H zu finden ist.

Typcodierungen dienen zur Beschreibung von Typen. Nicht die TypeDef-Art von Typen, sondern Typen im Sinne von int, single, Zeiger auf struct Foo und so weiter. Sofern Sie sich schon näher mit den Typbeschreibungen in Debug-Informationen oder in COM-Typbibliotheken beschäftigt haben, werden Sie wohl schon ahnen, welche Achterbahnen sich bei der Beschreibung von anwenderdefinierten Typen auftun.

Wie die meisten Debug-Formate beginnen auch die Typcodierungen der Metadaten mit einer Basismenge von einfach darzustellenden Typen, die von der Aufzählung CorElementType in der CORHDR.H definiert werden. Tabelle T2 zeigt diese einfachen Typen und ihre Darstellung in einer Typcodierung.

Tun wir einmal so, als hätte man Ihnen den Job aufgedrückt, die Parameter einer Funktion samt Rückgabewert zu codieren. Sie brauchen ein schnelles, kleines Ergebnis, das sich auf den unverzichtbaren Teil der Arbeit beschränkt. Höflichkeiten wie Parameternamen oder Funktionsnamen sind nicht nötig. Und welchen Sinn sollte das eigentlich haben? Nun, das ist genau die Art von Arbeit, die ein Linker zu erledigen hat, wenn er den Funktionsaufruf in einem Modul mit einer Zielfunktion verbinden will, die in einem anderen Modul liegt.

Für diese Aufgabe soll ein Vergleich der beiden folgenden Funktionen zum selben Ergebnis führen. Beide haben also dieselbe Typcodierung:

long FirstFunction( long j, float k);long SecondFunction( long abc, float xyz);


Andererseits sollen die beiden folgenden Funktionen als verschieden erkannt werden:

void SomeVoidFunction( long j, float k)long SomeFunctionWithOneParameter( long j)


Mit den Werten aus der Tabelle könnte man sich auf folgende Konvention verständigen:

<Zahl der Parameter><Typ des Rückgabewerts><Parameter 1><Parameter 2>...<Parameter n>

Nach dieser Konvention würde man FirstFunction und SecondFunction folgendermaßen codieren:

0x02 0x08 0x08 0x0C

Im Gegensatz dazu würde man SomeVoidFunction mit

0x02 0x01 0x08 0x0C

codieren und SomeFunctionWithOneParameter als:

0x01 0x08 0x08

Obwohl es sich nur um extrem einfache Beispiele handelt, ist damit eigentlich schon alles gesagt, was man über Typcodierungen wissen muss. In diesem Sinne ist eine Typcodierung einfach eine Bytefolge, mit deren Hilfe man schnell überprüfen kann, ob zwei Typen identisch sind. Mit etwas zusätzlichem Aufwand lassen sie sich in leicht lesbare Typspezifikationen umformen.

Wenn man bei diesen einfachen Typen bleibt, ist der Unterschied zwischen dem oben benutzten Schema für Typcodierungen und dem Schema, das im .NET benutzt wird, relativ klein. Die .NET-Typcodierung hat nämlich als einzigen Unterschied am Anfang ein zusätzliches Byte, aus dem die Aufrufkonvention der Funktion hervorgeht:

<Aufrufkonvention><Zahl der Parameter><Typ des Rückgabewerts><Parameter 1><Parameter 2>...<Parameter n>

Die Aufrufkonventionen und die dazugehörigen Flags können Sie sich in der Datei CORHDR.H anschauen, und zwar in den Aufzählungen CorCallingConvention und CorUnmanagedCallingConvention. Die üblichen C++-Konventionen wie __stdcall, varargs und __thiscall sind dort vertreten. Außerdem kann man per Oder-Verknüpfung auch noch Flags wie IMAGE_CEE_CS_CALLCONV_HASTHIS ins Konventionsbyte einbinden. Das genannte Flag zeigt übrigens an, dass die betreffende Methode einen impliziten this-Parameter hat. In C++ trifft das auf die normalen Klassenmethoden zu, aber nicht auf die static-Methoden.

Wie verhält es sich mit den komplizierteren Typen? Sie haben vielleicht nicht nur den Parameter long, sondern einen Zeiger auf long? Oder - möge der Gott der Schlichtheit es verhüten - ein Array mit Objekten des Typs System.ComponentModel.Design.CurrencyEditor? Die Typcodierung im .NET ist in der Lage, noch wesentlich kompliziertere Typen zu beschreiben.

Typmodifizierer sind die Basis für die Darstellung der komplexeren Typen. Ein Typmodifizierer ist ein spezielles Token. Es wird auf das Typtoken angewendet, das ihm in der Typcodierung folgt. Die beiden häufigsten Typmodifizierer betreffen Zeiger und Arrays. In der Aufzählung CorElementType werden sie folgendermaßen definiert:

ELEMENT_TYPE_PTR = 0xfELEMENT_TYPE_SZARRAY = 0x1D

Wie ein Blick auf die Tabelle mit den einfachen Typen zeigt, würde man einen Zeiger auf long folgendermaßen codieren:

0x0f 0x08

Ein eindimensionales Array aus floats würde dagegen so aussehen:

0x1D 0x0C

OK, das waren also Zeiger und Arrays. Aber wie sieht es bei den noch komplizierteren Typen aus, zum Beispiel bei den Klassen? In diesem Fall gibt es für die fragliche Klasse ein Metadaten-Token, das die Klasse repräsentiert. Dieses Token ist immer ein mdTypeDef oder ein mdTypeRef, die hier beide schon beschrieben wurden. Es gibt spezielle Token, die darauf hinweisen, dass ein Klassentoken folgt, zum Beispiel:

ELEMENT_TYPE_CLASS = 0x12

Nehmen wir an, eine Klasse habe den Wert 0x02000014. In einer Metadaten-Typcodierung könnte man einen Parameter dieses Typs folgendermaßen repräsentieren:

0x12 <0x02000014>

Ein Array mit Elementen dieser Klasse könnte so codiert werden:

0x1D 0x12 <0x02000014>

Beachten Sie bitte, dass ich in den beiden Beispielen für Klassenparameter "könnte" gesagt habe. In der Realität würde man sie mit weniger Bytes darstellen. Wie Sie noch feststellen werden, gibt es jede Menge Nullen in den Tokenwerten. Aus diesem Grund werden die mdTypeDef>- und mdTypeRef-Token in Typcodierungen nach einem simplen Verfahren komprimiert, das zwei bis vier Bytes zur Speicherung eines DWORDs braucht. Falls Sie sich für weitere Einzelheiten dieser Komprimierung und ihren Grenzen interessieren, schauen Sie sich die Funktion CorSigUncompressData (und Freunde) in der COR.H an.


Die Schnittstelle IMetaDataAssemblyImport


Neben IMetaDataImport gibt es in der unverwalteten API auch die Schnittstelle IMetaDataAssemblyImport. Diese Schnittstelle liefert Informationen über die Beschreibung der Baugruppe (das "Assembly Manifest"), also nicht über die darin enthaltenen Typen. Die erste Methode, die einen näheren Blick verdient, ist GetAssemblyProps. Sie liefert alle möglichen Strings wie zum Beispiel den Namen der Baugruppe und eine Beschreibung. Außerdem kann sie eine ASSEMBLYMETADATA-Struktur mit Daten füllen, darunter Versions- und Build-Nummern.

Wie IMetaDataImport hat auch IMetaDataAssemblyImport mit einer ansehnlichen Menge von EnumXXX- und GetXXXProps-Methodenpaaren aufzuwarten (Tabelle T3). Außerdem führt jedes dieser Paare wieder eine neue Tokenart ein. (Auf diesen Token-Wahn gehe ich später noch ein.)

T3 IMetaDataAssemblyImport-Tokentypen

Token Aufzählungsmethode GetProperties-Methode
mdAssemblyRef EnumAssemblyRefs GetAssemblyRefProps
mdFile EnumFiles GetFileProps
mdComType EnumComTypes GetComTypeProps
mdManifestResource EnumManifestResources GetManifestResourceProps
mdExecutionLocation EnumExecutionLocations GetExecutionLocationProps


Wie ein Blick auf die Aufzählungen und Token für IMetaDataAssemblyImport zeigt, sind manche praktisch selbsterklärend, während andere größere Rätsel aufgeben. So ist mdAssemblyRef zum Beispiel einfach eine Referenz auf eine andere Baugruppe, von der die aktuelle Baugruppe abhängt. Vom Konzept her könnte man sich das als die .NET-Version einer importierten DLL vorstellen.

Das mdManifestResource-Token ist ebenso leicht zu verstehen. Manifest-Ressourcen stellen Daten wie Bitmaps, Strings und andere Dinge dar. (Der Begriff Manifest wird in Jeffrey Richters Artikel in diesem Heft erklärt.) Sie liegen in Dateien oder anderen Baugruppen, aber sie halten sich nicht an das Ressourcenformat von Win32. Wenn eine Manifest-Ressource in eine separate .resource-Datei ausgelagert wird, dann wird sie über ein mdFile-Token angesprochen. Von der Methode EnumFiles erhalten Sie eine Liste aller Dateien und Module, aus denen sich die aktuelle Baugruppe zusammensetzt.


Metadatentoken - einfacher, als man denkt


Bei Ihren ersten Experimenten mit dem Beispielprogramm dieses Artikels, das auf den schönen Namen Meta hört, werden Sie feststellen, dass die Anzeige der Token sehr seltsam aussieht. Es handelt sich immer um DWORDs. Und wenn sie als Hexadezimalwerte dargestellt werden, zeigen sie ein leicht erkennbares Muster. So haben etwa die mdTypeDef-Token alle die Form 0x02XXXXXX (zum Beispiel 0x02000042). Und die mdMethodDef-Token halten sich alle an das Grundgerüst 0x06XXXXXX.

Sofern Sie sich die mdTypeDefs einer Baugruppe genauer anschauen, werden Sie zudem feststellen, dass sie alle monoton steigend sind (0x02000002, 0x02000003, 0x02000004). So soll es auch sein. Ein Blick auf die CorTokenType-Aufzählung in Listing L1 wird die Zusammenhänge verdeutlichen.
L1 CorTokenType-Tokentypen


typedef enum CorTokenType{ mdtModule = 0x00000000 mdtTypeRef = 0x01000000 mdtTypeDef = 0x02000000 mdtFieldDef = 0x04000000 mdtMethodDef = 0x06000000 mdtParamDef = 0x08000000 mdtInterfaceImpl = 0x09000000 mdtMemberRef = 0x0a000000 mdtCustomAttribute = 0x0c000000 mdtPermission = 0x0e000000 mdtSignature = 0x11000000 mdtEvent = 0x14000000 mdtProperty = 0x17000000 mdtModuleRef = 0x1a000000 mdtTypeSpec = 0x1b000000 mdtAssembly = 0x20000000 mdtAssemblyRef = 0x23000000 mdtFile = 0x26000000 mdtComType = 0x27000000 mdtManifestResource = 0x28000000 mdtExecutionLocation = 0x29000000 mdtSourceFile = 0x2a000000 mdtLocalVarScope = 0x2c000000 mdtLocalVar = 0x2d000000 mdtString = 0x70000000 mdtName = 0x71000000 mdtBaseType = 0x72000000} CorTokenType;


Die Token benutzen nur das höchste Byte in ihrem DWORD für die eigentliche Typangabe. Dadurch bleiben die unteren drei Bytes frei und können bestimmte Instanzen von Typen, Modulen, Dateien, Methoden und was auch immer repräsentieren. Diese Instanzen werden der Reihe nach durchgezählt, wobei die Zählung mit eins beginnt und RID genannt wird, Record IDs. Ein Token ist also folgendermaßen aufgebaut:

struct token{BYTE CorTokenTypeBYTE RID[3];}

Die Decodierung von Tokens ist kein Problem, wenn man die Werte von CorTokenTypeEnum parat hat. So handelt es sich bei dem Token 0x02000014 zum Beispiel um einen TypeDef, und zwar um den zwanzigsten (0x14) TypeDef im Modul. Ein anderes Beispiel: 0x23000002 ist die zweite Baugruppenreferenz. Die CORHDR.H bietet einige Makros an, mit denen sich Token leicht zusammenstellen und auseinandernehmen lassen. Hier ist eines dieser Makros:

#define TypeFromToken(tk) ((ULONG32)((tk) & 0xff000000))

Da zur Speicherung der RID nur 24 Bits zur Verfügung stehen, kann es höchstens 16,7 Millionen Token eines bestimmten Typs geben. Manchen mag das in Anbetracht der heutigen Programmgrößen als ein wenig restriktiv erscheinen. Allerdings darf man bei der Einschätzung dieses Problems nicht vergessen, dass die Tokenwerte nur lokal gelten, in der betreffenden Baugruppe. Es dürfte wohl ziemlich unwahrscheinlich sein, dass irgend eine Baugruppe es tatsächlich auf 16 Millionen Typen bringt oder auf 16 Millionen Methoden. Im Umkehrschluss bedeutet das natürlich auch, dass ein Token nutzlos ist, wenn man nicht weiß, zu welcher Baugruppe es gehört.

Als ich vorhin über Typcodierungen sprach, habe ich auch erwähnt, wie die Token zur Repräsentation von Parametern benutzt werden, bei denen es sich um .NET-Typen handelt. Ein weiterer Ort, an dem man auf diese Metadaten-Token trifft, ist MSIL. Zu folgt zum Beispiel für einen Konstruktor auf den Befehl newobj (Opcode 0x73) ein mdMethodRef oder mdMethodDef. Durch den Einsatz der Metadaten für den Konstruktor kann die Laufzeitschicht die Art des Objekts bestimmen, das angelegt werden soll.

Eine letzte Anmerkung zum Thema Token: in den .NET-Headern werden Sie auf den Typ mdToken stoßen. Jeder der CorTokenTypes ist eine Instanz dieses generischen Tokens. In vielen Metadaten-Schnittstellen sind zwei oder mehr Tokenarten gleichermaßen gültig. Um auf ein früheres Beispiel zurückzugreifen, mdTypeDefs und mdTypeRefs können beide als Basisklasse für eine Klasse zurückgegeben werden. in diesem Fall benutzt die Metadaten-Methode nicht die spezifischere Tokenart, sondern einen mdToken-Parameter.


Die Reflection-API


Die .NET-Reflection-API bietet von einer etwas höheren Warte aus die Sicht auf dieselben Informationen, die schon von der unverwalteten API angeboten werden. Wenn Sie die .NET-Klassen gerade erst kennen lernen, kann die Reflection ganz schön verwirrend sein. Sobald man sie aber durchschaut hat, ist der Umgang mit ihr viel einfacher als anfangs befürchtet. Im Vergleich mit den unverwalteten Schnittstellen für die Metadaten wurde ein Teil der lästigen Detailarbeit schon für Sie erledigt.

Die Reflection-API ist ein vollwertiges Mitglied der .NET-Klassen. Zu leitet sich zum Beispiel jedes .NET-Objekt von System.Object ab. Zu den wenigen Methoden, die von System.Object angeboten werden, gehört GetType. Diese Methode liefert einen Zeiger auf ein Typobjekt, das einen der Haupteintrittspunkte in die Reflection-API darstellt. Mit Hilfe eines Typzeigers von einem Objekt können Sie die Methoden und Felder des Objekts erfragen und sogar die Baugruppe, aus der es stammt.

Da die Reflection-API auf den unverwalteten APIs aufsetzt, gibt es naturgemäß viele Gemeinsamkeiten in den Konzepten. In vielen Fällen, in denen die unverwaltete API eine Gruppe von GetXXX- und GetXXXProps-Methoden anbietet, gibt es die entsprechenden Gegenstücke in der Reflection-API. Die unverwaltete EnumXXX-Methode wird zum GetXXX-Aufruf, der ein Array mit Objekten liefert. Die Properties dieser Objekte liefern ungefähr dieselben Informationen, die man auch von den unverwalteten GetXXXProps erhält.

T4 Unverwaltete API-Token und äquivalente Reflexionstypen und Methoden.

Unverwaltete Token Äquivalenter Reflection-Typ Reflection-Aufzählmethode
mdModule Module * Assembly::GetModules
mdTypeDef Type * Assembly::GetType
mdFieldDef FieldInfo * Type::GetFields
mdMethodDef MethodInfo * Type::GetMethods
mdParamDef ParameterInfo * MethodInfo::GetParameters
mdCustomAttribute Object * XXX::GetCustomAttributes
mdEvent EventInfo * Type::GetEvents
mdProperty PropertyInfo * Type::GetProperties
mdAssembly Assembly * Assembly::Load or
AppDomain::GetAssemblies
mdAssemblyRef AssemblyName * Assembly::GetReferencedAssemblies
mdManifestResource String * Assembly::GetManifestResourceNames


Tabelle T4 stellt die unverwalteten API-Token ihren äquivalenten Reflection-API-Typen und Methoden gegenüber. Beachten Sie bitte, dass es meistens mehr als nur eine Methode gibt, um eine Reflection-Klasse wie zum Beispiel Type * zu ermitteln. Im Tabelle T4 habe ich nur die gängigsten oder naheliegendsten Methoden zur Ermittlung des gewünschten Reflection-Typs angegeben.

Aus Tabelle T4 könnte man ableiten, dass die Reflection-API die Metadaten in einer stärker hierarchisch orientierten Weise anbietet als die unverwaltete API. Nimmt man sich zur Vereinfachung ein paar Freiheiten heraus, könnte man die Metadatenhierarchie, wie sie von der Reflection-API gesehen wird, so darstellen:

Assembly Module Type MethodInfo ParameterInfo FieldInfo EventInfo PropertyInfo AssemblyName ManifestResource (String *)

Wo ist bei dieser Reflection-API eigentlich vorne und hinten? Wo fängt man an? Nun, es gibt eine ganze Reihe von Eintrittspunkten. Wie man also in die Reflection-Hierarchie gelangt, hängt sehr vom Ausgangspunkt ab. Wenn Sie Informationen über Objekte in ihrem Programm brauchen, rufen Sie einfach die Methode GetType von einem Objekt Ihrer Wahl auf und erhalten so ein Type *. Von dort aus können Sie sich bis zur Baugruppe hinaufarbeiten oder bis zu den Methoden und Feldern hinunter. Wenn Sie mit einer Baugruppendatei beginnen möchten, rufen Sie die statische Methode Assembly::Load auf. Im ReflectMeta-Programm finden Sie ein Beispiel dafür (dazu später mehr).

Die größte Bequemlichkeit, die von der Reflection-API geboten wird, liegt meines Erachtens darin, dass sie verwaltete Arrays benutzt. Wenn Sie zum Beispiel die Methoden erfahren möchten, die ein Typ anbietet, rufen Sie einfach Type::GetMethods auf. Diese Methode liefert ein verwaltetes Array mit MethodInfo-Zeigern. Die Anzahl der MethodInfo-Zeiger können Sie ganz einfach mit dem Length-Property des Arrays ermitteln:

MethodInfo * arMethodInfo = pType->GetMethods();for ( unsigned idx =0; idx < arMethodInfo->Length; idx++ ){ ProcessMethodInfo( arMethodInfo[idx] );}

Die Reflection-API macht es nahezu überflüssig, in irgendwelchen DWORD-Werten die Bits zu untersuchen. In den meisten Fällen werden die einzelnen Flags eines DWORDs als Property angeboten. So liefert die unverwaltete Methode IMetaDataImport::GetMethodProps zum Beispiel ein DWORD, das Bits aus der CorMethodAttr-Aufzählung in der CORHDR.H enthält. In der Reflection-API werden dieselben Informationen von den Properties der Klasse MethodBase angeboten (Listing L2).

L2 MethodBase

MethodBase::IsAbstract
MethodBase::IsAssembly
MethodBase::IsConstructor
MethodBase::IsFamily
MethodBase::IsFamilyAndAssembly
MethodBase::IsFamilyOrAssembly
MethodBase::IsFinal
MethodBase::IsHideBySig
MethodBase::IsPrivate
MethodBase::IsPublic
MethodBase::IsSpecialName
MethodBase::IsStaticMethodBase::IsVirtual

Allerdings sind nicht alle Flags in der Reflection-API über Methoden zugänglich. Vergleicht man die CorMethodAttr-Werte mit den obigen Methoden, so fällt auf, dass die folgenden Flags nicht ihren Weg durch die Reflection-API finden (jedenfalls nicht direkt):

mdHideBySig
mdReuseSlot
mdNewSlot
mdAbstract
mdSpecialName
mdPinvokeImpl
mdUnmanagedExport
mdRTSpecialName
mdHasSecurity
mdRequireSecObject

Das sind Low-Level-Flags, und die meisten Anwender der Reflection-API werden sowieso kein Interesse an ihnen haben. Falls Sie Zugriff auf die Flags brauchen, liefert Ihnen das Property MethodInfo::Attributes die vollständige Menge der Flags aus der Aufzählung CorMethodAttr. Die Klassen MethodInfo, ParameterInfo, FieldInfo, EventInfo, PropertyInfo und Type bieten dieses Property Attributes an, mit dem man Zugriff auf die äquivalenten Aufzählwerte aus der CORHDR.H erhält.

Metadaten antreten zur Inspektion

Das wichtigste Werkzeug, das Microsoft zur Untersuchung der Metadaten anbietet, ist der ILDASM (Bild B2). Der ILDASM beschafft sich die Metadaten mit der unverwalteten API und zeigt sie in einem hierarchischen Format an. Der Name ILDASM weist auf den Umstand hin, dass es sich eigentlich um einen MSIL-Disassembler handelt. Wenn Sie in die Methoden hineingehen und die Methode gefunden haben, die Sie suchen, können Sie deren MSIL-Code durch einen Doppelklick auf den Methodennamen in einem neuen Fenster sichtbar machen.

In diesem Zusammenhang möchte ich aber nicht weiter auf die Fähigkeiten des ILDASM als Disassembler eingehen. Wenn Sie ihn auf der Kommandozeile mit "ILDASM /?" starten, zeigt er Ihnen seine Kommandozeilenoptionen an. Unter diese Optionen findet sich auch die Fähigkeit, die angezeigten Gegenstände zu filtern und die Sichtbarkeit als Kriterium zu verwenden. Eine andere Option weist den ILDASM an, seine Ergebnisse in eine Datei zu schreiben, statt sie in einem Fenster anzuzeigen.

Ein weiteres einfaches Werkzeug zur Anzeige der Metadaten nennt sich MetaInfo.EXE. Dieses Programm ist auf der Vorabversion PDC Tech Preview im Verzeichnis ".\NGWSSDK\tool developers guide\samples\metainfo" zu finden. Sie müssen die Quelltexte kompilieren, um eine lauffähige EXE zu erhalten. Aber die Mühe lohnt sich. MetaInfo lässt bei seiner Suche nach den kleinsten Kleinigkeiten in den Metadaten praktisch keinen virtuellen Stein auf dem anderen.

Für diesen Artikel habe ich zwei Beispielprogramme geschrieben. Das erste nennt sich Meta und benutzt die unverwaltete API. Das zweite heißt ReflectMeta und hält sich, wie der Name schon andeutet, an die Reflection-API. Beide zeigen die Typen, Methoden, Felder und Properties einer Baugruppe (Assembly) an. Sie finden die Programme wie üblich auf der Begleit-CD dieses Hefts.

Über diese Pflichtübungen hinaus zeigen Meta und ReflectMeta verschiedene Informationen an. So sind die Typcodierungen zum Beispiel in der unverwalteten API verfügbar. Also zeigt Meta sie an. Im Gegensatz dazu sind Typcodierungen (meines Wissens) nicht in der Reflection-API zugänglich. Statt dessen zeigt ReflectMeta, wie leicht es ist, eine für Menschen leichter lesbare Darstellung der Parametertypen zu erreichen. Dasselbe mit den unverwalteten APIs in Meta zu erreichen, würde Hunderte von Codezeilen erfordern.

Sowohl Meta als auch ReflectMeta sind beides Konsolenanwendungen, die in Visual C++ mit den "verwalteten Erweiterungen" (managed extensions) entwickelt wurden. Als Eingabe erwarten sie den Namen einer Baugruppendatei (normalerweise eine EXE oder DLL). Die Ausgabedaten schreiben sie auf die Standard-Ausgabedatei. Beide sind absichtlich so simpel aufgebaut und ich habe mir auch nicht die Mühe gemacht, wirklich jedes erhältliche Informatiönchen anzuzeigen. Der Überblick war mir wichtiger. Außerdem wollte ich den Code möglichst einfach halten, damit er sich leichter lesen lässt.

Das Meta-Programm verwendet nicht die allen Sprachen gemeinsame Laufzeitschicht von .NET. Es beginnt mit der Anzeige einiger Basisinformationen aus dem Baugruppenmanifest, darunter auch eine Liste mit den anderen benutzten Baugruppen. Diese Informationen stammen von den IMetaDataAssemblyImport-Methoden. Dann zeigt Meta alle TypeDefs an. Für jede TypeDef zeigt sie die Methoden, Felder und Properties an, zusammen mit den Token und Typcodierungbytes.

Nach dem TypeDefs wendet sich Meta der Anzeige der TypeRefs zu (also den Typen, die aus anderen Baugruppen importiert werden). Für jeden TypeRef wird die MVID der betreffenden Baugruppe angezeigt, sowie das mdTypeDef-Token des betreffenden Typs. Der letzte Teil jedes TypeRefs sind die MemberRefs. Ein MemberRef repräsentiert eine einzelne Methode, ein Feld oder ein Property des angesprochenen Typs. Nur solche Dinge, die aus dem angesprochenen Typ tatsächlich benutzt werden, erscheinen in der MemberRef-List. Zum Schluss zeigt Meta die ModuleRefs an. Das sind die Module, auf die es in der Baugruppe Bezüge gibt. Die Liste umfasst auch die .NET-fremden DLLs, die via PInvoke-Machanismus aufgerufen werden.

Das ReflectMeta-Beispielprogramm ist viel einfacher und benutzt die .NET-Laufzeitschicht. Ich habe absichtlich nicht versucht, die vielen .NET-Klassen zur Formatierung und Ausgabe zu benutzen und mich statt dessen darauf konzentriert, den Code möglichst leicht verständlich zu halten. Dazu gehört auch meine Entscheidung, die Datenanzeige mit dem guten alten printf aus der Mottenkiste zu erledigen. Da printf keine .NET-Methode ist, wird die printf-Funktion, die in der MSVCRT.DLL zu finden ist, mit dem PInvoke-Mechanismus aufgerufen. Von dem Punkt einmal abgesehen, an dem ich zur Deklaration von printf einen sysimport durchführe, ist der Aufruf von printf nicht vom Aufruf einer verwalteten Methode zu unterscheiden.

ReflectMeta beginnt mit dem Aufruf von Assembly::Load für die Datei, die auf der Kommandozeile angegeben wurde. Ist die Angabe ungültig, meldet die Methode einen Fehler, der in main bearbeitet wird. Lässt sich dagegen eine Datei mit Metadaten laden, geht der Code über alle Module in der betreffenden Baugruppe. Normalerweise enthält eine Baugruppe nur ein Modul.

Für jedes Modul ruft ReflectMeta meine Funktion DumpGlobalMethods auf, die alle Methoden anzeigt, die auf Modulebene verfügbar sind. Dann beschafft sich ReflectMeta mit Module::GetTypes eine Liste mit den Typen, die es im Modul gibt. Für jeden Typ ruft der Code die Methode Type::GetMembers auf, die jeweils ein Array mit MemberInfo-Zeigern liefert. Jedes MemberInfo stellt eine Methode dar, einen Konstruktor, ein Feld oder ein Property. Es ist zwar nicht allgemein bekannt, aber ein MemberInfo-Zeiger lässt sich in einen passenden MethodInfo, ConstructorInfo, FieldInfo oder PropertyInfo-Zeiger konvertieren, mit dem man sich ausführlichere Informationen beschaffen kann, als sie die MemberInfo-Klasse liefert.

Für jede MethodInfo-Instanz beschafft sich ReflectMeta mit MethodInfo::GetParameters ein Array mit ParameterInfo-Objekten. Wie Sie im Code meiner FormatParameterString-Funktion sehen, ist es wirklich kein Problem, sich den Typ und den Namen eines Parameters in lesbarer Textform zu beschaffen. Aber der Code von FormatParameterString ist vermutlich eine suboptimale Anwendung der .NET-Klasse StringBuilder. Derzeit ist es aber noch ziemlich schwierig, gute Beispiele für die Formatierung von Strings im .NET zu finden.

Nach der Aufzählung der Module und Typen kehrt ReflectMeta zu den Informationen aus dem Manifest zurück, die in der Klasse Assembly erhältlich sind. Es zeigt dieselbe Liste der benutzten Baugruppen an, mit der sich auch schon Meta profiliert. Zum Schluss zeigt es die Ressourcendateien der Baugruppe an. Offensichtlich ließe sich ReflectMeta leicht erweitern, um noch wesentlich mehr zu tun. Mir reicht es aber aus, in dem Beispiel die wichtigsten Bereiche kurz anzureißen, die über die Reflection-API verfügbar werden.


Fazit


.NET bietet eine ganze Reihe von neuen Diensten und Leistungen an. Ein Großteil des Angebots ist auf genaue, vollständige, sprachunabhängige Informationen über Anwendungs- und Systemcode angewiesen. Die Metadaten liefern diese Informationen. Außerdem stellen die Metadaten einen besseren und nützlicheren Weg zur Repräsentation dieser Daten dar. Zumindest im Vergleich mit älteren Formaten wie IDL und die Typbibliotheken von COM.

Microsoft hat Schnittstellen implementiert, mit denen sich die Metadaten lesen und schreiben lassen. Beides kann über traditionelle (unverwaltete) COM-Schnittstellen erfolgen oder über die Reflection-API der .NET-Laufzeitschicht. In diesem Artikel haben Sie die unverwalteten und die Reflection-APIs bei der Arbeit gesehen. Leider konnte ich kaum die Oberfläche der Informationsvielfalt ankratzen, die über Metadaten zur Verfügung steht. Durch eine genauere Untersuchung der Metadaten habe ich einiges darüber gelernt, wie das .NET funktioniert. Und Sie können es auch.

Der Autor
Matt Pietrek macht Forschungen für die NuMega-Labs der Compuware Corporation. Er hat einige Bücher zur Windows-Programmierug geschrieben. Seine Web-Site http://www.wheaty.net enthält eine Frage und Antwort-Seite und Informationen über seine Artikel.

Content-Key: 13

Url: https://administrator.de/contentid/13

Printed on: April 25, 2024 at 19:04 o'clock