Chapter 3. gRPC Communication Patterns

In the first couple of chapters, you learned the basics of gRPC’s inter-process communication techniques and got some hands-on experience in building a simple gRPC-based application. So far what we have done is define a service interface, implement a service, run a gRPC server, and invoke service operations remotely through a gRPC client application. The communication pattern between the client and the server is a simple request–response style communication, where you get a single response for a single request. However, with gRPC, you can leverage different inter-process communication patterns (or RPC styles) other than the simple request–response pattern.

In this chapter, we’ll explore four fundamental communication patterns used in gRPC-based applications: unary RPC (simple RPC), server-side streaming, client-side streaming, and bidirectional streaming. We’ll use some real-world use cases to showcase each pattern, define a service definition using a gRPC IDL, and implement both the service and client side using Go.

Note

Go and Java Code Samples

To maintain consistency, all the code samples in this chapter are written using Go. But if you are a Java developer, you can also find the complete Java code samples for the same use cases in the source code repository for this book.

Simple RPC (Unary RPC)

Let’s begin our discussion on gRPC communication patterns with the simplest RPC style, simple RPC, which is also known as unary RPC. In simple RPC, when a client invokes a remote function of a server, the client sends a single request to the server and gets a single response that is sent along with status details and trailing metadata. In fact, this is exactly the same communication pattern that you learned in Chapters 1 and 2. Let’s try to understand the simple RPC pattern further with a real-world use case.

Suppose we need to build an OrderManagement service for an online retail application based on gRPC. One of the methods that we have to implement as part of this service is a getOrder method, where the client can retrieve an existing order by providing the order ID. As shown in Figure 3-1, the client is sending a single request with the order ID and the service responds with a single response that contains the order information. Hence, it follows the simple RPC pattern.

Simple/unary RPC
Figure 3-1. Simple/unary RPC

Now let’s proceed to the implementation of this pattern. The first step is to create the service definition for the OrderManagement service with the getOrder method. As shown in the code snippet in Example 3-1, we can define the service definition using protocol buffers, and the getOrder remote method takes a single request order ID and responds with a single response, which comprises the Order message. The Order message has the required structure to represent the order in this use case.

Example 3-1. Service definition of OrderManagement with getOrder method that uses simple RPC pattern
syntax = "proto3";

import "google/protobuf/wrappers.proto"; 1

package ecommerce;

service OrderManagement {
    rpc getOrder(google.protobuf.StringValue) returns (Order); 2
}

message Order { 3
    string id = 1;
    repeated string items = 2; 4
    string description = 3;
    float price = 4;
    string destination = 5;
}
1

Use this package to leverage the well-known types such as StringValue.

2

Remote method for retrieving an order.

3

Define the Order type.

4

repeated is used to represent the fields that can be repeated any number of times including zero in a message. Here one order message can have any number of items.

Then, using the gRPC service definition proto file, you can generate the server skeleton code and implement the logic of the getOrder method. In the code snippet in Example 3-2, what we have shown is the Go implementation of the OrderManagement service. As the input of the getOrder method, you get a single order ID (String) as the request and you can simply find the order from the server side and respond with an Order message (Order struct). The Order message can be returned along with a nil error to tell gRPC that we’ve finished dealing with the RPC and the Order can be returned to the client.

Example 3-2. Service implementation of OrderManagement with getOrder in Go
// server/main.go
func (s *server) GetOrder(ctx context.Context,
	orderId *wrapper.StringValue) (*pb.Order, error) {
     // Service Implementation.
	ord := orderMap[orderId.Value]
	return &ord, nil
}
Note

The low-level details of the complete message flow of a gRPC server and client are explained in Chapter 4. In addition to the method parameters that we have specified for the getOrder method in your service definition, you can observe that there is another Context parameter passed to the method in the preceding Go implementation of the OrderManagement service. Context carries some of the constructs such as deadlines and cancellations that are used to control gRPC behavior. We’ll discuss those concepts in detail in Chapter 5.

