Hello! Welcome back to our second module on "Distributed Communication Patterns."
Introduction
In our last lesson, we built a synchronous request-response client for a trading scenario. We made it robust by handling network timeouts and service unavailability. You saw that the TradingService had to actively poll the MarketDataService and would block, waiting for a response. This tight coupling means a slow or unavailable MarketDataService directly impacts the performance and availability of the TradingService.
Today, we will explore a fundamentally different approach: asynchronous message passing. Instead of the TradingService pulling data, we will have the MarketDataService push data whenever it's available. This is achieved using a message queue, a powerful intermediary that decouples our services.
Learning Outcome:
Implement asynchronous message passing between services using a message queue (e.g., RabbitMQ) for the same trading scenario.
We will introduce RabbitMQ as our message broker and refactor our services. The MarketDataService will become a producer, publishing price updates as messages. The TradingService will become a consumer, listening for these messages and processing them as they arrive. This pattern eliminates the blocking behavior and introduces a new level of resilience and scalability.
1. The Core Concepts of Message Queuing
Before we write any code, it's essential to understand the key components of a message queuing system like RabbitMQ.
At a high level, the architecture involves:
- Producer: The application that sends messages.
- Consumer: The application that receives messages.
- Broker: The message queuing software (RabbitMQ in our case) that routes messages from producers to consumers.
The broker itself has several important internal components.
Introduction to RabbitMQ for Python Developers
This video, 'Introduction to RabbitMQ for Python Developers' by Denis Orehovsky, provides a concise and clear explanation of the fundamental architecture of RabbitMQ.
Please watch from 00:59 to 02:20. This segment covers the roles of the Producer, Broker, and Consumer, and then introduces the three key components within the broker: Exchanges, Queues, and Bindings.
To summarize the video:
- A Producer sends a message to an Exchange.
- The Exchange receives the message and is responsible for routing it. The routing logic depends on the exchange type (e.g., direct, topic, fanout) and a
routing_keyprovided with the message. - A Binding is a rule that links an Exchange to a Queue. It tells the exchange which queues are interested in which messages.
- The Queue is a buffer that stores messages until a consumer is ready to process them.
- A Consumer connects to a queue and receives messages from it.
This architecture decouples the producer from the consumer. The producer doesn't need to know where the consumer is or even if it's running. It simply sends a message to an exchange and trusts the broker to deliver it correctly.
2. Setting Up Your Environment
For this lesson, you'll need a running RabbitMQ instance. The most straightforward way to do this for local development is by using its official Docker image. You'll also need the Python library pika to communicate with RabbitMQ.
-
Install Pika:
pip install pika -
Run RabbitMQ with Docker:
If you have Docker installed, run the following command in your terminal. This will start a RabbitMQ container with the management plugin enabled, which gives you a web UI to see what's happening.docker run -d --hostname my-rabbit --name some-rabbit -p 5672:5672 -p 15672:15672 rabbitmq:3-management-p 5672:5672maps the port for the AMQP protocol used by our Python clients.-p 15672:15672maps the port for the web-based management UI.
Once it's running, you can access the management UI at
http://localhost:15672. The default credentials areguest/guest. It's worth taking a quick look, but we will be creating our queues and exchanges programmatically.
3. A "Hello World" Example
Let's start with the simplest possible implementation to see the producer and consumer in action. We'll use the official RabbitMQ "Hello World" tutorial as our guide.
The key insight for this simple example is that we don't need to explicitly declare an exchange. RabbitMQ provides a default "nameless" exchange. This exchange is of the direct type and routes messages to the queue whose name exactly matches the message's routing_key.
RabbitMQ tutorial - "Hello world!"
The official RabbitMQ tutorial, 'RabbitMQ tutorial - "Hello world!"', is the best place to start. It provides the fundamental code for sending and receiving a message.
Please read the sections 'Sending' and 'Receiving'. You don't need to copy the code, as I will provide it below, but focus on understanding the purpose of each Pika function call: pika.BlockingConnection, connection.channel(), channel.queue_declare(), channel.basic_publish(), and channel.basic_consume(). Finally, quickly review 'Putting it all together' to see the complete scripts.
Here is the code, adapted from the tutorial.
send.py (Producer)
import pika
# 1. Establish a connection to the RabbitMQ server
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# 2. Create a queue to which the message will be delivered.
# queue_declare is idempotent - it will only be created if it doesn't exist.
channel.queue_declare(queue='hello')
# 3. Publish the message.
# We use the default exchange (specified by exchange='').
# The routing_key must match the queue name.
channel.basic_publish(exchange='',
routing_key='hello',
body='Hello World!')
print(" [x] Sent 'Hello World!'")
# 4. Close the connection to ensure network buffers are flushed.
connection.close()
receive.py (Consumer)
import pika
import time
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# Declare the queue again. This is a good practice as we want to make sure
# the queue exists before we try to consume from it.
channel.queue_declare(queue='hello')
# Define the callback function that will process the message.
def callback(ch, method, properties, body):
print(f" [x] Received {body.decode()}")
# In a real app, you might do more complex work here.
time.sleep(1) # Simulate work
print(" [x] Done")
# Tell RabbitMQ that this callback function should receive messages from our 'hello' queue.
channel.basic_consume(queue='hello',
auto_ack=True, # We'll discuss acknowledgements later
on_message_callback=callback)
print(' [*] Waiting for messages. To exit press CTRL+C')
# Enter a never-ending loop that waits for data and runs callbacks whenever necessary.
channel.start_consuming()
Try it out:
- Make sure your RabbitMQ Docker container is running.
- Open two terminals.
- In the first terminal, run the consumer:
python receive.py. It will hang, waiting for messages. - In the second terminal, run the producer:
python send.py. - Observe the output in the consumer's terminal. You can run
send.pymultiple times to see the messages being consumed.
4. Implementation: Asynchronous Trading Scenario
The "Hello World" example is great, but real-world services exchange structured data, not plain strings. Let's adapt our trading scenario to use this asynchronous pattern.
- The
MarketDataServicewill now be a producer. It will periodically generate a price for "AAPL" and publish it as a JSON message. - The
TradingServicewill be a consumer. It will listen for price updates and "execute" a trade when it receives one.
To send structured data, we'll serialize our Python dictionary to a JSON string before publishing. The consumer will then deserialize it back into a dictionary.
Python Microservices Full Course - Event-Driven Architecture with RabbitMQ
The 'Python Microservices Full Course' video demonstrates a more complex, realistic implementation. We'll focus on the part that shows how to send structured JSON data and process it in the consumer.
Watch from 01:03:01 to 01:07:30. This segment covers publishing a JSON object and then conditionally processing it in the consumer based on a message property. Pay close attention to the use of json.dumps() in the producer and json.loads() in the consumer.
Now, let's write the code for our scenario.
market_data_producer.py
import pika
import json
import time
import random
# Establish connection to RabbitMQ
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# Declare a queue for price updates
queue_name = 'price_updates'
channel.queue_declare(queue=queue_name)
print(f"Producer started. Publishing to '{queue_name}' queue. Press CTRL+C to exit.")
try:
while True:
# Generate a random price
price = round(random.uniform(150, 180), 2)
ticker = "AAPL"
# Create the message body as a dictionary
message = {
"ticker": ticker,
"price": price,
"timestamp": time.time()
}
# Publish the message, converting the dictionary to a JSON string
channel.basic_publish(exchange='',
routing_key=queue_name,
body=json.dumps(message),
# Add a content_type property for clarity
properties=pika.BasicProperties(
content_type='application/json',
))
print(f" [x] Sent: {message}")
# Wait for a few seconds before sending the next update
time.sleep(random.randint(1, 4))
except KeyboardInterrupt:
print("Producer stopped.")
finally:
# Cleanly close the connection
connection.close()
trading_consumer.py
import pika
import json
# Establish connection to RabbitMQ
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# Ensure the queue exists
queue_name = 'price_updates'
channel.queue_declare(queue=queue_name)
def trade_execution_callback(ch, method, properties, body):
"""Callback function to process a received price update."""
print(f" [x] Received message with content_type: {properties.content_type}")
# Deserialize the JSON string back into a Python dictionary
price_data = json.loads(body)
ticker = price_data.get('ticker')
price = price_data.get('price')
# Simulate executing a trade based on the received price
if ticker and price:
print(f" --> EXECUTING TRADE for {ticker} at ${price:.2f}")
else:
print(" [!] Received malformed message.")
print(" [x] Done processing message.")
# Set up the subscription to the queue
channel.basic_consume(queue=queue_name,
on_message_callback=trade_execution_callback,
auto_ack=True)
print(' [*] TradingService is waiting for price updates. To exit press CTRL+C')
channel.start_consuming()
Your Task
Now, let's run the full asynchronous system.
- Save the two files:
market_data_producer.pyandtrading_consumer.py. - Ensure RabbitMQ is running: Check that your Docker container is active.
- Start the consumer: In one terminal, run
python trading_consumer.py. It will connect and wait. - Start the producer: In a second terminal, run
python market_data_producer.py. - Observe: Watch your two terminals. You will see the producer sending price updates and, almost instantly, the consumer receiving them and "executing" trades. The producer and consumer are running independently, communicating only through the message queue. You can stop and restart the consumer, and it will pick up messages that were published while it was offline (as long as the queue is durable, a topic for another day!).
Conclusion
In this lesson, you successfully replaced a synchronous, tightly-coupled communication pattern with a robust, asynchronous one using a message queue. This is a foundational architectural shift in building distributed systems.
Key Takeaways:
- Asynchronous communication decouples services, allowing them to operate and scale independently.
- RabbitMQ acts as a broker, routing messages from producers to consumers via exchanges and queues.
- The Pika library is the standard way to interact with RabbitMQ in Python.
- The core workflow involves:
- Connecting to the broker.
- Declaring a queue.
- A producer using
basic_publishto send messages. - A consumer using
basic_consumewith a callback function to process messages.
- Structured data can be easily sent by serializing it to a format like JSON.
Preview of the Next Lesson:
We have now seen two ways for our services to communicate: synchronous request-response and asynchronous messaging. Which one is "better"? The answer, as is common in system design, is "it depends." In our next lesson, we will formally compare the trade-offs between synchronous and asynchronous communication patterns, analyzing them in terms of latency, throughput, coupling, and fault tolerance to help you decide which pattern is right for a given situation.
Can't find a good explanation? Sign up and we'll make it for you
Sign up