Go Context Package

Go Context Package

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:

  • Werte an einen Request zu binden wenn z. B. im Logging eine Request-ID mit ausgegeben werden soll oder wenn Security Informationen durch die Anwendung geschleust werden sollen.
  • Die Verarbeitung ganzer Aufrufketten abzubrechen wenn z. B. die HTTP Verbindung des aufrufenden HTTP-Clients abbricht und eine weitere Verarbeitung keinen Sinn mehr macht, da der Client das Ergebnis nie bekommen wird.
  • Funktionsaufrufen einen Timeout mitzugeben wenn z. B. nur eine gewisse Zeit auf die Abarbeitung eines Funktionsaufrufs, auch eines Remote-Calls, gewartet werden soll.

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:

  • Contexte nie in struct types (in Attributen) speichern.
  • Der propagierte context.Context sollte immer der erste Parameter einer Funktion sein und (am Besten) ctx heißen
  • Kein nil Context als Parameter in Funktionen übergeben. Wenn man sich nicht sicher ist welcher Context übergeben werden soll/kann, so kann der TODO Context verwendet werden.
  • Keine optionalen Funktionsparameter als Context Variablen in Funktionen übergeben.
  • Das gleiche Context Objekt kann gleichzeitig in mehreren Go-Routinen verwendet werden.

Kurze Historie

Das 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.

Context Interface

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(...).

Context mit Werten

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.

Canelation und Deadline

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()
}

Context mit Timeout

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.

HTTP Server Beispiel

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

 

Kennen Sie schon das Buch zum Thema?

Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.

  • Von den Sprachgrundlagen bis zur Qualitätssicherung
  • Architekturstil verstehen und direkt anwenden
  • Idiomatic Go, gRPC, Go Cloud Development Kit
  • Cloud-native Anwendungen erstellen
Microservices mit Go Buch

zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)

Kontakt

Source Fellows GmbH

Source Fellows GmbH Logo

Lerchenstraße 31

72762 Reutlingen

Telefon: (0049) 07121 6969 802

E-Mail: info@source-fellows.com