WLAN-Doktor (Linux-Magazin, August 2023)

Kaum im AirBnb angekommen, stellt sich oft heraus, dass das WLAN dort nicht funktioniert. Woran liegt es? Ist es der DHCP-Server des Routers, der dem Laptop keine IP zuweist? Ist es DNS? Ist nur der Durchsatz so mies, dass alles hängt? Alles dies lässt sich durch diverse Kommandozeilentools in Erfahrung bringen, aber das Verfahren jedesmal zu wiederholen ist ermüdend und nervig. Wie wäre es mit einem Tool, das einzelne Schritte alle sofort und in regelmäßigen Abständen wieder und wieder ausführt, deren Ergebnisse grafisch anzeigt und so hoffentlich die Ursache einkreist?

Abbildung 1: Das Diagnose-Tool zeigt ein funktionierendes Netzwerk.

Abbildung 2: Bei einem Netzwerkproblem hilft das Tool, die Ursache einzukreisen.

Als Terminal-UI kommt in dieser Ausgabe die Library tview von Github zum Einsatz, die auch einige berühmte Projekte, unter anderem Kubernetes, für ihre Kommandozeilentools verwenden. Mit nur wenigen Zeilen Code schaltet tview das aktuelle Terminal in den Grafikmodus und zeigt simple grafische Elemente wie Tabellen oder Formulare im Retro-Look weiß auf schwarz an (Abbildung 1). Tastatureingaben nimmt tview im Raw-Modus entgegen und Applikationen können diese zur Steuerung ihrer Oberfläche nutzen.

Von der Kommandozeile aus aufgerufen feuert das vorgestellte Go-Programm wifi vier verschiedene Tests gleichzeitig ab und und zeigt die Ergebnisse in einer Tabelle an. Nach einem Poll-Intervall von 10 Sekunden führt es die Tests abermals aus und gibt so dynamisch wieder, wenn sich im Netzwerk etwas ändert. Tickt alles wie gewünscht, zeigt die Terminal-UI die Messergebnisse wie in Abbildung 1 an. Schlagen hingegen Tests fehl, zeigt das Tool die entsprechenden Fehlermeldungen wie in Abbildung 2. Ein Druck auf ctrl-c beendet den Reigen, schaltet das Terminal wieder in den Normalmodus und springt zurück in die Shell.

Parallel Testen

Der ersten beiden Tests des Tools schicken Ping-Requests an den Google-Server, einmal an den Hostnamen www.google.com und einmal an die Adresse von Googles bekanntem DNS-Server, unter der IP 8.8.8.8. Schlägt beides fehl, ist wohl die Verbindung zum Internet vollständig unterbrochen, wird nur der Host nicht gefunden, liegt das Problem wohl an den DNS-Einstellungen. Als Test Nummer drei sucht das Tool unter dem Feldnamen Ifconfig nach allen Client-IP-Adressen, die dem Rechner von seiten des DHCP-Servers zugewiesen wurden. Steht dort nichts, hakt es wohl am Router oder der Wifi-Verbindung. In der fünften und letzten Zeile der Tabelle setzt das Tool schließlich einen HTTP-Request an den Youtube-Server ab und zeigt im Erfolgsfall die zur Abarbeitung des Reqests verstrichene Anzahl von Millisekunden an. So lässt sich ein lahmer ISP diagnostizieren.

Geschmäcklerisches Beispiel

Um erst einmal auf den Geschmack mit der tview-Library zu kommen, implementiert Listing 1 eine laufende Stoppuhr, deren aktuelle Uhrzeit im Sekundentakt über einen Go-Channel als String eintrifft und in einem Widget vom Typ TextView in der UI des Programms dynamisch aufgefrischt wird.

Listing 1: clock-main.go

    01 package main
    02 import (
    03   "fmt"
    04   "github.com/rivo/tview"
    05 )
    06 func main() {
    07   app := tview.NewApplication()
    08   tv := tview.NewTextView()
    09   tv.SetBorder(true).SetTitle("Test Clock")
    10   ch := clock()
    11   go func() {
    12     for {
    13       select {
    14       case val := <-ch:
    15         app.QueueUpdateDraw(func() {
    16           tv.Clear()
    17           fmt.Fprintf(tv, "%s ", val)
    18         })
    19       }
    20     }
    21   }()
    22   err := app.SetRoot(tv, true).Run()
    23   if err != nil {
    24     panic(err)
    25   }
    26 }

