-
-
Notifications
You must be signed in to change notification settings - Fork 112
Remote Procedure Calls using Interfaces
So far, we've used Cap'n Proto as a faster version of Protocol Buffers. This is already a huge improvement, but the real power of Cap'n Proto lies in its high-performance RPC protocol. Cap'n Proto RPC builds on the data serialization in much the same way as gRPC is built on Protocol Buffers.
But unlike gRPC, Cap'n Proto RPC offers a rich object capability model and protocol-level optimizations including network path-shortening and promise pipelining (shown below).
At its core, Cap'n Proto RPC is a distributed object protocol. It allows you to call methods on objects residing in remote hosts. To do this, you first obtain a reference to the remote object, called a capability. Method calls on the capability are translated into RPC calls against the object it points to.
Capabilities are first-class objects, and this means you can:
- embed them in a Cap'n Proto
struct
, - store them in a
List
type, - pass them as arguments to RPC methods, and
- return them from RPC calls.
This is a huge improvement over typical RPC protocols. JSON-RPC, Go's net/rpc
, gRPC and Thrift only allow you to address global URLs or singleton objects that are registered with the server. In contrast, Cap'n Proto RPC allows you to dynamically create new objects at runtime, and share them over the network. In other words, you can do object-oriented programming (OOP) over the network!
This pattern of Object Capabilities provides a powerful framework for writing secure, performant protocols. We'll explore this paradigm in more detail in a later chapter.
For now, let's skip over the theory and proceed by example.
First, you will learn how to declare a capability in your schema. In this step, we'll also see how capnp compile
uses this to generate a Go interface. Next, you'll learn how to implement the capability server, i.e. the implementation for the Go interface generated in the previous step. Lastly, you'll learn how to obtain a capability that points to your server, share it with a remote host, and use it to make RPC calls.
Capnp RPC operates on interface
types in your schema. We will begin by reproducing the Arith
RPC server from the Go Standard Library RPC package documentation.
Open a new file called arith.capnp
, and copy/paste the following schema:
using Go = import "/go.capnp";
@0xf454c62f08bc504b;
$Go.package("arith");
$Go.import("arith");
interface Arith {
multiply @0 (a :Int64, b :Int64) -> (:product :Int64);
divide @1 (num :Int64, denom :Int64) -> (quo :Int64, rem :Int64);
}
Now, compile the schema as before:
capnp compile -I$GOPATH/src/capnproto.org/go/capnp/std -ogo arith.capnp
You should take a moment to inspect the generated types in arith.capnp.go
. For interface declarations in your schema, the capnp compiler generates several types, summarized in the following tables.
Name | Go Type | Descripton |
---|---|---|
Arith_Server |
interface |
Network-shareable object. Methods are RPC endpoints. |
Arith_<method> |
struct |
Method call parameters, e.g. Arith_multiply .Received by Arith_Server method when handling RPC call. |
Name | Go Type | Descripton |
---|---|---|
Arith |
struct |
The "client" or "capability". Instances point to a specific Arith_Server .Method calls perform RPC against corresponding Arith_Server . |
Arith_<method>_Params |
struct |
Arguments to <method> , e.g. Arith_multiply_Params
|
Arith_<method>_Results |
struct |
The results of an RPC call, e.g. Arith_multiply_Results . |
Arith_<method>_Results_Future |
struct |
A promise type. Represents in-flight RPC request, e.g. Arith_multiply_Results_Future .Resolves to Arith_<method>_Results . |
Now that we have compiled our schema and inspected the generated Go code, let's write an implementation for Arith_Server
. Most of the time, you will only write one implementation for your capability. Note however that because Arith_Server
is just an ordinary Go interface, you can have multiple implementations in your program. Common uses for this are to create mock implementations for testing, and to implement restricted or revokable capabilities. We will explore both patterns in a later chapter. For now, let's keep it simple.
Let's define our Arith
server. In the same directory, create an arith.go
file and paste the following code:
package arith
import capnp "capnproto.org/go/capnp/v3"
type ArithServer struct{}
func (Arith) Multiply(ctx context.Context, call Arith_multiply) error {
res, err := call.AllocResults() // allocate the results struct
if err != nil {
return err
}
res.SetProduct(call.Args().A() * call.Args().B())
return nil
}
func (Arith) Divide(ctx context.Context, call Arith_divide) error {
if call.Args().B() == 0 {
return errors.New("divide by zero")
}
res, err := call.AllocResults()
if err != nil {
return err
}
res.SetQuo(call.Args().A() / call.Args().B())
res.SetRem(call.Args().A() % call.Args().B())
return nil
}
We now have a working RPC server implementation for our schema interface. Let's begin by starting a server and listening for incoming RPC calls.
The following snippet instantiates an arith.Arith
server, and exports it over a bidirectional stream.
// Create a new locally implemented arith server.
server := arith.Arith_ServerToClient(arith.ArithServer{})
// Listen for calls, using the server as the bootstrap interface.
// The 'rwc' parameter can be any io.ReadWriteCloser, usually net.Conn.
conn := rpc.NewConn(rpc.NewStreamTransport(rwc), &rpc.Options{
// The BootstrapClient is the RPC interface that will be made available
// to the remote endpoint by default. In this case, Arith.
BootstrapClient: capnp.Client(server),
})
defer conn.Close()
// Block until the connection terminates.
select {
case <-conn.Done():
return nil
case <-ctx.Done():
return conn.Close()
}
And here's the corresponding client setup and RPC call:
// As before, rwc can be any io.ReadWriteCloser, and will typically be
// a net.Conn. The rpc.Options can be nil, if you don't want to override
// the defaults.
conn := rpc.NewConn(rpc.NewStreamTransport(rwc), nil)
defer conn.Close()
// Now we wait until we receive the bootstrap interface from the ArithServer.
// The context can be used to time-out or otherwise abort the bootstrap call.
// It is safe to cancel the context after `Bootstrap` returns.
a := Arith(conn.Bootstrap(ctx))
// Okay! Let's make an RPC call!
//
// There are a few things to notice here:
// 1. We pass a callback function to set parameters on the RPC call. If the
// call takes no arguments, you MAY pass nil.
// 2. We return a Future type, representing the in-flight RPC call. We also
// return a release function, which MUST be called when you're done with
// the RPC call and its results.
f, release := a.Multiply(ctx, func(ps arith.Arith_multiply_Params) error {
ps.SetA(2)
ps.SetB(42)
return nil
})
defer release()
// You can do other things while the RPC call is in-flight, but we're going to
// block until the call completes.
res, err := f.Struct()
if err != nil {
return err
}
log.Println(res.Product()) // prints 84
And that's it! Let's reiterate the key points about calling RPC methods:
- For the sake of simplicity, this example uses an in-memory pipe, but you can use TCP connections, Unix pipes, or any other type that implements
io.ReadWriteCloser
. - The return type for a client call is a promise, not an immediate value.
It isn't until the
Struct()
method is called on a method that theclient
function blocks on the remote side.
A few additional words on the Future type are in order. If your RPC method returns another interface type, you can use the Future to immediately make calls against that as-of-yet-unreturned interface. This relies on a feature of the Cap'n Proto RPC protocol called promise pipelining, the advantage of which is that Cap'n Proto can often optimize away the additional network round-trips when such method calls are chained. This is one of Cap'n Proto's key advantages.
Now that you've learned the basics of Cap'n Proto RPC, you are ready to learn more about object capabilities and advanced RPC.