Event-Driven Setup with Docker, RabbitMQ & Node.js

Getting Our Hands Dirty: A Chill Walkthrough to Event-Driven Goodness with Some of Our Fave Tech Stacks! πŸš€

Introduction

Hey there, techie friends! πŸ™Œ Ever wondered how to make microservices chat like old pals at a reunion? Yep, that's where the event-driven magic comes into play. It's like having a party where everyone knows when to chime in without stepping on each other’s toes. πŸŽ‰ And guess what? We're about to dive deep into crafting our very own chat fest using RabbitMQ (our super cool middleman), Node.js (because, c'mon, who doesn't love JavaScript?), and Docker Compose (so everything fits together like a perfect puzzle 🧩). Buckle up, fam! It’s about to get exciting! πŸš—πŸ’¨

The Prerequisites You Need Before We Start

Alright, tech enthusiast! Before we step into the core of our journey, let's ensure our toolkit is ready:

  1. Docker & Docker Compose - Got these installed? Sweet! It's essential gear, so make sure they’re on your system.
  2. Node.js - Again, just a check. Have this buddy up and running.
  3. A Dash of Event-Driven Architectures - Now, for those who might need a wee refresher: event-driven architecture (EDA) is all about components in a system reacting to events. Think of it as a domino effect; when one piece (an event) falls, it triggers reactions in others. In the realm of microservices, it's about services responding seamlessly to changes, making our systems more agile and adaptable. It’s the heart of our discussion, so a solid grasp on this concept will serve you well. I will however give you an introduction of this architecture in this article.

Ready to roll? Let’s dive in! πŸŒŠπŸš€

Event-Driven Architecture: A Short Guide πŸ“˜

At its core, EDA is like a sophisticated notification system. Imagine various components in a system waiting for a bell to ring, signaling them to act. This "bell" is an event, and once it rings, specific components (let's call'em subscribers) know it's their time to move.

Now, How Does It Work? πŸ€”

Let me break it down step by step.

EDA Overview
  1. Event Producers: These are the components responsible for identifying and sending out events. Think of them as the "bell ringers".
  2. Event Channels: After the event is produced, it’s passed along these channels. Consider them as the "corridors" the bell’s sound travels through.
  3. Event Consumers: These are on the receiving end. They "listen" for these events and act accordingly. In our analogy, they're the ones springing into action when they hear the bell.

So let's dive a bit deeper. Event producers are like the starting point of any event-driven narrative, an event can be anything you can imagine in your system as an action. They're the entities in the system that generate or produce events based on certain activities or changes. This could be something as simple as a user clicking a button, or more complex scenarios like a system monitoring tool detecting a spike in traffic. So basically, a click from a user "triggers" an event.

Example

In an online shopping platform, let's say a customer places an order. The action of the order being placed can trigger our event producer to generate an event titled OrderPlaced.

Now, for the event to reach other places and to be consumed, it needs a way to be transmitted to the relevant parties. Event channels serve as the "pathways" for these events. They're responsible for efficiently routing events from producers to consumers. Some popular event channels/brokers include Apache Kafka and RabbitMQ.

So why using an event channel by an event broker is important? Or better said, how can a message broken be of help? Foremost, event brokers often ensure that messages aren't lost in case a consumer is down or busy. They can store the event until the consumer is ready to process it. Furthermore, they can determine which events are relevant for which consumers. Not every consumer needs to know about every event. So, they can be selective about who gets what.

In essence, while event channels are primarily about the pathway for event data, event brokers provide a set of services around the management, routing, and processing of those events.

Now, as for the consumers, they are the components or services waiting on the other end, eagerly listening for specific events. Once they detect an event of interest, they spring into action, processing the information in the event or triggering other activities or even events.

Example

Following our online shopping analogy, once the OrderPlaced event is detected by our inventory management system (the consumer), it might automatically reduce the stock count for the purchased item.

Bonus material πŸ₯³

Some EDA setups also use an event store – a specialized storage system optimized for storing events. This can serve multiple purposes:

  • Replay: If a system component crashes, events from the event store can be replayed to help the component recover to its last known state.
  • Audit: For regulatory or business reasons, having a log of all events can be invaluable.
  • Analytics: Analyzing past events can provide insights into system behavior and user activities.

Now, event stores are considered a part of the Event Sourcing Design Pattern, which can be implemented in an Event Driven Architecture.

Why EDA Rocks for Systems & Developers: 🎸

Well, I believe that advantages and disadvantages of EDA are more or less related to the context of implementation and their use case. However, there are three general benefits of designing an EDA system:

  • Flexibility & Scalability: EDA allows services to operate independently. This means scaling, modifying, or adding new services becomes way easier.
  • Loose Coupling: Services are less dependent on one another, making the system more resilient.
  • Real-time Responsiveness: EDA is great for systems where real-time updates and reactions are crucial.

Now, the flip side is,

  • Complexity: Setting up an EDA can be intricate, especially for larger systems.
  • Debugging Challenges: Tracing events through a system can sometimes feel like finding a needle in a haystack.
  • Ordering Issues: Ensuring events are processed in the correct sequence can be tricky.

Where EDA Shines most?

  1. E-commerce Platforms: Imagine stocking, ordering, payment, and shipping services all responding to user actions seamlessly.
  2. Real-time Analytics: Processing and reacting to data in real time? EDA's your champ.
  3. IoT (Internet of Things): Devices communicating with each other based on triggers, like your smart thermostat adjusting when your smartwatch says you're home.
  4. Social Media Platforms: User interactions like comments, likes, and shares being processed and notified in real time.

Understanding AMQP: The Backbone of RabbitMQ

AMQP stands for Advanced Message Queuing Protocol. It's an open standard application layer protocol for message-oriented middleware. The primary goal of AMQP is to provide a common, interoperable messaging solution that ensures message delivery and integrates various messaging features.

The AMQP is made of 6 components. Just as I mentioned earlier, Producers, Consumers and Channels/Brokers are part of AMQP. However, there's more to it:

  1. Producer: This is the system component that sends messages.
  2. Consumer: A system component that receives the messages.
  3. Broker: Think of it as a post office. The producer sends messages to the broker, and the broker routes and stores these messages in appropriate queues.
  4. Exchange: When a producer sends a message, it first goes to an exchange. Exchanges are responsible for routing messages to specific queues. They do this based on various criteria like topic, headers, or simply a direct match.
  5. Queue: It's a buffer that stores messages, waiting for consumers to consume them.
  6. Binding: This is a link between a queue and an exchange. It defines the rules for which messages from the exchange should be routed to the queue.

So basically, AMQP with its structured approach to message queuing, provides a robust and flexible framework to handle diverse messaging needs. In the vast world of distributed systems and microservices, protocols like AMQP are somewhat crucial to ensuring smooth and efficient communication.

Building The System

To harness the full potential of event-driven systems, especially those using AMQP, we'll start with setting up RabbitMQ. Docker Compose offers an excellent means to get RabbitMQ up and running in no time, ensuring our entire system can be reproducible across different environments.

Directory structure

Now first things first, let us start by making a directory for our project. I'd assume you're using a *nix bash.

$ mkdir sample-amqp-project
$ cd sample-amqp-project

Then initialize the project for nodejs:

$ npm init -y

Followed by making a docker compose file:

$ touch docker-compose.yml

RabbitMQ in docker compose

Now let us setup our RabbitMQ service.

version: '3'

services:
  rabbitmq:
    image: "rabbitmq:management"
    ports:
      - "15672:15672" # RabbitMQ Management Dashboard
      - "5672:5672"   # Default RabbitMQ broker port
    environment:
      RABBITMQ_DEFAULT_USER: "guest"
      RABBITMQ_DEFAULT_PASS: "guest"

And launch it by using docker compose up. I'd rather left -d to see the logs, however, this is your choice to have it or not.

Now you can access the RabbitMQ management console by opening http://localhost:15672/.

Producer app/script

With the RabbitMQ broker up and running, we're ready to start building producers and consumers using Node.js (or any other preferred platform), and dive deep into event-driven architectures.

For starter, I'm going to create a producer, which as mentioned before will generate messages. Remember that it isn't essential that producers and consumers get separate, rather they can actually live in the same app. This approach is often referred to as a "producer-consumer" pattern or a "work queue" pattern. But remember, even if the producer and consumer reside in the same service, they are often logically separated. This means that the functions or classes handling production and consumption of messages are distinct. This separation ensures clarity in codebase and makes it easier to manage, test, and debug.

For the clarity of the project, I will be separating them from each other. So I assume that one service sends a message, and another service only receives them.

Let us start by installing the required package, amqplib :

$ npm i amqplib

Then create the file producer.js :

const amqp = require('amqplib/callback_api');

const RABBITMQ_URL = 'amqp://guest:guest@localhost:5672';
const QUEUE_NAME = 'test-queue';

amqp.connect(RABBITMQ_URL, (err, connection) => {
    if (err) {
        console.error('[err]', err.message);
        process.exit(1);
    }

    connection.createChannel((err, channel) => {
        if (err) {
            console.error('[err]', err.message);
            process.exit(1);
        }

        const message = {
            type: 'SomeEvent',
            timestamp: Date.now(),
            data: {
                userId: '12345',
                name: 'Aien'
            }
        };

        // Ensure the queue is declared
        channel.assertQueue(QUEUE_NAME, { durable: true });

        // Send message to the queue
        channel.sendToQueue(QUEUE_NAME, Buffer.from(JSON.stringify(message)));

        console.log(`[inf] Sent message: ${JSON.stringify(message)}`);

        setTimeout(() => {
            connection.close(); 
            process.exit(0);
        }, 500);
    });
});

After executing node producer.js you should see the message [inf] Sent message: ... in the console, indicating that the message has been sent to RabbitMQ.

Consumer app/script

Following up from our producer script, let's create a consumer that listens to the messages sent to our RabbitMQ queue and processes them.

Given that we've already initialized our Node.js project and installed amqplib, we can directly jump into writing our consumer script.

Let's write the script named consumer.js:

const amqp = require('amqplib/callback_api');

const RABBITMQ_URL = 'amqp://guest:guest@localhost:5672';
const QUEUE_NAME = 'test-queue';

amqp.connect(RABBITMQ_URL, (err, connection) => {
    if (err) {
        console.error('[err]', err.message);
        process.exit(1);
    }

    connection.createChannel((err, channel) => {
        if (err) {
            console.error('[err]', err.message);
            process.exit(1);
        }

        // Ensure the queue is declared
        channel.assertQueue(QUEUE_NAME, { durable: true });

        // Prefetch setting ensures that a consumer only gets a defined number of messages at a time
        // Setting it to 1 makes sure it processes one message at a time
        channel.prefetch(1);

        console.log(`[inf] Waiting for messages in ${QUEUE_NAME}. To exit press CTRL+C`);

        channel.consume(QUEUE_NAME, (msg) => {
            const receivedMessage = JSON.parse(msg.content.toString());
            console.log(`[inf] Received message: ${JSON.stringify(receivedMessage)}`);

            // Mock processing time
            setTimeout(() => {
                console.log("[inf] Message processed.");
                channel.ack(msg); // Acknowledge the message as processed
            }, 2000); // 2-second delay to simulate some processing
        }, {
            noAck: false // This setting ensures messages are acknowledged after processing. This way, if the consumer crashes during processing, the message will be redelivered.
        });
    });
});

After executing node consumer.js this script will keep running, waiting for messages. When a message arrives (perhaps from our earlier producer script), it will process the message and log it.

With this setup, you've now got a simple producer-consumer system in place. In real-world scenarios, you'll likely integrate more complex logic into the consumer – perhaps database interactions, further message publishing, API calls, etc. But this provides a solid foundational understanding getting started!

Conclusion

And there we have it, folks! We've embarked on an enlightening journey, starting with a simple spark of an idea and finishing with a full-fledged event-driven system utilizing Docker Compose, RabbitMQ, and Node.js.

Throughout this process, we unearthed the beauty of asynchronous communication, letting our microservices chat without the awkward tight coupling. Our producer gleefully sends messages, while our consumer patiently listens and acts, all while keeping our setup scalable and maintainable.

But remember, this is just the tip of the iceberg. The world of event-driven architectures is vast, and there's so much more to explore. From diving deeper into RabbitMQ's features, like topic exchanges and routing keys, to integrating more services and scaling our infrastructure β€” the sky's the limit. And of course, there are various other message brokers and patterns to experiment with.

So, what's next for you? Perhaps you're thinking about integrating this approach into your next big project or refining what we've built to make it production-ready. Whatever it is, always keep in mind the fundamentals we covered and continue building upon them.

Thank you for coding along! I'm eager to hear about your adventures. Share your experiences, ask questions, or just geek out with fellow readers in the comments section. πŸš€

Until next time, keep those events flowing and happy coding! πŸŽ‰πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»