vgarvardt / grpc-tutorial

Simple gRPC service written in Go with steps to reproduce

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

gRPC Tutorial

Simple gRPC service written in Go with steps to reproduce

All the steps have corresponding commits in the repo to track how application evolves. This readme highlights only the most interesting parts of every step, all the details are available in corresponding commits.

Index:

1. Hello world go application

go mod init
touch main.go

main.go contents:

package main

import "fmt"

func main() {
	fmt.Println("Welcome to the gRPC Tutorial!")
}

2. gRPC service definition and basic Makefile

Define simple Echo service and its corresponding types in proto/echo.proto file.

syntax = "proto3";

package rpc;

import "google/protobuf/timestamp.proto";

message SaySomething {
    string Message = 1;
}

message HearBack {
    string Message = 1;
    google.protobuf.Timestamp HappenedAt = 2;
}

service EchoService {
    rpc Reflect (SaySomething) returns (HearBack);
}

Create simple Makefile with only two tasks defined at the moment

  • deps for installing required dependencies
  • proto for generating go code from protobuf definition

Lines that worth looking at are package rpc; in the protobuf definition and --go_out=plugins=grpc:pkg/rpc in the proto task - go package name must match package name in protobuf definition, so in this tutorial app it is going to be located at github.com/vgarvardt/grpc-tutorial/pkg/rpc.

Another interesting thing is import "google/protobuf/timestamp.proto"; and google.protobuf.Timestamp HappenedAt = 2; that is basically custom protobuf type that we're going to use.

There are at least two ways of working with generated code - it can be generated in the codebase and pushed to a repository or generated by a CI task before application will be build. Both ways have pros and cons and it is up to developers to choose the way they want to follow. For this tutorial I'll be storing generated file in the repo.

Do not forget to update all the package used by existing project by running go mod vendor.

3. Implement EchoService Server

Time to look into code generated from protobuf definition. It has the following parts:

  • struct types for messages with fields for internal usage
  • EchoServiceClient interface and implementation
  • EchoServiceServer interface w/out implementation
  • RegisterEchoServiceServer function that accepts EchoServiceServer

We will start from the server interface implementation.

As I'm going to use this tutorial repository for several different applications I'm going to organise small monorepo here. All the shared code will be stored in pkg package on the top level, all applications will be stored in app/<application> packages with all private dependencies stored in internal sub-package to make them really private.

Start with mkdir -p app/echo/internal/service && touch app/echo/internal/service/echo.go.

Here is the full listing of the service server:

package service

import (
	"context"

	"github.com/golang/protobuf/ptypes"
	"github.com/vgarvardt/grpc-tutorial/pkg/rpc"
)

type echoServiceServer struct{}

// NewEchoServiceServer builds and returns is rpc.EchoServiceServer implementation
func NewEchoServiceServer() *echoServiceServer {
	return &echoServiceServer{}
}

// Reflect is the rpc.EchoServiceServer implementation
func (s *echoServiceServer) Reflect(ctx context.Context, in *rpc.SaySomething) (*rpc.HearBack, error) {
	return &rpc.HearBack{
		Message:    in.Message,
		HappenedAt: ptypes.TimestampNow(),
	}, nil
}

It starts with struct type for a service. Since it is going to be interface implementation it does not make sense to expose the type, it should be built using the builder function. The implementation of the only method is simple - get the message from the input and current timestamp.

4. Wrap application into simple command-based approach

Applications are going to be organised as monorepo so it also can be build as a single application working in different modes. I'll use github.com/spf13/cobra to organise it.

First of all I'm going to create a root command that will be used to attach different commands/modes to it. Start with touch app/root.go and add the following content to it:

package app

import (
	"github.com/spf13/cobra"
)

// NewRootCmd creates a new instance of the root command
func NewRootCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "grpc-tutorial",
		Short: "gRPC Tutorial is the set of simple apps to play with gRPC in go",
	}

	return cmd
}

Do nt forget to run go mod vendor to let go modules add missing package to a project.

Now we can add the first command - let it be version as it does not require any logic. Do the touch app/version.go and add the following content to it:

