Die .Net-CLR als Makro-Baukasten

16.05.2006 von Thomas Wölfer
Die .Net CLR bietet nicht nur einen gigantischen Funktionsumfang, sie enthält auch einen kostenlosen C# und VB-Compiler. Damit ist es kein Problem, eigene Makros und Skripte zukünftig direkt in einer dieser Sprachen zu schreiben.

Zudem lassen sich damit problemlos Applikationen mit einer eigenen Plugin-Schnittstelle entwickeln, für die dann die Anwender Ihrer Software eigene Makros oder Module erstellen können, ohne auf eine IDE oder den Original-Sourcecode Ihrer Anwendung angewiesen zu sein.

Doch zunächst beschäftigen wir uns mit den Grundlagen des eingebauten Compilers: Wir erstellen ein universelles Programm namens css, das auf der Kommandozeile normalen C# Code direkt ausführt. Der Ablauf soll dabei genau so einfach sein wie bei den üblichen Macros für den Windows-Skripting Host: Man ruft das Tool auf und übergibt ihm lediglich den Pfad zu einer Datei. Nur enthält diese dann kein VB-Script oder JavaScript mehr, sondern völlig normalen C#-Code.

Diese Datei wird dann geladen, kompiliert und in eine Assembly überführt. Gelingt dies, werden die C#-Anweisungen direkt ausgeführt. Tritt ein Fehler auf, wird dieser mit entsprechenden Hinweisen angezeigt. Im Prinzip macht unser css also nichts weiter, als den Compiler und Linker mit dem übergebenen C#-Sourcecode aufzurufen und das Ergebnis auszuführen.

Compilerbau als Dreizeiler

Letztlich stellt sich die ganze Lösung als ein einfacher Dreizeiler dar. Die benötigten Klassen befinden sich in den Namespaces System.CodeDom.Compiler und Microsoft.CSharp. Den eigentlichen Compiler instanziieren Sie einfach wie folgt.

CSharpCodeProvider compiler = new CSharpCodeProvider ();

Dieser Compiler kann Quellcode aus verschiedenen Quellen übersetzen. Bei unserem Projekt css lassen wir den Quellcode aus einer Datei einlesen. Die zugehörige Methode benötigt zwei Parameter: Der erste übergibt die Compiler-Optionen, der zweite den Pfad auf die zu übersetzende Datei. Für die Kapselung der Compiler-Optionen existiert eine eigenständige Klasse namens CompilerParameters. Mit diesem Wissen können Sie die erste Version des css-Programms bereits erstellen:

CSharpCodeProvider compiler = new CSharpCodeProvider ();
CompilerParameters options = new CompilerParameters();
compiler.CompileAssemblyFromFile( options, “PfadZurDatei.cs”);

Der Pfad zum Quellcode PfadZurDatei ist natürlich durch den Namen der Datei zu ersetzen.

Übergabe der Argumente

Alle Argumente werden von der CLR als Array aus Strings an die Methode Main des Programms übergeben. Vor dem Start muss css also überprüfen, ob der Anwender einen passenden Pfad zu einer C#-Datei übergeben hat. Ist dieser vorhanden wird er als Argument an den CodeProvider übergeben.

static void Main(string[] args)
{
if (args.Length != 0)
{
CSharpCodeProvider compiler = new CSharpCodeProvider ();
CompilerParameters options = new CompilerParameters();
compiler.CompileAssemblyFromFile( options, args[0]);
}
}

Mit diesem Programm können Sie schon einmal einen ersten Testlauf durchführen. Legen Sie dazu zum Beispiel die folgende Datei unter dem Namen test.cs an:

using System;
using System.Text;

namespace css
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hallo aus Ihrem ersten CS-Skript");
}
}
}

Wenn Sie nun das eigentliche Programm mit css test.cs aufrufen, wird test.cs kompiliert. Allerdings sieht man überhaupt nichts. Das liegt daran, dass das Programm mit dem Resultat von CompileAssemblyFromFile() noch nichts unternimmt. Das müssen Sie im nächsten Schritt ändern.

CompilerResults auswerten

CompileAssemblyFromFile() liefert als Resultat eine Instanz von Typ CompilerResults. Diese Instanz hat etliche Eigenschaften. Die zunächst interessanteste ist die Eigenschaft Errors. Dabei handelt es sich um eine Collection aus Objekten vom Typ CompilerError. Treten beim Übersetzen Fehler auf, wird für jeden ein CompilerError-Objekt in der CompilerErrorCollection Errors angelegt. Mit anderen Worten: Ist Errors.Count ungleich 0, dann hat das Übersetzen nicht funktioniert, und Sie müssen eine Fehlermeldung anzeigen.

