Arbeiten mit Unix Pipes

Microservices in der Praxis

Pipes sind einer der Grundpfeiler der Softwareentwicklung unter Unix und können als Vorläufer der zentralen Prinzipien von Microservices gesehen werden. Ein Praxisbeispiel aus dem Bereich Web-Entwicklung.

Der Begriff Microservices gehört seit Jahren zum unverzichtbaren Vokabular wenn die Rede von Prinzipien der Software-Architektur ist. Im Grunde geht es darum, Anwendungen modular aufzubauen, sodass für jede noch so bescheidene Teilaufgabe ein kleines, aber überschaubares und austauschbares Programm verwendet wird.

Entscheidend für das Zusammenspiel dieser Programme ist die Verwendung von universellen und softwareunabhängigen Schnittstellen. Eine solche Schnittstelle ist die für Unix entwickelte Pipe, die zwei Prozesse so miteinander verbindet, dass der Ausgabestrom eines Prozesses als Eingabestrom eines anderen Prozesses weiterverarbeitet werden kann.

Die Philosophie dahinter wurde vom Entwickler der Pipes Doug McIlroy bereits in den 1960er Jahren mit der Analogie zur Verbindung von Gartenschläuchen beschrieben:

We should have some ways of coupling programs like garden hose-screw in another segment when it becomes necessary to massage data in another way.

Die Pipe wird mit dem Zeichen | gebildet, wobei auf der linken Seite von | das Programm steht, das die Ausgabe liefert und auf der rechten Seite von | das Programm, das die Ausgabe als Eingabe liest:

Programm1 | Programm2

Dieses Prinzip ist auf beliebig viele Programme erweiterbar:

Programm1 | Programm2 | Programm3 | Programm4 | Programm5 usw.

Anwendungsbeispiel

Problemstellung

Das in PHP geschriebene Content Management System Kirby stellt eine Funktion t() zur Verfügung, um Texte in verschiedenen Sprachen anzuzeigen. Die allgemeine Form von t() lautet:

t(string|array $key, string $fallback = null): mixed

Die Funktion referenziert über den Schlüssel $key einen Text in einer für jede gewünschte Sprache zu erstellenden Sprachdatei, die entweder ein PHP-Array mit den Übersetzungen enthält oder die Übersetzungen im YAML-Format aus einer separaten Datei einliest (Details siehe Flexible language variables).

Ein Beispiel für Übersetzungen in einem PHP-Array (hier für die Sprache Deutsch) könnte wie folgt aussehen:

  'translations' => [
    'change' => 'Ändern',
    'confirm' => 'OK',
    'copy' => 'Kopieren',
    'create' => 'Erstellen'
  ]

Und die gleichen Übersetzungen im YAML-Format:

change: Ändern
confirm: OK
copy: Kopieren
create: Erstellen

Sollte der $key nicht gefunden werden, wird der optionale Parameter $fallback angezeigt.

Die Funktion kann im PHP-Programmcode von Kirby verwendet werden und könnte wie folgt aussehen:

...
<?= t('copy','Copy') ?>
...
<?= t("create") ?>
...

Beide Varianten sind zulässig. Wenn die Zielsprache Deutsch sein soll, wird im ersten Fall an der Stelle "Kopieren" angezeigt. Falls es den Schlüssel copy in der Sprachdatei für Deutsch nicht gibt, wird "Copy" angezeigt. Im zweiten Fall wird entweder "Erstellen" oder gar nichts angezeigt, denn wenn es weder eine Übersetzung noch einen Fallback gibt, kann auch nichts ausgegeben werden.

Vor dem Go-Live einer mit Kirby erstellten Website muss man also dafür sorgen, dass alle Schlüssel aller verwendeten t() Funktionen erfasst und übersetzt wurden.

Lösung

Zunächst ist es notwendig, alle Dateien, in denen die Funktion t() verwendet werden kann, zu finden. Diese Dateien können bei Kirby sogenannte Templates, Snippets oder Controller sein, die in den entsprechenden Unterverzeichnissen des site Verzeichnisses gespeichert sind.

