Cloud Microservice mit Go/Golang implementieren

Cloud Microservice mit Go/Golang implementieren

Bei der Umsetzung einer Anwendung über Microservices steht die Modularisierung der Architektur im Vordergrund. Einzelne Bestandteile der Anwednung werden als eigenständiger Service verwaltet und können auch dementsprechend unabhängig von anderen Services aktualisiert oder ausgetauscht werden. Jeder Microservice sollte eine einzige Businessfunktion bereitstellen.

Do One Thing and Do It Well

Im Gegensatz zu einem Anwendungs-Monolithen, der natürlich auch intern sauber modularisiert werden kann und natürlich auch sollte, ergeben erst die Summe von mehreren Services die Anwednung. In solch einer Architektur nimmt die Infrastruktur eine zentrale Rolle ein.

Wo und wie man seinen Microservice deployt ist natürlich jedem selbst überlassen. Verbreitet haben sich allerdings Docker-Container, die jeweils einen Service beinhalten. Diese lassen sich dann zum Beispiel über docker-compose oder in einer öffentlichen Cloud ausführen.

Entwicklet werden sollte ein Microservice nur von einem kleineren Team und falls sich das Team für eine Lösung mittels Go/Golang entscheiden sollte stehen mehrere Microservice Bibliotheken/Frameworks bereit.

Der Artikel gibt einen Überblick über Cloud Microservice Frameworks bzw. Bibliotheken für Go.

Go Microservice Frameworks

Die hier aufgeführten Implementierungen sind wohl aktuell die beliebtesten. Zur Einschätzung der Beliebtheit und Verbreitung sind die "Stars on GitHub" und "Forks on GitHub" pro Projekt Stand (Anfang 2019) angegeben. Einfluss genommen hat natürlich auch das Google Ranking sowie die Recherche mehrerer Vorträge zum Thema.

Betrachtet werden folgende Umsetzungen:

NameURLStars on GitHubForks on GitHub
Go Microhttps://github.com/micro/go-microca. 5600ca. 600
Go Kithttps://github.com/go-kit/kitca. 12500ca. 1300
Gizmohttps://github.com/NYTimes/gizmoca. 2500ca. 170
Kitehttps://github.com/koding/kiteca. 2300ca. 200

Go Micro

Go Micro is a pluggable RPC framework. It’s used for distributed systems development.

Bei Go Micro handelt es sich um ein Microservice Framework, dass Grundfunktionalität für verteilte Anwendungen wie RPC oder Event-Driven basierte Kommunikation umsetzt. Nutzt man das vorgegebene Standardverhalten kann man schnell produktive Anwendungen erstellen. Man hat allerdings immer die Möglichkeit tief in das Frameworkverhalten einzugreifen und eigene, vielleicht abweichenden, Anforderungen umzusetzen.

Wichtig ist hier zu sehen, dass es sich um ein RPC Framework handelt. Go Micro stellt standardmäßig eine gRPC Schnittstelle zur Verfügung. Kein Rest/HTTP.

Durch die Implementierung bestimmter Interfaces lassen sich einzelner Funktionalitäten von Go Micro anpassen bzw. austauschen. So lassen sich z. B. folgende Bestandteile von Go Micro über sogenannte Plugins verändern:

  • Service Discovery
  • Load Balancing
  • Message Encoding

Als Beispiel seien hier Apache Kafka als alternativer Message-Broker oder etcd als alternative Service Registry genannt.

Go Micro Architektur

Go Micro Architektur

Go Micro Beispiel

Ein kleines Beispiel soll den Aufbau bzw. die Programmierung von und mit Go Micro darstellen. Die benötigten Abhängigkeiten wurden innerhalb eines Docker Images zusammengestellt, so dass nur mit dem Docker Image eine lauffähige Anwendung erstellt werden kann.

Das Docker Image macht alle Abhängigkeiten der Anwednung "sichtbar".

Das komplette Beispiel kann auch in GitHub gefunden werden.

from golang:1.12

ENV GO111MODULE on

RUN apt-get update && apt-get install -y unzip

# Install the Protoc Compiler
RUN cd /go && \
    wget https://github.com/protocolbuffers/protobuf/releases/download/v3.7.1/protoc-3.7.1-linux-x86_64.zip && \
    unzip protoc-3.7.1-linux-x86_64.zip

# Install the Protoc Go compiler
RUN go get -u github.com/golang/protobuf/protoc-gen-go

# Install the Go Micro Extension for Protoc Compiler
RUN go get github.com/micro/protoc-gen-micro

# Install all the Go-Micro stuff
RUN go get github.com/micro/go-micro@v1.1.0

WORKDIR /go/app

Benötigt wird neben der Go Runtime (hier Version 1.12) ein Proto-Compiler plus Extension für Go und Go Micro. Der Proto Compiler kann direkt von GitHub geladen und ausgepackt werden. Die Erweiterungen können über go get Kommandos installiert werden (go get -u github.com/golang/protobuf/protoc-gen-go bzw. go get github.com/micro/protoc-gen-micro).

Für das Docker Image wird zusätzlich gleich die Abhängigkeit zu Go Micro installiert (go get github.com/micro/go-micro@v1.1.0). das hat den Vorteil, dass bei einem Neustart des Images/Containers nicht immer alle Abhängigkeiten heruntergeladen werden müssen.

Gebaut und gestartet werden kann das Docker Image mit folgenden Kommandos (Ausführung im Projekt-Root Verzeichnis):

docker build -t go-micro .
docker run -v `pwd`:/go/app --rm -ti go-micro /bin/bash

Nachdem die Umgebung aufgebaut ist, kann mit der eigentlichen Programmierung begonnen werden. Im Folgenden wird von der Verzeichnisstruktur des GitHub Projektes ausgegangen.

Verzeichnisstruktur für Go Micro Beispiel

Verzeichnisstruktur für Go Micro Beispiel

Service Definition

Startpunkt für eine Service Implementierung bei Go Micro ist eine ProtoBuf Datei (siehe hierzu auch ProtoBuffer/protobuf mit Go nutzen), die mit Hilfe des Proto-Compilers in die entsprechende Implementierung überführt werden muss.

syntax = "proto3";

service HelloService {
	rpc Hello(HelloRequest) returns (HelloResponse) {}
}

message HelloRequest {
	string name = 1;
}

message HelloResponse {
	string greeting = 2;
}

Nach dem Start des Docker Images im Root Verzeichnis des Projektes erreicht man das mit einem einfach protoc Aufruf:

cd /go/app
mkdir -p /go/app/server/hello
protoc --proto_path=/go/app/proto \
       --micro_out=server/hello \
       --go_out=server/hello \
       /go/app/proto/hello.proto

Die Parameter --micro_out und --go_out weisen den Compiler an entsprechenden Go Code für Go Micro zu erzeugen. Im oben dargestellten Beispiel wird der Code für die Serverseite erzeugt.

Der generierte Code für Client und Server unterscheiden sich an dieser Stelle nicht. Aus Gründen der Übersichtlichkeit des Codes und der Abhängigkeiten wird dieser zweimal generiert. Einmal für den Server und ein weiteres Mal für den Client.

Service Implementierung

Der Protoc Aufruf generiert unter anderem das Interface HelloServiceHandler, dessen Implementierung man bei Go Micro als neuen Service anmeldet. Das generierte Interface erzwingt, zusätzlich zu den eigentlichen Parametern und im Unterschied zu einer "normalen" gRPC Generierung, die Mitgabe eines Contexte Objektes. Das bietet natürlich Vorteile in Bezug auf den Abbruch oder Timeoutsteuerung einzelner Calls.

type HelloServiceHandler interface {
	Hello(context.Context, *HelloRequest, *HelloResponse) error
}

Für die Anmeldung des Service an Go Micro bzw. dessen Registry wird eine mico.Service Instanz benötigt, die man über micro.NewService(..) erzeugen kann. Als Parameter für diese Factory Methode muss ein micro.Name angegeben werden unter dem der Service in der Registry später gefunden werden kann (hier hello). Mit dem Aufruf von hello.RegisterHelloServiceHandler meldet man die Instanz dann bei Go Micro an.