Das Anzeigen der Fehler ist zum Glück recht einfach, denn eine CompilerError-Instanz enthält neben der Zeile und Spalte, in der der Fehler aufgetreten ist, auch einen passenden Fehlertext. Sie können also einfach über alle Compiler-Fehler iterieren und sie dabei ausgeben.

Ausgabe der Fehler

Um zu ermitteln, ob überhaupt Fehler aufgetreten sind, fragen Sie einfach die Count-Eigenschaft des Error-Objektes ab:

CompilerResults results = c.CompileAssemblyFromFile( options, args[0]);

if (results.Errors.Count != 0)
{
DumpErrors(results.Errors);
}

Liegen Fehler vor, dann geben Sie die Fehler einfach nacheinander aus.

private static void DumpErrors(CompilerErrorCollection collection)
{
foreach (CompilerError error in collection)
{
Console.WriteLine( error.Line.ToString() + "," + error.Column.ToString() + ": " + error.ErrorText);
}
}

Diese neue Funktionalität können Sie direkt testen: Ändern Sie die Testdatei test.cs so, dass diese einen Fehler enthält. Sie könnten zum Beispiel einfach den Buchstaben „x“ vor das Wort Console stellen. Danach rufen Sie das Ganze wie zuvor auf: css test.cs. Sie erhalten nun die Compiler-Fehlermeldung angezeigt. Damit ist schon viel geholfen, denn Sie können nun zumindest schon einmal sehen, dass das Programm überhaupt etwas Sinnvolles tut.

Jetzt geht’s ans Ausführen

Wenn nun sicher gestellt ist, dass der übergebene C#-Quelltext keine Fehler enthält, kann unser Programm css den Code auch gleich ausführen. Wie der Name CompileAssemblyFromFile() schon vermuten lässt, ist das eigentliche Resultat dieser Methode eine neue Assembly. Die findet sich in den CompilerResults unter der Eigenschaft CompiledAssembly.

Eine Assembly erhält einen Eintrittsprungpunkt zum direkten Ausführen, wenn das übersetzte Programm eine statische Methode mit dem Namen Main enthält. Das ist beim unserem Testprogramm auch der Fall. Den Eintrittspunkt können Sie einfach aufrufen:

CompilerResults results = c.CompileAssemblyFromFile( options, args[0]);
results.EntryPoint.Invoke( p1, p2);

Invoke() verlangt zwei Parameter, über die man ein wenig nachdenken muss. Der erste Parameter ist das Objekt, dessen Methode aufgerufen werden soll. Beim EntryPoint handelt es sich in Wirklichkeit um ein Objekt vom Typ MethodInfo. Um eine damit gekapselte Methode aufzurufen, müssen Sie erst die Instanz spezifizieren, für die die Methode aufgerufen werden soll. In unserem Beispiel handelt es sich jedoch um eine statische Methode. Es gibt also kein entsprechendes Objekt. In diesem Fall kann man als ersten Parameter null übergeben.

Kompliziert: Array aus Strings als erstes Array-Element

Beim zweiten Parameter handelt es sich um ein Array aus Objekten, das die Parameter für die aufzurufende Methode enthält. Dabei muss die Array-Größe der Anzahl der Parameter der Zielmethode entsprechen, und die einzelnen Objekte im Array müssen auch zum Typ der Parameter der Zielmethode kompatibel sein.

Nun erwartet die Zielmethode ein Array aus Strings als Parameter. Für das Verständnis ist es nun wichtig, dass das Array selbst ja auch ein Objekt ist: Die Zielmethode Main erwartet also genau einen Parameter.

Das bedeutet, dass das Array aus Objekten für den Aufruf von Invoke() genau ein Element groß sein muss, und dieses eine Element muss vom Typ Array aus Strings sein. Nun ist es ganz praktisch, wenn man den C#-Skripten später Parameter von der Kommandozeile aus übergeben kann. Man ruft css dann also mit mehreren Parametern auf: Der erste ist weiterhin das auszuführende C#-Programm, die folgenden Parameter sind die Parameter, die an dieses Programm übergeben werden.

Vereinfachung

Sie können es sich also ganz einfach machen und dem auszuführenden Programm auch noch seinen Dateinamen übergeben. Denn dann können Sie einfach das an css übergebene Array an das auszuführende Programm weiterreichen:

results.CompiledAssembly.EntryPoint.Invoke(null, new object[] { args });

Auch an dieser Stelle ist ein Test hilfreich: Entfernen Sie zunächst den Fehler aus test.cs, und ändern Sie das Skript dann so, dass es die übergebenen Argumente verwendet. Zum Beispiel so:

if (args.Length != 0)
{
Console.WriteLine( args[1] );
}

