Wir schreiben Geschichte (Linux-Magazin, Dezember 2020)

Wie ging nochmal das ellenlange Kommando zum Verbinden mit dem Datenbank-Server? Shell-Power-User kennen dieses Gedächtnisproblem und haben sich über die Jahre Tricks beigebracht, um abgesetzte Kommandozeilen zu wiederholen oder modifiziert erneut abzuschicken. So wiederholt zum Beispiel die Zeichenfolge !! das zuletzt abgesetzte Kommando (praktisch, um ein "sudo" voranzustellen), und mit Ctrl-R lassen sich weiter zurückliegende Sequenzen anhand for Suchmustern aufspüren und erneut verwenden. Dass die Bash-Shell sich diese Historie merkt, zeigt das Kommando history, das die letzten getippten Befehlssequenzen auflistet (Abbildung 1).

Abbildung 1: Das Kommando history zeigt die letzten abgesetzten Befehle an.

Wer schon einmal einen Blick in die Datei .bash_history im User-Verzeichnis geworfen hat, kennt das Geheimnis, dort reiht die Bash-Shell einfach jedes abgeschickte Kommando auf, egal, ob es erfolgreich war oder mit einem Fehler abgebrochen ist. Fragt der User nach zuletzt geschickten Kommandos, sieht die Bash-Shell einfach dort nach (Abbildung 2).

Abbildung 2: In der Datei .bash_history schreibt die Bash-Shell mit, wann welches Kommando abgesetzt wurde.

Haun' Zeitstempel drauf!

Mit einer kleinen Änderung protokolliert die Bash-Shell nicht nur das Kommando, sondern fügt sogar noch Datum und Uhrzeit des eingegebenen Kommandos hinzu. Dazu muss der User (am besten in der Init-Datei .bash_profile) die folgende Environment-Variable setzen:

    export HISTTIMEFORMAT="%F %T: "

Fürderhin wird die Bash-Shell in der Log-Datei .bash_history jedem aufgeschriebenen Kommando eine Kommentarzeile mit dem Unix-Epoch-Stempel voranstellen, und das Kommando history stellt jedem Kommando in der angezeigten Liste eine menschenlesbare Datums- und Zeitangabe voran (Abbildung 3).

Abbildung 3: Ist HISTDATEFORMAT gesetzt, gibt das Kommando history nicht nur zuletzt abgesetzte Kommandos aus, sondern jeweils auch den Zeitstempel ihrer Ausführung.

So eine Datensammlung regt natürlich zum Auswerten an, wie wäre es, die Wochentage zu ermitteln, an denen der User am fleißigsten getippt hat? Oder eine Aufstellung der am häufigsten getippten Kommandos, um den Tippaufwand von nun an mittels Kürzeln oder Shell-Skripts zu reduzieren?

Callback als Abstraktion

Listing 1 definiert hierzu die Funktion histWalk(), die die globale Bash-History-Logatei des Users in dessen Home-Verzeichnis findet, sie zeilenweise durchforstet, und zu jedem Befehlseintrag eine ihr übergebene Callback-Funktion anspringt. So können weitere Auswertungsprogramme eigene Callbacks definieren, bekommen die History-Daten mit deren Zeitstempeln, ohne sich um die Details des History-Logformats kümmern zu müssen.

Die in Zeile 19 mit os.Open() zum Lesen geöffnete Logdatei liest der in Zeile 24 angelegte Scanner zeilenweise ein. Dieser schnappt sich das über die File-Struktur angebotene Reader-Interface der geöffneten Datei und saugt bei jedem Scan()-Aufruf eine weitere Zeile ein, die scanner.Text() anschließend als String zurückgibt.