Now let’s implement the client-side logic to invoke the getOrder method remotely. As with the server-side implementation, you can generate code for the preferred language to create the client-side stub and then use that stub to invoke the service. In Example 3-3, we have used a Go gRPC client to invoke the OrderManagement service. The first steps, of course, are to set up the connection to the server and initiate the client stub to invoke the service. Then you can simply invoke the client stub’s getOrder method to invoke the remote method. As the response, you get an Order message that contains the order information that we define using protocol buffers in our service definition.

Example 3-3. Client implementation to invoke remote method getOrder using Go
// Setting up a connection to the server.
...
orderMgtClient := pb.NewOrderManagementClient(conn)
...

// Get Order
retrievedOrder , err := orderMgtClient.GetOrder(ctx,
       &wrapper.StringValue{Value: "106"})
log.Print("GetOrder Response -> : ", retrievedOrder)

The simple RPC pattern is quite straightforward to implement and fits well for most inter-process communication use cases. The implementation is quite similar across multiple programming languages, and you can find the source code for Go and Java in the sample source code repository of the book.

Now, since you have a good understanding of the simple RPC communication pattern, let’s move on to server-streaming RPC.

Server-Streaming RPC

In simple RPC you always had a single request and single response in the communication between the gRPC server and gRPC client. In server-side streaming RPC, the server sends back a sequence of responses after getting the client’s request message. This sequence of multiple responses is known as a “stream.” After sending all the server responses, the server marks the end of the stream by sending the server’s status details as trailing metadata to the client.

Let’s take a real-world use case to understand server-side streaming further. In our OrderManagement service suppose that we need to build an order search capability where we can provide a search term and get the matching results (Figure 3-2). Rather than sending all the matching orders at once, the OrderManagement service can send the orders as and when they are found. This means the order service client will receive multiple response messages for a single request that it has sent.

Server-streaming RPC
Figure 3-2. Server-streaming RPC

Now let’s include a searchOrder method in our gRPC service definition of the OrderManagement service. As shown in Example 3-4, the method definition is quite similar to simple RPC, but as the return parameter, you have to specify a stream of orders by using returns (stream Order) in the proto file of the service definition.

Example 3-4. Service definition with server-side streaming RPC
syntax = "proto3";

import "google/protobuf/wrappers.proto";

package ecommerce;

service OrderManagement {
    ...
    rpc searchOrders(google.protobuf.StringValue) returns (stream Order); 1
    ...
}

message Order {
    string id = 1;
    repeated string items = 2;
    string description = 3;
    float price = 4;
    string destination = 5;
}
1

Defining server-side streaming by returning a stream of Order messages.

From the service definition, you can generate the server-side code and then by implementing the generated interfaces you build the logic of the searchOrder method of the OrderManagement gRPC service. In the Go implementation shown in Example 3-5, the SearchOrders method has two parameters: searchQuery, a string value, and a special parameter OrderManagement_SearchOrdersServer to write our responses to. OrderManagement_SearchOrdersServer acts as a reference object to the stream that we can write multiple responses to. The business logic here is to find the matching orders and send them one by one via the stream. When a new order is found, it is written to the stream using the Send(…) method of the stream reference object. Once all the responses are written to the stream you can mark the end of the stream by returning nil, and the server status and other trailing metadata will be sent to the client.

Example 3-5. Service implementation of OrderManagement with searchOrders in Go
func (s *server) SearchOrders(searchQuery *wrappers.StringValue,
	stream pb.OrderManagement_SearchOrdersServer) error {

	for key, order := range orderMap {
		log.Print(key, order)
		for _, itemStr := range order.Items {
			log.Print(itemStr)
			if strings.Contains(
				itemStr, searchQuery.Value) { 1
				// Send the matching orders in a stream
				err := stream.Send(&order) 2
				if err != nil {
				   return fmt.Errorf(
					    "error sending message to stream : %v",
						    err) 3
				}
				log.Print("Matching Order Found : " + key)
				break
			}
		}
	}
	return nil
}
1

Find matching orders.

2

Send matching order through the stream.

3

Check for possible errors that could occur when streaming messages to the client.

The remote method invocation from the client side is quite similar to simple RPC. However, here you have to process multiple responses as the server writes multiple responses to the stream. So in the Go implementation of the gRPC client (Example 3-6), we retrieve messages from the client-side stream using the Recv() method and keep doing so until we reach the end of the stream.

Example 3-6. Client implementation of OrderManagement with searchOrders in Go
// Setting up a connection to the server.
...
	c := pb.NewOrderManagementClient(conn)
...
     searchStream, _ := c.SearchOrders(ctx,
     	&wrapper.StringValue{Value: "Google"}) 1

	for {
		searchOrder, err := searchStream.Recv() 2
		if err == io.EOF { 3
			break
		}
           // handle other possible errors
		log.Print("Search Result : ", searchOrder)
	}
1

The SearchOrders function returns a client stream of OrderManagement_SearchOrders​Client, which has a Recv method.

2

Calling the client stream’s Recv() method to retrieve Order responses one by one.

3

When the end of the stream is found Recv returns an io.EOF.

Now let’s look at client-streaming RPC, which is pretty much the opposite of server-streaming RPC.

Client-Streaming RPC

In client-streaming RPC, the client sends multiple messages to the server instead of a single request. The server sends back a single response to the client. However, the server does not necessarily have to wait until it receives all the messages from the client side to send a response. Based on this logic you may send the response after reading one or a few messages from the stream or after reading all the messages.

Let’s further extend our OrderManagement service to understand client-streaming RPC. Suppose you want to include a new method, updateOrders, in the OrderManagement service to update a set of orders (Figure 3-3). Here we want to send the order list as a stream of messages to the server and server will process that stream and send a message with the status of the orders that are updated.

Client Streaming RPC
Figure 3-3. Client-streaming RPC

Then we can include the updateOrders method in our service definition of the OrderManagement service as shown in Example 3-7. You can simply use stream order as the method parameter of updateOrders to denote that updateOrders will get multiple messages as the input from the client. As the server only sends a single response, the return value is a single string message.

Example 3-7. Service definition with client-side streaming RPC
syntax = "proto3";

import "google/protobuf/wrappers.proto";

package ecommerce;

service OrderManagement {
...
    rpc updateOrders(stream Order) returns (google.protobuf.StringValue);
...
}

message Order {
    string id = 1;
    repeated string items = 2;
    string description = 3;
    float price = 4;
    string destination = 5;
}

Once we update the service definition, we can generate the server- and client-side code. At the server side, you need to implement the generated method interface of the UpdateOrders method of the OrderManagement service. In the Go implementation shown in Example 3-8, UpdateOrders has an OrderManagement_UpdateOrdersServer parameter, which is the reference object to the incoming message stream from the client. Therefore, you can read messages via that object by calling the Recv() method. Depending on the business logic, you may read a few messages or all the messages until the end of the stream. The service can send its response simply by calling the SendAndClose method of the OrderManagement_UpdateOrdersServer object, which also marks the end of the stream for server-side messages. If the server decides to prematurely stop reading from the client’s stream, the server should cancel the client stream so the client knows to stop producing messages.

Example 3-8. Service implementation of OrderManagement with updateOrders method in Go
func (s *server) UpdateOrders(stream pb.OrderManagement_UpdateOrdersServer) error {

	ordersStr := "Updated Order IDs : "
	for {
		order, err := stream.Recv() 1
		if err == io.EOF { 2
			// Finished reading the order stream.
			return stream.SendAndClose(
				&wrapper.StringValue{Value: "Orders processed "
				+ ordersStr})
		}
		// Update order
		orderMap[order.Id] = *order

		log.Printf("Order ID ", order.Id, ": Updated")
		ordersStr += order.Id + ", "
	}
}
1

Read message from the client stream.

2

Check for end of stream.

Now let’s look at the client-side implementation of the client-streaming RPC use case. As shown in the following Go implementation (Example 3-9), the client can send multiple messages via the client-side stream reference using the updateStream.Send method. Once all the messages are streamed the client can mark the end of the stream and receive the response from the service. This is done using the CloseAndRecv method of the stream reference.

Example 3-9. Client implementation of OrderManagement with updateOrders method in Go
// Setting up a connection to the server.
...
	c := pb.NewOrderManagementClient(conn)
...
     updateStream, err := client.UpdateOrders(ctx) 1

	if err != nil { 2
		log.Fatalf("%v.UpdateOrders(_) = _, %v", client, err)
	}

	// Updating order 1
	if err := updateStream.Send(&updOrder1); err != nil { 3
		log.Fatalf("%v.Send(%v) = %v",
			updateStream, updOrder1, err) 4
	}

	// Updating order 2
	if err := updateStream.Send(&updOrder2); err != nil {
		log.Fatalf("%v.Send(%v) = %v",
			updateStream, updOrder2, err)
	}

	// Updating order 3
	if err := updateStream.Send(&updOrder3); err != nil {
		log.Fatalf("%v.Send(%v) = %v",
			updateStream, updOrder3, err)
	}

	updateRes, err := updateStream.CloseAndRecv() 5
	if err != nil {
		log.Fatalf("%v.CloseAndRecv() got error %v, want %v",
			updateStream, err, nil)
	}
	log.Printf("Update Orders Res : %s", updateRes)
1

Invoking UpdateOrders remote method.

2

Handling errors related to UpdateOrders.

3

Sending order update via client stream.

4

Handling errors when sending messages to stream.

5

Closing the stream and receiving the response.

As a result of this function invocation, you get the response message of the service. Since now you have a good understanding of both server-streaming and client-streaming RPC, let’s move on to bidirectional-streaming RPC, which is sort of a combination of the RPC styles that we discussed.

Bidirectional-Streaming RPC

In bidirectional-streaming RPC, the client is sending a request to the server as a stream of messages. The server also responds with a stream of messages. The call has to be initiated from the client side, but after that, the communication is completely based on the application logic of the gRPC client and the server. Let’s look at an example to understand bidirectional-streaming RPC in detail. As illustrated in Figure 3-4, in our OrderManagement service use case, suppose we need order processing functionality where you can send a continuous set of orders (the stream of orders) and process them into combined shipments based on the delivery location (i.e., orders are organized into shipments based on the delivery destination).

Bidirectional-streaming RPC
Figure 3-4. Bidirectional-streaming RPC

We can identify the following key steps of this business use case:

  • The client application initiates the business use case by setting up the connection with the server and sending call metadata (headers).

  • Once the connection setup is completed, the client application sends a continuous set of order IDs that need to be processed by the OrderManagement service.

  • Each order ID is sent to the server as a separate gRPC message.

  • The service processes each order for the specified order ID and organizes them into combined shipments based on the delivery location of the order.

  • A combined shipment may contain multiple orders that should be delivered to the same destination.

  • Orders are processed in batches. When the batch size is reached, all the currently created combined shipments will be sent back to the client.

  • For example, an ordered stream of four where two orders addressed to location X and two to location Y can be denoted as X, Y, X, Y. And if the batch size is three, then the created combined orders should be shipment [X, X], shipment [Y], shipment [Y]. These combined shipments are also sent as a stream back to the client.

The key idea behind this business use case is that once the RPC method is invoked either the client or service can send messages at any arbitrary time. (This also includes the end of stream markings from either of the parties.)

Now, let’s move on to the service definition for the preceding use case. As shown in Example 3-10, we can define a processOrders method so that it takes a stream of strings as the method parameter to represent the order ID stream and a stream of CombinedShipments as the return parameter of the method. So, by declaring both the method parameter and return parameters as a stream, you can define a bidirectional-streaming RPC method. The combined shipment message is also declared in the service definition and it contains a list of order elements.

Example 3-10. Service definition for bidirectional-streaming RPC
syntax = "proto3";

import "google/protobuf/wrappers.proto";

package ecommerce;

service OrderManagement {
    ...
    rpc processOrders(stream google.protobuf.StringValue)
        returns (stream CombinedShipment); 1
}

message Order { 2
    string id = 1;
    repeated string items = 2;
    string description = 3;
    float price = 4;
    string destination = 5;
}

message CombinedShipment { 3
    string id = 1;
    string status = 2;
    repeated Order ordersList = 3;
}
1

Both method parameters and return parameters are declared as streams in bidirectional RPC.

2

Structure of the Order message.

3

Structure of the CombinedShipment message.

Then we can generate the server-side code from the updated service definition. The service should implement the processOrders method of the OrderManagement service. In the Go implementation shown in Example 3-11, processOrders has an OrderManagement_ProcessOrdersServer parameter, which is the reference object to the message stream between the client and the service. Using this stream object, the service can read the client’s messages that are streamed to the server as well as write the stream server’s messages back to the client. Using that stream reference object, the incoming message stream can be read using the Recv() method. In the processOrders method, the service can keep on reading the incoming message stream while writing to the same stream using Send.

Note

To simplify the demonstration, some of the logic of Example 3-10 is not shown. You can find the full code example in this book’s source code repository.

Example 3-11. Service implementation of OrderManagement with processOrders method in Go
func (s *server) ProcessOrders(
	stream pb.OrderManagement_ProcessOrdersServer) error {
	...
	for {
		orderId, err := stream.Recv() 1
		if err == io.EOF {            2
			...
			for _, comb := range combinedShipmentMap {
				stream.Send(&comb) 3
			}
			return nil               4
		}
		if err != nil {
			return err
		}

		// Logic to organize orders into shipments,
		// based on the destination.
		...
		//

		if batchMarker == orderBatchSize { 5
			// Stream combined orders to the client in batches
			for _, comb := range combinedShipmentMap {
				// Send combined shipment to the client
				stream.Send(&comb)      6
			}
			batchMarker = 0
			combinedShipmentMap = make(
				map[string]pb.CombinedShipment)
		} else {
			batchMarker++
		}
	}
}
1

Read order IDs from the incoming stream.

2

Keep reading until the end of the stream is found.

3

When the end of the stream is found send all the remaining combined shipments to the client.

4

Server-side end of the stream is marked by returning nil.

5

Orders are processed in batches. When the batch size is met, all the created combined shipments are streamed to the client.

6

Writing the combined shipment to the stream.

Here we process incoming orders based on the ID, and when a new combined shipment is created the service writes it to the same stream (unlike client-streaming RPC where we write and close the stream with SendAndClose.). The end of the stream at the server side is marked when we return nil when the client’s end of the stream is found.

The client-side implementation (Example 3-12) is also quite similar to the previous examples. When the client invokes the method processOrders via the OrderManagement client object, it gets a reference to the stream (streamProcOrder) that is used in sending messages to the server as well as reading messages from the server.

Example 3-12. Client implementation of OrderManagement with processOrders method in Go
// Process Order
streamProcOrder, _ := c.ProcessOrders(ctx) 1
	if err := streamProcOrder.Send(
		&wrapper.StringValue{Value:"102"}); err != nil { 2
		log.Fatalf("%v.Send(%v) = %v", client, "102", err)
	}

	if err := streamProcOrder.Send(
		&wrapper.StringValue{Value:"103"}); err != nil {
		log.Fatalf("%v.Send(%v) = %v", client, "103", err)
	}

	if err := streamProcOrder.Send(
		&wrapper.StringValue{Value:"104"}); err != nil {
		log.Fatalf("%v.Send(%v) = %v", client, "104", err)
	}


	channel := make(chan struct{})  3
    go asncClientBidirectionalRPC(streamProcOrder, channel) 4
    time.Sleep(time.Millisecond * 1000)     5

	if err := streamProcOrder.Send(
		&wrapper.StringValue{Value:"101"}); err != nil {
		log.Fatalf("%v.Send(%v) = %v", client, "101", err)
	}

	if err := streamProcOrder.CloseSend(); err != nil { 6
		log.Fatal(err)
	}

<- channel

func asncClientBidirectionalRPC (
	    streamProcOrder pb.OrderManagement_ProcessOrdersClient,
	    c chan struct{}) {
	for {
		combinedShipment, errProcOrder := streamProcOrder.Recv() 7
		if errProcOrder == io.EOF { 8
			break
		}
		log.Printf("Combined shipment : ", combinedShipment.OrdersList)
	}
	<-c
}
1

Invoke the remote method and obtain the stream reference for writing and reading from the client side.

