Im der Golang Standardbibliothek befindet sich das context Package, in dem das Interface Context
definiert wird. Mit dessen Hilfe können Deadline oder Abbruch/Cancelation Signale sowie aufrufabhängige Werte (request-scoped values) zwischen Prozessen ausgetauscht werden.
Durch den Einsatz von Context Objekten wird es in einer Go Anwendung möglich:
Context Objekte sollten möglichst durch die ganze Aufrufkette propagiert, sprich als Parameter mitgegeben, werden. Steht kein Context Objekt mehr zur Verfügung können auch die oben genannten Features nicht mehr genutzt werden. In den Server eingehende Aufrufe sollten dementsprechend immer einen neuen Context anlegen und diesen bei weiteren Funktionsaufrufen als Parameter mitgeben. Ausgehende Calls sollten ebenfalls einen Context als Parameter akzeptieren und diesen auswerten.
Im Unterschied zu den ThreadLocals von Java müssen bei Go die Context Objekte immer explizit mitgegeben werden! Hier findet Go typisch keine “Black-Magic” statt.
Jedes Glied in der Aufrufkette kann für sich entscheiden ob es den original Context oder einen davon abgeleiteten Context erstellt und diesen nutzt. Einem abgeleiteter Context können zusätzliche Werte, wie z. B. eine Request-ID, oder Bedingungen, wie z. B. eine Timeout-Information, mitgegeben werden. Die Erstellung eines abgeleiteten Contexts erzeugt eine Eltern-Kind Beziehung zwischen den Contexten und führt beim Beenden des Eltern-Context dazu, dass das Done Signal an alle Kind-Contexte durch die Context-Hierarchie propagiert wird. Die Kind-Contexte beenden sich entsprechend ebenfalls.
Zum Erstellen eines abgeleiteten Kind-Contexts stehen die Funktionen context.WithCancel
, context.WithDeadline
und context.WithTimeout
zur Verfügung, die jeweils einen Kind-Context mit entsprechender Information anlegen. Bei jeder dieser Funktionen muss ein Parent-Context mit übergeben werden.
Folgender Code-Schnipsel zeigt wie man einen Context anlegt, der nach 100 Millisekunden beendet wird:
context.WithTimeout(parentCtx, 100*time.Millisecond)
Bei der Entwicklung mit Contexten müssen folgende Punkte beachtet werden:
context.Context
sollte immer der erste Parameter einer Funktion sein und (am Besten) ctx
heißenDas Context Package entstand formals unterhalb des net
Packages und wurde erst ab Go Version 1.7 in die Standardlibrary im Package context
übernommen. Dies hat an manchen Stellen der Bibliotheken die Auswirkung, dass zwei Funktionen zur “selben” Funktionalität vorhanden sind (man wollte die Kompatibilität zwischen den Go Versionen aufrecht erhalten). Eine mit der “alten” Signatur ohne Context und eine neue Funktion mit einem Context Parameter. An dieser Stelle sei ein Beispiel aus dem net
Package gezeigt.
type Dialer struct {
...
}
func (d *Dialer) Dial(network, address string) (Conn, error)
func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error)
...
Wie man sieht gibt es die Funktion Dial
und DialContext
.
Wenn mögich sollte man die Signaturen mit
context.Context
verwenden.
Das Context Interface ist recht schlank gehalten und enthält lediglich 4 Funktionen:
type Context interface {
Done() <-chan struct{}
Deadline() (deadline time.Time, ok bool)
Err() error
Value(key interface{}) interface{}
}
Die Funktionen Done()
, Deadline()
und Err()
sind für die Funktionalität rund um Cancelation und Dealine vorhanden. Mit der Funktion Value(key interface{}) interface{}
können aufrufbezogene Werte aus dem Context gelesen werden. Das Schreiben von Werten in den Context erfolgt immer über die Anlage eines abgeleiteten Contexts durch den Aufruf von context.WithValue(...)
.
Das Speichern von aufrufbezogenen (request-scoped) Werten im Context kann, wie bereits oben erwähnt, z. B. für die Ausgabe von Werten im Logging benutzt werden. In folgendem Beispiel wird eine request-id
einem Context hinzugefügt und im Log ausgegeben. Für die Ausgabe des Logs wird im Beispiel die Logrus Bibliothek eingesetzt.
package main
import (
"context"
log "github.com/sirupsen/logrus"
)
func main() {
type keyval string
//Erzeugung Parent-Context
ctx := context.Background()
//hier wird der Context mit Werten erstellt
vctx := context.WithValue(ctx, keyval("request-id"), keyval("123"))
//Logrus Logger vorkonfigurieren
requestLogger := log.WithFields(
log.Fields{
"request-id": vctx.Value(keyval("request-id")),
})
//Loggen ohne angabe weiterer Werte
//diese sind im Logger vorkonfiguriert
requestLogger.Info("Some important message")
requestLogger.Info("Next important message")
}
Die Erstellung eines Parent-Context geschieht über ctx := context.Background()
. Dies ist hier nur notwendig da noch kein Context vorhanden ist. Im zweiten Schritt wird ein neuer abgeleiteter Context erstellt dem bei der Initialisierung ein Key/Value-Paar mitgegeben wird. Dieser Wert kann über den angegebenen Schlüssel später ausgelesen werden.
Die Signatur der context.WithValue()
, mit der man einen abgeleiteten Context mit Werten erstellt, ist wie folgt:
func WithValue(parent Context, key, val interface{}) Context
Und kann dann so aufgerufen werden:
vctx := context.WithValue(ctx, keyval("request-id"), keyval("123"))
Als Schlüssel und Inhalt können Werte jedes Go-Typs verwendet werden. Allerdings wird empfohlen keine primitiven Typen sondern eigene Typen zu verwenden damit keine Verwechslungen bei der Nutzung entstehen kann.
A key can be any type that supports equality; packages should define keys as an unexported type to avoid collisions.
Aus diesem Grund wurde hier im Beispiel der Typ keyval
eingeführt und verwendet.
Die eigentliche Log-Ausgabe erfolgt nun mittels der Logrus Bibliothek. Sie bietet unter Anderem die Möglichkeit das Log-Statement über (Meta-)Felder zu erweitern. So kann man einem einfachen Text-Statement noch Informationen mitgeben, die ebenfalls ausgegeben werden sollen. Verwendet man z. B. eine JSON-basierte Ausgabe des Log-Files werden diese Felder als eigenständige JSON-Fields ausgegeben und müssen nicht umständlich aus dem eigentlichen Log-Statement geparst werden.
Das Anhängen der Felder an einen Logrus Logger geschieht über die Funktion log.WithField(...)
. Danach kann die eigentliche Textausgabe erfolgen.
log.WithField("request-id", ctx.Value(keyval("request-id"))).Info("Output with Field")
Der gezeigte Code würde im Standardformat folgende Ausgabe erzeugen:
INFO[XXXX] Output with Field request-id=123
Das Logging im JSON Format kann man über die Funktion
log.SetFormatter(&log.JSONFormatter{})
konfigurieren.
Im JSON Format würde folgendes ausgegeben:
{
"level":"info",
"msg":"Output with Field",
"request-id":"123",
"time":"..."
}
Möchte man mehrere Log Statements mit den gleichen Werten ausgeben empfiehlt sich die Konfiguration eines vorkonfigurierten separaten Loggers.
requestLogger := log.WithFields(
log.Fields{
"request-id": vctx.Value(keyval("request-id")),
})
requestLogger.Info("Some important message")
requestLogger.Info("Next important message")
Die Ausgabe im Log wäre wieder ähnlich, allerdings muss nicht bei jedem Aufruf die request-id
mit übergeben werden.
INFO[0000] Some important message request-id=123
INFO[0000] Next important message request-id=123
Das komplette Beispiel kann auch in GitHub gefunden werden.
Die Cancelation und Deadline Funktionalität des Context Package beruht auf dem Go Pipeline Concurrency Pattern (siehe auch “Go Concurrency Patterns: Pipelines and cancellation”). Bei diesem Ansatz wird über einen receive-only Channel mit dem Client, also mit der aufgerufenen Funktion, kommuniziert. Der mittels Context übergebene Channel muss dann entsprechend im Client ausgewertet werden. Die einzige Nachricht, die über diesen Channel ausgetauscht wird, ist die Information dass der Context beendet ist. Der Client arbeitet im Prinzip solange bis er seine Arbeit erledigt hat oder über den Channel das Signal zur Beendigung kommt (Done
-Signal).
Da es sich um einen receive-only Channel handelt, der dem Client durch den Context übergeben wird, kann er selbst keine Nachrichten auf den Channel schicken und somit darüber auch nicht den Parent-Context beenden.
Das Warten im Client, also der aufegrufenen Funktion, könnte so aussehen:
func (ctx context.Context) {
<-ctx.Done()
}
In einem einfachem Beispiel soll gezeigt werden, wie man mit einem Context arbeiten kann. Eine Go-Routine soll endlos Integer Werte in einen Channel schreiben. Der Abbruch dieser Schleife soll nach einer bestimmten Zeit über einen Context erfolgen. Der Client wird im diesem Fall seine Arbeit also nie beenden können.
Im ersten Schritt ist hier die Funktion ohne Context und Abbruch:
func generate() <-chan int {
ch := make(chan int)
go func() {
i := 0
for {
ch <- i
i++
}
}()
return ch
}
Der enstprechende Aufruf der Funktion erzeugt eine Endlosschleife:
func main() {
c := generate()
for i := range c {
fmt.Printf("count %d \n", i)
}
}
Das
for...range
Statement wertet solange Werte eines Channels aus, bis dieser Beendet wird. Das passiert in obigem Beispiel allerdings nicht.
Im nächsten Schritt wird ein Context Objekt erstellt, das der generate
Funktion als Parameter mitgegeben werden soll. Über die Funktion WithTimeout()
wird ein abgeleiteter Context erstellt, der nach einem Timeout von einer Sekunde beendet wird. Also auf den Channel ein Done
-Signal schickt.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
c := generate(ctx)
Auf dieses Signal muss nun die generate
Funktion reagieren. Hierzu liest man den Channel mittels <-ctx.Done()
aus und falls dort eine Nachricht eingetroffen ist wird der Schreib-Channel (Variable ch
) geschlossen und die Funktion verlassen.
Go bietet hierzu das select-switch
Statement. Falls keine Nachricht eingetroffen ist werden weiter Werte in den Schreib-Channel (Variable ch
) geschrieben.
select {
case ch <- i:
i++
case <-ctx.Done():
close(ch)
return
}
Durch das Schließen des Channels ch
, der in der main
Funktion im for... range
Statement verwendet wird, wird auch diese Schleife abgebrochen und die Anwendung beendet sich (nach ca. 1 Sekunde).
Das ganze Beispiel sieht dann wie folgt aus:
package main
import (
"context"
"fmt"
"time"
)
func generate(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
i := 0
for {
select {
case ch <- i:
i++
case <-ctx.Done():
close(ch)
return
}
}
}()
return ch
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
c := generate(ctx)
for i := range c {
fmt.Printf("count %d \n", i)
}
}
Das komplette Beispiel kann auch in GitHub gefunden werden.
Am Ende noch ein Beispiel mit dem Go eigenen HTTP-Server. Er bietet mittlerweile die Möglichkeit ihn “sauber” zu beenden (graceful shutdown). Das heißt, dass er nach dem srv.Shutdown(..)
Aufruf noch so lange aktiv bleibt bis alle offenen Verbindungen beendet wurden.
Als Parameter kann man der Funktion einen Context mitgeben, den man natürlich wiederrum mit einem Timeout versehen kann und somit nach einer gewissen Zeit einen Shutdown forcieren kann.
Hier ein komplettes, einfaches Beispiel:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"time"
)
func handle(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World"))
}
func main() {
srv := http.Server{Addr: ":8081"}
idleConnsClosed := make(chan struct{})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
go func() {
sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt)
<-sigint
// We received an interrupt signal, shut down.
if err := srv.Shutdown(ctx); err != nil {
// Error from closing listeners, or context timeout:
log.Printf("HTTP server Shutdown: %v", err)
}
log.Print("HTTP server Shutdown successful")
close(idleConnsClosed)
}()
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
// Error starting or closing listener:
log.Printf("HTTP server ListenAndServe: %v", err)
}
<-idleConnsClosed
}
Viel Spaß mit dem Context Package!
19.07.2019
Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.
zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)
Source Fellows GmbH
Lerchenstraße 31
72762 Reutlingen
Telefon: (0049) 07121 6969 802
E-Mail: info@source-fellows.com