Golang stdlib net/rpc
When I started getting into distributed systems and consensus mechanisms, I was very surprised how useful the golang net/rpc package is. It allows you to build rpc services and clients with minimal codebase. It's perfect for prototyping and learning projects.
net/rpc
At its core, it's very simple. You have two main components:
- Service
- Client
A service needs to follow some rules:
- The service's type needs to be exported
- The service's methods need to be exported
- The first argument needs to be the request
- The second argument needs to be the response
- Each service method needs to return a type that implements the
errorinterface
This is how you would define a service:
type Service struct {}
func (s *Service) Greet(args string, reply *string) error {
*reply = fmt.Sprintf("%s world!", args)
return nil
}
All you have to do afterwards is to register your service:
err = rpc.Register(&Service{})
And then you would run the service (example over tcp):
listener, _ := net.Listen("tcp", ":8080")
defer listener.Close()
for {
conn, _ := listener.Accept()
go rpc.ServeConn(conn)
}
Using the client is just as simple, call the rpc method by providing:
- Service name
- Request
- Response
Service name is taken from your service type name, but you can specify it manually.
client, _ := rpc.Dial("tcp", "localhost:8080")
defer client.Close()
var reply string
_ = client.Call("Service.Method", "hello", &reply)
fmt.Println(reply) // hello world!
You create a connection with the rpc service, and each request is handled in a
separate goroutine. Once you are done, you close the connection manually.
Reflection does most of the work here:
- When registering the service, to know what methods are available for the service
- Serializing and deserializing requests and responses
- When a request is received, to invoke the correct service method
Reflection is also a reason why you would lose some performance when using it, compared to grpc for example.
The selling point for me was not having to worry about serialization, even
though that is a solved problem. While prototyping, it helps to not even think
of it. net/rpc uses gob under the hood, so all of your types are serialized
into bytes. All you need to do is define your go structs.
Pros:
- Great when you want to get started fast
- Extremely simple to use
- Built-in serialization
- Supports sync and async calls
- Supports different transport protocols
Cons:
- Development is frozen
- No support for context
- All participants need to be in the same binary
While net/rpc is not something I would advise using in production, I would
definitely encourage everyone doing their learning projects using it.
Usage
In this demo, I'll build a simple key-value store exposed through an rpc service. The service will allow setting key-value pairs, getting a value for a key and deleting a value for a key. The rpc service will be provided through:
tcphttpunix sockets
First, let's define a key-value store, a simple map:
// minirpc/store.go
package minrpc
type KVStore struct {
data map[string]string
}
func NewKVStore() *KVStore {
return &KVStore{data: map[string]string{}}
}
func (kvs *KVStore) Put(key, value string) {
kvs.data[key] = value
}
func (kvs *KVStore) Get(key string) (string, bool) {
if v, ok := kvs.data[key]; ok {
return v, true
}
return "", false
}
func (kvs *KVStore) Delete(key string) {
delete(kvs.data, key)
}
Next, let's define our requests and responses:
// minirpc/transport.go
package minrpc
type PutRequest struct {
Key string
Value string
}
type PutResponse struct {
Ok bool
}
type GetRequest struct {
Key string
}
type GetResponse struct {
Value string
Found bool
}
type DeleteRequest struct {
Key string
}
type DeleteResponse struct {
Ok bool
}
Next, we define a service that we will register. It will provide:
KVService.PutKVService.GetKVService.Delete
// minirpc/service.go
package minrpc
type KVService struct {
kvs *KVStore
}
func NewKVService() *KVService {
return &KVService{kvs: NewKVStore()}
}
func (s *KVService) Put(args PutRequest, reply *PutResponse) error {
s.kvs.Put(args.Key, args.Value)
reply.Ok = true
return nil
}
func (s *KVService) Get(args GetRequest, reply *GetResponse) error {
value, found := s.kvs.Get(args.Key)
reply.Value = value
reply.Found = found
return nil
}
func (s *KVService) Delete(args DeleteRequest, reply *DeleteResponse) error {
s.kvs.Delete(args.Key)
reply.Ok = true
return nil
}
And our clients, we define a base client for communication logic, and client for each transport protocol that contains logic on how connections are created.
// minirpc/client.go
package minrpc
import "net/rpc"
type BaseClient struct {
client *rpc.Client
}
func NewBaseClient(c *rpc.Client) *BaseClient {
return &BaseClient{client: c}
}
func (c *BaseClient) call(method string, args any, reply any) error {
return c.client.Call(method, args, reply)
}
func (c *BaseClient) Put(args PutRequest, reply *PutResponse) error {
return c.call("KVService.Put", args, reply)
}
func (c *BaseClient) Get(args GetRequest, reply *GetResponse) error {
return c.call("KVService.Get", args, reply)
}
func (c *BaseClient) Delete(args DeleteRequest, reply *DeleteResponse) error {
return c.call("KVService.Delete", args, reply)
}
type TCPClient struct {
*BaseClient
}
func NewTCPClient(addr string) (*TCPClient, error) {
client, err := rpc.Dial("tcp", addr)
if err != nil {
return nil, err
}
return &TCPClient{BaseClient: NewBaseClient(client)}, nil
}
type HTTPClient struct {
*BaseClient
}
func NewHttpClient(addr string) (*HTTPClient, error) {
client, err := rpc.DialHTTP("tcp", addr)
if err != nil {
return nil, err
}
return &HTTPClient{BaseClient: NewBaseClient(client)}, nil
}
type UnixSocketClient struct {
*BaseClient
}
func NewUnixSocketClient(sock string) (*UnixSocketClient, error) {
client, err := rpc.Dial("unix", sock)
if err != nil {
return nil, err
}
return &UnixSocketClient{BaseClient: NewBaseClient(client)}, nil
}
In the end, we define how a server is started for each transport protocol:
// minirpc/server.go
package minrpc
import (
"net"
"net/http"
"net/rpc"
"os"
)
func RunTCPServer(addr string, s *KVService) error {
if err := rpc.Register(s); err != nil {
return nil
}
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
rpc.Accept(l)
return nil
}
func RunHTTPServer(addr string, s *KVService) error {
if err := rpc.Register(s); err != nil {
return nil
}
rpc.HandleHTTP()
return http.ListenAndServe(addr, nil)
}
func RunUnixSocketServer(sock string, s *KVService) error {
if err := os.RemoveAll(sock); err != nil {
return err
}
l, err := net.Listen("unix", sock)
if err != nil {
return nil
}
if err := rpc.Register(s); err != nil {
return err
}
rpc.Accept(l)
return nil
}
And now you are ready to run your services:
// cmd/tcp/main.go
package main
import (
"fmt"
"netrpc/minrpc"
"time"
)
func main() {
service := minrpc.NewKVService()
go func() {
time.Sleep(2 * time.Second)
client, _ := minrpc.NewTCPClient(":9000")
putReq := minrpc.PutRequest{Key: "key", Value: "value"}
putResp := minrpc.PutResponse{}
if err := client.Put(putReq, &putResp); err != nil {
fmt.Println(err)
}
fmt.Println(putResp) // {true}
getReq := minrpc.GetRequest{Key: "key"}
getResp := minrpc.GetResponse{}
if err := client.Get(getReq, &getResp); err != nil {
fmt.Println(err)
}
fmt.Println(getResp) // {"value" true}
delReq := minrpc.DeleteRequest{Key: "key"}
delResp := minrpc.DeleteResponse{}
if err := client.Delete(delReq, &delResp); err != nil {
fmt.Println(err)
}
fmt.Println(delResp) // {true}
getReq = minrpc.GetRequest{Key: "key"}
getResp = minrpc.GetResponse{}
if err := client.Get(getReq, &getResp); err != nil {
fmt.Println(err)
}
fmt.Println(getResp) // {"" false}
}()
err := minrpc.RunTCPServer(":9000", service)
if err != nil {
panic(err)
}
}
This is an example with tcp, but you can use the same snippet with http or
unix if you want to (just update how server and client are initialized).