Grunt ist ein Tool, welches Aufgaben (Tasks) in erster Linie synchron ausführt und das ist für ein Automatisierungstool die wichtigste Fähigkeit. Es ist es keine triviale Eigenschaft, weil Grunt naturgemäß mit fremden Shells zusammenarbeitet und von gestarteten Anwendungen Informationen darüber benötigt, wann und wie eine Aufgabe tatsächlich fertiggestellt wurde. Nicht immer gelingt dies Grunt mit seinen Bordmitteln, so dass manchmal nachgeholfen werden muss (z.B. bei dem unter Windows sehr interessanten Kopier-Programm Robocopy, für das es deshalb ein eigenes Grunt-Module grunt-robocopy gibt).
Grunt bietet aber auch die Möglichkeit, Aufgaben asynchron zu verarbeiten. Wer diese Möglichkeiten nutzen will, kommt schnell in Schwierigkeiten, wenn er nicht richtig verstanden hat, wie Grunt hierbei vorgeht. Es ist eine gute Übung, sich mit diesen Möglichkeiten zu Beginn der Einarbeitung mit Grunt zu beschäftigen, da man sich hier ein solides Grundverständnis für das Arbeiten mit diesem Automatisierungstool erarbeiten kann.
done - Wie Grunt asynchrone Tasks verarbeitet
Die anschaulichste Erklärung, wie Grunt mit Asynchronität umgeht, habe ich hier im Kommentar von Shama gefunden:
grunt.registerTask('later', function() { var done = this.async(); // ausklammern für den Test setTimeout(function() { grunt.log.writeln('I WILL BE CALLED IN 2 SECONDS'); done; // ausklammern für den Test }, 2000); console.log('wait ...'); }); grunt.registerTask('now', function() { grunt.log.writeln('I WILL BE CALLED IMMEDIATELY'); }); grunt.registerTask('default', ['later', 'now']);
Lässt man die beiden Zeilen 2 und 5 mit done weg (Zeilen ausklammern), ergibt sich ein unerwünschtes Verhalten. Die zuerst gestartete, aber um zwei Sekunden verzögerte Task later wird nicht mehr ausgeführt, weil das Programm vorher beendet wird.
Genau dies will man durch die Funktion done verhindern. Man erzeugt sie, bevor man die asynchrone Aufgabe, von der man nicht weis, wann sie beendet wird, startet. Die Task wartet dann auf das Ende der Verarbeitung, das durch Ausführung der Funktion done durch den letzten asynchronen Prozess angezeigt wird und gibt erst dann den Staffel-Stab weiter an die nächste Task.
grunt.util.spawn - Ausführung in einem Child-Prozess
Ein Konstrukt, dem man häufig in Grunt-Prozessen begegnet, ist grunt.util.spawn, mit dem man etwas in einem untergeordneten Prozess (child) ausführen kann.
Wir schauen uns einmal ein etwas ausführlicheres typisches Beispiel - eine Copy-Aufgabe - an, wobei wir jetzt die oben schon beschriebene done-Funktion für die Synchronisierung einsetzen.
module.exports = function(grunt) { grunt.initConfig({ pkg : grunt.file.readJSON('package.json'), copy: { "main": { filelist: "input.txt", dest : "C:\\Work\\tmp" } }, }); function escapeRegExp(string) { // needed for 'replaceAll' return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); } function replaceAll(string, find, replace) { return string.replace(new RegExp(escapeRegExp(find), 'g'), replace); } var done_count = -1; grunt.registerTask('docopy', 'Kopiere alle Dateien aus Liste asynchron ...', function(){ var file = grunt.config('copy.main.filelist'); var filelist = grunt.file.read(file); var lines = filelist.split('\r\n'); var line = ''; var dest = grunt.config('copy.main.dest'); done_count = lines.length; var done = this.async(); while (lines.length > 0) { line = lines.pop(); grunt.log.write('Kopiere: ' + line + 'lines.length: ' + lines.length +' ...\r\n'); var done = this.async(); grunt.util.spawn({ cmd: 'xcopy', args: [ line, dest, '/Y', // overwrite '/V' // verbose ], opts: { cwd: 'C:\\Work' } }, function(err, result) { if (err) { console.log('####### error ######'); throw err } done_count--; console.log('File done! lines.length: ' + lines.length + ' done_count: ' + done_count); if (done_count <= 0) { console.log('***** All done *****'); done(); } } ); } console.log('hallo'); }); grunt.registerTask('exit', 'Synchrones Beenden des Prozesses', function(){ grunt.log.write('Prozess "Exit" beendet!').ok(); }); grunt.registerTask('copy', ['docopy', 'exit']); }
Das Skript kopiert Dateien, deren Pfade in einer Datei input.txt gespeichert sind, in einen Zielordner. Die Datei input.txt sieht dabei folgendermaßen aus:
files\file3.txt files\file1.txt files\file2.txt files\file4.txt files\file5.txt files\file1.txt files\file2.txt files\file4.txt files\file5.txt files\file1.txt files\file2.txt files\file4.txt files\file5.txt ....
Man beachte, dass file3.txt nur ein einziges Mal vorkommt und zwar ganz oben. In den folgenden Wiederholungen der Files 1,2,4,5 kommt es niemals vor. Die Anzahl der Wiederholungen ist beliebig. Insgesamt sollten aber nicht mehr als 100 Einträge vorhanden sein, denn sonst bekommt man leicht einen Out of Memory-Fehler bzw. einen anderen Fehler, auf den wir noch eingehen werden, der auf einen Zugriffskonflikt zurückzuführen ist.
Der Grund, warum die Datei file3.txt ganz oben steht, hat mit dem Test zu tun. Unser Copy-Programm arbeitet die Liste von hinten nach vorne ab. Wir können also erkennen, ob unsere Liste fehlerfrei abgearbeitet wurde, wenn die Datei file3.txt im Ziel-Ordner vorhanden ist, da sie als letztes veranlaßt wurde.
Ausgeführt wird das Windows-Kopier-Shell-Kommando xcopy im current-working-directory (cwd) C:\Work. Die zu kopierenden Dateien (file1.txt...file5.txt) müssen sich deshalb im Ordner C:\Work\files befinden. Legen Sie dort einfach ein paar Textfiles mit den vorgegebenen Namen, aber beliebigem Inhalt an. Die Dateien werden in das Zielverzeichnis C:\Work\tmp kopiert. Dieser Ordner muss natürlich ebenfalls vorhanden sein (vgl. Abb. 1).
Abb. 1: Ordnerstruktur und Test-Dateien für unsere Experimente
Startet man das Skript mit grunt copy sollte man ungefähr folgende Ausgabe erhalten:
Abb. 2: Ausgabe der Grunt-Task "grunt copy"
Man erkennt sehr gut anhand dieser Ausgabe, wie das Skript arbeitet. Auffällig ist, dass die Kopier-Prozesse offensichtlich sehr schnell gestartet werden, denn lines.length ist immer Null.
Der Zähler done_count, welcher in der Callback-Funktion des asynchronen Childprozesses runtergezählt wird, ist deshalb hier das einzige Kriterium, mit dem entschieden werden kann, wann alle Kopier-Aufgaben ausgeführt wurden - sprich, die input.txt-Liste abgearbeitet wurde. Erst dann (s. Programm-Code) wird dem Master-Prozess mittels der Funktion done mitgeteilt, dass er den Staffelstab an die nächste Task exit weitergeben kann.
Dieses Programm sollte nicht als Grundlage für wirkliche Kopieraufgaben verwendet werden, sondern dient nur der Illustration des Prinzips. Denn es hat einige gravierende Nachteile:
- Die Struktur von input.txt deutet es schon an. Hier wird die gleiche Datei mehrmals kopiert. Dies führt in der Praxis manchmal zu Zugriffsfehlern, weil zwei Prozesse gleichzeitig auf eine Datei zugreifen wollen. In diesem Fall wird eine Fehlermeldung ausgeworfen und das Skript beendet (s. Zeile: if (err) { ...}). Dies könnte man verschmerzen, wenn man davon ausgeht, dass in der input.txt niemals Doppelnennungen vorkommen, aber der nächste Nachteil macht das Programm dann wirklich unbrauchbar.
- Wenn ich eine große Dateistruktur mit vielen Verzeichnissen und Dateien kopieren möchte, erhalte ich schnell einen Process out of memory - Fehler (Malloced operator new Allocation failed).
- Außerdem: Das Programm erzeugt im Zielordner keine Ordner-Struktur. Wer zuviel Zeit hat, kann das Programm ja dahingehend erweitern. Aber man spare sich den Aufwand. Es gibt bessere Alternativen, um mit Grunt Ordner-Inhalte zu kopieren (z.B. mit den Modulen grunt-shell und grunt-robocopy). Dieses Programm hier dient ausschließlich dem Ziel, asynchrone Prozesse zu diskutieren.
Anregungen zum Experimentieren
Automatisches Erzeugen der Dateiliste input.txt
Wer diesbezüglich noch etwas komfortabler experimentieren möchte, der kann sein Skript durch die folgenden Snippets erweitern:
... collect: { "main": { filelist: "input.txt", src : "C:\\Work\\StageAndDeployHr\\sites" } }, ... grunt.registerTask('docollect', 'Lese alle Dateipfade in input.txt ...', function(){ var src = grunt.config('collect.main.src'); var filelist = grunt.config('collect.main.filelist'); allFiles = []; grunt.file.recurse(src, function(abspath, rootdir, subdir, filename){ aFilePath = replaceAll(abspath, '/', '\\'); allFiles.push(aFilePath); }); grunt.file.write(filelist, allFiles.join('\r\n')); }) ... grunt.registerTask('collect', ['docollect', 'exit']); ...
Diese Task liest alle Dateipfade mit Filenamen aus einem in der initialen Konfiguration gegebenem Verzeichnis und schreibt sie in die Datei input.txt. Diese kann dann wiederum als Input von grunt copy prozessiert werden. Aber Vorsicht! Es dräut der schon erwähnte fatale Out of memory - Fehler, wenn man eine solche Liste danach mit grunt copy verarbeitet. Das Dateiverzeichnis, das man so kopieren möchte, sollte also nicht zu groß sein.
Zusätzlichen asynchronen Prozess simulieren
Lustig ist es auch, folgende Funktion an verschiedenen Stellen im Skript aufzurufen und zu schauen, was passiert:
function wait(){ if (done_count > 0){ grunt.log.write('.'); setTimeout(wait,1000); } else { grunt.log.write('!'); } }
Hier wird quasi ein weiterer asynchroner Prozess simuliert, der in das Geschehen "hineinfunkt".
Fallstricke bei Steuerung des Workflows mit Input-Prompts
Ein ernsthafteres Problem ist die Verwendung von Abfragen-Prompts innerhalb asynchron ablaufender Prozesse. Dies kann zu absolut instabilem Verhalten führen. Um hierfür ein Gefühl zu bekommen, kann man sich z.B. das Modul grunt-prompt installieren und eine Prompt-Abfrage hinter die While-Schleife setzen:
...... prompt: { commit: { options: { questions: [{ config: 'mymessage', type: 'input', message: '[Enter] to continue ...' }] } } }, ..... grunt.loadNpmTasks('grunt-prompt'); .... grunt.registerTask('docopy', 'Kopiere alle Dateien aus Liste asynchron ...', function(){ ... var done = this.async(); while (lines.length > 0) { ..... ..... done(); } // wait(); // has nothing to do with the 'prompt'-Experiment // Don't do that: grunt.task.run('prompt:commit'); } // !!!! This is the right way: grunt.registerTask('exit', 'Synchrones Beenden des Prozesses', function(){ grunt.task.run('prompt:commit'); var answer = grunt.config("mymessage"); grunt.log.write('Answer: ' + answer + '\r\n'); grunt.log.write('Prozess synchron beendet!').ok(); }); ..... grunt.registerTask('copy', ['docopy', 'exit']);
Das Ergebnis ist instabiles Verhalten. Manchmal läuft das Programm komplett durch und gibt dann einen undefined-Eingabeparameter aus und manchmal hält es am Prompt und erwartet ordentlich eine Eingabe. Verantwortlich dafür ist offensichtlich das Übermaß an Ausgaben, welche den Eingabeprompt überschreibt. Man sollte also Prompts, aber auch die Weiterverarbeitung von Daten, die durch die Prozesse erzeugt werden, in die nächste Task verlegen (hier die exit-Task) und auf keinen Fall direkt hinter parallel laufenden Prozessen.
Wichtig! Eine asynchrone Task sollte also immer direkt irgendwo hinter dem done; beendet werden. Die Prozessierung irgendwelcher Ergebnisse oder die Verarbeitung anderer Daten gehört in die nächste synchron ausgeführte Task.
Verwendung der Callback-Funktion zur Synchronisierung von asynchronen Tasks
Eine Möglichkeit für eine kontrollierte Ausführung asynchroner Aufgaben soll hier noch aufgezeigt werden, auch wenn diese Möglichkeit auf den ersten Blick widersinnig erscheint, weil sie den Vorteil asynchroner Ausführung wieder zunichte macht. Aber es gibt Fälle, wo eine solche Vorgehensweise durchaus Sinn macht.
Man stelle sich vor, man will die Dateien, die in der input.txt-Datei beschrieben sind, nicht in ein anderes Verzeichnis kopieren, sondern in einer einzigen Archiv-Datei speichern. In diesem Fall würden die Prozesse alle mehr oder weniger gleichzeitig auf die Archiv-Datei zugreifen wollen, was natürlich umgehend einen Fatal Error hervorrufen würde aufgrund der Zugriffskonflikte.
Will man jetzt für das Kopieren einer Datei nicht selbst eine Javascript-Funktion copy in Javascript programieren, sondern wieder auf ein Shell-Kommando wie xcopy zurückgreifen, wird man wohl auf die hier beschriebene Konstruktion zurückgreifen müssen.
Im folgenden wird das Skript für diese Problemstellung dargestellt.
module.exports = function(grunt) { grunt.initConfig({ pkg : grunt.file.readJSON('package.json'), pack: { "main": { filelist: "input.txt", archive : "archive.tar" } }, }); // needed for 'replaceAll' function escapeRegExp(string) { return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); } function replaceAll(string, find, replace) { return string.replace(new RegExp(escapeRegExp(find), 'g'), replace); } var done_count = -1; var extdone=null; var lines = []; function doPackTask() { var line = lines.pop(); grunt.log.write('Packe: ' + line + ' lines.length: ' + lines.length +' ...\r\n'); line = replaceAll(line, '\\', '\\\\'); grunt.util.spawn({ cmd: 'tar', args: [ '-rf', grunt.config('pack.main.archive'), line ], opts: { cwd: 'C:\\Work' } }, function(err, result) { if (err) { console.log('####### error ######'); throw err } done_count--; console.log('File done! lines.length: ' + lines.length + ' done_count: ' + done_count); if (done_count <= 0) { console.log('***** All done *****'); extdone(); } else doPackTask(); } ); } grunt.registerTask('dopack', 'Packe alle Dateien aus Liste synchron ...', function(){ var file = grunt.config('pack.main.filelist'); var filelist = grunt.file.read(file); lines = filelist.split('\r\n'); done_count = lines.length; extdone = this.async(); doPackTask(); console.log('hallo'); }); grunt.registerTask('exit', 'Synchrones Beenden des Prozesses', function(){ grunt.log.write('Prozess synchron beendet!').ok(); }); grunt.registerTask('pack', ['dopack', 'exit']);
In diesem Fall wird der Aufruf von xcopy durch grunt.util.spawn in eine eigene Funktion ausgelagert, die sich in der Callback-Funktion solange selbst aufruft, bis alle Dateien gepackt sind und dann der letzte Prozess durch Aufruf der Funktion done die Ausführung der nächsten Task exit in der synchronen Task-Kette ermöglicht.
Abb. 3: Ausgabe der Grunt-Task "grunt pack"
Im Vergleich zu Abb. 2 kann man hier schön erkennen, dass die Aufgaben jetzt synchron ausgeführt werden.
In der Praxis würde man diese Struktur aber noch vereinfachen können und zwar mit Hilfe des Grunt-Moduls grunt-shell. Darauf werden wir in späteren Blogbeiträgen noch stoßen. Hier geht es erst einmal um die grundsätzliche Struktur und Strategie für den Einsatz von Grunt-Pur, also ohne zusätzliche Module.
Zusammenfassung
Hier wurde das für Grunt wichtige Thema "Asynchrone Prozesse" hoffentlich so dargestellt, dass sich ein grundlegendes Verständnis für die damit verbundenen Probleme aufbaut.
Dargestellt wurden:
- das der Synchronisierungsfunktion done zugrundeliegende Prinzip für die Kontrolle asynchroner Prozesse,
- ein für asynchrone Prozesse typisches Problem: das parallele Kopieren von Dateien in einen Zielordner,
- einige kleine Experimente mit diesem Programm, wobei die wichtige Regel abgeleitet wurde, niemals in einer Task hinter der Verarbeitung paralleler Prozesse deren Ergebnisse oder andere Datenquellen (z.B. aus einem Prompt) weiterzuverarbeiten, sondern diese Verarbeitung in die nächste synchrone Task zu verlagern,
- eine für die Kontrolle asynchron gestarteter Prozesse mögliche Strategie, indem man asynchrone Prozesse mittels ihrer Callback-Funktion sychnron ausführt am Beispiel des Packens von Dateien in eine Archiv-Datei.
Die kleinen Skripte enthalten außerdem eine Menge von kleinen Problemlösungen (z.B. die Anwendung von grunt.util.spawn, eine Backslash-Konvertierung, die Ausführung von Shell-Befehlen in einem definierten Directory mit der cwd-Option (cwd: current working directory) und einige grundlegende interessante Read- and Write-Operationen, z.B. grunt.file.recurse), so dass sich die Skripte als Copy&Paste-Vorlagen für das schnelle Anlegen eigener Skripte eignen und die Kenntnisse über Grunt gegenüber den bisherigen Blogbeiträgen zum Thema erweitern.