Broker-based communication framework written in python 3. Implements the most common communication patterns (RPC/ReqResp, PubSub) over a message broker. A message broker is a communication middleware responsible for routing messages to the proper endpoints. Some examples of message brokers are: AMQP brokers (e.g. RabbitMQ), Apache Kafka, MQTT brokers (e.g. Mosquito and EMQX) and Redis.
Yes, Redis can also be used as a message broker for RPC and PubSub communication!!
Currently, AMQP, Redis and MQTT brokers are supported.
The goal of this project is to implement a standard communication middleware based on message brokers, for building systems. A system can be a device, an IoT environment or a software platformm. Performance is also considered as it is often used on low-cost devices, so messaging has to be fast and with low footprint.
python setup.py install
or
pip install .
In order to have access to the Redis transport you will have to install the
redis
python package
pip install redis
However, there’s also a C library, Hiredis, that contains a fast parser that can offer significant speedups for some Redis commands such as LRANGE. You can think of Hiredis as an optional accelerator for Redis commands.
It is highly recommended to also install hiredis.
pip install hiredis
In order to have access to the AMQP transport you will have to installed the following dependencies:
- pika==1.1.0
pip install "pika==1.1.0"
In order to have access to the MQTT transport you will have to installed the following dependencies:
- paho-mqtt
pip install paho-mqtt
It is recommended to use a fast json library, such as orjson or ujson.
The framework will load and use the most performance optimal library based on installations.
The purpose of this implementation is to provide an application-level communication layer, by providing implementations for Remote-Procedure-Calls (RPCs), Topic-based PubSub, Preemptable Services (aka Actions), Events etc.
A Node is a software component that follows the Component-Port-Connector model. It has input and output ports for communicating with the world. Each port defines an endpoint and can be of the following types.
+-----------------+
| |
InPort +-+ Node +-+ OutPort
+-+ +-+
| |
+-----------------+
+--------+
+----------+ OutPort | | InPort +----------+
| +-+ --------> | | ---------> +-+ |
| +-+ | | +-+ |
| Node A | | Broker | | Node B |
| +-+ <-------- | | <--------- +-+ |
| +-+ | | +-+ |
+----------+ InPort | | OutPort +----------+
+--------+
Input Port:
- Subscriber
- RPC Service
- Action Service
Output Port:
- Publisher
- RPC Client
- Action Client
- Event Emitter
InOut Port:
- RPCBridge: Bridge RPC Communication between two brokers. Directional.
- TopicBridge: Bridge PubSub Communication between two brokers. Directional.
- PTopicBridge: Bridge PubSub Communication between two brokers, based on a topic pattern. Directional.
Furthermore, it implements several features:
- Publish Heartbeat messages in the background for as long as the node is active
- Provide control interfaces, to
start
andstop
the execution of the Node - Provides methods to create endpoints and bind to Node ports.
from commlib.node import Node, TransportType
from commlib.msg import RPCMessage, DataClass
## Import the Redis transports
## Imports are lazy handled internally
from commlib.transports.redis import ConnectionParameters
class AddTwoIntMessage(RPCMessage):
@DataClass
class Request(RPCMessage.Request):
a: int = 0
b: int = 0
@DataClass
class Response(RPCMessage.Response):
c: int = 0
def on_request(msg):
print(f'On-Request: {msg}')
resp = AddTwoIntMessage.Response(c = msg.a + msg.b)
return resp
if __name__ == '__main__':
transport = TransportType.MQTT
rpc_name = 'thing.device0.ops.add_two_ints'
# Set broker connection parameters
conn_params = ConnectionParameters()
conn_params.credentials.username = ''
conn_params.credentials.password = ''
# Create an instance of a Node
node = Node(node_name='example-node',
transport_type=transport,
transport_connection_params=conn_params,
debug=True)
# Create an RPCService endpoint for the Node
rpc = node.create_rpc(msg_type=AddTwoIntMessage,
rpc_name=rpc_name,
on_request=on_request)
# Starts the RPCService and wait until an exit signal is catched.
node.run_forever()
A Node always binds to a specific broker for implementing the input and output ports. Of course you can instantiate and run several Nodes in a single-process application.
def __init__(self, node_name: Text = '',
transport_type: TransportType = TransportType.REDIS,
connection_params=None,
remote_logger: bool = False,
remote_logger_uri: Text = '',
debug: bool = False,
device_id: Text = None):
Node methods to create Endpoints::
▼+Node : class
+create_action(self, *args, **kwargs) : member
+create_action_client(self, *args, **kwargs) : member
+create_event_emitter(self, *args, **kwargs) : member
+create_publisher(self, *args, **kwargs) : member
+create_mpublisher(self, *args, **kwargs) : member
+create_rpc(self, *args, **kwargs) : member
+create_rpc_client(self, *args, **kwargs) : member
+create_subscriber(self, *args, **kwargs) : member
It is possible to construct endpoints without binding them to a specific Node. This is a feature to support a wider range of applications, where the concept Node might not be usable.
One can create endpoint instances by using the following classes of each supported transport
- RPCClient
- RPCServer
- Publisher
- Subscriber
- MPublisher (Multi-topic Publisher)
- PSubscriber (Pattern-based Subscriber)
- ActionService (Preemptable Services with feedback)
- ActionClient
- EventEmitter
from commlib.transports.redis import RPCService
from commlib.transports.amqp import Subscriber
from commlib.transports.mqtt import Publisher, RPCClient
...
Or use the endpoint_factory
to construct endpoints.
import time
from commlib.endpoints import endpoint_factory, EndpointType, TransportType
def callback(data):
print(data)
if __name__ == '__main__':
topic = 'factory_test_topic'
mqtt_sub = endpoint_factory(EndpointType.Subscriber, TransportType.MQTT)(
topic=topic,
on_message=callback
)
mqtt_sub.run()
mqtt_pub = endpoint_factory(EndpointType.Publisher, TransportType.MQTT)(
topic=topic,
debug=True
)
data = {'a': 1, 'b': 2}
while True:
mqtt_pub.publish(data)
time.sleep(1)
+---------------+
+-------->+ RPC Topic +------+
+--------------+ | | | | +---------------+
| +---+ +---------------+ +------->+ |
| RPC Client | | RPC Service |
| +<--+ +---------------+ +--------+ |
+--------------+ | |Temporaty Topic| | +---------------+
+---------+ +<-----+
+---------------+
from commlib.node import Node, TransportType
from commlib.msg import RPCMessage, DataClass
# The RPC Communication Object
class AddTwoIntMessage(RPCMessage):
@DataClass
class Request(RPCMessage.Request):
a: int = 0
b: int = 0
@DataClass
class Response(RPCMessage.Response):
c: int = 0
def add_two_int_handler(msg):
# This is the implementation of the RPC callback.
print(f'Request Message: {msg}')
resp = AddTwoIntMessage.Response(c = msg.a + msg.b)
return resp
if __name__ == '__main__':
conn_params = ConnectionParameters()
rpc_name = 'example_rpc_service'
node = Node(node_name='example_node',
transport_type=transport,
transport_connection_params=conn_params,
debug=True)
# Create an RPCService endpoint
rpc = node.create_rpc(msg_type=AddTwoIntMessage,
rpc_name='testrpc',
on_request=on_request)
# Starts the RPCService and wait until an exit signal is catched.
node.run_forever()
from commlib.node import Node, TransportType
from commlib.msg import RPCMessage, DataClass
# The RPC Communication Object
class AddTwoIntMessage(RPCMessage):
@DataClass
class Request(RPCMessage.Request):
a: int = 0
b: int = 0
@DataClass
class Response(RPCMessage.Response):
c: int = 0
if __name__ == '__main__':
conn_params = ConnectionParameters()
rpc_name = 'add_two_ints_node.add_two_ints'
node = Node(node_name='myclient',
transport_type=transport,
transport_connection_params=conn_params,
# heartbeat_uri='nodes.add_two_ints.heartbeat',
debug=True)
rpc = node.create_rpc_client(msg_type=AddTwoIntMessage,
rpc_name=rpc_name)
node.run()
# Create an instance of the request object
msg = AddTwoIntMessage.Request()
while True:
# returns AddTwoIntMessage.Response instance
resp = rpc.call(msg)
print(resp)
msg.a += 1
msg.b += 1
time.sleep(1)
+------------+
| |
+------>+ Subscriber |
| | |
| +------------+
|
+-----------+ +------------+ | +------------+
| | | | | | |
| Publisher +------------>+ Topic +----------->+ Subscriber |
| | | | | | |
+-----------+ +------------+ | +------------+
|
| +------------+
| | |
+------>+ Subscriber |
| |
+------------+
import time
from commlib.msg import PubSubMessage, DataClass
from commlib.transports.mqtt import (
Publisher, ConnectionParameters
)
@DataClass
class SonarMessage(PubSubMessage):
distance: float = 0.001
horizontal_fov: float = 30.0
vertical_fov: float = 14.0
if __name__ == "__main__":
conn_params = ConnectionParameters(host='localhost', port=1883)
pub = Publisher(topic=topic, msg_type=SonarMessage, conn_params=conn_params)
msg = SonarMessage(distance=2.0)
while True:
time.sleep(0.5)
pub.publish(msg)
msg.distance += 1
import time
from commlib.msg import PubSubMessage, DataClass
from commlib.transports.mqtt import (
Subscriber, ConnectionParameters
)
@DataClass
class SonarMessage(PubSubMessage):
distance: float = 0.001
horizontal_fov: float = 30.0
vertical_fov: float = 14.0
def sonar_data_callback(msg):
print(f'Message: {msg}')
if __name__ == "__main__":
conn_params = ConnectionParameters(host='localhost', port=1883)
sub = Subscriber(topic=topic,
on_message=sonar_data_callback,
conn_params=conn_params)
sub.run()
while True:
time.sleep(0.001)
For pattern-based topic subscription use the PSubscriber
class.
#!/usr/bin/env python
import sys
import time
from commlib.msg import PubSubMessage, DataClass
@DataClass
class SonarMessage(PubSubMessage):
distance: float = 0.001
horizontal_fov: float = 30.0
vertical_fov: float = 14.0
def sensor_data_callback(msg, topic):
print(f'Sensor Data Message: {topic}:{msg}')
if __name__ == '__main__':
topic = 'sensors.*'
p1_topic = topic.split('*')[0] + 'sonar.front'
p2_topic = topic.split('*')[0] + 'ir.rear'
# Create an instance of a Patter-based Subscriber (PSubscriber)
sub = PSubscriber(topic=topic, msg_type=SonarMessage,
on_message=sensor_data_callback)
# Run the PSubscriber in the background.
sub.run()
# Create an instance of the communication message
msg = SonarMessage()
# Create an instalce of a Multi-topic Publisher (MPublisher)
pub = MPublisher(msg_type=SonarMessage)
while True:
time.sleep(1)
# Publish message to topic A
pub.publish(msg, p1_topic)
# Publish message to topic B
pub.publish(msg, p2_topic)
msg.distance += 1
Actions are pre-emptable services with support for asynchronous feedback publishing. This communication pattern is used to implement services which can be stopped and can provide feedback data, such as the move command service of a robot.
import time
from commlib.action import GoalStatus
from commlib.msg import ActionMessage, DataClass
from commlib.transports.redis import (
ActionService, ConnectionParameters
)
class ExampleAction(ActionMessage):
@DataClass
class Goal(ActionMessage.Goal):
target_cm: int = 0
@DataClass
class Result(ActionMessage.Result):
dest_cm: int = 0
@DataClass
class Feedback(ActionMessage.Feedback):
current_cm: int = 0
def on_goal(goal_h):
c = 0
res = ExampleAction.Result()
while c < goal_h.data.target_cm:
if goal_h.cancel_event.is_set():
break
goal_h.send_feedback(ExampleAction.Feedback(current_cm=c))
c += 1
time.sleep(1)
res.dest_cm = c
return res
if __name__ == '__main__':
action_name = 'testaction'
conn_params = ConnectionParameters()
action = ActionService(msg_type=ExampleAction,
conn_params=conn_params,
action_name=action_name,
on_goal=on_goal)
action.run()
while True:
time.sleep(0.001)
import time
from commlib.action import GoalStatus
from commlib.msg import ActionMessage, DataClass
from commlib.transports.redis import (
ActionClient, ConnectionParameters
)
class ExampleAction(ActionMessage):
@DataClass
class Goal(ActionMessage.Goal):
target_cm: int = 0
@DataClass
class Result(ActionMessage.Result):
dest_cm: int = 0
@DataClass
class Feedback(ActionMessage.Feedback):
current_cm: int = 0
def on_feedback(feedback):
print(f'ActionClient <on-feedback> callback: {feedback}')
def on_goal_reached(result):
print(f'ActionClient <on-goal-reached> callback: {result}')
if __name__ == '__main__':
action_name = 'testaction'
conn_params = ConnectionParameters()
action_c = ActionClient(msg_type=ExampleAction,
conn_params=conn_params,
action_name=action_name,
on_feedback=on_feedback,
on_goal_reached=on_goal_reached)
goal_msg = ExampleAction.Goal(target_cm=5)
action_c.send_goal(goal_msg)
resp = action_c.get_result(wait=True)
print(resp)
An EventEmitter can be used to fire multiple events, for event-based systems, over a single connection.
+---------+
+---------------------> | Topic A |
| +---------+
|
|
+---------------+
| | +---------+
| EventEmitter |-------------->| Topic B |
| | +---------+
+---------------+
|
|
| +---------+
+---------------------> | Topic C |
+---------+
An Event has the following properties:
@DataClass
class Event(Object):
"""Event.
"""
name: Text
uri: Text
description: Text = ''
payload: OrderedDict = DataField(default_factory=OrderedDict)
- name: The name of the Event
- uri: Broker URI to send the Event
- description: Optional Description of the Event.
- payload: Optional payload to attach on the Event.
Below is an example of an EventEmitter used to fire the bedroom.lights.on
and bedroom.lights.off
events.
#!/usr/bin/env python
import time
from commlib.events import Event
from commlib.transports.mqtt import (
EventEmitter, ConnectionParameters
)
if __name__ == '__main__':
conn_params = ConnectionParameters()
emitter = EventEmitter(conn_params=conn_params, debug=True)
eventA = Event(name='TurnOnBedroomLights', uri='bedroom.lights.on')
eventB = Event(name='TurnOffBedroomLights', uri='bedroom.lights.off')
emitter.send_event(eventA)
time.sleep(2)
emitter.send_event(eventB)
In the context of IoT and CPS, it is a common requirement to bridge messages between message brokers, based on application-specific rules. An example is to bridge analytics (preprocessed) data from the Edge to the Cloud. And what happens if the brokers use different communication protocols?
{Bridge}
[Producer] -------> [Broker A] -------------> [Broker B] ------> [Consumer]
{Bridge}
In the context of the current work, communication bridges are implemented for PubSub and RPC communication between various message brokers. Currently, MQTT, AMQP and Redis are supported.
Below are examples of an MQTT Redis-to-MQTT Bridge and a Redis-to-MQTT Topic Bridge.
#!/usr/bin/env python
import time
import commlib.transports.amqp as acomm
import commlib.transports.redis as rcomm
import commlib.transports.mqtt as mcomm
from commlib.bridges import (
RPCBridge, RPCBridgeType, TopicBridge, TopicBridgeType
)
def redis_to_mqtt_rpc_bridge():
"""
[RPC Client] ----> [Broker A] ------> [Broker B] ---> [RPC Service]
"""
bA_params = rcomm.ConnectionParameters()
bB_params = mcomm.ConnectionParameters()
bA_uri = 'ops.start_navigation'
bB_uri = 'thing.robotA.ops.start_navigation'
br = RPCBridge(RPCBridgeType.REDIS_TO_MQTT,
from_uri=bA_uri, to_uri=bB_uri,
from_broker_params=bA_params,
to_broker_params=bB_params,
debug=False)
br.run()
def redis_to_mqtt_topic_bridge():
"""
[Producer Endpoint] ---> [Broker A] ---> [Broker B] ---> [Consumer Endpoint]
"""
bA_params = rcomm.ConnectionParameters()
bB_params = mcomm.ConnectionParameters()
bA_uri = 'sonar.front'
bB_uri = 'thing.robotA.sensors.sonar.font'
br = TopicBridge(TopicBridgeType.REDIS_TO_MQTT,
from_uri=bA_uri, to_uri=bB_uri,
from_broker_params=bA_params,
to_broker_params=bB_params,
debug=False)
br.run()
if __name__ == '__main__':
redis_to_mqtt_rpc_bridge()
redis_to_mqtt_topic_bridge()
A Pattern-based Topic Bridge (PTopicBridge) example is also shown below.
#!/usr/bin/env python
import time
import commlib.transports.amqp as acomm
import commlib.transports.redis as rcomm
from commlib.msg import PubSubMessage, DataClass
from commlib.bridges import PTopicBridge
@DataClass
class SonarMessage(PubSubMessage):
distance: float = 0.001
horizontal_fov: float = 30.0
vertical_fov: float = 14.0
if __name__ == '__main__':
"""
[Broker A] ------------> [Broker B] ---> [Consumer Endpoint]
"""
bA_uri = 'sensors.*'
bB_namespace = 'myrobot'
bA_params = rcomm.ConnectionParameters()
bB_params = mcomm.ConnectionParameters()
br = PTopicBridge(TopicBridgeType.REDIS_TO_MQTT,
bA_uri,
bB_namespace,
bA_params,
bB_params,
msg_type=SonarMessage,
debug=True)
br.run_forever()
TODO: Action bridges
TCP bridge forwards tcp packages between two endpoints:
[Client] -------> [TCPBridge, port=xxxx] ---------> [TCP endpoint, port=xxxx]
A one-to-one connection is performed between the bridge and the endpoint.
Implements a REST proxy, that enables invocation of REST services via broker communication. The proxy uses an RPCService to run the broker endpoint and an http client for calling REST services. An RPC call is transformed into proper, REST-compliant, http request, based on the input parameters.
class RESTProxyMessage(RPCMessage):
@DataClass
class Request(RPCMessage.Request):
host: str
port: int = 80
path: str = '/'
verb: str = 'GET'
query_params: Dict = DataField(default_factory=dict)
path_params: Dict = DataField(default_factory=dict)
body_params: Dict = DataField(default_factory=dict)
headers: Dict = DataField(default_factory=dict)
@DataClass
class Response(RPCMessage.Response):
data: Dict = DataField(default_factory=dict)
Responses from the REST services are returned to clients in the form of a
RPCMessage.Response
message.
RPC (request/reply) and PubSub Endpoints are supported by the protocol itself (AMQP), using dedicated exchanges.
For RPC enpoints a Direct Exchange
is used to route requests and responses,
optionally using the Direct Reply-to.
If the Direct Reply-to
feature is used, then RPC endpoints must publish
to the default exchange ""
.
To use Direct Reply-to
, an RPC client should:
- Consume from the pseudo-queue
amq.rabbitmq.reply-to
in no-ack mode. - Set the
reply-to
property in their request message toamq.rabbitmq.reply-to
.
Meta-information such as the serialization method used, is passed through the message properties metadata, as specified my AMQP.
PubSub endpoints uses the out-of-the-box Redis pubsub channel to exchange messages. PubSub message payload in Redis includes the data of the message and meta-information (header) regarding serialization method used, timestamp, etc. Below is an example of the payload for pubsub communication.
{
'data': {},
'header': {
'timestamp': <int>,
'properties': {
'content_type': 'application/json',
'content_encoding': 'utf8'
}
}
}
This is useful for transparency between brokers. Default values are evident in the previous example.
Req/Resp communication (RPC) is not supported out-of-the-box. To support
RPC communication over Redis, a custom layer implements the pattern for both endpoints
using Redis Lists to represent queues. RPC server listens for requests from
a list (LPOP / BLPOP), while an RPC client sends request messages to that list (RPUSH).
In order for the client to be able to receive responses, he must listen to a temporary queue.
To achieve this, the request message must include a reply_to
property that is
used by the RPCServer implementation to send the response message. Furthermore,
serialization
and encoding
properties are defined. Finally, the header
includes a timestamp
, that indicates the time that the message was sent to to wire.
Below is the data model of the request message.
{
'data': {},
'header': {
'timestamp': <int>,
'reply_to': <str>,
'properties': {
'content_type': 'application/json',
'content_encoding': 'utf8'
}
}
}
Note: The RPC Client implementation is responsible to remove any created temporary queues!
PubSub message payload in MQTT includes the data of the message and meta-information (header) regarding serialization method used, timestamp, etc. Below is an example of the payload for pubsub communication.
{
'data': {},
'header': {
'timestamp': <int>,
'properties': {
'content_type': 'application/json',
'content_encoding': 'utf8'
}
}
}
This is useful for transparency between brokers. Default values are evident in the previous example.
Though, Req/Resp communication (RPC) is not supported out-of-the-box. To support
RPC communication over MQTT, a custom layer implements the pattern for both endpoints
using MQTT topics. RPC server listens for requests at a specific topic,
while an RPC client listens to a temporary topic for response messages.
For the server to know where to send the response, the request message must include a reply_to
property that is
used by the RPCServer implementation to send the response message. Furthermore,
serialization
and encoding
properties are defined. Finally, the header
includes a timestamp
, that indicates the time that the message was sent to to wire.
Below is the data model of the Request message.
{
'data': {},
'header': {
'timestamp': <int>,
'reply_to': <str>,
'properties': {
'content_type': 'application/json',
'content_encoding': 'utf8'
}
}
}
Examples can be found at the examples/ directory of this repository.
Run tests by executing tox
command under this repo directory:
make tests
TODO - Currently working on!!
Make docs by running:
make docs
TODO - Currently working on!!