07 Architectural Design Designing a Flexible Rpc Framework

07 Architectural Design - Designing a flexible RPC framework #

Hello, I am He Xiaofeng. Up to now, we have completed the learning of the fundamental knowledge, and now we are entering the advanced section.

In the fundamental section, we discussed the communication principles of RPC and the roles of various functional components in RPC. Let’s review it again in a paragraph: “In fact, RPC is the process of converting intercepted method parameters into binary data that can be transmitted over the network, and ensuring that the semantics can be correctly restored by the service provider, ultimately achieving the goal of remotely calling as if it were a local call.” Do you remember it?

However, there is still some distance to go before implementing a flexible RPC framework. Knowing the various functional components is just the first step. Next, you need to understand how the components interact with each other to achieve data exchange. This is the focus of today’s lecture. Let’s clarify the architecture design of RPC together.

RPC Architecture #

When it comes to architecture design, I believe you are no stranger to it. In my understanding, architectural design starts from a top-level perspective to clarify the data flow between modules and components, giving us an overall macro understanding of the system. Let’s take a look at the functional modules in RPC.

As we have discussed, RPC is essentially a remote procedure call, which requires data transmission through the network. Although there are multiple choices for the transmission protocol, we generally default to TCP protocol for reliability. To shield the complexity of network transmission, we need to encapsulate a separate data transmission module to send and receive binary data. This separate module can be called the transport module.

When a user makes a request, it is based on method invocation, and the method’s input and output parameters are object data. Objects cannot be directly transmitted over the network, so we need to convert them into transportable binary form in advance. This process is called serialization. However, it is not enough to just transmit the binary data of the method invocation parameters to the service provider. We need to add a “delimiter” symbol to separate different requests after the binary data of the method invocation parameters. The content between two “delimiter” symbols is our request’s binary data. This process is called protocol encapsulation.

Although these are two different processes, their purpose is the same, which is to ensure that data can be correctly transmitted over the network. When I say “correct,” it not only means that the data can be transmitted, but it also needs to be correctly restored to the semantics before transmission. Therefore, we can put these two processing steps in the same module of the architecture, referred to as the protocol module.

In addition, we can also add compression functionality to the protocol module because the compression process also operates on the binary data being transmitted. During the actual network transmission process, our request data packet may be split into multiple data packets for transmission due to its size at the data link layer. To reduce the number of splits and the resulting long transmission time, we can compress the binary data of method invocation parameters or return values using lossless compression when they exceed a certain threshold. Then, on the other end, we can use the same compression algorithm to decompress and restore the data.

Transport and protocol are the most fundamental modules in RPC, as they allow objects to be correctly transferred to the service provider. But to achieve the goal of RPC, which is to make remote calls feel like local calls, there is still something missing. Because the capabilities provided by these two modules are just some basic abilities, we need to manually write some glue code to make these two modules work together. However, this code has no meaning for developers using RPC, and it is repetitive work that can make the usage experience very unfriendly.

Therefore, in RPC, we need to shield these details from developers and make them feel no difference between local and remote calls. Assuming we are using Spring, we hope that RPC can allow us to define an RPC interface as a Spring Bean, and this Bean will also be managed by the Spring Bean Factory uniformly and can be referenced in projects through Spring dependency injection. This is the entry point for RPC calls, which is generally called the Bootstrap module.

Up to this point, we have completed a point-to-point version of the RPC framework. I usually refer to this pattern of RPC framework as a standalone version because it lacks clustering capability. Clustering capability refers to having multiple service providers for the same interface, but these service providers are transparent to the caller. Therefore, in RPC, we also need to find all the service providers for the caller and maintain the relationship between the interface and the service provider addresses within the RPC framework. This is what we commonly refer to as “service discovery”.

However, service discovery only solves the lookup problem of the interface and service provider address mapping, and it belongs to more of a “static data”. It is called static data because, for our RPC, we need to use TCP connections each time we send a request, and the TCP connection status is constantly changing compared to the service provider’s IP address. Therefore, our RPC framework needs to have a connection manager to maintain the TCP connection status.

With clustering, the service provider may need to manage these services. Therefore, our RPC framework needs to include some service governance features, such as setting service provider weights, authorization for invocation, and other common governance methods. What additional responsibilities does the service caller have? Before each call, we need to choose an available connection from the cluster based on the rules set by the service provider for sending requests.

At this point, a fairly complete RPC framework has been built, with most of its functionalities. Following the principle of layered design, I have divided these functional modules into four layers, as shown in the diagram:

Scalable Architecture #

Is the RPC architecture design done once and for all? Of course not, no one can escape technological iteration.

Have you ever experienced this? You design a system that looks perfect and functions well, and then you successfully deliver it to the business team. One day, the business team has new requirements and wants to add many new features. At this point, you will realize that the current architecture faces a big challenge, and many changes need to be made to achieve the new requirements.

For example, let’s say you design a product publishing system. In earlier years, we could only buy physical products such as computers and clothes online. But now this has evolved into buying virtual products like phone recharge cards and game cards online. The process of publishing physical products requires choosing the purchasing region, but virtual products do not have this restriction. If you want to handle the publishing of both physical and virtual products in one system, you would have to add a lot of if-else logic in the code. This approach may work, but the code will become bloated and messy, making it difficult to maintain in the long run.

In fact, when designing an RPC framework, we face the same challenge. We cannot cover everything from the beginning. Is there a better way to solve these problems? That’s what we’re going to talk about next: plug-in architecture.

How do we support plug-in architecture in an RPC framework? We can abstract each feature point as an interface, which serves as the contract for plugins. Then we separate the interface of a feature from its implementation and provide a default implementation for the interface. In Java, JDK has a built-in SPI (Service Provider Interface) service discovery mechanism, which can dynamically find the service implementation for an interface. To use the SPI mechanism, you need to create a file named after the service interface in the META-INF/services directory under the Classpath. The content of this file is the specific implementation class for the interface.

However, in actual projects, we rarely use the JDK’s built-in SPI mechanism. First of all, it cannot load on demand. When ServiceLoader loads the implementation class for a certain interface, it traverses all possibilities, which means all implementation classes need to be loaded and instantiated once, resulting in unnecessary waste. Another issue is that if an extension depends on other extensions, it cannot achieve automatic injection and assembly. This makes it difficult to integrate with other frameworks. For example, if an extension depends on a Spring Bean, the native Java SPI does not support it.

With the addition of plug-in functionality, our RPC framework consists of two major systems: the core feature system and the plug-in system, as shown in the following diagram:

At this point, the entire architecture becomes a microkernel architecture. We abstract each feature point as an interface, which serves as the contract for plugins. Then we separate the interface of a feature from its implementation and provide a default implementation for the interface. Compared to the previous architecture, this architecture has many advantages. First, it has good scalability and follows the open-closed principle. Users can easily extend their own functionality through plugins without modifying the core functionality itself. Second, it maintains a lean core package with minimal external dependencies, effectively reducing package version conflicts caused by introducing RPC.

Summary #

We all know that the process of software development is complex, not only because business requirements often change, but also because it is difficult to ensure the unity of team members’ goals during the development process. We need to use a communicable language and a “touchable” vision to achieve our goals. I believe this is the significance of software architecture design.

However, designing a software architecture solely from a functional perspective is not robust enough. The system not only needs to run correctly, but also needs to be maintained at the lowest cost for sustainability. Therefore, it is essential for us to pay attention to the scalability of the system. Only in this way can we meet the changing demands of the business and continuously extend the vitality of the system.

Reflection #

Can you share any examples of how you have used the plugin approach to solve extensibility problems in your daily work?

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