Hey, today as Innokrea, we'll tell you a bit about RPC, serialization, interprocess communication, and how it relates to distributed systems. If you haven't read our article on distributed systems, we encourage you to do so.

 

Processes, Threads, and Communication

Some time ago in an article about writing our own server, we explained how to use threads to accept successive connections from clients. We learned then that threads, being one of the basic execution units on a computer, share memory within the process in which they operate. However, some interpreters (like CPython) do not allow processing parallel data due to their internal implementation (GIL). In that case, to multiply computational power, instead of a multithreaded approach, a multiprocessing approach can be used. However, another problem arises here, as processes do not share memory - mechanisms allowing information exchange between them must be used. The operating system can manage access to resources for processes, but how one process communicates with another is up to the programmer. We distinguish different communication modes - including shared memory, message passing, pipes, named pipes, queues, and RPC.

 

Types of interprocess communication

Figure 1 - Types of interprocess communication, source [1]

 

RPC

In one of our recent articles, while implementing a server, we used sockets as one of the ways for interprocess communication over the network. However, this is not the only way to pass data between two remote processes. The RPC mechanism (Remote Procedure Call) is a way to remove the responsibility of implementing remote calls from the programmer. The programmer can call methods within their program almost as if they were in the same class. Libraries supporting RPC process the called method and ensure that it is executed on the other side exactly once.

 

Remote procedure call in client-server architecture

Figure 2 - Remote procedure call in client-server architecture, source [2]

 

The component responsible for hiding all the logic of calling a procedure over the network in RPC is called a 'stub'. The internal implementation of RPC ensures that regardless of the programming language used by the second process and the encoding it uses (big-endian vs little-endian), we can call the appropriate procedure. RPC invisibly conducts object marshalling, which is a process very similar to serialization and allows the call to be processed into a form that can be passed over the network. After calling the procedure on the target process, the same process is performed in the other direction (unmarshalling) to return the result of the called method.

One of the most popular frameworks used for RPC is gRPC, created by Google. It is supported by several languages, supports data streaming, and uses protobuf for data serialization.

 

Protobuf

What is protobuf? It's a language-independent mechanism for data serialization and interface definition. The so-called IDL (Interface Definition Language) allows us to define an interface needed for procedure calls regardless of the programming language, and then compile such an interface accordingly. This mechanism is referred to as language-agnostic or language-neutral. Additionally, serialization is superior to formats like JSON/XML, resulting in smaller messages transmitted between processes.

 

Example - protobuf + Java + gRPC

Let's try to replicate the example we showed in the previous article about sockets, where we sent numbers to another process for summation. We'll prepare a project in IntelliJ using Java and Gradle.

First, create a new project in IntelliJ and create a build.gradle file, where we'll define our dependencies. It should look like this:

plugins {
   id "com.google.protobuf" version "0.9.4"
   id "java"
}

group 'org.example'
version '1.0-SNAPSHOT'

repositories {
   mavenCentral()
}

dependencies {
   testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
   testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'

   implementation group: 'com.google.protobuf', name: 'protobuf-java', version: '0.9.4'
   implementation group: 'io.grpc', name: 'grpc-all', version: '1.63.0'

   implementation 'javax.annotation:javax.annotation-api:1.3.2'
}

sourceSets {
   main {
       java {
           srcDirs 'build/generated/source/proto/main/grpc'
           srcDirs 'build/generated/source/proto/main/java'
       }
   }
}

protobuf {
   protoc {
       artifact = 'com.google.protobuf:protoc:3.13.0'
   }

   plugins {
       grpc {
           artifact = 'io.grpc:protoc-gen-grpc-java:1.39.0'
       }
   }

   generateProtoTasks {
       all()*.plugins {
           grpc {}
       }
   }
}

test {
   useJUnitPlatform()
}

 

After saving the file, a 'reload' button should appear, which you should click to download the dependencies. After downloading the dependencies, create a 'main' folder in the 'src' directory, and within it, create a 'proto' folder where we will define our interfaces and model classes.

 

IntelliJ Project View

Figure 3 - Project View

 

An example proto class may look like this:

syntax = "proto3";
package com.example.grpc;

message SumRequest{
 repeated int32 numbers = 1;
}

message SumResponse{
 int32 sum = 1;
}

