Hey,
I’ve been exploring the new Go support for AWS Lambda that has been announced by AWS early this year (see Announcing Go Support for AWS Lambda), and there’s a little detail of it that I found very interesting: at its core, it uses Go’s net/rpc package as a mean of communication between the AWS infrastructure and your code.
Having never used net/rpc
myself, I decided to explore it.
- Architecting an RPC hello world
- Creating a Go RPC Handler
- Creating a Go RPC Server and Client
- Go RPC in Practice
- Closing thoughts
Architecting an RPC hello world
As net/rpc
gives us ways of making a piece of code communicate with another piece of code over the wire, a natural example is a “Hello World” in a client-server architecture.
To achieve that, I created this repository: cirocosta/sample-rpc-go.
The file structure looks like the following:
.
├── Makefile # instructions for building and running the
| # example
|
├── client # RPC client
│ └── client.go # provides the interface which executes the
| # command remotely.
|
├── core # Shared functionality.
│ └── core.go # - implements the actual handler that gets
| # executed in the server;
| # - provides the go representation of the
| # messages exchanged between client and server.
|
├── main.go # CLI
| #
└── server # RPC server - provides the interface which
| # allows a client to communicate with it
└── server.go # over the wire.
That allow us to keep the project really simple as main
executes either the server or client code based on a flag (-server
) and all the rest of the functionality is implemented independently.
Create a client and a server, and you’ll be able to communicate.
Let’s do that, then!
Creating a Go RPC Handler
The main piece of an RPC server is the methods that get executed when the client makes a call to them, the p part of the name - procedures.
For those accustomed to HTTP, in RPC land the equivalent of an endpoint (like, POST /say-hello
) is a service/procedure name (HelloSayer.Say
). Go constructs a map of these service endpoints by taking an exported struct that has a set of exported methods and making them available.
Naturally, these methods must obey to a certain interface so that net/rpc
can properly provide (de)serialization of the request and response objects and perform the right method execution:
- it must take two arguments and return an error object;
- the first argument is the request and second is the response;
- the second (response) must be a pointer - in case of failure (an error in the service), error is returned, and the response is sent as
nil
.
Looking at the Go source code (src/net/rpc/server.go), we can understand how it’s done behind the scenes.
// Register publishes in the server the set of methods of the
// receiver value that satisfies the following conditions:
// - exported method of exported type
// - two arguments, both of exported type
// - the second argument is a pointer
// - one return value, of type error
// It returns an error if the receiver is not an exported type or has
// no suitable methods. It also logs the error using package log.
// The client accesses each method using a string of the form "Type.Method",
// where Type is the receiver's concrete type.
func (server *Server) Register(rcvr interface{}) error {
return server.register(rcvr, "", false)
}
// ciro: `register` does the hard work of taking the struct and then analyzing
// its methods to register them.
//
// In the end, `net/rpc` constructs a map that maps endpoint names
// (`Struct.Method`) to the actual implementations, as well as
// performing the (de)serialization accordingly such that the methods
// are properly called.
func (server *Server) register(rcvr interface{}, name string, useName bool) error {
s := new(service)
s.typ = reflect.TypeOf(rcvr)
s.rcvr = reflect.ValueOf(rcvr)
s.name = reflect.Indirect(s.rcvr).Type().Name()
// Install the methods
// ciro: this inspects the methods and verify (for each) whether they
// adhere to the "interface" that net/rpc expects - first arg is
// represents the request and is not a pointer ; second is the response
// and must be pointer, and then it also has an error return.
s.method = suitableMethods(s.typ, true)
// store the service with the given service name
if _, dup := server.serviceMap.LoadOrStore(s.name, s); dup {
return errors.New(
"rpc: service already defined: " +
sname)
}
return nil
}
So, for a Hello World I end up with this:
type Response struct {
Message string
}
type Request struct {
Name string
}
type Handler struct {}
func (h *Handler) Execute(req Request, res *Response) (err error) {
if req.Name == "" {
err = errors.New("A name must be specified")
return
}
res.Message = "Hello " + req.Name
return
}
It adheres to the interface that net/rpc
expects and is simple enough for our example.
Creating a Go RPC Server and Client
Once the hander has been defined, creating the server is easy if you’ve set up an HTTP or TCP server before. The only difference is that before actually listening for incoming connections, you have to call rpc.Register
and pass an instance of the struct that carries the exported methods you define (i.e., your service handlers).
// Publish our Handler methods
rpc.Register(&core.Handler{})
// Create a TCP listener that will listen on `Port`
listener, _ = net.Listen("tcp", ":"+strconv.Itoa(Port))
// Close the listener whenever we stop
defer listener.Close()
// Wait for incoming connections
rpc.Accept(listener)
on the client side, it’s almost the same as establishing a TCP connection:
var (
addr = "127.0.0.1:" + strconv.Itoa(Port)
request = &core.Request{Name: Request}
response = new(core.Response)
)
// Establish the connection to the adddress of the
// RPC server
client, _ = rpc.Dial("tcp", addr)
defer c.client.Close()
// Perform a procedure call (core.HandlerName == Handler.Execute)
// with the Request as specified and a pointer to a response
// to have our response back.
_ = c.client.Call(core.HandlerName, request, response)
fmt.Println(response.Message)
Let’s see how it works in practice.
Go RPC in Practice
Given the simplicity of having a client and server, I got curious about how the underlying communication works.
By default, the Go RPC mechanism uses encoding/gob
to (de)serialize the messages that come and go from net/rpc
. This means that we’d not have an easy time deciphering what’s going on by inspecting the wire. Nonetheless, net/rpc
is pluggable enough that other encoding mechanisms can be used - for instance, the official jsonrpc which is much simpler to inspect.
First, a connection is established (plain TCP
), and the request is sent:
{
"id": 0,
"method": "Handler.Execute",
"params": [
{
"Name": "ciro"
}
]
}
As no error happens, the response cames back with error: null
and the result (our Response
message):
{
"error": null,
"id": 0,
"result": {
"Message": "Hello ciro"
}
}
That’s it!
Closing thoughts
Having never used net/rpc
before, I felt that there’s a lot of value having a working RPC system so fast.
At the same time, it’s very hard to find great resources about this package online.
I started searching why and it turns out that net/rpc
is in a “freeze” state - no more development is meant to go on in this package - and you can clearly see that: no method of this package takes context
, although it makes a lot of sense for some of them (e.g., the client’s .Call
method could easily have request cancellation with a context
just like you have with net/http
).
If you’re curious about whether people use net/rpc
out there, the answer is: yes. Most notably, minio, which saw a decrease in performance when evaluating gRPC
. Pretty interesting.
What about you? Have you ever used it? Did you, like me, went straight to gRPC
without checking net/rpc
? I’d love to know!
Reach me at Twitter @cirowrc at any time.
Have a good one!
finis