بناء Microservices باستخدام Node.js

بناء Microservices باستخدام Node.js

مقدمة Microservices باستخدام Node.js

في السنوات الأخيرة، لم يعد بناء التطبيقات الكبيرة كما كان من قبل. كثير من المشاريع التي تبدأ كفكرة بسيطة، أو كمنصة صغيرة لخدمة عدد محدود من المستخدمين، تكبر بسرعة وتتحول إلى أنظمة معقدة تحتاج إلى مرونة عالية، سرعة في التطوير، وإمكانية للتوسع دون أن ينهار كل شيء عند أول تغيير. هنا تظهر Microservices كأحد أكثر الأساليب شيوعًا لبناء الأنظمة الحديثة، وخصوصًا عندما يكون Node.js حاضرًا في الصورة، لأنه يمنحك خفة في الأداء، وسرعة في التطوير، ومرونة ممتازة في التعامل مع الطلبات غير المتزامنة.

قد يبدو موضوع Microservices في البداية وكأنه مجرد تقسيم للتطبيق إلى أجزاء صغيرة، لكن الحقيقة أعمق من ذلك بكثير. فالفكرة ليست أن تقسم مشروعك إلى ملفات أو مجلدات أكثر، بل أن تعيد التفكير في شكل النظام نفسه: كيف تُحدد حدود كل خدمة، كيف تتواصل الخدمات مع بعضها، كيف تضمن أن الأعطال لا تنتشر كالنار في الهشيم، وكيف تحافظ على تجربة تطوير مريحة رغم أن النظام أصبح موزعًا ومتشعبًا. وهذا بالضبط ما سنبنيه معًا في هذا المقال: صورة واضحة وعملية لبناء Microservices باستخدام Node.js خطوة بخطوة، بأسلوب قريب من الواقع وليس مجرد تنظير جميل.

ما هي Microservices أصلًا؟

Microservices هي أسلوب معماري يقسم التطبيق الكبير إلى مجموعة من الخدمات الصغيرة المستقلة نسبيًا، بحيث تختص كل خدمة بمسؤولية محددة جدًا. بدل أن يكون لديك تطبيق ضخم واحد، تجد نفسك أمام خدمات متعددة، كل واحدة لها منطقها الخاص، وبياناتها الخاصة في الغالب، وواجهتها الخاصة، ويمكن تشغيلها أو تطويرها أو حتى نشرها بشكل مستقل عن باقي النظام.

إذا أخذنا مثالًا بسيطًا لتطبيق تجارة إلكترونية، فقد تجد خدمة للمستخدمين، وخدمة للمنتجات، وخدمة للطلبات، وخدمة للدفع، وخدمة للإشعارات. كل خدمة تقوم بدور واضح، وكل خدمة يمكن أن تُبنى بلغة أو إطار عمل مختلفين، رغم أننا هنا سنركز على Node.js لأنه مناسب جدًا لهذا النوع من الأنظمة.

الميزة الحقيقية في Microservices ليست فقط في التنظيم، بل في الاستقلالية. عندما تتعطل خدمة الطلبات مثلًا، لا يجب أن يتعطل النظام كاملًا إذا كانت بقية الخدمات تعمل بشكل صحيح. وعندما تريد تطوير خدمة الإشعارات، لا تحتاج إلى لمس خدمة المستخدمين أو تغيير كل شيء في المستودع الضخم. هذا النوع من الحرية مغرٍ جدًا، لكنه أيضًا يأتي بثمن: التعقيد يزداد، والاختبارات تصبح أكثر أهمية، والمراقبة تصبح ضرورة لا رفاهية، والتواصل بين الخدمات يحتاج إلى تصميم ذكي.

لماذا Node.js مناسب جدًا لـ Microservices؟

Node.js ليس الخيار الوحيد بالتأكيد، لكنه من الخيارات التي يفضلها كثير من الفرق الحديثة، خصوصًا عندما تكون هناك حاجة لبناء APIs بسرعة، أو التعامل مع عدد كبير من الطلبات المتزامنة، أو دمج الخدمات مع تقنيات JavaScript كاملة من الواجهة إلى الخلفية.

أول شيء يميز Node.js هو أنه مبني على نموذج غير متزامن event-driven، وهذا يجعله ممتازًا للتعامل مع عمليات I/O مثل قواعد البيانات، والاتصالات الشبكية، ورسائل الطوابير، والنداءات الخارجية إلى خدمات أخرى. وفي بيئة Microservices، هذا مهم جدًا لأن جزءًا كبيرًا من العمل يكون التواصل مع أنظمة أخرى، وليس فقط الحسابات الثقيلة.

السبب الثاني هو أن Node.js يملك مجتمعًا ضخمًا، ومكتبات كثيرة، ونمط تطوير سريع جدًا. تستطيع أن تبني خدمة Express بسيطة خلال دقائق، وتضيف لها التحقق من الصحة، وتوصيلها بقاعدة بيانات، ثم تضعها داخل Docker، ثم تشغلها مع بقية الخدمات. هذا السلاسة تساعد الفرق الصغيرة والمتوسطة بشكل خاص.

