Mastering Microservices: A Hands-On Tutorial with Node.js, RabbitMQ, Nginx, and Docker
David Chibueze Ndubuisi
Posted on February 8, 2024
Microservices architecture is a smart way to design applications by breaking them into smaller, independent components—microservices—each focusing on a specific task. These microservices operate autonomously, allowing for independent development, deployment, and scaling. This flexibility enables developers to tweak or update specific components without overhauling the entire application. This is in stark contrast to traditional monolithic architecture, where the whole application is treated as one indivisible unit, often leading to inflexibility and scalability issues.
Before diving into this tutorial, if you find microservices mysterious, check out my previous article for a detailed explanation. In this hands-on tutorial, we'll build a real-time chat server using Node.js, Socket.io, RabbitMQ, and Docker. Get ready for a practical journey into the world of microservices! Let's begin.
Prerequisites
Before getting into the development process, ensure you have the following prerequisites installed on your machine and ready:
1. Node.js and npm
Make sure you have Node.js and npm (Node Package Manager) installed. You can download them from the Node.js official website.
# Check Node.js and npm are installed correctly
node -v
npm -v
2. TypeScript
As we'll be using TypeScript for enhanced development, install it globally using npm.
# Install TypeScript globally
npm install -g typescript
# Check TypeScript version
tsc -v
3. Docker
Docker will be used for containerization. Download and install Docker from Docker's official website.
4. MongoDB
Ensure you have MongoDB installed for data storage. You can download MongoDB Community Server from MongoDB's official website or use the cloud cluster.
5. Nginx
For setting up Nginx as a reverse proxy, you can follow the installation instructions on Nginx's official documentation.
6. Postman (Optional)
To test the API endpoints, you can use Postman. Download and install Postman from Postman's official website.
Once you have these prerequisites in place, you'll be ready to proceed with setting up and developing our microservices-based chat server. Let's move on to the next steps!
Setting Up the Microservices
Before we start coding, let's understand the project requirements and goals. We aim to build a real-time chat server using a microservices architecture. The key requirements include:
- Real-Time Communication: The chat server should facilitate instant messaging between users, ensuring a seamless real-time experience.
- Scalability: The architecture should support easy scaling of individual components to accommodate a growing user base without affecting the entire system.
- Modularity: We want our system to be modular, allowing us to update, deploy, and scale each microservice independently.
To achieve our goals, we'll leverage the following technologies and tools:
-
Node.js for Backend Services
Node.js is a powerful and efficient JavaScript runtime known for its non-blocking, event-driven architecture. It's an excellent choice for building scalable and responsive backend services.
-
TypeScript for Enhanced Development
We'll be using TypeScript to enhance the development process by introducing static typing, making our code more robust and maintainable.
-
Socket.io for Real-Time Communication
Socket.io is a library that enables real-time, bidirectional communication between clients and servers. It's a crucial component for building the real-time chat features of our microservices.
-
Docker for Containerization
Docker simplifies the deployment process by encapsulating each microservice into a container. Containers ensure consistency across different environments, making it easy to deploy and scale our services.
-
MongoDB for Data Storage
MongoDB, a NoSQL database, provides a flexible and scalable solution for storing our chat data. Its document-oriented structure aligns well with the dynamic nature of chat messages.
-
Nginx as a Reverse Proxy
Nginx acts as a reverse proxy to handle incoming requests and distribute them to the appropriate microservices. This enhances security and load balancing and simplifies the overall architecture.
By combining these technologies, we create a robust foundation for our microservices-based chat server. In the next sections, we'll delve into the implementation details. Let's get started!
Parent Folder Structure
Before we proceed, let's ensure our project structure is organized. Create a housing folder named chat-server
that will contain all our microservices. Inside this folder, add the .gitignore
file to exclude unnecessary files from version control. Additionally, include a docker-compose.yml
file for managing the Docker configurations of our microservices.
Navigate to your preferred directory and use the following commands to achieve this:
mkdir chat-server && cd chat-server && touch .gitignore && touch docker-compose.yml
Now, open the .gitignore
file and add the following entries:
**/node_modules
**/.env
**/build
These entries ensure that the node_modules
, .env
, and build
folders are excluded from being pushed to GitHub. This organized structure lays the foundation for a streamlined development process as we progress with building our microservices. Now, let’s proceed with our User Service.
User Service
In this section, we will dive into the development of the User Service, a fundamental component of our microservices architecture. The User Service is responsible for handling user registration, authentication, and the storage of user-related data in MongoDB.
Designing the User Service
We will start by designing the structure of our User Service, creating essential folders and files that will form the backbone of our microservice. This includes configuration settings, controllers for user authentication, a MongoDB user model, Express routes, utility functions for JWT and password handling, and the main server setup.
Here’s our User service’s folder structure:
Now, use the following commands to set up your User Service project structure, ensure you’re in your project’s root directory:
Create and navigate to your user-service
folder:
mkdir user-service && cd user-service
Next, create the src
folder and navigate to it:
mkdir -p src/config src/controllers src/services src/database/models src/middleware src/routes src/utils
cd src
Create individual files:
touch config/config.ts controllers/AuthController.ts services/RabbitMQService.ts database/models/UserModel.ts database/connection.ts database/index.ts middleware/index.ts routes/authRoutes.ts utils/index.ts server.ts
Create Dockerfile
, .dockerignore
, and .env
files:
cd ..
touch Dockerfile .dockerignore .env
Here is the description for each file and folder's role:
-
user-service
folder:- Description: The main folder for the User Service, houses all related files and folders.
-
src
folder:- Description: Contains the source code for the User Service, organized into subfolders for different functionalities.
-
config
folder:- Description: Stores configuration settings for the User Service.
-
controllers
folder:- Description: Holds the AuthController file and handles registration and login logic.
-
services
folder:- Description: Holds our RabbitMQService file and is responsible for handling RabbitMQ interactions.
-
database
folder:-
models
folder:- Description: Contains the UserModel file, defining the Mongoose user model.
-
connection.ts
file:- Description: Manages the connection to the MongoDB database.
-
index.ts
file:- Description: Acts as the entry point for the database-related functionality.
-
-
middleware
folder:-
index.ts
file:- Description: Serves as the entry point for middleware-related functionality.
-
-
routes
folder:-
authRoutes.ts
file:- Description: Defines Express routes for user authentication.
-
-
utils
folder:-
index.ts
file:- Description: Contains utility functions, such as custom error handler and password-related functions.
-
-
server.ts
file:- Description: Serves as the entry point for the Express app setup.
-
Dockerfile
:- Description: Specifies the Docker instructions for containerizing the User Service.
-
.dockerignore
file:- Description: Lists files and directories to be excluded from the Docker build context.
-
.env
file:- Description: Stores environment variables for the User Service.
Next, let’s set up our environment and install the necessary dependencies for the User Service. Use the following command, ensuring you’re in the user-service
root directory:
Initialize Node.js project
npm init -y
Install dependencies
npm install amqplib bcryptjs dotenv express jsonwebtoken mongoose nodemon ts-node typescript validator @types/express @types/validator @types/amqplib @types/bcryptjs @types/jsonwebtoken
The above command sequence will create a package.json
file and install the required Node.js packages for the User Service. Now, let's proceed with setting up TypeScript:
Install TypeScript globally if you don't have it already
npm install -g typescript
Initialize TypeScript configuration
tsc --init
Open the generated tsconfig.json
file and update it with the following configuration:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./build",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"tests"
]
}
Now, let's set up the .env
file by adding the following content:
NODE_ENV="development"
PORT=8081
JWT_SECRET="{{YOUR_SECRET_KEY}}"
MONGO_URI="{{YOUR_MONGODB_URI}}"
MESSAGE_BROKER_URL="{{YOUR_MESSAGE_BROKER_URL}}"
Replace YOUR_SECRET_KEY
with a strong secret key, YOUR_MONGODB_URI
with your MongoDB URI, and YOUR_MESSAGE_BROKER_URL
with your RabbitMQ message broker URI. You can obtain it from their official website, by following these steps:
- Visit CloudAMQP’s website, log in or register an account.
- Create a new instance with a chosen name, select a region, and create the instance.
- Copy the message broker URL from your instance details.
Finally, let’s set up our config.ts
file—located in the src/config
folder. Copy and paste the following code:
import { config } from "dotenv";
const configFile = `./.env`;
config({ path: configFile });
const { MONGO_URI, PORT, JWT_SECRET, NODE_ENV, MESSAGE_BROKER_URL } =
process.env;
export default {
MONGO_URI,
PORT,
JWT_SECRET,
env: NODE_ENV,
msgBrokerURL: MESSAGE_BROKER_URL,
};
With this setup, we can start writing our User Service code in TypeScript. Let's get down to business!
User Model
To set up our User model, open your UserModel.ts
file, located in the src/database/models
folder, then copy and paste the following code:
import mongoose, { Schema, Document } from "mongoose";
import validator from "validator";
export interface IUser extends Document {
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
const UserSchema: Schema = new Schema(
{
name: {
type: String,
trim: true,
required: [true, "Name must be provided"],
minlength: 3,
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
validate: [validator.isEmail, "Please provide a valid email."],
},
password: {
type: String,
trim: false,
required: [true, "Password must be provided"],
minlength: 8,
},
},
{
timestamps: true,
}
);
const User = mongoose.model<IUser>("User", UserSchema);
export default User;
Here’s a breakdown of what’s going on in the above file:
-
IUser
interface outlines the structure of the User document, including fields likename
,email
,password
,createdAt
, andupdatedAt
. -
UserSchema
utilizes Mongoose'sSchema
class, specifying types and constraints for each User document field. -
name
field requires a minimum length of 3 characters and must be trimmed. -
email
field is mandatory, unique, and validated as a valid email usingvalidator.isEmail
. -
password
field is mandatory with a minimum length of 8 characters. -
timestamps: true
in the schema adds automaticcreatedAt
andupdatedAt
fields to track creation and update times. -
User
model is created using Mongoose'smodel
function, associating it withIUser
interface andUserSchema
. - The final step exports the
User
model for use in other parts of the application.
Our User Model defines the structure and validation rules for user documents in MongoDB, providing a foundation for handling user data in the User Service.
Database Connection
Open the connection.ts
located in the src/database
folder and add the following code:
import mongoose from "mongoose";
import config from "../config/config";
export const connectDB = async () => {
try {
console.info("Connecting to database..." + config.MONGO_URI);
await mongoose.connect(config.MONGO_URI!);
console.info("Database connected");
} catch (error) {
console.error(error);
process.exit(1);
}
};
In the above file, we defined an asynchronous connectDB
function that establishes a connection to the MongoDB database. It uses the mongoose.connect
method to connect to the database using the MongoDB URI specified in the config
file.
Next, open the index.ts
file located in the src/database
folder and export everything by adding the following code:
import User, { IUser } from "./models/UserModel";
import { connectDB } from "./connection";
export { User, IUser, connectDB };
AuthController
With our database set up, the next focus is on defining authentication methods within the AuthController.ts
file, located in the src/controllers
folder. Copy and paste the following code:
import express, { Request, Response } from "express";
import jwt from "jsonwebtoken";
import { User } from "../database";
import { ApiError, encryptPassword, isPasswordMatch } from "../utils";
import config from "../config/config";
import { IUser } from "../database";
const jwtSecret = config.JWT_SECRET as string;
const COOKIE_EXPIRATION_DAYS = 90; // cookie expiration in days
const expirationDate = new Date(
Date.now() + COOKIE_EXPIRATION_DAYS * 24 * 60 * 60 * 1000
);
const cookieOptions = {
expires: expirationDate,
secure: false,
httpOnly: true,
};
const register = async (req: Request, res: Response) => {
try {
const { name, email, password } = req.body;
const userExists = await User.findOne({ email });
if (userExists) {
throw new ApiError(400, "User already exists!");
}
const user = await User.create({
name,
email,
password: await encryptPassword(password),
});
const userData = {
id: user._id,
name: user.name,
email: user.email,
};
return res.json({
status: 200,
message: "User registered successfully!",
data: userData,
});
} catch (error: any) {
return res.json({
status: 500,
message: error.message,
});
}
};
const createSendToken = async (user: IUser, res: Response) => {
const { name, email, id } = user;
const token = jwt.sign({ name, email, id }, jwtSecret, {
expiresIn: "1d",
});
if (config.env === "production") cookieOptions.secure = true;
res.cookie("jwt", token, cookieOptions);
return token;
};
const login = async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email }).select("+password");
if (
!user ||
!(await isPasswordMatch(password, user.password as string))
) {
throw new ApiError(400, "Incorrect email or password");
}
const token = await createSendToken(user!, res);
return res.json({
status: 200,
message: "User logged in successfully!",
token,
});
} catch (error: any) {
return res.json({
status: 500,
message: error.message,
});
}
};
export default {
register,
login,
};
The above file contains functions for user registration and login. The register
function checks if the user already exists, creates a new user, and responds with success or error messages. The login
function verifies user credentials, creates a JWT token, and sends it as a cookie upon successful login. The code ensures a secure and efficient authentication process, with a clear separation of responsibilities.
Routes
Now, let’s define the routes that utilize the controller methods. In the authRoutes.ts
file located in the src/routes
folder, add the following code:
import { Router } from "express";
import AuthController from "../controllers/AuthController";
const userRouter = Router();
userRouter.post("/register", AuthController.register);
userRouter.post("/login", AuthController.login);
export default userRouter;
This file sets up routes for user registration (/register
) and user login (/login
). These routes are associated with the corresponding methods defined in the AuthController
. The Router
from Express is used to create and manage these routes. With this setup, our Express app can now handle incoming HTTP requests to these routes and invoke the appropriate controller methods for user registration and login.
Utils
With our AuthController
and routes all set, let’s work on our utils. Open the index.ts
file located in the src/utils
directory and add the following code:
import bcrypt from "bcryptjs";
class ApiError extends Error {
statusCode: number;
isOperational: boolean;
constructor(
statusCode: number,
message: string | undefined,
isOperational = true,
stack = ""
) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
const encryptPassword = async (password: string) => {
const encryptedPassword = await bcrypt.hash(password, 12);
return encryptedPassword;
};
const isPasswordMatch = async (password: string, userPassword: string) => {
const result = await bcrypt.compare(password, userPassword);
return result;
};
export { ApiError, encryptPassword, isPasswordMatch };
In this utility module, we've defined three key elements:
-
ApiError
Class: This custom error class is designed to handle API-related errors. It takes parameters such as the status code, message, operational status, and stack trace. -
encryptPassword
Function: This asynchronous function uses bcrypt to hash passwords. It takes a plain text password as input and returns the hashed password. -
isPasswordMatch
Function: Another asynchronous function that checks if a provided password matches the stored hash. It takes a plain text password and the stored hashed password as input, returning a boolean result.
These utility functions play a crucial role in enhancing the security and error-handling capabilities of our User Service. With these utilities, our application gains robust password encryption and consistent error management.
Middleware
In the index.ts
file located in the src/middleware
folder, add the following code:
import { ErrorRequestHandler } from "express";
import { ApiError } from "../utils";
export const errorConverter: ErrorRequestHandler = (err, req, res, next) => {
let error = err;
if (!(error instanceof ApiError)) {
const statusCode =
error.statusCode ||
(error instanceof Error
? 400 // Bad Request
: 500); // Internal Server Error
const message =
error.message ||
(statusCode === 400 ? "Bad Request" : "Internal Server Error");
error = new ApiError(statusCode, message, false, err.stack.toString());
}
next(error);
};
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
let { statusCode, message } = err;
if (process.env.NODE_ENV === "production" && !err.isOperational) {
statusCode = 500; // Internal Server Error
message = "Internal Server Error";
}
res.locals.errorMessage = err.message;
const response = {
code: statusCode,
message,
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
};
if (process.env.NODE_ENV === "development") {
console.error(err);
}
res.status(statusCode).json(response);
next();
};
In these middleware functions:
-
errorConverter
: converts non-API errors to API errors, ensuring consistency in error handling. It checks if the error is an instance ofApiError
and, if not, creates a newApiError
instance with relevant details. -
errorHandler
: handles API errors. It sets the appropriate status code and message for non-operational errors in production. The response payload includes the error code, message, and, in development mode, the stack trace. The error is logged in the console in development mode. This middleware ensures a standardized and informative response for API errors.
RabbitMQ Services
Now, let’s set up our RabbitMQ services. Open your RabbitMQService.ts
file located in the src/services
folder and add the following code:
import amqp, { Channel, Connection } from "amqplib";
import config from "../config/config";
import { User } from "../database";
import { ApiError } from "../utils";
class RabbitMQService {
private requestQueue = "USER_DETAILS_REQUEST";
private responseQueue = "USER_DETAILS_RESPONSE";
private connection!: Connection;
private channel!: Channel;
constructor() {
this.init();
}
async init() {
// Establish connection to RabbitMQ server
this.connection = await amqp.connect(config.msgBrokerURL!);
this.channel = await this.connection.createChannel();
// Asserting queues ensures they exist
await this.channel.assertQueue(this.requestQueue);
await this.channel.assertQueue(this.responseQueue);
// Start listening for messages on the request queue
this.listenForRequests();
}
private async listenForRequests() {
this.channel.consume(this.requestQueue, async (msg) => {
if (msg && msg.content) {
const { userId } = JSON.parse(msg.content.toString());
const userDetails = await getUserDetails(userId);
// Send the user details response
this.channel.sendToQueue(
this.responseQueue,
Buffer.from(JSON.stringify(userDetails)),
{ correlationId: msg.properties.correlationId }
);
// Acknowledge the processed message
this.channel.ack(msg);
}
});
}
}
const getUserDetails = async (userId: string) => {
const userDetails = await User.findById(userId).select("-password");
if (!userDetails) {
throw new ApiError(404, "User not found");
}
return userDetails;
};
export const rabbitMQService = new RabbitMQService();
In our RabbitMQ service:
- The
RabbitMQService
class initializes the RabbitMQ connection and channels. It establishes a connection to the RabbitMQ server, creates channels, and asserts the existence of the request and response queues. - The
listenForRequests
method listens for incoming messages on the request queue, processes the user details request, sends a response to the response queue, and acknowledges the processed message. - The
getUserDetails
function fetches user details from MongoDB based on the provided user ID. - Finally, an instance of
RabbitMQService
is created to initialize the RabbitMQ connection when the file is imported.
How RabbitMQ Works
Imagine RabbitMQ as a post office for your applications. It helps different parts of your software communicate by passing messages between them.
-
The Post Office (Message Broker): RabbitMQ is like the central post office. It sits in the middle, ready to receive, store, and deliver messages. In our code, the
RabbitMQService
class represents this central post office. It initializes the connection to RabbitMQ and sets up the necessary queues. - The Sender (Producer): The sender is like the part of your application that wants to send a message. In our code, the sender is not explicitly shown but is represented by the concept of producing messages. Messages are sent to the RabbitMQ exchange.
-
The Message (Letter): Messages are the information we want to share. In the code, messages are JSON objects containing a
userId
field. These messages are sent to the RabbitMQ request queue. -
The Exchange (Mailbox): Before sending the letter, you put it in a mailbox. In RabbitMQ, this mailbox is called an exchange. There can be different types of mailboxes for different purposes. In our code, the
requestQueue
serves as the mailbox (exchange) where messages are sent for processing. -
Routing (Delivery Instructions): Sometimes, you want your letter to go to a specific department or person. In RabbitMQ, routing specifies where a message should go. In our code, routing is not explicitly shown, but the
requestQueue
serves the purpose of directing messages to the proper destination. -
The Receiver (Consumer): On the other end, someone is waiting to receive the letter. The receiver is like the part of your application waiting to receive and process the message. In our code, the
listenForRequests
function acts as the consumer, waiting for messages in therequestQueue
. -
Queues (Mail Sorting): Before reaching the receiver, letters are sorted in queues. Each type of letter (message) might have its queue. Here, messages are placed in the
requestQueue
before being processed. -
Delivery to Consumers: Once sorted, the letters are delivered to the respective receivers (consumers) who can then process the information. The
listenForRequests
function processes messages from therequestQueue
and delivers the user details response to theresponseQueue
. -
Acknowledgment (Received Confirmation): In RabbitMQ, acknowledgments confirm that a message has been successfully processed. Our code uses
this.channel.ack(msg)
to acknowledge that the processed message has been received.
In summary, the RabbitMQService.ts
code simulates a scenario where messages (user details requests) are sent to a central post office (RabbitMQ), sorted in a mailbox (queue), and then processed by a receiver (consumer). The acknowledgment mechanism ensures that the processing status is communicated back to RabbitMQ. This setup enables efficient communication between different parts of your application using RabbitMQ.
Server Setup and Initialization
With our RabbitMQService.ts
in place, the final piece of the puzzle is the server.ts
file, which orchestrates the setup of our Express server, connects to the database and initializes the RabbitMQ client. Here's the code:
import express, { Express } from "express";
import { Server } from "http";
import userRouter from "./routes/authRoutes";
import { errorConverter, errorHandler } from "./middleware";
import { connectDB } from "./database";
import config from "./config/config";
import { rabbitMQService } from "./services/RabbitMQService";
const app: Express = express();
let server: Server;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(userRouter);
app.use(errorConverter);
app.use(errorHandler);
connectDB();
server = app.listen(config.PORT, () => {
console.log(`Server is running on port ${config.PORT}`);
});
const initializeRabbitMQClient = async () => {
try {
await rabbitMQService.init();
console.log("RabbitMQ client initialized and listening for messages.");
} catch (err) {
console.error("Failed to initialize RabbitMQ client:", err);
}
};
initializeRabbitMQClient();
const exitHandler = () => {
if (server) {
server.close(() => {
console.info("Server closed");
process.exit(1);
});
} else {
process.exit(1);
}
};
const unexpectedErrorHandler = (error: unknown) => {
console.error(error);
exitHandler();
};
process.on("uncaughtException", unexpectedErrorHandler);
process.on("unhandledRejection", unexpectedErrorHandler);
This server.ts
file does the following:
- Express Server Setup: Initializes an Express app, sets up middleware for JSON and form data parsing, adds routes, and includes error handling middleware.
-
Database Connection: Connects to the MongoDB database using the
connectDB
function. -
Server Start: Starts the Express server, listening on the specified port from the
config
file. -
RabbitMQ Client Initialization: Calls the
initializeRabbitMQClient
function to initialize the RabbitMQ client using ourRabbitMQService
. - Graceful Shutdown: Implements a graceful shutdown mechanism. When the server is closed (either intentionally or due to an error), it logs the closure and exits the process.
- Error Handling: Registers event listeners for uncaught exceptions and unhandled rejections, ensuring proper error handling and graceful shutdown in case of unexpected errors.
With this file, our microservice architecture for the User Service is complete and ready to handle user registration, login, and RabbitMQ communication.
Script Update
After setting up our server, it's important to update the package.json
file with scripts that facilitate development, building, and starting the server. Open your package.json
file and add the following code:
"scripts": {
"dev": "NODE_ENV=development nodemon src/server.ts",
"build": "rm -rf build/ && tsc -p .",
"start": "NODE_ENV=production nodemon build/server.js"
},
Then, ensure that the "main"
tag is set to "src/server.ts"
. These scripts provide convenient commands for different stages of the development process:
-
"dev"
: Starts the server in development mode usingnodemon
. -
"build"
: Clears the existing build folder and compiles TypeScript files using the TypeScript compiler (tsc
). -
"start"
: Starts the server in production mode using the compiled JavaScript files.
This setup enhances the development workflow and allows for easy deployment in production.
Now, let’s see our User service in action. Open your terminal and make sure you’re in the user-service
root directory. Run the following command to start the development server based on your package manager:
npm run dev
or
yarn dev
This command initiates the server in development mode, enabling live reloading with nodemon
. Once the server is running, you can use Postman or any API tool to test the API endpoints and ensure that everything is functioning as expected. Test the /register
and /login
endpoints to verify user registration and login functionality.
Chat Service
The Chat Service enables real-time communication between users, fostering a dynamic and interactive user experience within our application. We'll structure the project, set up dependencies, and create the necessary files and folders to establish a robust foundation for building the Chat Service. Follow the step-by-step guide to integrate this service into our microservices ecosystem seamlessly. Let's get started!
Here’s our Chat service’s structure:
Now, use the following commands to set up your Chat Service project structure, ensure you’re in your project’s root directory:
Create and navigate to your chat-service
folder:
mkdir chat-service && cd chat-service
Create the src
folder and navigate to it:
mkdir -p src/config src/controllers src/services src/database/models src/middleware src/routes src/utils
cd src
Create individual files:
touch config/config.ts controllers/MessageController.ts services/RabbitMQService.ts database/models/MessageModel.ts database/connection.ts database/index.ts middleware/index.ts routes/messageRoutes.ts utils/apiError.ts utils/messageHandler.ts utils/userStatusStore.ts utils/index.ts app.ts server.ts
Create Dockerfile
, .dockerignore
, and .env
files:
cd ..
touch Dockerfile .dockerignore .env
Here is the description for each file and folder:
-
config
: contains the configuration files for our Chat Service. Theconfig.ts
file holds environment variables and configurations. -
controllers
: Here, we manage the business logic for handling messages in theMessageController.ts
file. This is where operations on messages are defined. -
services
: TheRabbitMQService.ts
file in this folder handles interactions with the RabbitMQ message broker, facilitating communication between microservices. -
database/models
: In this folder, we define the data model for messages usingMessageModel.ts
. -
database
: Theconnection.ts
andindex.ts
files handle database connections and export models. -
middleware
: Theindex.ts
file in this folder consolidates the middleware functions we will use later on. -
routes
: ThemessageRoutes.ts
file in this folder defines the routes and their corresponding handlers for message-related operations. -
utils
: This folder contains utility files such asapiError.ts
for handling API errors,messageHandler.ts
for managing incoming messages, anduserStatusStore.ts
for tracking user statuses. Theindex.ts
file consolidates exports from the utils folder. -
app.ts
: This file serves as the entry point for our Chat Service application. -
server.ts
: Here, we set up the Express server, configure middleware, and establish routes.
These files and folders collectively form the foundational structure for our Chat Service, allowing for the organized development of features and functionalities.
Next, let’s set up our environment and install the necessary dependencies. Use the following command, ensuring you’re in the chat-service
root directory:
Initialize Node.js project:
npm init -y
Install dependencies
npm install amqplib dotenv express jsonwebtoken mongoose nodemon ts-node typescript socket.io uuid @types/express @types/amqplib @types/jsonwebtoken @types/uuid
This command sequence will create a package.json
file and install the required Node.js packages for the Chat Service.
Let's proceed with the TypeScript configuration. Run the following command:
tsc --init
Open the generated tsconfig.json
file and update it with the following configuration:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./build",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"tests"
]
}
Add the following code to your .env
file:
NODE_ENV="development"
PORT=8082
JWT_SECRET="{{YOUR_SECRET_KEY}}"
MONGO_URI="{{YOUR_MONGODB_URI}}"
MESSAGE_BROKER_URL="{{YOUR_MESSAGE_BROKER_URL}}"
Replace YOUR_SECRET_KEY
with a strong secret key, YOUR_MONGODB_URI
with your MongoDB URI, and YOUR_MESSAGE_BROKER_URL
with the same RabbitMQ message broker URL you used in the User Service.
Now, let’s set up our config.ts
file located in the src/config
folder. Open the file and add the following code:
import { config } from "dotenv";
const configFile = `./.env`;
config({ path: configFile });
const { MONGO_URI, PORT, JWT_SECRET, NODE_ENV, MESSAGE_BROKER_URL } =
process.env;
const queue = { notifications: "NOTIFICATIONS" };
export default {
MONGO_URI,
PORT,
JWT_SECRET,
env: NODE_ENV,
msgBrokerURL: MESSAGE_BROKER_URL,
queue,
};
In this config.ts
file, we're configuring the environment variables needed for the Chat Service. We start by loading these variables from a specified file using the dotenv
library. Once loaded, we extract key information such as the MongoDB URI, port, JWT secret, environment type, and the message broker URL. Additionally, we define a queue
object, specifically a notifications
queue, which will be utilized in our message broker. This is similar to what we did in the User service.
This centralized configuration approach allows us to easily manage and modify settings as required. It ensures that sensitive information, like the MongoDB URI or JWT secret, is kept secure by loading it from environment variables. The queue
object represents specific queues used for communication within the message broker, adding clarity to our messaging setup.
Message Model
Everything is set, let’s set up our Message model. Open the MessageModel.ts
file located in the src/database/models
folder and add the following code:
import mongoose, { Schema, Document } from "mongoose";
enum Status {
NotDelivered = "NotDelivered",
Delivered = "Delivered",
Seen = "Seen",
}
export interface IMessage extends Document {
senderId: string;
receiverId: string;
message: string;
status: Status;
createdAt: Date;
updatedAt: Date;
}
const MessageSchema: Schema = new Schema(
{
senderId: {
type: String,
required: true,
},
receiverId: {
type: String,
required: true,
},
message: {
type: String,
required: true,
},
status: {
type: String,
enum: Object.values(Status),
default: Status.NotDelivered,
},
},
{
timestamps: true,
}
);
const Message = mongoose.model<IMessage>("Message", MessageSchema);
export default Message;
This MessageModel.ts
file defines the schema for our messages. Each message has a sender ID, receiver ID, message content, status (which can be "NotDelivered," "Delivered," or "Seen"), and timestamps for creation and last update. The use of TypeScript interfaces ensures that our document adheres to a specific structure. The enum
for status provides clear options and a default value, enhancing code readability. This schema will be used to interact with the MongoDB database for storing and retrieving messages.
Database Connection
Open the connection.ts
in the src/database
folder and add the following code just like we did in User service:
import mongoose from "mongoose";
import config from "../config/config";
export const connectDB = async () => {
try {
console.info("Connecting to database..." + config.MONGO_URI);
await mongoose.connect(config.MONGO_URI!);
console.info("Database connected");
} catch (error) {
console.error(error);
process.exit(1);
}
};
Then, export by adding the following code to the index.ts
file located in the src/database
folder:
import Message from "./models/MessageModel";
import { connectDB } from "./connection";
export { Message, connectDB };
Message Controller
Now that we have the database all set. Let’s work on the Message controller, open the MessageController.ts
file and add the following code:
import { Request, Response } from "express";
import { AuthRequest } from "../middleware";
import { Message } from "../database";
import { ApiError, handleMessageReceived } from "../utils";
const send = async (req: AuthRequest, res: Response) => {
try {
const { receiverId, message } = req.body;
const { _id, email, name } = req.user;
validateReceiver(_id, receiverId);
const newMessage = await Message.create({
senderId: _id,
receiverId,
message,
});
await handleMessageReceived(name, email, receiverId, message);
return res.json({
status: 200,
message: "Message sent successfully!",
data: newMessage,
});
} catch (error: any) {
return res.json({
status: 500,
message: error.message,
});
}
};
const validateReceiver = (senderId: string, receiverId: string) => {
if (!receiverId) {
throw new ApiError(404, "Receiver ID is required.");
}
if (senderId == receiverId) {
throw new ApiError(400, "Sender and receiver cannot be the same.");
}
};
const getConversation = async (req: AuthRequest, res: Response) => {
try {
const { receiverId } = req.params;
const senderId = req.user._id;
const messages = await Message.find({
$or: [
{ senderId, receiverId },
{ senderId: receiverId, receiverId: senderId },
],
});
return res.json({
status: 200,
message: "Messages retrieved successfully!",
data: messages,
});
} catch (error: any) {
return res.json({
status: 500,
message: error.message,
});
}
};
export default {
send,
getConversation,
};
This MessageController.ts
file defines methods for handling messages. The send
method allows users to send messages to a specified receiver, and it performs validation to ensure the receiver ID is provided and not the same as the sender's ID. Additionally, there’s a handleMessageReceived
function that handles and notifies the receiver when he/she receives a new message using our RabbitMQService
which we’ll define later.
The getConversation
method retrieves messages between two users based on their IDs. Error handling is included to manage potential issues during these operations.
Message Routes
Add the following code to the messageRoutes.ts
file located in the src/routes
folder:
import { Router } from "express";
import MessageController from "../controllers/MessageController";
import { authMiddleware } from "../middleware";
const messageRoutes = Router();
// @ts-ignore
messageRoutes.post("/send", authMiddleware, MessageController.send);
messageRoutes.get(
"/get/:receiverId",
// @ts-ignore
authMiddleware,
MessageController.getConversation
);
export default messageRoutes;
This messageRoutes.ts
file sets up routes for sending messages (/send
) and retrieving conversations (/get/:receiverId
). These routes are associated with the corresponding methods defined in the MessageController
. The authMiddleware
ensures that only authenticated users can access these routes. With these routes, users can send messages and retrieve their conversations.
RabbitMQ Services
In the src/services
folder, open the RabbitMQService.ts
file and add the following code:
import amqp, { Channel } from "amqplib";
import { v4 as uuidv4 } from "uuid";
import config from "../config/config";
class RabbitMQService {
private requestQueue = "USER_DETAILS_REQUEST";
private responseQueue = "USER_DETAILS_RESPONSE";
private correlationMap = new Map();
private channel!: Channel;
constructor() {
this.init();
}
async init() {
const connection = await amqp.connect(config.msgBrokerURL!);
this.channel = await connection.createChannel();
await this.channel.assertQueue(this.requestQueue);
await this.channel.assertQueue(this.responseQueue);
this.channel.consume(
this.responseQueue,
(msg) => {
if (msg) {
const correlationId = msg.properties.correlationId;
const user = JSON.parse(msg.content.toString());
const callback = this.correlationMap.get(correlationId);
if (callback) {
callback(user);
this.correlationMap.delete(correlationId);
}
}
},
{ noAck: true }
);
}
async requestUserDetails(userId: string, callback: Function) {
const correlationId = uuidv4();
this.correlationMap.set(correlationId, callback);
this.channel.sendToQueue(
this.requestQueue,
Buffer.from(JSON.stringify({ userId })),
{ correlationId }
);
}
async notifyReceiver(
receiverId: string,
messageContent: string,
senderEmail: string,
senderName: string
) {
await this.requestUserDetails(receiverId, async (user: any) => {
const notificationPayload = {
type: "MESSAGE_RECEIVED",
userId: receiverId,
userEmail: user.email,
message: messageContent,
from: senderEmail,
fromName: senderName,
};
try {
await this.channel.assertQueue(config.queue.notifications);
this.channel.sendToQueue(
config.queue.notifications,
Buffer.from(JSON.stringify(notificationPayload))
);
} catch (error) {
console.error(error);
}
});
}
}
export const rabbitMQService = new RabbitMQService();
This RabbitMQService.ts
file sets up a RabbitMQ service for handling communication between different microservices. It uses the amqplib
library to interact with RabbitMQ. The service initializes by connecting to the RabbitMQ server and asserting the necessary queues. The methods requestUserDetails
and notifyReceiver
demonstrate the use of RabbitMQ for requesting user details and notifying receivers about incoming messages, respectively. The service utilizes correlation IDs to match requests with responses, ensuring effective communication between services.
Utils
With our services and controller all set, let’s work on our utils. In the src/utils
, add the following code to the apiError.ts
file:
class ApiError extends Error {
statusCode: number;
isOperational: boolean;
constructor(
statusCode: number,
message: string | undefined,
isOperational = true,
stack = ""
) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
export { ApiError };
This ApiError
class is similar to the one we have in our User service. They perform the same function.
Next, in the userStatusStore.ts
file, the following code defines the UserStatusStore
class:
export class UserStatusStore {
private static instance: UserStatusStore;
private userStatuses: Record<string, boolean>;
private constructor() {
this.userStatuses = {};
}
public static getInstance(): UserStatusStore {
if (!UserStatusStore.instance) {
UserStatusStore.instance = new UserStatusStore();
}
return UserStatusStore.instance;
}
setUserOnline(userId: string) {
this.userStatuses[userId] = true;
}
setUserOffline(userId: string) {
this.userStatuses[userId] = false;
}
isUserOnline(userId: string): boolean {
return !!this.userStatuses[userId];
}
}
The UserStatusStore
class is a singleton class responsible for managing the online/offline status of users. It provides methods to set a user as online or offline and check whether a user is currently online.
Lastly, in the messageHandler.ts
file, the following code:
import { UserStatusStore } from "./userStatusStore";
import { rabbitMQService } from "../services/RabbitMQService";
const userStatusStore = UserStatusStore.getInstance();
export const handleMessageReceived = async (
senderName: string,
senderEmail: string,
receiverId: string,
messageContent: string
) => {
const receiverIsOffline = !userStatusStore.isUserOnline(receiverId);
if (receiverIsOffline) {
await rabbitMQService.notifyReceiver(
receiverId,
messageContent,
senderEmail,
senderName
);
}
};
The handleMessageReceived
function checks whether the receiver is online using the UserStatusStore
. If the receiver is offline, it utilizes the RabbitMQService
to notify the receiver about the incoming message. This ensures that users receive notifications even when they are not actively using the chat service.
Now, export the utils by adding the code below to the index.ts
file:
import { ApiError } from "./apiError";
import { UserStatusStore } from "./userStatusStore";
import { handleMessageReceived } from "./messageHandler";
export { ApiError, UserStatusStore, handleMessageReceived };
This code exports the ApiError
, UserStatusStore
, and handleMessageReceived
from the utils, making them available for use in other parts of the application. With our utils in place, let’s define our middleware.
Middleware
Open the index.ts
file located in the src/middleware
folder and add the following code:
import { Request, Response, NextFunction, ErrorRequestHandler } from "express";
import jwt from "jsonwebtoken";
import { ApiError } from "../utils";
import config from "../config/config";
interface TokenPayload {
id: string;
name: string;
email: string;
iat: number;
exp: number;
}
interface IUser {
_id: string;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
export interface AuthRequest extends Request {
user: IUser;
}
const jwtSecret = config.JWT_SECRET as string;
const authMiddleware = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return next(new ApiError(401, "Missing authorization header"));
}
const [, token] = authHeader.split(" ");
try {
const decoded = jwt.verify(token, jwtSecret) as TokenPayload;
req.user = {
_id: decoded.id,
email: decoded.email,
createdAt: new Date(decoded.iat * 1000),
updatedAt: new Date(decoded.exp * 1000),
name: decoded.name,
password: "",
};
return next();
} catch (error) {
console.error(error);
return next(new ApiError(401, "Invalid token"));
}
};
const errorConverter: ErrorRequestHandler = (err, req, res, next) => {
let error = err;
if (!(error instanceof ApiError)) {
const statusCode =
error.statusCode ||
(error instanceof Error
? 400 // Bad Request
: 500); // Internal Server Error
const message =
error.message ||
(statusCode === 400 ? "Bad Request" : "Internal Server Error");
error = new ApiError(statusCode, message, false, err.stack.toString());
}
next(error);
};
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
let { statusCode, message } = err;
if (process.env.NODE_ENV === "production" && !err.isOperational) {
statusCode = 500; // Internal Server Error
message = "Internal Server Error";
}
res.locals.errorMessage = err.message;
const response = {
code: statusCode,
message,
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
};
if (process.env.NODE_ENV === "development") {
console.error(err);
}
res.status(statusCode).json(response);
next();
};
export { authMiddleware, errorConverter, errorHandler };
This code defines three middleware in the src/middleware
folder: authMiddleware
, errorConverter
, and errorHandler
. The authMiddleware
handles user authentication, errorConverter
converts errors to a standardized format, and errorHandler
sends appropriate error responses. This middleware will be used in the Express application to handle authentication and errors.
Server Setup and Initialization
It’s time to set up our server. Firstly, open the app.ts
file in the src
folder and add the following code:
import express, { Express } from "express";
import userRouter from "./routes/messageRoutes";
import { errorConverter, errorHandler } from "./middleware";
const app: Express = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(userRouter);
app.use(errorConverter);
app.use(errorHandler);
export default app;
This code sets up an Express application with middleware to handle JSON and URL-encoded data. It also includes the messageRouter
for routing related to messages, as well as error-handling middleware.
Then, add the following code to the server.ts
file in the same folder:
import { Server } from "http";
import { Socket, Server as SocketIOServer } from "socket.io";
import app from "./app";
import { Message, connectDB } from "./database";
import config from "./config/config";
let server: Server;
connectDB();
server = app.listen(config.PORT, () => {
console.log(`Server is running on port ${config.PORT}`);
});
const io = new SocketIOServer(server);
io.on("connection", (socket: Socket) => {
console.log("Client connected");
socket.on("disconnect", () => {
console.log("Client disconnected", socket.id);
});
socket.on("sendMessage", (message) => {
io.emit("receiveMessage", message);
});
socket.on("sendMessage", async (data) => {
const { senderId, receiverId, message } = data;
const msg = new Message({ senderId, receiverId, message });
await msg.save();
io.to(receiverId).emit("receiveMessage", msg); // Assuming receiverId is socket ID of the receiver
});
});
const exitHandler = () => {
if (server) {
server.close(() => {
console.info("Server closed");
process.exit(1);
});
} else {
process.exit(1);
}
};
const unexpectedErrorHandler = (error: unknown) => {
console.error(error);
exitHandler();
};
process.on("uncaughtException", unexpectedErrorHandler);
process.on("unhandledRejection", unexpectedErrorHandler);
This code initializes the server, establishes a Socket.IO connection, and sets up event listeners for handling client connections, disconnections, and message sending. Additionally, it includes error handling for server closure and unexpected errors.
Script Update
Don’t forget to update the "scripts"
tag for our Chat service, open the package.json
file, and update it with the following—just like our User service:
"scripts": {
"dev": "NODE_ENV=development nodemon src/server.ts",
"build": "rm -rf build/ && tsc -p .",
"start": "NODE_ENV=production nodemon build/server.js"
},
Also, update the "main"
tag by setting it to "src/server.ts"
. With these, our chat service is ready. Start the development server by running:
npm run dev
This will initiate the development server for your Chat service. Additionally, you can test the Chat service endpoints using Postman or any API tool of your choice.
Notification Service
Congratulations on completing the setup for our Chat Server microservice! With the Chat Service up and running smoothly, it's time to enhance the user experience by implementing a Notification Service.
In this section, we'll focus on creating a Notification Service that will handle real-time notifications for our chat application. Notifications play a crucial role in keeping users informed about new messages, ensuring timely communication, and providing a seamless chatting experience.
By the end of this section, we'll have a fully functional Notification Service integrated seamlessly with our existing Chat Server microservice, enhancing the overall user experience with real-time notifications. Let's dive in and get started!
Here’s our Notification service’s file and folder structure:
Now, use the following commands to set up your Notification Service project structure, ensure you’re in your project’s root directory:
Create and navigate to your notification-service
folder:
mkdir notification-service && cd notification-service
Next, create the src
folder and navigate to it:
mkdir -p src/config src/services src/middleware src/utils
cd src
Now, create individual files:
touch config/config.ts services/RabbitMQService.ts services/EmailService.ts services/FCMService.ts services/index.ts middleware/index.ts utils/apiError.ts utils/userStatusStore.ts utils/index.ts server.ts
Finally, create Dockerfile
, .dockerignore
, and .env
files:
cd ..
touch Dockerfile .dockerignore .env
Here's a brief description of each file and folder created for the Notification Service:
-
config/config.ts:
- This file contains configurations related to the Notification Service, such as RabbitMQ connection details and other environment variables.
-
services/RabbitMQService.ts:
- This file implements the RabbitMQ service, which handles message queueing and communication with other microservices via RabbitMQ.
-
services/EmailService.ts:
- This file defines the Email service, responsible for sending email notifications to users based on certain events or triggers within the application.
-
services/FCMService.ts:
- This file contains the Firebase Cloud Messaging (FCM) service implementation, allowing the Notification Service to send push notifications to mobile devices.
-
services/index.ts:
- This file exports all service modules, facilitating easy import and access to service functionalities from other parts of the application.
-
middleware/index.ts:
- This file houses and exports middleware functions used within the Notification Service, such as authentication middleware or error handling middleware.
-
utils/apiError.ts:
- This file defines the
ApiError
class, which represents custom error objects used throughout the application to handle API-related errors.
- This file defines the
-
utils/userStatusStore.ts:
- This file contains the
UserStatusStore
class, which manages the online/offline status of users, facilitating real-time communication and notification delivery.
- This file contains the
-
utils/index.ts:
- This file exports utility functions and classes for easy access and use within the Notification Service.
-
server.ts:
- This file initializes and starts the Express server for the Notification Service, defining routes, middleware, and server setup logic.
Next, let’s set up our environment and install the necessary dependencies. Use the following command, ensuring you’re in the notification-service
root directory:
Initialize Node.js project:
npm init -y
Install dependencies
npm install amqplib dotenv express firebase-admin nodemon ts-node typescript nodemailer sib-api-v3-typescript @types/express @types/amqplib @types/nodemailer
This command sequence will create a package.json
file and install the required Node.js packages for the Notification Service.
Now, let's proceed with the TypeScript configuration. Run the following command:
tsc --init
Open the generated tsconfig.json
file and update it with the following configuration:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./build",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"tests"
]
}
Next, add the following code to your .env
file:
# Environment variables for Notification Service
# Set the environment to development
NODE_ENV="development"
# Port for the Notification Service
PORT=8083
# Replace 'YOUR_MESSAGE_BROKER_URL' with the RabbitMQ message broker URL used in the User and Chat Service
MESSAGE_BROKER_URL="{{YOUR_MESSAGE_BROKER_URL}}"
# SMTP configuration for sending emails using SendInBlue (Brevo)
SMTP_HOST="smtp-relay.brevo.com"
SMTP_PORT=587
# Replace placeholders with your SendInBlue (Brevo) account details
SMTP_USER="{{YOUR_SENDINBLUE_ACCOUNT_EMAIL}}"
SMTP_PASS="{{YOUR_SENDINBLUE_MASTER_PASSWORD}}"
# Replace placeholders with your SendInBlue (Brevo) API key and email source
SENDINBLUE_APIKEY="{{YOUR_SENDINBLUE_APIKEY}}"
EMAIL_FROM="{{YOUR_EMAIL_SOURCE}}"
To set up the .env
file correctly:
- Replace
{{YOUR_MESSAGE_BROKER_URL}}
with the RabbitMQ message broker URL you used in the User and Chat Service. - Visit Brevo's website and log in or create an account if you don't have one.
- Navigate to the SMTP & API key page and create an SMTP key. Copy the value and replace
{{YOUR_SENDINBLUE_MASTER_PASSWORD}}
with this value. - Click on the
API KEYS
tab, create an API key, and copy the value. Replace{{YOUR_SENDINBLUE_APIKEY}}
with this value. - Replace
{{YOUR_SENDINBLUE_ACCOUNT_EMAIL}}
with your Brevo account's email. - Replace
{{YOUR_EMAIL_SOURCE}}
with the email address you want the emails to be sent from.
Now, let’s set up the config.ts
file in the src/config
folder. It manages the configuration variables used throughout the Notification Service. Let's dive into the code:
import { config } from "dotenv";
const configFile = `./.env`;
config({ path: configFile });
const {
PORT,
JWT_SECRET,
NODE_ENV,
MESSAGE_BROKER_URL,
SENDINBLUE_APIKEY,
EMAIL_FROM,
SMTP_HOST,
SMTP_PORT = 587,
SMTP_USER,
SMTP_PASS,
} = process.env;
const queue = { notifications: "NOTIFICATIONS" };
export default {
PORT,
JWT_SECRET,
env: NODE_ENV,
msgBrokerURL: MESSAGE_BROKER_URL,
SENDINBLUE_APIKEY,
EMAIL_FROM,
queue,
smtp: {
host: SMTP_HOST,
port: SMTP_PORT as number,
user: SMTP_USER,
pass: SMTP_PASS,
},
};
In our config.ts
file, we handle the configuration settings for our Notification Service. Using the dotenv
library, we load environment variables from the .env
file, which contains crucial settings like the server port, JWT secret key, message broker URL, SMTP settings, and more. These variables are then deconstructed for convenient access within the configuration object. Within this object, we define the notification queue and SMTP settings, ensuring seamless integration with external services such as RabbitMQ and SendInBlue (Brevo) for email delivery. Ultimately, we export this configuration object, making it available for use throughout the Notification Service, ensuring consistent and reliable behavior across the application.
Utils
With our configurations in place, let’s work on our utils. Add the following code to the apiError.ts
in the src/utils
file:
class ApiError extends Error {
statusCode: number;
isOperational: boolean;
constructor(
statusCode: number,
message: string | undefined,
isOperational = true,
stack = ""
) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
export { ApiError };
This ApiError
class performs the same function as in our User and Chat services.
Next, add the following code to the userStatusStore.ts
file:
export class UserStatusStore {
private static instance: UserStatusStore;
private userStatuses: Record<string, boolean>;
constructor() {
this.userStatuses = {};
}
public static getInstance(): UserStatusStore {
if (!UserStatusStore.instance) {
UserStatusStore.instance = new UserStatusStore();
}
return UserStatusStore.instance;
}
setUserOnline(userId: string) {
this.userStatuses[userId] = true;
}
setUserOffline(userId: string) {
this.userStatuses[userId] = false;
}
isUserOnline(userId: string): boolean {
return !!this.userStatuses[userId];
}
}
This performs the same function as it does in our Chat Service.
Lastly, export the utils by adding the code below to the index.ts
file:
import { UserStatusStore } from "./userStatusStore";
import { ApiError } from "./apiError";
export { UserStatusStore, ApiError };
This code exports the ApiError
and UserStatusStore
from the utils, making them available for use in other parts of the application. With our utils in place, let’s define our middleware.
Email Service
Add the following code to the EmailService.ts
file located in the src/services
folder:
import nodemailer from "nodemailer";
import config from "../config/config";
export class EmailService {
private transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: false,
auth: {
user: config.smtp.user,
pass: config.smtp.pass,
},
});
}
async sendEmail(to: string, subject: string, content: string) {
const mailOptions = {
from: config.EMAIL_FROM,
to: to,
subject: subject,
html: content,
};
try {
const info = await this.transporter.sendMail(mailOptions);
console.log("Email sent: %s", info.messageId);
} catch (error) {
console.error("Error sending email:", error);
}
}
}
In the EmailService.ts
file, we define the EmailService
class responsible for handling email delivery. Upon instantiation, the constructor initializes a Nodemailer transporter instance using SMTP settings retrieved from the configuration file. The sendEmail
method accepts the recipient's email address, subject, and content as parameters and constructs the email message options accordingly. Finally, it attempts to send the email using the transporter instance, logging success or error messages appropriately. This service ensures efficient and reliable email communication within our application.
Firebase Cloud Messaging (FCM) Services
In our Notification Service, we're integrating Firebase Cloud Messaging (FCM) Services to enable push notification delivery to mobile devices. The FCMService.ts
file contains the code responsible for sending push notifications using the Firebase Admin SDK.
import admin from "firebase-admin";
admin.initializeApp({
credential: admin.credential.applicationDefault(),
});
export class FCMService {
async sendPushNotification(token: string, message: string) {
const payload = {
notification: {
title: "New Message",
body: message,
},
token: token,
};
try {
await admin.messaging().send(payload);
console.log("Notification sent successfully");
} catch (error) {
console.error("Error sending notification", error);
}
}
}
The FCMService
class initializes the Firebase Admin SDK with default application credentials upon instantiation. It provides a sendPushNotification
method, which accepts a device token and message as parameters to construct the notification payload. The method then sends the notification using the Firebase Admin SDK's messaging functionality. Any errors encountered during the process are logged for further investigation. This service ensures seamless push notification delivery to mobile devices, enhancing user engagement and real-time interaction within our application.
RabbitMQ Services
The RabbitMQService.ts
file orchestrates the communication between the Notification Service and RabbitMQ message broker, enabling efficient message exchange for real-time notifications. This service integrates our Email and FCM (Firebase Cloud Messaging) services to deliver notifications via email or push notifications based on the user's online status.
import amqp, { Channel } from "amqplib";
import config from "../config/config";
import { FCMService } from "./FCMService";
import { EmailService } from "./EmailService";
import { UserStatusStore } from "../utils";
class RabbitMQService {
private channel!: Channel;
private fcmService = new FCMService();
private emailService = new EmailService();
private userStatusStore = new UserStatusStore();
constructor() {
this.init();
}
async init() {
const connection = await amqp.connect(config.msgBrokerURL!);
this.channel = await connection.createChannel();
await this.consumeNotification();
}
async consumeNotification() {
await this.channel.assertQueue(config.queue.notifications);
this.channel.consume(config.queue.notifications, async (msg) => {
if (msg) {
const {
type,
userId,
message,
userEmail,
userToken,
fromName,
} = JSON.parse(msg.content.toString());
if (type === "MESSAGE_RECEIVED") {
// Check if the user is online
const isUserOnline =
this.userStatusStore.isUserOnline(userId);
if (isUserOnline && userToken) {
// User is online, send a push notification
await this.fcmService.sendPushNotification(
userToken,
message
);
} else if (userEmail) {
// User is offline, send an email
await this.emailService.sendEmail(
userEmail,
`New Message from ${fromName}`,
message
);
}
}
this.channel.ack(msg); // Acknowledge the message after processing
}
});
}
}
export const rabbitMQService = new RabbitMQService();
The RabbitMQService
class establishes a connection with the RabbitMQ message broker and consumes notifications from a designated queue. Upon receiving a notification, it parses the message content and determines the appropriate notification delivery method based on the user's online status. If the user is online and has a valid FCM token, the service sends a push notification using the FCMService
. Otherwise, if the user is offline and an email address is provided, it dispatches an email notification using the EmailService
. This integration ensures seamless and reliable delivery of notifications to users across different channels, enhancing the overall user experience.
Next, export the services:
import { FCMService } from "./FCMService";
import { EmailService } from "./EmailService";
export { FCMService, EmailService };
Middleware
With our services ready, open the index.ts
file in the src/middleware
folder and add the following code:
import { ErrorRequestHandler } from "express";
import { ApiError } from "../utils";
export const errorConverter: ErrorRequestHandler = (err, req, res, next) => {
let error = err;
if (!(error instanceof ApiError)) {
const statusCode =
error.statusCode ||
(error instanceof Error
? 400 // Bad Request
: 500); // Internal Server Error
const message =
error.message ||
(statusCode === 400 ? "Bad Request" : "Internal Server Error");
error = new ApiError(statusCode, message, false, err.stack.toString());
}
next(error);
};
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
let { statusCode, message } = err;
if (process.env.NODE_ENV === "production" && !err.isOperational) {
statusCode = 500; // Internal Server Error
message = "Internal Server Error";
}
res.locals.errorMessage = err.message;
const response = {
code: statusCode,
message,
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
};
if (process.env.NODE_ENV === "development") {
console.error(err);
}
res.status(statusCode).json(response);
next();
};
The errorConverter
and errorHandler
functions perform the same functions as in our User and Chat Services.
Server Setup and Initialization
Well done so far. It’s time to set up our server. Open the server.ts
file located in the src
folder and add the following code:
import express, { Express } from "express";
import { Server } from "http";
import { errorConverter, errorHandler } from "./middleware";
import config from "./config/config";
import { rabbitMQService } from "./services/RabbitMQService";
const app: Express = express();
let server: Server;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(errorConverter);
app.use(errorHandler);
server = app.listen(config.PORT, () => {
console.log(`Server is running on port ${config.PORT}`);
});
const initializeRabbitMQClient = async () => {
try {
await rabbitMQService.init();
console.log("RabbitMQ client initialized and listening for messages.");
} catch (err) {
console.error("Failed to initialize RabbitMQ client:", err);
}
};
initializeRabbitMQClient();
const exitHandler = () => {
if (server) {
server.close(() => {
console.info("Server closed");
process.exit(1);
});
} else {
process.exit(1);
}
};
const unexpectedErrorHandler = (error: unknown) => {
console.error(error);
exitHandler();
};
process.on("uncaughtException", unexpectedErrorHandler);
process.on("unhandledRejection", unexpectedErrorHandler);
This code initializes the Express application, sets up middleware for error handling, and starts the server to listen on the specified port. Additionally, it initializes the RabbitMQ client to handle messaging functionality and sets up handlers for process exits and unexpected errors to ensure graceful shutdown and error handling within the application.
Script Update
To ensure proper execution of the Notification service, update the "scripts"
tag in the package.json
file as follows:
"scripts": {
"dev": "NODE_ENV=development nodemon src/server.ts",
"build": "rm -rf build/ && tsc -p .",
"start": "NODE_ENV=production nodemon build/server.js"
},
Additionally, update the "main"
tag by setting it to "src/server.ts"
. This change ensures that the main entry point of the service is correctly identified.
To initiate the development server for your Notification service, run the following command:
npm run dev
Executing this command will start the development server, allowing it to listen to incoming messages and handle notifications effectively.
API Gateway
Congratulations on completing the setup of the Notification service! Now that our microservices are ready, let’s create our API Gateway. This will connect all our microservices ports to one gateway.
- Navigate to the
chat-server
root directory - Create and navigate to the
gateway
folder
mkdir gateway && cd gateway
- Set up the development server:
npm init -y
- Install dependencies
npm install nodemon express ts-node express-http-proxy @types/express @types/express-http-proxy
- Create the
index.ts
file:
touch index.ts
- Add the following code to the
index.ts
file:
import express from "express";
import proxy from "express-http-proxy";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const auth = proxy("http://localhost:8081");
const messages = proxy("http://localhost:8082");
const notifications = proxy("http://localhost:8083");
app.use("/api/auth", auth);
app.use("/api/messages", messages);
app.use("/api/notifications", notifications);
const server = app.listen(8080, () => {
console.log("Gateway is Listening to Port 8080");
});
const exitHandler = () => {
if (server) {
server.close(() => {
console.info("Server closed");
process.exit(1);
});
} else {
process.exit(1);
}
};
const unexpectedErrorHandler = (error: unknown) => {
console.error(error);
exitHandler();
};
process.on("uncaughtException", unexpectedErrorHandler);
process.on("unhandledRejection", unexpectedErrorHandler);
In the above file, we create an Express application to serve as our API Gateway. This application is responsible for routing incoming HTTP requests to the appropriate microservices based on the request path. We begin by importing the necessary modules: express
for creating the server and express-http-proxy
for proxying requests to other services.
Next, we instantiate an Express application and configure it to parse JSON and URL-encoded data from incoming requests using express.json()
and express.urlencoded()
middleware.
Then, we create proxy middleware instances for each of our microservices: auth
, messages
, and notifications
. These proxy middleware are configured to forward incoming requests to the corresponding microservice running on different ports: 8081
for user, 8082
for chat, and 8083
for notification service.
After defining the proxy middleware, we mount them on specific routes using app.use()
. For example, requests to /api/auth
are forwarded to the User microservice, requests to /api/messages
are forwarded to the Chat microservice, and requests to /api/notifications
are forwarded to the Notification microservice.
Finally, we start the Express server on port 8080
to listen for incoming requests. We also define error handlers to gracefully handle uncaught exceptions and unhandled rejections to ensure the stability of our application. This completes the setup of our API Gateway, which now acts as a central entry point for accessing the functionalities provided by our microservices.
Next, update the package.json
file. Update the "script"
tag with the following:
"scripts": {
"dev": "nodemon index.ts",
},
and the "main"
tag with "index.ts"
.
Now, run the following command to start the Gateway server:
npm run dev
Testing and Debugging
With the setup of our microservices and API Gateway complete, it's crucial to conduct thorough testing to ensure flawless functionality.
-
Microservice Verification:
Confirm that each microservice is up and running. The user service should be accessible at
[http://localhost:8081](http://localhost:8081)
, the chat service at[http://localhost:8082](http://localhost:8082/)
, and the notification service at[http://localhost:8083](http://localhost:8083/)
. With your Postman or any other API tool of your choice. We’d be using Postman in this tutorial.
-
User Registration and Authentication:
Register a new user by sending a
POST
request tohttp://localhost:8080/user/register
with the following JSON data:{ "name": "Test User", "email": "{{REAL_EMAIL_ADDRESS}}", "password": "password" }
Replace
REAL_EMAIL_ADDRESS
with a valid email address (we’ll need this to send emails). Upon successful registration, proceed to log in by sending aPOST
request to[http://localhost:8080/user/login](http://localhost:8080/user/login)
with the registered email and password. -
Message Sending:
Test the message-sending functionality by making a
POST
request to[http://localhost:8080/chat/send](http://localhost:8080/chat/send)
with the following JSON data:{ "receiverId": "{{RECEIVER_ID}}", "message": "Hello there!" }
Replace
RECEIVER_ID
with the ID of another registered user obtained from the registration response. Upon sending the message, an email notification will be dispatched to the recipient's email address.
By meticulously executing these testing steps, we ensure that our microservices operate seamlessly and deliver the desired functionality. Any encountered issues or discrepancies will be addressed promptly, ensuring the reliability and robustness of our system.
Nginx Configuration
Configure Nginx by following these steps:
- Create the necessary files and folders by running the following code in the
chat-server
root folder:
mkdir nginx && cd nginx
touch Dockerfile nginx.conf
This command creates the nginx
folder to house our Nginx configurations and creates both the Dockerfile
and nginx.conf
files.
- Add the following code to the
Dockerfile
:
FROM nginx:latest
RUN rm /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf
- Add the following code to the
nginx.conf
file:
http {
upstream user {
server user:8081;
}
upstream chat {
server chat:8082;
}
upstream notification {
server notification:8083;
}
server {
listen 85;
location /user/ {
proxy_pass http://user/;
}
location /chat/ {
proxy_pass http://chat/;
}
location /notification/ {
proxy_pass http://notification/;
}
}
}
events {}
This configuration uses Nginx as a reverse proxy to route requests to the appropriate microservices based on the URL path. Each microservice is defined as an upstream server, and Nginx listens on port 85
for incoming requests. Requests to /user/
, /chat/
, and /notification/
are proxied to the respective microservices running on ports 8081
, 8082
, and 8083
.
Docker and Containerization
With everything working perfectly, it’s time to containerize our microservices. Containerization is pivotal for deploying and managing our services efficiently. Let's proceed with containerizing each microservice using Docker. Follow these steps:
- Open the
Dockerfile
located in theuser-service
folder, and add the following code:
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 8081
CMD [ "npm", "start" ]
This Dockerfile configures a lightweight Node.js environment, copies the necessary files, installs dependencies, builds the application, exposes port 8081
, and starts the user service upon container initialization.
- Open the
Dockerfile
located in thechat-service
folder, and append the following code:
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 8082
CMD [ "npm", "start" ]
Similarly, this exposes port 8082
and starts the chat service upon container initialization.
- Open the
Dockerfile
located in thenotification-service
folder, and add the following code:
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 8083
CMD [ "npm", "start" ]
This also exposes port 8083
and starts the notification service upon container initialization.
- Update the
.dockerignore
file of every microservice with the following code:
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
This excludes the above-mentioned files from being included in the Docker image creation process for each microservice. This helps reduce the size of the Docker image and ensures that only necessary files are included.
- Open the
docker-compose.yml
file in thechat-server
folder (the parent folder) and add the following code:
version: '3.8'
services:
mongodb:
image: mongo:latest
restart: unless-stopped
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
user:
build:
context: ./user-service
dockerfile: Dockerfile
ports:
- "8081:8081"
restart: always
depends_on:
- "mongodb"
environment:
- NODE_ENV=production
chat:
build:
context: ./chat-service
dockerfile: Dockerfile
ports:
- "8082:8082"
depends_on:
- "mongodb"
environment:
- NODE_ENV=production
notification:
build:
context: ./notification-service
dockerfile: Dockerfile
ports:
- "8083:8083"
depends_on:
- "mongodb"
environment:
- NODE_ENV=production
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
ports:
- "85:85"
depends_on:
- user
- chat
- notification
volumes:
mongo-data:
This Docker Compose configuration defines several services:
-
mongodb
: Runs a MongoDB container. -
user
,chat
, andnotification
: Build and run containers for the user service, chat service, and notification service, respectively. -
nginx
: Configures an NGINX server for routing requests to the appropriate microservices.
Each service has its configuration for building the Docker image, specifying ports to expose, and setting environment variables. Additionally, dependencies are defined to ensure that the services start-up in the correct order. Finally, a volume is specified to persist MongoDB data between container restarts.
- To start up our application in a containerized environment, run the following command:
docker-compose up --build
This command will build and start all the services defined in the docker-compose.yml
file. It will also ensure that any changes made to the Dockerfiles or other configurations are applied during the build process.
Conclusion
We have successfully designed, developed, and containerized a microservices-based chat application server. Through a step-by-step approach, we tackled each component of the system, including the User Service, Chat Service, Notification Service, API Gateway, Docker, and Nginx configuration.
We began by structuring our project directory and setting up each microservice individually. The User Service facilitated user registration and authentication, while the Chat Service enabled real-time communication between users. The Notification Service handled email notifications and push notifications using RabbitMQ for message queuing.
To ensure seamless communication between microservices, we implemented an API Gateway using Express.js
, which served as a single entry point for our application. This allowed us to centralize access to our microservices and manage routing efficiently.
Following the development phase, we containerized our microservices using Docker, ensuring consistent deployment across different environments. We configured Nginx as a reverse proxy to route incoming requests to the appropriate microservice based on the URL path.
Throughout the tutorial, we emphasized the importance of testing and debugging to ensure the reliability and functionality of our application. By conducting manual testing and leveraging tools like Postman, we verified the behavior of each microservice and API endpoint, addressing any issues that arose along the way.
In conclusion, this tutorial serves as an introductory guide to microservices, providing foundational knowledge and practical experience in building and deploying microservices-based applications. While it covers essential concepts and implementation steps, it's important to recognize that microservices entail a vast and nuanced ecosystem beyond the scope of this tutorial. By following along with this tutorial, beginners can gain valuable insights into microservices architecture, containerization, and best practices for developing scalable and resilient applications. For experienced developers, this tutorial offers a refresher on key principles and an opportunity to explore modern technologies in the context of microservices development. To access the full code of this tutorial, please visit this repository.
Posted on February 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024