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.
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.
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.
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.
syntax
=
"proto3"
;
import
"google/protobuf/wrappers.proto"
;
package
ecommerce
;
service
OrderManagement
{
rpc
getOrder
(
google.protobuf.StringValue
)
returns
(
Order
)
;
}
message
Order
{
string
id
=
1
;
repeated
string
items
=
2
;
string
description
=
3
;
float
price
=
4
;
string
destination
=
5
;
}
Use this package to leverage the well-known types such as StringValue
.
Remote method for retrieving an order.
Define the Order
type.
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.
// 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
}
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.
// Setting up a connection to the server.
...
orderMgtClient
:=
pb
.
NewOrderManagementClient
(
conn
)
...
// Get Order
retrievedOrder
,
err
:=
orderMgtClient
.
GetOrder
(
ctx
,
&
wrapper
.
StringValue
{
Value
:
"106"
})
log
.
(
"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.
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.
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.
syntax
=
"proto3"
;
import
"google/protobuf/wrappers.proto"
;
package
ecommerce
;
service
OrderManagement
{
...
rpc
searchOrders
(
.
protobuf
.
StringValue
)
returns
(
stream
Order
)
;
...
}
message
Order
{
string
id
=
1
;
repeated
string
items
=
2
;
string
description
=
3
;
float
price
=
4
;
string
destination
=
5
;
}
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.
func
(
s
*
server
)
SearchOrders
(
searchQuery
*
wrappers
.
StringValue
,
stream
pb
.
OrderManagement_SearchOrdersServer
)
error
{
for
key
,
order
:=
range
orderMap
{
log
.
(
key
,
order
)
for
_
,
itemStr
:=
range
order
.
Items
{
log
.
(
itemStr
)
if
strings
.
Contains
(
itemStr
,
searchQuery
.
Value
)
{
// Send the matching orders in a stream
err
:=
stream
.
Send
(
&
order
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"error sending message to stream : %v"
,
err
)
}
log
.
(
"Matching Order Found : "
+
key
)
break
}
}
}
return
nil
}
Find matching orders.
Send matching order through the stream.
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.
// Setting up a connection to the server.
...
c
:=
pb
.
NewOrderManagementClient
(
conn
)
...
searchStream
,
_
:=
c
.
SearchOrders
(
ctx
,
&
wrapper
.
StringValue
{
Value
:
"Google"
}
)
for
{
searchOrder
,
err
:=
searchStream
.
Recv
(
)
if
err
==
io
.
EOF
{
break
}
// handle other possible errors
log
.
(
"Search Result : "
,
searchOrder
)
}
Now let’s look at client-streaming RPC, which is pretty much the opposite of server-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.
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.
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.
func
(
s
*
server
)
UpdateOrders
(
stream
pb
.
OrderManagement_UpdateOrdersServer
)
error
{
ordersStr
:=
"Updated Order IDs : "
for
{
order
,
err
:=
stream
.
Recv
(
)
if
err
==
io
.
EOF
{
// 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
+
", "
}
}
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.
// Setting up a connection to the server.
...
c
:=
pb
.
NewOrderManagementClient
(
conn
)
...
updateStream
,
err
:=
client
.
UpdateOrders
(
ctx
)
if
err
!=
nil
{
log
.
Fatalf
(
"%v.UpdateOrders(_) = _, %v"
,
client
,
err
)
}
// Updating order 1
if
err
:=
updateStream
.
Send
(
&
updOrder1
)
;
err
!=
nil
{
log
.
Fatalf
(
"%v.Send(%v) = %v"
,
updateStream
,
updOrder1
,
err
)
}
// 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
(
)
if
err
!=
nil
{
log
.
Fatalf
(
"%v.CloseAndRecv() got error %v, want %v"
,
updateStream
,
err
,
nil
)
}
log
.
Printf
(
"Update Orders Res : %s"
,
updateRes
)
Invoking UpdateOrders
remote method.
Handling errors related to UpdateOrders
.
Sending order update via client stream.
Handling errors when sending messages to stream.
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.
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).
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.
syntax
=
"proto3"
;
import
"google/protobuf/wrappers.proto"
;
package
ecommerce
;
service
OrderManagement
{
...
rpc
processOrders
(
stream
.
protobuf
.
StringValue
)
returns
(
stream
CombinedShipment
)
;
}
message
Order
{
string
id
=
1
;
repeated
string
items
=
2
;
string
description
=
3
;
float
price
=
4
;
string
destination
=
5
;
}
message
CombinedShipment
{
string
id
=
1
;
string
status
=
2
;
repeated
Order
ordersList
=
3
;
}
Both method parameters and return parameters are declared as streams in bidirectional RPC.
Structure of the Order
message.
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
.
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.
func
(
s
*
server
)
ProcessOrders
(
stream
pb
.
OrderManagement_ProcessOrdersServer
)
error
{
...
for
{
orderId
,
err
:=
stream
.
Recv
(
)
if
err
==
io
.
EOF
{
...
for
_
,
comb
:=
range
combinedShipmentMap
{
stream
.
Send
(
&
comb
)
}
return
nil
}
if
err
!=
nil
{
return
err
}
// Logic to organize orders into shipments,
// based on the destination.
...
//
if
batchMarker
==
orderBatchSize
{
// Stream combined orders to the client in batches
for
_
,
comb
:=
range
combinedShipmentMap
{
// Send combined shipment to the client
stream
.
Send
(
&
comb
)
}
batchMarker
=
0
combinedShipmentMap
=
make
(
map
[
string
]
pb
.
CombinedShipment
)
}
else
{
batchMarker
++
}
}
}
Read order IDs from the incoming stream.
Keep reading until the end of the stream is found.
When the end of the stream is found send all the remaining combined shipments to the client.
Server-side end of the stream is marked by returning nil
.
Orders are processed in batches. When the batch size is met, all the created combined shipments are streamed to the client.
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.
// Process Order
streamProcOrder
,
_
:=
c
.
ProcessOrders
(
ctx
)
if
err
:=
streamProcOrder
.
Send
(
&
wrapper
.
StringValue
{
Value
:
"102"
}
)
;
err
!=
nil
{
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
{
}
)
go
asncClientBidirectionalRPC
(
streamProcOrder
,
channel
)
time
.
Sleep
(
time
.
Millisecond
*
1000
)
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
{
log
.
Fatal
(
err
)
}
<-
channel
func
asncClientBidirectionalRPC
(
streamProcOrder
pb
.
OrderManagement_ProcessOrdersClient
,
c
chan
struct
{
}
)
{
for
{
combinedShipment
,
errProcOrder
:=
streamProcOrder
.
Recv
(
)
if
errProcOrder
==
io
.
EOF
{
break
}
log
.
Printf
(
"Combined shipment : "
,
combinedShipment
.
OrdersList
)
}
<-
c
}
Invoke the remote method and obtain the stream reference for writing and reading from the client side.
Send a message to the service.
Create a channel to use for Goroutines.
Invoke the function using Goroutines to read the messages in parallel from the service.
Mimic a delay when sending some messages to the service.
Mark the end of stream for the client stream (order IDs).
Read service’s messages on the client side.
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.
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.
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.
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.
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.