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
- 2. gRPC service definition and basic Makefile
- 3. Implement EchoService Server
- 4. Wrap application into simple command-based approach
- 5. Create simple gRPC Server, finally!
- 6. Create simple gRPC client
- 7. Time to add a pinch of security
- 8. Moving towards production-ready solution - adding timeouts
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 dependenciesproto
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 implementationEchoServiceServer
interface w/out implementationRegisterEchoServiceServer
function that acceptsEchoServiceServer
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:
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