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_t which 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 with ln::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 for elevator03.prompt, and adds a method request_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 name elevator03.prompt to 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 name elevator::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 of respond().)

  • 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 when respond() 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 the respond() 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.

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.