Developing a Simple gRPC API in Golang
This post assumes that you already have a basic understanding of gRPC. If not, I highly recommend you go through the docs. It also requires familiarity with golang, for which the go playground is a great resource.
Overview
The post will walk you through developing a simple gRPC API and implement both the client and server in golang. We will write the .proto
files, generate code, implement the server and client. All code in the repository can be found in this repository.
Protocol Buffers
To write a gRPC service, you’ll have to define the structure of the payload messages and the service interface. Protocol Buffers is the default interface definition language, so we’ll be using that.
syntax="proto3";
option go_package = ".";
enum Operations{
ADDITION = 0;
SUBTRACTION = 1;
MULTIPLICATION = 2;
DIVISION = 3;
}
message Input {
int32 operand1 = 1;
int32 operand2 = 2;
Operations operation = 3;
}
message Output {
int32 operand1 = 1;
int32 operand2 = 2;
Operations operation = 3;
int64 result = 4;
}
service Calculator {
rpc Calculate(Input) returns (Output) {}
}
The above protobuf snippet defines a Calculator
service which has a rpc method Calculate()
which takes input of type Input
and returns value of type Output
.
Both Input
and Output
has two operands, operand1
, operand2
and an operation
, while output has an additional field called result
.
operation
is of type Operations
which is an enum. In an enum in protobuf, there needs to be a default value which should be assigned to 0. So, in this case, ADDITION
is the default value.
option go_package = ".";
is a golang specific statement which tells the protoc
compiler where to place the generated protobuf files.
Now that we have the definition ready, it is time to generate the protobuf files. Create a directory called definitions/
inside your project directory and save the above snippet as calculator.proto
.
Code Generation
You’ll need to install the protobuf compiler and the go plugins for the protocol compiler.
- Install the protobuf compiler. While there are many methods to do this, I recommend using a package manager like brew or apt to install it, as that has been the most hassle-free.
brew install protobuf
- Install the go plugins for the compiler. (This assumes you have working go install in your path)
go install google.golang.org/protobuf/cmd/[email protected]
go install google.golang.org/grpc/cmd/[email protected]
- Update your
PATH
so that the protobuf compiler can find the plugins.
export PATH="$PATH:$(go env GOPATH)/bin"
- Now, you can generate the protobuf files for the above definition by using the following command.
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative definitions/calculator.proto
You’ll have two new files calculator.pb.go
and calculator_grpc.pb.go
inside the definitions/
directory. You’ll need these two files for your gRPC service to work, so make sure these files are present or generated wherever you want to have the server or the client.
gRPC Server
If you look inside the auto-generated calculator_grpc.pb.go
file, you’ll have an interface for the CalculatorServer
. You’ll have to implement this interface to implement the server.
type CalculatorServer interface {
Calculate(context.Context, *Input) (*Output, error)
mustEmbedUnimplementedCalculatorServer()
}
The interface is implemented in the below code snippet. The Calculate()
function now gives you the result of an operation after taking in two operands and the operator.
type CalculatorServer struct {
api.UnimplementedCalculatorServer
}
func (s *CalculatorServer) Calculate(ctx context.Context, request *api.Input) (*api.Output, error) {
sugar.Info("Got Payload:", " Operand1: ", request.Operand1, " Operand2: ", request.Operand2, " Operation: ", request.Operation)
var result int64
switch request.Operation {
case api.Operations_ADDITION:
result = int64(request.Operand1) + int64(request.Operand2)
case api.Operations_SUBTRACTION:
result = int64(request.Operand1) - int64(request.Operand2)
case api.Operations_MULTIPLICATION:
result = int64(request.Operand1) * int64(request.Operand2)
case api.Operations_DIVISION:
if request.Operand2 == 0 {
return &api.Output{Operand1: request.Operand1, Operand2: request.Operand2, Result: result, Operation: request.Operation}, errors.New("division by zero is not possible")
}
result = int64(request.Operand1) / int64(request.Operand2)
}
return &api.Output{Operand1: request.Operand1, Operand2: request.Operand2, Result: result, Operation: request.Operation}, nil
}
To run the server, use the code below. This creates a TCP listener and listens on the given port. Then, we use the NewServer() function to start a new gRPC server, register our service to the server and serve it using the created listener.
func Run() {
defer logger.Sync()
sugar.Info("Calculator GRPC server")
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", config.Host, config.Port))
if err != nil {
sugar.Fatal(err)
os.Exit(1)
}
server := grpc.NewServer()
api.RegisterCalculatorServer(server, &CalculatorServer{})
err = server.Serve(listener)
if err != nil {
sugar.Fatal("Failed to server GRPC server")
os.Exit(1)
}
}
gRPC Client
You’ll see a CalculatorClient
interface in the same auto-generated calculator_grpc.pb.go
.
type CalculatorClient interface {
Calculate(ctx context.Context, in *Input, opts ...grpc.CallOption) (*Output, error)
}
type calculatorClient struct {
cc grpc.ClientConnInterface
}
func NewCalculatorClient(cc grpc.ClientConnInterface) CalculatorClient {
return &calculatorClient{cc}
}
func (c *calculatorClient) Calculate(ctx context.Context, in *Input, opts ...grpc.CallOption) (*Output, error) {
out := new(Output)
err := c.cc.Invoke(ctx, "/Calculator/Calculate", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
As you can see, the client is already implemented for us. We can just go ahead and use it in our code to call the server.
func Run() {
sugar.Info("GRPC Client")
conn, err := grpc.Dial(fmt.Sprintf("%s:%d", config.Host, config.Port), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
sugar.Fatal(err)
os.Exit(1)
}
sugar.Info("Connection established with server")
defer conn.Close()
c := api.NewCalculatorClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
request := get_input()
response, err := c.Calculate(ctx, &request)
if err != nil {
sugar.Fatal(err)
os.Exit(1)
}
sugar.Info("Got result: ", response.Result)
}
The above code calls the server using CalculatorClient
without any TLS. The request needs to be a struct
of type request
We use the context
module to call the Calculate
function in the server with a timeout attached.
Cool Note
When you take a closer look at the auto-generated CalculatorServer
interface in the calculator_grpc.pb.go
file, you’ll realise there is a method mustEmbedUnimplementedCalculatorServer()
. This is to ensure forward compatibility with future versions of the API.
// CalculatorServer is the server API for Calculator service.
// All implementations must embed UnimplementedCalculatorServer
// for forward compatibility
type CalculatorServer interface {
Calculate(context.Context, *Input) (*Output, error)
mustEmbedUnimplementedCalculatorServer()
}
// UnimplementedCalculatorServer must be embedded to have forward compatible implementations.
type UnimplementedCalculatorServer struct {
}
func (UnimplementedCalculatorServer) Calculate(context.Context, *Input) (*Output, error) {
return nil, status.Errorf(codes.Unimplemented, "method Calculate not implemented")
}
func (UnimplementedCalculatorServer) mustEmbedUnimplementedCalculatorServer() {}
To clarify, when you redefine the service and add or delete a new method, the CalculatorServer
will change. This results in any struct that we have used to implement that (CalculatorServer
struct in the server/server.go
in this case) will report an error during compilation.
But, now that we have an auto-generated UnimplementedCalculatorServer
struct which implements all methods of CalculatorServer
interface every time it is regenerated, and we are embedding it in our implementation, the error will not come up.
This is because:
- when you embed a struct
UnimplementedCalculatorServer
inside another structCalculatorServer
, - and if the
UnimplementedCalculatorServer
has implemented an interfaceCalculatorServer
, - then due to composition, the struct
CalculatorServer
now also implementsCalculatorServer
through promotion of methods from theUnimplementedCalculatorServer
struct. - Now, when you have the same method implemented with
CalculatorServer
as the receiver (which is what is happening withCalculate
in our case), the method is called using theCalculatorServer
as the receiver. If that method is not implemented, then theUnimplementedCalculatorServer
’s method is called. - i.e.: If you don’t implement
Calculate
usingCalculatorServer
struct, when you call the methodCalculate
using a variable of typeCalculatorServer
, you’ll get a response sayingmethod not implemented
.
In hindsight, naming my implementation the same as the interface (CalculatorServer
) was a mistake.