Beginnt die Zeile mit einem Kommentarzeichen, handelt es sich um einen Zeitstempel für später nachfolgende Kommandos, also legt Listing 1 den Sekundenwert in der Variablen timestamp ab und schickt den Scanner in die nächste Runde. Steht am anfang kein Hashzeichen, handelt es sich um eine Kommandozeile, und das Programm springt in den else-Zweig ab Zeile 34 und ruft die vom User hereingereichte Callback-Funktion auf. Als Parameter übergibt es ihr den von vorher gespeicherten Zeitstempel, sowie das aktuell eingelesene Kommando.

Listing 1: histWalk.go

    01 package main
    02 
    03 import (
    04   "bufio"
    05   "os"
    06   "os/user"
    07   "path/filepath"
    08   "strconv"
    09 )
    10 
    11 func histWalk(cb func(int64, string) error) error {
    12   usr, err := user.Current()
    13   if err != nil {
    14     panic(err)
    15   }
    16   home := usr.HomeDir
    17   histfile := filepath.Join(home, ".bash_history")
    18 
    19   f, err := os.Open(histfile)
    20   if err != nil {
    21     panic(err)
    22   }
    23 
    24   scanner := bufio.NewScanner(f)
    25   var timestamp int64
    26   for scanner.Scan() {
    27     line := scanner.Text()
    28     if line[0] == '#' {
    29       timestamp, err = strconv.ParseInt(line[1:], 10, 64)
    30       if err != nil {
    31         panic(err)
    32       }
    33     } else {
    34       err := cb(timestamp, line)
    35       if err != nil {
    36         return err
    37       }
    38     }
    39   }
    40   return nil
    41 }

Volle Bürgerrechte für Funktionen

In Go genießen Funktionen volle Bürgerrechte, können also beliebig Variablen zugewiesen oder auf Parameterlisten an andere Funktionen weitergegeben werden. So können Anwenderprogramme auf die von Listing 1 angebotene Funktion histWalk() zurückgreifen, ihr eine ganz an ihre speziellen Bedürfnisse angepasste Callback-Funktion mitgeben, und sie im lokalen Scope existierende Datenstrukturen mit den Ergebnissen füllen lassen.

Dank Gos strenger Typisierung ist allerdings die Umwandlung des als String eingelesenen Sekundenwerts in einen Integerwert ein richtiger Akt. Die Funktion ParseInt() aus dem Standardpaket strconv nimmt als Parameter den zu parsenden String entgegen (line[1:] schneidet den ersten Buchstaben, also das Kommentarzeichen ab), sowie die Basis der Integerzahl (10 für eine Dezimalzahl) und die maximale Anzahl der Bits (64). Zurück kommt ein Wert vom Typ int64, was wichtig ist, damit nicht am 19. Januar 2038 die Lichter ausgehen, weil dann der Sekundenvorrat an 32-Bit Integern erschöpft ist.

Listing 2: dow.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "time"
    06 )
    07 
    08 func main() {
    09   var countByDoW [7]int
    10 
    11   err := histWalk(func(stamp int64, line string) error {
    12     dt := time.Unix(stamp, 0)
    13     countByDoW[int(dt.Weekday())]++
    14     return nil
    15   })
    16 
    17   if err != nil {
    18     panic(err)
    19   }
    20 
    21   for dow := 0; dow < len(countByDoW); dow++ {
    22     dowStr := time.Weekday(dow).String()
    23     fmt.Printf("%s: %v\n", dowStr, countByDoW[dow])
    24   }
    25 }

Eine Anwendung der Walker-Funktion zeigt Listing 2 mit einer Analyse der Tippaktivitäten des Users, aufgedröselt über die Tage der Woche. Da der in den Callback hereingereichte Zeitstempel stamp als Integer vorliegt, konvertiert ihn die in Zeile 12 aufgerufene Standardfunktion time.Unix() in das Go-interne time.Time-Format für Zeit- und Datumsangaben. Die mit dem konvertierten Wert aufgerufene Weekday()-Funktion ermittelt den Wochentag des gegebenen Datums. Das darumgewickelte int() konvertiert den als Struktur vorliegenden Wert in einen Integerwert zwischen 0 (Sonntag) und 6 (Samstag) um.