Hierfür kann das Programm find verwendet werden:

$ find controllers snippets templates -type f

In dieser Weise eingegeben, muss zum Zeitpunkt der Ausführung das site Verzeichnis das Arbeitsverzeichnis sein. find findet rekursiv in den angegebenen Verzeichnissen alle Verzeichniseinträge und grenzt die Ausgabe auf Dateien ein (-type f).

Die Ausgabe ist eine Liste von Dateinamen, die – verkürzt – so aussehen könnte:

controllers/blog.php
snippets/blog/excerpt.php
snippets/blog/header.php
templates/blog-article.php
templates/blog.php

Nun muss geprüft werden, in welcher dieser Dateien tatsächlich die Funktion t() verwendet wird.

Dazu kann das Programm grep verwendet werden. grep sucht zeilenweise nach dem Vorkommen eines Musters in einer Datei oder im Eingabestrom. Eine einfache Umleitung des Ausgabestroms von find zum Eingabestrom von grep würde allerdings dazu führen, dass grep in den Pfad- und Dateinamen nach dem Muster sucht, es soll aber innerhalb der Dateien suchen.

Man könnte die Ausgabe von find in einer Schleife abarbeiten, aber es ist einfacher, sie dem Programm xargs zu übergeben, das aus den Elementen im Eingabestrom und einem anderen Programm als Parameter neue Befehle erstellt und ausführt.

Dazu muss die Ausgabe der Dateiliste von find an das Programm xargs weitergereicht werden und dieses für jede Datei das Programm grep ausführen:

$ find controllers snippets templates -type f | xargs -r grep MUSTER

Der Parameter -r bei xargs bewirkt, dass kein Befehl erstellt und ausgeführt wird, falls ein Element im Eingabestrom leer ist.

Allerdings muss noch folgendes beachtet werden: Sollten die Dateinamen, die von find geliefert werden, Sonderzeichen wie z.B. Leerzeichen enthalten, wird xargs nicht funktionieren, da es Leerzeichen (und Zeilenvorschübe) als Trenner zwischen mehreren Elementen im Eingabestrom interpretiert.

Glücklicherweise kann man find dazu bringen, die einzelnen Elemente seiner Ausgabe mit einem Null-Zeichen (NUL) zu trennen anstatt mit einem Zeilenvorschub. Dazu hängt man die Option -print0 an den find-Befehl an und teilt xargs über den zusätzlichen Parameter -0 mit, dass die Elemente im Eingabestrom mit einem Null-Zeichen voneinander getrennt sind:

$ find controllers snippets templates -type f -print0 | xargs -0r grep MUSTER

Fehlt noch das Muster, nach dem grep in den Dateien suchen soll.

Dieses Muster kann aus beliebigen Zeichen oder einem regulären Ausdruck bestehen. Obwohl in diesem Artikel nicht weiter auf die Details regulärer Ausdrücke eingegangen werden kann, werden hier und im nachfolgenden vor allem sogenannte erweiterte reguläre Ausdrücke verwendet.

grep wird mit der Option -E angewiesen, nach einem erweiterten regulären Ausdruck zu suchen. Wenn grep eine Zeichenkette findet, auf die dieser Ausdruck passt, gibt es normalerweise die ganze Zeile, in der die Zeichenkette vorkommt, als Ergebnis aus. Außerdem wird die betreffende Zeile auch dann nur einmal ausgegeben, wenn die Zeichenkette mehrfach in der Zeile vorkommt.

Beides ist nicht erwünscht. Gut, dass sich grep über den optionalen Parameter -o anweisen lässt, zum einen nur die Zeichenkette, die dem Muster entspricht, auszugeben und zum anderen bei mehreren Zeichenketten innerhalb einer Zeile alle Vorkommnisse einzeln auszugeben.

Außerdem lässt sich die Ausgabe des Dateinamens unterdrücken (Option -h).

Die Fortsetzung des Befehls lautet also (ab hier aus Platzgründen auf mehrere Eingabezeilen gesplittet):

