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:

  1. Service
  2. Client

A service needs to follow some rules:

  1. The service's type needs to be exported
  2. The service's methods need to be exported
  3. The first argument needs to be the request
  4. The second argument needs to be the response
  5. Each service method needs to return a type that implements the error interface

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:

  1. Service name
  2. Request
  3. 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:

  1. When registering the service, to know what methods are available for the service
  2. Serializing and deserializing requests and responses
  3. 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:

  1. Great when you want to get started fast
  2. Extremely simple to use
  3. Built-in serialization
  4. Supports sync and async calls
  5. Supports different transport protocols

Cons:

  1. Development is frozen
  2. No support for context
  3. 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:

  1. tcp
  2. http
  3. unix 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:

  1. KVService.Put
  2. KVService.Get
  3. KVService.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).