english | deutsch
robertw.io

Textdaten in der Shell verarbeiten? Mach das doch mal effizient und robust!

Vergiss awk und perl! Gerade, wenn es um das Verarbeiten einfacher Textdaten oder Textdateien geht, ist es oft effizienter, Standard-Mechanismen der Bash zu verwenden.

Nimm dir ein paar Minuten Zeit - ich habe dir hier dafür die wichtigsten Mechanismen zusammengestellt und erklärt. Mehr brauchst Du nicht, um in eigenen Shell-Skripten robust und effizient Textdaten zu verarbeiten.

Dir ist egal, warum das alles funktioniert? Du willst einfach schnelle Ergebnisse? Dann überspringe den ersten Teil und schaue dir direkt die Beispiele und Performance-Tests an.

Die Beispiele kannst du direkt im Browser ausprobieren und verändern.

Für die Ungeduldigen: Beispiele und Performance-Tests

Daten einlesen mit read

Zuerst müssen die Daten natürlich irgendwie eingelesen - also in Variablen zwischengespeichert - werden. Dafür bietet sich das in der Bash eingebaute Kommando “read” an.

read? Was mache ich damit?

Das read-Kommando ist vordergründig dafür gedacht, Eingaben vom Benutzer abzufragen. Wenn du in einem Skript den Anwender etwas fragen möchtest, rufst du einfach nur read auf - danach befindet sich die Benutzereingabe in der Variablen REPLY.

~$ read
Hallo
~$ echo $REPLY
Hallo

Soll die Variable anders heißen, gib einen Variablen-Namen vor:

~$ read -p "Wie ist Dein Name? " NAME
Wie ist Dein Name? Robert
~$ echo $NAME
Robert

Hier habe ich noch mit der Kommandozeilen-Option -p "Wie ist Dein Name? " dafür gesorgt, dass der Eingabeprompt für den Benutzer nicht ganz so spartanisch (sprich: leer) aussieht. Der letzte Parameter NAME legt den zu verwendenden Variablennamen fest.

read in der Schleife

Eigentlich wollen wir ja hier nicht mit dem Anwender kommunizieren, sondern komplette Textdateien oder Dateiströme einlesen und verarbeiten. Dafür können wir read in einer while-Schleife verwenden.

Dass das funktioniert, liegt an zwei Eigenschaften des read-Kommandos:

1. read liest bis zum Ende der Zeile

read liest bei einem Aufruf immer alle Zeichen “bis der Benutzer Enter drückt”. Oder anders ausgedrückt: read liest so lange, bis auf dem Eingabe-Datenstrom ein Zeilenumbruch kommt. (Dieses Verhalten von read kann man mit Kommandozeilenparametern beeinflussen - das wollen wir hier aber nicht.)

2. read liefert bei Erfolg einen Returncode von “0”

read liefert einen Returncode von “0”, wenn eine Zeile eingelesen werden konnte und “1”, wenn es keine Daten zu lesen gab.

… ähm, ja …

Während Punk 1 leicht zu durchschauen ist, möchte ich Punkt 2 etwas genauer erklären: Wie du wahrscheinlich weist, liefert jedes Kommando auf der Shell zum Abschluss einen Return-Code, an dem man in der Regel den Erfolg des Kommandos ablesen kann. Dabei steht “0” für erfolgreich, alle anderen Werte stehen für einen Fehler.

Der Returncode eines Kommandos wird nicht automatisch angezeigt, sondern muss über die besondere Variable “?” ausgewertet werden:

~$ read ANTWORT
Hallo
~$ echo $?
0

Hier hat read den Return-Code “0” zurückgegeben - vermeldet also Erfolg.

… bis zum bitteren Ende …

Wann signalisiert read jetzt aber einen Fehler? Ganz einfach: wenn es nichts (mehr) zu lesen gab. Dabei ist das Einlesen einer leeren Zeichenkette aber natürlich auch ein Erfolg (nämlich der, dass erfolgreich die leere Zeichenkette “” eingelesen wurde). read gibt erst dann einen “Fehler” zurück, wenn tatsächlich überhaupt nichts eingelesen werden konnte.

Innerhalb eines Datenstroms wird der Zustand “Hier ist der Datenstrom zu Ende” durch die “end-of-file” Markierung (EOF) gekennzeichnet. In der Konsole wird der selbe Mechanismus verwendet, allerdings heißt die verwendete Markierung hier “end-of-terminal” (EOT).

Selber kannst du auch eine EOF (bzw. EOT) Markierung senden, indem du die Tastenkombination <Strg>+d drückst:

~$ read INPUT
			     <-- ENTER gedrückt
~$ echo $?
0
~$ read INPUT
			     <-- <Strg>+d gedrückt
~$ echo $?
1

… los jetzt …

Wenn wir die beiden Eigenschaften von read (zeilenweise Lesen, Returncode != 0 bei Ende der Daten) miteinander verknüpfen, lassen sich in einer Schleife alle Zeilen einer Eingabe einlesen:

~$ while read ZEILE; do
> echo "Eingelesen: $ZEILE"
> done
Hallo               <-- Eingabe
eingelesen: Hallo
Welt                <-- Eingabe
eingelesen: Welt
                    <-- <Strg>+d
~$

Zur Erinnerung: Die while-Schleife führt vor jedem Schleifendurchlauf das Kommando hinter dem Schlüsselwort while aus (hier read ZEILE). Und nur falls dieses Kommando erfolgreich war (Returncode “0”), werden die Kommandos zwischen “do” und “done” ausgeführt.

Auslesen von Dateien und Datenströmen

Jetzt wollen wir die Eingaben ja aber nicht selber eintippen, sondern wir wollen bereits vorhandene Daten einlesen. Ich habe unten drei Beispiele zusammengestellt, wie du das bewerkstelligen könntest. Dabei nutze ich die Standard-Mechanismen der Bash, um Eingabedaten aus den Ausgaben anderer Kommandos oder aus vorhandenen Dateien zu beziehen (Ein-/Ausgabeumleitung):

Beispiel 1: Verarbeiten der Ausgabe des Kommandos ls

~$ ls | while read ZEILE ; do echo $ZEILE; done
Zeile: a.txt
Zeile: hallo.sh
Zeile: skript.log
~$

Beispiel 2: Verarbeiten der Ausgabe des Kommandos “cat hallo.sh”

~$ cat hallo.sh | while read ZEILE ; do echo $ZEILE; done
#!/bin/bash
echo "nur ein Beispiel-Skript"
~$

Beispiel 3: Verarbeiten des Inhaltes der Datei “hallo.sh”

~$ while read ZEILE ; do echo $ZEILE; done < hallo.sh
#!/bin/bash
echo "nur ein Beispiel-Skript"
~$

Verarbeiten der eingelesenen Daten

Das Einlesen der Daten ist nur der erste Schritt. Im zweiten Schritt geht es um die Weiterverarbeitung. Das läuft in der Regel darauf hinaus, einzelne Elemente aus einer Zeile herauszulösen. Als Beispiel nehmen wir hier an, dass wir die Daten der Datei /etc/passwd verarbeiten sollen. Eine Zeile der /etc/passwd sieht dann in etwa so aus:

robert:x:1000:1000:Robert Wohlfahrt:/home/robert:/bin/bash

Angenommen, wir interessieren uns für 2 Datenfelder aus dieser Zeile: den Benutzernamen (Feld 1) und das Home-Verzeichnis des Benutzers (Feld 6).

Diesmal habe ich vier verschiedene Ansätze als Beispiel zusammengestellt.

Spoiler: Die ersten beiden Varianten funktionieren gut, sollen hier aber tatsächlich nur als schlechtes Beispiel dienen :-)

1. awk - mit Kanonen auf Spatzen

Obwohl es hierfür völlig überdimensioniert ist, wird einem für diese Art von Problemen gerne das Werkzeug awk vorgeschlagen. Das hast du sicherlich so auch schon irgendwo gesehen …