package app

import (
	"context"
	"fmt"

	"github.com/spf13/cobra"
)

var version = "0.0.0-dev"

// NewVersionCmd creates a new version command
func NewVersionCmd(ctx context.Context) *cobra.Command {
	return &cobra.Command{
		Use:     "version",
		Short:   "Print the version information",
		Aliases: []string{"v"},
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Printf("grpc-tutorial %s\n", version)
		},
	}
}

Variable version is declared as private and has predefined default value. It allows to define real application version in compile-time in CI.

Now it is time to register the first command in the root, so the root command builder function will look like this:

// NewRootCmd creates a new instance of the root command
func NewRootCmd() *cobra.Command {
	ctx := context.Background()

	cmd := &cobra.Command{
		Use:   "grpc-tutorial",
		Short: "gRPC Tutorial is the set of simple apps to play with gRPC in go",
	}

	cmd.AddCommand(NewVersionCmd(ctx))

	return cmd
}

And now it is time to use the root command as an entry point for the application. To do this we need to change the main() function, so the full contents of the main.go will be:

package main

import (
	"log"

	"github.com/vgarvardt/grpc-tutorial/app"
)

func main() {
	rootCmd := app.NewRootCmd()

	if err := rootCmd.Execute(); err != nil {
		log.Fatalf("Failed to run command: %v\n", err)
	}
}

And finally we can test the application

$ go run main.go version
grpc-tutorial 0.0.0-dev

5. Create simple gRPC Server, finally!

All preparations are done, nothing holds us from creating an gRPC Server! Do the touch app/echo/app.go and add the following contents to it:

package echo

import (
	"context"
	"fmt"
	"log"
	"net"

	"github.com/pkg/errors"
	"github.com/spf13/cobra"
	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"

	"github.com/vgarvardt/grpc-tutorial/app/echo/internal/service"
	"github.com/vgarvardt/grpc-tutorial/pkg/rpc"
)

const tcpPort = 5000

// NewServerCmd builds new echo-server command
func NewServerCmd(ctx context.Context, version string) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "echo-server",
		Short: "Starts Echo gRPC Server",
		RunE: func(cmd *cobra.Command, args []string) error {
			return RunServer(ctx, version)
		},
	}

	return cmd
}

// RunServer is the run command to start echo-server
func RunServer(ctx context.Context, version string) error {
	log.Printf("Starting echo-server v%s", version)

	// create new gRPC Server instance
	s := grpc.NewServer()

	// create new service server instance
	srv := service.NewEchoServiceServer()

	// register service in gRPC Server
	rpc.RegisterEchoServiceServer(s, srv)

	// register server reflection to help tools interact with the server
	reflection.Register(s)

	// create TCP listener
	tcpListener, err := net.Listen("tcp", fmt.Sprintf(":%d", tcpPort))
	if err != nil {
		return errors.Wrap(err, "could not start TCP listener")
	}

	log.Printf("Running gRPC server on port %d...\n", tcpPort)
	if err := s.Serve(tcpListener); err != nil {
		return errors.Wrap(err, "failed to server gRPC server")
	}

	return nil
}

Almost all lines are commented here and should be pretty easy to understand. The only line that can be unclear is reflection.Register(s) and I'll explain it a bit later in this section.

The command is ready, now need to register it in the root one with cmd.AddCommand(echo.NewServerCmd(ctx, version)) right after the version one and gRPC Server is ready for the first test!

$ go run main.go echo-server
2019/08/21 23:09:24 Starting echo-server v0.0.0-dev
2019/08/21 23:09:24 Running gRPC server on port 5000...

gRPC Server testing tools

Now that we have gRPC server up and running it would be nice to test it somehow. For HTTP-based API Postman or curl can be used, but they are useless when it comes to gRPC. Luckily there are more or less similar tools to make gRPC calls. I tried two of them:

  • grpcui - GUI one with web interface
  • grpcurl - CLI curl-like one

There are two ways of using these tools - either you need to provide a protobuf definition file so it could get the information about the server and types it is going to work with (same as we generated go file out of it), or it can get the all the required information from the server in the runtime and this is where gRPC Reflection comes into play, the one that we enabled with reflection.Register(s).

