Groovy Shell Scripting Teil 2 - Pipes und FIFOs

Im ersten Teil unserer Serie haben wir Groovy mit Hilfe von SDKMAN installiert und auf der Kommandozeile in guter Tradition ein erstes “Hello World” geschrieben. Wir sind jetzt so weit, dass wir unsere Groovy-Skripte in der Kommandozeile mithilfe eines Shebang starten können, aber wie interagieren wir mit anderen Prozessen auf der Shell? In diesem Blogpost soll es also um Pipes und FIFOs gehen.

Exkurs: Unix Pipes

C-Programmierer sagen oft, dass unter Unix alles eine Datei sei. Mit dieser Aussage ist gemeint, dass fast alles wie eine Datei behandelt werden kann, weil sie das gleiche Interface unterstützen. Jede Funktion, die mit Dateien arbeiten kann, kann gleichzeitig auch mit Gerätedateien unter /dev, dem procfs unter /proc und eben auch mit Pipes arbeiten. Das Lesen aus einer normalen Datei, aus einer Pipe und aus einer “Geräte”-Datei funktioniert auf die gleiche Weise.

Unter C verwendet man dafür Funktionen wie fopen, fclose, fgets und fputs. Die Funktion fopen öffnet eine Datei und liefert einen Dateideskriptor, eine Referenz auf eine Datei, die von den anderen Funktionen zum Lesen und Schreiben verwendet werden kann. Im Kontext der Java API erinnert das an die Streams in java.io, die genauso von der Quelle der Daten abstrahieren, wie Files unter Unix. Die API der genannten C-Funktionen spricht auch von Streams, statt von Dateien.

In der Tat werden wir sehen, dass man auch auf der JVM unter Groovy mit den gleichen Mitteln auf alle diese Ein- und Ausgabequellen zugreifen kann.

Eigenschaften von Pipes

Pipes sind gepufferte Kanäle zur Interprozesskomunikation. Sie haben ein schreibbares und ein lesbares Ende. Beide Enden haben einen eigenen Dateideskriptor. Daten die an einem Ende geschrieben werden, kommen in der selben Reihenfolge am anderen Ende auch wieder aus der Pipe (FIFO).

Pipes haben eine interne Kapazität. Wird zu viel geschrieben, blockiert der Schreibvorgang, bis am anderen Ende gelesen wird. Ebenso blockieren lesende Operationen, wenn die Pipe leer ist. Pipes sind zeilengepuffert. Es kann erst gelesen werden, wenn das Schreiben einer Zeile mit \n abgeschlossen wurde.

Einzige Ausnahme: Wenn alle produzierenden Prozesse das die Verbindung mit fclose schließen, zählt das Schreiben der aktuellen Zeile auch als abgeschlossen.

Die üblichen Verdächtigen: stdin, stdout, stderr

Die bekanntesten Pipes sind stdin, stdout und stderr. Normalerweise ist stdin auf der einen Seite mit unserem Keyboard verbunden, wenn wir mit der Shell arbeiten. Das Gegenstück, stdout ist mit dem Terminal verbunden, ebenso wie stderr, das normalerweise zur Ausgabe von Fehlermeldungen getrennt vom normalen Output verwendet wird.

➜ grep groovy README.md | wc -l
3

Dieser einfach anmutende Befehl, der die Zahl der Zeilen in README.md, die “groovy” enthalten zählt, tut mehr, als man auf den ersten Blick sieht: Er öffnet eine Pipe zwischen zwei Prozessen, grep und wc.

Im Prozess grep wird der Dateideskriptor von stdout auf das schreibbare Ende der erstellten Pipe geändert. Bei wc passiert das Gegenteil: der Deskriptor von stdin zeigt jetzt auf das lesende Ende der Pipe. Beide Prozesse lesen und schreiben wie gewohnt über das Datei-Interface. Sind beide Prozesse beendet, kann auch die Pipe geschlossen werden.

Pipes mit Groovy

Unter Java und damit auch unter Groovy ist stdin mit dem InputStream System.in verbunden. Für die Ausgabe sind stdout und stderr normalerweise mit einem PrintStream in System.out und System.err verbunden. Diesen Umstand werden wir uns jetzt zunutze machen.

Unsere Eingabedatei README.md hat folgenden Inhalt:

# groovy-cli #

Examples demonstrating how to use groovy on the command line

This is a line without our keyword

this line is groovy

Wir wollen jetzt unter Groovy auf verschiedene Arten die Funktionalität des Aufrufs von grep nachbauen:

Der erste Aufschlag

#!/usr/bin/env groovy
for(String line : System.in.readLines()) {
    if(line.contains("groovy")) {
        println line
    }
}

Wir führen unser Skript so aus und bekommen das erwartete Ergebnis:

➜ ./3_pipes.groovy < README.md
# groovy-cli #
Examples demonstrating how to use groovy on the command line
this line is groovy

Man sieht oft folgenden Aufruf:

➜ cat README.md | ./3_pipes.groovy

Das ist aber ineffizient, weil es den Input erst noch durch cat leitet, statt direkt in unser Script, wie es mit dem < Operator möglich wäre. Dieser setzt direkt den stdin-Pointer auf unsere Datei.

