Aus Linux-Magazin 12/2020

Shell-Kommandos statistisch auswerten

© NejroN, 123RF

Im History-Log schreibt die Bash stetig alle vom User getippten Kommandos mit. Mike Schilli extrahiert die Daten mit Go und unterwirft sie einigen statistischen Auswertungen.

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 [Strg]+[R] lassen sich weiter zurückliegende Sequenzen anhand von Suchmustern aufspüren und erneut verwenden. Dass die Bash 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.

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 dahinter: Dort reiht die Bash einfach jedes abgeschickte Kommando auf, egal, ob es erfolgreich war oder mit einem Fehler abgebrochen hat. Fragt der User nach den zuletzt gesendeten Kommandos, sieht die Bash einfach dort nach (Abbildung 2).

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

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

Zeitstempel drauf!

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

export HISTTIMEFORMAT="%F %T: "

Fürderhin stellt die Bash in der Log-Datei ».bash_history« jedem notierten Kommando eine Kommentarzeile mit dem Unix-Epoch-Stempel voran, und der Befehl »history« gibt zu jedem Kommando in der Anzeigeliste eine menschenlesbare Datums- und Zeitangabe aus (Abbildung 3).

Abbildung 3: Ist »HISTDATEFORMAT« gesetzt, liefert die History die zuletzt abgesetzten Kommandos mitsamt dem Zeitstempel ihrer Ausführung.

Abbildung 3: Ist »HISTDATEFORMAT« gesetzt, liefert die History die zuletzt abgesetzten Kommandos mitsamt dem Zeitstempel ihrer Ausführung.

So eine Datensammlung regt zum Auswerten an. Wie wäre es, die Wochentage zu ermitteln, an denen der User am fleißigsten getippt hat? Oder die am häufigsten getippten Kommandos zu identifizieren, um den Tippaufwand künftig mittels Kürzeln oder Shell-Skripts zu reduzieren?

Callback als Abstraktion

Listing 1 definiert dazu die Funktion »histWalk()«, die die globale History-Log-Datei 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 und bekommen die History-Daten samt Zeitstempel, ohne sich um die Details des History-Log-Formats kümmern zu müssen.

Die in Zeile*19 mit »os.Open()« zum Lesen geöffnete Log-Datei liest der in Zeile 24 angelegte Scanner zeilenweise ein. Er schnappt sich dazu 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. Entsprechend legt Listing 1 den Sekundenwert in der Variablen »timestamp« ab und schickt den Scanner in die nächste Runde. Steht am Anfang kein Hash-Zeichen, 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

package main
import (
  "bufio"
  "os"
  "os/user"
  "path/filepath"
  "strconv"
)
func histWalk(cb func(int64, string) error) error {
  usr, err := user.Current()
  if err != nil {
    panic(err)
  }
  home := usr.HomeDir
  histfile := filepath.Join(home, ".bash_history")
  f, err := os.Open(histfile)
  if err != nil {
    panic(err)
  }
  scanner := bufio.NewScanner(f)
  var timestamp int64
  for scanner.Scan() {
    line := scanner.Text()
    if line[0] == '#' {
      timestamp, err = strconv.ParseInt(line[1:], 10, 64)
      if err != nil {
        panic(err)
      }
    } else {
      err := cb(timestamp, line)
      if err != nil {
        return err
      }
    }
  }
  return nil
}

Volle Bürgerrechte für Funktionen

In Go genießen Funktionen volle Bürgerrechte: Sie lassen sich also beliebig Variablen zuweisen oder auf Parameterlisten an andere Funktionen weitergeben. 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.

Wegen Gos strenger Typisierung gerät allerdings das Umwandeln des als Zeichenkette eingelesenen Sekundenwerts in eine Ganzzahl zur Geduldsprobe. Die Funktion »ParseInt()« aus dem Standardpaket strconv nimmt als Parameter den zu parsenden String entgegen (»line[1:]« schneidet den ersten Buchstaben ab, also das Kommentarzeichen), sowie die Basis der Integer-Zahl (»10« für eine Dezimalzahl) und die maximale Anzahl der Bits (»64«). Zurück kommt ein Wert vom Typ »int64«, ein wichtiges Detail: Ansonsten gehen am 19. Januar 2038 die Lichter aus, weil dann der Sekundenvorrat an 32-Bit-Integern erschöpft ist.

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

Abbildung 4: Das schnell kompilierte <a href="#artRef-l2">Listing&nbsp;2</a> zeigt die Tippaktivit&auml;t, &uuml;ber die Wochentage verteilt.

Abbildung 4: Das schnell kompilierte Listing 2 zeigt die Tippaktivität, über die Wochentage verteilt.

Listing 2

dow.go

