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
PATHso 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
UnimplementedCalculatorServerinside another structCalculatorServer, - and if the
UnimplementedCalculatorServerhas implemented an interfaceCalculatorServer, - then due to composition, the struct
CalculatorServernow also implementsCalculatorServerthrough promotion of methods from theUnimplementedCalculatorServerstruct. - Now, when you have the same method implemented with
CalculatorServeras the receiver (which is what is happening withCalculatein our case), the method is called using theCalculatorServeras the receiver. If that method is not implemented, then theUnimplementedCalculatorServer’s method is called. - i.e.: If you don’t implement
CalculateusingCalculatorServerstruct, when you call the methodCalculateusing 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.