Abbildung 4: Listing 2 ist schnell kompiliert und zeigt die Tippaktivität über die Wochentage verteilt.

Wochentage von 0 bis 6

Der eingangs in Zeile 9 angelegte Array der Länge 7 stellt Integerwerte an den Positionen 0 bis 6 für die einzelnen Wochentage bereit, die die Callbackfunktion bei jedem eintrudelnden Kommando mit Zeitstempel an der entsprechenden Stelle um Eins hochzählt. Eine Besonderheit der Inline definierten Callback-Funktion ist, dass sie Zugriff auf die vorab definierte Variable countByDoW hat, die auch nach der Analysephase, weiter unten in der For-Schleife ab Zeile 21, noch Bestand hat.

Die Schleife iteriert über die Indexpositionen 0 bis 6 und gräbt die Zähler für die einzelnen Wochentage aus. Wie lässt sich nun ein Integerwert wieder in einen Wochentag-String umwandeln? Im Paket time existiert hierzu der Datentyp Weekday, der Integer-Konstanten von 0 bis 6 definiert, entsprechend der Wochentage Sonntag bis Samstag. Des weiteren steht dort eine Funktion String(), die die Konstantenwerte in englische Wochentags-Strings umwandelt. Zeile 22 fieselt daraus den Wochentag zusammen, und Zeile 23 bleibt nur noch, diese mit den aufkumulierten Zählern auszugeben. Abbildung 4 zeigt die Compilation der Listings zu einem Binary, und dessen Aufruf, der offenbart, dass der User an Montagen offenbar am meisten tippt.

Schlager der Woche

Eine weitere Auswertung der History-Daten zeigt Listing 3 mit den drei am meisten genutzten Kommandos. Als Datenstruktur zum Zählen gleicher Kommandos legt Zeile 9 eine Hash-Map an, die Kommandos als Strings jeweils einem Integer-Zähler zuweist. Die Callback-Funktion ab Zeile 11 hat Zugriff auf die Datenstruktur und erhält von histWalk() zu jeder vorbeifliegenden History-Zeile sowohl einen Zeitstempel als auch den String mit der ausgeführten Kommandozeile.

Listing 3: top3.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "sort"
    06 )
    07 
    08 func main() {
    09   cmds := map[string]int{}
    10 
    11   err := histWalk(func(stamp int64, line string) error {
    12     cmds[line]++
    13     return nil
    14   })
    15 
    16   if err != nil {
    17     panic(err)
    18   }
    19 
    20   type kv struct {
    21     Key   string
    22     Value int
    23   }
    24 
    25   kvs := []kv{}
    26   for k, v := range cmds {
    27     kvs = append(kvs, kv{k, v})
    28   }
    29 
    30   sort.Slice(kvs, func(i, j int) bool {
    31     return kvs[i].Value > kvs[j].Value
    32   })
    33 
    34   for i := 0; i < 3; i++ {
    35     fmt.Printf("%s (%dx)\n", kvs[i].Key, kvs[i].Value)
    36   }
    37 }

Mehr Arbeit durch Typstrenge

Am Ende des Durchlaufs stehen die Gewinner mit den höchsten Zählerwerten fest, doch wie die Top-3 aus dem Gesamtfeld herausfieseln? Skriptsprachen bieten wegen schwacher Typisierung deutlich komfortablere Methoden, um eine Hashmap nach den in ihr enthaltenen Werten zu sortieren. Go mit seinem strengen Typsystem hingegen verlangt einige Klimmzüge. Als erstes legt Zeile 20 eine neue Datenstruktur an, eine Kombination aus einem Key genannten String und einem Value genannten Integer-Wert.

Dann baut die For-Schleife ab Zeile 26 aus der Hashmap einen sortierbaren Array mit den Schlüsseln und Werten der Hash-Struktur zusammen. Die Funktion sort.Slice() sortiert den Array dann numerisch absteigend nach den Value-Feldern (also den Integerwerten). Nach diesem Aufwand ist es für die For-Schleife ab Zeile 34 ein Leichtes, die Top-Drei als die ersten drei Elemente des sortierten Array-Slices auszugeben.

Da Listing 3 ohne jedwege Extra-Pakete auskommt, wird es einfach mit

    $ go build top3.go histWalk.go

compiliert und kurz darauf steht ein Binary namens top3 zur Verfügung, das die History-Datei durchforstet und das Sieger-Trio der am häufigsten eingetippten Kommandos anzeigt:

    $ ./top3
    make (72x)
    vi histWalk.go (57x)
    vi top3.go (23x)

Mit einigen algorithmischen Tricks, zum Beispiel durch die Verwendung einer Heap-Struktur, könnte man den Aufwand zur Ermittlung der Top-N aus einer Liste noch effizienter gestalten, als dies bei der Sortierung der gesamten Liste der Fall ist. Da die Anzahl handgetippter Kommandos jedoch für Rechnerverhältnisse eher überschaubar ist, hat Listing 2 darauf verzichtet.

Gespaltenes Gehirn

Wer die vorgestellten Programme gleich ausprobiert, wird sich vielleicht wundern, warum ein bestimmtes Terminalfenster scheinbar History-Einträge anderer Fenster nicht gleich mitbekommt. Die Bash-Shell die fragwürdige Angewohnheit, getrennte Historien für separate Terminal-Fenster desselben Users anzulegen. Tippt dieser also in einem Fenster ein Kommando ein, weiß die history in einem anderen Fenster davon nichts. Beendet der User aber die Shell in einem Terminal-Fenster, indem er es zum Beispiel schließt, schickt die darin laufende Shell kurz vor dem Abnippeln noch ihre ihre Historie an die allen Bash-Sessions gemeine .bash_history-Datei im Home-Verzeichnis. Jede neu gestartete Shell hat ab dann Zugriff auf die neu hinzugekommenen Daten.

Wer die globale Historie ständig auf dem Laufenden halten möchte, kann sich mit folgender Kommandosequenz behelfen, die, in der Environment-Variablen PROMPT_COMMAND abgelegt, von der Shell nach jedem abgesetzten Kommando durchlaufen wird:

    export PROMPT_COMMAND="history -a; history -c; history -r; $PROMPT_COMMAND"

Die drei history-Befehle schreiben die Kommandos der aktuellen Bash-Session in die globale Datei (-a für "append"), löschen die lokale Historie (-c für clear) und laden die globale Historie in die lokale Session (-r für Reload). Diese Einstellung beansprucht aber die Festplatte bei jedem Kommando, je nach Länge der Historie kann dies Geschwindigkeitseinbußen zur Folge haben.

Weiter begrenzt die Bash-Shell von Natur aus die Anzahl der in der Historie angelegten Kommandos auf 500. Wer auf weiter zurückliegende Kommandos zurückgreifen will, ist gut damit bedient die folgenden Variablen zu setzen:

    export HISTSIZE=100000
    export HISTFILESIZE=100000

Der erste Wert stellt die maximal erlaubten Einträge ein, die die Bash-Shell in einer laufenden Session im Gedächtnis behält. Der zweite Wert gibt die maximale Anzahl der Zeilen in der globalen History-Datei an. Zugriff und Analyse der History-Daten bieten auf jeden Fall Gelegenheit, zu oft getippte Shell-Kommandos aufzudecken und bessere, weil effizientere Methoden zu entwickeln. Die vorgestellte Walker-Funktion animiert hoffentlich zu weiteren praktischen Anwendungen.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/12/snapshot/

[2]

"Histsize vs. Histfilesize", https://stackoverflow.com/questions/19454837/bash-histsize-vs-histfilesize

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc