Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de

Warum Go? – Teil 2: Einfach gut

Teil 1 der Einführung in Google Go hat einen ersten Einblick in die Struktur und die Eigenschaften der Sprache sowie auf die enthaltenen Werkzeuge gegeben. Es wurde schnell deutlich, wie wenig spektakulär die Sprache eigentlich ist. Da stellt sich die Frage, warum sie in einigen Anwendungsgebieten einen solchen Erfolg hat.
Author Image
Frank Müller

Author


  • 27.03.2020
  • Lesezeit: 13 Minuten
  • 19 Views

Der Sprachumfang von Go liegt mit seiner ausgewogenen Gestaltung zwischen absoluter Systemnähe und interpretierten Hochsprachen in einem Sektor, welcher sich sowohl für technische als auch für geschäftliche Anwendungen eignet. Die einzige Einschränkung ist vielleicht die fehlende Bibliothek zu grafischen Oberflächen. Doch diese werden heute zunehmend durch Web UIs ersetzt.

Pakete

Auch das Package database/sql für relationale Datenbanksysteme ist auf recht niedriger Ebene. Dafür wurde aber erstaunlich schnell eine Vielzahl von NoSQL-Datenbanken unterstützt, seien es nun Key/ Values, Dokumente oder Zeitreihen. Dazu kommen diverse direkt in Go entwickelte Datenbanksysteme, die natürlich eine ebenso gute Unterstützung genießen.

Ein Umfeld, in dem sich die Standardbibliotheken wohlfühlen, sind die der Netzwerkanwendungen. Sei es nun direkt mit TCP, UDP, Unix Domain Sockets und dem Domain Name Resolving. Dazu kommen noch ein leistungsfähiges HTTP-Package mit diversen Subpackages, Mail, JSON-RPC und SMTP. Ebenso wichtig ist Sicherheit, weshalb Go über eine Vielzahl leistungsfähiger Kryptopakete verfügt.

Schließlich runden Marshallings und Encodings diese Packages ab und unterstützen die Implementierung verteilter Anwendungen, speziell in Clouds und Container-Umgebungen wie Google Cloud, Docker, containerd und Kubernetes. Nicht umsonst sind viele Komponenten der Cloud Native Computing Foundation [CNCF] selbst in Go entwickelt. Ein wichtiger Faktor hierfür ist sicherlich die Idee, die Runtime der Sprache in das erzeugte einfache Binary einzubinden. Dieses lässt sich so ohne Abhängigkeiten zu externen Bibliotheken oder Laufzeitsystemen durch ein einfaches Kopieren auf einem Server oder in einem Container installieren. Dies funktioniert durch das mögliche Cross-Compiling auch zwischen Betriebssystemen. So kann auf dem Mac oder dem PC entwickelt und das Linux-Binary schnell im Container getestet werden.

Gleiches gilt auch für die Arbeit in entsprechenden CI/CD-Umgebungen. Gerade bei Letzterem erfreut, neben der hohen Ausführungsgeschwindigkeit der Sprache, auch die sehr kurze Übersetzungszeit. Dies hilft Entwicklern sowohl am eigenen Rechner, auch durch das Caching der Unittests unterstützt, wie auch beim Warten auf das Feedback der CI/CD-Pipeline. Die Binarys werden schnell erzeugt, Testenvironments dank aktueller Container-Landschaften ebenso schnell ausgerollt und die Integrationstests durchgeführt. So erhalten Entwickler schnell ihr Feedback und die Turn-around-Zeiten werden verringert. Der aktuelle Stand auf dem historischen Weg vom im Wasserfall-Modell entwickelten Monolithen über agil entwickelte Services einer SOA hin zu einer Vielzahl zusammenspielender Microservices, welche via CI/CD kontinuierlich in die Produktion fließen.

Fallstudien von Migrationen auf Go zeigen, natürlich immer abhängig von den zuvor genutzten Sprachen und Architekturen, um den Faktor 3 verbesserte Übersetzungs- und um den Faktor 24 verbesserte Testzeiten. Hierbei erzeugte Services sind in der Lage, 70.000 Anfragen auf Servern mit 20 MB RAM zu bedienen, was bei Mercado Libre zu einer Reduktion von 32 Servern auf 4 führte. Und bei Testkonvertierungen von ISO8583-Finanztransaktionsdaten nach JSON bei American Express [Can19] erreichte Go mit 140.000 Transaktionen pro Sekunde die zweitbeste Geschwindigkeit in einem Vergleich von Go, C++, Java und Node.js. Letztendlich ausschlaggebend für Go waren dann die Einfachheit und Geradlinigkeit der Sprache sowie die Geschwindigkeit und Fähigkeit der Tools.

Ein Beispiel – Web-API

Nun lassen sich weder die Übersetzungsgeschwindigkeit noch die Performanz der Sprache in einem Artikel darstellen. Dafür jedoch die Realisierung einer in vielen aktuellen Services benötigten Web-Schnittstelle, hier bewusst ohne externe Lösungen. Ausgangspunkt ist das Package net/http, inzwischen ein recht umfangreiches Paket. Es enthält sowohl einen Client als auch einen Server. Letzterer kommt hier im Beispiel zum Einsatz. Der http. Server kann dabei direkt genutzt werden und bietet hier eine Vielzahl von Möglichkeiten zur Einflussnahme auf seine Arbeit. Doch für den Mal-eben-so-Server gibt es auch Komfortfunktionen, welche den Server nutzen, das Leben der Entwicklerin aber erleichtern.

Der zweite für die Arbeit mit dem Server wichtige Typ ist das Interface http.Handler. Es ist nur mit der einen Methode ServeHTTP() definiert. Diese erhält als Argumente den Request in Form des Typs http.Request und das Interface http.ResponseWriter für das Schreiben der Antwort. Insofern kann jede Instanz eines Typs mit dieser Methode als Handler eingesetzt werden. Die Angabe eines implements ist nicht notwendig, Go arbeitet hier komfortabel mit Duck Typing. Wenn der implementierte Handler quakt, dann ist er ein Handler. Dies führt zunehmend zur Konvention, dass Funktionen und Methoden in Packages zwar komplexe Typen zurückgeben, jedoch lokal definierte Interfaces mit so wenig Methoden wie möglich als Argumente erwarten, http.Handler ist so eines.

Eine der schönen Spezialitäten der Sprache zeigt sich hier mit dem Funktionstyp http.HandlerFunc. Er definiert nur den Typ von Funktionen mit der gleichen Signatur wie die oben genannte Serve-HTTP(). In Go dürfen Funktionstypen ebenfalls über Methoden verfügen, die hierin die definierte Funktion selbst nutzen dürfen. Die http.HandlerFunc macht dies in einfachster Form. Es ist selbst wieder die ServeHTTP() und ruft ausschließlich ihren Empfänger auf (s. Listing 1).

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
Listing 1: Methode HandlerFunc.ServeHTTP()

Benötigt ein Handler nicht mehr als nur eine Funktion, kann diese nun einfach direkt implementiert und über ein Type Casting als Handler genutzt werden (s. Listing 2). Normale Handler werden hingegen in der Regel als strukturierte Typen implementiert, mit privaten Feldern und eben solchen Helfermethoden. Hier ist kein Casting notwendig (s. Listing 3).

func FooHandlerFunc(
w http.ResponseWriter,
r *http.Request,
) { ... }
...
// Nutzung der Handler-Funktion.
// h:=http.HandlerFunc(FooHandlerFunc)
http.ListenAndServe(":8080", h)
Listing 2: Nutzung einer HandlerFunListing 2: Nutzung einer HandlerFun
type BarHandlerstruct {
one string
two int
three *BazType
}
func NewBarHandler(a string) *BarHandler { ... }
func (h *BarHandler) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) { ... }
func (h *BarHandler) privateMethod() { ... }
...
// Nutzung des Handlers.
http.ListenAndServe(":8080", NewBarHandler("eins"))
Listing 3: Implementierung eines Handlers

An dieser Stelle zeigt sich ein weiterer pragmatischer Ansatz der Sprache. In Go wird auf einen expliziten Konstruktor verzichtet. Vielmehr werden einfache Funktionen verwendet, welche Instanzen erzeugen und initialisieren. Diese sind auch nicht auf Strukturen eingeschränkt, sondern jegliche Art zu initialisierender Typ, mit und ohne Methoden. So existieren auch genug Beispiele, in denen mehrere Funktionen jeweils Funktionen eines definierten Typs erzeugen. Dies kann für Optionen genutzt werden. Sie haben durch die Definition im gleichen Package Zugriff auf die privaten Felder eines exportierten Structs. Als Variadic können keine, mehrere oder alle Argumente sprechend übergeben werden, auch mehrfach, um Werte an Slices anzuhängen (s. Listing 4) – eine praktische Form von Closures, deren Notation sich auch nur mit dem fehlenden Namen von einer normalen Funktion unterscheidet.

type Option func(b *BarHandler)
func One(one string) Option {
return func(b *BarHandler) {
b.one = one
}
}
func Two(two int) Option {
return func(b *BarHandler) {
b.two = two
}
}
func NewBarHandler(opts ...Option) *BarHandler {
"b := &BarHandler{",
 one: defaultOne,
 ...
}
for _, opt := range opts {
 opt(b)
}
return b
}
...
http.ListenAndServe(
":8080",
NewBarHandler(Two(2), One("eins")),
)
Listing 4: Optionen via Funktionen

Nun wurde soweit immer nur ein Handler gestartet, was jedoch in der Regel nicht ausreichend ist. So müsste der Handler komplex gestaltet werden und sich sowohl um Pfade als auch HTTP-Methoden für die Verteilung der Logik kümmern. Dies ist mit einem großen switch-Baum und vielen privaten Methoden sicherlich möglich, doch keiner wird dies als guten Ansatz erachten.

Go verfolgt an dieser Stelle den Ansatz des Wrappings. Das Package http bietet auch den ServeMux, welcher wie oben beschrieben mit http.NewServeMux() instanziiert wird. Anschließend können mit Handle() beziehungsweise HandleFunc() Handler für übergebene Pfadmuster registriert werden. Basierend auf diesen Pfaden werden dann später die Requests verteilt (s. Listing 5). Hierfür verfügt ServeMux ebenfalls über die Methode ServeHTTP und implementiert so automatisch das Handler-Interface. Damit kann der ServeMux direkt vom Server genutzt werden.

mux := http.NewServeMux()
mux.HandleFunc("/foo", FooHandlerFunc)
mux.Handle("/bar", NewBarHandler(One("1"))
http.ListenAndServe(":8080", mux)
Listing 5: Handler registrieren

Hier zeigt sich die Eleganz dieses einfachen Interface-Konzepts und der schmalen Schnittstelle. Dieses lässt sich nun für RESTful APIs weiter fortsetzen, mit einfachen Wrappern als kleine Toolbox. Ein Beispiel sind geschachtelte URLs mit Identifiern, also von /api/orders über /orders/1234 und /orders/1234/items bis hin zu /orders/1234/items/5. Hier hilft ein NestedHandler, welcher die individuellen Handler via AppendHandler() hinzufügt (s. Listing 6).

type NestedHandler struct {
handlers []http.Handler
}
func (h *NestedHandler) AppendHandler(h http.Handler) {
h.handlers = append(h.handlers, h)
}
Listing 6: NestedHandler für RESTful APIs

Das ServeHTTP() des NestedHandlers analysiert nun die URL und fügt dem Request eventuell enthaltene Identifikatoren von Entitäten als Header hinzu. Abschließend wird die Länge der URL ausgewertet und der Request an den entsprechenden Handler weiter geleitet.

Nun sind die URLs nur ein Bestandteil von RESTful APIs, die HTTP-Methoden jedoch noch ein weiterer. Doch auch hier können Wrapper helfen. Es bieten sich zwei Methoden als Lösungen an. In der ersten werden in einem MethodHandler pro Methode individuelle Handler registriert. ServeHTTP() prüft, ob ein passender Handler registriert wurde, und leitet den Request entsprechend weiter. Andernfalls wird der Fehler http.StatusMethodNotAllowed an den Aufrufer zurück geliefert.

Eine Lösung mit weniger Handlern arbeitet hingegen mit der einmaligen Definition von Interfaces pro HTTP-Methode (s. Listing 7). Der dazugehörige MetaMethodHandler wird nun in seiner ServeHTTP() prüfen, ob sein geschachtelter Handler das dazu passende Interface implementiert. In diesem Fall ruft er die entsprechende Methode auf. Andernfalls wird die ServeHTTP() des Handlers aufgerufen, wo zum Beispiel eine Standardverarbeitung oder eine Fehlermeldung durchgeführt werden kann (s. Listing 8).

type GetHandler interface {
ServeHTTPGet(w http.ResponseWriter, r *http.Request)
}
type PostHandler interface {
ServeHTTPPost(w http.ResponseWriter, r *http.Request)
}
Listing 7: Interfaces für HTTP-Methoden
switch r.Method {
case http.MethodGet:
if h, ok := mmh.handler.(GetHandler); ok {
 h.ServeHTTPGet(w, r)
 return
 }
case http.MethodPost:
 ...
}
mmh.handler.ServeHTTP(w, r)
Listing 8: MetaMethodHandler

Damit ist dieses Spielfeld natürlich noch nicht ausgereizt. Oft sind APIs an Rechte gebunden, was ein SecurityHandler übernehmen kann, zum Beispiel über den JSON Web Token. So können die geschachtelten Handler entwickelt und getestet werden, ohne sich um die Zugriffssicherheit zu kümmern. Gleiches gilt für Wrapper für das Logging und für eventuelle Metriken. Sie alle können individuell entwickelt werden, ihre Komplexität beschränkt sich in der Regel auf einen schmalen Aspekt. Die so aufgebaute Werkzeugbox bleibt mit nur wenigen Wrappern übersichtlich und ist sehr gut wartbar.

Eine Spezialität der Methoden sei aber noch erwähnt. In Go können nil-Werte dennoch Empfänger von Methodenaufrufen sein. Eine panic() analog einer NullPointerException wird nicht ausgeführt. Stattdessen kann die Methode auf nil prüfen und entsprechend reagieren (s. Listing 9). Hiervon sollte man sich dann nicht überraschen lassen.

type Any struct { ... }
func (a *Any) DoSomething() {
if a == nil {
log.Printf("Help, I'm nil")
 return
}
...
}
...
var any *Any
// Call DoSomething() on nil value of Any.
any.DoSomething()
Listing 9: Methodenaufruf auf nil

Nebeneinander

Ein weiterer wichtiger Punkt in Go ist die Nebenläufigkeit. Leichtgewichtige Goroutinen und Channels erlauben eine sehr große Anzahl von nebeneinander ausgeführten Code-Fragmenten. Sie werden durch das Laufzeitsystem auf einen Thread-Pool verteilt, was selbst bei einer sehr großen Anzahl von Goroutinen mit einer geringen Anzahl paralleler Threads gut funktioniert. Der Grundgedanke der Nebenläufigkeit ist nicht mit der Parallelität gleichzusetzen. Vielmehr geht es um individuelle Routinen, welche mal nur einmalig zur Verarbeitung eines Datums oder einer Datenmenge und mal permanent im Hintergrund arbeitend eingesetzt werden. In letzterer kommt eine Endlosschleife zum Einsatz, hierin ein select, welches auf einen oder mehrere Channels lauscht und auf dieser Basis tätig wird.

Bei dem oben genannten http.Server kommt die Variante der Goroutine zur Verarbeitung nur eines Datums zum Einsatz. In diesem Fall ist es der http.Request. Der Server selbst lauscht in einer Endlosschleife auf einem net.Listener auf eintreffende Verbindungen. Nach einem erfolgreichen Verbindungsaufbau wird eine Instanz des privaten Typs http.conn erzeugt und der Variablen c zugewiesen. An dieser Stelle wird die Verarbeitung der Connection mit go c. serve(ctx) in den Hintergrund geschickt. Hierin werden Request und ResponseWriter erzeugt und an den Handler zur Verarbeitung weiter gegeben. Ein Pooling findet nicht statt, quasi nur ein Fire and Forget. Die Schleife des Servers steht nach dem go unmittelbar wieder zur Verfügung, was zu einer hohen Performanz bei Webservern führt.

Bei den Endlosschleifen arbeiten mal eigenständige Funktionen, mal aber Methoden von Structs als Backend. Sie empfangen auf einem oder mehreren Kanälen, was sie zu tun haben. Wird eine Antwort erwartet, so wird im Regelfall noch ein Antwort-Channel mitgeschickt, der Aufruf erfolgt komfortabel verpackt über eine Methode (s. Listing 10).

type addReq struct {
value int
respC chan int
}
type LittleServer struct {
ctx context.Context
sum int
addC chan addReq
...
}
func NewLittleServer(ctx context.Context) *LittleServer {
s := &LittleServer{
 ctx: ctx,
 sum: 0,
addC: make(chan *addC),
} go s.backend()
return s
}
func (s *LittleServer) Add(i int) int {
req := &addReq{
 value: i,
respC: make(chanint, 1),
}
s.addC<- req
return<-req.respC
}
func (s *LittleServer) backend() {
for {
 select {
 case <-ctx.Done():
 return
case req := <-s.reqC:
 s.sum += req.value
 req.respC <- s.sum
 case ...:
 ...
 }
}
}
Listing 10: Schleifen in Goroutinen

In Listing 10 können nun weitere Operationen hinzugefügt werden. Die Goroutine mit ihrer sequenziellen Ausführung sorgt dafür, dass immer nur eine Änderung oder ein Zugriff zurzeit stattfindet. Dies mag hier nicht sinnvoll erscheinen, ein sync.Mutex täte es auch. Doch bei komplexen Operationen mit längerer Laufzeit können durch gepufferte Channels Blockaden verhindert werden.

Der hier genutzte context.Context ist ein Typ der Standardbibliothek. Er dient dem Signalisieren des Arbeitsendes, zum Beispiel nach abgelaufenen Zeiten, zu einem definierten Zeitpunkt oder durch einen Aufruf einer zuvor für einen Context erzeugten Cancel-Funktion. Und die Anlage des Response-Channels in der Add()-Methode mit einem Puffer der Größe 1 dient dazu, dass die Backend-Schleife nicht blockiert, solange die Antwort nicht ausgelesen ist. Zudem ist es wichtig zu wissen, dass bei einem Empfang über mehrere Channels die Verarbeitungsreihenfolge nicht definiert ist.

Nun wird diese Form bei Goroutinen mit vielen Typen, für die jeweils ein Channel notwendig ist, schnell sehr unübersichtlich. Das select wächst, die hierin enthaltene Logik oder die Anzahl der aufgerufenen privaten Methoden als Counterparts zu den öffentlichen Methoden ebenso. Mit dem Muster je einer exportierten Methode als Schnittstelle, dem Serialisieren im Backend und einer privaten Methode für die Implementierung wächst der Overhead schnell. Glücklicherweise sind Funktionen, wie zuvor bereits erwähnt, ebenfalls Typen und lassen sich über Channels versenden. Hiermit vereinfacht sich das vorherige Beispiel (s. Listing 11).

type action func()
type LittleServer struct {
ctx context.Context
sum int
actionC chan action
}
func (s *LittleServer) Add(i int) int {
var sum int
s.actionC <- func() {
s.sum += i
sum = s.sum
}
return sum
}
func (s *LittleServer) backend() {
for {
 select {
 case <-ctx.Done():
 return
case doIt := <-s.actionC:
 doIt()
 }
}
}
Listing 11: Versand von Funktionen

Ohne Veränderung des Backends und ohne weitere Typen lassen sich so schnell und einfach weitere Operationen hinzufügen. Ihre Definition bleibt wie gewohnt in der exportierten Methode, während die Schleife im Backend nur für eine sequenzielle Ausführung verantwortlich ist. Ein zweiter Action-Channel mit einem dem Programm angemessenen Puffer kann zudem für asynchrone Methoden genutzt werden, die unmittelbar nach dem Versand der Aufgabe wieder an den Aufrufer zurückkehren.

Fazit

Die Beispiele hier sind nichts Großartiges, zeigen keine besonderen Eigenschaften der Sprache. Denn über diese verfügt sie nicht. Doch sie zeigen, wie einfach Go konstruiert ist, wie es ohne komplexe Bausteine auskommt und wie Lösungen direkt und unmittelbar umgesetzt werden können. So lassen sich einerseits schnell und einfach Anforderungen erfüllen, andererseits aber auch vorhandene und über die Jahre gewachsene Code-Mengen warten.

Im Gespräch mit den Freunden der Sprache wird gerade dies immer wieder betont. Sie möchten ihre Aufgaben erledigen können, ohne durch ein unübersichtliches Werkzeug hieran gehindert zu werden. Dies hat dafür gesorgt, dass sich Go nach inzwischen 10 Jahren auf dem Markt der vernetzt arbeitenden Backend-Systeme einen guten und stetig wachsenden Anteil erarbeitet hat.

Literatur und Links

[Can19]
B. Cane, Choosing Go at American Express, 25.11.2019, https://americanexpress.io/choosing-go/

[CNCF]
Cloud Native Computing Foundation, https://www.cncf.io

[Go]
The Go Programming Language, https://golang.org/

[Go2]
R. Griesemer, Go 2, Here we come!, The Go Blog, 29.11.2018,
https://blog.golang.org/go2-here-we-come

[Mue11]
F. Müller, Systemprogrammierung in Google Go, dpunkt. verlag, 2011

[Mue19]
F. Müller, Warum Go? – Teil 1: Tour de Force, in: JAVASPEKTRUM, 4/2019

. . .

Author Image

Frank Müller

Author
Zu Inhalten
Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.

Artikel teilen