// Create a new service. Optionally include some options here.
service := micro.NewService(
    micro.Name("hello"),
)

// Init will parse the command line flags.
service.Init()

// Register handler
hello.RegisterHelloServiceHandler(service.Server(), new(HelloService))

Der Aufruf von service.Run() startet dann schlußendlich den Service und er ist von außen aufrufbar.

# go run main.go
2019/xx/xx 12:20:23 Transport [http] Listening on [::]:33359
2019/xx/xx 12:20:23 Broker [http] Connected to [::]:45623
2019/xx/xx 12:20:23 Registry [mdns] Registering node: hello-88616a93-ff23-44fb-8b8f-ed0f8126e088

Die komplette Server Implementierung befindet sich auch bei GitHub.

Client Implementierung

Auf Clientseite ist der Ablauf bei der Implementierung analog. Zuerst muss aus der .proto Datei eine Implementierung erzeugt werden, dann kann der eigene Client Code den generierten Code nutzen.

Für den Client werden im Beispiel nur die Ausgabeverzeichnisse umgestellt (--micro_out und --go_out - client).

cd /go/app
mkdir -p /go/app/client/hello
protoc --proto_path=/go/app/proto \
       --micro_out=client/hello \
       --go_out=client/hello \
       /go/app/proto/hello.proto

Der Client Code lässt sich analog zum Server erstellen. Zuerst wird ein Service angelegt, danach kann die Hello Methode aufgerufen werden.

service := micro.NewService(micro.Name("hello.client"))
service.Init()

// Create new greeter client
greeter := hello.NewHelloService("hello", service.Client())

// Call the greeter
rsp, err := greeter.Hello(context.TODO(), &hello.HelloRequest{Name: "Kristian"})
if err != nil {
    fmt.Println(err)
}

// Print response
fmt.Println(rsp.Greeting)

Der Start des Client, wieder über das Docker Image, erzeugt folgende Ausgabe:

# go run main.go
Hello Kristian

Anmerkungen

Was im Beispiel auffällt ist, dass beim Client keine Serveradresse angegeben wird. Über die Registry findet der Client den Server "automatisch". standardmäßig nutzt Go Micro hier Multicast DNS.

Service Discovery - Automatic service registration and name resolution. Service discovery is at the core of micro service development. When service A needs to speak to service B it needs the location of that service. The default discovery mechanism is multicast DNS (mdns), a zeroconf system. You can optionally set gossip using the SWIM protocol for p2p networks or consul for a resilient cloud-native setup.

Mächtig wird Go Micro allerdings erst mit den sogenannten Wrappern. Sie sind eine Umsetzung des Decorator Patterns und ermöglichen fast beliebige Funktionalität an bestehenden Services hinzuzufügen. Ähnlich arbeiten AOP (Aspekt-Orientierte) Frameworks wie z. B. AspectJ bei Java.

Um z. B. ein einfaches Log Statement bei jedem Aufruf auszugeben kann folgender Wrapper eingesetzt werden:

// logWrapper is a handler wrapper
func logWrapper(fn server.HandlerFunc) server.HandlerFunc {
	return func(ctx context.Context, req server.Request, rsp interface{}) error {
		log.Printf("[wrapper] server request: %v", req.Endpoint())
		err := fn(ctx, req, rsp)
		return err
	}
}

Die Konfiguration für den jeweiligen Service erfolgt beim NewService Aufruf.

service := micro.NewService(
    micro.Name("hello"),
    micro.WrapHandler(logWrapper),
)

Klassische Anwendungsfälle sind hier z. B.:

  • Security/ Authentifizierung
  • Logging
  • Rate limiting
  • Instrumentierung für Monitoring (z. B. Prometheus)

Für viele Anwendungsfälle bietet Go Micro bereits eine Implementierung an. Weitere Beispiele zur Verwendung von Go Micro kann man auch den Beispielen im Projekt GitHub entnehmen.

