preface

In the last article we started by outlining the requirements and defining the messaging protocol. This time we set out to build the basic RPC framework, first to implement the basic method call function.

Functional design

The first step in RPC calls is to define the exposed methods on the server side. In GRPC or Thrift, we need to write language-independent IDL files and then generate the corresponding language code from the IDL files. In our framework, for simplicity, we define interfaces and methods directly in code, rather than in IDL. Here, the external method must comply with the following conditions:

  1. Methods that are exposed must be Exported by type and by themselves. That is, the first letter must be capitalized
  2. The method must take three arguments, and the first must be of type context.context
  3. The third method parameter must be of pointer type
  4. The method return value must be of type error
  5. The client refers to the service Method as “type. Method”, where Type is the full class name of the Method implementation class and Method is the Method name

The reason for these rules is as follows: Because the dynamic proxy used by RPC framework fields in Java is not supported in the GO language, we need to explicitly define a unified format for methods so that different methods can be handled uniformly in the RPC framework. So we specify the format of the method:

  • The first argument to the method is fixed to Context, which is used to pass Context information
  • The second argument is the actual method argument
  • The third parameter represents the return value of the method, which will be changed to the node executed by the server upon completion of the call
  • Method returns a fixed value of type error, indicating an error that occurred during a method call.

Here we need to note that the service provider does not need to be exposed in the form of an interface, as long as the service provider has a method that complies with the rules. When a client calls a method, it specifies the type of service provider and cannot specify the name of the interface, even if the service provider implements the interface.

contet.Context

Context is an abstraction of the request context provided by the GO language. It carries information about the request deadline, cancel signal, and can also pass some context information. It is very suitable for RPC request context. You can also pass some parameter independent metadata to the server through the context.

In fact, the fixed format of methods and the use of Call and Go to represent synchronous and asynchronous calls are all part of Go’s RPC rules, except that context.context is added as an argument. I have to say that go’s RPC design is indeed very good, worth learning to understand.

The interface definition

The client and server

The first is the client and server interfaces in the consumer-oriented RPC framework:

type RPCServer interface {
        // Register a service instance. RCVR stands for receiver, which is the implementer of our exposed methods. MetaData is the additional metaData carried when registering a service, which describes other RCVR information
        Register(rcvr interface{}, metaData map[string]string) error
        // Start to provide external services
        Serve(network string, addr string) error
}
type RPCClient interface {      
        //Go stands for asynchronous invocation
        Go(ctx context.Context, serviceMethod string, arg interface{}, reply interface{}, done chan *Call) *Call
        //Call indicates an asynchronous Call
        Call(ctx context.Context, serviceMethod string, arg interface{}, reply interface{}) error
        Close() error
}
type Call struct {
	ServiceMethod string      // Service name. Method name
	Args          interface{} / / parameters
	Reply         interface{} // Return value (pointer type)
	Error         error       // Error message
	Done          chan *Call  // Activate at the end of the call
}
Copy the code

The selector and registery

This time, the RPC call part will be implemented first, and these two layers will be ignored for the time being and implemented later.

codec

Next we need to choose a serialization protocol, in this case the messagepack we used earlier. The communication protocol previously designed is divided into two parts: head and body, both of which need to be serialized and deserialized. The head part is metadata, which can be serialized directly by Messagepack, while the body part is the parameter or response of the method, and its serialization is determined by SerializeType in the head. This advantage is for the convenience of subsequent extensions. Currently, Messagepack serialization is also used. Subsequent serialization can also be done in other ways.

Serialization logic is also defined as an interface:

type Codec interface {
   Encode(value interface{}) ([]byte, error)
   Decode(data []byte, value interface{}) error
}
Copy the code

protocol

With the serialization protocol in place, we can define the interface associated with the message protocol. Protocol Design Reference previous article: Implementing an RPC framework from Scratch (Zero)

Here is the protocol interface definition:

//Messagge represents a message body
type Message struct {
	*Header // The Header is defined in the previous article
	Data []byte / / body parts
}

//Protocol defines how to construct and serialize a complete message body
type Protocol interface {
	NewMessage() *Message
	DecodeMessage(r io.Reader) (*Message, error)
	EncodeMessage(message *Message) []byte
}
Copy the code

Based on the previous design, all interactions are done through interfaces, which are easy to expand and replace.

transport

After the protocol interface is defined, the next is the definition of the network transport layer:

// The transport layer is defined for reading data
type Transport interface {
	Dial(network, addr string) error
	// The ReadWriteCloser interface is directly embedded, including Read, Write and Close methods
	io.ReadWriteCloser 
	RemoteAddr() net.Addr
	LocalAddr() net.Addr
}
// Server listener definition, used to listen on ports and establish connections
type Listener interface {
	Listen(network, addr string) error
	Accept() (Transport, error)
	// The Closer interface is directly embedded, including the Close method
	io.Closer
}
Copy the code

The specific implementation

After the interface of each level is defined, you can start to build the basic framework. The specific code is not attached here, but you can refer to github link for the specific code. Here is a general description of the implementation ideas of each part.

Client

Code implementation

The function of the client side is relatively simple, which is to serialize parameters, assemble them into a complete message body and send them. Requests are cached as they are sent out, and each response is matched with an incomplete request.

The core of sending a request lies in the Go and Send methods, which assemble parameters. The SEND method serializes parameters and sends them through the transport layer interface, while caching the request into pendingCalls. The Call method, on the other hand, simply calls the Go method and blocks until it returns or times out. The core of receiving the response is in the input method, which is executed by go Input () when client initialization is complete. The input method contains an infinite loop in which data from the transport layer is read and deserialized, and the deserialized response is matched with the cached request.

Note: The names of the send and input methods are also derived from go’s RPC.

Server

Code implementation

When the server accepts registration, it filters the methods of the service provider and caches the valid methods.

The core logic of the server side is the serveTransport method. It receives a Transport object, reads data from Transport and deserializes it into a request in an infinite loop, searches for its own cached method according to the method specified by the request, and then performs the corresponding implementation and return through reflection. After the execution is complete, a complete message is assembled based on the returned result or the exception occurred during the execution and sent via Transport.

The server needs the implementer as the first argument to execute the reflected method, so there is one more argument than the one in the method definition.

The codec and protocol

These two parts are relatively simple, codec basically uses Messagepack to implement the corresponding interface; The implementation of protocol is to parse according to the protocol we define.

Threading model

In the process of execution, in addition to the client user thread and the server thread used to execute the method, the client polling thread and the server listener thread are also added respectively, as shown in the following diagram:

conclusion

At this point, our RPC framework is in shape to support basic RPC calls. In fact, the whole framework refers to the structure of GO’s RPC. The thread model of client and server is the same as that of GO’s RPC, except that it defines serialization and message protocols by itself, and retains the extended interface in the implementation process, which is convenient for subsequent improvement and expansion. The next step in the planning is to implement the filter chain so that service governance-related functions can be implemented later.

The historical link

Implementing an RPC framework from scratch (zero)