package main
import (
  "fmt"
  "time"
)
func main() {
  var countByDoW [7]int
  err := histWalk(func(stamp int64, line string) error {
    dt := time.Unix(stamp, 0)
    countByDoW[int(dt.Weekday())]++
    return nil
  })
  if err != nil {
    panic(err)
  }
  for dow := 0; dow < len(countByDoW); dow++ {
    dowStr := time.Weekday(dow).String()
    fmt.Printf("%s: %v\n", dowStr, countByDoW[dow])
  }
}

Wochentage von 0 bis 6

Das eingangs in Zeile 9 angelegte Array der Länge 7 stellt Integer-Werte an den Positionen 0 bis 6 für die einzelnen Wochentage bereit, die die Callback-Funktion bei jedem eintrudelnden Kommando mit Zeitstempel an der entsprechenden Stelle um eins hochzählt. Eine Besonderheit der inline definierten Callback-Funktion: Sie hat Zugriff auf die vorab definierte Variable »countByDoW«, 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 Integer-Wert wieder in einen Wochentag-String umwandeln? Im Paket »time« gibt es dazu den Datentyp »Weekday«, der Integer-Konstanten von 0 bis 6 definiert, entsprechend der Wochentage Sonntag bis Samstag. Des Weiteren steht dort eine Funktion »String()« parat, die die Konstantenwerte in englische Wochentags-Strings umwandelt. Zeile 22 fieselt daraus den Wochentag zusammen. Zeile 23 bleibt nur noch, die Zeichenketten mit den kumulierten Zählern auszugeben. Abbildung 4 zeigt das Kompilieren der Listings zu einem Binary, sowie 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 identischer 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 dem ausgeführten Befehl.

Listing 3

top3.go

package main
import (
  "fmt"
  "sort"
)
func main() {
  cmds := map[string]int{}
  err := histWalk(func(stamp int64, line string) error {
    cmds[line]++
    return nil
  })
  if err != nil {
    panic(err)
  }
  type kv struct {
    Key   string
    Value int
  }
  kvs := []kv{}
  for k, v := range cmds {
    kvs = append(kvs, kv{k, v})
  }
  sort.Slice(kvs, func(i, j int) bool {
    return kvs[i].Value > kvs[j].Value
  })
  for i := 0; i < 3; i++ {
    fmt.Printf("%s (%dx)\n", kvs[i].Key, kvs[i].Value)
  }
}

Mehr Arbeit durch Typstrenge

Am Ende des Durchlaufs stehen die Gewinner mit den höchsten Zählerwerten fest. Doch wie fieselt man die Top-3 aus dem Gesamtfeld heraus? Skriptsprachen bieten wegen schwacher Typisierung deutlich komfortablere Methoden, um eine Hash-Map 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.

Dann baut die For-Schleife ab Zeile 26 aus der Hash-Map ein sortierbares Array mit den Schlüsseln und Werten der Hash-Struktur zusammen. Die Funktion »sort.Slice()« sortiert das Array dann numerisch absteigend nach den »Value«-Feldern (also den Integer-Werten). Danach 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 jegliche Extra-Pakete auskommt, wird es einfach mit »go build top3.go histWalk.go« kompiliert. 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 (Listing 4).

Listing 4

Sieger-Trio anzeigen

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

Mit einigen algorithmischen Tricks, zum Beispiel durch Verwenden einer Heap-Struktur, könnte man den Aufwand zur Ermittlung der Top-N aus einer Liste noch effizienter gestalten als bei der Sortierung der gesamten Liste. 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, wundert sich vielleicht, warum ein bestimmtes Terminalfenster scheinbar History-Einträge anderer Fenster nicht gleich mitbekommt.

Die Bash hat die fragwürdige Angewohnheit, getrennte Historien für separate Terminalfenster desselben Users anzulegen. Tippt der also in einem Fenster ein Kommando ein, weiß die History in einem anderen Fenster davon nichts. Beendet der User aber die Shell in einem Terminalfenster, indem er es zum Beispiel schließt, schickt die darin laufende Shell kurz vor dem Abnippeln noch ihre Historie an die allen Bash-Sessions gemeine Datei ».bash_history« 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 der Kommandosequenz aus Listing 5 behelfen. Legt man sie in der Environment-Variablen »PROMPT_COMMAND« ab, durchläuft die Shell sie nach jedem abgesetzten Kommando.

Listing 5

Historie aktuell halten

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 nimmt allerdings bei jedem Kommando die Festplatte in Anspruch; je nach Länge der Historie kann das Geschwindigkeitseinbußen zur Folge haben.

Außerdem begrenzt die Bash von Natur aus die Anzahl der in der Historie angelegten Kommandos auf 500. Wer auf weiter zurückliegende Kommandos zurückgreifen will, setzt die in Listing 6 gezeigten Variablen.

Listing 6

Variablen setzen

export HISTSIZE=100000
export HISTFILESIZE=100000

Der erste Wert stellt die maximal erlaubte Anzahl von Einträgen ein, die die Bash 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.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben