rcomLink
rcom is light-weight C++ libary for inter-node communication. All data is sent over websockets and rcom provides an implementation of both server-side and client-side websockets.
rcom offers a low-level API that can be used to build several communication patterns, such the publisher-subscriber pattern (pub-sub), or a message bus.
rcom also offers a higher-level API that provides the remote procedure call pattern (RPC). We will discuss this API in more detail first. After that we will present the generic API.
InstallationLink
The installation process follows the classical clone/cmake/make pattern:
$ git clone -b ci_dev https://github.com/romi/librcom.git
$ cd librcom/
$ mkdir build
$ cd build
$ cmake ..
$ make
Then run the tests to make sure all is well:
$ ctest -V
To check the code coverage run:
$ make librcom_unit_tests_coverage
$ firefox librcom/librcom_unit_tests_coverage/index.html
Using rcom for remote procedure callsLink
We will document how to use rcom through C++ API. However, it is possible to combine rcom with code writen in Python or Javascript, among other. We will provide some examples further below.
Using C++Link
Suppose that you are writing an application called Madness that
controls a bunch of happy monsters on the local network
(whatever...). You design an interface called IMonster
, as follows:
#include <string>
#include <iostream>
class IMonster
{
public:
virtual ~IMonster() = default;
virtual void jump_around() = 0;
virtual void gently_scare_someone(const std::string& person_id) = 0;
virtual double get_energy_level() = 0;
};
All the monsters of your application will derive from this interface,
such as the HappyMonster
below.
class HappyMonster : public IMonster
{
protected:
std::string name_;
double energy_;
public:
HappyMonster(const std::string name);
~HappyMonster() override = default;
void jump_around() override;
void gently_scare_someone(const std::string& person_id) override;
double get_energy_level() override;
};
HappyMonster::HappyMonster(const std::string name)
: name_(name), energy_(1.0)
{
}
void HappyMonster::jump_around()
{
std::cout << "Jump around!" << std::endl;
}
void HappyMonster::gently_scare_someone(const std::string& person_id)
{
std::cout << "Hey " << person_id
<< ", don't watch that. Watch this. "
<< "This is the happy happy monster show."
<< std::endl;
}
double HappyMonster::get_energy_level()
{
return energy_;
}
You can now write a small application, create a monster, and have it do things.
int main(int argc, char** argv)
{
HappyMonster monster("Elmo");
monster.gently_scare_someone("you");
return 0;
}
The full code of this example is split over the following files: monster_simple.cpp, IMonster.h, and HappyMonster.h
The client-side applicationLink
In the next step we will write a monster that lives in a remote
application, either on the same machine but in a different process, or
on a remote machine on the local network. We will write a new type of
monster, called RemoteMonster
.
#include "rcom/RemoteStub.h"
#include "rcom/RcomClient.h"
class RemoteMonster : public IMonster, public rcom::RemoteStub
{
public:
RemoteMonster(std::unique_ptr<rcom::IRPCClient>& client);
~RemoteMonster() override = default;
void jump_around() override;
void gently_scare_someone(const std::string& person_id) override;
double get_energy_level() override;
};
The new class inherits both from IMonster
and RemoteStub
. The
latter is part of rcom. You can also see that the RemoteMonster
constructor takes an instance of IRPCClient
as an argument. This
class represents the connection between the local application and the
remote process. As you can see below, this pointer is passed to the
constructor of RemoteStub
who will use it to send and receive
messages. Normally, you should not have to add arguments to the
constructor or create additional member variables in the
RemoteMonster
class because it is just a stub that will forward all
requests to the real implementation that lives in a remote process.
RemoteMonster::RemoteMonster(std::unique_ptr<rcom::IRPCClient>& client)
: RemoteStub(client)
{
}
We still have to implement the methods of our example class. They are shown below.
void RemoteMonster::jump_around()
{
bool success = execute_simple_request("jump-around");
if (!success) {
std::cout << "jump_around failed" << std::endl;
}
}
void RemoteMonster::gently_scare_someone(const std::string& person_id)
{
nlohmann::json params;
params["person-in"] = person_id;
bool success = execute_with_params("gently-scare-someone", params);
if (!success) {
std::cout << "gently_scare_someone failed" << std::endl;
}
}
double RemoteMonster::get_energy_level()
{
double energy_level = -1.0;
nlohmann::json result;
bool success = execute_with_result("get-energy-level", result);
if (success) {
energy_level = result["energy-level"];
} else {
std::cout << "get_energy_level failed" << std::endl;
}
return energy_level;
}
The implementation mostly calls upon the methods provided by
RemoteStub
:
- Use
execute_simple_request
for methods that don't take any arguments and return no values. - Use
execute_with_params
when the caller has to send arguments, but no return value is expected. - Use
execute_with_result
when there are no arguments but a value is returned. - Finally, the generic method
execute
takes arguments for the remote method and returns a value.
Both the parameters and the return value are sent using the JSON format. The RemoteStub takes care of the encoding the data to a JSON string representation and parsing the incoming string to a C++ JSON data structure. For this rcom uses the JSON library by Niels Lohmann. Check out its documentation to get to know all its features.
The various execute methods return true
when the remote method was
executed successfully and false
when an error occured. They do not
throw an exception. This leaves the choice up to you whether to throw
an exception in response to a failed invokation or not. When an error
occured, the RemoteStub
will write a message with to the rcom
logger. See more on the log system below.
NOTE: The other functions, such as RcomClient::create
below do throw
exceptions.
Here is the main function, again, rewriten for the use of the remote monster:
int main()
{
try {
auto client = rcom::RcomClient::create("elmo", 10.0);
RemoteMonster monster(client);
monster.gently_scare_someone("you");
} catch (std::exception& e) {
log_error("main: '%s'", e.what());
}
return 0;
}
The function rcom::RcomClient::create
establishes the connection to
a remote object on the local network (or local machine) identified by
"elmo". The second argument is a timeout for the connection. If "elmo"
doesn't show up within 10 seconds, the application calls it quits.
If the connection is established, it is passed to the RemoteMonster
object. The application can then call the IMonster
methods as if the
remote monster was a normal, local object.
The full code of the new version can be found in monster_client.cpp.
The registryLink
If you run the example application above, it will quit with the following error message:
ERROR: Socket::connect: failed to bind the socket
ERROR: Socket::Socket: Failed to connect to address 192.168.1.100:10101
ERROR: main: 'Socket: Failed to connect'
In order for the example above to find the "elmo" object, rcom
uses
another service called the rcom-registry
. It is basically a
directory service the maps identifiers to IP addresses. You will have
to start the service separately:
$ ./bin/rcom-registry
INFO: Registry server running at 192.168.1.100:10101.
If you run the example application again, it will still quit. This time, after 10 seconds, it will show the error message below:
WARNING: MessageLink::connect: Failed to obtain address for topic 'elmo'
ERROR: MessageLink: Failed to connect: elmo
ERROR: main: 'MessageLink: Failed to connect'
This is normal: we didn't implement and start the remote process, yet. We will look into that in the next session.
The server-side applicationLink
The remote side - or server side - will receive requests coming from the application that was introduced above. These requests are sent as JSON strings. They have to be parsed and mapped to the methods of the actual C++ object that the remote client wants to address. For this, we will use an adaptor, as follows:
int main()
{
try {
std::string name = "elmo";
HappyMonster monster(name);
MonsterAdaptor adaptor(monster);
auto monster_server = rcom::RcomServer::create(name, adaptor);
while (true) {
monster_server->handle_events();
usleep(1000);
}
} catch (std::exception& e) {
log_error("main: '%s'", e.what());
}
return 0;
}
The MonsterAdaptor
instance sits in between the generic RcomServer
object and the HappyMonster
object. The server will handle incoming
JSON requests and call the adapter. The adaptor must map the request
to the Monster object. Any return values will be converted to JSON by
the server and sent back.
The key here is the adapter class. It looks as follows:
class MonsterAdaptor : public rcom::IRPCHandler
{
protected:
IMonster& monster_;
public:
MonsterAdaptor(IMonster& monster);
~MonsterAdaptor() override = default;
void execute(const std::string& method, nlohmann::json& params,
nlohmann::json& result, rcom::RPCError& status) override;
void execute(const std::string& method, nlohmann::json& params,
rcom::MemBuffer& result, rcom::RPCError &status) override;
};
The two execute
methods will be called by the server instance. The
first one is for JSON text messages. The second one is for methods
returning large binary data. The use of binary data will be discussed
later.
In our example, the execute
method checks the value of the method
argument and then dispatches the call to the appropriate methods on
the "real" C++ object:
void MonsterAdaptor::execute(const std::string& method, nlohmann::json& params,
nlohmann::json& result, rcom::RPCError& error)
{
error.code = 0;
if (method == "jump-around") {
monster_.jump_around();
} else if (method == "gently-scare-someone") {
std::string id = params["person-id"];
monster_.gently_scare_someone(id);
} else if (method == "get-energy-level") {
result["energy-level"] = monster_.get_energy_level();
} else {
error.code = rcom::RPCError::kMethodNotFound;
error.message = "Unknown method";
}
}
That's it! The full code of this section can be found here: monster_server.cpp.
Run the exampleLink
To run the example, you must first start the rcom-registry
:
$ build/bin/rcom-registry
INFO: Registry server running at 192.168.1.100:10101.
Then, in another shell, you start the server-side application that runs the remote object:
$ build/bin/monster_server
The rcom-registry console should display something like the message below. It shows that the remote server successfully registered with the "elmo" identifier.
INFO: RegistryServer: Received message: {"request": "register", "topic": "elmo", "address": "192.168.1.100:45175"}
INFO: RegistryServer: Register topic 'elmo' at 192.168.1.100:45175
In a third shell, you can now start the client application:
$ build/bin/monster_client
This example application will quit almost immediately because it
doesn't do anything other than send a simple message. The console of
monster_server
should show the following, though:
Hey you, don't watch that. Watch this. This is the happy happy monster show.
Returning binary dataLink
To send binary data in the textual JSON format, it has to be encoded, for example, using the Base64 encoding. This can be quite a performance hit. For example, when the Raspberry Pi Zero has to transmit images, this encoding becomes a showstopper.
So, it is therfore possible to return the data as a binary
buffer. This is the reason for the second execute
method in the
adapter class discussed above.
On the client side, you will have to do the following:
rcom::MemBuffer& MyClass::call_method_with_binary_output(rcom::MemBuffer& buffer)
{
nlohmann::json params;
RPCError error;
buffer.clear();
client_->execute("method-id", params, buffer, error);
if (error.code != 0) {
// ...
}
return buffer;
}
In the example above, we don't use the execute
methods of the stub
but directly the execute
method of the client connection maintained
the stub.
Currently, it is only possible to retrive binary data from the server. There is no method, yet, for sending a buffer of binary data to the server. If you have to send binary data, you will have to encode it and sending it as part of the JSON request.
The generic APILink
rcom
provides both server-side and client-side websockets. We'll call
them client end-point and server end-points. A separate application,
called 'rcom-registry' is a directory server that maintains the list
of all server end-points. The rcom-registry application should be
launched separately before any other application.
The server end-points are identified using a topic, which is a free-form string. The topic should be unique for a given rcom-registry. Client end-points that want to communicate with a server first contact the rcom-registry to obtain the address of the server end-point. The address is simply a combination of IP address and port number. The client can then connect to the server end-point directly.
An application can open several server end-points. And a single server end-point can handle many clients.
The rcom library does not impose any format on the messages sent back and forth between the client and the server. Since the websocket standard makes a distinction between text-based, so does rcom. But under the hood, rcom is agnostic about the content of the messages.
The loggerLink
By default, the rcom libray logs the internal messages, including
error messages, to the console. If you are writing a large
application, you probably want to redirect these messages to a file or
a GUI window. In that case, you can subclass the rcom::ILog
interface
and inject it into the API functions discussed so far. For example, in
the example discussed previously, we created a client connection to a
remote object as follows:
int main()
{
// ...
auto client = rcom::RcomClient::create("elmo", 10.0);
// ...
}
This can be adapted as follows:
#include "MyLog.h"
int main()
{
// ...
auto log = std::make_shared<MyLog>();
auto client = rcom::RcomClient::create("elmo", 10.0, log);
// ...
}
The class MyLog
implements the rcom::Ilog
interface. It must
handle the four types of messages that may be sent by the library as
follows:
#include <iostream.h>
#include <rcom/ILog.h>
class MyLog : public rcom::ILog
{
public:
MyLog() {}
~MyLog() override = default;
void error(const std::string& message) override {
std::cout << "MyErr: " << message << std::endl;
}
void warn(const std::string& message) override {
std::cout << "MyWarn: " << message << std::endl;
}
void info(const std::string& message) override {
std::cout << "MyInfo: " << message << std::endl;
}
void debug(const std::string& message) override {
std::cout << "MyDebug: " << message << std::endl;
}
};
Similarly, for the server-side, you can pass your own the ILog
object:
#include "MyLog.h"
int main()
{
// ...
auto monster_server = rcom::RcomServer::create(name, adaptor, log);
// ...
}
Fixed portLink
No registrationLink
SecurityLink
Specifying the address of the registryLink
Behind a web serverLink
httpLink
httpsLink
Connecting from JavascriptLink
TODO: This section is work in progress (as is most of this documentation BTW).
Connecting to a remote object from Javascript is a two-step process:
- Create a websocket to rcom-registry to obtain the address of the requested object.
function createRemoteMonster(name, registry)
{
var registrySocket = new WebSocket('ws://' + registry + ':10101');
registrySocket.onopen = function (event) {
var request = { 'request': 'get', 'topic': name };
registrySocket.send(JSON.stringify(request));
};
registrySocket.onmessage = function (event) {
console.log(event.data);
var reply = JSON.parse(event.data);
if (reply.success) {
registrySocket.close();
monster = new RemoteMonster(reply.address);
}
}
}
- Create a websocket to the remote object using the obtained address.
class RemoteMonster
{
constructor(address) {
this.socket = new WebSocket('ws://' + address);
this.socket.onmessage = (event) => {
this.handleMessage(event.data);
};
this.socket.onopen = (event) => {
// ...
};
}
handleMessage(buffer) {
var response = JSON.parse(buffer);
if (response.error) {
this.handleErrorMessage(response.error);
} else if (response.method == 'get-energy-level') {
console.log('RemoteMonster: Energy level ' + response['energy-level']
}
}
handleErrorMessage(err) {
console.log('RemoteMonster: Method: ' + response.method
+ ', Error: ' + response.error.message);
}
execute(method, params) {
var request = { 'method': method, 'params': params };
var s = JSON.stringify(request);
this.socket.send(s);
}
jumpAround() {
this.execute('jump-around');
}
gentlyScareSomeone(id) {
this.execute('gently-scare-someone', {'person-id': id}};
}
getEnergyLevel() {
this.execute('get-energy-level');
}
}
Connecting from PythonLink
The rcom
library provides some helper code to exchange data between
Python code and rcom objects written in C++. At the current
development stage, this Python code has only been used for prototyping
during development. The code is not production ready but it may help
to get started in your own projects.
In the root directory of the rcom repository, you will find a
directory called python
that contains the Python rcom
modules and
some examples. You can install the rcom Python code and dependencies
as follows:
$ cd python
$ python3 setup.py install --user
A Python client connecting to an C++ rcom serverLink
To run the example, start the rcom-registry server in a new shell:
$ bin/rcom-registry
In another shell, start the remote monster server that we discussed above:
$ bin/monster_server
Finally, run the Python client:
$ python3 examples/monster_client.py
The Python code looks as follows. First, we define a new class
RemoteMonster
that subclasses the RcomClient
from the
rcom.rcom_client
module.
from rcom.rcom_client import RcomClient
class RemoteMonster(RcomClient):
def __init__(self, name, registry):
super().__init__(name, registry)
def jump_around(self):
self.execute('jump-around')
def gently_scare_someone(self, person_id):
self.execute('gently-scare-someone', {'person-id': person_id})
def get_energy_level(self):
answer = self.execute('get-energy-level')
return answer['energy-level']
TODO: The implementation still requires that you pass the IP address
to the registry to the RcomClient
instance. You can find the local
IP address using this code snippet:
import socket
def get_local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
Calling the remote C++ object is now very straightforward:
monster = RemoteMonster('elmo', get_local_ip())
monster.jump_around()
monster.gently_scare_someone('you')
energy = monster.get_energy_level()
print(f'energy level is {energy}')
Overview of the classes and codeLink
ILinux
, Linux
, MockLinux
: To facilitate unit testing, the system
functions are abstracted in the ILinux
interface. The Linux
class
provides the default implementation, and MockLinux
the
implementation used for testing.
The interface ISocket
defines a standard TCP/IP socket API. The
class Socket
is the default implementation of the API. Similarly,
IServerSocket
defines the API for a socket that accepts incoming
connection. It's default implementation can be found in the
ServerSocket
class. Both Socket
and ServerSocket
actually share
a lot of functionality. This functionality is grouped together in the
class BaseSocket
, which encapsulates the standard BSD socket
interface. Both Both Socket
and ServerSocket
delegate most of the
methods to BaseSocket
.
Websockets have there own API, defined in IWebSocket
. This interface
basically defines the methods to send or receive a message. The
WebSocket
class provides the default implementation. It uses an
ISocket
to send and receive data on the TCP/IP connection and then
implements the websocket protocol as defined in RFC
6455.
Most of the code doesn't create WebSockets directly but uses an
instance of ISocketFactory
to create them. Again, this facilitates
the testing of the code by passing in a MockSocketFactory
.
The WebSocketServer
implements a server that waits for incoming
websocket connections and creates a new ServerSideWebSocket
after a
successful handshake. It also maintains the list of all open
connections. This allows to send broadcast messages to all client
connected to this server. The handle_events
method should be called
regularly to deal with the incoming connection requests.
We distinguish between server-side and client-side websockets:
ServerSideWebSocket
: The websocket created on the server-side in response to a new incoming connection.ClientSideWebSocket
: The websocket created by the client to connect to aWebSocketServer
.
Both inherit implementation from the WebSocket
class.
A MessageHub
is like a WebSocketServer
with the following
additional functionality:
- It has a topic name.
- It registers the topic and its address to the remote registry.
RPC classesLink
IRPCHandler
IRPCClient
IRPCServer
IMessageListener
RcomClient
RemoteStub
RcomServer
RcomMessageHandler
TODOLink
Remote access 4G router, set-up at the farm Managing an fleet of rovers 4G router with a solar panel queue management doc format messages describe format message for different actions: move, path, grab, ... c++ -> Python