
Posted on • Originally published atpikotutorial.com
Trying ROS2: client/server within a single container
Welcome to the nextpikoTutorial !
In the first article of Trying ROS2 series I showed how to implement and examine a basic publisher and subscriber within a single Docker container. Now it's time for checking out how do you implement and set up a client/server connection within a single Docker container. In this pikoTutorial you'll read about:
- setting up a typical ROS server and client written in C++ using ROS 2
- defining a custom service with .srv file
- building everything as a Docker image
- running both nodes within a single container using a launch file which now can be written in Python
Project structure
Let's first define a project structure to understand what am I aiming at:
project├── launch ├── run_server_client.py├── src ├── cpp_client ├── CMakeLists.txt ├── main.cpp ├── package.xml ├── cpp_server ├── CMakeLists.txt ├── main.cpp ├── package.xml ├── interfaces ├── srv ├── GetDataSize.srv ├── CMakeLists.txt ├── package.xml├── Dockerfile├── ros_entrypoint.sh
Create packages
I will put server, client and the service definitions into separate ROS packages, so let's first create them using the following commands:
ros2 pkg create--build-type ament_cmake cpp_clientros2 pkg create--build-type ament_cmake cpp_serverros2 pkg create--build-type ament_cmake interfaces
Defining a custom service type
To define a custom service type calledGetDataSize
, I create aGetDataSize.srv file insrc/interfaces/srv folder. Remember that services definitionsmust be placed insrv folder and their namesmust be camel case. Otherwise, you'll get the following build error:
rosidl_adapter.parser.InvalidResourceName: 'get_data_size_Request' is aninvalid message name. It should have the pattern '^[A-Z][A-Za-z0-9]*$'
Thesrc/interfaces/srv/GetDataSize.srv file looks like below:
string dataset_name---int32 dataset_size
To generate a C++ code out of such service definition, we need to userosidl_generate_interfaces
function insrc/interfaces/CMakeLists.txt file:
cmake_minimum_required(VERSION 3.8)project(interfaces)find_package(ament_cmake REQUIRED)find_package(rosidl_default_generators REQUIRED)rosidl_generate_interfaces(${PROJECT_NAME}"srv/GetDataSize.msg")ament_package()
And add proper entries to thesrc/interfaces/package.xml file:
<buildtool_depend>rosidl_default_generators</buildtool_depend><exec_depend>rosidl_default_runtime</exec_depend><member_of_group>rosidl_interface_packages</member_of_group>
Writing a C++ ROS2 client
Below you can find the implementation of the C++ client using ROS2. Below is the content of thesrc/cpp_client/main.cpp file.
// ROS client library for C++ header#include"rclcpp/rclcpp.hpp"// GetDataSize service header#include"interfaces/srv/get_data_size.hpp"// aliasesusingGetDataSizeService=interfaces::srv::GetDataSize;usingGetDataSizeRequest=GetDataSizeService::Request;usingGetDataSizeRequestPtr=std::shared_ptr<GetDataSizeService::Request>;usingGetDataSizeResponsePtr=std::shared_ptr<GetDataSizeService::Response>;usingnamespacestd::chrono_literals;// constantsstaticconstexprconstchar*kNodeName{"cpp_client"};staticconstexprconstchar*kServiceName{"GetDataSize"};// client classclassCppClient:publicrclcpp::Node{public:CppClient():Node(kNodeName),client_{create_client<GetDataSizeService>(kServiceName)}{// wait for the service in 1 second intervalswhile(!client_->wait_for_service(1s)){std::cout<<"[CLIENT] Waiting for service..."<<std::endl;}std::cout<<"[CLIENT] GetDataSizeService service found!"<<std::endl;}// function responsible for sending requestvoidSendRequest(conststd::stringdataset_name){std::cout<<"[CLIENT] Requesting size of dataset "<<dataset_name<<std::endl;// create a requestGetDataSizeRequestPtrrequest=std::make_shared<GetDataSizeRequest>();request->dataset_name=dataset_name;// obtain the responseautoresponse_future=client_->async_send_request(request);constautoresult=rclcpp::spin_until_future_complete(this->get_node_base_interface(),response_future);// check validity of the resultif(result!=rclcpp::FutureReturnCode::SUCCESS){std::cout<<"[CLIENT] Request sending failed!"<<std::endl;client_->remove_pending_request(response_future);return;}// read the response from the future objectconstGetDataSizeResponsePtrresponse=response_future.get();// print the response payloadstd::cout<<"[CLIENT] Response received: "<<response->dataset_size<<std::endl;}private:rclcpp::Client<GetDataSizeService>::SharedPtrclient_;};// main functionintmain(intargc,char*argv[]){rclcpp::init(argc,argv);std::shared_ptr<CppClient>node=std::make_shared<CppClient>();// send 3 different requestsnode->SendRequest("Lakes");node->SendRequest("Oceans");node->SendRequest("Rivers");rclcpp::shutdown();}
I keptsrc/cpp_client/CMakeLists.txt file in its most minimalistic form, so it contains the executable depending on our previously definedinterfaces package:
cmake_minimum_required(VERSION 3.5)project(cpp_client)find_package(ament_cmake REQUIRED)find_package(rclcpp REQUIRED)find_package(interfaces REQUIRED)add_executable(${PROJECT_NAME} main.cpp)ament_target_dependencies(${PROJECT_NAME} rclcpp interfaces)install(TARGETS${PROJECT_NAME} DESTINATION lib/${PROJECT_NAME})ament_package()
We must also remember about addinginterfaces dependency to thesrc/cpp_client/package.xml file. Otherwise, the dependency build order will not be preserved andcolcon
may try to build e.g.cpp_client package beforeinterfaces package (on which cpp_client depends) has been built.
<depend>interfaces</depend>
Writing a C++ ROS2 server
Now it's time to createsrc/cpp_server/main.cpp file:
// ROS client library for C++ header#include"rclcpp/rclcpp.hpp"#include<iomanip>// GetDataSize service header#include"interfaces/srv/get_data_size.hpp"// aliasesusingGetDataSizeService=interfaces::srv::GetDataSize;usingRequestHeaderPtr=std::shared_ptr<rmw_request_id_t>;usingGetDataSizeRequestPtr=std::shared_ptr<GetDataSizeService::Request>;usingGetDataSizeResponsePtr=std::shared_ptr<GetDataSizeService::Response>;// constantsstaticconstexprconstchar*kNodeName{"cpp_server"};staticconstexprconstchar*kServiceName{"GetDataSize"};// overloaded stream operator for easy request header printingstd::ostream&operator<<(std::ostream&os,constRequestHeaderPtr&request_header){os<<"Request number "<<request_header->sequence_number<<" from ";std::copy(std::begin(request_header->writer_guid),std::end(request_header->writer_guid),std::ostream_iterator<int>(os," "));returnos;}// mock datastaticconststd::unordered_map<std::string,std::vector<int>>kData={{"Lakes",{1,2,3}},{"Oceans",{1,2,3,4,5}},{"Rivers",{1,2,3,4,5,6,7}},};// server classclassCppServer:publicrclcpp::Node{public:CppServer():Node(kNodeName){// create a service and provide a callback which will be called upon// receiving a requestservice_=create_service<GetDataSizeService>(kServiceName,[this](RequestHeaderPtrrequest_header,GetDataSizeRequestPtrrequest,GetDataSizeResponsePtrresponse){HandleGetDataSizeRequest(request_header,request,response);});std::cout<<"[SERVER] Server up and running"<<std::endl;}private:voidHandleGetDataSizeRequest(RequestHeaderPtrrequest_header,GetDataSizeRequestPtrrequest,GetDataSizeResponsePtrresponse){std::cout<<"[SERVER] Received: "<<request_header<<std::endl;// populate the response with the proper data sizeresponse->dataset_size=kData.at(request->dataset_name).size();}rclcpp::Service<GetDataSizeService>::SharedPtrservice_;};// main functionintmain(intargc,char*argv[]){rclcpp::init(argc,argv);std::shared_ptr<CppServer>node=std::make_shared<CppServer>();rclcpp::spin(node);rclcpp::shutdown();}
The implementations ofsrc/cpp_server/CMakeLists.txt andsrc/cpp_server/package.xml files are analogous to cpp_client, so I'll skip writing about them here.
Writing a ROS2 launch file
After we have client and server ready to run, it's time to define a Python launch filelaunch/run_server_client.py :
fromlaunchimportLaunchDescriptionfromlaunch_ros.actionsimportNodedefgenerate_launch_description():returnLaunchDescription([Node(package='cpp_server',executable='cpp_server',output='screen',),Node(package='cpp_client',executable='cpp_client',output='screen',),])
This file can be provided toros2 launch
command, but because we will run it inside the docker container environment which will require sourcing the ROS underlay and the workspace overlay anyway, let's put all of these commands to aros_entrypoint.sh file:
#!/bin/bashset-esource /opt/ros/rolling/setup.bashsource /app/src/install/setup.bashros2 launch launch/run_server_client.py
If you want to learn more about the ROS underlays and overlays, you can read more about them inthis pikoTutorial.
Alternatively, if you don't want to useros2 launch
, this script could look like this:
#!/bin/bashset-esource /opt/ros/rolling/setup.bashsource /app/src/install/setup.bashros2 run cpp_server cpp_server &ros2 run cpp_client cpp_client
Writing a Dockerfile
To create a Docker image, I'll useosrf/ros:rolling-desktop
as a base image. It's because on my Ubuntu 24.04 I use Rolling Ridley ROS 2 distro, so I want my container environment to be the same as my local one.
# use ROS Rolling Ridley image as the baseFROM osrf/ros:rolling-desktop# specify bash as shell to use source commandSHELL ["/bin/bash", "-c"]# change working directory to /appWORKDIR /app# copy all the ROS workspace filesCOPY src src/COPY launch launch/COPY ros_entrypoint.sh /app# build workspaceRUNcdsrc&&\source /opt/ros/rolling/setup.bash&&\ colcon build# assign permission to the entrypoint scriptRUNchmod +x /app/ros_entrypoint.sh# run entrypoint scriptENTRYPOINT ["/app/ros_entrypoint.sh"]
Putting all together
At this point we have all the parts, but before we start working with the container, let's check locally if the workspace build at all with the following command:
source /opt/ros/rolling/setup.bashcolcon build
If everything succeeded, you should see the output similar to:
Starting >>> interfacesFinished <<< interfaces [6.45s] Starting >>> cpp_clientStarting >>> cpp_serverFinished <<< cpp_server [15.2s] Finished <<< cpp_client [15.4s] Summary: 3 packages finished [22.2s]
Now as we know that ROS workspace can be successfully built, it's time to build a docker image:
docker build-t ros_server_client.
Note for beginners: if you're struggling with missing Docker permissions, checkthis pikoTutorial out.
To run the container basing on this image, run:
docker run--rm--name server_client ros_server_client
Examining nodes' behavior inside the container
After running the container, you will see the following logs:
[SERVER] Server up and running[CLIENT] GetDataSizeService service found![CLIENT] Requesting size of dataset Lakes[SERVER] Received: Request number 1 from 1 15 235 125 64 0 173 57 0 0 0 0 0 0 20 4 [CLIENT] Response received: 3[CLIENT] Requesting size of dataset Oceans[SERVER] Received: Request number 2 from 1 15 235 125 64 0 173 57 0 0 0 0 0 0 20 4 [CLIENT] Response received: 5[CLIENT] Requesting size of dataset Rivers[SERVER] Received: Request number 3 from 1 15 235 125 64 0 173 57 0 0 0 0 0 0 20 4 [CLIENT] Response received: 7
You may want to examine further what's going on in the container, so let's go inside our container using:
docker containerexec-it server_client /bin/bash
The first thing to check is whether our serviceGetDataSize is on the list of active services:
ros2 service list
This should give the output in which one of the entries represents our service:
/GetDataSize
Let's then check the properties of that service:
ros2 service info /GetDataSize
As expected, we see that there is 1 server and 0 clients because after sending 3 requests, the client implemented above exits:
Type: interfaces/srv/GetDataSizeClients count: 0Services count: 1
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse