Developing a Simple gRPC API in Golang

11 Sep 2022

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.

  1. 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
  1. 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]
  1. Update your PATH so that the protobuf compiler can find the plugins.
export PATH="$PATH:$(go env GOPATH)/bin"
  1. 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 struct CalculatorServer,
  • and if the UnimplementedCalculatorServer has implemented an interface CalculatorServer,
  • then due to composition, the struct CalculatorServer now also implements CalculatorServer through promotion of methods from the UnimplementedCalculatorServer struct.
  • Now, when you have the same method implemented with CalculatorServer as the receiver (which is what is happening with Calculate in our case), the method is called using the CalculatorServer as the receiver. If that method is not implemented, then the UnimplementedCalculatorServer’s method is called.
  • i.e.: If you don’t implement Calculate using CalculatorServer struct, when you call the method Calculate using a variable of type CalculatorServer, you’ll get a response saying method not implemented.

In hindsight, naming my implementation the same as the interface (CalculatorServer) was a mistake.

Tags