Here are examples of using the tools:

$ grpcui -plaintext localhost:5000
gRPC Web UI available at http://127.0.0.1:57917/...
$ grpcurl -plaintext localhost:5000 list
grpc.reflection.v1alpha.ServerReflection
rpc.EchoService

$ grpcurl -plaintext localhost:5000 describe rpc.EchoService
rpc.EchoService is a service:
service EchoService {
  rpc Reflect ( .rpc.SaySomething ) returns ( .rpc.HearBack );
}

$ grpcurl -plaintext -d '{"Message": "Hello from gRPCurl"}' localhost:5000 rpc.EchoService/Reflect
{
  "Message": "Hello from gRPCurl",
  "HappenedAt": "2019-08-22T08:09:02.061941Z"
}

One parameter worth mentioning here is the -plaintext that we have to set in both tools - it enables insecure connection mode. I'll cover security part a bit further. For now just keep in mind - Server is running in insecure mode and is not ready for production usage.

6. Create simple gRPC client

Now that we already have gRPC server up and running we can start building a client that will talk to the server from an application. For this purpose I'm going to introduce one more application - client. Do the mkdir -p app/client && touch app/client/app.go and add the following contents to it:

package client

import (
	"context"
	"log"
	"time"

	"github.com/spf13/cobra"
	"google.golang.org/grpc"

	"github.com/vgarvardt/grpc-tutorial/pkg/rpc"
)

const echoServerTarget = "localhost:5000"

// NewClientCmd builds new gRPC client command
func NewClientCmd(ctx context.Context, version string) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "client",
		Short: "Runs gRPC client",
		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
			log.Printf("Running gRPC client v%s", version)
			return nil
		},
	}

	echoCmd := &cobra.Command{
		Use:   "echo",
		Short: "Runs gRPC echo-server client",
		RunE: func(cmd *cobra.Command, args []string) error {
			return runEchoClient(ctx)
		},
	}

	cmd.AddCommand(echoCmd)

	return cmd
}

func runEchoClient(ctx context.Context) error {
	log.Printf("Connecting to the gRPC Server at %s", echoServerTarget)

	// create dial context (connection) for the client, it will be used bu the client to communicate with the server,
	// kep in mind that connection object is lazy, that means it will establish real connection only before
	// the first usage
	clientConn, err := grpc.DialContext(context.TODO(), echoServerTarget, grpc.WithInsecure())
	if err != nil {
		return err
	}

	// do not forget to close the connection after communication is over
	defer func() {
		if err := clientConn.Close(); err != nil {
			log.Printf("Got an error on closing client connection: %v\n", err)
		}
	}()

	// create EchoService client from generated code - all it needs is connection
	echoClient := rpc.NewEchoServiceClient(clientConn)

	// prepare a message to send to a server - just send current date and time
	msg := &rpc.SaySomething{Message: time.Now().String()}
	log.Printf("Sending a message to an Echo Server: %v\n", msg)

	// send the message and get the response
	response, err := echoClient.Reflect(context.TODO(), msg)
	if err != nil {
		return err
	}

	log.Printf("Got a response from the Echo Server: %v\n", response)

	return nil
}

Application has two functions - NewClientCmd is the command builder. As you can see I'm using nested client command keeping in mind that this tutorial will include more server examples. The second function has the actual gRPC client logic.

Comments in the code describe what is going on there. The only thing that worth mentioning additionally is grpc.WithInsecure() parameter passed to a connection context - it has the same effect as -plaintext parameter we used in the tools before, so it just enables insecure connection mode.

Add a client command to a root one with cmd.AddCommand(client.NewClientCmd(ctx, version)) and we're ready to test it.

Run the server and run the client in another session - you should get something like this:

$ go run main.go client echo
2019/08/22 11:56:34 Running gRPC client v0.0.0-dev
2019/08/22 11:56:34 Connecting to the gRPC Server at localhost:5000
2019/08/22 11:56:34 Sending a message to an Echo Server: Message:"2019-08-22 11:56:34.12164 +0200 CEST m=+0.006463419"
2019/08/22 11:56:34 Got a response from the Echo Server: Message:"2019-08-22 11:56:34.12164 +0200 CEST m=+0.006463419" HappenedAt:<seconds:1566467794 nanos:128242000 >

7. Time to add a pinch of security

Until now communication between server and client was running over an insecure channel. Time to add some security to a server. We'll start with TLS.

First we need to generate self-signed SSL-certificate. I'll only list commands that needs to be executed, for details - please refer to official OpenSSL documentation (genrsa, req, x509).

$ mkdir -p resources/cert
$ openssl genrsa -out resources/cert/echo.key 2048
$ openssl req -new -x509 -sha256 -key resources/cert/echo.key -out resources/cert/echo.crt -days 3650
$ openssl req -new -sha256 -key resources/cert/echo.key -out resources/cert/echo.csr
$ openssl x509 -req -sha256 -in resources/cert/echo.csr -signkey resources/cert/echo.key -out resources/cert/echo.crt -days 3650

Since we're generating certificate for development Common Name (eg, fully qualified host name) should be set to localhost.

Not that we have an SSL Key and Certificate we can start securing our server.

To avoid hard-coding Key and Certificate paths I added paths as flags for an application. Now command initialisation looks like this:

const tcpPort = 5000

type serverConfig struct {
	port    int
	tlsCert string
	tlsKey  string
}

// NewServerCmd builds new echo-server command
func NewServerCmd(ctx context.Context, version string) *cobra.Command {
	cfg := new(serverConfig)

	cmd := &cobra.Command{
		Use:   "echo-server",
		Short: "Starts Echo gRPC Server",
		RunE: func(cmd *cobra.Command, args []string) error {
			return runServer(ctx, version, cfg)
		},
	}

	cmd.PersistentFlags().IntVar(&cfg.port, "port", tcpPort, "Port to run gRPC Sever")
	cmd.PersistentFlags().StringVar(&cfg.tlsCert, "tls-cert", "", "TLS Certificate file path")
	cmd.PersistentFlags().StringVar(&cfg.tlsKey, "tls-key", "", "TLS Key file path")

	return cmd
}

As you can see port is also extracted to ann application flags but it has default value so it does not change a lot.

And here are changes made to the server initialisation:

func runServer(ctx context.Context, version string, cfg *serverConfig) error {
	log.Printf("Starting echo-server v%s", version)

	// create TLS credentials from certificate and key files
	tlsCredentials, err := credentials.NewServerTLSFromFile(cfg.tlsCert, cfg.tlsKey)
	if err != nil {
		return err
	}

	opts := []grpc.ServerOption{
		grpc.Creds(tlsCredentials),
	}

	// create new gRPC Server instance
	s := grpc.NewServer(opts...)

    ...

    return nil

As you can see there is a small piece of code added before the server initialisation - reading a Certificate and a Key from files into TLS credentials and setting these credentials as a server options.

Now you can run the server using the following command:

$ go run main.go echo-server --tls-cert `pwd`/resources/cert/echo.crt --tls-key `pwd`/resources/cert/echo.key
2019/08/22 12:35:51 Starting echo-server v0.0.0-dev
2019/08/22 12:35:51 Running gRPC server on port 5000...

Time to add TLS connection support for a client.

The changes to a command builder are very similar to the changes made for server but a client requires only a Certificate:

const echoServerTarget = "localhost:5000"

type clientConfig struct {
	target  string
	tlsCert string
}

// NewClientCmd builds new gRPC client command
func NewClientCmd(ctx context.Context, version string) *cobra.Command {
	cfg := new(clientConfig)

	cmd := &cobra.Command{
		Use:   "client",
		Short: "Runs gRPC client",
		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
			log.Printf("Running gRPC client v%s", version)
			return nil
		},
	}

	cmd.PersistentFlags().StringVar(&cfg.tlsCert, "tls-cert", "", "TLS Certificate file path")

	echoCmd := &cobra.Command{
		Use:   "echo",
		Short: "Runs gRPC echo-server client",
		RunE: func(cmd *cobra.Command, args []string) error {
			return runEchoClient(ctx, cfg)
		},
	}

	echoCmd.PersistentFlags().StringVar(&cfg.target, "target", echoServerTarget, "Server target")

	cmd.AddCommand(echoCmd)

	return cmd
}

Now that we have a way to provide a Certificate to a client we should add it to a connection:

func runEchoClient(ctx context.Context, cfg *clientConfig) error {
	log.Printf("Connecting to the gRPC Server at %s", cfg.target)

	tlsCredentials, err := credentials.NewClientTLSFromFile(cfg.tlsCert, "")
	if err != nil {
		return err
	}

	// create dial context (connection) for the client, it will be used bu the client to communicate with the server,
	// kep in mind that connection object is lazy, that means it will establish real connection only before
	// the first usage
	clientConn, err := grpc.DialContext(
		context.TODO(),
		cfg.target,
		grpc.WithTransportCredentials(tlsCredentials),
	)
	if err != nil {
		return err
	}

    ...

    return nil
}

Now you can run the client using the following command:

$ go run main.go client echo --tls-cert `pwd`/resources/cert/echo.crt
2019/08/22 12:36:04 Running gRPC client v0.0.0-dev
2019/08/22 12:36:04 Connecting to the gRPC Server at localhost:5000
2019/08/22 12:36:04 Sending a message to an Echo Server: Message:"2019-08-22 12:36:04.574518 +0200 CEST m=+0.007852211"
2019/08/22 12:36:04 Got a response from the Echo Server: Message:"2019-08-22 12:36:04.574518 +0200 CEST m=+0.007852211" HappenedAt:<seconds:1566470164 nanos:586764000 >

IMPORTANT SECURITY NOTE: I'm committing all the openssl-generated files for a tutorial purpose, but they should not be exposed in production environment. Work with them in the same way as you work with other security-sensitive information like DB credentials.

8. Moving towards production-ready solution - adding timeouts

Time to add more mature solutions and one of them is timeouts. From a client-perspective there are two types of timeouts that are interesting for us - connection timeout and method call timeout.

Let's add timeouts as options with default values to the client application:

type clientConfig struct {
	target  string
	tlsCert string

	dialTimeout    time.Duration
	requestTimeout time.Duration
}

// NewClientCmd builds new gRPC client command
func NewClientCmd(ctx context.Context, version string) *cobra.Command {
	...
    cmd.PersistentFlags().DurationVar(&cfg.dialTimeout, "dial-timeout", 5*time.Second, "Server dial timeout")
    ...
    echoCmd.PersistentFlags().DurationVar(&cfg.requestTimeout, "request-timeout", 10*time.Second, "Request timeout")
    ....
}

Timeouts are managed by context, so for a connection it looks like:

    dialCtx, cancel := context.WithTimeout(context.TODO(), cfg.dialTimeout)
	defer cancel()

	// create dial context (connection) for the client, it will be used bu the client to communicate with the server,
	// kep in mind that connection object is lazy, that means it will establish real connection only before
	// the first usage
	clientConn, err := grpc.DialContext(
		dialCtx,
		cfg.target,
		grpc.WithTransportCredentials(tlsCredentials),
	)
	if err != nil {
		return err
	}

and for request:

    rqCtx, cancel := context.WithTimeout(context.TODO(), cfg.requestTimeout)
	defer cancel()

	// send the message and get the response
	response, err := echoClient.Reflect(rqCtx, msg)
	if err != nil {
		return err
	}

The only problem that we have now is the fact that echo server is running locally and real request time is measured in nanoseconds. But we can artificially slow down our server by adding delay into the method. Let's do this! =)

type serverConfig struct {
	port    int
	tlsCert string
	tlsKey  string

	requestMaxDelay time.Duration
}

// NewServerCmd builds new echo-server command
func NewServerCmd(ctx context.Context, version string) *cobra.Command {
    cfg := new(serverConfig)

	cmd := &cobra.Command{
		Use:   "echo-server",
		Short: "Starts Echo gRPC Server",
		PreRunE: func(cmd *cobra.Command, args []string) error {
			// initialise random generator as we may need some randomness
			rand.Seed(time.Now().UnixNano())
			return nil
		},
		RunE: func(cmd *cobra.Command, args []string) error {
			return runServer(ctx, version, cfg)
		},
	}
	...
    cmd.PersistentFlags().DurationVar(&cfg.requestMaxDelay, "request-max-delay", 0, "Artificial random delay that is added to every request call")
    ...
}

func runServer(ctx context.Context, version string, cfg *serverConfig) error {
	...
    // create new service server instance
    srv := service.NewEchoServiceServer(cfg.requestMaxDelay)
    ...
}

Please note that I added PreRunE callback to the command that seeds random generator for further usage.

And now let's modify the server to slow down randomly:

type echoServiceServer struct {
	requestMaxDelay time.Duration
}

// NewEchoServiceServer builds and returns is rpc.EchoServiceServer implementation
func NewEchoServiceServer(requestMaxDelay time.Duration) *echoServiceServer {
	return &echoServiceServer{requestMaxDelay}
}

// Reflect is the rpc.EchoServiceServer implementation
func (s *echoServiceServer) Reflect(ctx context.Context, in *rpc.SaySomething) (*rpc.HearBack, error) {
	if s.requestMaxDelay > 0 {
		delay := time.Duration(rand.Int63n(int64(s.requestMaxDelay)))
		log.Printf("Adding artificial delay to a method call: %s", delay.String())

		time.Sleep(delay)
	}

	return &rpc.HearBack{
		Message:    in.Message,
		HappenedAt: ptypes.TimestampNow(),
	}, nil
}

And now we can run the server to check if the timeout really works - the first request takes less time than a timeout, the second one fails.

Server logs output:

$ go run main.go echo-server --tls-cert `pwd`/resources/cert/echo.crt --tls-key `pwd`/resources/cert/echo.key --request-max-delay 5s
2019/08/22 15:13:40 Starting echo-server v0.0.0-dev
2019/08/22 15:13:40 Running gRPC server on port 5000...
2019/08/22 15:13:51 Adding artificial delay to a method call: 930.524666ms
2019/08/22 15:15:14 Adding artificial delay to a method call: 4.884658024s

Client logs output:

$ go run main.go client echo --tls-cert `pwd`/resources/cert/echo.crt --request-timeout 2s
2019/08/22 15:13:51 Running gRPC client v0.0.0-dev
2019/08/22 15:13:51 Connecting to the gRPC Server at localhost:5000
2019/08/22 15:13:51 Sending a message to an Echo Server: Message:"2019-08-22 15:13:51.629538 +0200 CEST m=+0.007633975"
2019/08/22 15:13:52 Got a response from the Echo Server: Message:"2019-08-22 15:13:51.629538 +0200 CEST m=+0.007633975" HappenedAt:<seconds:1566479632 nanos:572179000 >

$ go run main.go client echo --tls-cert `pwd`/resources/cert/echo.crt --request-timeout 2s
2019/08/22 15:15:14 Running gRPC client v0.0.0-dev
2019/08/22 15:15:14 Connecting to the gRPC Server at localhost:5000
2019/08/22 15:15:14 Sending a message to an Echo Server: Message:"2019-08-22 15:15:14.791099 +0200 CEST m=+0.007889743"
Error: rpc error: code = DeadlineExceeded desc = context deadline exceeded
Usage:
  grpc-tutorial client echo [flags]

Flags:
  -h, --help                       help for echo
      --request-timeout duration   Request timeout (default 10s)
      --target string              Server target (default "localhost:5000")

Global Flags:
      --dial-timeout duration   Server dial timeout (default 5s)
      --tls-cert string         TLS Certificate file path

2019/08/22 15:15:16 Failed to run command: rpc error: code = DeadlineExceeded desc = context deadline exceeded
exit status 1

About

Simple gRPC service written in Go with steps to reproduce

License:Apache License 2.0


Languages

Language:Go 96.0%Language:Makefile 4.0%