Dazu zieht Listing 1 in Zeile 4 das tview-Framework von Github herein. Zeile 7 erzeugt eine neue Terminal-Applikation und legt eine Referenz darauf in der Variablen app ab. Als Fensterinhalt kommt das TextView-Widget zum Einsatz, das in der Variable tv liegt und dessen Repräsentation im Terminal wegen SetBorder(true) einen Rand aufweist. Außerdem bekommt es mit SetTitle() eine Überschrift spendiert.

Der Aufruf der Funktion clock() in Zeile 10 startet die eigentliche Stoppuhr. Die Funktion schubst nicht nur den Timer an und lässt ihn im Hintergrund immer weiter laufen, sondern erzeugt auch einen Channel, den sie an den Aufrufer zurückreicht. Über diesen Kanal treffen anschließend im Sekundentakt die aktuellen Stoppuhr-Strings ein, die der Aufrufer aufschnappt und die grafische Anzeige damit aktualisiert.

Abbildung 3: Eine Stoppuhr mit tview als Beispiel

Ebenfalls nebenläufig fängt die Goroutine ab Zeile 11 in einer Endlosschleife mit einer select-Anweisung aus dem Channel eintrudelnde Strings ab. Immer wenn in Zeile 14 ein neuer Wert ankommt, benachrichtigt das Programm mit app.QueueUpdateDraw() die Terminal-UI, und weist sie an, zunächst die Uhranzeige mit tv.Clear() zu löschen und anschließend mit der Fprintf()-Funktion den neuen, aktuellen Wert in das TextView-Widget hineinzuschreiben.

Die grafische Anzeige wäre hiermit definiert, bleibt nur noch, in Zeile 22 mit app.SetRoot() dem Applikationsfenster das TextView-Widget einzupflanzen und mit Run() die UI zu starten. Ab dann läuft sie, bis der User ctrl-c drückt (Abbildung 3), und sie faltet sich daraufhin sofort sauber zusammen und gibt das Terminal wieder für die Shell frei.

Listing 2: clock.go

    01 package main
    02 import (
    03   "time"
    04 )
    05 func clock(arg ...string) chan string {
    06   ch := make(chan string)
    07   start := time.Now()
    08   go func() {
    09     for {
    10       z := time.Unix(0, 0).UTC()
    11       ch <- z.Add(time.Since(start)).Format("15:04:05")
    12       time.Sleep(1 * time.Second)
    13     }
    14   }()
    15   return ch
    16 }

Tickende Uhr

Die eigentliche Stoppuhr implmentiert Listing 2 mit der Funktion clock(), die ein optionales String-Argument akzeptiert. Die Stoppuhr nutzt dies nicht, aber das Format der Funktion soll später auch komplexere Aktionen für die UI einleiten, deswegen implementiert Listing 2 sie als sogenannte varadische Funktion. Die drei Punkte zwischen dem Namen des Parameters arg und dessen Typ (string) bestimmen in Go, dass die Funktion entweder gänzlich ohne diese Argumente aufzurufen ist, oder mit einem oder mehreren dieses Typs.

Gleich zu Anfang in Zeile 6 erzeugt clock() den Channel, den es später ans Hauptprogramm zurückreicht und durch diesen dann nebenläufig Updates für die Uhrzeit hochschickt. Für die Anzeige der seit dem Startzeitpunkt verstrichenen Zeit in Stunden, Minuten und Sekunden nutzt Listing 2 einen Trick: Die Funktion time.Since() in Zeile 11 ermittelt die seit dem Startzeitpunkt verstrichene Zeit als Wert vom Typ time.Duration. Allerdings bietet Go für diesen Typ keine elegante Formatierung als String an. Der Typ time.Time hingegen, mit dem Go Werte für die Uhrzeit darstellt, bietet die Funktion Format(), die das interne Zeitformat leserlich für menschliche User formatiert. Wer sich übrigens über den seltsamen String 15:04:05 als Argument im Code wundert, muss wissen, dass Go so das Format von Stunden, Minuten und Sekunden als Platzhalter erwartet. Andere Programmiersprachen legen so etwas mit einem Template-String wie HH:MM::SS fest. Go hingegen wählt den seltsamen Weg, den magischen Zeitpunkt am Montag, den 2.1.2006 um 15:04:05 als Referenz zu verwenden ([2]).

