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:
the code providing the computation can run in another process
the code can also be written in another language
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:
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!
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 toln::service,area_circle_svcandarea_ellipse_svc, which are needed to set up the service provider.in line 20, it initializes an
ln::clientobject, 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 methodln::client::get_service_provider(). This pointer is owned by theln::clientinstance. 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 namecall_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}
4.5.6. Configuring services support in the links and nodes manager
Finally, we need to write a bit of configuration for the links and nodes manager to connect all three of message definitions, service provider, and service client. This looks like this:
1instance
2name: c++ service direct example for %(env USER)
3manager: :%(get_port_from_string %(instance_name))
4
5add_message_definition_dir: %(CURDIR)/msg_defs
6
7process C++ service provider
8change_directory: %(CURDIR)
9command: ./provider
10pass_environment: PATH, PYTHONPATH, LD_LIBRARY_PATH
11add flags: use_execvpe
12node: localhost
13ready_regex: ready
14
15
16process C++ service client
17change_directory: %(CURDIR)
18command: ./client
19pass_environment: PATH, PYTHONPATH, LD_LIBRARY_PATH
20add flags: use_execvpe
21add flags: no_error_on_successful_stop
22node: localhost
23depends_on: C++ service provider
We can now run this sample by starting the LN manager like this:
ln_manager -c sample_services.lnc
In our service client program, the different methods are called simply a number of times, before the program exits. Of course, we could do many more complex things, like doing calls in an endless processing loop, or calling computations from different threads. But in essence, the above is how service calls work.
4.5.6.1. Compatibility between Client and Provider Implementations
One last interesting and often very useful thing we want to mention is that you can freely mix service clients and provider implementations written in different languages, such as between Python 3 and Python 2, or Python 3 and C++, as long as you use exactly the same message definitions, and the same names for the services. This is often useful when we write a draft or concept implementation of an algorithm in Python, and need to re-implement it in C++: We can test, for example, the new service provider written in C++ against the old service client written in Python, and verify that it has the same result.
Tip
In some situations, you might have to define a lot of services with similar code, which can result in a large amount of boilerplate code. In this case, you can use auto-generated wrapper code. A quickstart example for this is shown in Quickstart: Handling Services with auto-generated Wrapper Code from C++, and a detailed example is explained in the chapter Using Service Wrappers from C++. One caveat: While this code is shorter, it is not simple to explain its interface concisely. This interface has a much larger surface, and its exact behavior is probably harder to understand, which is the reason it is not part of the default introduction.
Footnotes