MIRA
Services and Remote Procedure Calls


Contents

Overview

Beside Channels, Remote Procedure Calls (RPC) are the second mechanism that can be used to communicate with other modules outside of your own Authority or Unit. Remote Procedure Calls are suitable for "request - reply" interactions since they allow to call a procedure or method.

The procedures or methods are offered by services, that group these methods. This is similar to instances of classes (objects) that also provide methods. In contrast to C++ objects, the RPC services may be located locally in the same framework, within the same process, or in a different process or even on a different machine.

A unit can be turned into an RPC service easily by offering its methods or methods of an other object for remote calls using MIRA's reflect mechanism (see Offering Services).

The RPC mechanism that is implemented by MIRA takes object orientation into account. Methods that are exposed as RPC methods are inherited by derived classes via the reflect mechanism. Additionally, the RPC concept supports interfaces (see Interfaces). Such an object oriented RPC is also known as "remote method invocation".

Offering Services

Each Authority, Unit or MicroUnit can easily register and offer services by calling the publishService() method. This method takes the object as parameter whose methods should be exposed as service methods.

To be able to publish the methods of an object, the class of the object just needs to implement a reflect() method where the exported methods of the class are specified:

class MyServiceObject
{
public:
template <typename Reflector>
void reflect(Reflector& r)
{
r.method("myMethod", &MyServiceObject::myMethod, this, // method name and function to be called
"This method is available for RPC calls", // description of the method
"param", "It has a parameter."); // names and descriptions of the method's
// parameter(s) - 1 parameter here
r.method("myStaticMethod", myStaticMethod,
"This static method is available for RPC calls", // this method has 2 parameters,
"v1", "some parameter", "data", "the actual data"); // those are documented here
r.method("myOtherMethod", &MyServiceObject::myOtherMethod, this
"This method has a parameter with dynamic structure",
"values", "values list", std::list<int>({1})); // Optionally, a sample value can be
// provided in addition to parameter
// name and description
}
int myMethod(float param);
static void myStaticMethod(int v1, std::vector<int> data);
void myOtherMethod(std::list<int> values);
};
...
// Within our Authority / MicroUnit / Unit:
MyServiceObject mObject;
...
publishService(mObject); // publish all reflected methods of mObject

The method declaration must contain either

Parameter types are recognized automatically, there is no need to document them. Name and description are just text, while the sample value is an object of the respective parameter's type. These sample values (just as name and description) are purely for documentation, e.g. to show sample notation in interactive tools. They are NOT used as default values when the method is called, or affect processing of the call in any way. If no such sample value is provided, a default-constructed instance of the type is created. Thus, it makes most sense to manually define samples for types with dynamic structure (pointers, containers, etc.), for which the default-constructed (empty) object holds little information.

A Unit can also publish itself as service, e.g:

class MyUnit : public mira::Unit
{
MIRA_OBJECT(MyUnit)
public:
template <typename Reflector>
void reflect(Reflector& r)
{
r.method("myMethod", &MyUnit::myMethod, this,
"This method is available for RPC calls",
"param", "a param");
}
void initialize()
{
publishService(*this); // publish us as service
}
int myMethod(float param);
}


You can provide arbitrary methods in services, as long as the parameters of the method and its return type can be serialized by the serialization framework, i.e. parameters and return value can be built-in types, complex classes with reflect() method, etc. (see What types are supported ?).

Please note, that the object whose methods are published as service, must exist as long as the service is published. In other words, if the object is destroyed, the service must be unpublished before.

The publishService() method may also take the name of the service as optional parameter

publishService("MyService", mObject);


If the name is omitted, the name of the Authority is used as name of the service. Similar to Channels, the names of Services also reside within the namespace of the Authority where they are published. They can also be aliased similar to Channel names.
You can publish multiple service objects under the same service name, as long as the signatures of the offered methods are different:

// publish the methods of two objects under the service "MyService"
publishService("MyService", mObject);
publishService("MyService", mAnotherObject);

Parameters and Return Value of Service Methods

As stated before, you can publish arbitrary methods as RPC methods, as long their parameters and their return value can be serialized using the serialization framework (see What types are supported ?).

In C++ the parameters and the return value can be passed by value, by reference, or using pointers. The following table gives an overview what calling conventions are also supported for RPC methods:

ConventionParameterReturn Value
T
by value
supportedsupported
T&
by reference
supportedNOT supported
T*
pointer
supported,
but must be handled with care (see below)
supported,
but must be handled with care (see below)
shared_ptr<T>
smart pointer
supportedsupported

As shown in the table, most calling conventions can be used. The only restriction is that return values can not be returned by reference. This is because the variable that takes the return value must be temporarily created internally before the return value is assigned - which is not possible for references.

Additionally, parameters and return values passed as plain C++ pointers must be handled with care to avoid memory leaks. To understand why, one has to keep in mind that the RPC methods can be called from other processes or even on other machines. Hence, a pointer that is used as parameter can not simply be passed as pointer to the called method, since it is not valid in a different process. Instead, the object that the pointer points to is serialized on the caller's side and passed to the callee's side, where it is deserialized into a new instance of the object. Finally, a pointer to this new instance is passed to the called method. In order to avoid memory leaks, the called method must delete the created instance that is passed as pointer. The same applies, if a return value is returned as pointer. In this case the caller must delete the instance that is returned by the RPC method.

To avoid memory leaks it is highly recommended to use shared pointers whenever values are passed as pointers to or from an RPC method. Shared pointers automatically take care of deleting the created objects when they are used no longer.

Calling Methods of Services

You can call methods of Services easily using the callService() method of your Authority, MicroUnit or Unit. When calling this method, the name of the service, the name of the method and the parameters for the method call need to be specified. Moreover, the return type of the remote method must be specified as template parameter:

callService<int>("MyService", "myMethod", 1.23f);
// ^ ^ ^ ^
// Return-Type Service-Name Method Variable number of parameters

In the above example the myMethod() of the above service "MyService" will be called. The method takes one float-parameter and returns an int. Please note that you need to specify the full qualified name of the service (including all namespaces) when you call a services from a different namespace:

callService<int>("/SomeNamespace/MyService", "myMethod", 1.23f); // this may hide runtime errors! see "Exception and Error Handling"!

Obtaining the Result of RPC calls

If you are interested in the return value of an RPC call, or if you want to block until the call has finished, you need to use RPCFutures. An RPCFuture is a proxy for the result of the asynchronous RPC call. The RPCFuture is returned by the callService() method. It takes a template parameter which is the type of the return value of the RPC method:

RPCFuture<int> result = callService<int>("/SomeNamespace/MyService", "myMethod", 1.23f);

It can be used to block the caller until the RPC call has finished and the result is available:

RPCFuture<int> result = callService<int>("/SomeNamespace/MyService", "myMethod", 1.23f);
result.wait(); // wait until the RPC call has finished
int value = result.get(); // get the result

Using the wait() method of the RPCFuture, you can wait until the RPC call has finished. There's also a timedWait() method with timeout, see RPCFuture for details. Using the get() method of the RPCFuture you can access the return value of the RPC call. If the call has not yet finished before calling get(), it will block until the RPC call has finished.

The use of futures dramatically reduces latency in RPC calls, since the caller can decide whether it wants to wait for the result or not and where it wants to wait for the result. E.g. you can invoke an RPC call which may take some time to complete. In the mean time you can process or compute something else to use the time efficiently while waiting for the result of the RPC call:

// trigger the RPC call which may take a while to complete
RPCFuture<int> result = callService<int>("/SomeNamespace/MyService", "myMethod", 1.23f);
// ... compute something instead of idling while waiting for the result ...
// get the result, here we will block until the result becomes available. However,
// if the above computation takes as long as the computation of the RPC call, we
// won't block at all and don't waste time.
int value = result.get();

If you are not interested in the result of an RPC call, or if the RPC call was successfully processed, then you will not need to block at all. Hence, you can easily control the blocking behavior.

Exception and Error Handling

If an exception occurs on the caller side, e.g. a certain service is not found, the exception will be thrown immediately when the RPC service method is called.

If an exception occurs while processing the RPC call on the callee side, the exception will be transported from the remote side to the caller. When the caller then accesses the get() method of the RPCFuture, an XRPC exception will be thrown that contains the message of the original exception that has occurred on the caller side within the RPC method. To check if an RPC call has finished with an exception, you can use the hasException() method of the RPCFuture.

An RPCFuture will be in exception state in the following cases:

  1. an exception occured within the called RPC method
  2. the specified RPC method of the service does not exist, or has a different signature (different parameter number, parameter types or return value than the specified ones)
  3. a timeout has occured while waiting for the response
Note, that an exception that has occured in the above cases will be thrown only, if you use RPCFutures and if you call the future's get() method. If no future is used, a possible exception will be hidden. In some cases this could shadow a problem within the code or the designed program logic. In some other cases it is a desired behavior, e.g. when the usage of a service is optional. Hence, the designer needs to decide whether to check for exceptions or not.

If an exception is thrown in the called function, it is also possible to make the RPCFuture throw that exception instead of a generic XRPC. This allows catching and handling specific types of exceptions, as if calling a native function. This functionality is based on serializing the exception at the execution site and restoring it at the caller site by deserializing, thus it requires the exception type to be available for construction in the local class factory.

For backward compatibility, the default behaviour of RPCFuture::get() is to throw an XRPC for any occured exception. When calling it as RPCFuture::get(false), it will instead throw the original exception, if available. A second parameter (true by default) controls whether it should recursively unwrap the innermost exception (if the wrapped exception is an XRPC again, from a call to callService() within the called method).

try {
RPCFuture<void> f = callService<void>("/SomeNamespace/MyService", "myThrowingMethod");
f.get(false, true);
}
catch (XIO& ex) {
// handle it
}
catch (XLogical& ex) {
// handle differently
}
catch (XRPC& ex) {
// may still get an XRPC e.g. for errors in RPC framework itself (like unknown method/wrong signature)
// or if the original exception cannot be restored
}

Interfaces

Services or service objects can also specify interfaces they implement. The interfaces are addressed by simple string identifiers. Specifying interfaces has several advantages. It allows to query all services that support/implement a certain interface or to wait for a service with an interface to become available without needing to know the exact name of the service.

Each class can indicate in its reflect() method that it implements one or more interfaces as follows:

template <typename Reflector>
void reflect(Reflector& r)
{
// our class provides all RPC methods of the IDrive interface
r.interface("IDrive");
}

By stating that your service implements an interface you sign an implicit contract that your service has all the methods that are described in the interface documentation. Since a class should always call the reflect method of its base class, the interface descriptions are inherited automatically. In the above example let's assume the IDrive interface is documented to provide exactly one method that allows to set a velocity. The IDrive interface can be implemented by all robot drivers that allow to move a robot. Other Units that like to move the robot can now use any service that implements IDrive.

void setVelocity(const mira::Velocity2& v);

Since correct implementation of string based interfaces can not be checked by the compiler, the interface definition needs to be well documented:

Even with documentation, the use of string based interfaces is somewhat error prone. Below a method is described that adds compiler support to the interface concept.

Interfaces with compiler support

For adding compiler support and therefore make the use of interfaces more safe, one should create an abstract class for each interface. In the case of IDrive this would look like:

class IDrive
{
public:
virtual ~IDrive() {};
virtual void setVelocity(const mira::Velocity2& v) = 0;
template <typename Reflector>
void reflect(Reflector& r)
{
// our class provides all RPC methods of the IDrive interface
r.interface("IDrive");
r.method("setVelocity", &IDrive::setVelocity, this, "Set the velocity"
"v", "velocity");
}
}

This abstract class already has a reflect method that registers the service method as well as the implemented interface. Services that like to implement IDrive can now derive from the IDrive class and call the reflect of IDrive explicitely in their own reflect method:

class MyDriver : IDrive
{
public:
virtual void setVelocity(const mira::Velocity2& v) { ... }
template <typename Reflector>
void reflect(Reflector& r)
{
MIRA_REFLECT_BASE(r, IDrive);
}
}

Now it is guaranteed that the service implements all methods of the interface.

Querying interfaces

Assume that you have multiple Units that are able to move a robot. Each robot driver offers the service to set the speed of the robot. Moreover, each of these services has indicated that it implements the "IDrive" interface, hence it provides a "setVelocity" method. To obtain all services that are able to move a robot, you now just have to query all services that support the "IDrive" interface using the queryServicesForInterface() method:

std::list<std::string> services = queryServicesForInterface("IDrive");

After you have obtained the names of all services, you can decide which concrete service you want to use and therefore what robot you like to control. If there is more than one service available, you can either ask the user to choose one or you can select the service automatically based on its capabilities. To do so, the services could provide a "getCapability()" RPC method that can be called to obtain detailed information on each service.

Waiting for services to become available and checking for their existence

Beside the above method to query a service without knowing its name, you sometimes want to access a certain service that you know by name. The service name could be specified by the user or in a configuration file.

Since the startup of Units/Authorities in a framework follows no specific order or remote frameworks containing other services will connect later, you can not assume that a service is already available when your Unit is initialized.

It is sometimes necessary to wait for a certain service or interface to become available. Authorities provide two ways for this purpose: synchronous wait for a service or asynchronous callback whenever a service gets registered.

For the synchronous case there exist two methods waitForService and waitForServiceInterface.

waitForService blocks until a service with the specified name becomes available and returns true. You can specify a maximum duration that is used for waiting for the service. In the case of a timeout the method will return false.

if (waitForService("MyService", Duration::seconds(5)))
callService<void>("MyService", "method");

waitForServiceInterface blocks until the first service with the specified interface becomes available. It will then return the name of that service. If the method times out, an empty string is returned.

std::string service = waitForServiceInterface("IDrive", Duration::seconds(5));
if (!service.empty())
callService<void>(service, "setVelocity", Velocity2(0.1, 0.0, 0.0));

Beside waiting for a service you can also use existsService method to check for its existence only. The method will return true if a service with the given name exists and will return false otherwise.

For the asynchronous case, the user can add a callback that gets called whenever a service with a specific interface gets registered using registerCallbackForInterface:

void onNewService(const std::string& interface, const std::string& service)
{
MIRA_LOG(DEBUG) << "New service " << service
<< " with interface " << interface << " registered";
}
void initialize()
{
registerCallbackForInterface("IDrive", &MyClass::onNewService, this);
}

Built-in Service Methods

Beside the user-defined RPC methods, the MIRA framework provides several built-in methods for each Authority and Unit. Primarily, these methods are used internally by the framework. However, some of these methods may also be useful for the user. A list of important built-in methods is given below:

Built-in methodDescription
void start()

Starts the authority, if it was stopped before

void stop()

Stops the authority

void isRunning()

Returns true, if the authorities main dispatcher is running

void isStarted()

Returns true, if the authority was started

void hasUnrecoverableFailure()

Does the authority have an error state that is unrecoverable?

std::string resolveName(std::string)

Resolves a name in the namespace of the authority using the existing aliases

std::set<std::string> getPublishedChannels()

All channels published by this authority and all its sub-authorities

std::set<std::string> getSubscribedChannels()

All channels subscribed to by this authority and all its sub-authorities

std::multimap<std::string, std::string> getPublishedChannelNames()

All channels published by this authority and all its sub-authorities as a pair of resolved and local names

std::multimap<std::string, std::string> getSubscribedChannelNames()

All channels subscribed to by this authority and all its sub-authorities as a pair of resolved and local names

StatusMap getStatusMap()

Get status information of this authority and all its sub-authorities

std::string getProperty(std::string)

Returns the value of the specified property as string

void setProperty(std::string property, std::string value)

Sets the value of the specified property from string

json::Value getPropertyJSON(std::string)

Returns the value of the specified property as json string

std::string setPropertyJSON(std::string, json::Value)

Sets the value of the specified property from json string

PropertyTree getProperties()

Returns the complete property tree

These methods can be accessed by appending "#builtin" to the original name of the service. For example, the "/Foo" service can by stopped by calling the "stop" built-in method of the service "Foo#builtin".

Moreover, the built-in methods are hidden in the RPCView of miracenter by default. To show them, you need to enable the hidden services via the View-Menu of the RPCView.