Primer
Welcome to the Primer! This document is a tutorial introduction to rpclib
for absolute beginners. If you are new to the library and prefer detailed instructions and explanation, you are in the right place. If short examples with less explanation work better for you, you might want to check out the Cookbook!
The tutorial is sturctured as follows: in the first part, writing servers is explained with one simple and one more advanced example. In the second part, the corresponding clients are implemented.
Prerequisites
Knowledge-wise, this tutorial assumes that you have an intermediate grasp of C++ and that you have an idea of what RPC (Remote Procedure Call) is.
For your build environment, make sure that you are able to compile and link a program with
rpclib
. The Getting Started page can help you with that.
Introduction
rpclib
is a RPC library that provides both a client and a server implementation. The server allows you to expose functions of your program to be called remotely, while the client allows you to call functions of servers. You can use the rpclib
client and server in tandem (even in the same program, if you want to), but it's not a requirement.
As other RPC libraries, rpclib
is a good candidate for inter-process communication. Also, there exist many implementations of the protocol in a large amount of languages, which makes it a possible inter-language communication bridge.
msgpack-RPC is the protocol that rpclib
uses for dispatching and encoding calls to functions. The protocol is based on msgpack, a fast and compact format. For details on how exactly it is structured, see the Specification chapter.
Writing servers
In the first part of this tutorial, we will learn about writing server applications. Two example applications will be implemented step-by-step.
Calculator, the "Hello World" of RPC libraries.
Our first server application will expose four functions: add
, subtract
, multiply
, divide
. For the sake of this example, the functions are implemented as various callable entities.
#include "rpc/server.h" double divide(double a, double b) { return a / b; } struct subtractor { double operator()(double a, double b) { return a - b; } }; struct multiplier { double multiply(double a, double b) { return a * b; } }; int main() { subtractor s; multiplier m; auto add = [](double a, double b) { return a + b; }; // ... return 0; }
Now, let's create the server:
rpc::server srv(8080);
This server will listen on port 8080 (but not right away after construction - we need to run
it). Next, we bind the functors to names in order to expose them:
srv.bind("add", [](double a, double b) { return a + b; }); srv.bind("sub", s); srv.bind("div", ÷); srv.bind("mul", [&m](double a, double b) { return m.multiply(a, b); });
These are the names that the client can use to call our functions. There is nothing stopping you from binding divide
to the name "add"
, but it's a good practice to use names that reflect the source names. It is also possible to bind the same function to multiple names.
Info
Under the hood, each bind
statement generates a compile-time a wrapper function that takes a msgpack
object, then decodes it into the real parameters of the bound function (if any) and calls the bound function. If the function has a return value the wrapper is generated so that it encodes the result as a msgpack
object which the server can send to the client in a response. More information on this mechanism can be found in the Internals chapter.
After we exposed the function, we need to run
the server:
srv.run();
run
is a blocking function, but it also has a non-blocking pair called async_run
. When run
is called, the server starts listening on the port we assigned to it in its constructor, and its internal event loop will start processing the incoming requests.
This is now a functioning (although, in some ways, incomplete) server. The complete listing so far:
#include "rpc/server.h" double divide(double a, double b) { return a / b; } struct subtractor { double operator()(double a, double b) { return a - b; } }; struct multiplier { double multiply(double a, double b) { return a * b; } }; int main() { rpc::server srv(8080); subtractor s; multiplier m; srv.bind("add", [](double a, double b) { return a + b; }); srv.bind("sub", s); srv.bind("div", ÷); srv.bind("mul", [&m](double a, double b) { return m.multiply(a, b); }); srv.run(); return 0; }
If you want, you can fire up a quick python script to test it (don't worry, we'll write a client with rpclib
, too).
Responding with errors
There is, however, an issue with this server. Did you spot it? Any client can easily make it crash just by calling div
with a 0 divider (causing division by zero). What can we do about this? Well of course, we can just check the divider and not perform the division. We still need to return something though:
#include "rpc/server.h" double divide(double a, double b) { if (b == 0) { return 0.0; // <- ugh, that's not very good :S } return a / b; }
This is enough to avoid the crash, but it's fundamentally broken: division by zero does not yield zero, after all. The client gets an arbitrary result (oblivious to the fact that there was an error), which is most likely not what they want.
Luckily, the msgpack-rpc protocol supports error signaling. We need to modify our divide
function a little bit to utilize this functionality:
#include "rpc/server.h" #include "rpc/this_handler.h" double divide(double a, double b) { if (b == 0) { rpc::this_handler().respond_error("Division by zero"); } return a / b; }
This is better. The error is stringly-typed, but it's better than an arbitrary result. To amend this, we can return practically any object, such as a tuple that contains an error code besides the message:
double divide(double a, double b) { if (b == 0.0) { rpc::this_handler().respond_error( std::make_tuple(1, "Division by zero")); } return a / b; }
Info
msgpack-rpc
does not define the structure of error objects, so making up something like this is
perfectly fine (in fact, I'd encourage you to do this with well-defined error codes). Consider
it part of your server interface and document accordingly.
You might be puzzled about why we are not returning after setting the error. The reason for this is that respond_error
throws an internal exception that is handled inside the library. (This can be considered an implementation detail, but it's good to know what happens here (and it's unlikely to change).
Now, with the added error handling, our server is bullet-proof. Or is it?
What about my exceptions?
Our little calculator server is pretty stable at this point, but real-world applications often have to deal with exceptions. In general, exceptions should be handled at the library users' discretion (that is, caught on the handler level). So by default, rpclib
doesn't do anything with them. If an exception leaves the handler, it is an unhandled exception and your server will crash. Yet, there are cases when you can't or don't want to handle exceptions in the handler. To facilitate this, rpclib
provides a way to automatically turn exceptions into RPC errors:
srv.suppress_exceptions(true);
With this, you can call functions that throw or throw exceptions:
double divide(double a, double b) { if (b == 0) { rpc::this_handler().respond_error( std::make_tuple(1, "Division by zero")); } else if (b == 1) { throw std::runtime_error("Come on!"); } throw std::logic_error("What am I doing here?"); }
So yes, this means that if you set suppress_excpetions
to true
, you might as well signal errors from handlers by throwing exceptions. Be advised that respond_error
is still valid and remains the preferred way to do so (especially that it's the only way to respond with structured error objects).
What exactly happens to the suppressed exception? rpclib
will try to catch std::exceptions
and use their what()
members to get a string representation which it sets as an error.
What if you throw something that is not a std::exception
-descendant? First of all, shame on you. Second, rpclib
will send an error message letting your clients know that you threw something that is not a std::exception
(shaming you in front of your clients). Don't do this, really.
A more complicated server - Parallel mandelbrot-generation
The following example demonstrates parallel processing and binding custom data types. The server itself will have two functions: one for getting the current date and time, and one for getting a rendering of the mandelbrot set. The two functions can be called asynchronously by a client.
Using custom types as parameters
Anything that msgpack can process can be used as a parameter or return value for a bound function. In order to teach msgpack about your custom types, you need to use the MSGPACK_DEFINE_ARRAY
or MSGPACK_DEFINE_MAP
macros.
Info
The difference between the two macros is that the array only contains the data values after each other, while the map also contains the names of the values. The latter gives more flexibility, the former is more compact.
In our mandelbrot example, we will want to send pixel data to the clients, so let's define a struct:
struct pixel { unsigned char r, g, b; MSGPACK_DEFINE_ARRAY(r, g, b) }; using pixel_data = std::vector<pixel>;
We will share this definition between the client and server, so for our purposes it's best to put it in a common header.
Like in the first example, we create the server and bind the functions we expose. This time we are using lambdas as the bound functions.
rpc::server srv(8080); srv.bind("get_time", []() { time_t rawtime; struct tm *timeinfo; time (&rawtime); timeinfo = localtime(&rawtime); return asctime(timeinfo); }); srv.bind("get_mandelbrot", [&](int width, int height) { pixel_data data; for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { // ... } } return data; });
The exact contents of these functions is not a concern for our purposes, just note that the get_time
returns a value very quickly, while get_mandelbrot
computes a large array of numbers for several seconds.
Running the server asynchrously and utilizing workers
In the first example, we called the blocking run
function of the server to start it. Here, we are going to use async_run
. There are two important differences.
. run
blocks, async_run
returns after starting the server.
. async_run
supports spawning worker threads for executing the bound functions.
In this example, we call it like this:
srv.async_run(2);
This will spawn two worker threads in the server (so now there are three in the program, because the main thread already exists). The threads will wait until there is work to do.
Info
"Work" is not only executing handlers. Processing network I/O is also part of the work that threads can take. You don't need an extra thread per connection though, because processing the I/O is typically not very processor-intensive.
Now this server can take a call to get_mandelbrot
, start executing it and in the meantime it can finish multiple get_time
calls. The handlers are only executed by these worker threads, the main thread is free to continue.
Writing clients
Creating msgpack-rpc clients with rpclib
happens very similarly to servers. Mirroring the server examples above, we will implement their corresponding clients.
The Calculator client
The client
object is instantiated like this:
rpc::client client("127.0.0.1", 8080);
The important difference, compared to a server, is that we also need to specify the host to connect to.
Another difference is that the client tries to connect to the server right away during construction (but the construction of the client is not a blocking call). The client object can be used right away:
double five = c.call("add", 2, 3).as<double>();
Let's try and call all of the exposed functions. With a little bit of logging to the standard output, the complete listing looks like this so far:
#include <iostream> #include "rpc/client.h" int main() { rpc::client c("localhost", 8080); std::cout << "add(2, 3) = "; double five = c.call("add", 2, 3).as<double>(); std::cout << five << std::endl; std::cout << "sub(3, 2) = "; double one = c.call("sub", 3, 2).as<double>(); std::cout << one << std::endl; std::cout << "mul(5, 0) = "; double zero = c.call("mul", five, 0).as<double>(); std::cout << zero << std::endl; std::cout << "div(3, 0) = "; double hmm = c.call("div", 3, 0).as<double>(); std::cout << hmm << std::endl; return 0; }
Error handling
Any request that a client makes might potentially receive an error response. In the calculator server, we decided to respond with a tuple
containing an error code and a message. rpclib
allows you to handle these error objects by catching rpc::rpc_error
exceptions. To handle the errors the server throws, we would wrap the calls like this:
try { std::cout << "add(2, 3) = "; double five = c.call("add", 2, 3).as<double>(); std::cout << five << std::endl; std::cout << "sub(3, 2) = "; double one = c.call("sub", 3, 2).as<double>(); std::cout << one << std::endl; std::cout << "mul(5, 0) = "; double zero = c.call("mul", five, 0).as<double>(); std::cout << zero << std::endl; std::cout << "div(3, 0) = "; double hmm = c.call("div", 3, 0).as<double>(); std::cout << hmm << std::endl; } catch (rpc::rpc_error &e) { std::cout << std::endl << e.what() << std::endl; std::cout << "in function " << e.get_function_name() << ": "; using err_t = std::tuple<int, std::string>; auto err = e.get_error().as<err_t>(); std::cout << "[error " << std::get<0>(err) << "]: " << std::get<1>(err) << std::endl; return 1; }
As you would expect, the output looks like this:
add(2, 3) = 5 sub(3, 2) = 1 mul(5, 0) = 0 div(3, 0) = rpclib: a handler responded with an error in function 'div': [error 1]: Division by zero
That's pretty much all we need for the calculator client.
The anatomy of a call
call
does a couple of things:
- If the client is not yet connected to the server, it waits until it connects (this might block until the connection is established)
- Sends a "call" message to the server
- Waits for the response and returns it as a msgpack object - this blocks until the response is read.
In the example above, you can see how getting a strongly typed value from the result is done: using the as
member template. This takes the msgpack object and tries to deserialize it into the type given. If that fails, you will get a type_error
.
call
takes at least one parameter (the name of the function to call), and an arbitrary number and type of other paramters that are meant to be passed to the function being called. Each parameter has to be serializable by msgpack.
Tip
See msgpack adaptors from the msgpack documentation for more information on serializing and deserializing custom types.
The Mandelbrot client
The client for the mandelbrot server above is interesting because we will take advantage of the multiple workers in the server. In order to do that, instead of call
we are going to use async_call
.
async_call
is very similar to call
, but it does not wait for the response. Instead, it will return a future, allowing us to continue our program flow and retrieve the result later (which the server can compute in the meantime).
rpc::client c("127.0.0.1", 8080); // this returns immediately: auto result_obj = c.async_call("get_mandelbrot", width, height); // we can now call another function and wait for its result: auto current_time = c.call("get_time").as<std::string>(); // ... after some time, retrieve the result (optionally wait for it) auto result = result_obj.get().as<pixel_data>();
The call to get_time
can be performed with call
(no need for async_call
), because the other call is running on a different worker.
Info
What would happen if our server only had one worker thread? We would get the same output, but with more delay: The server would only start processing the get_time
call after it finished executing and writing the response of get_mandelbrot
. Essentially, a single-threaded server works in a "queue" fashion. The same thing would happen if the server was simple under heavy load.
Async servers vs. async clients vs. parallel execution
Does the asynchonous nature of async_call
depend on the server or the load of the server then? No, it does not. It's important to realize that async_call
is still asynchronous even if the server does not execute requests in parallel. If there are multiple clients connected to the server, their requests are processed in a more queued manner (still two requests processed at the same time).
Tip
rpclib
uses a simple convention: foo
is a synchronous call, async_foo
is asynchronous. This conventions was adapted from Asio. The latter only means that the call returns "immediately" (or rather, very quickly and without finishing all of the work).
The two worker threads in the mandelbrot server can serve two clients in parallel. Or two calls of the same client, which happens in the example. In order to be able to send two requests in an interleaved fashion, we first use async_call
which allows the control flow of the client to continue.
Where to go from here
The Cookbook features most (if not all) intended use cases of rpclib - it's a great place to continue.
If you are interested in the internal design of rpclib
, take a look at the Internals
page.