Remote Procedure Call#
Introduction#
What you’ll learn
This module provides an introduction to the RPC communication pattern.
You will do the following:
Define a service interface with a few operations
Implement the service interface in a server application
Make remote calls from a client application
RPC (Remote Procedure Call) is a communication pattern suitable for use cases where an application needs to get a one-time snapshot of information—for example, to make a query into a database or retrieve configuration parameters. Other times, an application needs to ask a remote application to perform an action.
With RPC, a client and a server communicate using a functional interface. The client application calls functions on the interface (synchronously or asynchronously), and the server application implements the functions. Connext takes care of delivering the request and the reply to the right place.
In contrast with the publish-subscribe communication model, where publishers and subscribers don’t need to know about each other, RPC allows you to send a request to a service and receive a reply. In this example, you’ll build a simple service to simulate a robot that can walk to a given destination and report its speed. The client requests a robot to walk and receives a reply when it arrives at its destination. The client can also ask for the robot’s current speed.
How to complete this module#
To complete this module you’ll need the following:
20-30 minutes
A Connext installation (full installation or pip-based installation). See Get Started.
A text editor or IDE to write your code.
You can complete the module from scratch by copying and pasting the code provided in the sections below or you can get the full code from GitHub.
Cloning the GitHub repository
Clone the GitHub repository with the following command:
$ git clone --recurse-submodule https://github.com/rticommunity/rticonnextdds-examples.git
The code for this module is located in the tutorials/rpc
directory. See the README.md
files for additional instructions.
1. Define your service interface#
With this pattern, a service interface allows a client to make remote function calls into a service. A service interface can be defined in IDL (for C++) or in Python.
In a new directory, create a file called robot.py
:
import abc
import rti.types as idl
import rti.rpc as rpc
@idl.struct
class Coordinates:
x: int = 0
y: int = 0
@rpc.service
class RobotControl(abc.ABC):
@rpc.operation
async def walk_to(
self, destination: Coordinates, speed: idl.float32
) -> Coordinates:
pass
@rpc.operation
async def get_speed(self) -> idl.float32:
pass
A service interface such as RobotControl
is an abstract base class (ABC
)
decorated with the @rpc.service
decorator and with a set of async
abstract
methods decorated with the @rpc.operation
decorator.
IDL is a language for defining your data types and service interfaces. From the IDL definition, Connext generates the necessary the code for the target language.
In a new directory, create a file called robot.idl
:
struct Coordinates {
int64 x;
int64 y;
};
@service("DDS")
interface RobotControl {
Coordinates walk_to(Coordinates destination, float speed);
float get_speed();
};
To generate code from this IDL definition, run the following command:
$ <install dir>/bin/rtiddsgen -language c++11 -platform <platform_name> robot.idl
Replace <platform_name>
with the platform you are using (for example
x64Linux4gcc7.3.0
, x64Win64VS2017
, arm64Darwin20clang12.0
).
For example:
$ rtiddsgen -language c++11 -example x64Linux4gcc7.3.0 robot.idl
This command generates the type definition for your data type in the target language (C++), the support code to serialize it so it can be sent over the network, the client class, and a service stub, as well as the build files for a specific platform.
2. Implement the service interface#
In this step, you’ll implement the RobotControl
interface with the service logic.
First, import the Connext DDS and RPC modules and the types we defined in the previous step:
import rti.connextdds as dds
import rti.rpc as rpc
from robot import Coordinates, RobotControl
Then implement the the service logic in a new class, RobotControlExample
,
derived from the interface RobotControl
:
class RobotControlExample(RobotControl):
def __init__(self):
self.position = Coordinates(0, 0)
self.speed = 0.0
async def walk_to(self, destination: Coordinates, speed: idl.float32) -> Coordinates:
self.speed = speed
print(f"Robot walking at {self.speed}mph to {destination}")
self.position = destination
return self.position
def get_speed(self) -> idl.float32:
return self.speed
The implementation of each method can be asynchronous or synchronous.
To start receiving function calls into RobotControlExample
,
join a domain with a DomainParticipant
, and create an rpc.Service
with an instance of RobotControlExample
, the participant
, and a
service name:
participant = dds.DomainParticipant(domain_id=0)
robot_control = RobotControlExample()
service = rpc.Service(robot_control, participant, "MyRobotControl")
await service.run() # runs forever
While service
is running, it will receive calls from matching clients
(those with the same service name and compatible interfaces and QoS), call the
corresponding method in robot_control
, and send the result of the operation
back to the client that called it.
Put everything together in a file called robot_service.py
, with
an implementation of the walk_to
method that simulates the robot walking
as a sleep:
import asyncio
import rti.connextdds as dds
import rti.rpc as rpc
import rti.types as idl
from robot import Coordinates, RobotControl
class RobotControlExample(RobotControl):
def __init__(self):
self.position = Coordinates(0, 0)
self.speed = 0.0
async def walk_to(
self, destination: Coordinates, speed: idl.float32
) -> Coordinates:
if speed <= 0.0:
return self.position # return previously known position
self.speed = speed
print(f"Robot walking at {self.speed}mph to {destination}")
await asyncio.sleep(10.0 - 0.1 * self.speed)
self.speed = 0.0
self.position = destination
print(f"Robot arrived at {destination}")
return destination
def get_speed(self) -> idl.float32:
return self.speed
async def main():
participant = dds.DomainParticipant(domain_id=0)
robot_control = RobotControlExample()
service = rpc.Service(robot_control, participant, "MyRobotControl")
await service.run() # runs forever
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
First, include the interface and type definitions:
#include "robot.hpp"
Then implement the the service logic in a new class, RobotControlExample
,
derived from the interface RobotControl
:
class RobotControlExample : public RobotControl {
public:
Coordinates walk_to(const Coordinates &destination, float speed) override
{
speed_ = speed;
std::cout << "Robot walking at " << speed_ << "mph to "
<< destination << std::endl;
position_ = destination;
return position_;
}
float get_speed() override
{
return speed_;
}
private:
Coordinates position_;
float speed_ { 0.0 };
};
To start receiving function calls into RobotControlExample
,
join a domain with a DomainParticipant
, create a Service
with an instance
of RobotControlExample
, and run it in a Server
:
dds::domain::DomainParticipant participant(0);
dds::rpc::ServerParams server_params;
server_params.extensions().thread_pool_size(4);
dds::rpc::Server server(server_params);
dds::rpc::ServiceParams params(participant);
params.service_name("MyRobotControl");
auto robot_control = std::make_shared<RobotControlExample>();
RobotControlService service(robot_control, server, params);
server.run();
A Server
provides the thread pool to run one or more services. While
the service is running, it will receive calls from matching clients
(those with the same service name and compatible interfaces and QoS), call the
corresponding method in robot_control
, and send the result of the operation.
Put everything together in a file called robot_service.cxx
,
with an implementation of the walk_to
method that simulates the robot walking
as a sleep:
#include <iostream>
#include "robot.hpp"
class RobotControlExample : public RobotControl {
public:
Coordinates walk_to(const Coordinates &destination, float speed) override
{
if (speed <= 0.0) {
return position_;
}
speed_ = speed;
std::cout << "Robot walking at " << speed_ << "mph to " << destination
<< std::endl;
// Simulate walking to the destination
std::this_thread::sleep_for(std::chrono::milliseconds(
static_cast<int>(10000 - 100 * speed_)));
std::cout << "Robot arrived at " << destination << std::endl;
speed_ = 0.0;
position_ = destination;
return destination;
}
float get_speed() override
{
return speed_;
}
private:
Coordinates position_;
float speed_ { 0.0 };
};
int main(int argc, char *argv[])
{
dds::domain::DomainParticipant participant(0);
dds::rpc::ServerParams server_params;
server_params.extensions().thread_pool_size(4);
dds::rpc::Server server(server_params);
dds::rpc::ServiceParams params(participant);
params.service_name("MyRobotControl");
auto robot_control = std::make_shared<RobotControlExample>();
RobotControlService service(robot_control, server, params);
std::cout << "RobotControlService running... " << std::endl;
server.run();
return 0;
}
3. Create a client application#
In this step, you’ll create a client application that makes remote calls to the service.
Define the client class as follows:
class RobotControlClient(RobotControl, rpc.ClientBase):
pass
The base class rpc.ClientBase
provides the implementation of all the
methods decorated with @rpc.operation
. The RobotControlClient
class
doesn’t have any implementation of its own.
Next, create a DomainParticipant on the same domain as the service, and an instance of the client class with the same service name:
participant = dds.DomainParticipant(domain_id=0)
client = RobotControlClient(participant, "MyRobotControl")
You can now make calls to the service:
result = await client.walk_to(destination=Coordinates(x=5, y=10), speed=30.0)
print(result)
The call to walk_to
will send a request to the server and return a coroutine
that will complete when the reply is received, providing the return value.
Put everything together in a file called robot_client.py
:
import asyncio
from time import sleep
import rti.connextdds as dds
import rti.rpc as rpc
from robot import Coordinates, RobotControl
class RobotControlClient(RobotControl, rpc.ClientBase):
pass
async def main():
participant = dds.DomainParticipant(domain_id=0)
client = RobotControlClient(participant, "MyRobotControl")
sleep(2)
print("Calling walk_to...")
result = await client.walk_to(destination=Coordinates(x=5, y=10), speed=30.0)
print(result)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Create a DomainParticipant on the same domain as the service, and an instance of the client class with the same service name:
dds::domain::DomainParticipant participant(0);
dds::rpc::ClientParams client_params(participant);
client_params.service_name("MyRobotControl");
RobotControlClient client(client_params);
You can now make synchronous and asynchronous calls to the service:
Coordinates result = client.walk_to(Coordinates(10, 20), 40.0);
std::future<Coordinates> future_result =
client.walk_to_async(Coordinates(5, 10), 30.0);
Put everything together in a file called robot_client.cxx
:
#include <iostream>
#include "robot.hpp"
int main(int argc, char *argv[])
{
dds::domain::DomainParticipant participant(0);
dds::rpc::ClientParams client_params(participant);
client_params.service_name("MyRobotControl");
RobotControlClient client(client_params);
client.wait_for_service();
std::cout << "Calling walk_to..." << std::endl;
std::future<Coordinates> result =
client.walk_to_async(Coordinates(5, 10), 30.0);
std::cout << result.get() << std::endl;
return 0;
}
4. Run the client and server#
In this step, you’ll run the service and the client applications.
Run the server on one terminal:
$ python robot_service.py
Run the client on another terminal:
$ python robot_client.py
First, build the client and service applications using the build
files generated by rtiddsgen
for your platform.
Build the applications using the makefile_robot_<platform>
file. For example:
$ make -f makefile_robot_x64Linux4gcc7.3.0
Open robot-<platform>.sln
in Visual Studio and compile the
applications.
Build the applications using the makefile_robot_<platform>
file. For example:
$ make -f makefile_robot_arm64Darwin20clang12.0
Run the server on one terminal:
$ ./objs/<platform>/robot_service
Run the client on another terminal:
$ ./objs/<platform>/robot_client
Troubleshooting
In case of errors building or running your application, make sure you have set up your environment and license file.
For instructions, go to Get Started, select your platform and installation method, then find the section Run a Hello World.
You should see the following output in the server terminal:
Robot walking at 30.0mph to Coordinates(x=5, y=10)
Robot arrived at Coordinates(x=5, y=10)
And the following output in the client terminal:
Calling walk_to...
Coordinates(x=5, y=10)
Learn More#
This module introduced the basic concepts of the RPC communication pattern with a simple example. We defined our types and interface, wrote an implementation of the service, and created a client application to call the service operations.
You can combine the RPC and publish-subscribe patterns in the same application. For example, you could have the service publish an update on a “RobotStatus” topic containing the Coordinates after each walk so other subscribers in the system that don’t directly manipulate the robot receive these updates asynchronously. See the Publish-Subscribe module for more information.
Connext also supports a Request-Reply communication pattern in which client and server communicate with request messages and reply messages instead of function calls. This pattern provides additional features such as response streaming (multiple replies for a single request) or delayed responses (the server has full control on when to reply).
Next Steps
Related modules:
Additional code examples for RPC and Request-Reply:
Reference documentation:
API Reference, Modern C++, Python, C#, Java, C