Den aktuellen Stand der Stoppuhr schiebt also Zeile 11 in den Channel ch, an dessen anderem Ende das aufrufenden Hauptprogramm lauscht und seine UI mit den ankommenden Informationen auffrischt.

Zum Erzeugen des Binaries aus dem Source-Code holt der Dreisprung in Listing 3 den Code abhängiger Libraries von Github herein, compiliert die ganze Enchilada und erzeugt schließlich ein Binary clock-main. Von der Kommandozeile aus aufgerufen färbt es das Terminal schwarz und malt die dynamisch im Sekundentakt tickende Stoppuhr in einer gerahmten Box hinein (Abbildung 3). Zu beachten ist, dass die tview-Library mindestens Go 1.18 benötigt, wer noch eine ältere Version fährt, muss vorher upgraden.

Listing 3: build-clock.sh

    1 go mod init clock-main
    2 go mod tidy
    3 go build clock-main.go clock.go

Kein Spielzeug

Nun aber von der Spielzeuguhr hin zur eigentlichen Applikation, die im Hintergrund das Netzwerk prüft und die Ergebnisse aller Tests periodisch in der grafischen Oberfläche auffrischt.

Das aus Listing 4 später compilierte Programm wird wifi heißen, wie man auf Amerikanisch zum WLAN sagt, obwohl sich die Applikation natürlich genauso auf verdrahtete Netzwerke ansetzen lässt. Zur Darstellung der Testergebnisse nutzt es in Zeile 8 das Tabellen-Widget des tview-Projekts. Insgesamt 5 Reihen mit jeweils zwei Spalten enthalten links eine Beschreibung des Tests und rechts das dynamisch aufgefrischte Ergebnis.

Listing 4: wifi.go

    01 package main
    02 import (
    03   "strings"
    04   "github.com/rivo/tview"
    05 )
    06 func main() {
    07   app := tview.NewApplication()
    08   table := tview.NewTable().SetBorders(true)
    09   table.SetBorder(true).SetTitle("Wifi Monitor v1.0")
    10   newPlugin(app, table, "Time", clock)
    11   newPlugin(app, table, "Ping", ping, "www.google.com")
    12   newPlugin(app, table, "Ping", ping, "8.8.8.8")
    13   newPlugin(app, table, "Ping", ping, "173.228.85.1")
    14   newPlugin(app, table, "Ping", ping, "69.147.88.7")
    15   newPlugin(app, table, "Ifconfig", nifs)
    16   newPlugin(app, table, "HTTP", httpGet, "https://youtu.be")
    17   err := app.SetRoot(table, true).SetFocus(table).Run()
    18   if err != nil {
    19     panic(err)
    20   }
    21 }
    22 func newPlugin(app *tview.Application, table *tview.Table,
    23     field string, fu func(...string) chan string, arg ...string) {
    24   if len(arg) > 0 {
    25     field += " " + strings.Join(arg, " ")
    26   }
    27   row := table.GetRowCount()
    28   table.SetCell(row, 0, tview.NewTableCell(field))
    29   ch := fu(arg...)
    30   go func() {
    31     for {
    32       select {
    33       case val := <-ch:
    34         app.QueueUpdateDraw(func() {
    35           table.SetCell(row, 1, tview.NewTableCell(val))
    36         })
    37       }
    38     }
    39   }()
    40 }

Bei der Definition der unterschiedlichen Ränder des Fensters und der Tabelle gilt es genau hinzusehen: Das Table-Widget verfügt über eine Funktion SetBorders(), die festlegt, ob die Tabelle Zeilen- und Spaltenlinien einzeichnet oder nicht. Andererseits ruft Zeile 9 auch noch SetBorder() (Singular) auf, was sich nicht auf die Tabelle bezieht sondern auf die sogenannte "Box" (einem Container), in der diese liegt, und einen Rand um die Applikation mitsamt einer Überschrift am oberen Ende zeichnet.

Über einen Kamm geschert

Jede Tabellenzeile bekommt nun ein Testprogramm zugewiesen. So landet die tickende Uhr in der ersten Zeile, zwei Netzwerk-Pings in den Zeilen 2 und 3, die Anzeige lokaler IPs in Zeile 4 und ein HTTP-Request auf den Youtube-Server in Zeile 5.

