Microservices mit Go
Wann lohnt sich Go im Vergleich zu Java?
Java ist der unangefochtene Platzhirsch beim Bau von Enterprise-Software. Doch für die Entwicklung von Microservices bietet die Programmiersprache Go durchaus Vorteile. Dieser Artikel zeigt, wie man mit Go Microservices bauen kann und vergleicht die wesentlichen Aspekte mit Java. Schließlich bietet er eine Entscheidungshilfe, wann man Microservices lieber mit Go als mit Java realisieren sollte.
Java im Überblick
Seit Jahren belegt Java Platz 1 im Tiobe Programmiersprachenindex mit einem extrem großen Open-Source-Ökosystem [1]. Es gibt wohl kaum eine Schnittstellentechnologie, für die nicht schon eine fertige Java-Anbindung existiert. Im Bereich der Microservice-Entwicklung hält das Java-Ökosystem viele Alternativen bereit: Von Java EE über Spring Cloud bis hin zu Lösungen wie Micronaut oder Quarkus von Red Hat. Was will man mehr?
Go im Überblick
Go wurde als C/C++-Alternative zur Entwicklung von Backend-Komponenten bei Google entwickelt und 2009 in der Version 1.0 Open Source gestellt. Dabei muss man wissen, dass Google seit jeher C++ im Backend einsetzt (z. B. für die Suchmaschine selbst). Die Anwendungsentwicklung wird aber zum großen Teil mit Python realisiert. Java spielt bei Google im Backend keine große Rolle (sehr wohl bei Android).
Das Go-Team ist sehr prominent und besteht aus: Robert Griesheimer (Entwickler der Java Hotspot VM), Ken Thompson (einer der maßgeblichen Erfinder von Unix) sowie Rob Pike (Erfinder von UTF-8) [2]. Das Buch zu Go hat kein geringerer als Brian Kernigham geschrieben, dessen Werk "The C Programming Language" vor fast 40 Jahren die Welt verändert hat. Inzwischen gibt es Go in der aktuellen Version 1.12. Go 2.0 ist bereits in der Abstimmung.
Der Fokus von Go sind verteilte und parallele Systeme bzw. systemnahe Komponenten. Go ist DIE Sprache im Cloud Native Stack der Cloud Native Software Foundation (CNCF) [3]. Alle wesentlichen Komponenten sind in Go geschrieben: Docker, Kubernetes, Etcd, Prometheus u.v.m. Das alles sind Gründe, sich Go genauer anzusehen.
Designziele von Go
Ziel bei Google war es, die Vorteile von C++ mit den Eigenschaften dynamischer Programmiersprachen wie Python oder Javascript zu verbinden, ohne deren Nachteile zu erben. Die Nachteile von C++ – wie langsame Kompilierung, fehleranfällige Programmierung und fehlende Unterstützung für plattformübergreifende Parallelprogrammierung – sollten gelöst werden. Die Vorteile dynamischer Sprachen sollten in einer kompilierten und typsicheren Sprache umgesetzt werden. Der wichtigste Design-Aspekt war und ist: Go zu programmieren soll erfahrenen Programmierern Spaß machen!
Zusammenfassend kann man bei Go folgende Designziele erkennen:
- KISS – Keep it stupid and simple (Anzahl Schlüsselwörter: Go=25, Ansi-C=32, Java=50),
- Schneller Compiler: Cross Compiler, Binärcode,
- Schnelle, echtzeitfähige Garbage Collection,
- Statisches Typsystem mit Laufzeitunterstützung (Reflection, Dynamic Types),
- Unterstützung von Nebenläufigkeit, Parallelität und Verteilung (Multi-Core / Cloud),
- Einfache Anbindung von C/C++-Code (ohne C-Adapter-Code),
- Statischer Linker (Single Binary) – Ideal für Docker-Container,
- Sprachfeatures aus modernen, dynamischen Sprachen,
- Unterstützung diverser Programmierstile (Prozedurale Programmierung, OOP, Funktionale Programmierung),
- Kompatibilität – Genau wie Java baut ein Go 1.0 Programm ohne Codeänderungen mit Go 1.12.
Auch wenn die Anzahl von Schlüsselwörtern kein verlässliches Maß zur Beurteilung der Komplexität einer Programmiersprache ist, fällt auf, dass Go nur über halb so viele Schlüsselwörter wie Java verfügt. Da die Sprachen eine gewisse Ähnlichkeit haben, ist die Anzahl aber ein Hinweis: Go soll kürzere und kompaktere Programme ermöglichen als Java. Sprachkonstrukte wie public static void sind in Go per Konvention gelöst und können weggelassen werden.
Go kompiliert nicht in Bytecode sondern in plattformspezifischem Binärcode. Der Compiler ist aber im Gegensatz zu javac ein Cross Compiler. Auf jeder Plattform kann Code für eine beliebige andere Plattform erzeugt werden. Dazu reicht es, die Environment-Variablen GOOS und GOARCH zu setzen. Damit entfällt die Notwendigkeit einer virtuellen Maschine.
Oft wird behauptet, dass Go keine objektorientierte Sprache ist, ohne die zugrundeliegenden Kriterien zu erwähnen. Go hat keine Klassen, kennt aber benutzerdefinierte Typen, Interfaces incl. Polymorphismus und Embedding. Vererbung kann man mit Interfaces und Embedding ersetzen. OO-Design-Patterns lassen sich direkt, sehr einfach und gut lesbar in Go implementieren. Insofern stimmt die oben erwähnte Aussage nicht.
Go unterstützt nebenläufige Programmierung und parallele Ausführung auf Multicore-Maschinen mit Go-Routinen. Go-Routinen sind leichtgewichtige, auf Threads abgebildete Abläufe (multiplexing), welche bei API-Aufrufen der Go-Standardbibliothek unterbrochen werden können. Das verbindet die Vorteile von Multi-Threading mit den Vorteilen des kooperativen Multitaskings. Für die Synchronisation zwischen Go-Routinen verwendet man Channels, deren Programmierung einfacher und weniger fehlerträchtig ist als die Verwendung von Locks und Monitoren in Java.
Der Erfolg und die Relevanz von Go liegt neben den Features sicher auch in der kompromisslosen Abwärtskompatibilität begründet. Ähnlich wie Java-Programme (ab JDK-Version 1.1) kann auch Go mit neueren Versionen ausgeführt werden. Das garantiert Investitionsschutz und ist besonders relevant für betriebliche Informationssysteme, die jahrelang laufen sollen und gewartet werden müssen.
Microservices mit Java (Schwächen)
Ressourcenbedarf
Java-Anwendungen haben einen hohen Grundbedarf an Ressourcen (Memory, CPU, Startzeit), verursacht durch Class Loading, Instrumentierung und dynamische Initialisierung. Ein minimaler Java-Microservice benötigt mindestens 64 MB Heap zuzüglich Ressourcen für die virtuelle Maschine (z. B. Class Cache, GC-Regionen ...). Außerdem kann die Startzeit einer Java-Anwendung selbst durchaus signifikant sein. Technologien wie Spring oder CDI können ein komplettes Scanning des Classpath zur Laufzeit erfordern. Damit erhöht sich die Startzeit zusätzlich. Für diese Probleme gibt es aber Lösungen. Von der GrallVM bis hin zum Quarkus Microservice Framework. Letztendlich sind das aber Workarounds, die ihre eigenen Probleme und Komplexität mitbringen.
Docker
Mit Docker können ganze Anwendungen in einer Datei, dem Container, ausgeliefert werden (ähnlich einer virtuellen Festplatte, in die sich die Anwendung plus aller benötigten Bibliotheken packen lässt). Alle heutigen Cloud-Plattformen verwenden diesen Mechanismus für das Deployment von Anwendungen.
Java-Anwendungen benötigen zusätzlich zum Bytecode eine virtuelle Maschine (JVM). Aufgrund der umfangreichen Abhängigkeiten in das Betriebssystem (.so Bibliotheken) enthalten viele Docker-Container ein komplettes Betriebssystem als Image. Dieses hat eine Größe von einigen 100 MB bis hin zu einem GB. Das ist heute zwar kein Problem, verlängert aber das Deployment und macht einen agilen Entwicklungsprozess schwierig (Change -> Build -> Test -> Deploy -> Change).
Footprint
Java-Anwendungen benötigen alle transitiven Abhängigkeiten von Bibliotheken (JARs) im Klassenpfad. Ein kleines Hadoop-Tool erfordert 100 MB und mehr an Bibliothekscode. Aufgrund der dynamischen Class-Loader-Architektur kann erst zur Laufzeit entschieden werden, welche Klassen wirklich benötigt werden. Oft ist das nur ein kleiner Prozentsatz des vorhanden Codes.
Threads
Threads sind schwergewichtige Betriebssystem-Ressourcen. Java-Anwendungen können außerdem Threads über lange Zeiten blockieren. Thread-Pools sind keine Lösung für das Problem, da langlaufende Aktionen zur einer kompletten Blockade führen können, falls mehr Anfragen bearbeitet müssen als Threads im Pool zur Verfügung stehen.
Diese vier Themen sind in Go anders gelöst. Deswegen bietet Go bei der Entwicklung von Microservices echte Vorteile gegenüber Java.
Microservices mit Go (Stärken)
Ressourcenbedarf
Go-Anwendungen sind statisch gelinkt. Ein Go-Binary ist direkt ausführbar und benötigt keine externen Bibliotheken oder sonstige Laufzeitumgebungen. Trotzdem sind Go-Programme vergleichsweise schlank. Ein einfacher Go-Microservice benötigt weniger als 10 MB Speicherplatz. Die Startzeiten liegen im Bereich von einigen Millisekunden. Es gibt keinen langwierigen Startvorgang (z. B. durch Classpath Scanning). Exzessiver Einsatz von Reflektion ist auch in Go-Bibliotheken möglich. Das wird per Konvention vermieden und kommt in der Praxis daher nicht vor. Zum Beispiel arbeitet das neue Dependency Injection Framework "wire" auf Basis von generierten und statisch gelinktem Code. Reflektion ist dabei nicht notwendig.
Docker
Da Go-Binaries keine externen Bibliotheken benötigen, kann ein Go-Binary in einen leeren Docker-Container gepackt werden (FROM SCRATCH). Der resultierende Docker-Container ist nur minimal größer als das Binary selbst. Das macht schnelles Deployment möglich.
Footprint
Die Go-Laufzeitumgebung bedarf keiner großen Speicher-Pools. Eine Go-Anwendung benötigt nur wenige MB, um zu starten und Logik auszuführen.
Threads
Go verwendet Go-Routinen. Es ist nicht möglich, direkt (ohne externen Systemaufruf) Threads zu erzeugen und zu verwenden. Go verwendet intern Threads für die Abarbeitung von Go-Routinen. Die Logik in Go-Routinen wird abschnittsweise auf Threads verteilt. Damit kann man um Faktoren mehr Go-Routinen als Threads starten. Anwendungen mit einer Million nebenläufigen Abläufen sind mit Go dadurch möglich. Das Starten einer Go-Routine passiert im Nanosekundenbereich. Es müssen i. d. R. keine weiteren Betriebssystem-Ressourcen allokiert werden.
Wie baut man Microservices mit Go?
Go hat die notwendige Unterstützung für RESTful-Services mit Http, JSON, XML & Co in der Standardbibliothek eingebaut. Die Standardbibliothek ist in der Mächtigkeit vergleichbar zum JDK. Allerdings wurde diese nicht in den 90er Jahren entworfen, sondern trägt ganz klar die Handschrift dieses Jahrzehnts mit HTTP 2-, JSON- und YAML-Support. Der folgende Code zeigt einen minimalen Service mit Starter-Code (main) und dem Handler-Code (handler.go).
Listing 1: Microservice-Starter: main.go
package main
import (
"log"
"net/http"
)
func main() {
router := http.NewServeMux()
router.HandleFunc("/customers", getAllCustomers)
log.Println("CustomerServer: Listening on
localhost/customers ...")
log.Fatal(http.ListenAndServe(":8080", router))
}
Die Funktion main() erzeugt einen serverseitigen Multiplexer/Router und installiert die Funktion getAllCustomers() als Handler unter der URL /customers. Die Funktion http.ListenAndServe() startet jetzt einen Listen-Socket und leitet HTTP-Anfragen an den Multiplexer weiter. Die Funktion blockiert und kehrt nur im Ausnahmefall (panic!) zurück. In diesem Fall beendet das Programm. Hinweis: Fail Fast ist bei Cloud-Native-Microservices ein übliches Pattern. Tritt eine echte Ausnahme auf, wird auf der Top-Ebene gar nicht mehr versucht, das Problem zu kompensieren. Die Cloud-Plattform (z. B. Kubernetes) startet nach der Beendigung einfach den Service automatisch durch.
Listing 2: Microservice-Handler: handler.go
package main
import (
"encoding/json"
"net/http"
)
// Customer type
type Customer struct {
Name string `json:"name"`
Address string `json:"adress"`
Tel string `json:"tel"`
}
// Static customer data.
var customers = []Customer{
Customer{"QAware GmbH", "Munich", "+49 000 12345678"},
Customer{"QAware GmbH", "Mainz", "+49 111 87654321"},
}
// Handler to get all customers encoded as JSON
func getAllCustomers(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(customers); err != nil {
panic(err)
}
}
Der Handler besteht aus einer Funktion und dem Datentyp Customer. Interessant sind die json:-Kommentare. Diese steuern das JSON-Marshalling analog zu Annotationen bei JAX-RS. Es gibt auch spezielle Kommentare für das Marshalling in XML. Diese können miteinander kombiniert werden. Der JSON-Encoder (json.NewEncoder) ist direkt in der Lage, Strukturen, Listen und Maps in einen Writer zu schreiben (hier der http.ResonseWriter). Das Encoding sollte auf der Ebene nie fehlschlagen. Ansonsten ist etwas komplett faul. In diesem Fall wird ähnlich wie in Java eine Runtime-Exception in Go eine Panic ausgelöst, welche dann den Server beendet (s. Starter).
Dieses Programm – bestehend aus zwei Dateien – kann mit dem Kommando go build. in ein Binary übersetzt werden. Das Binary hat per Default den Namen des aktuelle Verzeichnisses. Das Binary ist komplett startbar und ca. 7 MB groß. Will man dieses als Microservice in der Cloud laufen lassen, muß man das Binary noch in einen Docker-Container verpacken:
Listing 3: Code zum Bauen des Docker-Containers: Dockerfile
#
# Minimal Microservice Go Container using Docker Builder Pattern.
#
FROM golang:alpine as builder
RUN mkdir /build
ADD . /build/
WORKDIR /build
RUN apk add git
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o main .
FROM scratch
COPY --from=builder /build/main /app/
WORKDIR /app
Der Container "builder" enthält die komplette Go-Compiler-Suite und -Tools. Der generierte Container "scratch" enthält ausschließlich das von Go generierte Programm. Das Programm ist statisch gelinkt und hat keine weiteren Abhängigkeiten. Der finale Container ist nur 7 MB groß.
Allein an diesem kleinen Beispiel wird klar, dass Go sich hervorragend dazu eignet, kompakte Microservices für die Cloud zu bauen. Die Compile-, Build- und Deploymentzeiten sind minimal. Schneller geht es kaum noch. Was ist aber, wenn die Services anspruchsvoller werden, viel Logik besitzen oder umfangreiche Schnittstellen anbieten müssen? Hier reichen die Go-Bordmittel oft nicht mehr aus. Dafür gibt es aber Lösungen.
Komplexe Services mit Go
Wenn die Go-Bordmittel nicht mehr ausreichen, gibt es viele Möglichkeiten, Services zu bauen. Zum einen kann man sich mit Go-Swagger den kompletten RESTful-Webservice-Infrastruktur-Code aus einer Swagger-Schnittstellenbeschreibung generieren lassen [4].
Zum anderen gibt es RPC-Unterstützung direkt in Go eingebaut oder mit gRPC ein extrem leistungsfähiges RPC-Framework auf der Basis von Protocol Buffers. Außerdem stehen komplette Frameworks zur Erstellung von Microservices wie micro oder go-kit zur Verfügung.
Microservices mit Go und gRPC
Go und gRPC passen perfekt zusammen. Es gibt zwar auch gRPC-Bindings für Java; allerdings lässt sich der HTTP2-Code bisher nur im Jetty direkt betreiben. In anderen Servlet Engines hat man damit Probleme (natürlich geht ein Parallelbetrieb auf verschiedenen Sockets grundsätzlich immer).
- gRPC ist ein binärer und effizienter RPC auf der Basis von Protocolbuffers.
- gRPC hat Bindings zu allen gängigen Programmiersprachen.
- gRPC unterstützt synchronen RPC, Messaging, Streaming (client/server), Timeouts und Canceling.
- gRPC eignet sich damit ideal für die Service-zu-Service-Kommunikation zwischen Microservices.
Das folgende Beispiel zeigt einen Go-Microservice zur Generierung von zentral vergebenen und netzwerkweit eindeutigen IDs (UUIDs). Authentifizierung und Security sind Einsatzgebiete für derartige Dienste. Genau wie in Java ist man bei Microservices gut beraten, die Anwendungslogik von der technischen Schnittstelle zu trennen. Daher starten wir im folgenden Beispiel mit einer nackten Go-Schnittstelle.
Listing 4: Microservice-Schnittstelle zur Generierung von UUIDs
// Package idserv contains the IDService API.
package idserv
// IDService can be used to produce network wide unique ids.
type IDService interface {
// NewUUID generates an UUID with a given client prefix.
NewUUID(clientID string) string
}
Die Funktion NewUUID(string) string bekommt einen String aus Eingabe und liefert einen String als Ausgabe zurück. Der Ausgabestring enthält die eindeutige ID. Der Eingabestring identifiziert den Client. Der folgende Codeblock zeigt die Implementierung der Schnittstelle:
Listing 5: Microservice-Implementierung zur Generierung von UUIDs
// Package core contains the business logic of the IDService
package core
import (
"fmt"
"sync/atomic"
)
// IDServiceImpl type
type IDServiceImpl struct {
}
var lastID int64 // The last given Id.
// NewIDServiceImpl creates a new instance
func NewIDServiceImpl() *IDServiceImpl {
return new(IDServiceImpl)
}
// NewUUID implements the IDService interface.
func (ids *IDServiceImpl) NewUUID(clientID string) string {
result := atomic.AddInt64(&lastID, 1)
return fmt.Sprintf("%v:%v", clientID, result)
}
Die Implementierung dieser Schnittstelle ist weitestgehend trivial. Im wesentlichen wird nur eine Zahl fortlaufend hochgezählt. Persistenz ist hier noch nicht implementiert. Das Hochzählen am Server muss aber zwingend threadsafe sein. Daher wird hier zum Hochzählen die Funktion AddInt64() verwendet, um einen parallelen Aufruf von mehreren Clients zu unterstützen. Der Service kann nun über die Schnittstelle aufgerufen werden.
Listing 6: Microservice-Aufruf
// GenerateIds calls n-times NewUUID() in a loop and returns the result as slice.
func GenerateIds(count int, service idserv.IDService) []string {
result := make([]string, count)
for i := 0; i < count; i++ {
result[i] = service.NewUUID("c1")
}
return result
}
Das ganze funktioniert allerdings nur lokal. Wir haben jetzt noch keinen remote-fähigen Microservice gebaut, sondern nur eine lokale Funktion mit einer Schnittstelle versehen und diese genutzt. Interessant wird es, wenn man mit gRPC eine Proxy/Stub-Kombination dazwischenschaltet (s. Abb. 1)
Dahinter steckt die Idee, Funktionalität zuerst lokal zu entwickeln, zu testen und zu verifizieren, und erst dann die Verteilung zum spätestmöglichen Zeitpunkt einzubauen ("Monolith First"). Das kann man mit dem Proxy-Pattern erreichen. Das Proxy-Pattern der Gang of Four (Gamma, Helm, Johnson, Vlissides [5]) tauscht die Implementierung einer Schnittstelle durch ein Proxy-Objekt aus. Der Proxy leitet die Aufrufe an einen entfernten Service weiter. Dadurch kann die Businesslogik lokal getestet werden. Wenn die Logik passt, tauscht man per Dependency Injection die Implementierung durch den Proxy aus.
Kann ein Service nur über seine Remote-Schnittstelle getestet werden, verkompliziert das die Entwicklung ungemein. Schon bei einfachen Tests muss man Prozesse starten, diese synchronisieren und gemeinsam beenden.
Das Proxy-Pattern in der verteilten Verarbeitung gibt es schon lange. Es war schon in Technologien wie Microsoft DCOM oder Corba enthalten. Das Problem damals war, dass die Proxy/Stub-Kombination generiert war und vom Nutzer nur bedingt beeinflusst werden konnte. Remote-Schnittstellen haben aber grundsätzlich andere Eigenschaften als lokale Schnittstellen. Die Fallacies of Distributed Computing können zu neuen Fehlern führen [6]. Insofern macht es Sinn, anders als damals die Proxy/Stub-Kombination selbst zu implementieren. Hier hilft gRPC als Mechanismus, der verteilte Funktionsaufrufe umsetzt.
Die Businesslogik möglichst unabhängig von rein technischen Themen wie Netzwerkprotokollen oder API-Aufrufen umzusetzen, ist auch eine der Grundideen der Onion- oder Hexagonal-Architektur. Beide Modelle haben einen technologiefreien Anwendungskern. Technologiefrei bedeutet in diesem Zusammenhang, dass außer der Programmiersprache selbst keine Abhängigkeiten in Middleware-, Kommunikations- oder Datenbanktechnologien existieren. Die Zugriffe auf die Technik sind über Schnittstellen gekapselt (den Repository Interfaces). Ein technologiefreier Anwendungskern ermöglicht ein monolithisches Deployment von mehreren Services. Erst zum spätmöglichsten Zeitpunkt deployed man die Services einzeln.
Das Proxy/Stub-Pattern ist notwendig zur Umsetzung des Onion-Modells bei mehreren Services. Wenn das Domain-Model nur über Repository-Schnittstellen mit externen Services kommunizieren kann, sollte man die technische Zugriffslogik von der Fachlogik zu trennen.
Der folgende Quellcode-Abschnitt zeigt die gRPC-Schnittstellendefinition für den UUID-Generator. Die Funktion NewUUID bekommt als Parameter eine Client-ID (einen String) und liefert als Ergebnis ebenfalls einen String, die generierte und eindeutige ID. Man sieht, dass sich die gRPC-Schnittstellendefinition von der lokalen Go-Version leicht unterscheidet. So sind die Eingabe- und Ausgabeparameter in Strukturen (message) gekapselt und die Parameter durchnummeriert.
Listing 7: Microservice mit gRPC: idserv.proto
// IDService delivers networkwide unique IDs
syntax = "proto3";
package idserv;
// The gRPC protobuf service definition
service IDService {
// NewUUID generates a globally unique ID
rpc NewUUID (IdRequest) returns (IdReply) {}
}
// The client sends a unique id.
message IdRequest {
string clientId = 1;
}
// The response message contains the uuid.
message IdReply {
string uuid = 1;
}
Aus der gRPC-Schnittstellendatei lässt sich nun der Go-Quellcode für Proxy bzw. Stub generieren.
Listing 8: : gRPC-Schnittstelle generieren
$ go get -u google.golang.org/grpc # install GRPC
$ go get -u github.com/golang/protobuf/protoc-gen-go # install Go Plugin
$ protoc -I remote/idserv/ remote/idserv/idserv.proto --go_out=plugins=grpc:remote/idserv
Die vom protoc-Compiler generierte Datei idserv.pb.go enthält die Schnittstelle zur Implementierung am Server, die Schnittstelle für den Aufruf am Client sowie den Protokollbuffer-Marshalling- und -Unmarshalling-Code. Diesen Code kann man jetzt direkt im Proxy bzw. im Stub verwenden:
Listing 9: Der Proxy implementiert die lokale Schnittstelle und delegiert an gRPC
// Proxy is a client side proxy which encapsulates the RPC logic. It implements the IDService interface.
type Proxy struct {
connection *grpc.ClientConn
}
// NewProxy creates a Proxy and starts the server connection
func NewProxy() *Proxy {
p := new(Proxy)
conn, err := grpc.Dial(address, grpc.WithInsecure())
if err != nil {
panic(fmt.Sprintf("did not connect: %v", err))
}
p.connection = conn
return p
}
// NewUUID implements the IDService interface.
func (p *Proxy) NewUUID(clientID string) string {
c := idserv.NewIDServiceClient(p.connection)
ctx, cancel := context.WithTimeout(context.Background(),
time.Second)
defer cancel()
r, err := c.NewUUID(ctx, &idserv.IdRequest{ClientId: clientID})
if err != nil {
log.Printf("could not generate id: %v", err)
r.Uuid = ""
}
return r.Uuid
}
Listing 10: Der Stub implementiert die Remote-Schnittstelle und delegiert an die lokale Schnittstelle
// Stub implements the idserv.IdServer GRPC server side
type Stub struct{}
// NewUUID implements idserv.IdService interface
func (s *Stub) NewUUID(c context.Context, r *idserv.IdRequest) (*idserv.IdReply, error) {
defer func() {
if err := recover(); err != nil {
log.Printf("Recovered from panic %v", err)
}
}()
service := core.NewIDServiceImpl()
return &idserv.IdReply{Uuid: service.NewUUID(r.GetClientId())}, nil
}
Mittels Dependency Injection kann ein Client entweder den Proxy oder die Implementierung geliefert bekommen, abhängig von der Situation. In diesem Zusammenhang gibt es aktuell ein neues Framework: Go Cloud von Google. Dieses Toolkit enthält ein DI-Framework mit dem Namen Wire. Interessanterweise sind die Dependency-Injection-Funktionen dort als statischer Code (teilweise generiert) eingebaut. Das führt zu einer extremen Performanz und Typsicherheit und ist in diesen Aspekten CDI oder Spring überlegen [7].
Die hier gezeigte Entkopplung über Proxies ermöglicht auch den Zusammenbau von mehreren Services in einer Deployment-Einheit (Modulith). Das kann für die Entwicklung sinnvoll sein, wenn die Teams nicht verteilt sind und die Entscheidung, ob und wann ein Service isoliert werden soll, nicht feststeht. Das Muster darf aber nicht missverstanden werden. Es ist kein Plädoyer dafür, alle Dienste in ein Self-Contained-System (SCM) zu packen, sondern dafür, bei Bedarf (z. B. bei der Entwicklung) die Businesslogik direkt verschalten zu können.
Cloud-Native-Microservices mit Go
Die bisher gezeigten Beispiele nutzen die Go-Standardbibliothek sowie das gRPC-Toolkit. Hier fehlt allerdings noch einiges an Querschnittsfunktionalität, wo es wenig Sinn macht, dies selber zu bauen. Beispiele für fehlende Querschnittsfunktionalität sind:
- Service Locator: Wie finde ich meine abhängigen Services?
- Metriken: Welche Metriken werden protokolliert? Welches API wird benutzt?
- Tracing: Welche Trancing Bibliothek nutzen die Services?
- Messaging: Wie kommuniziert man asynchron?
- Cirquit Breaker: Wie macht man Services im Fehlerfall resilient?
- Logging: Wo und in welchen Formaten wird geloggt?
Für diese Fragen könnte man individuelle Lösungen auf Basis diverser Open-Source-Komponenten oder Kaufprodukte entwickeln. In der Praxis ist das aber viel zu teuer und aufwändig. Man braucht hier fertige integrierte Lösungsbausteine.
Ein solcher Baustein ist beispielsweise das Go-Kit Toolkit [8]. Mit dem Go-Kit kann man Microservices in wenigen Sekunden anlegen und verfügt dann bereits über die wichtigsten hier genannten Technologien.
Zusammenfassung
- Die Go-Standardbibliothek unterstützt Low-Level-Netzwerkprogrammierung.
- Mit Go lassen sich leicht RESTful-Webservices bauen.
- Mit Go kann man leicht GRPC-Webservices bauen und anbinden.
- Channels und Go-Routinen vereinfachen parallele Serverprogrammierung.
- Es gibt diverse Microservice-Frameworks, aber keine Standards wie z. B. JavaEE.
- Der Reifegrad der Frameworks und Bibliotheken ist sehr unterschiedlich.
Für welche Anwendungen eignet sich Go?
- Hilfsprozesse wie Sidecar-Container auf Cloud-Plattformen (Kubernetes, OpenShift)
- Systemnahe Infrastrukturdienste wie Reverse Proxies, Authentication Proxies
- Automationsfunktionalität in der Cloud wie Elastic Scaling Automation
- Aggregator-Dienste wie Backend for Frontend-Microservices
- Infrastruktur-Dienste wie Monitoring, Logging ...
Für welche Anwendungen sollte man Java wählen?
- Anwendungen mit komplexen Schnittstellen
- Große Enterprise-Anwendungen mit langer Lebensdauer (Standards!)
- Anwendungen mit einem hohen Wiederverwendungspotenzial durch vorhandene Open-Source-Projekte
Mit beiden Sprachen lassen sich hervorragend Microservices bauen. Die Entscheidung für oder gegen Go vs. Java hängt neben technischen Fragen auch von den Entwicklern und der gewählten Strategie ab:
- Entwickler, die bisher C++ und Java programmiert haben, werden Go mögen und schnell produktiv sein.
- Entwickler, die bisher nur Java programmiert haben, sollten mit Go eher vorsichtig sein.
- Firmen, die sich stark in Richtung Cloud-nativer Entwicklung orientieren, sollten mit Go mutiger sein.
Unserer Einschätzung nach ist Go eine echte Alternative zu Java. Es macht Spaß Go zu programmieren. Go ist ideal auf den Einsatzzweck von Cloud-nativen Diensten zugeschnitten und schnell zu erlernen. Allerdings muss man bei Go Abstriche im Ökosystem in Kauf nehmen. Die Reife von Open-Source-Bibliotheken ist aktuell noch nicht vergleichbar. Auch die Entwickler-Community ist sehr viel kleiner. Im Zweifelsfall kann man aber mit Java nichts falsch machen. Daher wird Go den Siegeszug von Java nicht aufhalten.
- Tiobe: Programmiersprachenindex
- Wikipedia: Go
- Cloud Native Software Foundation (CNCF)
- Swagger-Schnittstellenbeschreibung
- Wikipedia: Gang of Four
- Wikipedia: Fallacies of Distributed Computing
- Go Cloud
- Go-Kit-Toolkit
Marc
am 22.06.2021- Anwendungen mit komplexen Schnittstellen
- Große Enterprise-Anwendungen mit langer Lebensdauer (Standards!)
- Anwendungen mit einem hohen Wiederverwendungspotenzial durch vorhandene OpenSource-Projekte
machen möchte, dann nehme ich auch GO!
Die Packages, die Skalierung und Hochverfügbarkeit, sind die Stärken von GO. Mit GO kann man sich auch mal an andere Sachen wagen, z.B. Desktopanwendungen, Spiele und selbst als OS habe ich es schon gesehen.
Wer heutzutage wirklich noch die Erwägung fast, mit Java die neuen technischen Herausforderungen anzugehen, der hat noch nicht verstanden das seine Zeit vorbei ist.
Zum Thema OOP, GO braucht das gar nicht! Go gibt einen soviel Bühne, um seine Struktur in eleganteren Strukturen abzubilden, also in irgendwelche OOP-Ansätzen aus den 90ern. Überhaupt GO irgendwo einzuordnen, oder es mit anderen Sprachen zu vergleichen, zeigt sehr deutlich die Ablehnung!
Java ist endlich am sterben. Und das richtig richtig gut so!