السبب الثالث هو أن JavaScript أو TypeScript أصبحت لغة شائعة في كثير من الفرق. وعندما تبني Microservices باستخدام Node.js، يمكنك أن توحد لغة التطوير بين الفرق أو على الأقل بين الواجهة والخلفية، وهذا يقلل الاحتكاك ويجعل مشاركة المعرفة أسهل. ومع TypeScript تحديدًا، يمكنك الحصول على نوع من الانضباط البرمجي الذي يفيد جدًا عندما يكبر عدد الخدمات.

متى تختار Microservices ومتى تتجنبها؟

ليس كل مشروع يحتاج Microservices. وهذه حقيقة مهمّة جدًا، لأن كثيرًا من الفرق تتسرع في تبني هذا النمط قبل أن تكون مستعدة له، ثم تكتشف أنها انتقلت من تعقيد واحد إلى عشرات التعقيدات الصغيرة بدلًا من حل حقيقي. Microservices ممتازة عندما يكون لديك فريق كبير نسبيًا، أو نظام ينمو باستمرار، أو أجزاء من التطبيق تحتاج إلى التوسع المستقل، أو خدمات متعددة يمكن فصلها منطقيًا.

أما إذا كان مشروعك صغيرًا، أو ما زال في مرحلة إثبات الفكرة، أو لديك فريق محدود جدًا، فربما يكون Monolith منظم جيدًا أكثر حكمة. لأن Microservices تتطلب بنية تحتية إضافية: API Gateway، خدمة مراقبة، logging مركزي، tracing، CI/CD أكثر نضجًا، إدارة سرية للمفاتيح، وتنسيق بين الخدمات. كل هذا جميل، لكنه ليس مجانيًا.

أفضل طريقة للتفكير هي أن Microservices ليست هدفًا بحد ذاتها، بل وسيلة. إذا كان تقسيم النظام سيمنحك مرونة حقيقية، وسرعة في التطوير، واستقلالية للفرق، فحينها تستحق الجهد. أما إذا كان سيضيف تعقيدًا بلا داعٍ، فالخيار الأبسط قد يكون أفضل وأذكى.

المبادئ الأساسية قبل أن تبدأ

قبل كتابة أي سطر كود، تحتاج إلى فهم المبادئ التي تجعل Microservices صحيحة فعلًا. أول مبدأ هو أن كل خدمة يجب أن تملك مسؤولية واحدة واضحة. لا تخلط بين خدمة الحسابات وخدمة التحقق من البريد وخدمة الملفات وشيء رابع غير واضح. كلما كانت الحدود أوضح، أصبح النظام أسهل في الصيانة.

المبدأ الثاني هو أن الخدمات يجب أن تكون مستقلة نسبيًا في البيانات. في العادة، كل خدمة تملك قاعدة بياناتها الخاصة أو على الأقل schema خاصًا بها، بدل أن تقتسم كل الخدمات قاعدة بيانات واحدة بشكل عشوائي. مشاركة الجداول بين الخدمات غالبًا تؤدي إلى كسر الاستقلالية، وتحوّل النظام إلى Monolith متخفي.

المبدأ الثالث هو أن التواصل بين الخدمات يجب أن يكون واعيًا ومدروسًا. أحيانًا يكون HTTP كافيًا، وأحيانًا تحتاج إلى الرسائل غير المتزامنة مثل RabbitMQ أو Kafka. اختيارك يعتمد على طبيعة العملية: هل تحتاج إلى رد فوري؟ هل يمكن تنفيذ العملية لاحقًا؟ هل لا بأس بالتأخير البسيط؟

المبدأ الرابع هو أن الفشل طبيعي في الأنظمة الموزعة. لذلك يجب أن تفكر من البداية في timeouts، retries، circuit breakers، fallback behaviors، وماذا يحدث عندما تكون خدمة خارج الخدمة. التصميم الجيد لا يفترض أن كل شيء سيعمل دائمًا، بل يبني نفسه على احتمال فشل أي جزء في أي لحظة.

الشكل العام لمشروع Microservices باستخدام Node.js

لنفترض أننا نبني منصة تجارة إلكترونية بسيطة، ونريد تقسيمها إلى الخدمات التالية:

  • خدمة المستخدمين

  • خدمة المنتجات

  • خدمة الطلبات

  • خدمة الدفع

  • خدمة الإشعارات

  • API Gateway أمام كل ذلك

كل خدمة ستكون تطبيق Node.js منفصلًا، غالبًا بإطار Express أو Fastify أو NestJS، لكننا سنستخدم Express في الأمثلة لأنه واضح وبسيط. لكل خدمة مجلدها الخاص، وملف package.json خاص، وبيئة تشغيلها الخاصة، وربما قاعدة بيانات خاصة بها. ويمكننا استخدام Docker لتشغيلها معًا بطريقة سهلة.

هيكلية كهذه قد تبدو كثيرة في البداية، لكنها في الحقيقة تعكس طريقة تفكير أكثر واقعية. بدل أن يصبح المشروع كتلة واحدة ضخمة يصعب لمسها، تتحول الأجزاء إلى وحدات مستقلة تستطيع الفرق التعامل معها بتوازٍ.

إنشاء أول خدمة: خدمة المستخدمين

لنبدأ بخدمة بسيطة جدًا هي خدمة المستخدمين. هذه الخدمة ستتعامل مع تسجيل المستخدم، جلب بياناته، وتحديث ملفه الشخصي. سنستخدم Express و TypeScript بشكل مبسط، لكن يمكنك تطبيق الفكرة نفسها في JavaScript الصرف.

