4.5. Calling and handling Services from C++

4.5.1. What can LN Services provide?

This quickstart example shows how to define and call LN services from C++11. It can be read independently from the corresponding quickstart example for Python, and any important explanation there will be repeated, if relevant for C++ clients.

Services are different from LN messages. While the latter are fire-and-forgot messages, services work much like a function call: One calls another piece of code with some parameters, and as a result, one gets one or more return values back. While this happens, the current flow of execution is paused, so the programmer does not need to handle the result concurrently, resulting in an easy control flow.

The differences to normal function calls are three-fold:

  1. the code providing the computation can run in another process

  2. the code can also be written in another language

  3. the code can also run on another computer, as long as a network connection exists.

To achieve these things, links and nodes uses, again, messages, which are defined slightly different. Therefore, we will start out again with message definitions, this time for services.

4.5.2. Our example: Geometric heavy lifting

Let’s assume we have a process which needs to make some really complicated geometry calculations. For the purpose of this example, let’s just show how to compute areas of a circle, a triangle, or an ellipse, so that we can keep the sample code short. (In practice, it can of course be any kind of computation, or object-oriented method call.)

4.5.3. Writing message definitions for service calls

To call services for this computation, we first need to create message definitions with the parameters and return values of each computation.

Now, a heads up: These message definitions are a bit different from the former examples, because they have two distinguished sections, one under the label request, and one under the name response. As you might guess, the message parts under “request” are the input or call parameters of the computation, and the message parts under “response” are the return parameters.

Create a sub-folder msg_defs/area_service/, and create two message definitions in files like these:

  1. the file circle_area:

service
request
double radius

response
uint32_t error_code
char* error_message
uint32_t error_message_len
double area

To compute the area of an circle, one only needs to know its radius as an input parameter. The resulting area is the return parameter. Both parameters are of type double, which is a standard 64-bit floating point number (as it is used in C/C++).

Because errors can happen almost everywhere in code, we add two fields which are needed for error handling: an error_code field, and an error_message field, which is basically an array of bytes with a variable length. The length needs to be stored explicitly into error_message_len.

Important

The LN client API does not treats the error_message as a null-terminated string, but as a variable-length array whose length will be explicitly determined by the following field with the name error_message_len. Therefore, the length needs always to be set explicitly!

  1. We create a second file named ellipse_area:

service
request
double a
double b

response
uint32_t error_code
char* error_message
uint32_t error_message_len
double area

Here, a and b mean the smaller and the larger semi-axis of the ellipse, which is a popular notation (if we wanted, we could of course also use longer variable names, to support our memory about the meaning).

4.5.4. Defining a service provider in C++

4.5.4.1. Build Scripts for Client and Provider

Using LN services from C and C++ requires a code generation step. Here is a Makefile which will build client and provider form source:

 1all:	client provider
 2
 3CPPFLAGS += -std=c++14
 4
 5LIBS ?= -lln
 6
 7provider: provider.cpp area_api_constants.h ln_messages.h
 8	g++ $(CPPFLAGS) $(CXXFLAGS) $(LDFLAGS) provider.cpp $(LIBS) -o provider
 9
10client: client.cpp area_api_constants.h ln_messages.h
11	g++ $(CPPFLAGS) $(CXXFLAGS) $(LDFLAGS) client.cpp $(LIBS) -o client
12
13NEEDED_MDS = \
14	area_service/circle_area   \
15	area_service/ellipse_area
16
17OWN_MDS_DIR = msg_defs/
18
19VPATH = $(OWN_MDS_DIR)
20
21ln_messages.h: $(NEEDED_MDS) Makefile
22	ln_generate -o $@ --md-dir $(OWN_MDS_DIR) $(NEEDED_MDS)
23
24clean:
25	rm -f ln_messages.h *.o provider client

The important aspect that matters at this point is that the program ln_generate needs to be run with the message definitions that we are going to use as an argument, in order to generate a header file with the name ln_messages.h. This is defined in line 22. This header file will, because the input to ln_generate was, for example, the message definition file for the message with the name area_service/circle_area, contain a structure type definition with the name area_service::circle_area_t, which will be needed in the next steps to define the client and handler code. Because of that, ln_messages.h is a dependency of the Make targets provider and client.

Now, we need to define code which actually does the computation. We write it in C++ and give it the name ln_quickstart_services/provider.cpp.

4.5.4.1.1. Initialization

We start with the initialization of the provider class. Here is how it looks:

 1#include <ln/ln.h>
 2#include <cstdint>
 3#include "area_api_constants.h"
 4#include <iostream>
 5#include <cmath>
 6
 7#include "ln_messages.h"
 8
 9using std::cout;
10
11class AreaServer
12{
13
14	ln::client clnt;
15	ln::service* area_circle_svc = nullptr;
16	ln::service* area_ellipse_svc = nullptr;
17
18public:
19	AreaServer() :
20		clnt("C++ area service")
21	{
22
23		area_circle_svc = clnt.get_service_provider(
24		                          "area_service.compute_circle_area",
25		                          "area_service/circle_area",
26		                          area_service_circle_area_signature);
27		area_circle_svc->set_handler(call_circle_area, this);
28		area_circle_svc->do_register("default group");
29
30		area_ellipse_svc = clnt.get_service_provider(
31		                           "area_service.compute_ellipse_area",
32		                           "area_service/ellipse_area",
33		                           area_service_ellipse_area_signature);
34		area_ellipse_svc->set_handler(call_ellipse_area, this);
35		area_ellipse_svc->do_register("default group");
36	}
37
38	~AreaServer()
39	{
40		clnt.release_service(area_circle_svc);
41		clnt.release_service(area_ellipse_svc);
42	}

This is the code which initializes the service provider class and its handlers for service message requests. To explain it very briefly:

  • We first define a class which provides service handler methods.

  • In line 14 to 16, defines a member variable of type ln::client, and in addition two more member variables that are pointers to ln::service, area_circle_svc and area_ellipse_svc, which are needed to set up the service provider.

  • in line 20, it initializes an ln::client object, which is used to manage all the communication, allocates buffer, and so on.

Now, we focus on setting up the circle_area service (both services are set up in the same way).

  • in lines , the constructor gets a LN service provider handle and assigns it to area_circle_svc, using the method ln::client::get_service_provider(). This pointer is owned by the ln::client instance. The two parameters of the method call are first the name of the service that is provided, and the second name of the service message definition that we want to associate with it. In our case, these are the message definitions which we defined above. Note that the name we pass does not include the folder in which the message definition is stored.

  • then, in lines 29, a handler method is registered for the service call that the class wants to respond, using the method ln::service::set_handler(). The registered handler is a function. In our case it is a static method with the name call_circle_area(). It could also be an arbitrary C function. We will look at its definition in a minute.

  • as the final part of the initialization, the method ln::service::do_register() actually registers the service provider with the LN manager, so that service name, message definition name, and the handler function that implements it are joined.

    The parameter of ln::service::do_register() (which is optional) is the name of the service group, which is here “default group” for the default service group [1] [2] .

Tip

Note that while in many cases, names of services and message definitions will be almost identical by convention, they can be different. Specifically, as message definitions define simple data types, for example vectors which store spatial positions, these can also originate from a system library or another package which provides a unique definition. (For further reading, the section on Message Definitions and Topics in the tutorial gives a more in-depth explanation of the relationships between message definitions, topics, services, and their names.)

4.5.4.1.2. Handler Methods

Now, how do the service handlers which are set using set_handler() look like? They are defined this way:

 44static int call_circle_area(::ln::client& clnt, ::ln::service_request& req, void* self_)
 45{
 46        AreaServer* self = (AreaServer*)self_;
 47        area_service::circle_area_t data{};
 48        req.set_data(&data, area_service_circle_area_signature);
 49
 50        return self->on_circle_area(req, data);
 51}
 52
 53int on_circle_area(ln::service_request& req,
 54                   area_service::circle_area_t& data) const
 55{
 56
 57
 58        if (data.req.radius == 0.0) {
 59                data.resp.error_code = static_cast<uint32_t>(E_AreaErrorStatus::ZERO_INPUT);
 60                data.resp.error_message = (char*)"on_circle_area(): input was zero";
 61
 62        } else if (data.req.radius < 0.0) {
 63                data.resp.area = NAN;
 64                data.resp.error_code = static_cast<uint32_t>(E_AreaErrorStatus::NEGATIVE_INPUT);
 65                data.resp.error_message = (char*)"on_circle_area(): input was negative";
 66
 67        } else {
 68                const double radius = data.req.radius;
 69                data.resp.area = M_PI * radius * radius;
 70
 71                data.resp.error_code = static_cast<uint32_t>(E_AreaErrorStatus::NO_ERROR);
 72                data.resp.error_message = (char*)"";
 73        }
 74        data.resp.error_message_len = strlen(data.resp.error_message);
 75        req.respond();
 76
 77
 78        cout << "request finished!\n";
 79        return 0;
 80}
 81
 82static int call_ellipse_area(::ln::client& clnt, ::ln::service_request& req, void* user_data)
 83{
 84        AreaServer* self = (AreaServer*)user_data;
 85        area_service::ellipse_area_t data{};
 86        req.set_data(&data, area_service_ellipse_area_signature);
 87        return self->on_ellipse_area(req, data);
 88}
 89
 90int on_ellipse_area(ln::service_request& req,
 91                    area_service::ellipse_area_t& data) const
 92{
 93
 94
 95        if ((data.req.a < 0.0) || (data.req.b < 0.0)) {
 96
 97                data.resp.area = NAN;
 98                data.resp.error_code = static_cast<uint32_t>(E_AreaErrorStatus::NEGATIVE_INPUT);
 99                data.resp.error_message = (char*)"on_ellipse_area(): input was negative";
100
101        } else if ((data.req.a == 0.0) || (data.req.b == 0.0)) {
102
103                data.resp.error_code = static_cast<uint32_t>(E_AreaErrorStatus::ZERO_INPUT);
104                data.resp.error_message = (char*)"on_ellipse_area(): input was zero";
105
106        } else  {
107                const double a = data.req.a;
108                const double b = data.req.a;
109                data.resp.area = M_PI * a * b;
110
111                data.resp.error_code = static_cast<uint32_t>(E_AreaErrorStatus::NO_ERROR);
112                data.resp.error_message = (char*)"";
113        }
114        data.resp.error_message_len = strlen(data.resp.error_message);
115        req.respond();
116
117
118        cout << "request finished!\n";
119        return 0;
120}

Each handler method will be invoked if a service call with the matching service name is received.

Handler functions have three parameters which are named clnt, req, and self_ here. The first is a reference to the ln::client object that the called service handler was associated to. The second parameter, named req, is a service request instance that has information about the request and provides methods to respond to it in several ways. The third, which is here called _self, is simply a pointer to a data structure that can hold data that should remain available between calls, such as a counter, or a cache, or something similar. Here, it is set to the used instance of AreaServer. We see that in line 50, the pointer to that data object is casted into a pointer to the AreaServer class, which corresponds to the argument passed in line 29 of the AreaServer() constructor.

4.5.4.2. The Request Data Buffer

Further, in line 52, a buffer with the name data is declared, allocated on the stack, and zero-initialized (using the uniform initializers “{}” which were added in C++11). This buffer has the purpose to hold the request parameter data, as well as the response data. Its type is of type area_service::circle_area_t, which is auto-generated by ln_generate from the message definition with the name “area_service/circle_area” - each slash in the message definition name is converted into a nested C++ name space identifier.

In line 54, the method ln::service_request::set_data() is called, which has two effects: First, the request data is copied into the buffer data, which we just created. And second, the address of that buffer is registered as the right address to retrieve the response data from later.

After these preparations, the handler calls a method which does the actual computation and is defined by us, which is AreaServer::on_circle_area().

This method is, in this particular case, defined as a const method, which would allow to read data from the AreaServer instance, but not modify it. It could also be a static method, which would not receive an instance pointer, or a simple C function, or of course also a normal method which can modify data of the instance - for example, to increment and store the number of service calls that were received.

Now, the data parameter of on_circle_area() is a struct with two members, req and resp. They correspond exactly to the “request” and “response” parts of the message definition, which means that data.req holds the call parameters of the service call, and data.resp holds the buffer space for the return parameters or result, which will be sent back to the service client. The type of this buffer is, by design, equal on the client and the provider side. We will discuss the actual data types and how they are defined in section The Request Buffer Type a little below.

If we look at what on_circle_area() does, it operates in two phases: First, it does input validation, by checking whether the radius value in data.req.radius is zero or negative. If that happens to be the case, it sets the error message string pointer in data.resp.error_message to a message about the problem, the field data.resp.error_message_len to the length of that string, and data.resp.error_code to a numerical error code. This data is sent back with the call to req.respond(). (At that point, it would also be possible to cancel the request or similar, which would be done by calling different methods).

Otherwise, the result is computed and assigned to data.resp.area in line 81, and sent back to the client. Because in line 52, the data buffer was initialized to zero, we can be sure that no spurious error message or return data will be sent back.

Also, the transfer mechanism invoked by respond() will take care that the referenced data with variable length that data.resp.error_message can point to, will be transferred correctly according to the length stored in data.resp.error_message_len. (The owner of the memory that is pointed to by req.resp.error_message is still the user program, which means that in case of data allocated to the heap, it needs to be deallocated, but must never be freed before req.respond() was called and has completed.) req.resp.error_message is not assumed to be null-terminated here.

The methods AreaServer::call_ellipse_area() and AreaServer::on_ellipse_area() work in the same way. If the class instance would need to be modified by a call, we would need to define AreaServer::on_ellipse_area() as not const.

4.5.4.2.1. Running the Service Provider

As the final parts of the service provider script, we need one more method definition, and a function call that runs the provider. This is done like shown here:

123	int run()
124	{
125		cout << "ready to receive service calls\n";
126		while (true) {
127			clnt.wait_and_handle_service_group_requests("default group", 0.5);
128		}
129	}
130
131
132};
133
134
135int main(int argc, char* argv[])
136{
137	AreaServer area_server;
138	return area_server.run();
139}

The function which actually performs the handling of service requests has the name ln::client::wait_and_handle_service_group_requests(). Here, the first parameter is the group of service handlers which will be run to serve a request. The second parameter is the maximum waiting time which the call will wait for a request to arrive. The call serves only one request, therefore it needs to be repeated in a loop.

In our example, the service handlers will run in the main thread of the program, which keeps things nicely simple and avoids nasty concurrency bugs such as race conditions or deadlocks. (In the case that processing in multiple threads is absolutely needed, one can alternatively use ln::client::handle_service_group_in_thread_pool().)

Avoid polling the same service group from multiple application threads. LN serializes concurrent calls internally, but one dispatcher thread per service group is the recommended structure. Use a service group thread pool if handlers should run concurrently.

4.5.5. Calling services from C++

4.5.5.1. Instantiating the Client

As the counterpart, we need, of course, code which calls the computation. For the sake of this example, we continue to use C++ for this, and create a service client program ln_quickstart_services/client.cpp like this:

 1#include <ln/ln.h>
 2#include "ln_messages.h"
 3#include "area_api_constants.h"
 4using namespace std::string_literals;
 5
 6#include <iostream>
 7
 8using std::cout;
 9
10class AreaClient
11{
12	ln::client* clnt;
13	ln::service* circle_area_svc;
14	ln::service* ellipse_area_svc;
15
16public:
17	AreaClient(ln::client* _clnt) : clnt(_clnt)
18	{
19		circle_area_svc = clnt->get_service(
20		                          "area_service.compute_circle_area",
21		                          "area_service/circle_area",
22		                          area_service_circle_area_signature);
23
24		ellipse_area_svc = clnt->get_service("area_service.compute_ellipse_area",
25		                                     "area_service/ellipse_area",
26		                                     area_service_ellipse_area_signature);
27	}

Here, the method call ln::client::get_service() has again two parameters. The first is, exactly as before, the name of the service method that is provided (which here is equal to the the function name at the provider side), the second is the used service message definition, and the third is the auto-generated call signature. All three need to match with the service provider that is called. The ln::client::get_service() method returns again a service handle of type ln::service for the client.

Attention

This retrieval of the handle should only be done once during the initialization of the client program, because it can take much longer than the ongoing actual calls!

(To be precise, the first service call will also usually take longer, because it causes some things such as internal buffers to be set up by the LN messaging system).

4.5.5.2. Calling the Service from Client Methods

29double circle_area(double radius)
30{
31        area_service::circle_area_t data{};
32
33        data.req.radius = radius;
34
35        circle_area_svc->call(&data);
36
37
38        if (data.resp.error_code > 0) {
39                std::string err_msg(data.resp.error_message,
40                                    data.resp.error_message_len);
41
42                cout << "WARNING: request returned  with error code "
43                     << (unsigned) data.resp.error_code
44                     << ", message = \"" << err_msg << "\"\n";
45        }
46        return data.resp.area;
47}
48
49double ellipse_area(double a, double b)
50{
51        area_service::ellipse_area_t data{};
52
53        data.req.a = a;
54        data.req.b = b;
55
56
57        ellipse_area_svc->call(&data);
58
59
60        if (data.resp.error_code > 0) {
61                std::string err_msg(data.resp.error_message,
62                                    data.resp.error_message_len);
63
64                cout << "WARNING: request returned  with error code "
65                     << (unsigned) data.resp.error_code
66                     << ", message = \"" << err_msg << "\"\n";
67        }
68        return data.resp.area;
69}

Now, we can write methods which call the service. As an example, we can look at the definition of circle_area(): It has the needed parameter radius.

4.5.5.3. The Request Buffer Type

Then, we need to allocate a buffer for the request data. Here, it is allocated in line 31 on the stack, with the type area_service::circle_area_t and the name data. Importantly, it is initialized to zero, using C++11 uniform initialization, as indicated by the “{}” initializer, and matches exactly the data buffer in the service handler.

The type name area_service::circle_area_t is formed from the message definition that we use, "area_service/circle_area", by replacing each slash (“/”) with a double-colon (“::”), and appending an “_t”. This is a type that is auto-generated by ln_generate from the message definitions when make builds the header file ln_messages as an intermediate target.

After that, it can assign the request parameters to the member fields of data.req, which has the auto-generated type MESSAGE_DEF_NAMESPACE::SVMDEF_NAME::req. Here, MESSAGE_DEF_NAMESPACE::SVMDEF_NAME is the meta-typename constructed from the name of the message definition with each slash (“/”) replaced by a pair of colons (“::”).

In our case, the member field is svc.req.radius. Then, the service is actually called by calling ln::service::call(), which sends the data to the service provider, waits for the result of the computation, and transports it back - including any possibly generated error messages.

The response data is in the member fields of svc.resp, which is of type MESSAGE_DEF_NAMESPACE::SVMDEF_NAME::resp. To check for the error, we just look whether the value svc.resp.error_code is larger than zero; if this is the case, we can use svc.resp.error_message in order to print a diagnostic message, for example. (In many cases, idiomatic c++ code would raise a suitable exception here that signals a failure - doing this is however within the responsibility of the service client).

4.5.5.3.1. Running the client

Here is shown how to run the client. We have included some bogus parameter values in our sample in order to make sure that errors are handled correctly.

71int run()
72{
73        for(double radius=-1; radius < 11; radius += 1.0) {
74
75                cout << "calling circle service for r = " << radius << '\n';
76
77                double area = circle_area(radius);
78
79                cout << "the resulting circle area is: "
80                     <<  area << '\n';
81        }
82
83        for(double a=-1; a < 5; a += 1.0)
84                for(double b = 2; b < 5; b += 2.0) {
85
86                        cout << "calling ellipse service for a = " << a
87                             << ", b = " << b << '\n';
88
89                        double area = ellipse_area(a, b);
90
91                        cout << "the resulting ellipse area is: "
92                             <<  area << '\n';
93                }
94
95        return 0;
96}
 99int main(int argc, char* argv[])
100{
101	ln::client clnt("area_client (C++)");
102	AreaClient area_client(&clnt);
103	return area_client.run();
104}