Viele Dateien nach einem vorgegebenen Schema umzubenennen, erfordert oft kleine Shell-Skripts. Mit seinem Go-Programm will Mike Schilli diese Arbeit vereinfachen.
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 in einem Rutsch in »*.log.old« um? Es soll angeblich schon vorgekommen sein, dass Kandidaten dafür den Shell-Befehl »mv *.log *.log.old« vorschlugen – allerdings wurden sie dann nicht eingestellt.
Auf Github lungert schon eine ganze Reihe von Werkzeugen herum, die solche Aufgaben bewerkstelligen, wie etwa das in Rust geschriebene Tool Renamer [1]. Aber auch in Go schreiben sich solche Utilities ratzfatz. Unsere im Folgenden vorgestellte Variante soll der Einfachheit halber ebenfalls Renamer heißen. Um damit zum Beispiel eine ganze Palette von Log-Dateien mit der Endung ».log« in ».log.bak« umzubenennen, genügt der in der ersten Zeile von Listing 1 gezeigte Aufruf.
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? Unser Go-Programm erledigt auch das mit dem Aufruf aus Zeile 4. Den Platzhalter »{seq}« ersetzt es bei jeder umbenannten Datei durch einen um eins erhöhten Zähler, den es mit führenden Nullen auf vier Stellen auffüllt.
Listing 1
Dateien umbenennen
$ renamer -v '.log$/.log.old' *.log out.log -> out.log.bak [...] $ renamer -v '/hawaii2020-{seq}.jpg' *.JPG IMG_8858.JPG -> hawaii2020-0001.jpg IMG_8859.JPG -> hawaii2020-0002.jpg
Immer der Reihe nach
Das Renamer-Hauptprogramm (Listing 2) verarbeitet in den Zeilen 19 und 20 die Kommandozeilenoptionen »-d« für einen Testlauf ohne Konsequenzen (»dryrun«) und »-v« für gesprächige Statusmeldungen (»verbose«). Das dazu verwendete Standard-Paket flag weist nicht nur den Pointer-Variablen »dryrun« und »verbose« den Wert »true« beziehungsweise »false« zu, sondern springt auch eine im Attribut »Usage« definierte Funktion »Usage()« 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 sowie eine oder mehrere Dateien, die es später umbenennt. Zeile 12 informiert den Anwender über die korrekte Aufrufsyntax des aus dem Quellcode kompilierten Binaries »renamer«.
Die Array-Slice-Arithmetik weist den ersten Kommandozeilenparameter mit der Indexnummer »0« der Variablen »cmd« zu. Es folgen ein oder mehrere Dateinamen, die die Shell auch gern über Wildcards expandieren darf, bevor sie sie an das Programm übergibt. Die Argumente ab der zweiten Position bis ultimo holt der Ausdruck »[1:]« aus dem Array-Slice; 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 3 definierte Funktion »mkmodifier()« durch. Die macht daraus eine Go-Funktion, die hereingereichte Dateinamen den Anweisungen des Benutzers gemäß manipuliert und eine neue Version zurückgibt.
Listing 2
renamer.go
package main
import (
"flag"
"fmt"
"os"
"path"
)
func usage() {
fmt.Fprintf(os.Stderr,
"Usage: %s 'search/replace' file ...\n",
path.Base(os.Args[0]))
flag.PrintDefaults()
os.Exit(1)
}
func main() {
dryrun := flag.Bool("d", false, "dryrun only")
verbose := flag.Bool("v", false, "verbose mode")
flag.Usage = usage
flag.Parse()
if *dryrun {
fmt.Printf("Dryrun mode\n")
}
if len(flag.Args()) < 2 {
usage()
}
cmd := flag.Args()[0]
files := flag.Args()[1:]
modifier, err := mkmodifier(cmd)
if err != nil {
fmt.Fprintf(os.Stderr,
"Invalid command: %s\n", cmd)
usage()
}
for _, file := range files {
modfile := modifier(file)
if file == modfile {
continue
}
if *verbose || *dryrun {
fmt.Printf("%s -> %s\n", file, modfile)
}
if *dryrun {
continue
}
err := os.Rename(file, modfile)
if err != nil {
fmt.Printf("Renaming %s -> %s failed: %v\n",
file, modfile, err)
break
}
}
}
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 dabei den Originalnamen der Datei und schnappt in Zeile 42 den neuen Namen in »modfile« auf.
Hat der User mit »-d« den Trockenlaufmodus gewählt, gibt Zeile 47 lediglich die beabsichtigte Umbenennungsaktion aus, und Zeile 50 läutet mit »continue« die nächste Runde der For-Schleife ein, ohne dass der Aufruf von »Rename()« in Zeile 52 an die Reihe käme.
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 aus »modfile« um. Falls Zugriffsrechte dem entgegenstehen, schlägt die Funktion fehl und »os.Rename()« gibt einen Fehler zurück, den Zeile 53 bemerkt. 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 Regexen
Statt eine reine String-Ersetzung anzufordern, 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 3 das Standard-Paket regexp herein und kompiliert den vom Benutzer hereingereichten regulären Ausdruck mittels »MustCompile()« in Zeile 25 in eine Variable »rex« vom Typ »*regexp.Regexp«. Danach kann der ab Zeile 27 definierte Modifizierer die Funktion »ReplaceAllString()« aufrufen. Sie ersetzt alle Treffer, auf die der Ausdruck im Originalnamen »org« passt, durch den in »repl« abgelegten Ersatz-String.
Aufmerksame Leser wundern sich vielleicht, dass die Funktion »mkmodifier()« in Listing 3 nicht nur eine Funktion ans Hauptprogramm zurückgibt, die das dann mehrfach aufruft. Obendrein 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?
Listing 3
mkmodifier.go
package main
import (
"errors"
"fmt"
"regexp"
"strings"
)
func mkmodifier(cmd string) (func(string) string, error) {
parts := strings.Split(cmd, "/")
if len(parts) != 2 {
return nil, errors.New("Invalid repl command")
}
search := parts[0]
repltmpl := parts[1]
seq := 1
var rex *regexp.Regexp
if len(search) == 0 {
search = ".*"
}
rex = regexp.MustCompile(search)
modifier := func(org string) string {
repl := strings.Replace(repltmpl,
"{seq}", fmt.Sprintf("%04d", seq), -1)
seq++
res := rex.ReplaceAllString(org, repl)
return string(res)
}
return modifier, nil
}
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 4 illustriert das Verfahren an einem einfachen Beispiel.
Bevor eine Funktion (im Beispiel »mkmycounter()«) eine neu konstruierte Funktion an den Aufrufer zurückreicht, darf sie vorab Variablen definieren, deren Daten die generierte Funktion umschlingt und die anschließend für sie (aber nur für sie und niemand anderen) global erscheinen. Modifiziert ein Aufruf der generierten und zurückgegebenen 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 4 kompilierten Binaries zeigt erwartungsgemäß, wie hintereinander folgende Aufrufe der erzeugten Funktion immer höhere Zählerwerte ausgeben (Listing 5).
Listing 4
closure.go
package main
import "fmt"
func main() {
mycounter := mkmycounter()
mycounter()
mycounter()
mycounter()
}
func mkmycounter() func() {
count := 1
return func() {
fmt.Printf("%d\n", count)
count++
}
}
Listing 5
Binary aufrufen
$ go build closure.go $ ./closure 1 2 3
Zeichen, Bytes und Runen
Auch der Aufruf der Regexp-Funktion »ReplaceAllString()« in Zeile 31 von Listing 3 bedarf einer Erklärung. Sie ersetzt alle Zeichen im String »org«, auf die der reguläre Ausdruck »rex« passt, durch die Zeichen im String »repl«. Die Funktion »ReplaceAll()« (ohne »String«) hingegen, die der Anwender bei flüchtigem Studium der Manual-Seite vielleicht als Erstes findet, erwartet statt Zeichenketten 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?
Dazu lohnt es sich, einen Exkurs in Gos Implementierung von Strings zu wagen [2]. 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. Zudem machen Strings einen Unterschied zwischen Zeichen und Bytes: Da Zeichenketten im Go-Code UTF-8-kodiert vorliegen, liegt der String “Öl” im Programmtext der Listings 6 und**7 als drei Bytes vor, da der Umlaut in UTF-8 hexadezimal als »c3 96« notiert wird.

Abbildung 1: Beim Abfahren von Strings zeigen range-Operator und for-Schleife unterschiedliche Ergebnisse.
Da die Bedeutung des Worts “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« kodiert. Zu allem Überfluss gibt es noch eine alternative Darstellung des Ö in Form zweier Unicode-Code-Points: Dabei schwebt ein waagerechter Doppelpunkt (diaeresis, dt.: Trema) über einem O – 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 Operator »range« in Listing 6 die Runen abfährt (Abbildung 1), indiziert die For-Schleife in Listing 7 die einzelnen Bytes und gibt so den Umlaut in Form zweier unleserlicher Zeichen aus. Es lohnt sich also, 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. Sie kostet also Rechenzeit, und zwar zur Laufzeit.
Listing 6
range.go
package main
import "fmt"
func main() {
str := "Öl"
for i, c := range str {
fmt.Printf("str[%d]='%c'\n", i, c)
}
}
Listing 7
forloop.go
package main
import "fmt"
func main() {
str := "Öl"
for i := 0; i < len(str); i++ {
fmt.Printf("str[%d]='%c'\n", i, str[i])
}
}
Auf geht’s
Zurück zu Listing 4: 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 gebrachten Integer-Wert. Aus »foo-{seq}.log« wird erst »foo-0001.log«, dann »foo-0002.log« und so weiter.
Der Aufruf »go build renamer.go mkmodifier.go« kompiliert beide Listings und linkt das Ergebnis zu einem Binary namens »renamer« zusammen. Abbildung 2 zeigt einige Anwendungsbeispiele.
Die Funktion »os.Rename()« akzeptiert übrigens auch identische Ausgangs- und Zieldateien – dann tut sie eben nichts. Existiert die Zieldatei aber schon, überschreibt sie sie rücksichtslos mit der Quelldatei. 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 noch einmal, und dieses Mal live.
Infos
- Renamer: https://github.com/adriangoransson/renamer
- “Strings, bytes, runes and characters in Go”: https://blog.golang.org/strings






