RPC variations in Go

Datetime:2016-08-23 03:09:06          Topic: RPC  Golang           Share

Introduction

Let's begin by understanding what an RPC (Remote Procedure Call) actually is:

RPC is a way of connecting two separate services via a raw TCP socket

Outline

The fundamental principle behind RPC is to create a service that exposes a function behind that service.

The steps typically look something like:

  • Write a function
  • Add some RPC configuration
  • Register our function as part of our RPC service
  • Start the service and have it listen for messages on a specific port

From here we would need to have a client that calls the RPC service:

  • Write code which calls RPC function
  • Call the function via a TCP socket with a specific ip/port
  • The resulting 'message' can be passed back in different formats (e.g. JSON)

Variations

With this understanding we can now start to look at the Go programming language and the different variations of its RPC package(s) that it offers. Effectively they consist of behaviour such as:

  • RPC over HTTP
  • RPC over TCP

The latter variation allows the use of either the standard net/rpc package or a JSON formatted version found under net/rpc/jsonrpc . In this post we'll take a look at code examples for each of these packages.

When utilising RPC you'll typically find there are three ' parts ':

  1. Backend (the RPC function)
  2. Service (exposes the RPC)
  3. Client (calls the RPC)

In most cases the backend will be unaffected. By this I mean, it's just a package with a set of behaviours/functionality which are being remotely exposed. The actual use of the net/rpc and net/rpc/jsonrpc packages are typically used within the Service and Client packages †

† unless the client is implemented in another language,

then you'll use whatever is best suited to that language

Requirements

Only methods that satisfy the following criteria will be made available for remote access, all other methods will be ignored (so if you hit a problem in the below code, chances are you're not exporting the expected items):

  • the method's type is exported
  • the method is exported
  • the method has two arguments, both exported
  • the method's second argument is a pointer
  • the method has return type error

RPC over HTTP

I've yet to find a justification for using HTTP over TCP, but you may have your reasons. If that's the case, then here is an example of how to achieve this in Go.

First, here's the directory structure I'm using:

├── remote
│   ├── rpc-html-backend.go
├── rpc-html-client.go
├── rpc-html-service.go

rpc-html-backend.go

As mentioned earlier, the backends responsibility is to define a specific function or behaviour (see the code comments for additional information):

package remote

import "fmt"

// Args is a data structure for the incoming arguments
// This needs to be exported for the RPC to be valid/work
type Args struct {
  A, B int
}

// Arith is our functions return type
// This also needs to be exported
type Arith int

// Multiply does simply multiplication on provided arguments
// This also needs to be exported
func (t *Arith) Multiply(args *Args, reply *int) error {
  fmt.Printf("Args received: %+v\n", args)
  *reply = args.A * args.B
  return nil
}

rpc-html-service.go

The service's responsibility is to expose the specific function. Below we do this using RPC over HTTP, so you'll notice the use of rpc.HandleHTTP for setting up a HTTP based handler and http.Serve for serving back a response to the client:

package main

import (
  "log"
  "net"
  "net/http"
  "net/rpc"

  "github.com/integralist/rpc/remote"
)

func main() {
  arith := new(remote.Arith)

  rpc.Register(arith)
  rpc.HandleHTTP()

  l, e := net.Listen("tcp", ":1234")
  if e != nil {
    log.Fatal("listen error:", e)
  }

  for {
    http.Serve(l, nil)
  }
}

Note: I was a little confused originally about having to manually open a TCP socket. I just assumed that in using HTTP, that step would've been abstracted away for me. But it's not, oh well

rpc-html-client.go

The client's responsibility is to connect to the remote service and call its exposed function. As our service is using RPC over HTTP you'll notice our client uses rpc.DialHTTP to create the TCP socket connection, just before calling the remote function via the returned client instance:

package main

import (
  "fmt"
  "log"
  "net/rpc"
)

type args struct {
  A, B int
}

func main() {
  client, err := rpc.DialHTTP("tcp", "localhost:1234")
  if err != nil {
    log.Fatal("dialing:", err)
  }

  var reply int

  e := client.Call("Arith.Multiply", &args{4, 2}, &reply)
  if e != nil {
    log.Fatalf("Something went wrong: %s", err.Error())
  }

  fmt.Printf("The reply pointer value has been changed to: %d", reply)
}

The output of the following program is as follows:

Args received: &{A:4 B:2}
The reply pointer value has been changed to: 8

RPC over TCP

Most of the time when you're exposing functionality and behaviour remotely, you'll want to have the least amount of overhead as possible and so you'll resort to stripping out the HTTP application layer and moving down to using just the TCP layer.

First, here's the directory structure I'm using:

├── remote
│   ├── rpc-tcp-backend.go
├── rpc-tcp-client.go
├── rpc-tcp-service.go

rpc-tcp-backend.go

As before, the backend's repsonsibility is to define a set of behaviours and functions (as mentioned above in the HTTP example, we need to export certain items in order for the RPC to be valid and work):

package remote

import "fmt"

// TCPArgs is structured around the client's provided parameters
// The struct's fields need to be exported too!
type TCPArgs struct {
  Foo string
  Bar string
}

// Compose is our RPC functions return type
type Compose string

// Details is our exposed RPC function
func (c *Compose) Details(args *TCPArgs, reply *string) error {
  fmt.Printf("Args received: %+v\n", args)
  *c = "some value"
  *reply = "Blah!"
  return nil
}

rpc-tcp-service.go

Our service will now expose the above behaviour by using rpc.Register along with rpc.Accept . This is the simplest implementation possible. The call to rpc.Accept is just a helper for directly accepting and serving an incoming request:

package main

import (
  "net"
  "net/rpc"

  "github.com/integralist/rpc/remote"
)

func main() {
  compose := new(remote.Compose)

  rpc.Register(compose)

  listener, err := net.Listen("tcp", ":8080")
  if err != nil {
    // handle error
  }

  rpc.Accept(listener)
}

If on the other hand you wish to interrogate the request (or at the very least, execute some other behaviour in-between the request being accepted and it being served) you can change the code as follows to swap out rpc.Accept for a for loop which calls Accept on the listener instance instead and then manually execute rpc.ServeConn (but remember to do this via a goroutine because it's a blocking call):

package main

import (
  "net"
  "net/rpc"

  "github.com/integralist/rpc/remote"
)

func main() {
  compose := new(remote.Compose)

  rpc.Register(compose)

  listener, err := net.Listen("tcp", ":8080")
  if err != nil {
    // handle error
  }

  for {
    conn, err := listener.Accept()
    if err != nil {
      // handle error
    }

    go rpc.ServeConn(conn)
  }
}

rpc-tcp-client.go

Lastly, as we already know, the client's responsibility is to call the exposed function. This time we use the rpc.Dial function instead of rpc.DialHTTP :

package main

import (
  "fmt"
  "log"
  "net/rpc"
)

type args struct {
  Foo, Bar string
}

func main() {
  client, err := rpc.Dial("tcp", "localhost:8080")
  if err != nil {
    log.Fatal("dialing:", err)
  }

  var reply string

  e := client.Call("Compose.Details", &args{"Foo!", "Bar!"}, &reply)
  if e != nil {
    log.Fatalf("Something went wrong: %v", e.Error())
  }

  fmt.Printf("The 'reply' pointer value has been changed to: %s", reply)
}

But if you want to implement a timeout (to prevent a call from taking too long), then you'll want to change rpc.Dial for net.DialTimeout (notice they're separate packages: rpc vs net ). Also be aware that the returned type isn't a client any more (as it is in the previous example); instead it is a 'connection'.

Once you have the connection you can then pass that to rpc.NewClient . Once you have your 'client' you'll notice that the rest of the code is the same as before (i.e. the calling of the exposed function via the client):

package main

import (
  "fmt"
  "log"
  "net"
  "net/rpc"
  "time"
)

type args struct {
  Foo, Bar string
}

func main() {
  conn, err := net.DialTimeout("tcp", "localhost:8080", time.Minute)
  if err != nil {
    log.Fatal("dialing:", err)
  }

  client := rpc.NewClient(conn)

  var reply string

  e := client.Call("Compose.Details", &args{"Foo!", "Bar!"}, &reply)
  if e != nil {
    log.Fatalf("Something went wrong: %v", e.Error())
  }

  fmt.Printf("The 'reply' pointer value has been changed to: %s", reply)
}

The output of the following program is as follows:

Args received: &{Foo:Foo! Bar:Bar!}
The 'reply' pointer value has been changed to: Blah!

JSON

There is another option available when creating an RPC and that is to expose a JSON formatted variation (which is required † if you're planning on using a different programming language to communicate with your Go RPC service - as we'll see below when we write a client using the Ruby programming language).

† The standard net/rpc package uses https://golang.org/pkg/encoding/gob/

Which is a Go specific streaming binary format

If your client isn't Go then it'll have a hard time communicating

If we look back at our TCP example from earlier (the one which utilised rpc.ServeConn ), we can switch that over to being JSON formatted by just using the same code but making some minor changes:

  • In both the service and the client: swap net/rpc to net/rpc/jsonrpc
  • In the service: swap rpc.ServeConn to jsonrpc.ServeConn
  • In the client: swap rpc.Dial to jsonrpc.Dial

Calling from Ruby

If you want to utilise a client written in another programming language (such as Ruby), you'll need to have the Go service setup to use net/rpc/jsonrpc . Once that's done, your client can connect via a raw TCP socket and pass over JSON data, as shown in the below example:

require "socket"
require "json"

socket = TCPSocket.new "localhost", "8080"

# Details of JSON structure can be found here:
# https://golang.org/src/net/rpc/jsonrpc/client.go#L45
# Thanks to Albert Hafvenström (@albhaf) for his help
b = {
  :method => "Compose.Details",
  :params => [{ :Foo => "Foo!", :Bar => "Bar!" }],
  :id     => "0" # id is just echo'ed back to the client
}

socket.write(JSON.dump(b))

p JSON.load(socket.readline)

The output from this program would be:

{"id"=>"0", "result"=>"Blah!", "error"=>nil}

gRPC

Google has started work on a new package called gRPC which, as per the site: grpc.io , states...

is a high performance, open source, general RPC framework that puts mobile and HTTP/2 first

They currently support C++, Java, Objective-C, Python, Ruby, Go, C#, Node.js, and PHP. You can either go to the main GitHub repo ( github.com/grpc ) or if you're only interested in the Go version, then you can find it here: github.com/grpc/grpc-go

I've not tried it yet, but it looks interesting.

Update

I've setup gRPC now. You can find a beginners guide I've writtenhere

Links





About List