Workshop: Makro-gesteuerte Dateiverarbeitung

15.09.2006 von THOMAS WOELFER 
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.

Im ersten Teil dieser Serie haben Sie erfahren, wie sich die CLR und C# ganz einfach als Makro-Sprache verwenden lassen. In diesem Teil gehen wir einen Schritt weiter: Eine abgewandelte Form der Makro-Engine wird für die Massenverarbeitung von Dateien implementiert.

Die Verarbeitung einer Vielzahl von Dateien ist eine häufig anfallende Aufgabe: In einem Verzeichnis, zum Beispiel mit Log-Dateien, liegt eine sehr große Anzahl Dateien vor, und die sollen auf eine bestimmte Art gefiltert und verarbeitet werden. So will man zum Beispiel alle Dateien archivieren, die eine bestimmte Größe überschreiten, sehr alte Dateien löschen und ähnliches. Die normale Windows-Shell bietet dazu keine besonders umfangreichen Hilfsmittel. Mit C# als Makro-Sprache können Sie diesen Zustand leicht beenden.

Das zu diesem Artikel gehörige Beispielprojekt können Sie hier herunterladen.

Funktionsweise

Das fertige Programm wird aus mehreren Komponenten bestehen: Zum einen gibt es den eigentlichen Motor. Dieser sorgt dafür, dass jede betroffene Datei verarbeitet wird, und kümmert sich auch um die Übersetzung und Ausführung der Makros. Diese Makros übernehmen die eigentliche Arbeit.

Dabei gibt es zwei unterschiedliche Makro-Typen. Der eine dient der Filterung der Dateien, er überprüft also beispielsweise das Alter und die Größe einer Datei oder stellt fest, ob eine Datei ein bestimmtes Wort enthält. Der zweite Typ sorgt für die Verarbeitung der Datei, also beispielsweise das Löschen, Umbenennen oder Verändern der Datei.

Die Filter-Makros lassen sich auch verketten, sprich die Dateien werden von mehreren Filtern durchgesiebt. Nur für die Dateien, auf die alle Filter zutreffen, wird das Bearbeitungs-Makro ausgeführt. Ein Aufruf der Datei-Verarbeitung könnte also in etwa wie folgt aussehen:

Css.exe PfadZumOrdnerMitDateien SizeFilter.cs AgeFilter.cs DeleteAction.cs

Das Programm soll über alle Dateien im angegebenen Ordner iterieren. Für jede Datei wird das Skript aus SizeFilter ausgeführt: Dieses Makro könnte beispielsweise untersuchen, ob eine gegebene Datei eine bestimmte Größe überschreitet. Ist das nicht der Fall, geht die Arbeit mit der nächsten Datei weiter. Ist die Größe aber überschritten, wird der AgeFilter ausgeführt. Der könnte zum Beispiel überprüfen, ob die Datei ein bestimmtes Alter überschritten hat. Trifft das auch zu, wird das Makro DeleteAction ausgeführt: Auf diese Weise werden also alle Dateien ab einem bestimmten Alter mit einer bestimmten Größe gelöscht.

Filter und Aktionen

Der Trick an der Sache ist der, dass die Filter- und Aktions-Skripte erst zur Laufzeit kompiliert werden, und darum wie Makros funktionieren. Der große Vorteil bei dieser Herangehensweise: Die Bedingungen und Aktionen lassen sich leicht ändern oder erweitern.

Das Hauptprogramm selbst ist relativ einfach. Es handelt sich einfach um eine Schleife, die über die Dateien in einem per Kommandozeilenparameter übergebenen Verzeichnis iteriert.

DirectoryInfo directoryInfo = new DirectoryInfo(args[0]);
FileInfo[] fileInfos = directoryInfo.GetFiles();
foreach (FileInfo fileInfo in fileInfos) {
...
}

Innerhalb dieser Schleife gibt es eine weitere Schleife: Die iteriert über die per Kommandozeile übergebenen Skripte. Dabei geht das Programm der Einfachheit davon aus, dass die ersten Skripte jeweils Filter enthalten und das letzte Skript die Aktion für die Datei bestimmt.