Die Integration dieser Plugins in die Tabelle erledigt die Funktion newPlugin(), deren Aufrufe in den Zeilen 10 bis 14 jeweils eine Referenz auf die Applikation und deren Tabelle mitgeschickt bekommen, sowie eine Beschreibung des jeweiligen Tests als String und eine Funktion, die den Test ausführt.

Wie sich an der Signatur von newPlugin() in Zeile 20 ablesen lässt, erwartet die Funktion die ihr übergebene Testfunktion fu in einem interessanten Format: Damit die Testfunktion allen Anwendungen gerecht wird, akzeptiert sie eine variablen Anzahl von String-Argumenten (...string in Go) und gibt einen Channel zurück, auf dem der Aufrufer später Ergebnisse vom Typ string abholen kann. Ein Beispiel für eine Testfunktion haben wir bereits in Listing 2 gesehen, mit clock() entsteht eine Stoppuhr, deren aktuelle Zeitstempel die Tabelle der grafischen Oberfläche nun im Sekundentakt in der ersten Zeile anzeigt.

Zum Verbandeln der Testfunktion mit einer Tabellenzeile hängt Zeile 26 bei jedem Aufruf eine neue Zeile an die Tabelle an. Dann ruft Zeile 27 die Testfunktion auf, die ihrerseits einen Channel zurückgibt und im Hintergrund ihren Netzwerk-Test startet. Um dessen Ergebnisse abzufangen startet Zeile 28 eine neue nebenläufige Goroutine mit einer Endlosschleife, die mittels einer select-Anweisung auf dem Channel lauscht. Kommt dort ein String an, frischt Zeile 33 mit table.SetCell den Inhalt des zugewiesenen Tabellenfelds auf. Damit dies auch tatsächlich auf dem Bildschirm erscheint, muss die Anweisung an den GUI-Verwalter weitergeleitet werden, was die Funktion app.QueueUpdateDraw() erledigt, und wonach die GUI das Tabellenfeld beim nächsten Auffrischvorgang neu zeichnet.

Palim-Palam!

Nun gilt es, neue Netzwerk-Tests nach Schema F in die Tabelle einzuhängen. Jeder Test besteht aus einer Funktion, die ein optionales String-Argument akzeptiert, einen Channel zurückreicht, und nebenläufig die ihr übertragene Testaufgabe wieder und wieder ausführt, und die Ergebnisse über den Channel an den Aufrufer zurückreicht.

Das Anpingen von Servern oder deren IP-Adressen übernimmt Listing 5 mit der Funktion ping, die entweder einen Hostnamen oder eine IP-Adresse als Argument entgegennimmt. Zurück an den Aufrufer gibt sie einen Channel, in dem später Nachrichten der nebenläufig ausgeführten Tests eintreffen.

Listing 5: ping.go

    01 package main
    02 import (
    03   "fmt"
    04   "github.com/prometheus-community/pro-bing"
    05   "time"
    06 )
    07 func ping(addr ...string) chan string {
    08   ch := make(chan string)
    09   firstTime := true
    10   go func() {
    11     for {
    12       pinger, err := probing.NewPinger(addr[0])
    13       pinger.Timeout, _ = time.ParseDuration("10s")
    14       if err != nil {
    15         ch <- err.Error()
    16         time.Sleep(10 * time.Second)
    17         continue
    18       }
    19       if firstTime {
    20         ch <- "Pinging ..."
    21         firstTime = false
    22       }
    23       pinger.Count = 3
    24       err = pinger.Run()
    25       if err != nil {
    26         ch <- err.Error()
    27         time.Sleep(10 * time.Second)
    28         continue
    29       }
    30       stats := pinger.Statistics()
    31       ch <- fmt.Sprintf("%v ", stats.Rtts)
    32       time.Sleep(10 * time.Second)
    33     }
    34   }()
    35   return ch
    36 }

Ganz wie die Kommandozeilen-Utility ping schickt Listing 5 ICMP-Pakete an die angegebene Adresse und nutzt dafür das Paket pro-bing von Github, das es in Zeile 4 hereinzieht. Die neue Pinger-Instanz pinger aus Zeile 12 setzt in Zeile 13 einen Timeout von 10 Sekunden. Sind diese verstrichen, nimmt der Pinger an, dass etwas schiefgelaufen ist und der Server nicht erreichbar ist. Klappt etwa die Namensauflösung des Servernamens nicht, posaunt Zeile 15 die Meldung in den Channel und Zeile 16 wartet anschließend zehn Sekunden, bevor continue in der folgenden Zeile in die nächste Iteration der Endlos-Forschleife ab Zeile 11 springt, um es erneut zu versuchen.