أولًا، أنشئ المشروع:

mkdir users-service
cd users-service
npm init -y
npm install express cors dotenv mongoose
npm install -D typescript ts-node-dev @types/express @types/cors @types/node
npx tsc --init

ثم أنشئ ملف src/server.ts:

import express from "express";
import cors from "cors";
import dotenv from "dotenv";

dotenv.config();

const app = express();
app.use(cors());
app.use(express.json());

const users = [
  { id: 1, name: "Ahmed", email: "ahmed@example.com" },
  { id: 2, name: "Sara", email: "sara@example.com" },
];

app.get("/health", (_, res) => {
  res.json({ status: "ok", service: "users-service" });
});

app.get("/users", (_, res) => {
  res.json(users);
});

app.get("/users/:id", (req, res) => {
  const user = users.find(u => u.id === Number(req.params.id));
  if (!user) {
    return res.status(404).json({ message: "User not found" });
  }
  res.json(user);
});

app.post("/users", (req, res) => {
  const { name, email } = req.body;

  if (!name || !email) {
    return res.status(400).json({ message: "Name and email are required" });
  }

  const newUser = {
    id: users.length + 1,
    name,
    email,
  };

  users.push(newUser);
  res.status(201).json(newUser);
});

const port = process.env.PORT || 3001;
app.listen(port, () => {
  console.log(`Users service running on port ${port}`);
});

هذا مثال بسيط جدًا، لكنه يوضح الفكرة الأساسية: كل خدمة لها API خاص بها، ومنطقيتها الخاصة، ويمكن تشغيلها بشكل مستقل. طبعًا في المشروع الحقيقي ستستخدم قاعدة بيانات، ومصادقة، وتحققًا من البيانات، وربما طبقة Service و Repository بدل وضع المنطق كله داخل الملف نفسه. لكن قبل أن نبني التعقيد، يجب أن نفهم النواة.

إضافة قاعدة بيانات مستقلة لكل خدمة

من الأخطاء الشائعة في Microservices أن تضع كل الخدمات على قاعدة بيانات مشتركة وتستمر في مشاركة الجداول بينها. هذا يسهّل البداية قليلًا، لكنه يقتل الاستقلالية لاحقًا. الأفضل أن يكون لكل خدمة قاعدة بيانات خاصة بها، أو على الأقل مخطط بيانات منفصل جدًا.

لنأخذ خدمة المستخدمين ونربطها بـ MongoDB مثلًا. نموذج المستخدم قد يكون:

import mongoose, { Schema, Document } from "mongoose";

