Kodax

هيكلة مشروع Node للإنتاج — Node.js و Express

هيكلة مشروع Node للإنتاج

ملف server.js من 800 سطر يعمل... حتى يكبر الفريق أو يسقط الخادم في الثالثة فجراً. هذا الدرس يجمع ما يفصل "يعمل على جهازي" عن "يعمل في الإنتاج".

تنظيم حسب الميزة

src/
├── config.js              ← قراءة متغيرات البيئة والتحقق منها
├── app.js                 ← إنشاء التطبيق (بلا listen) — قابل للاختبار
├── server.js              ← نقطة التشغيل: listen + الإيقاف المنظم
├── lib/
│   ├── logger.js
│   └── errors.js          ← AppError وأصنافه
├── db/
│   └── pool.js
└── modules/
    ├── users/
    │   ├── users.routes.js
    │   ├── users.service.js
    │   ├── users.repository.js
    │   └── users.test.js
    └── orders/
        └── ...

كل ما يخص "المستخدمين" في مجلد واحد. عندما تعدّل ميزة، لا تقفز بين عشرة مجلدات. فصل app.js عن server.js يسمح للاختبارات بإنشاء التطبيق دون فتح منفذ.

الإعدادات: فشل مبكر

function loadConfig(env) {
  const required = ["DATABASE_URL", "JWT_SECRET"];
  const missing = required.filter((k) => !env[k]);
  if (missing.length) throw new Error(`متغيرات ناقصة: ${missing.join(", ")}`);
  const port = Number(env.PORT ?? 3000);
  if (!Number.isInteger(port)) throw new Error("PORT غير صالح");
  return Object.freeze({ port, databaseUrl: env.DATABASE_URL, jwtSecret: env.JWT_SECRET, isProd: env.NODE_ENV === "production" });
}
try { loadConfig({ PORT: "8080" }); } catch (e) { console.log(e.message); }

كائن إعدادات واحد مجمّد يُستورد في كل مكان — لا process.env متناثرة في الكود.

السجلات المنظمة

console.log("user logged in") لا يفيد مع آلاف الطلبات. السجل الجيد JSON بحقول ثابتة: الوقت، المستوى، الرسالة، معرّف الطلب، والسياق — فتستطيع البحث والتصفية في أدوات المراقبة. مكتبة pino معيار شائع وسريع.

- المستويات: debug ← info ← warn ← error؛ في الإنتاج info فما فوق.

- لا تسجّل أبداً: كلمات المرور، الرموز (tokens)، أرقام البطاقات، ترويسة Authorization كاملة. pino يدعم redact لحذف حقول تلقائياً.

الإيقاف المنظم (Graceful Shutdown)

عند إعادة النشر، ترسل المنصة SIGTERM. إن أُغلق البرنامج فوراً، تنقطع الطلبات الجارية في منتصفها (دفعة نصف مكتملة!). الصحيح:

1. توقف عن قبول اتصالات جديدة (server.close()).

2. انتظر الطلبات الجارية لتنتهي (بمهلة قصوى).

3. أغلق مجمّع قاعدة البيانات والاتصالات الأخرى.

4. اخرج.

المثال الكامل يطبق ذلك.

فحص الصحة

مساران تستخدمهما المنصات وموزعات الحمل:

- /health/live: العملية حيّة (لا تفحص قاعدة البيانات هنا وإلا أعادت المنصة تشغيل كل النسخ عند عطل القاعدة).

- /health/ready: جاهزة لاستقبال الطلبات (القاعدة متصلة). أثناء الإيقاف تعيد 503 فيتوقف الموزع عن إرسال طلبات.

الأخطاء غير الملتقطة

unhandledRejection و uncaughtException: سجّل الخطأ بالتفصيل، ثم اخرج بشكل منظم (process.exitCode = 1 بعد الإغلاق) ودع مدير العمليات (Docker، المنصة، PM2) يعيد التشغيل. الاستمرار بعد خطأ مجهول قد يترك التطبيق بحالة فاسدة.

قائمة ما قبل الإنتاج

- [ ] إعدادات متحقق منها، وأسرار في متغيرات البيئة فقط.

- [ ] سجلات JSON بلا بيانات حساسة، ومعرّف لكل طلب.

- [ ] إيقاف منظم وفحوص صحة.

- [ ] helmet و CORS محدد وتحديد المعدل وحدود حجم الجسم.

- [ ] اختبارات تعمل في CI.

- [ ] npm ci و NODE_ENV=production وإصدار Node LTS محدد.

تمرين

أعد هيكلة API الكتب من دورة Express إلى بنية modules/books مع app.js و server.js منفصلين، وأضف فحصي الصحة والإيقاف المنظم، ثم اختبر المسار /health/live بـ node:test دون فتح منفذ ثابت (المنفذ 0).

// خادم بإعدادات متحقق منها، سجلات JSON، فحوص صحة، وإيقاف منظم — بلا مكتبات خارجية
const http = require("node:http");
const { randomUUID } = require("node:crypto");

// ===== config.js =====
function loadConfig(env) {
  const port = Number(env.PORT ?? 0);              // 0 = منفذ حر (للتجربة والاختبار)
  if (!Number.isInteger(port) || port < 0) throw new Error("PORT غير صالح");
  return Object.freeze({ port, shutdownTimeoutMs: 5000, isProd: env.NODE_ENV === "production" });
}

// ===== logger.js: JSON بحقول ثابتة، مع حذف الحقول الحساسة =====
const REDACT = new Set(["password", "token", "authorization"]);
function log(level, msg, ctx = {}) {
  const safe = Object.fromEntries(Object.entries(ctx).map(([k, v]) => [k, REDACT.has(k.toLowerCase()) ? "[محذوف]" : v]));
  console.log(JSON.stringify({ time: new Date().toISOString(), level, msg, ...safe }));
}

// ===== app.js: معالج الطلبات (بلا listen) =====
function createApp(state) {
  return (req, res) => {
    const requestId = randomUUID();
    const started = Date.now();
    res.setHeader("X-Request-Id", requestId);
    res.on("finish", () => log("info", "request", { requestId, method: req.method, url: req.url, status: res.statusCode, ms: Date.now() - started }));

    const send = (status, body) => {
      res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
      res.end(JSON.stringify(body));
    };
    if (req.url === "/health/live") return send(200, { ok: true });
    if (req.url === "/health/ready") return state.shuttingDown ? send(503, { ready: false }) : send(200, { ready: true });
    if (req.url === "/api/hello") return send(200, { message: "مرحباً" });
    send(404, { error: "غير موجود" });
  };
}

// ===== server.js =====
const config = loadConfig(process.env);
const state = { shuttingDown: false };
const server = http.createServer(createApp(state));

async function shutdown(signal) {
  if (state.shuttingDown) return;
  state.shuttingDown = true;                       // /health/ready يعيد 503 فيتوقف الموزع عن الإرسال
  log("info", "shutdown started", { signal });
  const timer = setTimeout(() => { log("error", "shutdown timeout"); process.exit(1); }, config.shutdownTimeoutMs);
  server.close(() => {                             // يرفض الجديد وينتظر الجاري
    // await pool.end();                           // أغلق قاعدة البيانات هنا
    clearTimeout(timer);
    log("info", "shutdown complete");
  });
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("unhandledRejection", (err) => { log("error", "unhandledRejection", { error: String(err) }); shutdown("unhandledRejection"); });

server.listen(config.port, () => {
  const { port } = server.address();
  log("info", "listening", { port, token: "سر-لا-يظهر" });

  // تجربة ذاتية: طلبان ثم إيقاف منظم
  const get = (path) => new Promise((resolve, reject) => {
    http.get({ port, path, agent: false }, (res) => {
      let body = "";
      res.on("data", (c) => (body += c));
      res.on("end", () => resolve(`${res.statusCode} ${body}`));
    }).on("error", reject);                     // بدون مستمع error يسقط البرنامج عند رفض الاتصال
  });
  (async () => {
    console.log(await get("/health/ready"));
    console.log(await get("/api/hello"));
    await shutdown("demo");
    console.log(await get("/health/ready").catch(() => "الخادم مغلق"));
  })();
});
اختبر فهمك