06 Rpc Practice Dissecting G Rpc Source Code to Implement a Complete Rpc by Hand

06 RPC Practice - Dissecting gRPC source code to implement a complete RPC by hand #

Hello, I am He Xiaofeng. In the previous lecture, I talked about dynamic proxies, whose function can be summarized in one sentence: “We can use dynamic proxy technology to mask the details of RPC calls, allowing users to program against interfaces.”

Up to this point, we have covered all the basic knowledge needed for RPC communication, but most of these contents are theoretical. In this lecture, let’s put what we have learned into practice and see how to implement an RPC framework in actual code.

To reach a consensus quickly, I have chosen to analyze the source code of gRPC (source code repository: https://github.com/grpc/grpc-java). By analyzing the communication process of gRPC, we can clearly understand how these knowledge points are implemented in the actual code in gRPC.

gRPC is a high-performance, cross-language RPC framework developed and open-sourced by Google. It currently supports languages like C, Java, and Go. The latest release version for the Java version is 1.27.0. gRPC has many features, such as cross-language support, communication protocol based on standard HTTP/2, and support for PB (Protocol Buffer) and JSON for serialization. The entire invocation process is shown in the following diagram:

If you want to quickly understand how a new framework works, I personally think the fastest way is to start with usage examples. So now let’s start with the simplest HelloWorld example to understand.

In this example, we will define a say method. The caller will use gRPC to call the service provider, and the service provider will return a string to the caller.

To ensure that the caller and the service provider can communicate correctly, we need to first agree on a contract for the communication process, which is what we call defining an interface in Java. This interface will only contain one say method. In gRPC, interface definition is done by writing Protocol Buffer code, which expresses the interface definition information using the semantics of Protocol Buffer. The Protocol Buffer code for HelloWorld is shown below:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.hello";
option java_outer_classname = "HelloProto";
option objc_class_prefix = "HLW";

package hello;

service HelloService {
  rpc Say(HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

With this code, we can generate message objects and the basic code needed for gRPC communication for both the client and server. We can use the Protocol Buffer compiler protoc and the gRPC Java plugin (protoc-gen-grpc-java) together. By using the protoc3 command line, along with the plugin and the proto directory address parameters, we can generate message objects and the basic code for gRPC communication. If your project is a Maven project, you can also choose to use the Maven plugin to generate the same code.

Sending Principle #

After generating the basic code, we can write the client-side code based on the generated code, as follows:

package io.grpc.hello;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;


import java.util.concurrent.TimeUnit;

public class HelloWorldClient {

    private final ManagedChannel channel;
    private final HelloServiceGrpc.HelloServiceBlockingStub blockingStub;
    /**
    * Building the Channel connection
    **/
    public HelloWorldClient(String host, int port) {
        this(ManagedChannelBuilder.forAddress(host, port)
                .usePlaintext()
                .build());
    }

    /**
    * Building the Stub for making requests
    **/
    HelloWorldClient(ManagedChannel channel) {
        this.channel = channel;
        blockingStub = HelloServiceGrpc.newBlockingStub(channel);
    }
    
    /**
    * Manually shutting down after calling
    **/
    public void shutdown() throws InterruptedException {
        channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
    }

 
    /**
    * Sending the RPC request
    **/
    public void say(String name) {
        // Building the request object
        HelloRequest request = HelloRequest.newBuilder().setName(name).build();
        HelloReply response;
        try {
            // Sending the request
            response = blockingStub.say(request);
        } catch (StatusRuntimeException e) {
            return;
        }
        System.out.println(response);
    }

    public static void main(String[] args) throws Exception {
            HelloWorldClient client = new HelloWorldClient("127.0.0.1", 50051);
            try {
                client.say("world");
            } finally {
                client.shutdown();
            }
    }
}

The client-side code can be roughly divided into three steps:

  • First, use the host and port to generate the channel connection;
  • Then, use the previously generated HelloService gRPC to create the Stub class;
  • Finally, we can use this Stub to call the say method to initiate the actual RPC call, and the rest of the RPC communication details are transparent to us users.

In order to see what exactly happens inside, we need to go into the ClientCalls.blockingUnaryCall method to see the logical details. However, to avoid too many details affecting your understanding of the overall process, I have only drawn the most important part in the following figure.

We can see that in the client-side code, we only need one line of code (line 48) to initiate an RPC call. So, how is this request sent to the service provider? This is completely transparent to us gRPC users. We only need to focus on how to create the stub object.

For example, how does gRPC transmit a character object, which is the input parameter, to the service provider? Because in [[Lesson 03]], we mentioned that only binary data can be transmitted over the network, but the current input parameter in the client-side code is a character object. So how does gRPC convert the object into binary data?

Let’s go back to step 3 in the flowchart above. Before writePayload, there is a line of code in ClientCallImpl, which is method.streamRequest(message). Just by looking at the method signature, we can roughly know that it is used to convert the object into an InputStream. With the InputStream, we can easily obtain the binary data of the input parameter object. The return value of this method is interesting. Why doesn’t it return the binary array we want directly, but returns an InputStream object? You can take a moment to think about why, and we will continue to discuss this issue later.

Let’s take a look at what kind of object the owner of the streamRequest method, method, is. As we can see, method is an instance associated with the MethodDescriptor object. MethodDescriptor is used to store metadata for calling RPC services, including the interface name, method name, service call method, and the implementation classes for request and response serialization and deserialization.

In plain language, MethodDescriptor is used to store some metadata during the RPC call process. When binding a request in MethodDescriptor, the requestMarshaller is used to serialize the object. Therefore, when we call method.streamRequest(message), we actually call the requestMarshaller.stream(requestMessage) method. Inside the requestMarshaller, a Parser is bound, which is the one that actually converts the object into an InputStream object.

After discussing the application of serialization in gRPC, let’s take a look at how gRPC completes the “sentence segmentation” of the request data, that is, the problem we mentioned in [[Lesson 02]] - after a binary stream is transmitted over the network, how to correctly restore the semantics of the original request?

We can see in the gRPC documentation that the gRPC communication protocol is based on the standard HTTP/2 design. Compared to the commonly used HTTP/1.X, the biggest feature of HTTP/2 is its support for multiplexing and bidirectional streams. How should we understand this feature? It’s like the difference between a one-way road and a two-way road in our daily life. HTTP/1.X is a one-way road, and HTTP/2 is a two-way road.

Since sentence segmentation of the request needs to be done after the request is received, it must be necessary to add sentence segmentation symbols when sending. Let’s take a look at how this is done in gRPC.

Because gRPC is based on the HTTP/2 protocol, and the basic unit of transmission in HTTP/2 is the Frame, the Frame format consists of a fixed 9-byte header followed by an indefinite-length payload. The protocol format is shown in the following figure:

Therefore, in gRPC, it becomes how to construct an HTTP/2 Frame.

Now, let’s go back to step 4 in the flowchart above. Before writing to Netty, we can see that in the MessageFramer.writePayload method, the writeKnownLengthUncompressed method is indirectly called. The two things that this method needs to do are to construct the Frame Header and Frame Body, and then send the constructed Frame to NettyClientHandler, and finally write the Frame into the HTTP/2 Stream to complete the sending of the request message.

Reception Principle #

After discussing the request sending principle of gRPC, let’s take a look at how the service provider handles the received requests. Let’s continue with the previous example and first examine the code of the service provider, which is as follows:

static class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {
  @Override
  public void say(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
    HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
    responseObserver.onNext(reply);
    responseObserver.onCompleted();
  }
}

The HelloServiceImpl class above implements the HelloService interface logic in the way gRPC is used. However, it cannot be called by the caller because we have not exposed this interface to the outside. In gRPC, we use the Builder pattern to bind the underlying service. The specific code is as follows:

package io.grpc.hello;

import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;

import java.io.IOException;

public class HelloWorldServer {

  private Server server;

  /**
  * Expose the service to the outside world
  **/
  private void start() throws IOException {
    int port = 50051;
    server = ServerBuilder.forPort(port)
        .addService(new HelloServiceImpl())
        .build()
        .start();
    Runtime.getRuntime().addShutdownHook(new Thread() {
      @Override
      public void run() {
        HelloWorldServer.this.stop();
      }
    });
  }

  /**
  * Shutdown the port
  **/
  private void stop() {
    if (server != null) {
      server.shutdown();
    }
  }

  /**
  * Graceful shutdown
  **/
  private void blockUntilShutdown() throws InterruptedException {
    if (server != null) {
      server.awaitTermination();
    }
  }

  public static void main(String[] args) throws IOException, InterruptedException {
    final HelloWorldServer server = new HelloWorldServer();
    server.start();
    server.blockUntilShutdown();
  }
}

The purpose of exposing the service to the outside world is to allow incoming requests to find the implementation of the corresponding interface after being restored to information. Before that, we need to ensure that the requests can be received normally, which means we need to first open a TCP port to allow the caller to establish a connection and send binary data to this connection channel. Only the most important part is shown here.

The four steps are used to start a Netty Server and bind the encoding and decoding logic. If you don’t understand it for the time being, it’s okay. We can ignore the details for now. Let’s focus on NettyServerHandler. In this handler, a FrameListener will be bound, and gRPC will handle the received data request’s headers and bodies in this listener. It will also handle commands such as Ping and RST. The specific process is shown in the following diagram:

After receiving the header or body binary data, the FrameListener bound on NettyServerHandler will pass these binary data to MessageDeframer, which implements the parsing of gRPC protocol messages.

You may ask, how are these header and body data separated? As we mentioned before, the caller sends a stream of binary data, and when we bind the Default HTTP/2FrameReader when starting the Netty Server, it automatically splits the data into headers and bodies according to the format of the HTTP/2 protocol. For our upper-level application gRPC, it can directly use the split data.

Summary #

This is the last lecture in our basic series. We have used the gRPC source code analysis to learn how to implement a complete RPC. Of course, the entire gRPC codebase is much larger, but today the main goal is to apply the knowledge we have learned about serialization, protocols, and other aspects to actual code. Therefore, we only analyzed the request and response processes in gRPC.

By implementing these two processes, we can achieve point-to-point RPC functionality. However, in actual usage, our service providers often provide services to the outside world in a cluster manner. Therefore, in gRPC, you can also see features such as load balancing and service discovery. Additionally, gRPC uses the HTTP/2 protocol, and we can also use the Stream feature to improve call performance.

In summary, we can simply think of gRPC as an RPC that uses the HTTP/2 protocol and the default PB serialization. It fully utilizes the multiplexing feature of HTTP/2, allowing us to send different Stream data bidirectionally on the same connection, solving the performance problems of HTTP/1.X.

After-Class Reflection #

In our discussion, we mentioned a key step in gRPC calls: converting objects into transferrable binary format. However, in gRPC, we don’t directly convert them into byte arrays. Instead, we return an InputStream. Do you know the benefits of doing it this way?

Feel free to leave a comment to share your answer with me. Also, feel free to share this article with your friends and invite them to join the learning. See you in the next class!