This was originally posted on Medium
How we use gRPC to build a client/server system in Go
Photo by Igor Ovsyannykov on Unsplash
This post is a technical presentation on how we use gRPC (and Protobuf) to build a robust client/server system.
I won’t go into the details of why we chose gRPC as the main communication protocol between our client and server, a lot of great posts already cover the subject (like this one, this one or this one).
But just to get the big picture: we are building a client-server system, using Go, that needs to be fast, reliable, and that scales (thus we chose gRPC). We wanted the communication between the client and the server to be as small as possible, as secure as possible and fully compliant with both ends (thus we chose Protobuf).
We also wanted to expose another interface on the server side, just in case we add a client that has no compatible gRPC implementation: a traditional REST interface. And we wanted that at (almost) no cost.
Context
We will start building a very simple client/server system, in Go, that will just exchange dummy messages. Once both communicate and understand each other, we’ll add extra features, such as TLS support, authentication, and a REST API.
The rest of this articles assumes you understand basic Go programming. It also assumes that you have protobuf
package installed, and the protoc
command available (once again, many posts cover that topic, and there’s the official documentation).
You will also need to install Go dependencies, such as the go implementation for protobuf, and grpc-gateway.
All the code shown in this post is available at https://gitlab.com/pantomath-io/demo-grpc. So feel free to get the repository, and use the tags to navigate in it. The repository should be placed in the src
folder of your $GOPATH
:
$ go get -v -d gitlab.com/pantomath-io/demo-grpc
$ cd $GOPATH/src/gitlab.com/pantomath-io/demo-grpc
Defining the protocol
git tag: init-protobuf-definition
Photo by Mark Rasmuson on Unsplash
First of all, you need to define the protocol, i.e. to define what can be said between client and server, and how. This is where Protobuf comes into play. It allows you to define two things: Services and Messages. A service
is a collection of actions the server can perform at the client’s request, a message
is the content of this request. To simplify, you can say that service
defines actions, while message
defines objects.
Write the following in api/api.proto
:
syntax = "proto3";
package api;
message PingMessage {
string greeting = 1;
}
service Ping {
rpc SayHello(PingMessage) returns (PingMessage) {}
}
So, you defined 2 things: a service
called Ping
that exposes a function called SayHello
with an incoming PingMessage
and returns a PingMessage
; and a message
called PingMessage
that consists in a single field called greeting
which is a string
.
You also specified that you are using the proto3
syntax, as opposed to proto2
(see documentation).
This file is not usable like this: it needs to get compiled. Compiling the proto file means generating code for your chosen language, that your application will actually call.
In a shell, cd
to the root directory of your project, and run the following command:
$ protoc -I api/ \
-I${GOPATH}/src \
--go_out=plugins=grpc:api \
api/api.proto
This command generates the file api/api.pb.go
, a Go source file that implements the gRPC code your application will use. You can look at it, but you shouldn’t change it (as it will be overwritten every time you run protoc
).
You also need to define the function called by the service Ping
, so create a file named api/handler.go
:
package api
import (
"log"
"golang.org/x/net/context"
)
// Server represents the gRPC server
type Server struct {
}
// SayHello generates response to a Ping request
func (s *Server) SayHello(ctx context.Context, in *PingMessage) (*PingMessage, error) {
log.Printf("Receive message %s", in.Greeting)
return &PingMessage{Greeting: "bar"}, nil
}
- the
Server
struct
is just an abstraction of the server. It allows to “attach some resources to your server, making them available during the RPC calls; - the
SayHello
function is the one defined in the Protobuf file, as therpc
call for thePing
service
. If you don’t define it, you won’t be able to create the gRPC server; -
SayHello
takes aPingMessage
as parameter, and returns aPingMessage
. ThePingMessage
struct
is defined in theapi.pb.go
file auto-generated from theapi.proto
definition. The function also has aContext
parameter (see further presentation in the official blog post). You’ll see later what use you can do of theContext
. On the other side, it also returns anerror
, in case something bad happens.
Creating the simplest server
git tag: init-server
Photo by Nathan Dumlao on Unsplash
Now you have a protocol in place, you can create a simple server that implements the service
and understands the message
. Take your favorite editor and create the file server/main.go
:
package main
import (
"fmt"
"log"
"net"
"gitlab.com/pantomath-io/demo-grpc/api"
"google.golang.org/grpc"
)
// main start a gRPC server and waits for connection
func main() {
// create a listener on TCP port 7777
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", 7777))
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// create a server instance
s := api.Server{}
// create a gRPC server object
grpcServer := grpc.NewServer()
// attach the Ping service to the server
api.RegisterPingServer(grpcServer, &s)
// start the server
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %s", err)
}
}
Let me break down to code to make it clearer:
- note that you import the
api
package, so that the Protobufservice
handlers and theServer
struct
are available; - the
main
function starts by creating a TCP listener on the port you want to bind your gRPC server to; - then the rest is pretty straight forward: you create an instance of your
Server
, create an instance of a gRPC server, register theservice
, and start the gRPC server.
You can compile your code to get a server binary:
$ go build -i -v -o bin/server gitlab.com/pantomath-io/demo-grpc/server
Creating the simplest client
git tag: init-client
Photo by Clem Onojeghuo on Unsplash
The client also imports the api
package, so that the message
and the service
are available. So create the file client/main.go
:
package main
import (
"log"
"gitlab.com/pantomath-io/demo-grpc/api"
"golang.org/x/net/context"
"google.golang.org/grpc"
)
func main() {
var conn *grpc.ClientConn
conn, err := grpc.Dial(":7777", grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %s", err)
}
defer conn.Close()
c := api.NewPingClient(conn)
response, err := c.SayHello(context.Background(), &api.PingMessage{Greeting: "foo"})
if err != nil {
log.Fatalf("Error when calling SayHello: %s", err)
}
log.Printf("Response from server: %s", response.Greeting)
}
Once again, the break down is pretty straight forward:
- the
main
function instantiates a client connection, on the TCP port the server is bound to; - note the
defer
call to properly close the connection when the function returns; - the
c
variable is a client for the thePing
service, that calls theSayHello
function, passing aPingMessage
to it.
You can compile your code to get a client binary:
$ go build -i -v -o bin/client gitlab.com/pantomath-io/demo-grpc/client
Make them talk
You’ve just built a client and a server, so fire them in two terminals to test them:
$ bin/server
2006/01/02 15:04:05 Receive message foo
$ bin/client
2006/01/02 15:04:05 Response from server: bar
Tool to ease your life
git tag: init-makefile
Now the API, the client and the server are working, you may prefer to have a Makefile to compile the code, clean your folder, manage dependencies, etc.
So create this Makefile
at the root of the project folder. Explaining this file is beyond the scope of this post, and it mostly uses compile command you already spawned previously.
To use the Makefile
, try calling the following:
$ make help
api Auto-generate grpc go sources
build_client Build the binary file for client
build_server Build the binary file for server
clean Remove previous builds
dep Get the dependencies
help Display this help screen
Secure the communication
git tag: add-ssl
Photo by Nathaniel Tetteh on Unsplash
The client and the servers talk to each other, over HTTP/2 (transport layer on gRPC). The messages are binary data(thanks to Protobuf), but the communication is in plaintext. Fortunately, gRPC has SSL/TLS integration, that can be used to authenticate the server (from the client’s perspective), and to encrypt message exchanges.
You don’t need to change anything to the protocol: it remains the same. The changes take place in the gRPC object creation, on both client and server side. Note that if you change only one side, the connection won’t work.
Before you change anything in the code, you need to create a self-signed SSL certificate. The purpose of this post is not to explain how to do that, but the official OpenSSL documentation (genrsa
, req
, x509
) can answer your question about it (DigitalOcean also has a nice and complete tutorial about it). Meanwhile, you can just use the files provided in the cert
folder. The following commands have been used to generate the files:
$ openssl genrsa -out cert/server.key 2048
$ openssl req -new -x509 -sha256 -key cert/server.key -out cert/server.crt -days 3650
$ openssl req -new -sha256 -key cert/server.key -out cert/server.csr
$ openssl x509 -req -sha256 -in cert/server.csr -signkey cert/server.key -out cert/server.crt -days 3650
You can proceed and update the server definition to use the certificate and the key:
package main
import (
"fmt"
"log"
"net"
"gitlab.com/pantomath-io/demo-grpc/api"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// main starts a gRPC server and waits for connection
func main() {
// create a listener on TCP port 7777
lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", "localhost", 7777))
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// create a server instance
s := api.Server{}
// Create the TLS credentials
creds, err := credentials.NewServerTLSFromFile("cert/server.crt", "cert/server.key")
if err != nil {
log.Fatalf("could not load TLS keys: %s", err)
}
// Create an array of gRPC options with the credentials
opts := []grpc.ServerOption{grpc.Creds(creds)}
// create a gRPC server object
grpcServer := grpc.NewServer(opts...)
// attach the Ping service to the server
api.RegisterPingServer(grpcServer, &s)
// start the server
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %s", err)
}
}
So what changed?
- you created a credentials object (called
creds
) from your certificate and key files; - you created a
grpc.ServerOption
array and placed your credentials object in it; - when creating the grpc server, you provided the constructor with you array of
grpc.ServerOption
; - you must have noticed that you need to precisely specify the IP you bind your server to, so that the IP matches the FQDN used in the certificate.
Note that grpc.NewServer()
is a variadic function, so you can pass it any number of trailing arguments. You created an array of options so that we can add other options later on.
If you compile your server
now, and use the client
you already have, the connection won’t work, and both sides will throw an error.
- the server report the client is not handshaking with TLS:
2006/01/02 15:04:05 grpc: Server.Serve failed to complete security handshake from "localhost:64018": tls: first record does not look like a TLS handshake
- the client has its connection closed before it can do anything:
2006/01/02 15:04:05 transport: http2Client.notifyError got notified that the client transport was broken read tcp localhost:64018->127.0.0.1:7777: read: connection reset by peer.
2006/01/02 15:04:05 Error when calling SayHello: rpc error: code = Internal desc = transport is closing
You need to use the exact same certificate file on the client side. So edit the client/main.go
file:
package main
import (
"log"
"gitlab.com/pantomath-io/demo-grpc/api"
"golang.org/x/net/context"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func main() {
var conn *grpc.ClientConn
// Create the client TLS credentials
creds, err := credentials.NewClientTLSFromFile("cert/server.crt", "")
if err != nil {
log.Fatalf("could not load tls cert: %s", err)
}
// Initiate a connection with the server
conn, err = grpc.Dial("localhost:7777", grpc.WithTransportCredentials(creds))
if err != nil {
log.Fatalf("did not connect: %s", err)
}
defer conn.Close()
c := api.NewPingClient(conn)
response, err := c.SayHello(context.Background(), &api.PingMessage{Greeting: "foo"})
if err != nil {
log.Fatalf("error when calling SayHello: %s", err)
}
log.Printf("Response from server: %s", response.Greeting)
}
The changes on the client side are pretty much the same as on the server:
- you created a credentials object with the certificate file. Note that the client do not use the certificate key, the key is private to the server;
- you added an option to the
grpc.Dial()
function, using your credentials object. Note that thegrpc.Dial()
function is also a variadic function, so it accepts any number of options; - same server note applies for the client: you need to use the same FQDN to connect to the server as the one used in the certificate, or the transport authentication handshake will fail.
Both sides use credentials, so they should be able to talk just as before, but in an encrypted way. You can compile the code:
$ make
And run both sides in separate terminals:
$ bin/server
2006/01/02 15:04:05 Receive message foo
$ bin/client
2006/01/02 15:04:05 Response from server: bar
Identify the client
git tag: add-auth
Photo by chuttersnap on Unsplash
Another interesting feature of the gRPC server is the ability to intercept a request from the client. The client can inject information on the transport layer. You can use that feature to identify your client, because the SSL implementation authenticates the server (via the certificate), but not the client (all your clients are using the same certificate).
So you’ll update the client side to inject metadata on every call (like a login and password), and the server side to check these credentials for every incoming call.
On the client side, you just need to specify a DialOption
on your grpc.Dial()
call. But that DialOption
has some constraints. Edit your client/main.go
file:
package main
import (
"log"
"gitlab.com/pantomath-io/demo-grpc/api"
"golang.org/x/net/context"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// Authentication holds the login/password
type Authentication struct {
Login string
Password string
}
// GetRequestMetadata gets the current request metadata
func (a *Authentication) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
return map[string]string{
"login": a.Login,
"password": a.Password,
}, nil
}
// RequireTransportSecurity indicates whether the credentials requires transport security
func (a *Authentication) RequireTransportSecurity() bool {
return true
}
func main() {
var conn *grpc.ClientConn
// Create the client TLS credentials
creds, err := credentials.NewClientTLSFromFile("cert/server.crt", "")
if err != nil {
log.Fatalf("could not load tls cert: %s", err)
}
// Setup the login/pass
auth := Authentication{
Login: "john",
Password: "doe",
}
// Initiate a connection with the server
conn, err = grpc.Dial("localhost:7777", grpc.WithTransportCredentials(creds), grpc.WithPerRPCCredentials(&auth))
if err != nil {
log.Fatalf("did not connect: %s", err)
}
defer conn.Close()
c := api.NewPingClient(conn)
response, err := c.SayHello(context.Background(), &api.PingMessage{Greeting: "foo"})
if err != nil {
log.Fatalf("error when calling SayHello: %s", err)
}
log.Printf("Response from server: %s", response.Greeting)
}
- You define a struct to hold the collection on fields you want to inject in your rcp calls. In our case, just a login and password, but you can imagine any fields you want;
- The
auth
variable holds the values you’ll be using; - You use
grpc.WithPerRPCCredentials()
function to create aDialOption
object to thegrpc.Dial()
function; - Note that the
grpc.WithPerRPCCredentials()
function takes an interface as parameter, so yourAuthentication
structure should comply to that interface. From the documentation, you know you should implement 2 methods on your structure:GetRequestMetadata
andRequireTransportSecurity
. - So you define
GetRequestMetadata
function that just returns a map of yourAuthentication
structure; - And finally, you define
RequireTransportSecurity
function, that tells yourgrpc
client if it should inject metadata at the transport level. In our current case, it always returns true, but you could have it return the value of a configuration boolean, for instance.
The client is up to push extra data during its calls to the server, but the server does not care, right now. So you need to tell him to check these metadata. Open server/main.go
and update it:
package main
import (
"fmt"
"log"
"net"
"strings"
"golang.org/x/net/context"
"gitlab.com/pantomath-io/demo-grpc/api"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
)
// private type for Context keys
type contextKey int
const (
clientIDKey contextKey = iota
)
// authenticateAgent check the client credentials
func authenticateClient(ctx context.Context, s *api.Server) (string, error) {
if md, ok := metadata.FromIncomingContext(ctx); ok {
clientLogin := strings.Join(md["login"], "")
clientPassword := strings.Join(md["password"], "")
if clientLogin != "john" {
return "", fmt.Errorf("unknown user %s", clientLogin)
}
if clientPassword != "doe" {
return "", fmt.Errorf("bad password %s", clientPassword)
}
log.Printf("authenticated client: %s", clientLogin)
return "42", nil
}
return "", fmt.Errorf("missing credentials")
}
// unaryInterceptor calls authenticateClient with current context
func unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
s, ok := info.Server.(*api.Server)
if !ok {
return nil, fmt.Errorf("unable to cast server")
}
clientID, err := authenticateClient(ctx, s)
if err != nil {
return nil, err
}
ctx = context.WithValue(ctx, clientIDKey, clientID)
return handler(ctx, req)
}
// main start a gRPC server and waits for connection
func main() {
// create a listener on TCP port 7777
lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", "localhost", 7777))
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// create a server instance
s := api.Server{}
// Create the TLS credentials
creds, err := credentials.NewServerTLSFromFile("cert/server.crt", "cert/server.key")
if err != nil {
log.Fatalf("could not load TLS keys: %s", err)
}
// Create an array of gRPC options with the credentials
opts := []grpc.ServerOption{grpc.Creds(creds),
grpc.UnaryInterceptor(unaryInterceptor)}
// create a gRPC server object
grpcServer := grpc.NewServer(opts...)
// attach the Ping service to the server
api.RegisterPingServer(grpcServer, &s)
// start the server
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %s", err)
}
}
Once again, let me break down this for you:
- you add a new
grpc.ServerOption
to the array you created before (see why it’s an array, now?):grpc.UnaryInterceptor
. And you pass a reference to a function to that function, so it knows who to call. The rest of the main code does not change; - you have to define the
unaryInterceptor
function, considering it gets a bunch of parameters:
- a
context.Context
object, containing your data, and that will exist during all the lifetime of the request; - an
interface{}
which is the inbound parameter of the RPC call; - a
UnaryServerInfo
struct which contains a bunch of information about the call (such as theServer
abstraction object, and the method call by the client); - a
UnaryHandler
struct which is the handler invoked byUnaryServerInterceptor
to complete the normal execution of a unary RPC (i.e. an handler to what happens when theUnaryInterceptor
returns).
- the
unaryInterceptor
function makes sure thegrpc.UnaryServerInfo
has the right server abstraction, and call the authentication function,authenticateClient
; - you define the
authenticateClient
function with your authentication logic — very very simple in this example. Note that it receives thecontext.Context
as parameter, and extract the metadata from it. It checks the user, and returns its ID (in the form of astring
, with a hypothetical error. - if the
unaryInterceptor
gets no error from theauthenticateClient
function, it pushes theclientID
in thecontext.Context
object, so that the rest of the execution chain can use it (remember thehandler
gets thecontext.Context
object as parameter?); - Note that you created your
type
andconst
to reference theclientID
in thecontext.Context
map. This is just an handy way to avoid naming conflict and to allow constant reference.
You can compile the code:
$ make
And run both sides in separate terminals:
$ bin/server
2006/01/02 15:04:05 authenticated client: john
2006/01/02 15:04:05 Receive message foo
$ bin/client
2006/01/02 15:04:05 Response from server: bar
Obviously, your authentication logic will probably be smarter, comparing credentials against a database. The easy part of it is: your authentication function gets your abstraction of a Server
, and this structure can hold your database handler.
Open to REST
git tag: add-rest
Photo by Rio Hodges on Unsplash
One last thing: you have a pretty neat server, client and protocol; serialized, encrypted and authenticated. But there is a important limit: your client needs to be gRPC compliant, that is be in the list of supported platforms. To avoid that limit, we can open the server to a REST gateway, allowing REST clients to perform requests it. Luckily, there is a gRPC protoc plugin to generate a reverse-proxy server which translates a RESTful JSON API into gRPC. We can use a few line of pure Go code to serve that reverse-proxy.
So edit your api/api.proto
file to add some extra information:
syntax = "proto3";
package api;
import "google/api/annotations.proto";
message PingMessage {
string greeting = 1;
}
service Ping {
rpc SayHello(PingMessage) returns (PingMessage) {
option (google.api.http) = {
post: "/1/ping"
body: "*"
};
}
}
The annotations.proto
import allows protoc
to understand the option
set later in the file. And the option
defines that method and the path to the endpoint.
Update the Makefile
to add a target for this new Protobuf
compilation:
SERVER_OUT := "bin/server"
CLIENT_OUT := "bin/client"
API_OUT := "api/api.pb.go"
API_REST_OUT := "api/api.pb.gw.go"
PKG := "gitlab.com/pantomath-io/demo-grpc"
SERVER_PKG_BUILD := "${PKG}/server"
CLIENT_PKG_BUILD := "${PKG}/client"
PKG_LIST := $(shell go list ${PKG}/... | grep -v /vendor/)
.PHONY: all api server client
all: server client
api/api.pb.go: api/api.proto
-I api/ \
-I${GOPATH}/src \
-I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
--go_out=plugins=grpc:api \
api/api.proto
api/api.pb.gw.go: api/api.proto
-I api/ \
-I${GOPATH}/src \
-I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
--grpc-gateway_out=logtostderr=true:api \
api/api.proto
api: api/api.pb.go api/api.pb.gw.go ## Auto-generate grpc go sources
dep: ## Get the dependencies
get -v -d ./...
server: dep api ## Build the binary file for server
build -i -v -o $(SERVER_OUT) $(SERVER_PKG_BUILD)
client: dep api ## Build the binary file for client
build -i -v -o $(CLIENT_OUT) $(CLIENT_PKG_BUILD)
clean: ## Remove previous builds
$(SERVER_OUT) $(CLIENT_OUT) $(API_OUT) $(API_REST_OUT)
help: ## Display this help screen
-E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
Generate the Go code for the gateway (the file api/api.pb.gw.go
will be generated — just as api/api.pb.go
, don’t edit it, it will be updated by compilation):
$ make api
The change on the server side is more important. The grpc.Serve()
function is a blocking function, that returns only on error (or can get killed by a signal). As we need to start another server (the REST interface), we need this call to be non-blocking. Fortunately, we have goroutines just for that. And there is a trick on the authentication. As the REST gateway is just a reverse-proxy, it acts as a client from the gRPC perspective. Thus it needs to use a WithPerRPCCredentials
option when dialing the server.
Head to your server/main.go
file:
package main
import (
"fmt"
"log"
"net"
"net/http"
"strings"
"github.com/grpc-ecosystem/grpc-gateway/runtime"
"golang.org/x/net/context"
"gitlab.com/pantomath-io/demo-grpc/api"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
)
// private type for Context keys
type contextKey int
const (
clientIDKey contextKey = iota
)
func credMatcher(headerName string) (mdName string, ok bool) {
if headerName == "Login" || headerName == "Password" {
return headerName, true
}
return "", false
}
// authenticateAgent check the client credentials
func authenticateClient(ctx context.Context, s *api.Server) (string, error) {
if md, ok := metadata.FromIncomingContext(ctx); ok {
clientLogin := strings.Join(md["login"], "")
clientPassword := strings.Join(md["password"], "")
if clientLogin != "john" {
return "", fmt.Errorf("unknown user %s", clientLogin)
}
if clientPassword != "doe" {
return "", fmt.Errorf("bad password %s", clientPassword)
}
log.Printf("authenticated client: %s", clientLogin)
return "42", nil
}
return "", fmt.Errorf("missing credentials")
}
// unaryInterceptor call authenticateClient with current context
func unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
s, ok := info.Server.(*api.Server)
if !ok {
return nil, fmt.Errorf("unable to cast server")
}
clientID, err := authenticateClient(ctx, s)
if err != nil {
return nil, err
}
ctx = context.WithValue(ctx, clientIDKey, clientID)
return handler(ctx, req)
}
func startGRPCServer(address, certFile, keyFile string) error {
// create a listener on TCP port
lis, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
// create a server instance
s := api.Server{}
// Create the TLS credentials
creds, err := credentials.NewServerTLSFromFile(certFile, keyFile)
if err != nil {
return fmt.Errorf("could not load TLS keys: %s", err)
}
// Create an array of gRPC options with the credentials
opts := []grpc.ServerOption{grpc.Creds(creds),
grpc.UnaryInterceptor(unaryInterceptor)}
// create a gRPC server object
grpcServer := grpc.NewServer(opts...)
// attach the Ping service to the server
api.RegisterPingServer(grpcServer, &s)
// start the server
log.Printf("starting HTTP/2 gRPC server on %s", address)
if err := grpcServer.Serve(lis); err != nil {
return fmt.Errorf("failed to serve: %s", err)
}
return nil
}
func startRESTServer(address, grpcAddress, certFile string) error {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(credMatcher))
creds, err := credentials.NewClientTLSFromFile(certFile, "")
if err != nil {
return fmt.Errorf("could not load TLS certificate: %s", err)
}
// Setup the client gRPC options
opts := []grpc.DialOption{grpc.WithTransportCredentials(creds)}
// Register ping
err = api.RegisterPingHandlerFromEndpoint(ctx, mux, grpcAddress, opts)
if err != nil {
return fmt.Errorf("could not register service Ping: %s", err)
}
log.Printf("starting HTTP/1.1 REST server on %s", address)
http.ListenAndServe(address, mux)
return nil
}
// main start a gRPC server and waits for connection
func main() {
grpcAddress := fmt.Sprintf("%s:%d", "localhost", 7777)
restAddress := fmt.Sprintf("%s:%d", "localhost", 7778)
certFile := "cert/server.crt"
keyFile := "cert/server.key"
// fire the gRPC server in a goroutine
go func() {
err := startGRPCServer(grpcAddress, certFile, keyFile)
if err != nil {
log.Fatalf("failed to start gRPC server: %s", err)
}
}()
// fire the REST server in a goroutine
go func() {
err := startRESTServer(restAddress, grpcAddress, certFile)
if err != nil {
log.Fatalf("failed to start gRPC server: %s", err)
}
}()
// infinite loop
log.Printf("Entering infinite loop")
select {}
}
So what happened?
- you moved all the code for the gRPC server creation in a goroutine with a dedicated function (
startGRPCServer
), so it does not block themain
; - you create a new goroutine with a dedicated function (
startRESTServer
) where you create an HTTP/1.1 server; - in
startRESTServer
where you create the REST gateway, you start by getting thecontext.Context
background object (i.e. the root of the context tree). Then, you create a request multiplexer object,mux
, with an option:runtime.WithIncomingHeaderMatcher
. This option takes a function reference as parameter,credMatch
, and is called for every HTTP header from the incoming request. The function evaluates whether or not the HTTP header should be passed to the gRPC context; - you defined the
credMatch
function to match the credentials header, allowing them to be metadata in the gRPC context. This is how you have your authentication working, because the reverse-proxy uses the HTTP headers it receives when it connects to the gRPC server; - you also create a
credentials.NewClientTLSFromFile
, to be used as agrpc.DialOption
, just like you did in the client side; - you register your api endpoint, i.e. you make the link between your multiplexer, you gRPC server, using the context and the gRPC options;
- and finally, you start an HTTP/1.1 server, and wait for incoming connections;
- aside to your goroutine, you use a blocking
select
call, so that your program does not end right away.
Now build the whole project, so you can test the REST interface:
$ make
And run both sides in separate terminals:
$ bin/server
2006/01/02 15:04:05 Entering infinite loop
2006/01/02 15:04:05 starting HTTP/1.1 REST server on localhost:7778
2006/01/02 15:04:05 starting HTTP/2 gRPC server on localhost:7777
2006/01/02 15:04:05 authenticated client: john
2006/01/02 15:04:05 Receive message foo
$ curl -H "login:john" -H "password:doe" -X POST -d '{"greeting":"foo"}' '
{"greeting":"bar"}
One last swag…
git tag: add-swagger
Photo by Lorenzo Castagnone on Unsplash
The REST gateway is cool, but it would be even cooler to generate documentation from it, right?
You can do that for free, using a protoc plugin to generate a swagger json file:
protoc -I api/ \
-I${GOPATH}/src \
-I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
--swagger_out=logtostderr=true:api \
api/api.proto
This will generate a api/api.swagger.json
file. As all generated code from Protobuf compilation, you should not edit it, but you can use it, and it can update it when you change your definition file.
You can add the compilation command in the Makefile
.
Conclusion
You have a fully functional gRPC client and server, with SSL encryption & authentication, client identification, and a REST gateway (with its swagger file). Where to go from here?
You can push a little on the REST gateway, to make it HTTPS instead of HTTP. You can obviously add more complex data structure on your Protobuf, alongside with more service
. You can benefit from HTTP/2 features, such as streaming, either from client to server, or from server to client, or bidirectional (but that’s only for gRPC, not for the REST, based on HTTP/1.1).
Many thanks to Charles Francoise who co-wrote this paper and https://gitlab.com/pantomath-io/demo-grpc.
We are currently working on Pantomath. Pantomath is a modern, open-source monitoring solution, built for performance, that bridges the gaps across all levels of your company. The well-being of your infrastructure is everyone’s business. Keep up with the project
Top comments (0)