Erste Runde

Beim ersten Eintritt ist die Variable firstTime auf true gesetzt und Zeile 20 schickt über den Channel ch den String Pinging ... an den Aufrufer zurück, damit dieser weiß, dass der Test in Arbeit ist. Die Funktion Run() in Zeile 24 führt entsprechend Zeile 23 drei Pings auf das angegebene Netzwerkziel aus und blockiert den Programmfluss solange dies läuft. Tritt ein Fehler auf, leitet Zeile 26 diesen per Channel an den Aufrufer weiter, und nach zehn Sekunden Pause geht continue in Zeile 28 in die nächste Runde.

Kommt auf die ausgeschickten ICMP-Pakete eine Antwort, ist das Netzwerk offensichtlich intakt und der Aufruf von Statistics() in Zeile 30 holt die statistischen Daten der absolvierten Tests ein. Die Antwortzeiten der einzelnen Ping-Requests liegen in stats.Rtts als Array-Slice von Sekundenwerten im Fließkommaformat. Zeile 31 verpackt alle drei Werte im Array-Slice kurzerhand mit dem Platzhalter %v im Formatstring in einen String, den Zeile 31 sofort in den Channel schiebt, von wo ihn der Aufrufer am anderen Ende aufschnappt und in seiner grafischen Oberfläche anzeigt.

Verbindung steht?

Verbindet sich ein WLAN-Client mit dem Router, bekommt er eine IP-Adresse zugewiesen, die er im Erfolgsfall mit Kommandos wie ifconfig anzeigen kann. Zu wissen, ob's geklappt hat, ist bei der Fehlersuche hilfreich, also forscht der Plugin in Listing 6 nach lokalen IP-Adressen auf den Netzwerkschnittstellen des Rechners.

Das net-Paket aus Gos Standardfundus bietet hierzu die Funktion Interfaces(), die in Zeile 26 alle Netzwerk-Interfaces des Rechners zurückgibt. Bei einem Laptop im WLAN sind das normalerweise zwei, die WLAN-Karte und das Loopback-Interface, ist er aber zusätzlich verdrahtet, können es auch mehr sein. Jedes dieser Interfaces führt nun, falls es verbunden ist, eine oder mehrere IP-Adressen, die Addrs() in Zeile 31 einholt und die for-Schleife ab Zeile 38 abklappert.

Listing 6: eth.go

    01 package main
    02 import (
    03   "net"
    04   "sort"
    05   "strings"
    06   "time"
    07 )
    08 func nifs(arg ...string) chan string {
    09   ch := make(chan string)
    10   go func() {
    11     for {
    12       eths, err := ifconfig()
    13       if err != nil {
    14         ch <- err.Error()
    15         time.Sleep(10 * time.Second)
    16         continue
    17       }
    18       ch <- strings.Join(eths, ", ")
    19       time.Sleep(10 * time.Second)
    20     }
    21   }()
    22   return ch
    23 }
    24 func ifconfig() ([]string, error) {
    25   var list []string
    26   ifaces, err := net.Interfaces()
    27   if err != nil {
    28     return list, err
    29   }
    30   for _, iface := range ifaces {
    31     addrs, err := iface.Addrs()
    32     if err != nil {
    33       return list, err
    34     }
    35     if len(addrs) == 0 {
    36       continue
    37     }
    38     for _, addr := range addrs {
    39       ip := strings.Split(addr.String(), "/")[0]
    40       if net.ParseIP(ip).To4() != nil {
    41         list = append(list, iface.Name+" "+ip)
    42       }
    43     }
    44   }
    45   sort.Strings(list)
    46   return list, nil
    47 }