Go Kit

Go kit is a programming toolkit for building microservices (or elegant monoliths) in Go.

Bei Go kit handelt es sich ebenfalls um ein auf RPC ausgelegtes Microservice Framework mit dem recht einfach und schnell ein eigener Microservice auf Golang Basis aufgebaut werden kann. Laut der Projektseite liegen die Ziele von Go kit bei:

  • Funktionsfähigkeit in heterogene SOAs - nicht nur Kommunikation mit Go Kit Services
  • RPC als primäres (und aktuell einziges) Messaging Pattern - kein Unterstützung für z. B. MPI, pub/sub, CQRS, etc.
  • Austauschbare Serialisierungs und Transportformate (nicht nur JSON über HTTP)
  • Zusammenarbeit mit bestehender Infrastruktur - kein Zwang zu spezifischen Werkzeugen oder Technologien
  • Nicht das Rad neu erfinden - Keine Dinge implementieren, die von anderer Software genutzt werden kann
  • Keine Vorgaben zu Deployment, Konfiguration, Process Supervision, Orchestration, etc.

Das Konzept von Go kit sieht vor, dass jede Anwendung aus mindestens 3 Schichten besteht:

  • Transport Layer
  • Endpoint Layer
  • Service Layer

Innerhalb der Serviceschicht befindet sich die eigentliche Businessfunktion. Die Endpoint und Transport Schichten sorgen dafür, dass der Service für Clients erreichbar wird. Eine Transport-Schicht Ausprägung bindet hierbei einen konkreten technischen Übertragungsmechanismus wie HTTP oder gRPC an einen Endpoint.

Aufbau von Go kit

Aufbau von Go kit

Natürlich ist es möglich mehrere Transports an einen Endpoint zu binden und somit mehrere technische Anbindungen an den Service zu ermöglichen.

Endpoints kann man wie den Controller in einer MVC Architektur betrachten.

Zusätzlich kennt Go kit den Begriff der Middleware. Nicht funktionale Aspekte wie Sicherheit oder Logging werden über Middleware Implementierungen an die eigentliche Serviceausprägung konfiguriert. Middlewares stellen eine klassische Umsetzung des Decorator Patterns dar.

Klassische Anwendungsfälle für Middlewares sind hier wieder z. B.:

  • Security/ Authentifizierung
  • Logging
  • Rate limiting
  • Instrumentierung für Monitoring (z. B. Prometheus)

Go kit Beispiel

Auch hier soll ein kleines Beispiel den Aufbau bzw. die Programmierung von und mit Go kit darstellen.

Das komplette Beispiel kann auch in GitHub gefunden werden.

Die Anwendung besteht aus einem einfachen StringFlipper Service, der einen übergebenen String umkehrt und das Ergebnis zurück zum Client schickt. Die Kommunikation mit dem Service erfolgt über JSON/HTTP.

Als Http Trace sieht das so aus:

POST  HTTP/1.1
Host: localhost:8080
Cache-Control: no-cache

{
"v": "Hello"
}

und die Antwort:

HTTP/1.1 200 OK

{"v":"olleH"}

Businessfunktion/ Service

Ausgangspunkt für die Beispielimplementierung ist die eigentliche Businessfunktion: der StringFlipper Service. Dieser besitzt das Interface StringFlipper mit einer entsprechenden Implementierung StringFlipperImpl.

package service

type StringFlipper interface {
	FlipString(val string) string
}

type StringFlipperImpl struct {
}

func (sf StringFlipperImpl) FlipString(val string) string {

	a := []rune(val)
	for i := len(a)/2 - 1; i >= 0; i-- {
		opp := len(a) - 1 - i
		a[i], a[opp] = a[opp], a[i]
	}

	return string(a)

}

Endpoint

Damit der Service nach außen verfügbar wird, wird, wie oben beschrieben, eine Transport/Endpoint Anbindung benötigt. Die Endpoint Definition von Go kit sieht wie folgt aus:

