Name der Chose (Linux-Magazin, Januar 2021)

Eine beliebte Frage im Vorstellungsgespräch für Systemadministratoren lautet, wie man eine Reihe von Dateien am einfachsten mit einer neuen Endung versieht. Ein Verzeichnis mit *.log-Dateien, zum Beispiel, wie benennt man die alle auf einen Rutsch in *.log.old um? Es soll angeblich schon vorgekommen sein, dass Kandidaten hierfür den Shell-Befehl mv *.log *.log.old vorgeschlagen haben. Allerdings wurden sie nicht eingestellt.

Auf Github lungern schon eine ganze Reihe von Tools herum, die solche Aufgaben bewerkstelligen, eines davon das in Rust geschriebene Tool renamer ([1]), aber auch in Go schreiben sich solche Utilities ratz-fatz. Um zum Beispiel eine ganze Palette von Logdateien mit der Endung .log in .log.bak umzubenennen, reicht der Aufruf des heute vorgestellten Programms:

    $ renamer -v '.log$/.log.old' *.log
    out.log -> out.log.bak
    ...

Oder wie wäre es, die Urlaubsfotos, die als IMG_8858.JPG bis IMG_9091.JPG vorliegen, in hawaii-2020-0001.jpg bis hawaii-2020-0234.jpg umzubenennen? Das Go-Programm ab Listing 1 erledigt auch dies, mit dem Aufruf:

    $ renamer '/hawaii-{seq}.jpg' *.JPG 
    IMG_8858.JPG -> hawaii2020-0001.jpg
    IMG_8859.JPG -> hawaii2020-0002.jpg
    ...

Den Platzhalter {seq} ersetzt das Programm bei jeder umbenannten Datei dabei jeweils mit einem um Eins erhöhten Zähler, der mit führenden Nullen auf vier Stellen aufgebauscht wurde.

Immer der Reihe nach

Das Hauptprogramm in Listing 1 verarbeitet in den Zeilen 19 und 20 die Kommandozeilenoptionen -d für einen Testlauf ohne Konsquenzen (Dryrun) und -v für gesprächige Statusmeldungen (Verbose). Das dazu verwendete Standard-Paket flag weist nicht nur den Pointer(!)-Variablen dryrun und verbose wahre bzw. falsche Werte zu, sondern springt auch eine im Usage-Attribut definierte Usage-Funktion an, falls der User dem Programm eine Option unterjubelt, die es gar nicht kennt.

Auf jeden Fall erwartet das Programm ein Kommando zur Manipulation der Dateinamen und eine oder mehrere Dateien, die es später umbenennt. Zeile 12 zeigt den richtigen Aufruf des aus dem Source-Code compilierten Binaries renamer.

Die Arrya-Slice-Arithmetik weist den ersten Kommandozeilenparameter mit der Indexnummer 0 der Variablen cmd zu. Es folgen ein oder mehrere Dateinamen, die die Shell auch gerne über Wildcards expandieren darf, bevor sie sie an das Programm übergibt. Argumente 2 bis ultimo holt der Ausdruck [1:] aus dem Array-Slice und Zeile 33 weist die Liste der Variablen files zu.

Die auf der Kommandozeile übergebene Anweisung zum Manipulieren der Dateinamen (also zum Beispiel '.log$/.log.old') reicht Zeile 34 an die später in Listing 2 definierte Funktion mkmodifier() weiter, die daraus eine Go-Funktion macht, die hereingereichte Dateinamen den Anweisungen des Users gemäß manipuliert und eine neue Version zurückgibt.

Funktion gibt Funktion zurück

Richtig, die Funktion mkmodifier() retourniert in Zeile 34 tatsächlich eine Funktion, die dort der Variablen modifier zugewiesen wird. Ein paar Zeilen weiter unten, in der for-Schleife, die über alle zu manipulierenden Dateien iteriert, ruft das Hauptprogramm die in modifier liegende Funktion einfach auf: Sie übergibt ihr den Originalnamen der Datei und schnappt in Zeile 42 den neuen Namen modfile auf.

Hat der User mit -d den Dryrun-Modus gewählt, gibt Zeile 47 lediglich die beabsichtige Umbenennungsaktion aus und Zeile 50 läute mit continue die nächste Runde der for-Schleife ein, ohne dass der Aufruf von Rename() in Zeile 52 drankäme.

Listing 1: renamer.go

    01 package main
    02 
    03 import (
    04   "flag"
    05   "fmt"
    06   "os"
    07   "path"
    08 )
    09 
    10 func usage() {
    11   fmt.Fprintf(os.Stderr,
    12     "Usage: %s 'search/replace' file ...\n",
    13     path.Base(os.Args[0]))
    14   flag.PrintDefaults()
    15   os.Exit(1)
    16 }
    17 
    18 func main() {
    19   dryrun := flag.Bool("d", false, "dryrun only")
    20   verbose := flag.Bool("v", false, "verbose mode")
    21   flag.Usage = usage
    22   flag.Parse()
    23 
    24   if *dryrun {
    25     fmt.Printf("Dryrun mode\n")
    26   }
    27 
    28   if len(flag.Args()) < 2 {
    29     usage()
    30   }
    31 
    32   cmd := flag.Args()[0]
    33   files := flag.Args()[1:]
    34   modifier, err := mkmodifier(cmd)
    35   if err != nil {
    36     fmt.Fprintf(os.Stderr,
    37       "Invalid command: %s\n", cmd)
    38     usage()
    39   }
    40 
    41   for _, file := range files {
    42     modfile := modifier(file)
    43     if file == modfile {
    44       continue
    45     }
    46     if *verbose || *dryrun {
    47       fmt.Printf("%s -> %s\n", file, modfile)
    48     }
    49     if *dryrun {
    50       continue
    51     }
    52     err := os.Rename(file, modfile)
    53     if err != nil {
    54       fmt.Printf("Renaming %s -> %s failed: %v\n",
    55         file, modfile, err)
    56       break
    57     }
    58   }
    59 }

Geht alles mit rechten Dingen zu, ruft Zeile 52 aber mit der Funktion Rename() aus dem Standardpaket os die Unix-System-Funktion rename() auf und benennt die Datei auf den neuen Namen modfile um. Falls Zugriffsrechte dem entgegenstehen, schlägt die Funktion fehl, os.Rename() gibt einen Fehler zurück, den Zeile 53 bemerkt, und der zugehörige if-Block gibt eine Meldung aus und bricht mit break die for-Schleife ab, denn dann ist tatsächlich Matthäi am letzten.

Flexibel mit Regexes

Statt reiner Stringersetzung darf der User auch reguläre Ausdrücke angeben, um Dateien umzumodeln. So gibt der eingangs illustrierte Suchausdruck mit .log$ an, dass die Endung .log tatsächlich am Ende des Namens stehen muss. Auf foo.log.bak würde er nicht anspringen. Hierzu zieht Listing 2 das Standard-Paket regexp herein, compiliert den von seiten des Users hereingereichten regulären Ausdruck mittels MustCompile() in Zeile 25 in eine Variable rex vom Typ *regexp.Regexp. Hierauf kann der ab Zeile 27 definierte Modifizierer die Funktion ReplaceAllString() aufrufen, die alle Treffer, auf die der Ausdruck im Originalnamen org passt, durch den in repl abgelegten Ersatzstring ersetzt.

Listing 2: mkmodifier.go

    01 package main
    02 
    03 import (
    04   "errors"
    05   "fmt"
    06   "regexp"
    07   "strings"
    08 )
    09 
    10 func mkmodifier(cmd string) (func(string) string, error) {
    11   parts := strings.Split(cmd, "/")
    12   if len(parts) != 2 {
    13     return nil, errors.New("Invalid repl command")
    14   }
    15   search := parts[0]
    16   repltmpl := parts[1]
    17   seq := 1
    18 
    19   var rex *regexp.Regexp
    20 
    21   if len(search) == 0 {
    22     search = ".*"
    23   }
    24 
    25   rex = regexp.MustCompile(search)
    26 
    27   modifier := func(org string) string {
    28     repl := strings.Replace(repltmpl,
    29       "{seq}", fmt.Sprintf("%04d", seq), -1)
    30     seq++
    31     res := rex.ReplaceAllString(org, repl)
    32     return string(res)
    33   }
    34 
    35   return modifier, nil
    36 }

Aufmerksame Leser werden sich vielleicht wundern, dass die Funktion mkmodifier() in Listing 2 nicht nur eine Funktion ans Hauptprogramm zurückgibt, die dieses dann mehrfach aufruft. Vielmehr behält die konstruierte Funktion über mehrere Aufrufe hinweg den aktuellen Status zum Beispiel der Variablen seq bei: Jeder erneute Aufruf der Funktion pflanzt einen um Eins hochgezählten Wert in den modifizierten Dateinamen. Wie ist das möglich?

Geschlossene Gesellschaft

Das Geheimnis nennt sich "Closure", und ist ein nicht nur von Go sondern auch vielen anderen Skript- und Programmiersprachen unterstütztes Feature. Listing 3 illustriert das Verfahren an einem einfachen Beispiel.

Listing 3: closure.go

    01 package main
    02 
    03 import "fmt"
    04 
    05 func main() {
    06   mycounter := mkmycounter()
    07 
    08   mycounter()
    09   mycounter()
    10   mycounter()
    11 }
    12 
    13 func mkmycounter() func() {
    14   count := 1
    15 
    16   return func() {
    17     fmt.Printf("%d\n", count)
    18     count++
    19   }
    20 }

Bevor eine Funktion (wie mkmycounter()) eine neu konstruierte Funktion an den Aufrufer zurückreicht, darf sie vorab Variablen definieren, deren Daten die generierte Funktion umschlingt und die für sie anschließend (aber nur für sie und niemand anderen) "global" erscheinen. Modifiziert ein Aufruf der generierten (und zurückgereichten) Funktion eine dieser Variablen, findet der nächste Aufruf der Funktion auch wieder den vorher modifizierten Wert vor. So gehören die umschlossenen Variablen zur Funktion, ähnlich wie Instanzvariablen in der objektorientierten Programmierung zu einem Objekt gehören.

Der Aufruf des aus Listing 3 compilierten Binaries zeigt erwartungsgemäß, wie hintereinanderfolgende Aufrufe der erzeugten Funktion immer höhere Zählerwerte ausgeben:

    $ go build closure.go
    $ ./closure 
    1
    2
    3

Zeichen, Bytes und Runen

Auch der Aufruf der Regexp-Funktion ReplaceAllString() in Zeile 31 in Listing 2, bedarf einer Erklärung. Sie ersetzt alle Zeichen im String org, auf die der Regex rex passt, durch die Zeichen im String repl. Die Funktion ReplaceAll() (ohne "String") hingegen, die der Anwender bei flüchtigem Studium der Manualseite vielleicht als erstes findet, erwartet statt Strings vielmehr Slices vom Typ []byte. Aufmerksame Leser fragen sich vielleicht, was der Unterschied ist, wenn doch der User einen String mit []byte(string) einfach in ein Byte-Slice konvertieren kann?

Listing 4: range.go

    01 package main
    02 
    03 import "fmt"
    04 
    05 func main() {
    06   str := "Öl"
    07   for i, c := range str {
    08     fmt.Printf("str[%d]='%c'\n", i, c)
    09   }
    10 }

Listing 5: forloop.go

    01 package main
    02 
    03 import "fmt"
    04 
    05 func main() {
    06   str := "Öl"
    07   for i := 0; i < len(str); i++ {
    08     fmt.Printf("str[%d]='%c'\n", i, str[i])
    09   }
    10 }

Dazu lohnt es sich, einen Exkurs in Gos Implementierung von Strings zu wagen ([3]). Dort findet der erstaunte Go-Student, dass Strings und Byte-Slices ([]byte) in Go grundverschiedene Datentypen sind. Bestehende Strings darf der User nicht mehr modifizieren, sie sind unveränderbar (immutable), während er auf Byte-Slices beliebig herumorgeln darf. Auch beherrschen Strings den Unterschied zwischen Zeichen und Bytes: Da Textstrings im Go-Code als Utf-8 codiert vorliegen liegt der String "Öl" im Programmtext der Listings 4 und 5 als drei Bytes vor, da der Umlaut in utf-8 hexadezimal als "c3 96" notiert.

Abbildung 1: Beim Abfahren von Strings zeigen range-Operator und for-Schleife unterschiedliche Ergebnisse.

Da die Bedeutung des Wortes "Zeichen" ("character") historisch bedingt oft mit "Byte" vermengt wurde, nennt der Unicode-Standard sie "Code Points". Dort steht das "Ö" auf Position U+00D6, was utf-8 als "c3 96" codiert. Zu allem Überfluss wird das Chaos noch größer, wenn man bedenkt, dass es noch eine alternative Darstellung des Ö-Zeichens als zwei Unicode-Code-Points gibt, als "O" und einem darüber schwebenden waagrechten Doppelpunkt, aber das wollen wir heute außen vor lassen. Wichtig ist nur, dass Go Code-Punkte im Unicode-Standard "runes" nennt, also Runen.

Während nun der range-Operator in Listing 4 die Runen abfährt, indiziert die for-Schleife in Listing 5 die einzelnen Bytes und gibt so den Umlaut als zwei unleserliche Zeichen aus. Die Moral von der Geschicht': Es lohnt sich, genau hinzuschauen, ob eine Funktion Strings oder Byte-Slices verarbeitet. Die Konvertierung zwischen den verschiedenen Datentypen sieht übrigens einfach aus, ist aber intern mit viel Aufwand verbunden, kostet also Rechenzeit, und zwar zur Laufzeit.

Auf geht's

Zurück zu Listing 2: Wegen der auch dort implementierten Closure zählt die Funktion bei jedem Aufruf den Wert der Variablen seq um Eins hoch, und ersetzt den Platzhalter {seq} im Dateinamen mit dem mittels führender Nullen auf vier Stellen aufgepumpten Integerwert. Aus foo-{seq}.log wird erst foo-0001.log, dann foo-0002.log, und so weiter.

Abbildung 2: Das Go-Programm benennt Dateien um und numeriert sie auf Wunsch auch durch.

Der Aufruf

    $ go build renamer.go mkmodifier.go

kompiliert beide Listings und linkt das Ergebnis zu einem Binary renamer zusammen. Abbildung 2 zeigt einige Anwendungsbeispiele. Die Funktion os.Rename() ist's übrigens auch zufrieden, falls Ausgangs- und Zieldatei identisch sind, dann tut sie eben nichts. Existiert die Zieldatei aber schon, wird sie sie rücksichtslos mit der Quelldatei überschreiben. Wer das nicht möchte, kann noch einen Test einbauen, und vielleicht eine neue Option --force, die wie ein Bulldozer dann doch drüberfährt. Um unbeabsichtigte Umbenennungen bei kritischen Dateien zu vermeiden, empfiehlt es sich immer, zuerst mit -d einen Trockenlauf zu absolvieren. Passt alles? Dann nochmal, und mach's diesmal live!

Infos

[1]

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

[2]

Renamer: https://github.com/adriangoransson/renamer

[3]

"Strings, bytes, runes and characters in Go", Rob Pike, https://blog.golang.org/strings

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