RPC Ports
For complex interaction between components (e.g. query/response), data ports are often not suitable (using them nevertheless, typically leads to complicated/fragile/inefficient workarounds). For such cases, RPC ports were introduced. They are available in finroc_plugins_rpc_ports
.
Use cases include components providing access to complex data structures - e.g. maps, (3D) scenes, or databases. Components with an integrated file server are another example.
Key features
Similar port types are available in other state-of-the-art robotic frameworks. They are often called "service ports" or "services". Key features of Finroc's implementation include:
- Very light-weight, efficient implementation
- No IDL: Interfaces are specified in C++11.
- Arguably simple to set up an use (low development overhead)
- Clients can call functions synchronously and asynchronously - typically their choice
- Integrated support for promises and futures
Overview
There are server and client ports. An arbitrary number of client ports can be connected to one server port. Clients can call functions from the interface the server provides.
The interface a server provides is the interface of the class that is specified as template argument of tServerPort
(let's call it tService
). This can be just about any class - possibly from a framework-independent library. The parameter and return types of all methods that are to be called via RPC ports need to be binary serializable (see Tutorials/Advanced/Suitable Port Data Types).
The component providing the server interface typically instantiates a tService
object (say service), registers a tRPCInterfaceType<tService>
specifying all functions that can be called, and has a tServerPort<tService>
in its interface that is bound to service. That's all. All function calls from client ports will be called on service.
Clients simply add a tClient<tService>
to their interface. They can use this port for function calls - either synchronously (blocking) or asynchronously by providing a callback or by obtaining a future on the result. Calling functions without return type never blocks.
Notably, calling functions of servers in the same process is implemented very efficiently: The function is directly executed on the target object (in particular, no serialization is performed).
A very simple example can be found in the test program in finroc_plugins_rpc_ports
.
Simple Example: Integrated file server
Especially in distributed applications, it can be helpful if relevant files do not need to be present on all computing nodes. (Notably, one real library used e.g. an rsync-based workaround - not particularly robust - before RPC ports existed).
In this minimal example, we will create a simple file service (tFileService
) that a component (mFileService
) provides via an RPC port. After that, different ways of calling methods are demonstrated.
Server
First, we will create the tFileService
class:
class tFileService : public rpc_ports::tRPCInterface
{
public:
/*!
* \param file File that is requested
* \return Memory buffer with contents of requested file
*
* \throw Throws std::runtime_error if file does not exist
*/
rrlib::serialization::tMemoryBuffer GetFile(const std::string& file)
{
rrlib::serialization::tMemoryBuffer result;
rrlib::serialization::tOutputStream stream(result);
stream << ("Contents of " + file); // TODO: fill buffer properly
stream.Close();
return result;
}
};
Now, we need to register the type and its methods.
(If tFileService
is part of a Finroc library or program, this should be done in tFileService.cpp
. Otherwise, in a suitable .cpp
file of a Finroc library or program.)
static rpc_ports::tRPCInterfaceType<tFileService> cREGISTER_FILE_SERVICE("File Service", &tFileService::GetFile);
Finally, we create a tService
instance and a server port in a component:
class mFileService : public structure::tModule
{
//----------------------------------------------------------------------
// Ports (These are the only variables that may be declared public)
//----------------------------------------------------------------------
public:
/*! Port providing file service */
tServer<tFileService> file_service;
//----------------------------------------------------------------------
// Public methods and typedefs
//----------------------------------------------------------------------
public:
mFileService(core::tFrameworkElement *parent, const std::string &name = "FileService");
//----------------------------------------------------------------------
// Private fields and methods
//----------------------------------------------------------------------
private:
/*! File service instance */
tFileService file_service_instance;
virtual void Update() override;
};
In the constructor, we bind the file service instance to the port:
mFileService::mFileService(core::tFrameworkElement *parent, const std::string &name) :
tModule(parent, name, false),
file_service(file_service_instance),
file_service_instance()
{}
Client
In a client component, we add a tClient
port:
//----------------------------------------------------------------------
// Ports (These are the only variables that may be declared public)
//----------------------------------------------------------------------
public:
/*! Port accessing file service */
tClient<tFileService> file_service_client;
Now, we have three options obtaining a file from the server:
Synchronous (Blocking)
try
{
rrlib::serialization::tMemoryBuffer file_content =
file_service_client.CallSynchronous(std::chrono::seconds(2), &tFileService::GetFile, "test.xml");
FINROC_LOG_PRINT(DEBUG, "Received file of size: ", file_content.GetSize());
// TODO: do something
}
catch (const std::exception& e)
{
FINROC_LOG_PRINT(ERROR, "Error obtaining file from server: ", e);
}
The first parameter is the timeout. If the timeout is exceeded, an exception (rpc_ports::tRPCException
) in thrown. Also, if the port is not connected, an exception is thrown - and also if the function itself returns an exception.
Asynchronous (Callback)
For this mechanism, our client component (mFileServiceClient
) needs to implement the tResponseHandler
interface:
class mFileServiceClient :
public structure::tModule,
public rpc_ports::tResponseHandler<rrlib::serialization::tMemoryBuffer>
{
//----------------------------------------------------------------------
// Ports (These are the only variables that may be declared public)
//----------------------------------------------------------------------
public:
/*! Port providing file service */
rpc_ports::tClientPort<tFileService> file_service_client;
//----------------------------------------------------------------------
// Public methods and typedefs
//----------------------------------------------------------------------
public:
mFileServiceClient(core::tFrameworkElement *parent, const std::string &name = "FileServiceClient");
//----------------------------------------------------------------------
// Private fields and methods
//----------------------------------------------------------------------
private:
virtual void HandleException(rpc_ports::tFutureStatus exception_type) override
{
FINROC_LOG_PRINT(ERROR, "Error obtaining file from server: ", make_builder::GetEnumString(exception_type));
}
virtual void HandleResponse(rrlib::serialization::tMemoryBuffer call_result) override
{
FINROC_LOG_PRINT(DEBUG, "Received file of size: ", call_result.GetSize());
// TODO: do something
}
virtual void Update() override;
};
Now, the function can be called like this (notably, this will not block):
Asynchronous (Future)
We can also obtain a future from the method call. In order to check over multiple cycles whether the value has arrived, the future can be stored in variable of the component:
E.g. in the component's Update()
method, the function could be called like this:
To check, whether a return value has arrived, the future can be queried (unlike a std::future
).
if (file_content_future.Ready())
{
try
{
rrlib::serialization::tMemoryBuffer file_content = file_content_future.Get();
FINROC_LOG_PRINT(DEBUG, "Received file of size: ", file_content.GetSize());
// TODO: do something
}
catch (const std::exception& e)
{
FINROC_LOG_PRINT(ERROR, "Error obtaining file from server: ", e);
}
}
(Calling file_content_future.Get()
immediately would block until the return value has arrived.)
Example: Simulated Scene
Robot simulations are possibly another good candidate for RPC ports (e.g. rrlib::simvis3d::tScene
). Components may want to add or remove objects in the simulated environment.
Using RPC ports, this could also be achieved like this:
Server
-
Register the methods of
tScene
: -
Provide access to the scene using a port
class mDemoRobotSimulation : public simvis3d::mSimulation { //---------------------------------------------------------------------- // Ports (These are the only variables that may be declared public) //---------------------------------------------------------------------- public: /*! Port providing access to scene */ tServer<tScene> scene_access; ... }
-
Bind port to scene (new port whenever scene changes)
void mDemoRobotSimulation::OnStaticParameterChange() { tScene old_scene = scene; mSimulation::OnStaticParameterChange(); if (scene != old_scene) { // Creates new port for a new scene (possibly suboptimal, as connections are discarded) scene_access.ManagedDelete(); scene_access = tServer<tScene>("Scene Access", this, *scene); } }
Client
-
Create a client port:
-
Add an object:
Optimization: Using futures
Function calls will be executed by the thread of the client - or e.g. the TCP-Thread when called over a network. Thus, functions must always return quickly in order not to stall the calling thread (as it is typically supposed to execute other tasks as well).
Adding complex objects to a scene may require more time. Therefore, adding the object should be done by another (worker) thread, while the function immediately returns a future. This requires modifying the function's signature (in tScene
).
For functions that return a future by themselves, there is a special call for tClientPort
s:
rpc_ports::tFuture<bool> future = scene_client.NativeFutureCall(&tScene::Add, "something...", true);
This will obtain the future quickly. The future can be filled by another thread later.
Notably, futures also work over the network (in distributed applications).