type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)

Das heißt, dass die Parameter als empty Interfaces definiert sind und man dementsprechend beliebige Typen übergeben kann. Im Beispiel sollen das zwei Request/Response structs sein: StringFlipperRequest bzw. StringFlipperResponse.

package model

type StringFlipperRequest struct {
	Value   string `json:"v"`
}

type StringFlipperResponse struct {
	Value   string `json:"v"`
}

Der Endpoint kann dementsprechend mit folgender Methode erzeugt werden:

func makeEndpoint(sf service.StringFlipper) endpoint.Endpoint {
	return func(_ context.Context, request interface{}) (interface{}, error) {
		req := request.(model.StringFlipperRequest)
		v := sf.FlipString(req.Value)
		return model.StringFlipperResponse{v}, nil
	}
}

Innerhalb dieser Methode wird dann der eigentliche Service (sf.FlipString(req.Value)) aufgerufen und ein entsprechendes Modell-Objekt zurück geliefert (model.StringFlipperResponse{v}).

Transport

Jetzt muss nur noch ein passender Transport aufegbaut werden, der wiederrum als Handler an einem http Server angemeldet werden kann.

handler := httptransport.NewServer(
    makeEndpoint(flipper),
    decodeRequest,
    encodeResponse,
)
http.Handle("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))

Wie man sieht müssen für die Erstellung eines HTTP-Transports noch ein Encoder und ein Decoder (decodeRequest und encodeResponse) mit übergeben werden. Diese können für das Beispiel recht einfach aussehen schließlich soll nur der Http-Request als JSON interpretiert werden und JSON als Ergebnis geliefert werden.

func decodeRequest(_ context.Context, r *http.Request) (interface{}, error) {
	var request model.StringFlipperRequest
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		return nil, err
	}
	return request, nil
}

func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
	return json.NewEncoder(w).Encode(response)
}

Die komplette Main Func sieht dann wie folgt aus:

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"

	"github.com/kkoehler/golang/go-kit/service"

	"github.com/go-kit/kit/endpoint"
	httptransport "github.com/go-kit/kit/transport/http"
	"github.com/kkoehler/golang/go-kit/model"
)

func makeEndpoint(sf service.StringFlipper) endpoint.Endpoint {
	return func(_ context.Context, request interface{}) (interface{}, error) {
		req := request.(model.StringFlipperRequest)
		v := sf.FlipString(req.Value)
		return model.StringFlipperResponse{v}, nil
	}
}

func decodeRequest(_ context.Context, r *http.Request) (interface{}, error) {
	var request model.StringFlipperRequest
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		return nil, err
	}
	return request, nil
}

func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
	return json.NewEncoder(w).Encode(response)
}

func main() {
	flipper := service.StringFlipperImpl{}

	handler := httptransport.NewServer(
		makeEndpoint(flipper),
		decodeRequest,
		encodeResponse,
	)

	http.Handle("/", handler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Nach dem Start kann der Service getestet werden (siehe oben).

go run main.go

Anmerkungen

Ähnlich wie bei Go micro wird Go kit erst richtig mächtig wenn man mit den Middlewares arbeitet (bei Go micro Wrappern). So kann man den nichtfunktionalen Umfang der Anwendung recht schön und unabhängig von der Businesslogik definieren bzw. implementieren.

Die Middleware Definition sieht so aus:

type Middleware func(Endpoint) Endpoint

Es muss also "nur" ein Endpoint angenommen werden und ein Endpoint wieder zurückgeliefert werden. Logging könnte so implementiert werden:

func loggingMiddleware(logger log.Logger) Middleware {
	return func(next endpoint.Endpoint) endpoint.Endpoint {
		return func(_ context.Context, request interface{}) (interface{}, error) {
			logger.Log("msg", "calling endpoint")
			defer logger.Log("msg", "called endpoint")
			return next(request)
		}
	}
}

Ohne viel Fantasie lässt sich vorstellen, dass man die oben genannten Funktionalitäten recht einfach einklinken kann.

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