2

Send a message to the service.

3

Create a channel to use for Goroutines.

4

Invoke the function using Goroutines to read the messages in parallel from the service.

5

Mimic a delay when sending some messages to the service.

6

Mark the end of stream for the client stream (order IDs).

7

Read service’s messages on the client side.

8

Condition to detect the end of the stream.

The client can send messages to the service and close the stream at any arbitrary time. The same applies for reading as well. In the prior example, we execute the client message writing and message reading logic in two concurrent threads using the Go language’s Goroutines terminology.

Goroutines

In Go, Goroutines are functions or methods that run concurrently with other functions or methods. They can be thought of as lightweight threads.

So, the client can read and write to the same stream concurrently and both incoming and outgoing streams operate independently. What we have shown is a somewhat complex use case to showcase the power of bidirectional RPC. It’s important to understand that the client and server can read and write in any order—the streams operate completely independently. Therefore, it is completely up to the client and service to decide the communication pattern between the client and service once the initial connection is established.

With that, we have covered all the possible communication patterns that we can use to build interactions with gRPC-based applications. There is no hard-and-fast rule when it comes to selecting a communication pattern, but it’s always good to analyze the business use case and then select the most appropriate pattern.

Before we conclude this discussion on gRPC communication patterns, it’s important to take a look at how gRPC is used for microservices communication.

Using gRPC for Microservices Communication

One of the main usages of gRPC is to implement microservices and their inter-service communication. In microservices inter-service communication, gRPC is used along with other communication protocols and usually gRPC services are implemented as polyglot services (implemented with different programming languages). To understand this further, let’s take a real-world scenario (Figure 3-5) of an online retail system, which is an extended version of what we have discussed so far.

In this scenario, we have a number of microservices serving specific business capabilities of the online relation system. There are services such as the Product service, which is implemented as a gRPC service, and there are composite services such as the Catalog service, which calls multiple downstream services to build its business capability. As we discussed in Chapter 1, for most of the synchronous message passing scenarios, we can use gRPC. When you have certain asynchronous messaging scenarios that may require persistent messaging, then you can use event brokers or message brokers, such as Kafka, Active MQ, RabbitMQ, and NATS. When you have to expose certain business functionalities to the external world, then you can use the conventional REST/OpenAPI-based services or the GraphQL service. Thus services such as Catalog and Checkout are consuming gRPC-based backend services, and also exposing RESTful or GraphQL-based external-facing interfaces.

A common microservice deployment pattern with gRPC and other protocols
Figure 3-5. A common microservices deployment pattern with gRPC and other protocols

In most of the real-world use cases, these external-facing services are exposed through an API gateway. That is the place where you apply various nonfunctional capabilities such as security, throttling, versioning, and so on. Most such APIs leverage protocols such as REST or GraphQL. Although it’s not very common, you may also expose gRPC as an external-facing service, as long as the API gateway supports exposing gRPC interfaces. The API gateway implements cross-cutting functionality such as authentication, logging, versioning, throttling, and load balancing. By using an API gateway with your gRPC APIs, you are able to deploy this functionality outside of your core gRPC services. One of the other important aspects of this architecture is that we can leverage multiple programming languages but share the same service contract between then (i.e., code generation from the same gRPC service definition). This allows us to pick the appropriate implementation technology based on the business capability of the service.

Summary

gRPC offers a diverse set of RPC communication styles for building inter-process communication between gRPC-based applications. In this chapter, we explored four main communication patterns. Simple RPC is the most basic one; it is pretty much a simple request–response style remote procedure invocation. Server-streaming RPC allows you to send multiple messages from the service to the consumer after the first invocation of the remote method, while client streaming allows you to send multiple messages from the client to the service. We delve into the details of how we can implement each of these patterns using some real-world use cases.

The knowledge you gained in this chapter is quite useful for implementing any gRPC use case so that you can select the most appropriate communication pattern for your business. While this chapter gave you a solid understanding of gRPC communication patterns, the low-level communication details that are transparent to the user were not covered in this chapter. In the next chapter, we will dive deep into how low-level communication takes place when we have gRPC-based inter-process communication.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset