BradCypert.com
gRPC fundamentals with Go
July 13, 2024

gRPC fundamentals with Go

Posted on July 13, 2024  (Last modified on November 8, 2024 )
13 minutes  • 2630 words

gRPC (Remote Procedure Call) is a powerful tool for building robust and scalable communication between server applications. It offers several advantages over traditional REST APIs, including type safety, performance optimizations, and a cleaner development experience. In this blog post, we'll explore how to leverage gRPC with Go to establish communication between servers. We'll build a simple coffee shop example where a coffee shop client communicates with a server to manage orders.

Why gRPC?

There are several compelling reasons to consider gRPC for server-to-server communication:

Building a Coffee Shop with gRPC and Go

Let's dive into building a practical example using gRPC and Go. We'll create a coffee shop server that offers functionalities like retrieving menus, placing orders, and checking order status. A separate client server will interact with the coffee shop server to simulate these actions.

Defining Messages with Protocol Buffers

Protocol Buffers (Protobuf) plays a crucial role in gRPC communication. It's a language-neutral mechanism for defining message structures used to exchange data between services. Our project utilizes a .proto file to define messages like MenuRequest, Order, Receipt, and OrderStatus. This file is called coffee_shop.proto.

syntax = "proto3";
package coffeeshop;

option go_package = "github.com/bradcypert/proto_example/coffeeshop_protos";

service CoffeeShop {
  rpc GetMenu(MenuRequest) returns (stream Menu) {}
  rpc PlaceOrder(Order) returns (Receipt) {}
  rpc GetOrderStatus(Receipt) returns (OrderStatus) {}
}

message MenuRequest {}

message Order {
  repeated Item items = 1;
}

message Receipt {
  string id = 1;
}

message OrderStatus {
  string orderId = 1;
  string status = 2;
}

message Menu {
  repeated Item items = 1;
}


message Item {
  string id = 1;
  string name = 2;
}

There's quite a lot going on in this little proto file! First, we're specifying that we're using the proto3 syntax. Second, we define a package. This is to help with naming collisions and is essentially a namespace. Third, we define a service called CoffeeShop. This service has three remote procedure calls (rpcs). We'll get into these in just a moment. Lastly, we have a slew of messages. Messages are the communication format for gRPC. We send messages and recieve messages when communicating over gRPC. You'll notice something interesting about these messages though. The types appear to be wrong (1 is not a string). With gRPC, we assign a number to each property of a message. That number is the field identifier. When we serialize or deserialize a gRPC message (our libraries do this for us), we use those indices to determine what value belongs in what space of the (de)serialized object.

The short and sweet is this: all fields have a number assigned to them, and once you have assigned a number to a field for a message you DO NOT change it. When you want to add new data a message, you add a new fields and give it a new number, even if it is replacing an old field. The last call out for messages is that if we want to work with a list of objects, we use the repeated keyword like I did above in the menu message.

Now, back to that service definition! This is definitely the most interesting part (to me, at least) of the proto file. We define three methods on our service, GetMenu which takes in a MenuRequest (which is empty) and streams a Menu back to the caller, PlaceOrder which takes in an Order and returns a Receipt, and GetOrderStatus which takes in a Receipt and returns an OrderStatus. Two special callouts here: The MenuRequest message is empty. gRPC always uses messages to communicate, so we can't have a service that takes in 0 arguments, so in our case, we use a message with no properties. The second thing is that this method streams the response back. The coffee shop piece here is a bit of a weird example, but we're able to stream multiple items back to the client with this approach, and with some changes to our proto file, clients can also stream data to our server! How neat!

Generating Code

We need to convert this protobuf file into something more usable. In our case, that's going to be Go code. You can run the following to do just that:

protoc --go_out=./coffeeshop_proto --go_opt=paths=source_relative \
    --go-grpc_out=./coffeeshop_proto --go-grpc_opt=paths=source_relative \
    coffee_shop.proto

That being said, you do need to install the protobuf compiler (brew install protobuf on mac, other OS instructions here ) to run the above code. Let's take this a step further and throw it in a Makefile so we don't have to remember all of that.

build_proto:
	protoc --go_out=./coffeeshop_proto --go_opt=paths=source_relative \
    --go-grpc_out=./coffeeshop_proto --go-grpc_opt=paths=source_relative \
    coffee_shop.proto

Sweet, now we can just run make build_proto to regenerate our code from the protocol file.

Implementing the Coffee Shop Server in Go

The coffee shop server is a Go application responsible for handling gRPC requests from the client server. Here's a breakdown of the key functionalities:

Here's an example of how the server might handle the GetMenu request:

func (s *server) GetMenu(menuRequest *pb.MenuRequest, srv pb.CoffeeShop_GetMenuServer) error {
  items := []*pb.Item{
    &pb.Item{Id: "1", Name: "Black Coffee"},
    &pb.Item{Id: "2", Name: "Americano"},
    &pb.Item{Id: "3", Name: "Vanilla Soy Chai Latte"},
  }

  for _, item := range items {
    srv.Send(&pb.Menu{Items: []*pb.Item{item}})
  }

  return nil
}

This code snippet demonstrates server-side streaming for the GetMenu method. It iterates over a predefined slice of pb.Item and sends them one by one using srv.Send. In a real-world scenario, the server would likely retrieve menu items from a database, separate network calls, or even call out to IoT devices to get stock levels.

NOTE: pb is a package alias for our generated code from our protocol file. You'll be able to see that in the full server code in just a moment.

When you're not working with streams (as is our two other methods), things get even simpler. Our place order method will look like the following:

func (s *server) PlaceOrder(context.Context, *pb.Order) (*pb.Receipt, error) {
	return &pb.Receipt{
		Id: "ABC123",
	}, nil
}

This receiver function exists on the server struct and receives a context and an Order from our generated code (pb), and returns a pb.Receipt and an error. In the case of an actual coffee shop, we wouldn't hardcode the values here and would likely interface with a POS api, but I don't have one of those on hand and my local coffee shop was not thrilled when I suggested they let me interface with theirs ๐Ÿ™ƒ.

With this in mind, here's the entirity of server.go:

package main

import (
	"context"
	"log"
	"net"

	pb "github.com/bradcypert/proto_example/coffeeshop_proto"
	"google.golang.org/grpc"
)

// Create a struct and embed our UnimplementCofeeShopServer
// We provide a full implementation to the methods that this embedded struct specifies down below
type server struct {
	pb.UnimplementedCoffeeShopServer
}

// Get a menu, stream the response back to the client
func (s *server) GetMenu(menuRequest *pb.MenuRequest, srv pb.CoffeeShop_GetMenuServer) error {
	items := []*pb.Item{
		&pb.Item{
			Id:   "1",
			Name: "Black Coffee",
		},
		&pb.Item{
			Id:   "2",
			Name: "Americano",
		},
		&pb.Item{
			Id:   "3",
			Name: "Vanilla Soy Chai Latte",
		},
	}

  // weird little gimmicky way to "simulate" streaming data back to the client
  // ideally this is representing sending the pieces of data we have back as we get them
	for i, _ := range items {
		srv.Send(&pb.Menu{
			Items: items[0 : i+1],
		})
	}

	return nil
}

// Place an order
func (s *server) PlaceOrder(context.Context, *pb.Order) (*pb.Receipt, error) {
	return &pb.Receipt{
		Id: "ABC123",
	}, nil
}

// Get order status
func (s *server) GetOrderStatus(context context.Context, receipt *pb.Receipt) (*pb.OrderStatus, error) {
	return &pb.OrderStatus{
		OrderId: receipt.Id,
		Status:  "IN PROGRESS",
	}, nil
}

func main() {

  // setup a listener on port 9001
	lis, err := net.Listen("tcp", ":9001")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

  // create a new grpc server
	grpcServer := grpc.NewServer()

  // register our server struct as a handle for the CoffeeShopService rpc calls that come in through grpcServer
	pb.RegisterCoffeeShopServer(grpcServer, &server{})

  // Serve traffic
	if err := grpcServer.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %s", err)
	}
}

The few pieces we haven't covered yet are our struct and the main function. For our struct, we're simply embedding UnimplementedCoffeeShopServer which is generated by our codegen process. The functions we're defining on our struct help us adhere to the UnimplementedCoffeeShopServer interface.

Our main function is fairly strightforward, too, barring one magical piece. We're creating a net listener on port 9001, then creating a new grpcServer, and then calling RegisterCoffeeShopServer from our generated code, providing it our newly created grpcServer and an instance of our server struct. This "magical" piece handles binding the server so that it will handle gRPC invocations that it supports. Finally, we call Serve on our grpcServer, passing in the listener that it should listen on.

We should be able to run our server now, although it doesn't seem to do anything at this point:

go run server.go

Creating a Client in Go

This is all well and good, but a server does nothing if a client can't communicate with it, and while gRPC is nice, it doesn't have the discovery of something like REST or HATEOAS (it wasn't designed to, though!). We need a client to communicate with our server.

Now, one of the nice things about gRPC is that these calls are treated like remote procedure calls and they're type-safe, but we can generate a client in any language that gRPC supports and call into our server, even if the two languages aren't the same. So, while we could write our client in Node, Dart, Python, Ruby, Java, or several others, I'm a gopher and we're going to write it in Go.

The client will interact with the coffee shop server using gRPC. Here's a breakdown of the key functionalities:

  1. Connecting to the Server: The client establishes a gRPC connection to the coffee shop server at a specified address (e.g., localhost:9001). It's important to use secure communication channels with appropriate TLS certificates in production environments.

  2. Creating a Client Stub: The client creates a client stub for the CoffeeShop service using the generated Go code from the .proto file. This stub provides methods that correspond to the gRPC service definitions.

  3. Calling Service Methods: The client uses the stub to call service methods on the coffee shop server. These calls can involve sending requests and receiving responses.

package main

import (
	"context"
	"io"
	"log"
	"time"

	pb "github.com/bradcypert/proto_example/coffeeshop_proto"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

func main() {
  // Create a new grpc client
	conn, err := grpc.NewClient("localhost:9001", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("failed to connect to gRPC server at localhost:9001: %v", err)
	}
  // dont forget to close it
	defer conn.Close()

  // create a new coffee shop client from our generated code and pass in the connection created above
	c := pb.NewCoffeeShopClient(conn)

  // give us a context that we can cancel, but also a timeout just to illustrate a point
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	// stream the menu
	menuStream, err := c.GetMenu(ctx, &pb.MenuRequest{})
	if err != nil {
		log.Fatalf("error calling function SayHello: %v", err)
	}

  // this next bit is some channel manipulation to let a go routine run with recieving messages from the stream.
  // there are other ways to handle this, but this is how I choose to handle it.
	done := make(chan bool)

  // We'll store the items here so that we can refer to them after streaming
	var items []*pb.Item

  // start a go routine that runs until we get an EOF from the stream.
  // We use this because our server sends us a partial menu as it builds up a menu in memory.
  // When we get an EOF, the stream is finished and we have the most up to date menu.
	go func() {
		for {
			resp, err := menuStream.Recv()
			if err == io.EOF {
				done <- true
				return
			}
			if err != nil {
				log.Fatalf("can not receive %v", err)
			}
      // Store the last message's items for use later
			items = resp.Items
			log.Printf("Resp received: %v", resp.Items)
		}
	}()

  // Wait until that done channel has a message.
	<-done

  // Make a simple call to order all the items on the menu
	receipt, err := c.PlaceOrder(ctx, &pb.Order{Items: items})
	log.Printf("%v", receipt)

  // Make a simple call to get the order status.
	status, err := c.GetOrderStatus(ctx, receipt)
	log.Printf("%v", status)
}

I took the liberty of adding additional comments to the code here to help explain the pieces as you read through it, but I'll recap it once more, too. We're creating a new gRPC client and pointing it to the port that our gRPC server (so the two can talk together). We're then getting some typesafety by passing that connection to our generated code which ultimately creates a CoffeeShopClient for us. This client is for our CoffeeShopService defined in our Protobuf file and provides the methods that we have specified on that service in our Protobuf file.

Over the next chunk of code, we call all three methods. The first streams the menu to us until it's finished streaming, and we store the menu items in a slice to use in the next block of code. That block of code calls the PlaceOrder method, passing in all the items on the Menu (๐Ÿค‘). That method returns a receipt, which we then use to get the order status of that receipt, which returns us the status.

Let's go ahead and (in a new terminal) run our client, too. Make sure your server is still running before running your client!

โžœ go run client/client.go
2024/07/13 21:46:06 Resp received: [id:"1" name:"Black Coffee"]
2024/07/13 21:46:06 Resp received: [id:"1" name:"Black Coffee" id:"2" name:"Americano"]
2024/07/13 21:46:06 Resp received: [id:"1" name:"Black Coffee" id:"2" name:"Americano" id:"3" name:"Vanilla Soy Chai Latte"]
2024/07/13 21:46:06 id:"ABC123"
2024/07/13 21:46:06 orderId:"ABC123" status:"IN PROGRESS"

Now we're cookin' with oil!

Additional Considerations

This client code has a couple of bad practices and I want to call those out. I choose to follow these practices in an effort to keep this tutorial simple and maintain some brevity with the code, but you should always consider these items when writing Go (or code in general, really):

That's all folks!

At this point, you've got a working client and server communicating together over grpc. If you want to make changes to the schema, you'll do that in the proto file, and you WILL NOT remove fields or re-number the indexes. Then, you'll be able to run make build_proto and get the newly generated messages and schema. I hope you found this help, and if so, connect with me on X or Linkedin !

Cartoon headshot of Brad Cypert
Follow me

Connect with me to follow along on my journey in my career, open source, and mentorship. Occasionally, I'll share good advice and content (quality not guaranteed).