~$ ZEILE="robert:x:1000:1000:Robert Wohlfahrt:/home/robert:/bin/bash"
~$ echo $ZEILE | awk -F : '{print $1}'
robert
~$ echo $ZEILE | awk -F : '{print $6}'
/home/robert

Mit -F : sagen wir awk, dass wir die Zeile gerne am Trennzeichen “:” aufgetrennt haben möchten. Mit den awk-Variablen $1 und $6 können wir dann direkt auf die einzelnen Felder zugreifen.

Wenn du die einzelnen Felder zur Weiterverarbeitung in Variablen ablegen möchtest, könntest du wie folgt vorgehen:

USERNAME=$(echo $ZEILE | awk -F : '{print $1}')
HOMEDIR=$(echo $ZEILE | awk -F : '{print $6}')

2. cut - die etwas kleinere Kanone

Wenn es tatsächlich nur um das Auseinanderschneiden von Feldern anhand von Trennzeichen geht, arbeitet cut möglicherweise etwas effizienter als awk.

~$ ZEILE="robert:x:1000:1000:Robert Wohlfahrt:/home/robert:/bin/bash"
~$ echo $ZEILE | cut -d : -f 1
robert
~$ echo $ZEILE | cut -d : -f 6
/home/robert

Bei cut gibt -d : das Trennzeichen vor, mit -f n wird das jeweils n-te Feld spezifiziert. In Variablen landen die einzelnen Werte dann wieder wie gehabt:

USERNAME=$(echo $ZEILE | cut -d : -f 1)
HOMEDIR=$(echo $ZEILE | cut -d : -f 1)

3. String-Operationen der Bash - nicht sehr bekannt aber mächtig

Weniger bekannt als awk und cut ist die Möglichkeit, direkt in der Bash Zeichenketten zu verabeiten.

ein Beispiel:

~$ ZEILE="robert:x:1000:1000:Robert Wohlfahrt:/home/robert:/bin/bash"
~$ echo ${ZEILE%%:*}
robert

Der Ausdruck ${ZEILE%%:*} sagt der Bash: “nimm den Inhalt der Variablen ZEILE, und entferne die längstmögliche Zeichenkette von rechts, die auf das Suchmusster :* passt.

Folgende Operatoren stehen bei diesem Mechanismus zur Verfügung:

  • ## –> entfernt die längstmögliche Zeichenkette von links
  • # –> entfernt die kürzest mögliche Zeichenkette von links
  • %% –> entfernt die längstmögliche Zeichenkette von rechts
  • % –> entfernt die kürzest mögliche Zeichenkette von rechts

Mit diesem Mechanismus könntest du die Felder 1 und 6 also wie folgt extrahieren:

~$ BENUTZERNAME=${ZEILE%%:*}  <-- alles hinter dem ersten: entfernen
~$ echo $BENUTZERNAME
robert
~$ TMP=${ZEILE%:*}            <-- erst nur das letzte Feld entfernen
~$ echo $TMP
robert:x:1000:1000:Robert Wohlfahrt:/home/robert
~$ HOMEDIR=${TMP##*:}         <-- dann alle Felder von links abschneiden
~$ echo $HOMEDIR
/home/robert

4. read - Auftrennen schon beim Einlesen

Die vierte Variante, die ich hier zeigen möchte, ist die direkte Verwendung von read: Das Kommando kann bereits beim Einlesen der Daten die Eingabezeile auftrennen und auf separate Variablen verteilen.

Als Trennzeichen für das Auftrennen der Eingabe verwendet read dabei den Inhalt der Variablen IFS (internal field separator), die per default ein Leerzeichen, einen Tabulator und einen Zeilenumbruch beinhaltet.

Wenn wir diese Variable jetzt temporär (nur für den Aufruf von read) auf “:” setzen, trennt read die Zeile für uns vollkommen selbstständig auf:

~$ IFS=: read BENUTZERNAME A B C D HOMEDIR E
robert:x:1000:1000:Robert Wohlfahrt:/home/robert:/bin/bash
~$ echo $BENUTZERNAME
robert
~$ echo $HOMEDIR
/home/robert

Und jetzt alles zusammen

Jetzt wo wir wissen, wie wir mit read Datenströme einlesen und diese dann weiterverarbeiten können, wird es Zeit für ein paar komplette Beispiel-Skripte. Alle Beispiele kannst du direkt im Browser bei codingground von tutorialspoint ausprobieren. Den entsprechenden Link findest du direkt unter jedem Listing.

read1.sh … die Variante mit awk …

#!/bin/bash
while read ZEILE; do
    BENUTZERNAME=$(echo $ZEILE | awk -F : '{print $1}')
    HOMEDIR=$(echo $ZEILE | awk -F : '{print $6}')
    echo "$BENUTZERNAME -> $HOMEDIR"
done < /etc/passwd

Beispiel online

read2.sh … die kleinere Kanone cut …

#!/bin/bash
while read ZEILE; do
    BENUTZERNAME=$(echo $ZEILE | cut -d : -f 1)
    HOMEDIR=$(echo $ZEILE | cut -d : -f 6)
    echo "$BENUTZERNAME -> $HOMEDIR"
done < /etc/passwd

Beispiel online

read3.sh … diese ominöse ${x%%*}-Syntax :-) …

#!/bin/bash
while read ZEILE; do
    BENUTZERNAME=${ZEILE%%:*}
    TMP=${ZEILE%:*}
    HOMEDIR=${TMP##*:}
    echo "$BENUTZERNAME -> $HOMEDIR"
done < /etc/passwd

Beispiel online

read4.sh … read macht die ganze Arbeit …

#!/bin/bash
while IFS=: read BENUTZERNAME A B C D HOMEDIR E; do
    echo "$BENUTZERNAME -> $HOMEDIR"
done < /etc/passwd

» Beispiel online

Effizienz

Und wie sieht es mit der Effizienz (bzw. Performance) der vier hier dargestellten Varianten aus? Eine einfache Messung mithilfe des time-Kommandos brachte auf einem kleinen Test-System folgende Ergebnisse:

  • Variante 1 - read und awk: 2,849s
  • Variante 2 - read und cut: 2,869s
  • Variante 3 - read und String-Operationen: 0,001s
  • Variante 4 - read und IFS: 0.001s

Der Unterschied zwischen den ersten beiden und den letzten beiden Varianten ist mehr als deutlich.

–> zum Nachvollziehen im codingground: Performance-Tests.

Woran liegt das? Ganz einfach: das Starten externer Prozesse - das sogenannte forking - kostet in einem Shell-Skript verhältnismäßig viel Zeit. Und bei Verwendung in Schleifen summiert sich dieser Zeitaufwand.

Als kleine Regel für performante Shell-Skripte könnte man sagen: “Starte so wenig wie möglich externe Prozesse”.

Aber wie überall, hat diese Regel auch hier zwei Seiten: Ein Skript muss ja nicht nur möglichst performant sein, sondern vor allem auch übersichtlich und einfach verständlich. Und was für den Einen verständlich und offensichtlich erscheint, sieht für den Anderen aus wie die Fragmente einer längst vergessenen Sprache ;)

Fazit

Wenn sich Daten einfach an einem einzelnen Trennzeichen auseinandernehmen lassen, verwende ich in der Regel die Variante, bei der read die Daten anhand des IFS auftrennt. Diese Variante ist robust, performant und - wie ich finde - sehr gut im Skript nachzuvollziehen.

Ansonsten versuche ich mit den String-Operationen der Bash zum Erfolg zu kommen (Variante 4). Diese Variante ist flexibel und trotzdem performant. Allerdings nicht immer einfach nachzuvollziehen.

Und falls es dann doch zu komplex wird, weiche ich auf eigenständige awk-Skripte zur Datenaufbereitung oder auf andere Skriptsprachen (z.B. python) aus.

Du hast Fragen zum Thema? Fordere mich heraus! Frag Robert!