interface IUser extends Document {
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

const UserSchema = new Schema<IUser>(
  {
    name: { type: String, required: true },
    email: { type: String, required: true, unique: true },
    password: { type: String, required: true },
  },
  { timestamps: true }
);

export const User = mongoose.model<IUser>("User", UserSchema);

وفي server.ts:

import mongoose from "mongoose";

mongoose
  .connect(process.env.MONGODB_URI || "mongodb://localhost:27017/users_service")
  .then(() => console.log("Connected to users DB"))
  .catch(err => console.error("DB connection error", err));

بهذا الشكل أصبحت خدمة المستخدمين مسؤولة عن بياناتها هي، دون أن تعتمد على تفاصيل خدمة أخرى. وهذا مهم جدًا، لأنك عندما تحتاج لاحقًا لتغيير قاعدة البيانات أو إعادة هيكلة الحقول، لن تكون مضطرًا للتنسيق مع خدمات أخرى بشكل مباشر.

خدمة المنتجات كمثال على الاستقلال الوظيفي

خدمة المنتجات يمكن أن تكون مستقلة تمامًا عن خدمة المستخدمين. هي تتعامل مع اسم المنتج، السعر، الوصف، المخزون، وربما الصور. لا حاجة لأن تعرف تفاصيل المستخدمين سوى ما هو ضروري جدًا مثل من قام بإنشاء المنتج أو من يملك صلاحية التعديل.

مثال بسيط:

const express = require("express");
const app = express();

app.use(express.json());

let products = [
  { id: 1, name: "Laptop", price: 1200, stock: 5 },
  { id: 2, name: "Mouse", price: 25, stock: 50 }
];

app.get("/products", (req, res) => {
  res.json(products);
});

app.post("/products", (req, res) => {
  const { name, price, stock } = req.body;

  if (!name || price == null || stock == null) {
    return res.status(400).json({ message: "Invalid product data" });
  }

  const product = {
    id: products.length + 1,
    name,
    price,
    stock
  };

  products.push(product);
  res.status(201).json(product);
});

app.listen(3002, () => console.log("Products service on 3002"));

الجميل هنا أن خدمة المنتجات لا تحتاج أن تعرف شيئًا عن الطلبات أو الدفع. هي فقط تعرض وتدير المنتجات. وإذا أردت لاحقًا أن تضعها داخل خدمة مستقلة مع قاعدة بيانات مستقلة، فأنت لا تحتاج إلى كسر باقي النظام.

كيف تتواصل الخدمات مع بعضها؟

هذه واحدة من أهم النقاط في Microservices. لديك بشكل عام طريقتان رئيستان للتواصل: التواصل المتزامن synchronous مثل HTTP/REST أو gRPC، والتواصل غير المتزامن asynchronous مثل الرسائل والـ events.

التواصل المتزامن جيد عندما تحتاج إلى رد فوري. مثلًا، خدمة الطلبات قد تحتاج أن تسأل خدمة المنتجات: هل المنتج متوفر؟ ما سعره الحالي؟ خدمة المستخدمين قد تحتاج أن تتأكد من هوية المستخدم عبر طلب API مباشر.

التواصل غير المتزامن مفيد عندما تكون العملية لا تحتاج ردًا فوريًا، أو عندما تريد فصل الخدمات بقوة أكبر. مثلًا، عندما يتم إنشاء طلب جديد، يمكن لخدمة الطلبات أن تنشر حدثًا OrderCreated، ثم تستمع خدمة الإشعارات لهذا الحدث لإرسال البريد، وتستمع خدمة الدفع لمعالجة العملية، وتستمع خدمة التحليلات لتخزينه.

مثال على خدمة الطلبات مع HTTP

لننشئ خدمة الطلبات التي تسجل طلبًا جديدًا وتستدعي خدمة المنتجات للتحقق من التفاصيل.

const express = require("express");
const axios = require("axios");

const app = express();
app.use(express.json());

let orders = [];

app.post("/orders", async (req, res) => {
  try {
    const { userId, productId, quantity } = req.body;

    if (!userId || !productId || !quantity) {
      return res.status(400).json({ message: "Missing fields" });
    }

    const productResponse = await axios.get(`http://localhost:3002/products/${productId}`);
    const product = productResponse.data;

    if (product.stock < quantity) {
      return res.status(400).json({ message: "Not enough stock" });
    }

    const order = {
      id: orders.length + 1,
      userId,
      productId,
      quantity,
      total: product.price * quantity,
      status: "pending"
    };

    orders.push(order);

    res.status(201).json(order);
  } catch (error) {
    res.status(500).json({ message: "Unable to create order" });
  }
});

app.get("/orders", (req, res) => {
  res.json(orders);
});

app.listen(3003, () => console.log("Orders service on 3003"));

هذا المثال يوضح فكرة مهمة: خدمة الطلبات تستفيد من خدمة المنتجات، لكنها لا تقتحم قاعدة بياناتها. هذا أفضل كثيرًا من مشاركة قاعدة البيانات، لكنه ما زال يعتمد على توفر خدمة المنتجات في لحظة الطلب. وهنا تبدأ الأسئلة الهندسية الحقيقية: ماذا لو تعطلت خدمة المنتجات؟ ماذا لو تأخر الرد؟ ماذا لو أردت تقليل الارتباط بين الخدمات أكثر؟

متى نستخدم Event-Driven Architecture؟

عندما تكبر المنظومة، ستكتشف أن HTTP وحده لا يكفي دائمًا. لأنه يجعل الخدمة الواحدة تنتظر الخدمة الأخرى، ويزيد من التبعية المباشرة. في الأنظمة الكبيرة، كثير من العمليات أنسب أن تكون أحداثًا. مثال: “تم إنشاء طلب”، “تم شحن الطلب”، “تم إتمام الدفع”، “تم تسجيل مستخدم جديد”.

في هذا النموذج، لا تقول الخدمة: “اذهب الآن ونفذ لي هذا”. بل تقول: “حدث هذا الأمر”، ثم تتفاعل الخدمات المهتمة بذلك الحدث وفقًا لما تحتاجه. وهذا يخفف الارتباط ويزيد المرونة.

مثال على RabbitMQ مع Node.js

يمكننا استخدام RabbitMQ كوسيط رسائل بين الخدمات. خدمة الطلبات تنشر حدثًا، وخدمة الإشعارات تستقبل الحدث وتتعامل معه.

تثبيت المكتبة:

npm install amqplib

النشر من خدمة الطلبات

const amqp = require("amqplib");

async function publishOrderCreated(order) {
  const connection = await amqp.connect("amqp://localhost");
  const channel = await connection.createChannel();
  const queue = "order_created";

  await channel.assertQueue(queue, { durable: true });
  channel.sendToQueue(queue, Buffer.from(JSON.stringify(order)));

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

ثم داخل إنشاء الطلب:

orders.push(order);
await publishOrderCreated(order);
res.status(201).json(order);

الاستهلاك داخل خدمة الإشعارات

const amqp = require("amqplib");

async function consumeOrders() {
  const connection = await amqp.connect("amqp://localhost");
  const channel = await connection.createChannel();
  const queue = "order_created";

  await channel.assertQueue(queue, { durable: true });

  channel.consume(queue, (msg) => {
    if (msg !== null) {
      const order = JSON.parse(msg.content.toString());
      console.log("New order received:", order);

      // إرسال بريد إلكتروني أو إشعار هنا

      channel.ack(msg);
    }
  });
}

consumeOrders().catch(console.error);

هذا النمط يجعل النظام أكثر مرونة، لأن الخدمات لم تعد كلها متصلة ببعضها بشكل مباشر. بدل أن يطلب كل جزء من الآخر في اللحظة نفسها، تبدأ الأحداث تتحرك في النظام كنبضات صغيرة، وكل خدمة تلتقط ما يهمها.

API Gateway: الباب الأمامي للنظام

عندما يكون لديك عدة خدمات، لا تريد أن يذهب العميل إلى كل خدمة مباشرة. هذا سيجعل الواجهة معقدة، ويكشف تفاصيل داخلية غير ضرورية، ويصعب إدارة المصادقة والتوجيه. هنا يأتي API Gateway.

وظيفته أن يكون نقطة دخول واحدة للعميل. يستقبل الطلب، ثم يوجّهه إلى الخدمة المناسبة، وقد يجمع لك نتائج من أكثر من خدمة في رد واحد. كما يمكنه أن يضيف طبقة مشتركة من التحقق والمصادقة والتسجيل.

مثال بسيط جدًا باستخدام Express:

const express = require("express");
const axios = require("axios");

const app = express();
app.use(express.json());

app.get("/api/users", async (req, res) => {
  const response = await axios.get("http://localhost:3001/users");
  res.json(response.data);
});

app.get("/api/products", async (req, res) => {
  const response = await axios.get("http://localhost:3002/products");
  res.json(response.data);
});

app.post("/api/orders", async (req, res) => {
  const response = await axios.post("http://localhost:3003/orders", req.body);
  res.status(response.status).json(response.data);
});

app.listen(3000, () => console.log("API Gateway running on 3000"));

في المشاريع الحقيقية، قد تستخدم Nginx أو Kong أو Traefik أو بوابة مخصصة باستخدام NestJS أو Fastify، لكن الفكرة واحدة: لا تجعل العميل يطلب من خمس خدمات مختلفة وهو لا يعرف حتى إن كانت موجودة.

المصادقة والتفويض في بيئة Microservices

المصادقة Authentication والتفويض Authorization من الأمور التي تحتاج تفكيرًا مبكرًا جدًا. السؤال ليس فقط: “كيف يسجل المستخدم الدخول؟” بل: “كيف تتأكد كل خدمة أن الطلب المرسل لها موثوق؟”.

أحد الأساليب الشائعة هو JWT. يقوم المستخدم بتسجيل الدخول عبر خدمة أو auth service، ثم يحصل على token، وبعدها يرسله مع كل طلب. كل خدمة تتحقق من صحة token، أو قد يمر token عبر API Gateway الذي يتحقق منه أولًا.

مثال middleware بسيط:

const jwt = require("jsonwebtoken");

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader) {
    return res.status(401).json({ message: "No token provided" });
  }

