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.

RPC Overview

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:

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:

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:

robot_service.py#
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:

robot_service.cxx#
#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:

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:

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:

Was this page helpful?

Back to Learn