Building Microservices with Spring Boot and gRPC
A practical guide to gRPC Remote Procedure Call in Java
gRPC Remote Procedure Call is a binary message-based protocol for writing cross-language applications.
As part of this tutorial, we will see how the gRPC framework enables a more efficient communication across distributed systems.
Understanding gRPC
Client-Server Model
Similar to REST APIs, gRPC APIs define one or more services and request/response messages.
On the server-side, the gRPC server implements a service interface to handle client calls. While on the client-side, clients are provided with a gRPC stub that has the same methods as the server. This means you must first create your API (API-First approach).
The gRPC Protocol
The gRPC protocol is built on top of HTTP/2, which provides a foundation for long-lived, real-time communication streams.
The protocol introduces three concepts: channels, remote procedure calls (RPCs), and messages.
Each channel may have a number of RPCs, while each RPC may have a number of messages.
Client-Server Communication
Now we have a better understanding of how the gRPC protocol works, let’s examine four different ways clients and servers can communicate.
- Unary RPC: It is the simplest type of RPC where the client sends a single request and gets back a single response.
- Server Streaming RPC: A server-streaming RPC is similar to a unary RPC, except that the server returns a stream of messages in response to a client’s request.
- Client Streaming RPC: A client-streaming RPC is similar to a unary RPC, except that the client sends a stream of messages to the server and the server responds with a single message.
- Bidirectional Streaming RPC: In a bidirectional streaming RPC, the call is initiated by the client invoking the method. The two streams are independent, so the client and server can read and write messages in any order.
Protocol Buffers
The Protocol Buffer language is a compact, language-neutral, platform-neutral extensible mechanism for serializing structured data. By default, gRPC uses the Protocol Buffers as both its Interface Definition Language (IDL) and as its underlying message interchange format.
The Protocol Buffer file is an ordinary text file with a .proto extension, which allows client-side stubs and service interface generation.
Let’s take the foo.proto
file described below as an example.
syntax = "proto3";
package foo.grpc;
option java_multiple_files = true;
This example uses the proto3 syntax defined by the syntax
property.
The compiler generates Java classes based on the .proto
file. It is convenient to specify a destination package in the package
property.
By default, the compiler generates a single Java file. In order to generate individual Java files, we set the java_multiple_files
property to true
.
Next, let’s define the FooRequest
and FooResponse
messages.
message FooRequest {
string bar = 1;
}
message FooResponse {
string result = 1;
}
Message fields must be defined with type, name and a number (usually auto-incremental).
To conclude, let’s define the service contract for FooService
.
service FooService {
// Unary RPC
rpc foo(FooRequest) returns (FooResponse);
// Server Streaming RPC
rpc fooStreamResponses(FooRequest) returns (stream FooResponse);
// Client Streaming RPC
rpc fooStreamRequests(stream FooRequest) returns (FooResponse);
// Bidirectional Streaming RPC
rpc fooStreamRequestsAndResponses(stream FooRequest) returns (stream FooResponse);
}
Here, the foo()
operation enables unary RPC, fooSreamResponses()
allows server streaming, fooSreamRequests()
allows client streaming, and fooSreamRequestsAndResponses()
enables bidirectional streaming.
Note that by simply adding the stream
keyword to the request/response, we can enable client/server streaming.
Spring Boot with gRPC
Scenario
The following example illustrates a simplified representation of a real-life distributed system.
Here, Loans Service is called by Accounts Service to create, read, update or delete loans.
Project Structure
As shown below, our Maven project is divided into three modules: common, loans and accounts.
├───accounts
│ ├───src
│ │ └───main
│ │ ├───java
│ │ └───resources
│ └───pom.xml
├───common
│ ├───src
│ │ └───main
│ │ └───proto
│ └───pom.xml
├────loans
│ ├───src
│ │ └───main
│ │ ├───java
│ │ └───resources
│ └───pom.xml
└───pom.xml
Creating a Common Module
The common
module holds the .proto
files and the Java code the compiler generates.
The first step is to define the loans.proto
file in the common/src/main/proto
directory.
syntax = "proto3";
import "google/protobuf/empty.proto";
package common.grpc.loans;
option java_multiple_files = true;
service LoansService {
rpc createLoan(Loan) returns (LoanId);
rpc readLoan(LoanId) returns (Loan);
rpc renegotiateLoan(Loan) returns (google.protobuf.Empty);
rpc deleteLoan(LoanId) returns (google.protobuf.Empty);
}
message Borrower {
string name = 1;
int32 age = 2;
double annual_income = 3;
optional double annual_debt = 4;
optional bool delinquent_debt = 5;
}
message Loan {
string guid = 1;
double requested_amount = 2;
int32 term_months = 3;
float annual_interest = 4;
Borrower borrower = 5;
}
message LoanId {
string guid = 1;
}
Next, in order to generate Java code using the protocol buffer compiler, we use the Maven Protocol Buffers plugin.
Let’s add the plugins and dependencies we need to common/pom.xml
.
<properties>
<grpc.version>1.40.1</grpc.version>
<protobuf.version>3.19.6</protobuf.version>
</properties>
<dependencies>
<dependency>
<artifactId>grpc-netty-shaded</artifactId>
<groupId>io.grpc</groupId>
<scope>runtime</scope>
<version>${grpc.version}</version>
</dependency>
<dependency>
<artifactId>grpc-protobuf</artifactId>
<groupId>io.grpc</groupId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<artifactId>grpc-stub</artifactId>
<groupId>io.grpc</groupId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<artifactId>protobuf-java</artifactId>
<groupId>com.google.protobuf</groupId>
<version>${protobuf.version}</version>
</dependency>
<dependency>
<artifactId>javax.annotation-api</artifactId>
<groupId>javax.annotation</groupId>
<version>1.2</version>
</dependency>
</dependencies>
<build>
<extensions>
<extension>
<artifactId>os-maven-plugin</artifactId>
<groupId>kr.motd.maven</groupId>
<version>1.6.2</version>
</extension>
</extensions>
<plugins>
<plugin>
<artifactId>protobuf-maven-plugin</artifactId>
<groupId>org.xolstice.maven.plugins</groupId>
<version>0.6.1</version>
<configuration>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}
</pluginArtifact>
<pluginId>grpc-java</pluginId>
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}
</protocArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
As a result, the plugin generates Java code in the target/classes
directory each time Maven compiles the source code.
Creating the gRPC Server
The loans
module has a simple implementation for the gRPC Server.
To use the Java code generated in the common
module, we add a dependency to loans/pom.xml
.
<dependency>
<artifactId>common</artifactId>
<groupId>com.manerajona</groupId>
<version>${project.version}</version>
</dependency>
Additionally, we want to add a dependency for the autoconfiguration of the embedded gRPC server. LogNet’s gRPC Spring Boot Starter provides us with this functionality.
<dependency>
<groupId>io.github.lognet</groupId>
<artifactId>grpc-spring-boot-starter</artifactId>
<version>5.1.4</version>
</dependency>
As a next step, we extend and override the default implementation of LoansServiceImplBase
generated by the protocol buffer compiler.
@GRpcService
public class LoansServiceImpl extends LoansServiceImplBase {
private final ConcurrentMap<LoanId, Loan> loans = new ConcurrentHashMap<>();
@Override
public void createLoan(Loan loan, StreamObserver<LoanId> responseObserver) {
LoanId loanId = LoanId.newBuilder().setGuid(loan.getGuid()).build();
loans.put(loanId, loan);
responseObserver.onNext(loanId);
responseObserver.onCompleted();
}
@Override
public void readLoan(LoanId loanId, StreamObserver<Loan> responseObserver) {
Loan loan = loans.get(loanId);
responseObserver.onNext(loan);
responseObserver.onCompleted();
}
@Override
public void renegotiateLoan(Loan loan, StreamObserver<Empty> responseObserver) {
LoanId loanId = LoanId.newBuilder().setGuid(loan.getGuid()).build();
loans.computeIfPresent(loanId, (k, v) -> loan);
responseObserver.onNext(Empty.getDefaultInstance());
responseObserver.onCompleted();
}
@Override
public void deleteLoan(LoanId loanId, StreamObserver<Empty> responseObserver) {
loans.remove(loanId);
responseObserver.onNext(Empty.getDefaultInstance());
responseObserver.onCompleted();
}
}
Here, Loan
objects are stored in ConcurrentMap<LoanId, Loan>
.
A response is added after each operation using StreamObserver’s onNext()
method, and the call is resumed after onCompleted()
.
In order to automatically configure the gRPC server, we annotate LoansServiceImpl
with @GRpcService
. By default the gRPC server will run on localhost:6565
.
Creating the Client
The accounts
module has a simple implementation the client.
First, we add the common
and spring-boot-starter-web
dependencies to accounts/pom.xml
.
<dependency>
<artifactId>common</artifactId>
<groupId>com.manerajona</groupId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Next, let’s create a configuration class for the gRPC Service Stub.
@Configuration
class GrpcClientConfiguration {
@Bean
LoansServiceBlockingStub loansServiceStub() {
Channel channel = ManagedChannelBuilder.forAddress("localhost", 6565)
.usePlaintext()
.build();
return LoansServiceGrpc.newBlockingStub(channel);
}
}
Here, we have created a bean of type LoansServiceBlockingStub
. This stub is blocking, meaning that for each request it blocks the RPC call until it receives a response. You can also create a non-blocking stub to make asynchronous calls.
Note that the LoansService.newBlockingStub()
method accepts Channel
as argument. By using ManagedChannelBuilder
, we create the channel for the address and port where the server is running, and we specify usePlainText()
to exchange messages without any encryption (not recommended.)
As a final step, we test the stub using Spring ApplicationRunner.
@Component
public class AccountsApplicationRunner implements ApplicationRunner {
private final LoansServiceBlockingStub stub;
public AccountsApplicationRunner(LoansServiceBlockingStub stub) {
this.stub = stub;
}
@Override
public void run(ApplicationArguments args) {
// create
var borrowerBuilder = Borrower.newBuilder()
.setName("John Doe")
.setAge(34)
.setAnnualIncome(100_000);
var loanBuilder = Loan.newBuilder()
.setGuid(UUID.randomUUID().toString())
.setBorrower(borrowerBuilder.build())
.setRequestedAmount(1_000_000_000)
.setTermMonths(12)
.setAnnualInterest(6.5f);
LoanId id = stub.createLoan(loanBuilder.build());
System.out.println("** Loan created with " + id);
// read
System.out.println("** Loan read:\n" + stub.readLoan(id));
// update
Loan loanUpdated = loanBuilder
.setTermMonths(24)
.setAnnualInterest(7.5f)
.build();
stub.renegotiateLoan(loanUpdated);
System.out.println("** Loan updated:\n" + stub.readLoan(id));
// delete
stub.deleteLoan(id);
System.out.println("** Is Loan deleted: " +
stub.readLoan(id).getGuid().isBlank());
}
}
This image shows each operation called and printed on the console:
Conclusion
In this article, we learned how to use gRPC in Java and Spring Boot. Let’s now look at some of the pros and cons of adopting this technology:
Pros:
- It enables API-First design.
- It enables streaming.
- It is an efficient, compact and secure way to exchange messages between clients and servers.
- It is agnostic to the programming language used in the application.
Cons:
- There may be some challenges using it on the frontend.
- Older infrastructure might not be ready for moving from HTTP/1 to HTTP/2.
Thanks for reading. I hope this was helpful!
The example code is available on GitHub.