RPC Action EP1: Implement a simple RPC interface in Go

huizhou92 - Sep 9 - - Dev Community

RPC(Remote Procedure Call)is a widely used communication method between different nodes in distributed systems and a foundational technology of the Internet era. Go's standard library provides a simple implementation of RPC under the net/rpc package. This article aims to help you understand RPC by walking you through implementing a simple RPC interface using the net/rpc package.

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

To enable a function to be remotely called in net/rpc, it must meet the following five conditions:

  • The method’s type is exported.
  • The method is exported.
  • The method has two arguments, both of which are exported (or built-in) types.
  • The method’s second argument is a pointer.
  • The method has a return type of error.

In other words, the function signature must be:

func (t *T) MethodName(argType T1, replyType *T2) error
Enter fullscreen mode Exit fullscreen mode

Creating a Simple RPC Request

Based on these five conditions, we can construct a simple RPC interface:

type HelloService struct{}  
func (p *HelloService) Hello(request string, reply *string) error {  
    log.Println("HelloService Hello")  
    *reply = "hello:" + request  
    return nil  
}
Enter fullscreen mode Exit fullscreen mode

Next, you can register an object of the HelloService type as an RPC service:

func main() {
    _ = rpc.RegisterName("HelloService", new(HelloService))  
    listener, err := net.Listen("tcp", ":1234")  
    if err != nil {  
        log.Fatal("ListenTCP error:", err)  
    }  
    for {  
        conn, err := listener.Accept()  
        if err != nil {  
           log.Fatal("Accept error:", err)  
        }  
        go rpc.ServeConn(conn)  
    }
}
Enter fullscreen mode Exit fullscreen mode

The client-side implementation is as follows:

func main() {
    conn, err := net.Dial("tcp", ":1234")
    if err != nil {
        log.Fatal("net.Dial:", err)
    }
    client := rpc.NewClient(conn)
    var reply string
    err = client.Call("HelloService.Hello", "hello", &reply)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(reply)
}
Enter fullscreen mode Exit fullscreen mode

First, the client dials the RPC service using rpc.Dial, then invokes a specific RPC method via client.Call(). The first parameter is the RPC service name and method name combined with a dot, the second is the input, and the third is the return value, which is a pointer. This example demonstrates how easy it is to use RPC.

In both the server and client code, we need to remember the RPC service name HelloService and the method name Hello. This can easily lead to errors during development, so we can wrap the code slightly by abstracting the common parts. The complete code is as follows:

// server.go
const ServerName = "HelloService"  

type HelloServiceInterface = interface {  
    Hello(request string, reply *string) error  
}  

func RegisterHelloService(srv HelloServiceInterface) error {  
    return rpc.RegisterName(ServerName, srv)  
}  

type HelloService struct{}  

func (p *HelloService) Hello(request string, reply *string) error {  
    log.Println("HelloService Hello")  
    *reply = "hello:" + request  
    return nil  
}

func main() {  
    _ = RegisterHelloService(new(HelloService))  
    listener, err := net.Listen("tcp", ":1234")  
    if err != nil {  
       log.Fatal("ListenTCP error:", err)  
    }  
    for {  
       conn, err := listener.Accept()  
       if err != nil {  
          log.Fatal("Accept error:", err)  
       }  
       go rpc.ServeConn(conn)  
    }  
}
Enter fullscreen mode Exit fullscreen mode
// client.go

type HelloServiceClient struct {  
    *rpc.Client  
}  

var _ HelloServiceInterface = (*HelloServiceClient)(nil)  

const ServerName = "HelloService" 

func DialHelloService(network, address string) (*HelloServiceClient, error) {  
    conn, err := net.Dial(network, address)  
    client := rpc.NewClient(conn)  
    if err != nil {  
       return nil, err  
    }  
    return &HelloServiceClient{Client: client}, nil  
}

func (p *HelloServiceClient) Hello(request string, reply *string) error {  
    return p.Client.Call(ServerName+".Hello", request, reply)  
}
func main() {
    client, err := DialHelloService("tcp", "localhost:1234")  
    if err != nil {  
        log.Fatal("net.Dial:", err)  
    }  
    var reply string  
    err = client.Hello("hello", &reply)  
    if err != nil {  
        log.Fatal(err)  
    }  
    fmt.Println(reply)
}
Enter fullscreen mode Exit fullscreen mode

Does it look familiar?

Implementing JSON Codec with Go's net/rpc Package

By default, Go's standard RPC library uses Go's proprietary Gob encoding. However, it's straightforward to implement other encodings, such as Protobuf or JSON, on top of it. The standard library already supports jsonrpc encoding, and we can implement JSON encoding by making minor changes to the server and client code.

// server.go
func main() {  
    _ = rpc.RegisterName("HelloService", new(HelloService))  
    listener, err := net.Listen("tcp", ":1234")  
    if err != nil {  
       log.Fatal("ListenTCP error:", err)  
    }  
    for {  
       conn, err := listener.Accept()  
       if err != nil {  
          log.Fatal("Accept error:", err)  
       }  
       go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))  
       //go rpc.ServeConn(conn)  
    }  
}

//client.go
func DialHelloService(network, address string) (*HelloServiceClient, error) {  
    conn, err := net.Dial(network, address)  
    //client := rpc.NewClient(conn)  
    client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))  
    if err != nil {  
       return nil, err  
    }  
    return &HelloServiceClient{Client: client}, nil  
}
Enter fullscreen mode Exit fullscreen mode

The JSON request data object internally corresponds to two structures: on the client side, it's clientRequest, and on the server side, it's serverRequest. The content of clientRequest and serverRequest structures is essentially the same:

type clientRequest struct {  
    Method string `json:"method"`  
    Params [1]any `json:"params"`  
    Id     uint64 `json:"id"`  
}
type serverRequest struct {  
    Method string           `json:"method"`  
    Params *json.RawMessage `json:"params"`  
    Id     *json.RawMessage `json:"id"`  
}
Enter fullscreen mode Exit fullscreen mode

Here, Method represents the service name composed of serviceName and Method. The first element of Params is the parameter, and Id is a unique call number maintained by the caller, used to distinguish requests in concurrent scenarios.

We can use nc to simulate the server and then run the client code to see what information the JSON-encoded client sends to the server:

 nc -l 1234
Enter fullscreen mode Exit fullscreen mode

The nc command receives the following data:

 {"method":"HelloService.Hello","params":["hello"],"id":0}
Enter fullscreen mode Exit fullscreen mode

This is consistent with serverRequest.

We can also run the server code and use nc to send a request:

echo -e '{"method":"HelloService.Hello","params":["Hello"],"Id":1}' | nc localhost 1234 
--- 
{"id":1,"result":"hello:Hello","error":null}
Enter fullscreen mode Exit fullscreen mode

Conclusion

This article introduced the rpc package from Go's standard library, highlighting its simplicity and powerful performance. Many third-party rpc libraries are built on top of the rpc package. This article serves as the first installment in a series on RPC research. In the next article, we will combine protobuf with RPC and eventually implement our own RPC framework.

. . . . . . . . . . . . . . . . .