In Amerika hat kaum jemand ipv6-Adressen zuhause, also filtert Zeile 40 alles was nicht nach ipv4 aussieht, heraus, bevor es den Namen der Schnittstelle (name, zum Beispiel "en0") und die IP (ohne den Subnetz-Zusatz /xx) an den Array-Slice list anhängt. Diese sortiert Zeile 45 alphabetisch und Zeile 46 gibt den Slice von IP-Strings an den Aufrufer der Funktion ifconfig() in Zeile 12 zurück. Dort passiert das Gleiche wie schon in vorher besprochenen Plugins, nämlich dass Ergebnisse wie etwa Fehlermeldungen oder erfolgreich eingeholte IP-Listen als kommaseparierte Strings in den Channel eingespeist werden, auf dem das Hauptprogramm lauscht und eintrudelnde Nachrichten in der zugewiesenen Tabellenspalte anzeigt. Steht also in der Ifconfig-Zeile der Terminal-UI ein Eintrag im häuslich verwendeten IP-Bereich um 192.168.0.*, dann steht offensichtlich die Verbindung zum Router, listet die Spalte hingegen nur das Loopback-Interface, stimmt etwas mit der Vergabe der IP-Adressen nicht.

Ganze Seiten holen

Schließlich bietet Listing 7 noch einen End-to-End-Test, indem es die Youtube-Titelseite vom Internet holt. Läuft dieser Test erfolgreich durch, ist alles in Butter, und nachdem er in der letzten Zeile der UI auch noch die zum Einholen der Seite verstrichene Zeit in Sekunden angibt, lässt sich die Geschwindigkeit der ISP-Verbindung abschätzen. Abbildung 1 zeigt, dass der Test zum Einholen der Seite ganze 0,142 Sekunden benötigte, und das ist vorbildlich.

Listing 7: www.go

    01 package main
    02 import (
    03   "fmt"
    04   "net/http"
    05   "time"
    06 )
    07 func httpGet(arg ...string) chan string {
    08   ch := make(chan string)
    09   firstTime := true
    10   go func() {
    11     for {
    12       if firstTime {
    13         ch <- "Fetching ..."
    14         firstTime = false
    15       }
    16       now := time.Now()
    17       _, err := http.Get(arg[0])
    18       if err != nil {
    19         ch <- err.Error()
    20         time.Sleep(10 * time.Second)
    21         continue
    22       }
    23       dur := time.Since(now)
    24       ch <- fmt.Sprintf("%.3f OK ", dur.Seconds())
    25       time.Sleep(10 * time.Second)
    26     }
    27   }()
    28   return ch
    29 }

Zur Ermittlung dieser Zahl setzt Listing 7 in Zeile 17 mit der Funktion Get() einen HTTP-Request ab, der solange blockt, bis die Daten eingetrudelt sind oder der Server einen Fehler liefert. Hängt die Anzeige in der Tabellenspalte später demnach bei "Fetching ..." stimmt etwas mit der Verbindung nicht. In diesem Fall sollten die anderen Tests Hinweise auf die Ursache geben. Klappt hingegen die Auflösung des Hostnamens wegen fehlerhafter DNS-Konfiguration schon nicht, schiebt Zeile 19 die Fehlermeldung in den bereitgestellten Channel, von wo das Hauptprogramm den Fehler aufschnappt und anzeigt.

Klappt alles, misst Zeile 23, wie lange es gedauert hat. Hierzu subtrahiert sie von der aktuellen Zeit die in Zeile 16 gesetzte Startzeit des Requests und schiebt den so erhaltenen Sekundenwert als Fließkommazahl in den Channel, auf dass er mit der Meldung "OK" in der Tabellenspalte erscheine.

Um aus den Sourcen des Hauptprogramms in Listing 4 und den Test-Plugins aus den Listings 5-7, sowie der Uhr aus Listing 1 das Binary wifi zu kompilieren, einschließlich der Github-Pakete und deren Abhängigkeiten, ist der Dreisprung aus Listing 8 aufzurufen.

Listing 8: build-wifi.sh

    1 go mod init wifi
    2 go mod tidy
    3 go build wifi.go clock.go eth.go ping.go www.go

Dann bringt der Aufruf des Binaries wifi die Terminal-UI hoch und der User erfährt, wie es um das Netzwerk steht. Bei Bedarf lassen sich natürlich selbstgeschriebene Plugins nach dem gleichen Schema einhängen und in weiteren Tabellenzeilen anzeigen. Endlich Klarheit darüber, was tatsächlich abgeht, wenn das Internet mal wieder spinnt!

Infos

[1]

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

[2]

Wie Go Datums- und Zeitangaben formatiert: https://pkg.go.dev/time#pkg-constants

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