$ find controllers snippets templates -type f -print0 \
 | xargs -0r grep -Eoh "\bt\([^\)]+\)"

Der reguläre Ausdruck in Anführungszeichen passt auf alle Vorkommnisse von t(, denen eine Wortgrenze vorangeht und denen ein oder mehrere beliebige Zeichen folgen, mit Ausnahme von ). ) beschließt das Muster. Über die Wortgrenze wird gesteuert, dass Funktionen mit dem Namen t() gefunden werden, nicht jedoch Funktionen wie z.B. test().

Die Ausgabe dieses Befehls ist eine Liste der verwendeten t() Funktion:

t( 'change', 'Change' )
t("create","Create")
t( 'copy' )
t( 'change' , 'Change' )
t( "create", 'Build' )
t( 'copy', 'Copy' )

Wie man sieht, ist es möglich, dass mehrere Funktionsaufrufe mit dem gleichen Schlüssel gefunden werden, was nicht weiter wundert, denn die t() Funktion kann ja mehrfach mit dem gleichen Schlüssel verwendet werden, wenn jedesmal der gleiche Text angezeigt werden soll.

Außerdem sieht man, dass in manchen Funktionsaufrufen die Parameter mit doppelten Anführungszeichen angegeben werden, anstatt mit einfachen und die Verwendung von Leerzeichen ist uneinheitlich. Ein Funktionsaufruf in diesem Beispiel enthält keine Angabe eines Fallback.

Das nächste Ziel ist es, die Ausgabe so zu gestalten, dass sie entweder als PHP-Array oder als YAML weiterverarbeitet werden kann.

Im folgenden werden daher zunächst die Anführungszeichen vereinheitlicht, indem alle Vorkommen eines doppelten Anführungszeichen " mit einem einfachen Anführungszeichen ' ersetzt werden.

Dafür kann das Programm tr eingesetzt werden, das genau für diese Aufgabe gemacht ist:

$ find controllers snippets templates -type f -print0 \
 | xargs -0r grep -Eoh "\bt\([^\)]+\)" \
 | tr \" \'

Die Ausgabe ist:

t( 'change', 'Change' )
t('create','Create')
t( 'copy' )
t( 'change' , 'Change' )
t( 'create', 'Build' )
t( 'copy', 'Copy' )

Eine weitere Vereinheitlichung ist sinnvoll, nämlich dass Funktionsaufrufe ohne Fallback dennoch mit einem zweiten Parameter geschrieben werden, der dann aus einem leeren String besteht.

So sollte zum Beispiel die Zeile t( 'copy' ) als t( 'copy','') geschrieben werden.

Dies ist eine gute Aufgabe für das Programm sed, einem Editor zur Bearbeitung eines Eingabestroms mit dem sehr viele Operationen durchgeführt werden können:

$ find controllers snippets templates -type f -print0 \
 | xargs -0r grep -Eoh "\bt\([^\)]+\)" \
 | tr \" \' \
 | sed -e "/,/! s/'\s*)/','')/"

Mit dem Parameter -e wird sed ein Skript hinzugefügt, das den Eingabestrom bearbeitet. In diesem Fall besteht das Skript nur aus zwei Befehlen: Der erste /,/! ist eine Bedingung, nämlich dass in der Zeile kein Komma vorkommen darf. Nur dann wird der zweite Befehl s/'\s*)/','')/ ausgeführt.

Dieser Befehl ersetzt eine Zeichenkette, die auf einen regulären Ausdruck passt, mit einem Text (allgemein: s/regex/ersetzung/). In diesem Fall passt das Muster '\s*) auf ein einzelnes Anführungszeichen gefolgt von einer schließenden Klammer zwischen denen sich noch beliebige nicht-druckbare Zeichen befinden dürfen. Der Ersetzungstext ','') fügt das einzelne Anführungszeichen und die schließende Klammer wieder ein, platziert dazwischen aber noch ein Komma und zwei einzelne Anführungszeichen.

Die Ausgabe ist jetzt:

t( 'change', 'Change' )
t('create','Create')
t( 'copy','')
t( 'change' , 'Change' )
t( 'create', 'Build' )
t( 'copy', 'Copy' )

Die gefundenen Übersetzungsfunktionen sind nun soweit vereinheitlicht, dass im ersten String-Parameter der Schlüssel und im zweiten String-Parameter der Fallback steht.

Mit einem weiteren Aufruf von sed können die beiden Strings isoliert und beliebig anders ausgegeben werden, z.B. so, wie sie zur Verwendung in einem PHP-Array benötigt werden.

Dafür muss sed mit -E ebenfalls ein erweiterter regulärer Ausdruck ermöglicht werden. Die speziellen Zeichen \1 und \2 fügen den Text, der mit dem ersten bzw. zweiten Klammerpaar im Suchmuster gefunden wird, wieder ein:

$ find controllers snippets templates -type f -print0 \
 | xargs -0r grep -Eoh "\bt\([^\)]+\)" \
 | tr \" \' \
 | sed -e "/,/! s/'\s*)/','')/" \
 | sed -Ee "s/.*'(.*)'\s*,\s*'(.*)'.*/'\1' => '\2',/"

'change' => 'Change',
'create' => 'Create',
'copy' => '',
'change' => 'Change',
'create' => 'Build',
'copy' => 'Copy',

Die Ausgabe kann direkt in ein PHP-Array eingefügt werden.

Sollte eine andere Ausgabe erwünscht sein, z.B. im oben bereits erwähnten YAML-Format, muss der Ersetzungsteil im letzten Aufruf von sed \1: \2 lauten, anstatt '\1' => '\2',.

Schön wäre es noch, wenn man das Ergebnis sortiert ausgeben könnte. Bei sehr vielen Übersetzungen würden dann die Problemfälle (z.B. verschiedene Fallbacks für den gleichen Schlüssel) eher auffallen.

Das ist natürlich kein Problem, man muss das Ergebnis nur noch durch das Programm sort leiten – hier jetzt im YAML-Format:

$ find controllers snippets templates -type f -print0 \
 | xargs -0r grep -Eoh "\bt\([^\)]+\)" \
 | tr \" \' \
 | sed -e "/,/! s/'\s*)/','')/" \
 | sed -Ee "s/.*'(.*)'\s*,\s*'(.*)'.*/\1: \2/" \
 | sort

change: Change
change: Change
copy: 
copy: Copy
create: Build
create: Create

Die Sortierung bietet auch die Möglichkeit, identische aufeinanderfolgende Zeilen zu entfernen. Diese repräsentieren ja identische Aufrufe der t() Funktion an verschiedenen Stellen des Kirby Programmcodes und brauchen in der Übersetzungsdatei nur einmal aufgeführt werden.

Das ist entweder mit dem Parameter -u des sort Programms möglich, oder eine weitere Pipe durch das Programm uniq:

$ find controllers snippets templates -type f -print0 \
 | xargs -0r grep -Eoh "\bt\([^\)]+\)" \
 | tr \" \' \
 | sed -e "/,/! s/'\s*)/','')/" \
 | sed -Ee "s/.*'(.*)'\s*,\s*'(.*)'.*/\1: \2/" \
 | sort \
 | uniq

change: Change
copy: 
copy: Copy
create: Build
create: Create

Fazit

Mit der geschickten Aneinanderreihung verschiedener kleiner bereits vorhandener Programme ist es möglich, komplexe Aufgaben auch ohne Programmierung eines neuen Tools, das nur diesem einen Zweck dienen würde, zu lösen.

Jedes der verwendeten Programme löst nur eine bestimmte Teilaufgabe und sollte sich im Laufe der Zeit herausstellen, dass andere Programme eine zugedachte Teilaufgabe besser lösen können, wäre es ohne Schwierigkeiten möglich, diese zu ersetzen.

Voraussetzung ist allerdings die Verwendung einer universellen Schnittstelle, wie in diesem Fall die Unix-Pipe.

Top