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
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
}
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)
}
}
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)
}
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)
}
}
// 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)
}
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
}
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"`
}
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
The nc
command receives the following data:
{"method":"HelloService.Hello","params":["hello"],"id":0}
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}
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.