In modern cloud-native software development, microservices are arguably the most widely used architectural pattern. To learn more about it, read the previous article Microservices 101: Understanding the architecture. If you want to dive into the microservice architecture design patterns, you can read my article Microservice Architecture and its 10 Most Important Design Patterns.
In a microservice architecture, a smaller service focuses on a particular domain, so the cognitive load for developing and maintaining the service is low. But the services must communicate to fulfill business requirements. This article will show how to establish high-performance microservice communication using gRPC (a Remote Procedure Call framework) and Liberica JDK.
Table of Contents
Implementation
Microservices can communicate with each other synchronously and asynchronously. REST is extensively used for synchronous communication, but gRPC is also gaining popularity. Like REST, gRPC provides a cross-platform language-agnostic way for services to communicate. In 2016, Google created gRPC to overcome the limitations of REST for microservice communication using RPC (Remote Procedure Call). It is based on HTTP/2 and uses the binary format Protobuf for better performance. In performance-critical applications, gRPC can give an edge over REST as it is more efficient. Due to gRPC’s use of HTTP/2 and binary message format Protobuf, gRPC edges out REST for the following three cases:
- higher performance
- bi-directional streaming
- client-side load balancing
For our Demo, we will develop a simple Payment microservice that can create a payment similar to PayPal. For the sake of simplicity, this Payment microservice will be transient, i.e., without any database.
System requirements:
- Java 17
- Gradle
- Protocol buffer compiler
Install Java
Install Liberica JDK 17 (the latest LTS Java release). To verify the installation, run the following command:
java –version
You will get the following output:
openjdk 17.0.4.1 2022-08-12 LTS
OpenJDK runtime environment (build 17.0.4.1+1-LTS)
OpenJDK 64-Bit server VM (build 17.0.4.1+1-LTS, mixed mode, sharing)
As mentioned earlier, the gRPC usually uses protocol buffer as a service definition and messaging format. In gRPC, the service and message definitions are set in the .proto files. The protocol buffer compiler converts the .proto file into a language-specific implementation.
As a prerequisite, we need to install the protocol buffer compiler on our machine, as described in the official documentation.
Here is the command to install protocol buffer in Ubuntu system:
sudo apt install -y protobuf-compiler
If successfully installed, we can check the protocol buffer compiler version using the command:
protoc –version
The output will be:
libprotoc 3.12.4
Create a project
In this Demo, we will create a Gradle project with IDE (e.g., IntelliJ) using Java 17.
We need to add the following dependencies for the gRPC and protocol buffer support:
implementation 'io.grpc:grpc-netty:1.49.0'
implementation 'io.grpc:grpc-protobuf:1.49.0'
implementation 'io.grpc:grpc-stub:1.49.0'
Define the .proto file
gRPC is a contract-first communication system where the service contract between the client and server must first be defined. The Java source code will be generated from the .proto files. Here is the definition of the .proto file for our Payment microservice:
syntax = "proto3";
option java_multiple_files = true;
package org.mkzaman.grpcservice;
import "google/protobuf/timestamp.proto";
message Person {
int32 id=1;
string name = 2;
string email = 3;
}
message PaymentRequest {
Person sender = 1;
Person receiver = 2;
string purpose = 3;
double amount = 4;
}
enum PaymentStatus {
SUCCESS = 0;
FAILURE = 1;
}
message PaymentResponse {
PaymentStatus status = 1;
string paymentId = 2;
google.protobuf.Timestamp executionTime = 3;
}
service PaymentService {
rpc sendPayment(PaymentRequest) returns (PaymentResponse);
}
Let’s analyze the file line by line.
The .proto file starts with the following basic configuration:
syntax = "proto3";
option java_multiple_files = true;
package org.mkzaman.grpcservice;
import "google/protobuf/timestamp.proto";
- The first line defines the Protobuf version we are using, which is version 3.
- The second line states that multiple Java files will be generated from the .proto file.
- The third line defines the package name of the generated Java files.
In the .proto file, we can also import other .proto files, including their definition, using “import.” In the fourth line, we import Google’s “timestamp.proto” file to use the Standard timestamp attribute.
Here is the message format:
message Person {
int32 id=1;
string name = 2;
string email = 3;
}
message PaymentRequest {
Person sender = 1;
Person receiver = 2;
string purpose = 3;
double amount = 4;
}
enum PaymentStatus {
SUCCESS = 0;
FAILURE = 1;
}
message PaymentResponse {
PaymentStatus paymentStatus = 1;
string paymentId = 2;
google.protobuf.Timestamp paymentTime = 3;
}
We defined the “Person” message in the first few lines. We need to give numbers for each parameter. Unlike REST, where attribute name (e.g., “id”) is passed every time, Protobuf passes the number “1” instead.
We also defined the PaymentRequest containing the sender, receiver, purpose, and amount.
Similarly, we defined the PaymentResponse containing the paymentStatus, paymentId, and paymentTime.
Finally, here is the Payment service contract:
service PaymentService {
rpc sendPayment(PaymentRequest) returns (PaymentResponse);
}
The service contains a simple sendPayment method that accepts a PaymentRequest and returns a PaymentResponse.
Generate the Java code
There are several ways to generate the Java code from the .proto contract. One way is to install the protocol buffer compiler “protoc” in our local machine as described in the gRPC documentation. This compiler “protoc” can then be used to generate the Java code from the .proto file.
The other way is to use the Gradle plugin to generate the Java code from the .proto file. We will use the Gradle plugin for that. Please visit the official website to learn more about the gRPC Gradle plugin.
First, save the “service.proto” file in the “src/main/proto” directory.
Then, add the protocol buffer Gradle plugin in the build.gradle file as given below:
plugins {
id 'java'
id 'com.google.protobuf' version '0.8.19'
}
We also need to configure the Protobuf compilation in the following way:
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.12.4'
}
plugins {
grpc {
artifact = 'io.grpc:protoc-gen-grpc-java:1.49.0'
}
}
generateProtoTasks {
all()*.plugins {
grpc {}
}
}
}
Finally, add the generated source code as “sourceSets” so that IDE can find and link the generated Java codes.
After the successful Gradle build, the following message files are generated:
- PaymentRequest.java
- PaymentResponse.java
- PaymentStatus.java
- Person.java
The PaymentServiceGrpc.java Service file is also generated. It contains the static abstract class PaymentServiceImplBase
that includes the stub method sendPayment()
.
We need to extend the PaymentServiceImplBase
class to implement the sendPayment()
method.
Define the server
Define the class PaymentServiceImpl
and fulfill the sendPayment()
method as shown below:
public void sendPayment(PaymentRequest request, StreamObserver<PaymentResponse> responseObserver) {
Instant time = Instant.now();
Timestamp timestamp = Timestamp.newBuilder().setSeconds(time.getEpochSecond())
.setNanos(time.getNano()).build();
PaymentResponse response = PaymentResponse.newBuilder()
.setPaymentId(UUID.randomUUID().toString())
.setPaymentTime(timestamp)
.setPaymentStatus(PaymentStatus.SUCCESS)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
logger.info("Payment request = " + request);
logger.info("Payment response = " + response);
}
The PaymentResponse is generated with the SUCCESS status, Payment ID, and Payment Time.
The gRPC Server is then defined with the following method:
public static void main(String[] args) throws IOException, InterruptedException {
Server server = ServerBuilder
.forPort(8080)
.addService(new PaymentServiceImpl()).build();
server.start();
server.awaitTermination();
}
The gRPC Server starts listening on port 8080 with the already defined Payment service implementation.
Define the client
The gRPC client is defined with the following main()
method:
public static void main(String[] args) {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080)
.usePlaintext()
.build();
PaymentServiceGrpc.PaymentServiceBlockingStub blockingStub
= PaymentServiceGrpc.newBlockingStub(channel);
PaymentRequest paymentRequest = PaymentRequest.newBuilder()
.setSender(Person.newBuilder().setName("Alice").setId(1).setEmail("[email protected]").build())
.setReceiver(Person.newBuilder().setName("Bob").setId(2).setEmail("[email protected]").build())
.setPurpose("Private")
.setAmount(1000.00)
.build();
PaymentResponse paymentResponse = blockingStub.sendPayment(paymentRequest);
channel.shutdown();
}
To abstract away the low-level details of the gRPC connection (like connection, connection pooling, and load balancing), gRPC provides the high-level ManagedChannel.
In this case, we specify the gRPC server address. In addition, the channel is defined as “plaintext,” i.e., without encryption.
The stub PaymentServiceBlockingStub
is used to make the actual remote method call sendPayment()
. The client uses the stub to interact with the server. Here, we are using a BlockingStub, which will wait until the response is received.
There is also the PaymentServiceStub
(an asynchronous stub) and PaymentServiceFutureStub
(a future stub) for asynchronous communication with the server.
A simple PaymentRequest is created to test the gRPC communication between the client and server. Once the remote sendPayment()
method is called synchronously using the stub, a PaymentResponse is obtained.
Here is the generated log message:
2022-09-14 00:54:59 INFO PaymentServiceImpl:31 - Payment request = sender {
id: 1
name: "Alice"
email: "[email protected]"
}
receiver {
id: 2
name: "Bob"
email: "[email protected]"
}
purpose: "Private"
amount: 1000.0
2022-09-14 00:54:59 INFO PaymentServiceImpl:32 - Payment response = paymentId: "23490a4d-75cc-49c0-9012-bc203624f576"
paymentTime {
seconds: 1663109699
nanos: 247148687
}
The log message shows that the gRPC client and server have communicated synchronously using the predefined service.proto file.
Performance comparison between gRPC and REST
One of the main reasons to choose gRPC over REST is gRPC’s superior performance, the key driver for which is the Protobuf binary format used instead of JSON text format.
Let us compare the size of the serialized Protobuf message and the JSON message using the library: protobuf-java-util
.
Below is the code snippet used for the comparison:
final int serializedSize = paymentRequest.getSerializedSize();
System.out.println("serializedSize = " + serializedSize);
final String jsonMessage = JsonFormat.printer().print(paymentRequest);
System.out.println("jsonMessageSize = " + jsonMessage.length());
In our example, the Protobuf binary message size for PaymentRequest is 68 bytes, and the JSON message size is 210 bytes. That means Protobuf message size for PaymentRequest is ca. 32% of the equivalent JSON message.
In the article Evaluating Performance of REST vs. gRPC, the author compared gRPC and REST by sending plaintext JSON requests over HTTP. According to that benchmark, gRPC is roughly seven times faster than REST when receiving data and approx. ten times faster than REST when sending data for this specific payload.
In another article, gRPC vs REST Performance Comparison, the author conducted a similar performance test by sending plaintext requests over HTTP in both the unidirectional and bidirectional way. The unidirectional communication results clearly show that gRPC performs better than REST in terms of CPU utilization, throughput, and response time:
CPU Utilization |
Throughput (Requests/Second) |
50th Percentile Response Time |
90th Percentile Response Time | |
REST |
~85% |
15.26 |
6.451 seconds |
6.823 seconds |
gRPC |
~52% |
37.29 |
2.442 seconds |
3.381 seconds |
Source: gRPC vs REST Performance Comparison
For bidirectional communication, gRPC demonstrates even better performance:
CPU Utilization |
Throughput (Requests/Second) |
50th Percentile Response Time |
90th Percentile Response Time | |
REST |
~85% |
15.26 |
6.451 seconds |
6.823 seconds |
gRPC-Unary |
~52% |
37.29 |
2.442 seconds |
3.381 seconds |
gRPC Bi-Directional Stream |
~42% |
94.98 |
1.042 seconds |
1.148 seconds |
Use cases
Although gRPC can be used instead of REST in all cases, I suggest using it in situations where performance, throughput, latency, and CPU utilization are the key performance indicators. Therefore, I would recommend gRPC for the following:
- Bi-directional streaming
- Highly performant microservice communication
- IoT
- High-performance streaming (e.g., Kafka or any other message bus and message queue).
Moreover, most public cloud services (e.g., API Gateway, message bus, message queue) natively support gRPC.
Alternatives in the JVM world
Suppose all microservices within a project are developed using JVM languages (e.g., Java, Scala, Kotlin, etc.). In that case, a pure JVM library can be used for client-server communication over TCP or UDP, or RMI-based RPC communication. One such library is KryoNet, which uses a popular and efficient binary object graph serialization framework Kryo.
Although Kryo is a good serialization framework and provides an alternative to Protobuf for RPC, it is not always more efficient than Protobuf and should be evaluated on a use-case basis.
Here is the code snippet to compare the serialized message size:
Kryo kryo = new Kryo();
kryo.register(PaymentRequest.class);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Output output = new Output(baos);
kryo.writeObject(output, paymentRequest);
output.close();
final byte[] bytes = baos.toByteArray();
System.out.println("bytes.length = " + bytes.length);
The serialized message size is 98. Although it is much smaller than JSON (46%), it is still comparatively larger than Protobuf (32%).
Conclusion
In this article, we mastered highly performant microservice communication with gRPC. There may be a better technology for typical web client and backend microservice communication. Still, gRPC can be preferred to REST for microservice-to-microservice communication thanks to its efficient features and excellent performance. In addition, gRPC is the go-to protocol if we need bi-directional streaming between microservices.
We demonstrated a straightforward way to establish the gRPC-based microservice communication using Java and Gradle. As you can see, Java supports modern RPC frameworks such as gRPC and is as performant and multifunctional as any other modern programming language.
The source code of the project is available on GitHub.