Introduction

These days most networked applications are designed as logical, self-contained, unifunctional services. Each service communicates with other services to complete some user request. For example, a notification service may contact a user management service to acquire a user’s credentials before sending a notification. Such inter-service communication requires a communication protocol such as REST and GraphQL. In this post, I want to briefly introduce another method called Remote Procedure Calls (RPC).

Remote Procedure Calls (RPC)

RPC is a communication protocol that allows one service to call a function/method/procedure in another service over a network. Essentially, the client service encodes the function definition and potential arguments into some format and sends it over to the server service. The server decodes the message and executes the specified method (using the arguments) and returns an appropriate response to the client. There are several frameworks that simplify the definition and usage of RPCs. These include gRPC and Apache Thrift. In this post, I want to discuss a simple RPC setup using Go’s RPC package.

Go’s RPC package

Per the documentation, the Go RPC packages “provides access to the exported methods of an object across a network or other I/O connection”. The methods that are made available must satisfy the following criteria:

- the method's type is exported.
- the method is exported.
- the method has two arguments, both exported (or builtin) types.
- the method's second argument is a pointer.
- the method has return type error.

Basically, a method whose signature matches the following template:

func (t *T) MethodName(argType T1, replyType *T2) error

To demonstrate how this will work, I’ll simulate a simple environment with two servers, each running a simple process, communicating using RPC. The servers will be created and networked using Vagrant (with Virtualbox).

Vagrant Servers

The Vagrantfile looks like so:

Vagrant.configure("2") do |config|

  config.vm.box = "ubuntu/focal64"

  config.vm.define "athena" do |athena|
        athena.vm.network "private_network", ip: "192.168.33.90"
        athena.vm.network "forwarded_port", guest: "7077", host: "7077"

        athena.vm.provision "shell", inline: <<-SHELL
                apt-get update && apt-get upgrade -y
                apt-get install golang -y
        SHELL
  end

  config.vm.define "kratos" do |kratos|
        kratos.vm.network "private_network", ip: "192.168.33.91"
        kratos.vm.network "forwarded_port", guest: "7088", host: "7088"

        kratos.vm.provision "shell", inline: <<-SHELL
                apt-get update && apt-get upgrade -y
                apt-get install golang -y
        SHELL
  end
end

This configuration creates two servers named athena with IP address 192.168.33.90 and kratos with IP address 192.168.33.91. Each server exposes some ports where the services will be listening on and is provisioned by installing Go.

Athena (Server)

The program defines a simple in-memory key-value store (Lines 36-39), with Get (Lines 41-54), Put (Lines 57-65) methods (to be exported. Then, there is a main function (Lines 68-90) that creates the key-value service, registers it with the RPC, starts a server, and listens for requests.

  1 package main
  2
  3 import (
  4     "log"
  5     "net"
  6     "net/rpc"
  7     "sync"
  8 )
  9
 10 const (
 11     OK       = "OK"
 12     ErrNoKey = "ErrNoKey"
 13 )
 14
 15 type Err string
 16
 17 type PutArgs struct {
 18     Key   string
 19     Value string
 20 }
 21
 22 type PutReply struct {
 23     Err Err
 24 }
 25
 26 type GetArgs struct {
 27     Key string
 28 }
 29
 30 type GetReply struct {
 31     Err   Err
 32     Value string
 33 }
 34
 35
 36 type KV struct {
 37     mu sync.Mutex
 38     data map[string]string
 39 }
 40
 41 func (kv *KV) Get(args *GetArgs, reply *GetReply) error {
 42     kv.mu.Lock()
 43     defer kv.mu.Unlock()
 44
 45     val, ok := kv.data[args.Key]; if ok {
 46         reply.Err = OK
 47         reply.Value = val
 48     } else {
 49         reply.Err = ErrNoKey
 50         reply.Value = ""
 51     }
 52
 53     return nil
 54 }
 55
 56
 57 func (kv *KV) Put(args *PutArgs, reply *PutReply) error {
 58     kv.mu.Lock()
 59     defer kv.mu.Unlock()
 60
 61     kv.data[args.Key] = args.Value
 62     reply.Err = OK
 63
 64     return nil
 65 }
 66
 67
 68 func main() {
 69     kv := new(KV)
 70     kv.data = map[string]string{}
 71
 72     rpcs := rpc.NewServer()
 73     rpcs.Register(kv)
 74
 75     l, e := net.Listen("tcp", ":7077")
 76     if e != nil {
 77         log.Fatal("listen error:", e)
 78     }
 79
 80     for {
 81         conn, err := l.Accept()
 82         if err == nil {
 83             go rpcs.ServeConn(conn)
 84         } else {
 85             break
 86         }
 87     }
 88
 89     l.Close()
 90 }

Kratos (Client)

The client defines two eponymous methods, get (Lines 44-58) and put (Lines 60-72), in addition to connect (Lines 34-41) that establishes a connection to the server and returns a client handle. In addition, there are mirror definitions for the argument and return value types of the methods defined in the server. This ensures that the correct arguments are sent and the correct response received.

  1 package main
  2
  3 import (
  4     "fmt"
  5     "log"
  6     "net/rpc"
  7 )
  8
  9 const (
 10     OK       = "OK"
 11     ErrNoKey = "ErrNoKey"
 12 )
 13
 14 type Err string
 15
 16 type PutArgs struct {
 17     Key   string
 18     Value string
 19 }
 20
 21 type PutReply struct {
 22     Err Err
 23 }
 24
 25 type GetArgs struct {
 26     Key string
 27 }
 28
 29 type GetReply struct {
 30     Err   Err
 31     Value string
 32 }
 33
 34 func connect() *rpc.Client {
 35     client, err := rpc.Dial("tcp", "192.168.33.90:7077")
 36     if err != nil {
 37         log.Fatal("dialing:", err)
 38     }
 39
 40     return client
 41 }
 42
 43
 44 func get(key string) string {
 45     client := connect()
 46
 47     args := GetArgs{key}
 48     reply := GetReply{}
 49
 50     err := client.Call("KV.Get", &args, &reply)
 51     if err != nil {
 52         log.Fatal("error:", err)
 53     }
 54
 55     client.Close()
 56
 57     return reply.Value
 58 }
 59
 60 func put(key, value string) {
 61     client := connect()
 62
 63     args := PutArgs{key, value}
 64     reply := PutReply{}
 65
 66     err := client.Call("KV.Put", &args, &reply)
 67     if err != nil {
 68          log.Fatal("error:", err)
 69     }
 70
 71     client.Close()
 72 }
 73
 74
 75 func main() {
 76     put("subject", "6.824")
 77     fmt.Printf("Put(subject, 6.824) done\n")
 78     fmt.Printf("get(subject) -> %s\n", get("subject"))
 79
 80     fmt.Println()
 81
 82     put("foo", "bar")
 83     fmt.Printf("Put(foo, bar) done\n")
 84     fmt.Printf("get(foo) -> %s\n", get("foo"))
 85
 86     fmt.Println()
 87
 88     put("bonnie", "clyde")
 89     fmt.Printf("Put(bonnie, clyde) done\n")
 90     fmt.Printf("get(bonnie) -> %s\n", get("bonnie"))
 91 }

The main function (Lines 75-91) simply calls the put method to store some data in the key-value store on the server and calls get to retrieve it.

Conclusion

This is as simple as RPC gets. For a more production-ready system, more robust frameworks such as gRPC and Apache Thrift should be preferred. gRPC provides a flexible, full-featured interface-definition language called protocol buffers that makes defining services a breeze. It also offers security features and improved performance by supporting bi-directional streaming, among others.