4.3. Calling and handling Services from Python
4.3.1. What can LN Services provide?
This quickstart example shows how to define and call LN services from Python. Services are different from LN messages. While the latter are fire-and-forgot messages, services work much like a function call: Running code calls another piece of code with some parameters, and as a result, it 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 however defined slightly different. They have slightly different message definitions, called service message definitions. Therefore, we will start out again with these.
4.3.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.3.3. Defining 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.
As mentioned, this message definition is a bit different from the
former examples. Specifically, it has 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 three message definitions like that:
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 is stored into error_message_len (when
using the Python API, this happens automatically).
We create a second file named triangle_area:
service
request
double base_length
double height
response
uint32_t error_code
char* error_message
uint32_t error_message_len
double area
In the case of an triangle, we need to know the base width and the height, for example.
For the third case, we create a file 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.3.4. Defining a service provider in Python
Now, we need to define code which actually does the computation. As
before, we write it in Python, and give it the name
ln_quickstart_services/provider.py.
4.3.4.1. Initialization
We start with the initialization of the provider class. Here is how it looks:
1#!/usr/bin/env python3
2
3import links_and_nodes as ln
4import traceback
5import math
6from math import pi
7
8from area_api_constants import ErrorCodes as ec
9
10
11class area_provider(object):
12 def __init__(self):
13 self.clnt = ln.client("test_provider")
14
15 self.circle_provider = self.clnt.get_service_provider(
16 "area_service.compute_circle_area", "area_service/circle_area"
17 )
18 self.circle_provider.set_handler(self.compute_circle_area)
19 self.circle_provider.do_register("default group")
20
21 self.triangle_provider = self.clnt.get_service_provider(
22 "area_service.compute_triangle_area", "area_service/triangle_area"
23 )
24 self.triangle_provider.set_handler(self.compute_triangle_area)
25 self.triangle_provider.do_register("default group")
26
27 self.ellipse_provider = self.clnt.get_service_provider(
28 "area_service.compute_ellipse_area", "area_service/ellipse_area"
29 )
30 self.ellipse_provider.set_handler(self.compute_ellipse_area)
31 self.ellipse_provider.do_register("default group")
32
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 handler methods, so-called service handlers.
in line 13, it initializes an
links_and_nodes.clientobject, which is used to manage all the communication.in lines 15, 21, and 27, it initializes three LN service provider handles, using the method
links_and_nodes.client.get_service_provider(). The two parameters of the method calls are each time first the name of the service call that is provided, and the name of a service message definition. Both names need to match between service provider and service client.In our case, the message definition names are the message definitions which we defined above, and the names passed do not include the folder in which the message definition is stored.
then, in lines 18, 24 and 30, a handler method is stored for each service call that the class wants to respond, using the method
links_and_nodes.service_provider.set_handler(). That handler method is a Python function, in our case it is simply a method of the classarea_providerthat we will define shortly.as the final part of the initialization, the method
links_and_nodes.service_provider.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 logically joined.The single parameter of
links_and_nodes.service_provider.do_register()is the name of the service group, which is here the default service group, so all call handlers will run in the main thread of our process.
Tip
Note that while in many cases, names of services and message definitions will be almost identical, they can be different. Specifically, as message definitions define simple data types, 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, and their names.)
4.3.4.2. Handler Methods
Now, how do the service handlers look like? They are defined this way:
33 def compute_circle_area(self, conn, req, resp):
34 if req.radius < 0:
35 resp.area = math.nan
36 resp.error_message = "compute_circle_area(): negative radius input value"
37 resp.error_code = ec.NEGATIVE_INPUT
38 print(
39 "compute_circle_area(): we return an error and NaN as value for a negative radius"
40 )
41
42 else:
43 if req.radius == 0:
44 resp.error_message = "compute_circle_area(): zero in radius input value"
45 resp.error_code = ec.ZERO_INPUT
46 else:
47 resp.error_code = ec.NO_ERROR
48
49 area_val = pi * req.radius**2
50 print(
51 "compute_circle_area(): circle with radius %5.2f has area %5.2f\n"
52 % (req.radius, area_val)
53 )
54 resp.area = area_val
55
56 conn.respond()
57 return 0
58
59 def compute_triangle_area(self, conn, req, resp):
60 if req.base_length < 0 or req.height < 0:
61 resp.area = math.nan
62 resp.error_message = (
63 "compute_triangle_area(): negative side length input value"
64 )
65 resp.error_code = ec.NEGATIVE_INPUT
66 print(
67 "compute_triangle_area(): we return an error and NaN as"
68 " value for a negative side length"
69 )
70
71 else:
72 if req.base_length == 0 or req.height == 0:
73 resp.error_message = (
74 "compute_triangle_area(): zero in side length input value"
75 )
76 resp.error_code = ec.ZERO_INPUT
77 else:
78 resp.error_code = ec.NO_ERROR
79
80 area_val = (req.base_length * req.height) / 2.0
81 print(
82 "compute_triangle_area(): triangle with base length = %5.2f "
83 "and height = %5.2f has area %f\n"
84 % (req.base_length, req.height, area_val)
85 )
86 resp.area = area_val
87
88 conn.respond()
89 return 0
90
91 class EllipseException(Exception):
92 def __init__(self, code, message, optret=None):
93 self.error_code = code
94 self.error_message = message
95 self.optret = optret
96 Exception.__init__(self)
97
98 def __str__(self):
99 return "EllipseException: %r (code %r)" % (
100 self.error_message,
101 self.error_code,
102 )
103
104 def lib_compute_ellipse_area(self, a, b):
105 # this simulates a function from an external library
106 if a < 0 or b < 0:
107 raise self.EllipseException(
108 ec.NEGATIVE_INPUT,
109 "compute_ellipse_area(): negative axis length input value",
110 math.nan,
111 )
112
113 if a == 0 or b == 0:
114 raise self.EllipseException(
115 ec.ZERO_INPUT, "compute_ellipse_area(): zero in axis length input value"
116 )
117
118 area_val = pi * a * b
119
120 print(
121 "compute_ellipse_area(): ellipse with semi-major axis a %5.2f "
122 "and semi-minor axis b %5.2f has area %5.2f\n" % (a, b, area_val)
123 )
124 return area_val
125
126 def compute_ellipse_area(self, conn, req, resp):
127 try:
128 resp.area = self.lib_compute_ellipse_area(req.a, req.b)
129 resp.error_code = ec.NO_ERROR
130 resp.error_message = "" # todo: report as bug against LN api
131
132 except self.EllipseException as e:
133 print("compute_ellipse_area(): %s" % str(e))
134 resp.error_code = e.error_code
135 resp.error_message = e.error_message
136 if e.optret is not None:
137 resp.area = e.optret
138
139 except:
140 resp.error_code = ec.OTHER_ERROR
141 resp.error_message = traceback.format_exc()
142 print("compute_ellipse_area(): unknown exception:\n%s" % resp.error_message)
143
144 conn.respond()
145 return 0
146
Each handler method has three parameters conn, req, and
resp. The first is an object that represents the service call
connection. The connection is essentially an object which offers
several methods that can handle (respond, abort, and so on) a service
call.
The second parameter, named req, is an object that
represents the call parameters that are transmitted with the service
call - each member of req corresponds to one member or element of
the “request” part of the message definition.
The third parameter, called resp, represents the response data
structure or response buffer. Its members correspond to the
“response” section of the associated service message
definition. Before responding the call, the results are assigned to
the members of that structure.
In the three methods of our example, called compute_circle_area(),
compute_triangle_area(), and compute_ellipse_area(), the
different formulas for each geometric figure are evaluated. We see
that in each method, in the first part, the input parameters are
checked for sanity: For example, in lines 34 to 40 of
compute_circle_area(), the radius of the circle is checked whether
it is smaller than zero; if this is the case, the fields
resp.error_message and resp.error_code are set, so that the
caller of the service method can know what went wrong. Note that even
in this branch of the code path, the respond() method is called,
since the error result needs to be communicated back to the client.
If, in turn, all parameters are correct, the computed value is
assigned to resp.area. When all response fields are assigned, the
method links_and_nodes.connection.respond() is called,
which returns the response data to the caller, and finishes the
request.
In the handler method compute_ellipse_area(), we see another case
that we need to cover: Here, the computation of the area of an ellipse
is done by calling a library function, which could throw an exception
(in our example, we emulate an external library function by calling
into the function lib_compute_ellipse_area(), but it could be of
course any function defined in some external library).
In that case, we need to catch any exception that could occur, as is
done in the try.... except clause in lines 127 to 137, so that the
error is caught and a sensible error message is returned to the
client. Otherwise, the client might receive an error message that
could not be comprehensible at all, which is generated by the LN
service handler code when it catches the exception on the run.
4.3.4.3. Running the Handlers
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:
147 def run(self):
148 print("ready to receive service calls")
149 while True:
150 self.clnt.wait_and_handle_service_group_requests("default group", 0.5)
151
152
153if __name__ == "__main__":
154 p = area_provider()
155 p.run()
The function which actually performs the handling of service requests
has the name
links_and_nodes.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. We set it to the same service group
name as we have passed in the calls to do_register() above.
The second parameter is the maximum waiting time which the call will wait for a request to arrive. In our example, the service handlers will run in the main thread of the program, which keeps things nicely simple.
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.
Note
It is also possible to run service handlers in multiple threads. However, this requires thread safe programming, which becomes quickly very difficult and has a high risk of difficult-to-debug errors like race conditions. For technical reasons, pure Python code is also unlikely to run faster in several threads. Therefore, it is strongly recommended to use simpler means unless processing in multiple threads is absolutely required. [1]
4.3.5. Calling services from Python
4.3.5.1. Instantiating the Client
As the counterpart, we need, of course, code which calls the
computation. For this, we create a service client program
ln_quickstart_services/client.py like this:
1#!/usr/bin/env python3
2
3import random
4import links_and_nodes as ln
5
6
7class area_service_client(object):
8 def __init__(self):
9 self.clnt = ln.client("area_service_client")
10
11 self.compute_circle_svc = self.clnt.get_service(
12 "area_service.compute_circle_area", "area_service/circle_area"
13 )
14 self.compute_triangle_svc = self.clnt.get_service(
15 "area_service.compute_triangle_area", "area_service/triangle_area"
16 )
17 self.compute_ellipse_svc = self.clnt.get_service(
18 "area_service.compute_ellipse_area", "area_service/ellipse_area"
19 )
20
21 def compute_circle_area(self, radius):
22 svc = self.compute_circle_svc
23 svc.req.radius = radius
24 svc.call()
25 if svc.resp.error_code != 0:
26 print(
27 "Error: code = {}, message = {}".format(
28 svc.resp.error_code, svc.resp.error_message
29 )
30 )
31 return svc.resp.area
32
33 def compute_triangle_area(self, base_length, height):
34 svc = self.compute_triangle_svc
35 svc.req.base_length = base_length
36 svc.req.height = height
37 svc.call()
38 if svc.resp.error_code != 0:
39 print(
40 "Error: code = {}, message = {}".format(
41 svc.resp.error_code, svc.resp.error_message
42 )
43 )
44 return svc.resp.area
45
46 def compute_ellipse_area(self, a, b):
47 svc = self.compute_ellipse_svc
48 svc.req.a = a
49 svc.req.b = b
50 svc.call()
51 if svc.resp.error_code != 0:
52 print(
53 "Error: code = {}, message = {}".format(
54 svc.resp.error_code, svc.resp.error_message
55 )
56 )
57 return svc.resp.area
58
59
60if __name__ == "__main__":
61 c = area_service_client()
62
63 for n in range(10):
64 a = random.uniform(-2, 10)
65 A = c.compute_circle_area(a)
66
67 print("circle result: %5.2f" % A)
68
69 for n in range(10):
70 a = random.uniform(-2, 10)
71 b = random.uniform(0, 10)
72 A = c.compute_triangle_area(a, b)
73
74 print("triangle result: %5.2f" % A)
75
76 for n in range(10):
77 a = random.uniform(-2, 10)
78 b = random.uniform(0, 10)
79 A = c.compute_ellipse_area(a, b)
80
81 print("ellipse result: %5.2f" % A)
Here, the method call links_and_nodes.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), and the second is the used
service message definition. The links_and_nodes.client.get_service()
method returns again a service handle of type
links_and_nodes.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!
4.3.5.2. Calling the Service from Client Methods
Now, we can write methods which call the
service. As an example, we can look at
the definition of compute_circle_area():
It has the needed parameter radius.
For performing the call, it first creates a shortcut name to the
service client handle of type links_and_nodes.service,
which we call svc. After that, it can assign the request
parameters to the member fields of links_and_nodes.service.req. In our case, this is
svc.req.radius. Then, the service is actually called by calling
links_and_nodes.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 links_and_nodes.service.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 Python code would raise a suitable exception here that
signals a failure - doing this is however within the responsibility of
the service client).
4.3.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: python service direct example for %(env USER)
3manager: :%(get_port_from_string %(instance_name))
4
5add_message_definition_dir: %(CURDIR)/msg_defs
6
7process python service provider
8change_directory: %(CURDIR)
9command: python3 provider.py
10pass_environment: PATH, PYTHONPATH, LD_LIBRARY_PATH
11add flags: use_execvpe
12node: localhost
13ready_regex: ready
14
15
16process python service client
17change_directory: %(CURDIR)
18command: python3 client.py
19pass_environment: PATH, PYTHONPATH, LD_LIBRARY_PATH
20add flags: use_execvpe
21add flags: no_error_on_successful_stop
22node: localhost
23depends_on: python service provider
We can now run this sample by starting the LN manager like this:
ln_manager -c sample_services.lnc
In our service script, 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.3.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.
It is also useful to keep in mind that the messaging protocol remains fully compatible (that is, both forward-compatible and backwards-compatible) between different LN versions. This means that you can mix clients with newer and older versions in one and the same system, and even written in different language standards, such as Python 2 and Python 3, or C++98, C++11, and C++17.
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 which is explained in the chapter Calling Services with the “wrapped” API. One caveat: While this code is shorter, it is not simple to explain and define its interface concisely. This “wrapped” 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 presented here.
Footnotes