service SumService{
 rpc sum(SumRequest) returns (SumResponse);
}

 

Now, using the menu on the right side (Gradle), you should compile our proto file to adapt the implementation to the chosen language.

 

Gradle menu

Figure 4 - Gradle menu

 

After double-clicking 'generateProto', our class should compile, and the compilation results should be visible in build\generated\source\proto\main\grpc\com\example\grpc. One of the results of the compilation should be the SumServiceImplBase class, which we will use to write our summing service.

 

Generated implementation of the base class from the defined proto file

Figure 5 - Generated implementation of the base class from the defined proto file

 

Now, in the main/java folder, we will create a file named SumServiceImpl and inherit the generated SumServiceImplBase class.

import com.example.grpc.SumServiceGrpc.SumServiceImplBase;
import com.example.grpc.SumServiceOuterClass.SumResponse;
import io.grpc.stub.StreamObserver;
import com.example.grpc.SumServiceOuterClass.SumRequest;
import java.util.List;

public class SumServiceImpl extends SumServiceImplBase {
   @Override
   public void sum(SumRequest request, StreamObserver responseObserver) {
       List numbers = request.getNumbersList();
       Integer sum = numbers.stream().reduce(0, Integer::sum);
       responseObserver.onNext(SumResponse.newBuilder().setSum(sum).build());
       responseObserver.onCompleted();
   }
}

 

We override the method defined in the proto file using the @override directive, then we retrieve the transmitted numbers, add them, and return them using the onNext() method, and we end the communication by calling onCompleted().

Next, we will create two files - a server file and a client file that sends numbers to the server. Both use the service we have written.

Server class:

import io.grpc.ServerBuilder;
import java.io.IOException;

public class Server
{
   public static void main( String[] args )
   {
       try {
           io.grpc.Server server = ServerBuilder.forPort(8080).addService(new SumServiceImpl()).build();
           server.start();
           System.out.println("Server started!");
           server.awaitTermination();
       } catch (IOException e) {
           e.printStackTrace();
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
   }
}

 

Client class:

<codeimport com.example.grpc.SumServiceGrpc;
import com.example.grpc.SumServiceOuterClass;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import java.util.Arrays;
import java.util.List;

public class Client {

   public static void main(String[] args) throws InterruptedException {
       sumNumbers();
   }
   public static void sumNumbers(){
       List numbers = Arrays.asList(0, 1, 1, 2,4,5,6,2);
       ManagedChannel channel = ManagedChannelBuilder.forTarget("localhost:8080").usePlaintext().build();
       SumServiceGrpc.SumServiceBlockingStub stub = SumServiceGrpc.newBlockingStub(channel);
       SumServiceOuterClass.SumRequest request = SumServiceOuterClass.SumRequest.newBuilder().addAllNumbers(numbers).build();

       SumServiceOuterClass.SumResponse response = stub.sum(request);
       System.out.println(response.getSum());

       channel.shutdown();
   }
}

 

Result obtained on the client side after running the server and client

Figure 6 - Result obtained on the client side after running the server and client

 

Where do we use gRPC?

We managed to construct a very basic project that demonstrates gRPC functionality in Java. However, how is this solution used in commercial software development? Some time ago, in our article about distributed systems, we presented various architectures, including microservices. Some examples showed event-driven architecture using a broker. However, this is not the only way to implement microservices. An architecture oriented towards communication using gRPC is faster and lighter in terms of message size (due to protobuf serialization) compared to JSON, for example. If you're developing large-scale applications like Netflix, it's worth considering using gRPC. Such a solution can be particularly useful in an application that requires collaboration among multiple users simultaneously, such as a shared whiteboard or document editing, as in the case of Google Docs.

 

Example microservices architecture built using gRPC

Figure 7 - Example microservices architecture built using gRPC, [3]

 

Summary

Today, we discussed interprocess communication, what RPC is, and how to configure a simple Java project using gRPC and protobuf. If you're interested in learning more about distributed architectures or sockets, we recommend checking out our previous articles. Until next time!

 

Sources:

[1] https://www.guru99.com/inter-process-communication-ipc.html

[2] https://www.linkedin.com/pulse/remote-procedure-calls-rpc-umang-agarwal/

[3] https://techdozo.dev/grpc-for-microservices-communication/