  const token = authHeader.split(" ")[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ message: "Invalid token" });
  }
}

module.exports = authMiddleware;

وفي خدمة محمية:

const authMiddleware = require("./authMiddleware");

app.get("/orders", authMiddleware, (req, res) => {
  res.json(orders.filter(order => order.userId === req.user.id));
});

في الأنظمة الأكثر تقدمًا، قد تستخدم OAuth2 أو OpenID Connect أو Identity Provider خارجي. لكن حتى مع ذلك، يظل المبدأ نفسه: لا تترك الخدمة تثق بأي طلب عشوائي.

تسجيل الأخطاء والمراقبة Logging & Observability

من أصعب الأشياء في Microservices أن المشكلة قد تكون موزعة عبر عدة خدمات. الطلب يدخل من الـ Gateway، يمر على خدمة المستخدمين، ثم الطلبات، ثم الدفع، ثم يتوقف فجأة. فإذا لم يكن لديك logging مركزي ومراقبة جيدة، ستقضي وقتًا طويلًا تبحث عن مكان الخلل.

القاعدة الذهبية هنا: كل خدمة يجب أن تكتب Logs مفيدة، وتضيف Request ID أو Correlation ID، وتوضح ماذا حدث ومتى ولماذا. لا يكفي أن تكتب “Error occurred”. هذا لا يفيد أحدًا.

مثال استخدام morgan للتسجيل:

const morgan = require("morgan");
app.use(morgan("combined"));

وفي المشاريع الأكثر احترافًا، يمكنك استخدام:

  • Winston أو Pino للـ logging

  • Prometheus للـ metrics

  • Grafana للعرض

  • Jaeger أو OpenTelemetry للـ tracing

مثال بسيط لفكرة correlation ID:

const { v4: uuidv4 } = require("uuid");

app.use((req, res, next) => {
  req.requestId = req.headers["x-request-id"] || uuidv4();
  res.setHeader("x-request-id", req.requestId);
  next();
});

ثم تضيف هذا المعرف في كل log. عندما يحدث خطأ، تستطيع تتبع الرحلة كاملة بدل أن تضيع في بحر من الرسائل.

التعامل مع الأعطال والفشل

في Microservices، الفشل ليس استثناءً نادرًا، بل جزء طبيعي من الحياة اليومية. لذلك من الأخطاء الشائعة أن تكتب النظام وكأن كل شيء سيعمل بلا مشاكل. هذا التفكير ينهار بسرعة في الإنتاج.

لو كانت خدمة المنتجات لا تستجيب، ماذا تفعل خدمة الطلبات؟ هل تنتظر للأبد؟ بالطبع لا. يجب أن تضع timeout معقولًا:

const response = await axios.get("http://localhost:3002/products/1", {
  timeout: 3000
});

كما يمكنك استخدام retries بحذر، لأن إعادة المحاولة بدون ضوابط قد تزيد الضغط على خدمة متعثرة أصلًا. وفي بعض الحالات، تحتاج circuit breaker حتى تمنع الانهيار المتسلسل.

فكر في هذا السيناريو: خدمة الدفع متوقفة، وخدمة الطلبات لا تزال تكرر الاتصال بها، والـ Gateway لا يعرف كيف يتصرف، والمستخدم يضغط retry عشر مرات. هنا يصبح النظام كله تحت ضغط غير صحي. التصميم الجيد يمنع هذا السيناريو من التصاعد.

إدارة البيانات والمعاملات الموزعة

واحدة من أكثر القضايا الحساسة في Microservices هي المعاملات Transactions. في monolith يمكنك أحيانًا تنفيذ Transaction واحدة على عدة جداول، لكن في Microservices يصبح الأمر أصعب لأن البيانات موزعة عبر خدمات وقواعد بيانات مختلفة.

إذا أنشأت طلبًا، ثم فشلت عملية الدفع، ثم تم حجز المخزون، فأنت أمام مشكلة: كيف تعيد النظام إلى حالة متسقة؟ هنا تظهر أنماط مثل Saga Pattern، حيث تتم العملية على خطوات، ولكل خطوة تعويض compensation action إذا فشلت الخطوات التالية.

مثال فكرة Saga بشكل مبسط:

  1. أنشئ الطلب

  2. احجز المخزون

  3. نفذ الدفع

  4. أرسل الإشعار

  5. إذا فشل الدفع، ألغِ الطلب وأعد المخزون

لا يوجد سحر هنا. فقط تحتاج إلى تصميم واعٍ يراعي أن النظام الموزع لا يملك نفس بساطة المعاملة المحلية في قاعدة بيانات واحدة.

مثال عملي على Service-to-Service communication

لنفرض أن API Gateway يحتاج أن يعرض تفاصيل الطلب مع اسم المستخدم واسم المنتج. بدل أن يرسل العميل ثلاث طلبات، يمكن للبوابة أن تجمع البيانات من أكثر من خدمة.

app.get("/api/orders/:id/details", async (req, res) => {
  try {
    const orderResponse = await axios.get(`http://localhost:3003/orders/${req.params.id}`);
    const order = orderResponse.data;

    const [userResponse, productResponse] = await Promise.all([
      axios.get(`http://localhost:3001/users/${order.userId}`),
      axios.get(`http://localhost:3002/products/${order.productId}`)
    ]);

    res.json({
      order,
      user: userResponse.data,
      product: productResponse.data
    });
  } catch (error) {
    res.status(500).json({ message: "Failed to load order details" });
  }
});

هذا المثال عملي جدًا، لكنه أيضًا يكشف نقطة مهمة: كلما زاد عدد الخدمات التي تتصل بها خدمة واحدة بشكل مباشر، زادت فرص التعطل. لذلك ليس كل تجميع للبيانات يجب أن يتم بهذا الشكل؛ أحيانًا يكون من الأفضل استخدام read models، أو CQRS، أو تخزين نسخة من البيانات اللازمة للعرض.

استخدام Docker لتشغيل الخدمات

عندما تبدأ الخدمات بالزيادة، يصبح تشغيل كل خدمة يدويًا مرهقًا. وهنا يأتي دور Docker. يمكنك أن تعزل كل خدمة في container خاص بها، ثم تديرها بسهولة عبر Docker Compose.

مثال Dockerfile لخدمة المستخدمين:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 3001

CMD ["npm", "start"]

ومثال docker-compose.yml مبسط:

version: "3.8"

services:
  users-service:
    build: ./users-service
    ports:
      - "3001:3001"
    environment:
      - PORT=3001
      - MONGODB_URI=mongodb://users-db:27017/users_service
    depends_on:
      - users-db

  users-db:
    image: mongo:7
    ports:
      - "27017:27017"

  products-service:
    build: ./products-service
    ports:
      - "3002:3002"

  orders-service:
    build: ./orders-service
    ports:
      - "3003:3003"

  gateway:
    build: ./gateway
    ports:
      - "3000:3000"
    depends_on:
      - users-service
      - products-service
      - orders-service

Docker يعطيك بيئة أقرب للإنتاج، ويجعل تشغيل النظام كاملًا أكثر اتساقًا بين جهاز المطور، وبيئة الاختبار، والخادم الحقيقي.

الاختبار في بيئة Microservices

الاختبار في Microservices يحتاج أن يُقسم بشكل ذكي. لديك:

  • Unit tests لاختبار الوظائف الصغيرة

  • Integration tests لاختبار التكامل مع قاعدة البيانات أو الخدمات الأخرى

  • Contract tests للتأكد من أن الخدمات تتفاهم بالشكل المتوقع

  • End-to-End tests لاختبار رحلة المستخدم كاملة

في Node.js يمكنك استخدام Jest وSupertest.

مثال:

npm install -D jest supertest
const request = require("supertest");
const app = require("../app");

describe("GET /health", () => {
  it("should return service status", async () => {
    const res = await request(app).get("/health");
    expect(res.statusCode).toBe(200);
    expect(res.body.status).toBe("ok");
  });
});