Wenn Sie das Programm nun per css test.cs „Hello World“ aufrufen, zeigt das Script den Text „Hello World“ an. Damit ist schon recht viel gewonnen: Sie können direkt auf der Kommandozeile Skripte ausführen, die in C# programmiert sind.

Dabei haben Sie Zugriff auf die komplette .Net-Klassenbibliothek. Zumindest in der Theorie. Denn wenn Sie in der Praxis nun die Zeile Console.WriteLine() durch einen Aufruf von MessageBox auswechseln, dann passiert etwas Ärgerliches:

global::System.Windows.Forms.MessageBox.Show( “Hello World” );

Das Programm kann nicht länger übersetzt werden. Der Grund: Der Compiler weiß nichts von Windows.Forms. Woher auch – schließlich übergibt das Programm ja auch keinerlei Informationen über diesen Teil der Klassenbibliothek an den Compiler. Sie benötigen also noch einen Mechanismus zur Übergabe von referenzierten Assemblies an den Compiler.

Konfiguration per XML

Im Beispielprogramm realisieren wir das einfach durch eine XML-Datei mit dem Namen css.xml, in der alle benutzten Assemblies einzutragen sind. Die XML-Datei muss sich im gleichen Ordner befinden, wie das Programm selbst. Jede Assembly, die in dieser Datei eingetragen wurde, wird dann beim Kompilieren an den CSharp-Compiler übergeben.

((Bild3.tif)) Wenn alles läuft, dann können Sie auch Windows.Forms im Skript verwenden

Sie können das mit dem Beispiel ausprobieren: Die Beispiel-ZIP-Datei enthält ein Release-Build von CSS, eine test.cs zum ausprobieren und die passenden XML-Datei, mit der auch Windows.Forms verwendet werden kann.

Ausblick

In diesem Teil der Workshop-Reihe haben wir uns mit den Grundlagen des im .NET eingebauten Compilers beschäftigt und einige Rahmenbedingungen für die dynamische Kompilierung erklärt. In den nächsten zwei Teilen zeigen wir Ihnen, wie Sie eigene Projekte erstellen, die sich schnell und unkompliziert über eigene Plugins erweitern lassen.

Zunächst stellen wir das allgemeine Plugin-Modell vor, um dann in einem weiteren Schritt eine GUI-Anwendung zu erstellen, bei der sich die Plugins auch im Menu des Programms verewigen können. (mha)

Komplette Listings

C#-Code für css.exe

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.CSharp;
using System.CodeDom.Compiler;
using System.Xml;

namespace css {
class Program {
static void Main(string[] args) {
if (args.Length == 0) {
Help();
} else {
CSharpCodeProvider c = new CSharpCodeProvider();
CompilerParameters options = new CompilerParameters();
options.GenerateExecutable = true;
options.GenerateInMemory = true;
options.CompilerOptions = GetReferencedAssemblies();
CompilerResults r = c.CompileAssemblyFromFile( options, args[0]);
if (r.Errors.Count != 0) {
DumpErrors(r.Errors);
} else {
r.CompiledAssembly.EntryPoint.Invoke(null, new object[] { args });
}
}
}

private static string GetReferencedAssemblies() {
StringBuilder result = new StringBuilder();

string configFile = AppDomain.CurrentDomain.SetupInformation.ApplicationBase + "\\css.xml";
XmlDocument document = new XmlDocument();
document.Load(configFile);
XmlNodeList list = document.GetElementsByTagName("assembly");
foreach (XmlNode node in list) {
XmlNode a = node.Attributes.GetNamedItem("path");
result.AppendFormat("/reference:{0} ", a.Value);
}
return result.ToString();
}

private static void DumpErrors(CompilerErrorCollection collection) {
foreach (CompilerError error in collection) {
Console.WriteLine( error.Line.ToString() + "," + error.Column.ToString() + ": " + error.ErrorText);
}
}

private static void Help() {
Console.WriteLine("css 1.0 - Von Thomas Wölfer - http://www.woelfer.com");
Console.WriteLine("Aufruf: css [Pfad]ScriptDatei.cs [Paramter fürs Skript]");
Console.WriteLine("Im Verzeichnis des Images kann eine XML-Datei mit zu referenzierenden Assemblies angelegt werden.");
}
}
}

css.xml - Einzubindende Assemblies

<assemblies>
<assembly path="C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\System.Windows.Forms.dll" />
<assembly path="C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\System.Xml.dll" />
</assemblies>

Beispiel-Makro test.cs:

using System;
using System.Collections.Generic;
using System.Text;

namespace css {
class Program {
static void Main(string[] args) {
Console.WriteLine("Hallo aus Ihrem ersten CS-Skript");
global::System.Windows.Forms.MessageBox.Show( "Hellom World" );
}
}
}