Kommunikationsprobleme: Welche Methode aufrufen ?

Es stellt sich an dieser Stelle die Frage: Wie ruft das Hauptprogramm nun die Methoden aus den Skripten auf? Denn eigentlich kann es ja gar nicht wissen, was sich in den Skripten befindet.

Diese Frage der Kommunikation ist dabei per Interfaces definiert. In der Solution gibt es ein separates Projekt, das nur für die Definition von Interfaces für genau diese Kommunikation gedacht ist. Das Projekt enthält nur eine Datei namens interfaces.cs, und diese Datei definiert zwei Schnittstellen.

public interface IFilePatternMatcher {
bool FileMatchesPattern(FileInfo fileInfo);
}

public interface IFileActionExecutor {
void Execute(FileInfo fileInfo);
}

Das erste Interface IFilePatternMatcher ist für die Skripten gedacht, die die Dateifilter darstellen. In einem solchen Skript muss sich also eine Klasse befinden, die dieses Interface implementiert. Das zweite Interface ist für die Aktions-Skripte gedacht: Diese Skripte müssen also eine Klasse enthalten, die das IFileActionExecutor Interface definiert.

Gemeinsame Interfaces für Skripte und Hauptprogramm

Das Projekt Interfaces erzeugt eine Assembly, und diese Assembly ist an zwei Stellen zu referenzieren. Die Referenz im Hauptprogramm erfolgt einfach wie gewohnt in Visual Studio. Dadurch kann das Hauptprogramm diese Interfaces bereits benutzen.

Das erfolgt in der inneren Schleife, und zwar über einem Aufruf von ScriptExecutor.CreateInstance<T>(). Diese Methode ist dafür zuständig, das übergebene Skript zu kompilieren, und dann anhand der resultierenden Assembly die Instanz eines Objektes zu erzeugen, das die als Generic-Parameter übergebene Interface implementiert. Doch dazu später mehr. Das komplette Hauptprogramm sieht also folgendermaßen aus:

class Program {
static void Main(string[] args) {
DirectoryInfo directoryInfo = new DirectoryInfo(args[0]);
FileInfo[] fileInfos = directoryInfo.GetFiles();

foreach (FileInfo fileInfo in fileInfos) {
for (int i = 1; i < args.Length; i++) {
if (i == args.Length - 1) {
IFileActionExecutor action = ScriptExecutor.CreateInstance<IFileActionExecutor>( args[i]);
action.Execute(fileInfo);
} else {
IFilePatternMatcher o = ScriptExecutor.CreateInstance<IFilePatternMatcher>( args[i]);
if (! o.FileMatchesPattern(fileInfo)) {
break;
}
}
}
}
}
}

Dieses Programm lässt sich in der vorliegenden Form für beliebige filtergesteuerte Dateioperationen verwenden.

Die zweite Referenz auf die Assembly mit den Interface muss natürlich beim Übersetzen der Skripte zum Zuge kommen. Das ist aber weiter kein Problem: Schon die Version des C#-Compilers aus dem ersten Teil des Artikels konnte eine XML-Datei auslesen, in der zu referenzierende Assemblies eingetragen werden konnten. Das ist auch hier der Fall: Um die Interfaces-Assembly also in einem Skript verwenden zu können, ist nichts weiter zu tun, als die interfaces.dll in der Datei css.xml Datei einzutragen: Danach sind beide Interfaces beim Übersetzen des Skripts bekannt.

ScriptExecutor: Kann auch im Skript verwendet werden

Anders als bei der ursprünglichen Version aus dem ersten Teil des Beitrages befindet sich der ScriptExecutor in dieser Version des Programms übrigens auch in einer eigenen Assembly. Der Vorteil: Durch das Referenzieren dieser Assembly, also durch den Eintrag in der css.xml, können ab sofort auch Skripte den Executor verwenden - ein Skript kann also einfach ein anderes Skript starten.

Der Code zum Executor befindet sich zum Großteil in der Methode CreateInstance<T>(). Die Methode erhält einen String als Parameter, der den Namen der auszuführenden Skript-Datei angibt. Der Generic-Parameter gibt das gewünschte Interface an.

public static T CreateInstance<T>(string scriptName) {
Assembly assembly;
if (!scriptToAssembly.TryGetValue(scriptName, out assembly)) {
...
}

Anders als im ersten Teil werden die übersetzten Assemblies in diesem Beispiel vom ScriptExecutor zwischengespeichert. Zu diesem Zweck verfügt er über ein Dictionary-Objekt:

private static Dictionary<string, Assembly> scriptToAssembly = new Dictionary<string, Assembly>();

Suche im Dictionary

Anhand des Pfades zum Skript sucht CreateInstance<T>() nun zunächst in diesem Dictionary nach einer bereits übersetzten Assembly. Wird eine solche nicht gefunden, folgt eine Code-Sequenz, die Sie im Kern schon aus dem ersten Teil zum CLR-Makrobaukasten kennen: Das Skript wird übersetzt und das Programm reagiert auf etwaige Fehler. Geht alles gut, liegt als Resultat die fertig übersetzte Assembly vor, die wir im Dictionary speichern:

CompilerResults r = c.CompileAssemblyFromFile(options, script);
assembly = r.CompiledAssembly;
scriptToAssembly[scriptName] = assembly;

Zu diesem Zeitpunkt kennt CreateInstance<T>() also die zum Skript passende Assembly - entweder, weil sie bereits im Dictionary enthalten war, oder aber, weil sie soeben hinzugefügt wurde.

Nun folgt der interessante Teil: CreateInstance<T> muss eine Instanz eines Typs erzeugen, der das Interface T implementiert. Dazu ermittelt CreateInstance<T>() zunächst alle öffentlichen Typen der Assembly:

Type[] types = assembly.GetExportedTypes();

Typen durchsuchen

Über dieses Array aus Typen wird dann iteriert:

foreach (Type type in types) {
if( typeof(T).IsAssignableFrom(type)) {
ConstructorInfo ctor = type.GetConstructor(Type.EmptyTypes);
object o = ctor.Invoke(null);

return (T)o;
}
}

Zunächst prüft der Code, ob eine Zuweisung zwischen dem Typen von T (typeof(T)) und dem aktuellen Typen aus der Schleife möglich ist (IsAssignableFrom). Das ist der Fall, wenn type das Interface implementiert, das beim Aufruf von CreateInstance<T>() übergeben worden war: type ist also der Typ im Skript, der gesucht wird.

Jetzt ist also der System.Type bekannt - nun benötigen wir nur noch eine Instanz dieses Typs. Das muss dieser einen Konstruktor ohne Parameter zur Verfügung stellen. Dieser Konstruktor wird im nächsten Schritt durch type.GetConstructor() ermittelt. GetConstructor() liefert eine Referenz auf ein Objekt vom Typ ConstructorInfo.

Dieses Objekt verfügt über die Methode Invoke() und diese ruft schließlich den Konstruktor des Objektes auf. Als Resultat erhält man ein Objekt vom Typ object, das sich per Typecast in den gewünschten Typ umwandeln lässt.

Mögliche Verbesserungen

An dieser Stelle sind verschiedene Verbesserungen des Beispielprogramms denkbar. Da die gesuchte Klasse im Skript über das Interface identifiziert wird, kann man auch mehrere Klassen mit diesem Interface in einem Skript unterbringen.

Statt also schon im ersten passenden Fall eine Instanz zu erzeugen, könnte der Code auch alle möglichen Klassen einsammeln, und von allen Klassen eine Instanz erstellen. Als Resultat würde CreateInstance<T>() dann eine Liste von Objekten zurückliefern: Jedes dieser Objekte wäre ein eigener Filter. Die Schleife im Hauptprogramm müsste dies natürlich berücksichtigen. Auf diese Weise wäre es einfach möglich, mehrere Filter mit unterschiedlicher Funktionalität in einem Skript unterzubringen.

Andererseits könnte man natürlich auch gleich alle Filterkriterien in einer einzigen Klasse unterbringen, der Effekt wäre derselbe. Die Erweiterung wäre also vielleicht praktisch, würde aber keine zusätzliche Funktionalität bieten.

Eine weitere Verbesserung wäre es, wenn man die erzeugten Objekte, ähnlich wie die kompilierten Assemblies, cachen würde. Es ist nämlich sehr aufwändig, die zum Erzeugen der Instanzen benötigten Informationen zu ermitteln und die Objekte mittels Reflection zu erzeugen. Allerdings müsste in diesem Fall berücksichtigt werden, dass die Objekte unter Umständen einen eigenen Status mit sich bringen - es dürften also nur Objekte zwischengespeichert werden, die auch sicher mehrfach benutzt werden dürfen. Ob das bei einem Typ der Fall ist oder nicht, könnte man zum Beispiel mit einem Attribut auf Klassen-Niveau klarstellen.

Das vorliegende Beispielprogramm sieht aber die Wiederverwendung von Objekten ohnehin nicht vor - das Programm selbst wird ja schließlich auch sofort wieder beendet. Daher haben wir im Beispiel auf diesen Luxus verzichtet.

Endlich: Das Skript kann aufgerufen werden

In der inneren Schleife des Hauptprogramms liegt nun also eine Referenz auf eine Implementierung des gewünschten Interfaces vor. Im Fall der Filter handelt es sich dabei um IFilePatternMatcher.

IFilePatternMatcher o = ScriptExecutor.CreateInstance<IFilePatternMatcher>( args[i]);

Dieses Interface stellt sicher, dass die Methode FileMatchesPattern( FileInfo) vorliegt, so dass diese Methode dann einfach aufgerufen werden kann:

if (! o.FileMatchesPattern(fileInfo))

Damit wäre die Kommunikation zwischen dem Hauptprogramm, dem Executor und den einzelnen Skripten geklärt. Bleiben noch die Skripte selbst. Beim Beispiel liegen zwei mehr oder minder leere Demo-Skripte vor. In der Datei filter findet sich ein Beispiel-Filter, in der Datei action ein Beispiel-Skript für eine Aktion.

Die Methoden für die Filter- und die Aktions-Skripte haben eines gemeinsam: Sie erhalten eine Referenz auf ein Objekt vom Typ FileInfo als Parameter. Mit diesem FileInfo-Objekt lassen sich eigentlich alle denkbaren Filtermechanismen implementieren - und sogar einige Aktionen.

FileInfo: Alles da, was man braucht

Das FileInfo Objekt bietet folgende für Filter wichtige Informationen:

Zusätzlich zu den Eigenschaften, die die Filter nutzen können, verfügt ein FileInfo-Objekt auch über verschiedene Methoden, die Sie bei den Aktions-Skripten verwenden können:

Praktisch alle anderen Datei-Aktionen lassen sich mit statischen Methoden der Klasse File durchführen. Dort findet sich zum Beispiel die Methode Delete() zum Löschen der Datei.

Installation und Fazit

Die Installation des Beispiel-Programms ist denkbar einfach. Sofern die aktuelle CLR bereits installiert ist, brauchen Sie bloß ein Verzeichnis anzulegen und das Programm css.exe sowie die DLLs executor.dll und interfaces.dll dort hinein zu kopieren. In diesem Verzeichnis legen Sie außerdem ein Verzeichnis namens scripts an: Dort sucht css.exe die Skripte, deren Namen Sie beim Aufruf per Kommandozeile übergeben. Ferner brauchen Sie noch die Datei css.xml. Diese Datei enthält die Pfade zu den Assemblies, die beim Übersetzen der Skripte referenziert werden sollen. Die Datei interfaces.dll muss dort auf jeden Fall eingetragen sein.

In diesem Beitrag haben Sie erfahren, wie man die CLR als Baukasten für die Verwaltung großer Mengen an Dateien einsetzen kann. Dabei kommt einfach eine Reihe von leicht zu erstellenden Skripten ins Spiel, mit denen die Dateien ganz nach Wunsch ausgefiltert und dann bearbeitet werden können. Im nächsten Beitrag dieser Serie zeigen wir Ihnen, wie Sie ein Programm mit einer dynamisch erweiterbaren GUI ausstatten. (mha)