الاختبار مهم جدًا لأنك لا تختبر فقط منطق خدمة واحدة، بل تختبر أيضًا التفاعل بين الخدمات. وأحيانًا المشكلة لا تكون في الكود نفسه، بل في شكل الرسائل، أو schema الرد، أو التوقيت، أو الإعدادات البيئية.

النسخ والإصدارات Versioning

عندما تتغير API في بيئة Microservices، يجب أن تكون حذرًا جدًا. قد تكون هناك خدمة قديمة لا تزال تعتمد على شكل معين من الردود، أو عميل موبايل لم يحدّث بعد. لذلك لا تكسر التوافق backward compatibility بسهولة.

يمكنك استخدام versioning في المسارات:

app.get("/api/v1/orders", ...)
app.get("/api/v2/orders", ...)

أو versioning على مستوى الرسائل والأحداث. المهم أن تبقي التغييرات مدروسة، وأن تمنح المستهلكين وقتًا كافيًا للانتقال. هذا الجانب قد يبدو إداريًا، لكنه من أهم أسباب نجاح الأنظمة الكبيرة أو فشلها.

الأمان في Microservices

الأمان ليس مجرد إضافة JWT وخلاص. في الأنظمة الموزعة، هناك جوانب كثيرة:

  • تأمين الاتصال بين الخدمات

  • إدارة المفاتيح السرية

  • التحقق من المدخلات في كل خدمة

  • منع الوصول غير المصرح به

  • حماية الـ APIs من الإساءة

  • عدم كشف التفاصيل الداخلية في الأخطاء

مثال بسيط للتحقق من المدخلات باستخدام zod:

npm install zod
import { z } from "zod";

const createUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8)
});

const result = createUserSchema.safeParse(req.body);

if (!result.success) {
  return res.status(400).json({
    message: "Validation failed",
    errors: result.error.flatten()
  });
}

التحقق في كل خدمة مهم لأنك لا يجب أن تفترض أن الخدمة السابقة أو الـ Gateway قد قام بكل شيء نيابة عنك. الدفاع الجيد متعدد الطبقات.

الأداء والتوسع Scaling

ميزة Microservices الأساسية هي أن كل خدمة يمكن أن تتوسع بشكل مستقل. إذا كانت خدمة الطلبات تتعرض لضغط كبير، يمكنك تشغيل نسخ أكثر منها فقط، دون الحاجة إلى توسيع بقية النظام بنفس النسبة.

في Node.js، قد تستفيد من:

  • horizontal scaling عبر عدة containers

  • load balancing

  • caching

  • asynchronous processing

  • message queues لتخفيف الضغط المباشر

التخزين المؤقت Cache مهم جدًا خصوصًا للبيانات التي تُقرأ كثيرًا وتتغير قليلًا، مثل إعدادات عامة أو بيانات منتجات شائعة. يمكنك استخدام Redis مثلًا:

npm install redis
const redis = require("redis");
const client = redis.createClient();

app.get("/products", async (req, res) => {
  const cached = await client.get("products");

  if (cached) {
    return res.json(JSON.parse(cached));
  }

  const data = await loadProductsFromDb();
  await client.setEx("products", 60, JSON.stringify(data));

  res.json(data);
});

هذا النوع من التحسينات يمكن أن يغير الأداء بشكل كبير عندما يبدأ النظام في النمو.

المراقبة والتشخيص في الإنتاج

عندما تنتقل إلى الإنتاج، تصبح الأسئلة مختلفة. لم يعد السؤال: “هل يعمل الكود؟”، بل: “كيف أعرف أن النظام كله بخير؟” لذلك تحتاج إلى metrics مثل:

  • عدد الطلبات في الثانية

  • زمن الاستجابة

  • نسبة الأخطاء

  • استهلاك الذاكرة

  • استهلاك CPU

  • عدد الرسائل في الطابور

يمكنك أيضًا إضافة health checks واضحة:

app.get("/health", (req, res) => {
  res.json({
    status: "ok",
    uptime: process.uptime(),
    timestamp: new Date().toISOString()
  });
});

ولا تكتفِ بـ “status: ok” فقط. في بعض البيئات، تحتاج readiness وliveness probes، خصوصًا إذا كنت تستخدم Kubernetes. الأولى تعني: هل الخدمة جاهزة لاستقبال الطلبات؟ والثانية: هل الخدمة لا تزال حية وتعمل؟ فرق صغير في الكلمة، لكنه مهم جدًا في الإدارة التشغيلية.

أخطاء شائعة عند بناء Microservices

هناك مجموعة من الأخطاء تتكرر كثيرًا، ومن المفيد أن تراها مبكرًا بدل أن تتعلمها بعد فوات الأوان.

أول خطأ هو تقسيم الخدمات بشكل عشوائي. لا تقسّم المشروع حسب الجداول أو الملفات، بل حسب الدومين والمعنى. على سبيل المثال، “users” خدمة، “orders” خدمة، “payments” خدمة. هذا تقسيم منطقي. أما تقسيم مثل “db service” و “helper service” فغالبًا غير مفيد.

الخطأ الثاني هو الاعتماد المفرط على الاتصالات المتزامنة. إذا كانت كل خدمة تنتظر الأخرى بشكل مباشر، فأنت تبني شبكة من الخيوط المتشابكة، وأي تأخير بسيط قد يسبب اختناقًا.

الخطأ الثالث هو استخدام قاعدة بيانات مشتركة. هذا اختصار مريح في البداية، لكنه يفسد استقلالية الخدمات.

الخطأ الرابع هو إهمال المراقبة. في الأنظمة الموزعة، عدم وجود logging وtracing يشبه قيادة سيارة ليلًا بلا أضواء.

الخطأ الخامس هو البدء بـ microservices قبل أن يفهم الفريق أساسيات deployment وmonitoring وtesting. التقنية ليست مجرد كود، بل منظومة كاملة.

كيف تنتقل من Monolith إلى Microservices؟

كثير من المشاريع تبدأ كتطبيق واحد كبير، وهذا ليس عيبًا. بل في الواقع هذا هو المسار الطبيعي غالبًا. المهم هو كيف تنتقل لاحقًا إن احتجت. أفضل أسلوب هو الانتقال التدريجي، وليس التفكيك العنيف.

ابدأ بتحديد وحدات منطقية واضحة داخل الـ Monolith. ثم افصل أكثر جزء مستقل وأقل جزء ارتباكًا. مثلًا، قد تبدأ بخدمة الإشعارات أو خدمة الملفات لأنها أقل ارتباطًا. بعد ذلك انقل البيانات والمنطق تدريجيًا. استخدم anti-corruption layer إن لزم الأمر. ثم راقب التأثير.

الانتقال الناجح ليس سباقًا. هو عملية هادئة ومدروسة. وإذا حاولت أن تفكك النظام كله دفعة واحدة، غالبًا ستنتهي بنصف نظام قديم ونصف نظام جديد، والاثنان لا يثقان ببعضهما.

مثال لبنية مشروع عملية

يمكن أن يكون شكل المشروع مثل هذا:

project-root/
├── api-gateway/
│   ├── src/
│   └── Dockerfile
├── users-service/
│   ├── src/
│   ├── tests/
│   └── Dockerfile
├── products-service/
│   ├── src/
│   ├── tests/
│   └── Dockerfile
├── orders-service/
│   ├── src/
│   ├── tests/
│   └── Dockerfile
├── notifications-service/
│   ├── src/
│   ├── tests/
│   └── Dockerfile
└── docker-compose.yml

هذه البنية ليست الوحيدة، لكنها واضحة ومريحة. كل خدمة مستقلة، وكل خدمة تستطيع أن تُفهم وحدها، وتُشغّل وحدها، وتُختبر وحدها.

نصائح عملية من أرض الواقع

في المشاريع الحقيقية، ستتعلم أن أهم شيء ليس أن تكون البنية “متقدمة” على الورق، بل أن تكون قابلة للحياة على المدى الطويل. أحيانًا تكون خدمة واحدة زائدة بلا حاجة. أحيانًا يكون تقسيمك ممتازًا لكن الرسائل بين الخدمات معقدة أكثر من اللازم. وأحيانًا يكون الحل الأبسط هو الأفضل فعلًا.

احرص على أن يكون لكل خدمة owner واضح، وAPI واضح، وresponsibility واضحة. لا تجعل فريقًا واحدًا يلمس كل شيء بلا حدود، لأنك عندها ستفقد قيمة الاستقلالية. واستخدم TypeScript إن أمكن، لأنه يساعد كثيرًا في تقليل الأخطاء في العقود بين الخدمات. كذلك، لا تفرط في الذكاء من أول يوم؛ ابدأ بما يكفي، ثم حسّن تدريجيًا حسب الحاجة الفعلية.

خاتمة

بناء Microservices باستخدام Node.js ليس مجرد اختيار تقني، بل هو طريقة تفكير كاملة. أنت هنا لا تبني تطبيقًا واحدًا فقط، بل تبني نظامًا موزعًا يحيا ويتنفس عبر عدة خدمات مستقلة، وكل واحدة منها تحتاج إلى تصميم نظيف، وحدود واضحة، وتواصل محسوب، ومراقبة دقيقة، وصبر من الفريق. Node.js يمنحك أدوات قوية جدًا لهذا النوع من المعمارية، خصوصًا عندما تجمعه مع Express أو Fastify أو NestJS، ومع Docker، ومع RabbitMQ أو Kafka، ومع Redis وJWT وPrometheus وغيرها من الأدوات التي تجعل النظام أكثر صلابة.

إذا بدأت صغيرًا، وفكرت جيدًا في الحدود، ولم تتسرع في التوزيع قبل الحاجة، فستجد أن Microservices قد تمنحك مرونة مذهلة. أما إذا تعاملت معها كموضة تقنية فقط، فقد تتحول إلى عبء كبير. السر ليس في أن تجعل كل شيء صغيرًا، بل في أن تجعل كل جزء مفهومًا، مستقلًا، وقابلًا للنمو. وهذا بالضبط ما يجعل بناء Microservices باستخدام Node.js رحلة ممتعة، وتحديًا هندسيًا يستحق التجربة حقًا.

#Microservices #Node.js #بناء Microservices باستخدام Node.js #Express.js #API Gateway #Docker #RabbitMQ #Event-Driven Architecture #JWT #Redis #Kubernetes #Observability #Distributed Systems

اشترك في نشرتنا البريدية

12k+

المشتركون

أسبوعيًا

التكرار

مجاني

دائمًا