Wer schon länger in der Java-API unterwegs ist, wird sich wundern, wo denn die Methode readLines() herkommt. Sie ist in der Tat nicht in der Java-API enthalten, sondern kommt von Groovy als Erweiterung für InputStream. Sie liest den gesamten Inhalt des Streams in eine Liste, über die wir iterieren und dann nur die Zeilen ausgeben, die “groovy” enthalten.

Gebaut für die Unendlichkeit

Ein Problem mit unserer ersten Lösung ist, dass Streams auch unendlich lang sein können. Irgendwann würde uns also der Speicher ausgehen, wenn wir versuchen einen unendlich langen Stream (oder einen sehr großen) in eine Liste zu laden. Wir sparen uns also die Liste und wählen einen funktionaleren Ansatz:

#!/usr/bin/env groovy
System.in.eachLine { line ->
    if(line.contains("groovy")) {
        println line
    }
}

Auch eachLine ist eine Groovy-Erweiterung von InputStream. Die Methode bekommt eine Funktion in Form einer Closure mit einem Argument für die aktuelle Zeile. Diese Funktion wird pro gelesener Zeile einmal mit dem Zeileninhalt als Argument aufgerufen. In unserem Fall geben wir die Zeile an stdout weiter, wenn sie “groovy” enthält.

Funktionaler Feinschliff

Eine if-Abfrage innerhalb einer Closure erinnert mich immer daran, meinen funktionalen Stil mehr zu pflegen und ein passendes, funktionales Konstrukt für das zu suchen, was ich gerade mache. Groovy hilft mir auch hier weiter:

#!/usr/bin/env groovy
System.in.filterLine { it.contains("groovy") }
         .each { println it }

Die Methode filterLine ist eine Erweiterung der API von InputStream und lässt nur Zeilen durch den Stream, die dem Prädikat (eine Funktion, die true oder false liefert) in der Closure entsprechen.

Die Variable it kommt ebenfalls von Groovy. Sie ist der Standardname bei Closures, die nur einen Parameter haben.

Damit sind wir bei einer eleganten, funktionalen Lösung gelandet, die zudem noch mit potenziell unendlich langen Streams umgehen kann. Dadurch, dass wir die Ergebnisse weiter auf die Konsole, also nach stdout, schreiben, können andere Programme auf der Shell diese natürlicherweise und ohne zusätzlichen Aufwand weiter verarbeiten, beispielsweise:

➜  ./3_pipes.groovy < README.md | wc -l
4

Hier geben wir die Ausgabe unseres Skripts mit einer Pipe an das Unix-Programm “wc” weiter, das mit dem Schalter -l die Anzahl der Zeilen zählt, die es liest.

Alle Groovy-Erweiterungen des JDKs könnt ihr übrigens hier nachlesen.

FIFOs oder Named Pipes

Reguläre pipes haben keinen eigenen Namen. Sie leben als Dateien in einem Dateisystem außerhalb von / und auch nur so lange wie der Prozess, der sie erstellt hat.

Named pipes dagegen legt man als Dateien im regulären Dateisystem mit den Rechten des erstellenden Benutzer an. Sie bleiben erhalten, auch wenn die verwendenden Prozesse beendet werden. Das bedeutet, dass produzierende und konsumierende Prozesse nicht den gleichen Lebenszyklus haben müssen. Man kann also eine Named Pipe anlegen, einen Konsumenten starten und ihn in die Pipe schreiben lassen. Irgendwann ist die Pipe voll und der Schreibvorgang blockiert, bis ein Konsument verbunden wird, der die Daten konsumiert.

Eine Named Pipe legt man folgendermaßen an:

➜  mkfifo namedPipe
➜  ls -lha namedPipe
prw-r--r-- 1 georg.berky staff 0 Sep 30 11:33 namedPipe|

Als Produzenten nehmen wir das folgende Groovy Skript:

#!/usr/bin/env groovy
import java.util.stream.IntStream

def namedPipe = "namedPipe" as File
def autoFlush = true
def pipe = new PrintWriter(new FileWriter(namedPipe), autoFlush)

IntStream.range(0, Integer.MAX_VALUE).each { i->
    pipe.println("${i}")
    sleep 1000
}

Der Produzent verwendet einen PrintStream, um zeilenweise in die Named Pipe zu schreiben. Durch das autoFlush Flag wird nach jeder Zeile ein flush() ausgelöst, um den gepufferten Inhalt des Writers in die Pipe zu schreiben. Je nach Use Case und Anzal der Produzenten kann das in der Praxis gewollt sein oder nicht.

Als Konsumenten nehmen wir einmal das folgende Groovy Skript:

#!/usr/bin/env groovy
def namedPipe = "namedPipe" as File
def pipe = namedPipe.newReader()

pipe.eachLine { println it }

Wir starten es es mit:

➜ ./4a_pipe_consumer.groovy

Als zweiten Konsumenten nehmen wir einfach das Kommandozeilen-Tool cat:

➜  cat namedPipe

Als Ergebnis sehen wir:

foo

Fazit

Pipes sind ein mächtiges Mittel, Prozesse miteinander kommunizieren zu lassen. Es ist mit Groovy-Skripten recht einfach, sich der vorhandenen Mittel des Betriebssystems zu bedienen und so Interoperabilität zwischen Kommandozeilen-Tools und selbst geschriebenen Groovy-Skripten herzustellen. Named Pipes erlauben Kommunikation zwischen Prozessen, auch wenn diese nicht immer gleichzeitig aktiv sind.

Referenzen