6.8. Using Service Wrappers from C++
This chapter describes in an extended example how to use code-generation for the C++ service provider class to create a service provider with less boiler plate. It picks up on the tutorial example for C++ and assumes that you have thoroughly read it and remember its content well, so that the amount of repetitions can be kept to a minimum here.
Warning
The auto-generated service wrapper code that is documented here leads to a reduction on the amount of code that the programmer has to write.
However, there is a significant price for that: The surface of the API becomes much larger, and there are more moving parts which need to be described and can interact. Also, the documentation tools are not tailored so much to auto-generated code, so that the API becomes a little awkward to describe. Therefore, if you just want to write a single service provicer within a standard scheme, the API presented here is probably not the quickest way to get to a working result, and you should reconsider using the API described in C++ Tutorial.
6.8.1. Implementing Service Calls using the wrapped API
We have seen that ln_generate will produce C++ code with struct definitions which allow to access the data buffers of LN messages like a struct.
In addition, ln_generate can also generate a wrapper class for a service provider, which allows to define such a service provider with less extra code. This requires a specialization of the base interface which assumes a common general structure which provides an LN service handler in a service provider class. It allows the class instance to hold data between calls. and might wrap a low-level hardware driver, for example. The resulting interface is described here.
For implementing LN service communication using the “wrapped API”, clients and service providers need to define functions which can invoke and handle requests. In C++, these are typically methods of class instances.
To define them, they need to use class and type definitions which are automatically generated from the message definitions for a service. ln_generate takes the message definitions, and generates a C++ class with method definitions that accept the structs which represent a message as parameters.
6.8.1.1. Auto-generated Service Provider Class
In the case of the service provider, the user needs to define a new class that is derived from an auto-generated class. The name depends on the message definition name. In our case, the name of the parent class is
elevator::request::elevator_call_base. Its name is composed from the name of the message definition of the service message (whose first part is usually similar to the name of the service, but does not need to - messages can also be declared as general types).The name is composed analogously as
elevator::request::elevator_call_twhich we saw above, as follows:each slash (“/”) in the message definition name is replaced by a C++ name space separator (“::”).
at the end of the name, the string “_base” is appended.
As the service call struct, this class is generated using ln_generate.
The initialization of the derived service provider class has to call the inherited method
register_elevator_call(), which has two parameters: A pointer to a client instance, created withln::client(), and the name of the service which the class wants to implement. In our case, the service name is"elevator03.prompt", because we will later use elevator number 3 from our test lab - as you can see, the service name can identify a specific piece of hardware, for example. The dot means that the “prompt” service is part of the “elevator03” name space. This creates a new client forelevator03.prompt, and adds a methodrequest_elevator()to the derived class which takes the floor number as a parameter and forwards the call to the service provider. The result of the service call is the return value of the registered method. (We have chosen the service nameelevator03.promptto just show by example that topic and service names are not required to be the same as the names of message definitions which are used by them - as explained in section Message Definition Names, they are different things which can have different names).
The third parameter to
register_elevator_call()is optional. It is the name of the service group, which is set to"default group"here. What happens here is that processes can run more than one service handler, and these handlers can be organized in service groups, which are processed by the same thread, while different service groups can be assigned to be processed in independent threads. If there are no specific requirements, putting all service handles in the same group with a name like “default” group is the easiest option, because in that case, all service handlers run in she same main thread. (In terms of correctness, this is also the safest option, because this avoids the risk of bugs such as race conditions, which are usually quite difficult to eliminate.)The name of the service message definition, “elevator_call”, is used as the auto-generated name of the method declaration which implements the call. It is declared as a virtual method of the base class
elevator::request::elevator_call_base, which we introduced just above.This method has to be implemented, or, using C++ lingo, “defined” by the service provider implementation code. It receives two call parameters: First, a handle of type
ln::service_request, an object that can be called when the the request is finished. And second, a parameter with the auto-generated struct type providing the message data which we already mentioned, with the nameelevator::request::elevator_call_t. It represents the request parameters for a service call, and holds buffer space for the fixed-size part response. (The buffer space for any dynamically-sized part needs to be managed by the caller ofrespond().)When the service is actually called, our method
on_elevator_call()is called with the request parameters in its arguments, and will receive and return request parameters in a structure that was generated by ln_generate.When the call is finished, the service provider code signals that by calling the method
ln::service_request::respond(), which returns the call. Only whenrespond()is called, the resulting response buffer is copied and transmitted back to the caller of the service. This means that any heap-allocated data that it refers to needs to be kept alive until therespond()call has returned.
We will illustrate this structure now by going step by step through the concrete example of the elevator service.
After that next section that is aimed to make the interface tangible, we will continue with a a detailed summary section. This summary will return to the LN client API methods and discuss in a bit more detail their properties and interface, and also link to the more formal and detailed description in the C++ API Reference chapter.
6.8.1.2. The Service Handler in the Controller
In section Implementing Service Calls of the C++ tutorial chapter (which we assume you have read), we implemented the service call. As the counterpart, the controller process also needs first some code which provides the service and registers it with LN.
This is done by deriving a class from
elevator::request::elevator_call::base, registering a class method
for the service, and then running the service from the
ElevatorServer::run() function which we have yet to fill out, like
so:
1#include <ln/ln.h>
2#include <iostream>
3#include <cstdint>
4#include "elevator_constants.h"
5
6#include "ln_messages.h"
7
8
9using std::cout;
10using namespace std::chrono_literals;
11
12
13
14class ElevatorServer :
15 public elevator::request::elevator_call_base
16{
17
18 using t_sensors = elevator_sensors_floor_count_t;
19 using t_motor_control = elevator_actors_motor_control_t;
20
21 ln::client clnt;
22 ln::inport* sensor_port;
23 ln::outport* actor_port;
24
25 t_motor_control motor_command;
26 t_sensors sensor_data;
27public:
28 ElevatorServer() :
29 clnt("elevator controller")
30 {
31
32 sensor_port = clnt.subscribe("elevator03.sensors",
33 "elevator/sensors/floor_count");
34
35 actor_port = clnt.publish("elevator03.actors",
36 "elevator/actors/motor_control");
37
38
39 register_elevator_call(&clnt, "elevator03.prompt");
40
41 cout << "getting hardware state...\n";
42
43 receive_current_sensor_data();
44
45 cout << "floor number is: " << sensor_data.floor_number << '\n';
46 clnt.handle_service_group_in_thread_pool(NULL, "main_pool");
47 cout << "service thread started!\n";
48 }
49
50
51 int run()
52 {
53 cout << "ready to receive service calls\n";
54 while (1) {
55 /// processing...
56 double time_out = -1; // means blocking
57 clnt.wait_and_handle_service_group_requests(NULL, time_out);
58 }
59 }
60
61private:
62
63 int on_elevator_call(ln::service_request& req,
64 elevator::request::elevator_call_t& svc) override
65 {
66 // .... to be implemented
67 }
68
69
70};
71
72
73int main(int argc, char* argv[])
74{
75 ElevatorServer elevator_server;
76 return elevator_server.run();
77}
Here, in line 39, the class registers that it can handle
the calls for the service messages of type elevator03.prompt,
by using the method register_elevator_call() inherited from
elevator::request::elevator_call_base, which is an
class that was auto-generated by ln_generate.
The handling of services requests, which happens in an own thread, is
started in the run() method in line 75-76, which in turn is called
from the main() function of the program.
6.8.1.3. Full example code
The full code example is as follows - it can be found in the folder documentation/examples/guide/example_elevator_wrapped_api/:
1#include <ln/ln.h>
2#include <cstdint>
3#include <chrono>
4#include "elevator_constants.h"
5#include <iostream>
6
7// ln_messages.h is generated by the CMake generate_ln_message_headers()
8// helper or by an equivalent Makefile rule.
9
10#include "ln_messages.h"
11#include <sstream>
12
13
14
15
16//////////////////////////////////////////////////////////////////
17
18// copied from https://wiki.robotic.dlr.de/Links_and_nodes/c%2B%2B_examples
19
20using namespace std::chrono_literals;
21using std::cout;
22
23
24
25class ElevatorServer :
26 public elevator::request::elevator_call_base
27{
28
29 using t_sensors = elevator_sensors_floor_count_t;
30 using t_motor_control = elevator_actors_motor_control_t;
31
32 ln::client clnt;
33 ln::inport* sensor_port;
34 ln::outport* actor_port;
35
36 t_motor_control motor_command;
37 t_sensors sensor_data;
38public:
39 ElevatorServer() :
40 clnt("elevator controller")
41 {
42
43 sensor_port = clnt.subscribe("elevator03.sensors",
44 "elevator/sensors/floor_count");
45
46 actor_port = clnt.publish("elevator03.actors",
47 "elevator/actors/motor_control");
48
49
50 register_elevator_call(&clnt, "elevator03.prompt");
51
52 cout << "getting hardware state...\n";
53
54 receive_current_sensor_data();
55
56 cout << "floor number is: " << sensor_data.floor_number << '\n';
57 }
58
59 int run()
60 {
61 cout << "ready to receive service calls\n";
62 while (1) {
63 /// processing...
64 double time_out = -1; // means blocking
65 clnt.wait_and_handle_service_group_requests(NULL, time_out);
66 }
67 }
68
69private:
70 // Try to move the elevator to the requested floor, by sending
71 // commands to the hardware.
72 // It returns the floor which the elevator has actually arrived at.
73 // Input parameter is the requested floor.
74 //
75 // Attention: This function can throw an exception of the type
76 // EelvatorException, with an error code as documented in
77 // elevator_constants.h and
78 // msg_defs/elevator/request/elevator_call .
79
80 int32_t request_floor(int32_t requested_floor)
81 {
82 using MovDir = E_MovementDirection;
83 bool fire_alarm_op = false;
84
85 while (true) {
86 cout << "working on request to go to floor "
87 << requested_floor
88 << ", current floor is: "
89 << sensor_data.floor_number
90 << ", movement direction: "
91 << static_cast<int>(sensor_data.movement_direction)
92 << '\n';
93
94 // wait for elevator top stop
95 while(sensor_data.movement_direction != static_cast<int8_t>(MovDir::STOP)) {
96 cout << "waiting for hardware state update..\n";
97 receive_current_sensor_data(0.5);
98
99 if (sensor_data.smoke_detected != 0) {
100 fire_alarm_op = true;
101 // go to ground floor
102 requested_floor = 0;
103 }
104 }
105
106 cout << "elevator is stopped\n";
107 if (requested_floor == sensor_data.floor_number) {
108 cout << "we are at the right floor, number "
109 << requested_floor << '\n';
110
111 if (fire_alarm_op) {
112 throw ElevatorFireAlarmException();
113 }
114 break;
115 }
116 MovDir command;
117 if (sensor_data.floor_number < requested_floor) {
118 command = MovDir::UP;
119 } else {
120 command = MovDir::DOWN;
121 }
122 cout << "we need to move into direction "
123 << static_cast<int>(command)
124 << ", sending command\n";
125 send_hw_command(static_cast<int32_t>(command));
126 while(sensor_data.movement_direction == static_cast<int8_t>(MovDir::STOP)) {
127 cout << "movement direction is "
128 << static_cast<int>(sensor_data.movement_direction)
129 << ", waiting for elevator to move..\n";
130 receive_current_sensor_data(0.5);
131 }
132
133 }
134 return sensor_data.floor_number;
135 }
136
137 int on_elevator_call(ln::service_request& req,
138 elevator::request::elevator_call_t& svc) override
139 {
140
141 const int MAX_FLOOR = 50;
142 const int MIN_FLOOR = -3;
143
144 cout << "received request to go to floor "
145 << svc.req.requested_floor
146 << ", current floor is: "
147 << sensor_data.floor_number << '\n';
148
149 try {
150
151 if (svc.req.requested_floor < MIN_FLOOR) {
152 throw ElevatorFloorNumberTooLowError();
153
154 }
155
156 if (svc.req.requested_floor > MAX_FLOOR) {
157 throw ElevatorFloorNumberTooHighError();
158 }
159
160 const int32_t arrived_floor = request_floor(svc.req.requested_floor);
161
162 svc.resp.arrived_floor = arrived_floor;
163 svc.resp.error_code = 0;
164 svc.resp.error_message = (char*)"";
165 svc.resp.error_message_len = 0;
166 req.respond();
167
168 } catch (ElevatorException &e) {
169
170 svc.resp.error_code = static_cast<uint32_t>(e.error_code);
171 // here, we format the message nicely so that python can parse it
172 // (because python has no other access to the error code
173 // if an exception is thrown).
174 svc.resp.error_message = (char*) e.what();
175 svc.resp.error_message_len = strlen(svc.resp.error_message);
176 svc.resp.arrived_floor = sensor_data.floor_number;
177 req.respond();
178 }
179
180 cout << "request finished!\n";
181 return 0;
182 }
183
184
185
186 void receive_current_sensor_data(double time_out=1.0)
187 {
188 cout << "getting state\n";
189 if (! sensor_port->read(&sensor_data, time_out)) {
190 cout << "Warning: no sensor data was received!\n";
191 } else {
192 cout << "getting state ... ok\n";
193 }
194 }
195
196 void send_hw_command(int32_t command)
197 {
198
199 motor_command.move_command = command;
200 actor_port->write(&motor_command);
201 }
202
203
204
205};
206
207
208int main(int argc, char* argv[])
209{
210 ElevatorServer elevator_server;
211 return elevator_server.run();
212}
6.8.2. Wrapped API for using LN Services from C++
6.8.2.1. Recapitulation of the above Example
LN services allow to call functions and methods that run in other processes or on other nodes. For using them in C++, we need specific type and (optionally) class definitions.
For exchanging request and response, both service clients and service
providers use the same data structure defined by the service message
definition. It is turned into C++ code by ln_generate, and
the name of the generated type depends on the message definition
name. In our case, the name of the service is “elevator03.prompt”, and
the name of the service message definition is
elevator/request/elevator_call.
When we run ln_generate on that message definition, it
generates a struct definition with the type name
elevator::request::elevator_call_t, as well as a base class
definition called elevator::request::elevator_call_base. The
struct is the data buffer structure which is used by both sides to
communicate. It has two members, one called req (for “request”)
and one resp member (for “response”).
The base class contains the extra auto-generated code that we use in the “wrapped” API. In the following, we will use names in all-caps to refer abstractly to the auto-generated type and method names. In the C++ API Reference part, they are shown and explained with these names.
For example, the name elevator::request::elevator_call_t will be
referred as
MESSAGE_DEF_NAMESPACE::SVMDEF_NAME_t.
We will use these all-caps names to link into the corresponding part
of the C++ API reference.
Note
A table of these names is provided in the reference part.
The service client calls the service als explained in Implementing Service Calls. The service provider receives the data and invokes a method that is configured to handle the call; it does the requested processing, and writes the result back into the response buffer. Then the result is sent back.
For the initialization and wiring of the service, the service provider
has to initialize an ln::client() object at the start of
the processing. After this, it needs to register and handle the
service.
See also
For the request buffer type elevator::request::elevator_call_t, see
MESSAGE_DEF_NAMESPACE::MDEF_NAME_t, as well as its member elements
MESSAGE_DEF_NAMESPACE::SVMDEF_NAME::req
and MESSAGE_DEF_NAMESPACE::SVMDEF_NAME::resp in the reference.
6.8.2.2. Elements used in the Service Provider
For the service provider, the approach can be summarized as follows:
The service provider implements a class which is derived from a base class named
elevator::request::elevator_call_base. This class is auto-generated by ln_generate.It has to bind to the service call name that it implements combined with a message definition name. This is done using a method of that generated base class, whose name is derived from the last part of the name of the message definition (it’s “base name”).
then, the service provider needs to implement a virtual method called
elevator::request::elevator_call_base::on_elevator_call(), which is declared by the above base class.This method takes two parameters. One is a struct with the request data buffer of the type
elevator::request::elevator_call_t, which we already explained above.And the other parameter is an object instance of the type
ln::service_request.That class has a method with the name
ln::service_request::respond().The service provider class first needs to process the request (using data from the request data buffer), fill out the response data structure as needed, and finally call the
ln::service_request::respond()method. This finishes the service call, and returns the response data to the service client.
6.8.2.2.1. elevator_call_base as base class
As we have seen in the code example for the service provider, the provider needs to define
a class which is derived from the auto-generated class
elevator::request::elevator_call_base
As a member of the new subclass, it needs to initialize an LN client
instance, using the ln::client() constructor, which
we already did describe above. In
our example, this client instance is assigned to the clnt member
variable (the name is arbitrary).
In our example, this looks like this:
class ElevatorServer :
public elevator::request::elevator_call_base
{
private:
ln::client clnt;
public:
ElevatorServer() :
clnt("elevator controller")
// ...
}
Here, clnt is the LN client instance, and its constructor
parameter "elevator controller" is a suggested client identifier
that the LN manager can give to the instance in cases where the client
is not started by the manager itself.
See also
For elevator::request::elevator_call_base, see MESSAGE_DEF_NAMESPACE::SVMDEF_NAME_base in the reference.
6.8.2.2.2. register_elevator_call: Service provider registration
With that client instance, it needs to call a registration method
named
MESSAGE_DEF_NAMESPACE::SVMDEF_NAME_base::register_SVMDEF_NAME()
like this:
register_elevator_call(&clnt, "elevator03.prompt");
Here, &clnt is the address of the client instance member that we
created before, and “elevator03.prompt” is the name of the service
which we are going to provide. So, the class instance tells the LN
system that it is going to provide the service under that name.
Note
In difference to the Python API, the name of the service is not split into a prefix and a suffix here. Instead, it uses a dotted name with dots separating name spaces for service names.
As in the case of topics, one service provider can respond to more than one service name, and these service call names can be bound to different message types.
See also
For the registration function, see MESSAGE_DEF_NAMESPACE::SVMDEF_NAME_base::register_SVMDEF_NAME() in the reference.
6.8.2.2.3. on_elevator_call(): service handler definition and implementation
Differently from Python, when we use the base class generated by
ln_generate, the service handler class does not need to
register the name of the service handler method - this happens
implicitly: When we derive the handler class from
elevator::request::elevator_call_base, this implicitly
defines that we have to implement a code-generated virtual method
MESSAGE_DEF_NAMESPACE::SVMDEF_NAME_base::on_SVMDEF_NAME(), in our case
on_elevator_call(), which has this pre-defined signature:
int on_elevator_call(ln::service_request& req,
elevator::request::elevator_call_t& svc) override
Here, it connects the name of a method of the class, that the user defines, (here, “request_elevator”) with the name of a service message definition.
Tip
The “override” keyword is a safety feature that induces the C++ compiler to check that the method signature matches in fact a virtual method of the base class.
We have to implement the method in the derived class ourselves. The method will be called with the input or “request” parameters of the message definition, and it will return a dictionary of objects which will be the return parameters.
When the method on_elevator_call() is finished, it needs to call
the method ln::service_request::respond() in order to
actually transfer the response to the caller. Any transfer error will
raise an exception here. When respond() returns, the provider can
be sure that the response has arrived at the caller, and that both
processes have reached the corresponding point of their execution (in
other words, the respond() method provides synchronization).
In our example, the corresponding lines of the implementation are:
int on_elevator_call(ln::service_request& req,
elevator::request::elevator_call_t& svc) override
{
// ....
svc.resp.arrived_floor = arrived_floor;
svc.resp.error_code = 0;
svc.resp.error_message = (char*)"";
svc.resp.error_message_len = 0;
req.respond();
}
See also
For the parameters of on_elevator_call(), see
ln::service_request and
MESSAGE_DEF_NAMESPACE::MDEF_NAME_t in the reference.
For the req.respond() method, see ln::service_request::respond() in the reference.
6.8.2.2.4. ln::client::wait_and_handle_service_group_requests(): Running the Service
After defining the provider, it needs to be run. The default method is
to start service processing via a blocking function call in a single
thread. In our example, this happened by defining a new method
run() which called the method
ln::client::wait_and_handle_service_group_requests(), and
calling run with an instance of our new class after program start-up,
like so:
int run()
{
while (1) {
/// processing...
double time_out = -1; // means blocking
clnt.wait_and_handle_service_group_requests(NULL, time_out);
}
}
This code starts the service processing with a blocking call in the
current thread. In our example, the main() function calls the
run() method of the service provider class. Using this function
is mutually exclusive with using
ln::client::handle_service_group_in_thread_pool(), which starts an
own thread to handle service calls in the background.
Do not use multiple application threads to poll the same service group
with wait_and_handle_service_group_requests(). LN serializes such
calls internally as a safety measure, but the recommended design is
one dispatcher thread per service group. If handlers should execute in
parallel, use the service-group thread-pool API instead.
The advantage of this simpler, single-threaded method is that no additional synchronization is required when handling requests. For example, it is not necessary to place locks around global resources.
See also
Service calls (client side) in the reference.
ln::client::wait_and_handle_service_group_requests() in the reference.
6.8.2.2.5. ln::client::handle_service_group_in_thread_pool(): Thread Configuration for the Service
Furthermore, the service provider class needs to define in which
thread service requests will be run. Optionally, this can be defined
via this statement (replacing
client::wait_and_handle_service_group_requests()), which then
needs to be called in the constructor of the service provider class:
clnt.handle_service_group_in_thread_pool(NULL, "main_pool");
It tells the thread system to use the main thread pool. for executing its requests. Because this causes the execution to take place in several threads, and potentially with multiple threads running at the same time, any resources which are accessed by the service handler need to be locked and protected to prevent simultaneous modification.
See also
Calling and handling Services from C++ for using the direct API in the quickstart example.