Die Golumne

Golumne: In die Ferne schweifen mit Go und gPRC

Frank Müller

© Reneé French (CC BY 3.0 DE / modifiziert)

Es wird Zeit für einen neuen Standard, der eine ähnliche Akzeptanz für die sprachübergreifende Kommunikation von Netzanwendungen findet wie JSON: gRPC ist ein aussichtsreicher Kandidat. In der Golumne geht es dieses Mal genau darum: Wie kann man Verbindungen mit gRPC in Go steuern?

Wir haben inzwischen 2020 und die Softwareentwicklung hat sich in mehreren Schritten von großen Programmen mit kompletter Funktionalität in einem System hin zu Microservices in verteilten Landschaften entwickelt. Das ist durch die Aufteilung in fachliche Komponenten, die leichtere Skalierbarkeit und eine bessere Hochverfügbarkeit eine spannende Veränderung. Dass damit auch neue Herausforderungen geschaffen werden, sei nicht verschwiegen. Das Design ändert sich, Netzwerklatenzen und -ausfälle werden zu eigenen Problemen und für die Verteilung muss ich mich auf weitere Plattformen einlassen. Hier hilft beispielsweise das leistungsfähige Kubernetes, das soll an dieser Stelle aber nicht das Thema sein.

Vielmehr geht es um die Kommunikation. Ein etablierter Weg sind aktuell RESTful APIs. Sie bilden ein einfaches Request-/Response-Modell und nutzen zur Kommunikation das bewährte HTTP. Die Methoden des HTTP, also GET, POST, DELETE und weitere, werden hierbei auf Aktionen auf verwalteten Ressourcen abgebildet. Für den Datenaustausch können viele Formate eingesetzt werden, doch hat sich hier in der Regel JSON durchgesetzt. Es ist wie HTTP einfach und wird über Sprachgrenzen hinweg unterstützt.

So weit, so gut – eigentlich. Denn der stets neue Verbindungsaufbau ist teuer, das Frage-Antwort-Spiel reicht nicht für jede Anwendung und JSON zeigt sich nicht gerade sparsam beim Datenumfang. Es wird Zeit für einen neuen Standard, der eine ähnliche Akzeptanz für die sprachübergreifende Kommunikation von Netzanwendungen findet. Hier zeigt sich gRPC. Es basiert auf den Erfahrungen und der Arbeit eines Unternehmens, das der Inbegriff groß-skalierender Systeme ist: Google. Dort wurde über 15 Jahre hinweg das interne RPC-System Stubby entwickelt und auf hohe Skalierbarkeit und Funktionalität hin optimiert. Im August wurde das hierauf basierende gRPC 1.0 als Open-Source-Framework zur Verfügung gestellt.

Das Framework unterstützt eine Vielzahl von Sprachen und Umgebungen. Es bietet sowohl Request/Response als auch Streamingmodelle. Das zugrunde liegende Protokoll hierfür ist HTTP/2. Über Plug-ins unterstützt gRPC unterschiedliche Datenformate, von Haus aus werden Protocol Buffers genutzt. Weitere Plug-ins betreffen Load Balancing, Health Checking und Authentisierung. Das Projekt wird heute durch die Cloud Native Computing Foundation (CNCF) betreut.

Und was ist mit Go?

Wen verwundert es, dass Google als Entwickler der Sprache Go diese auch von Beginn an im gRPC Framework unterstützt hat. Die Installation und Nutzung sind dabei recht einfach. Allerdings ist es nicht einfach nur ein Go Module. Für den Umgang mit Protocol Buffers ist ein weiteres Programm zu installieren. Doch starten wir zuerst mit Go. Mit aktivierten Go Modules (GO111MODULE=on) lässt sich die aktuelle Version wenig spektakulär mit zwei Zeilen installieren:

$ go get -u google.golang.org/grpc
$ go get -u github.com/golang/protobuf/protoc-gen-go

Die zweite Installation enthält das Go-Plug-in für den protoc-Compiler. Er generiert den Zielcode für die jeweiligen Sprachen aus den Definitionen in proto-Dateien. Das hierfür notwendige Tool ist der protoc-Compiler selbst, der auch noch installiert werden muss. Auf meinem Mac geht das sehr einfach mit Homebrew via brew install protobuf. Doch auch unter Linux ist es kein Problem:

$ PB_REL="https://github.com/protocolbuffers/protobuf/releases"
$ curl -LO $PB_REL/download/v3.11.4/protoc-3.11.4-linux-x86_64.zip
$ unzip protoc-3.11.4-linux-x86_64.zip -d $HOME/bin

Schon kann es losgehen. Wir starten mit einem ganz einfachen Taschenrechner. Hier können für verschiedene Register Werte gesetzt sowie weitere addiert, subtrahiert, multipliziert und dividiert werden. Eine weitere Besonderheit ist, dass ein Client auch die Veränderungen des internen Registers abonnieren kann. Der Taschenrechner wird als ein Service definiert. Er enthält die Funktionen als Remote Procedure Calls und ähnelt in dieser Form der Spezifikation von Interfaces in Go. Die aktuelle Syntax ist proto3, was zu Beginn angegeben werden muss (Listing 1).

syntax = "proto3";

service Calculator {
  // Set a register, return current value.
  rpc Set(Change) returns (Response) {}
  
  // Basic arithmetic operations, returns 
  // new register value.
  rpc Add(Change) returns (Response) {}
  rpc Subtract(Change) returns (Response) {}
  rpc Multiply(Change) returns (Response) {}
  rpc Divide(Change) returns (Response) {}
  
  // Subscribe to the changes of a register.
  rpc Subscribe(Subscription) returns (stream Response) {}
}

Aus dieser Definition entstehen zwei Fragen: Was sind Change, Subscription und Response? Und was bedeutet stream? Die Services in gRPC kommunizieren über Nachrichten. Das Nachrichtenformat ist hier selbst zu definieren. Wir benötigen also die Typen Change, Subscription und Response. Deren Format wird für einen Go-Entwickler keine Hürde sein (Listing 2).

// Change transports a register and values
// for the changing.
message Change {
  string register = 1;
  repeated double values = 2;
}

// Subscription transports a register to subscribe to.
message Subscription {
  string register = 1;
}

// Response transports a register and its 
// current value.
message Response {
  string register = 1;
  double value = 2;
  string err = 3;
}

Schauen wir uns das genauer an. message sagt aus, dass eine Nachricht definiert wird. Hier werden die Felder einer Nachricht definiert. Das Beispiel kennt drei Nachrichten für die Operationen des Taschenrechners, des Abonnements eines Registers und des Ergebnisses. Die Nachricht Change enthält das Register und dazu eine Reihe von Werten im Feld values. Dieses ist vom Typ repeated double, was in Go einem []float64 entspricht. Für ein Abonnement von Änderungen wird nur das Register benötigt. Die Antwort im Typ Response enthält erneut das Register, nur einen Wert und potenziell einen Fehler, hier als String dargestellt.

Die Protocol Buffers kennen unterschiedliche Datentypen für Strings, Bytes, Zahlenformate und Booleans. Dazu kommen Mengen von Daten, Maps und Enumerationen. Geschachtelte Nachrichten sind ebenfalls möglich. Wichtig sind dabei noch die Nummern hinter den Bezeichnern. Sie sind Identifikatoren, müssen eindeutig sein und bei Änderungen der Nachrichten konstant bleiben. Jedoch können sie bei gelöschten Feldern auch reserviert werden. Die IDs haben einen Bereich von 1 bis 229 – 1 und sind über varint binärcodiert. So kosten 1 bis 15 nur ein Byte Platz, ab 16 dann zwei Byte bis hin zur Obergrenze von 4 Byte. Die Bezeichner in den Messages kommen hingegen dem generierten Quelltext zugute.

Fehlt noch das Schlüsselwort stream. Es bedeutet, dass nicht nur eine Nachricht als Antwort geschickt wird. Vielmehr kann ein Stream an Nachrichten wie im Beispiel abonniert werden. Ebenso können die Eingabenachricht oder auch beide ein Stream sein. Die Protocol Buffers unterstützen somit vier Modi.

Mit der Protokolldefinition in der Datei calculator.proto kann ich nun die gewünschten Go-Dateien generieren:

$ protoc -I $SRCDIR --go_out $DSTDIR $SRCDIR/calculator.proto

Das Flag –go_out sagt dem Compiler, dass das Ausgabeformat Go ist. Auch weitere Sprachen stehen zur Verfügung. Das Ergebnis dieses Aufrufs ist die Datei calculator.pb.go. Sie enthält allen notwendigen Protocol-Buffer-Code für meine Typen in Go sowie Interfaces für Clients und Server, die ich nun implementieren muss.

Zuerst der Server

Ich widme mich zuerst dem Server und erzeuge hier den Code für meinen Taschenrechner. Er implementiert das generierte Serverinterface. Der Idee nach kommt das dem http.Handler bei Webanwendungen ziemlich gleich, muss sich aber nicht mit Multiplexing oder Serialisierung herumschlagen. Vielmehr werden die Methoden für die RPCs direkt aufgerufen und erhalten native Nachrichten (Listing 3).

type register struct {
  signalc chan struct{}
  value   float64
}

type calculatorServer struct {
  mu        sync.RWMutex
  registers map[string]*register
}

Mit nur einer Mutex und den Registern als map ist das sicherlich keine sehr skalierbare Implementierung, soll aber als Beispiel genügen. Die Methodensignaturen folgen dabei, entsprechend den vier Modi, immer gleichen Mustern. Für ein einfaches Request/Response sind es die Argumente der Methoden context.Context und der generierte Message-Typ, die Rückgabe ist der entsprechende generierte Message-Typ und error für den Fall eines Problems (Listing 4). Und so fällt es leicht, die Methoden einzig auf ihre Geschäftslogik konzentrieren zu können.

func (c *calculatorServer) Add(
  ctx context.Context,
  change *calculator.Change,
) (*calculator.Response, error) {
  c.mu.Lock()
  defer c.mu.Unlock()
  r := c.registers[change.GetRegister()]
  if r == nil {
    r = &register{
      signalc: make(chan struct{}, 1),
      value:   0.0,
    }
  }
  for _, addend := range change.GetValues() {
    r.value += addend
  }
  c.registers[change.GetRegister()] = r
  response := &calculator.Response{
    Register: change.GetRegister(),
    Value:    r.value,
    Err:      "",
  }
  select {
    case r.signalc <- struct{}{}:
    default:
  }
  return response, nil
}

Etwas anders verhält es sich natürlich beim Streaming. Hierfür gibt es einen generierten Servertyp für den Versand oder dem Empfang. Dieser wird entsprechend als Argument übergeben und kann dort in einer Schleife für Versand über die Methode Send() oder Empfang über die Methode Recv() genutzt werden (Listing 5).

func (c *calculatorServer) Subscribe(
  subscription *calculator.Subscription,
  stream *calculator.Calculator_SubscribeServer,
) error {
  c.mu.Lock()
  r := c.registers[subscription.GetRegister()]
  if r == nil {
    r = &register{
      signalc: make(chan struct{}, 1),
      value:   0.0,
    }
    c.registers[subscription.GetRegister()] = r
  }
  c.mu.Unlock()
  if err := stream.Send(r.value); err != nil {
    return err
  }
  // Wait for signalled changes.
  for {
    select {
    case <-stream.Context().Done():
      return stream.Context().Err()
    case <-r.signalc:
      c.mu.RLock()
      r = c.registers[subscription.GetRegister()]
      c.mu.RUnlock()
      if err := stream.Send(r.value); err != nil {
        return err
      }
    }
  }
  return nil
}

Bleibt für den Server nur noch der eigentliche Server, der auf den bekannten Netzwerktypen aufbaut. Es wird ein Listener gestartet, dann der gRPC-Server und bei diesem schließlich mein Server und eventuell weitere registriert (Listing 6). So kann der Server seine Arbeit aufnehmen.

lis, err := net.Listen("tcp", ":10000")
if err != nil {
  log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
calculator.RegisterCalculatorServer(grpcServer, &calculatorServer{})
grpcServer.Serve(lis)

Und nun der Client

Was hilft ein Server ohne einen passenden Client? Auch hier sind die Calls recht einfach. Sie benötigen natürlich eine Verbindung, die zuerst aufgebaut werden muss (Listing 7).

conn, err := grpc.Dial("localhost:10000")
if err != nil {
  log.Fatalf("failed to connect the server: %v", err)
}
defer conn.Close()

client := calculator.NewCalculatorClient(conn)

Mit diesem Client vom generierten Typ calculator.CalculatorClient kann die Arbeit schon beginnen. Die Nutzung der oben erzeugten Add()-Methode lässt sich so leicht durchführen. Das Gegenstück zur Serverseite wurde auch für den Client generiert (Listing 8).

resp, err := client.Add(
  context.Background(), 
  &calculator.Change{
    Register: "Golumne",
    Values:   []float64{1000.0, 300.0, 30.0, 7.0}
  })
if err != nil {
  log.Fatalf("ouch: %v", err)
}
if resp.Err != "" {
  log.Fatalf("ouch: %v", resp.Err)
}
fmt.Printf(
  "Value of register %q is %v", 
  resp.Register, 
  resp.Value,
)

So der Context bei komplexeren serverseitigen Operationen ausgewertet wird, kann er auch hier für die entfernte Unterbrechung des Remote Procedure Calls genutzt werden, in der Golumne in Ausgabe 4.19 des Entwickler Magazins habe ich darüber bereits berichtet. Im Add() Call wurde er nicht genutzt, dafür im nun folgenden Abonnement eines Registers (Listing 9). Auch das ist dank des generierten Codes nicht sehr schwer.

sub := &calculator.Subscription{
  Register: "Golumne",
}
stream, err := client.Subscribe(context.Background(), sub)
if err != nil {
  log.Fatalf("ouch: %v", err)
}
for {
  resp, err := stream.Recv()
  if err == io.EOF {
    break
  }
  if err != nil {
    log.Fatalf("ouch: %v", err)
  }
  log.Println(resp.Register, resp.Value)
}

Die beiden weiten Modi für clientseitiges oder bidirektionales Streaming weichen nicht sehr hiervon ab. Allein damit fängt gRPC bereits eine Menge der üblichen Handarbeit bei Web-APIs oder RESTful APIs ab. Die weiteren Möglichkeiten des umfangreichen gRPC-Stacks kommen noch hinzu.

Vielleicht habe ich euch ja bereits den Mund wässrig gemacht. Verteilte Softwaresysteme gehören zunehmend zum Alltag, vom leistungsstarken Backend über eine Vielzahl vernetzt arbeitender Microservices hin zu Frontends im Browser, Mobile-Devices und dem IoT. Infrastrukturprojekte wie Kubernetes zeigen bereits, wie notwendig sie hierfür sind und setzen bereits auf gRPC. Dank der Nutzung über Sprachgrenzen hinweg wird dieser Standard in der Zukunft eine wichtige Rolle einnehmen.

Verwandte Themen:

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger 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.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: