SELFLAB الستاك الكامل · ١١ إقليم SYSTEM ONLINE
الإقليم 03منهجٌ يُدرَس وحدك

الإقليم ٣ — من http الخام إلى Express: لماذا "إطار"؟

يبني فوق: خادم http الخام والـ streams (الإقليم ٢)، والدوال-كقيمٍ والإغلاق (١)، ورسالة HTTP وأفعالها ورموزها (٠). يفتح: التحقّق (٦)، الهوية كـ middleware (٧)، وكل مسارات الـ API في مشروعك.

النبذة

في الإقليم ٢ بنيتَ خادم HTTP خاماً بـ http وحده. اشتغل — لكن لمستَ بنفسك كم كلّفك 404 واحدٌ وقراءةُ جسمٍ واحدةٍ وتمييزُ مسار. الآن نواجه السؤال الذي يولد منه كلُّ إطارِ ويب: كيف نروّض هذا التكرار دون أن نخسر فهمَ ما تحته؟ الجواب فكرةٌ واحدةٌ أنيقةٌ — سلسلةٌ من الدوال يمرّ بها كلُّ طلب — سنبنيها بأيدينا حتى يصير Express، حين نفتحه، مألوفاً لا غامضاً. القاعدة المقدّسة هنا: لا تستعمل إطاراً قبل أن تبني نواته، وإلا بقي صندوقاً أسود تثق به بلا فهم.


اللغز المستفزّ

خذ خادمك الخام من الإقليم ٢ وتخيّل مشروعك الحقيقيّ ينمو عليه. تحتاج:

  • توجيهاً: GET /api/v1/health، POST /api/v1/auth/register، POST /api/v1/auth/login، GET /api/v1/me، ولاحقاً GET /api/v1/curricula/:id/sprints بمعرّفٍ متغيّرٍ في المسار.
  • قراءةَ جسمِ JSON وتحويلَه — في كل مسارِ POST.
  • ترويساتِ أمنٍ على كل ردّ، وسماحَ CORS لواجهتك على منفذٍ آخر، وحدّاً لمعدّل الطلبات على login ضد التخمين.
  • تحقّقاً من الهوية على GET /me وما بعده — قبل أن يصل الطلب للمعالِج.
  • معالجةَ أخطاءٍ موحّدةً: أيُّ انفجارٍ في أي مسارٍ يردّ بمظروفٍ ورمزٍ صحيحين، لا أن يسقط الخادم.

اكتب هذا كلّه بـ if (req.url === ... && req.method === ...) متشعّبة، وكرّر قراءةَ الجسم وترويساتِ الأمن وفحصَ الهوية في كل فرع. ستغرق في تكرارٍ هشٍّ مستحيلِ الصيانة. فالسؤال:

ملاحظة

ما التجريد الواحد الذي يلتقط "افعل شيئاً لكل طلبٍ (أو لبعضها) بترتيبٍ معيّن، ثم سلّمه للتالي"، فيوحّد التوجيهَ والتحليلَ والأمنَ والهويةَ والأخطاء في نمطٍ واحد؟

فكّر فيه كأنابيبَ يمرّ الطلبُ خلالها. اقعد معه قبل أن تقرأ.


ليش: الفكرة الواحدة — middleware (سلسلة المعالجة)

الجواب: middleware. كلُّ طلبٍ يمرّ عبر سلسلةٍ مرتّبةٍ من الدوال، لكلِّ دالةٍ نفس التوقيع:

code
(req, res, next) => { ... }

كلُّ حلقةٍ في السلسلة تفعل شيئاً بالطلب/الردّ، ثم إمّا:

هذا كلّ شيء. لاحظ كم يلتقط هذا التجريدُ الواحد:

الحاجةمجرّد middleware
ترويسات أمندالةٌ تضيف الترويسات ثم next() (= helmet)
قراءة الجسمدالةٌ تجمّع الـ stream وتضع req.body ثم next() (= express.json)
سماح CORSدالةٌ تضيف ترويسات CORS ثم next() (= cors)
حدّ المعدّلدالةٌ تعدّ الطلبات وتردّ 429 أو next() (= rate-limit)
الهويةدالةٌ تتحقّق من الـ token: next() أو 401 (= auth، الإقليم ٧)
التحقّقدالةٌ تفحص req.body: next() أو 400 (= Zod، الإقليم ٦)
التوجيهدالةٌ تطابق الفعل+المسار وتنادي معالِجَه
الأخطاءدالةٌ بأربعة معاملات (err, req, res, next) تُنادى عند next(err)
نموذج ذهني

النموذج الذهني — البصلة/خطّ التجميع: الطلبُ يدخل من طرفٍ ويمرّ على الحلقات بالترتيب؛ كلٌّ تُضيف أو تفحص أو تقرّر. الترتيب جوهريّ: الأمنُ والـ CORS وقراءةُ الجسم قبل المعالِج؛ المعالِجُ في الوسط؛ معالِجُ الأخطاء في النهاية. هذا ليس تفصيلاً تجميلياً — هو بنية التحكّم لخادمك كلّه. من يفهم السلسلة يفهم Express كاملاً؛ الباقي راحةٌ نحوية.

ليش هذا التجريد بالذات عبقريّ؟ لأنه قابلٌ للتركيب (composable): تبني سلوكاً معقّداً من دوالٍ صغيرةٍ مستقلّةٍ كلٌّ تفعل شيئاً واحداً، وترتّبها كقطعِ ليغو. وكلٌّ قابلةٌ لإعادة الاستخدام عبر المسارات. وكلٌّ قابلةٌ للاختبار وحدها. هذا هو جوهرُ "الإطار": ليس سحراً، بل عقدُ دالةٍ موحَّدٌ + مُشغِّلٌ يمرّر التحكّم بينها.


كيف: نبني Express المصغّر بأيدينا

قبل الأصل، النواة. هذا tiny-express كاملٌ على http الخام — اقرأه كاشتقاق، ثم ستبني نسختك في اللغز:

js
import http from "node:http"; function createApp() { const stack = []; // سلسلة الـ middleware/المسارات // تسجيل middleware عام (يمرّ به كلُّ طلب) function use(fn) { stack.push({ method: null, path: null, fn }); } // تسجيل مسارٍ بفعلٍ ومسار function route(method, path, fn) { stack.push({ method, path, fn }); } // المُشغِّل: يمرّر الطلبَ على السلسلة، حلقةً حلقة، عبر next function handle(req, res) { let i = 0; function next(err) { const layer = stack[i++]; if (!layer) { // انتهت السلسلة بلا ردّ res.statusCode = err ? 500 : 404; return res.end(JSON.stringify({ success: false, message: err ? "error" : "not found" })); } // طابق الفعل والمسار (null = يطابق الكل = middleware عام) const okMethod = !layer.method || layer.method === req.method; const params = layer.path ? match(layer.path, req.url) : {}; const okPath = !layer.path || params !== null; if (okMethod && okPath) { req.params = params || {}; try { layer.fn(req, res, next); } // نادِ الحلقة، ممرّراً next catch (e) { next(e); } // خطأٌ متزامن → قفزٌ لمعالِج الأخطاء } else { next(err); // لا يطابق → تجاوز للتالي } } next(); } const listen = (port, cb) => http.createServer(handle).listen(port, cb); return { use, get: (p, fn) => route("GET", p, fn), post: (p, fn) => route("POST", p, fn), listen }; } // مطابقة مسارٍ بمعاملاتٍ مثل /users/:id → يلتقط id function match(pattern, url) { const path = url.split("?")[0]; const pp = pattern.split("/"), up = path.split("/"); if (pp.length !== up.length) return null; const params = {}; for (let k = 0; k < pp.length; k++) { if (pp[k].startsWith(":")) params[pp[k].slice(1)] = decodeURIComponent(up[k]); else if (pp[k] !== up[k]) return null; } return params; }

ومحلّل JSON كـ middleware — يحلّ بذرة الـ stream من الإقليم ٢:

js
function jsonBody(req, res, next) { if (req.method !== "POST" && req.method !== "PUT" && req.method !== "PATCH") return next(); let body = ""; req.on("data", (c) => (body += c)); // قطعةً قطعة (streams، الإقليم ٢) req.on("end", () => { try { req.body = body ? JSON.parse(body) : {}; next(); } catch { res.statusCode = 400; res.end(JSON.stringify({ success: false, message: "bad json" })); } }); }

استعماله — انظر كم يطابق مشروعك:

js
const app = createApp(); app.use(jsonBody); // قراءة الجسم لكل طلب app.get("/api/v1/health", (req, res) => json(res, 200, { success: true, data: { status: "ok" } })); app.post("/api/v1/auth/register", (req, res) => { const { username } = req.body; // جاهزٌ بفضل jsonBody json(res, 201, { success: true, data: { username } }); }); app.get("/api/v1/curricula/:id/sprints", (req, res) => json(res, 200, { success: true, data: { curriculumId: req.params.id } })); // :id مُلتقَط! app.listen(3000); function json(res, code, obj) { const s = JSON.stringify(obj); res.writeHead(code, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(s) }); res.end(s); }

توقّف وتأمّل: بنيتَ للتوّ نواةَ Express. use، الأفعال، :params، next، قفزُ الأخطاء، محلّلُ الجسم — كلّها هنا في خمسين سطراً. Express الحقيقيّ يضيف متانةً وأداءً وميزاتٍ، لكن القلب هو هذا بالضبط. لم يعد صندوقاً أسود.


كيف: من المصغّر إلى Express الحقيقيّ

كلُّ ما بنيته يقابله شيءٌ في Express، بنفس الدلالة:

ts
import express, { Request, Response, NextFunction } from "express"; const app = express(); app.use(express.json()); // = jsonBody، يضع req.body app.get("/api/v1/health", (req, res) => res.status(200).json({ success: true, data: { status: "ok" } })); app.post("/api/v1/auth/register", createUser);

الموجِّه (Router): تقسيم السلسلة لوحدات

سلسلةٌ واحدةٌ لكل المسارات تصير فوضى. express.Router() سلسلةٌ فرعيةٌ تركّبها تحت بادئة. هذا ما يفعله مشروعك:

ts
// user.routes.ts — سلسلةٌ فرعيةٌ خاصّةٌ بالمستخدم const router = express.Router(); router.post("/register", createUser); export default router; // app.ts — ركّبها تحت بادئة app.use("/auth", routes); // /auth + /register = POST /auth/register

(ملاحظةٌ على مشروعك: app.ts يركّبها تحت /auth بينما عقدُ الـ API يقول /api/v1. هذا أحدُ ما ستملكه وتصلحه — بادئةُ نسخةٍ موحّدة. المنهج جعلك قادراً على رؤيته.)

معالِجُ الأخطاء: الحلقة ذات الأربعة معاملات

middleware بأربعة معاملاتٍ (err, req, res, next) يُنادى فقط حين يُنادى next(err) أو (في Express 5) حين ترفض دالةٌ async. يوضع آخر السلسلة، فيوحّد كلَّ الأخطاء في مظروفٍ ورمزٍ واحد:

ts
app.use((err, req, res, next) => { console.error(err); res.status(err.status || 500).json({ success: false, message: err.message || "Internal error" }); });

Express 5 يلتقط رفضَ الـ async تلقائياً (يخصّ مشروعك)

مشروعك على Express 5. الفرق العمليّ الأهمّ عن 4: لو رمى معالِجٌ async (رُفض وعدُه)، يلتقطه Express 5 ويمرّره لمعالِج الأخطاء تلقائياً. في Express 4 كان الرفضُ غير المُلتقَط يضيع (أو يُسقط العملية)، فكنت تلفّ كلَّ معالِجٍ بـ try/catch أو بمغلِّف. هذا يبسّط متحكّماتك: ركّز على المنطق، ودع الأخطاء تتدفّق لمعالِجٍ واحد.


ليش: طبقات المتحكّم/الخدمة/المسار (فصل المسؤوليّات)

لاحظ بنية مشروعك: models/User/ فيها user.routes.ts، user.controller.ts، user.service.ts، user.schema.ts. لماذا أربعة ملفّاتٍ لا واحد؟ فصلُ مسؤوليّاتٍ مقصود، كلُّ طبقةٍ تعرف شيئاً واحداً:

ليش يهمّك لإدارة المشروع؟ لأن الـ HTTP يجب أن يكون قشرةً رقيقة. لو خبّأتَ منطقَ التسجيل داخل req/res، لما استطعتَ اختباره ولا إعادةَ استخدامه (من سطر أوامر، من مهمّةٍ مجدولة...). الفصلُ يجعل كلَّ طبقةٍ مستقلّةً مفهومة. (ملاحظةٌ على الواقع: user.service.ts في مشروعك فارغٌ حالياً، ومنطقُ Prisma موضوعٌ مؤقّتاً في المتحكّم — هذا بالضبط نوعُ القرار الذي تملكه الآن: تنقل منطق prisma.user.create إلى الخدمة، فيبقى المتحكّم قشرةً.)

دورة حياة الطلب كاملةً (اجمع الصورة)

POST /api/v1/auth/register يمرّ بالسلسلة بالترتيب:

helmet → cors → express.json() → rate-limit(login فقط) → router(/auth) → [validate(Zod) → controller.createUser → service → Prisma → Postgres] → (نجاح) res.status(201).json(envelope) → (خطأ في أي حلقة) next(err) → معالِج الأخطاء → res.status(4xx/5xx).json(envelope)

كلُّ صندوقٍ دالةٌ في السلسلة. هذا الخطّ هو تدفّق البيانات داخل الخادم الذي وعدتك به الخريطةُ الأم — وسنكمله من المتصفّح إلى Postgres ورجوعاً في الإقليم ٩.


كيف: middleware النظام البيئيّ في مشروعك (كلٌّ حلقةٌ في السلسلة)

تبعيّات مشروعك ليست أسماءً تُحفظ — كلٌّ middleware بدور:

كلُّها — بلا استثناء — دوالٌّ بتوقيع (req, res, next) في السلسلة. حين تستوعب هذا، تتوقّف عن حفظ المكتبات وتبدأ بقراءتها.


اللغز / البناء من الصفر

أدواتك: node + وحدة http القياسية فقط. ممنوع express — تبنيه. هذا يجبرك على امتلاك آلية السلسلة، فلا تبقى سحراً.

اللغز أ — ابنِ tiny-express بيدك. اكتب createApp() يدعم: use(fn), get(path, fn), post(path, fn), تمريرَ التحكّم عبر next, ومطابقةَ معاملاتِ المسار (/curricula/:id يملأ req.params.id). لا تنسخ كودي أعلاه حرفياً — أغلقه وابنِه من فهمك للسلسلة، ثم قارن. (إن علقتَ في next: تذكّر أنه إغلاقٌ يلتقط مؤشّرَ موضعٍ i في السلسلة — الإقليم ١.)

اللغز ب — اكتب middleware ثلاثة وركّبها بترتيبٍ صحيح:

  • logger: يطبع METHOD URL لكل طلبٍ ثم next().
  • jsonBody: يجمّع الـ stream، يضع req.body، أو يردّ 400 على JSON مكسور.
  • requireApiKey: يفحص ترويسة x-api-key؛ إن غابت يردّ 401 بلا next؛ وإلّا next(). (هذا تمرينُ الهوية المصغّر — يفتح الإقليم ٧.)

ثم سجّل مسارَ GET /health (عام) وPOST /secret (محميٌّ بـ requireApiKey). جرّب الترتيب الخاطئ: ضع requireApiKey بعد المعالِج بدل قبله، وراقب كيف ينكسر الأمن. الترتيبُ ليس رأياً — هو البنية.

اللغز ج — معالِجُ أخطاءٍ موحّد + رفعُ خطأ. أضِف معالِجَ أخطاءٍ بأربعة معاملاتٍ آخرَ السلسلة يردّ مظروفاً موحّداً بالرمز الصحيح. اجعل أحدَ معالِجاتك يرمي خطأً عمداً (throw new Error("boom") ومتزامناً، ثم جرّب async يرفض) وتأكّد أن كليهما يصلان معالِجَ الأخطاء (راقب الفرق الذي تصنعه آلية الالتقاط). فكّر: كيف ستحمل "الرمز المقصود" (مثلاً 409) مع الخطأ حتى يقرأه المعالِج؟

اللغز د — أعِد بناء مساري مشروعك على إطارك. على tiny-express حقّك، حقّق GET /api/v1/health وPOST /api/v1/auth/register (دون قاعدةِ بياناتٍ بعد — أرجِع ما استلمتَ). ضع helmet-بديلاً بسيطاً (middleware يضيف ترويسةَ أمنٍ واحدة) وcors-بديلاً (يضيف Access-Control-Allow-Origin) لتشعر كيف يندمجان كحلقتين. ثم افتح app.ts وuser.routes.ts في مشروعك واقرأهما — يجب أن تراهما نسخةَ أناقةٍ من إطارك، لا أكثر.

ملاحظة

لا تنتقل قبل أن يعمل :params في إطارك (اللغز أ) وترى بعينك أثرَ ترتيبِ السلسلة (اللغز ب). إن فهمتَ السلسلة، فهمتَ كلَّ خادمِ Express في الدنيا.


الخلاصة — أين تتّصل هذه العقدة بالشجرة

  • تحت: Express ليس سحراً — هو سلسلةُ middleware فوق خادم http الخام (الإقليم ٢)، مبنيّةٌ من دوالٍّ-كقيمٍ وإغلاقاتٍ (١)، تنطق أفعالَ ورموزَ HTTP (٠). بنيتَ نواتها بخمسين سطراً.
  • العقدة الجديدة: خادمٌ كسلسلةٍ قابلةٍ للتركيب — عقدُ (req, res, next)، التوجيهُ بالأفعال والمعاملات، محلّلُ الجسم، الموجِّهات، معالِجُ الأخطاء الموحّد، وفصلُ route/controller/service. والترتيبُ بنيةٌ لا زينة.
  • فوق: السلسلةُ هي حيث ستُحقن حلقاتٌ قادمة: التحقّق (Zod، ٦) حلقةٌ قبل المتحكّم؛ الهوية (٧) حلقةٌ تحرس المسارات؛ وPrisma (٥) تعيش في طبقة الخدمة. والـ controller هو حيث يلتقي كلُّ ذلك في الإقليم ٩.

الأبواب المفتوحة: المتحكّم ينادي prisma.user.create — ما هذا، وكيف يصير كائنُ JS صفّاً في جدول؟ (٤، ٥). req.body ما زال any لا يُوثَق — من يحرسه قبل المتحكّم؟ (٦). كيف يصير requireApiKey تحقّقاً حقيقياً من هويةٍ لا تُزوَّر؟ (٧).


الوصلة للواقع (نمط SelfLab)

افتح backend/src/app.ts:

ts
import express from "express"; import routes from "./models/User/user.routes"; const app = express(); app.use(express.json()); // = jsonBody حقّك: يملأ req.body app.use("/auth", routes); // يركّب موجِّهَ المستخدم تحت بادئة export default app;

ثلاثةُ أسطرٍ تقرؤها الآن بثقة: تطبيقٌ = سلسلة؛ express.json() حلقةٌ تحلّل الجسم؛ use("/auth", routes) يركّب سلسلةً فرعية. وفي user.controller.ts، createUser(req, res) هو الحلقةُ الأخيرة — يقرأ req.body، يستدعي المنطق، يكتب res.status(201).json(...). كلُّ ما في الملفّ صار شفّافاً: لا سطرٌ فيه سحر.

ولاحظ ما ينقص (وستضيفه وأنت تملك المشروع): البادئة /api/v1 لا /auth؛ helmet/cors/rate-limit في السلسلة؛ معالِجُ أخطاءٍ موحّد؛ ونقلُ منطقِ Prisma من المتحكّم إلى user.service.ts. صرتَ تراها كلَّها، وتعرف مكانها في السلسلة.

بذرة الإقليم التالي

البذرة التالية: المتحكّم ينادي prisma.user.create({ data: {...} })، فيظهر "صفٌّ" في "جدولٍ" في Postgres. لكن ما الجدول؟ ولماذا جداولُ لا ملفّاتٌ ولا كائنات؟ ولماذا username فريدٌ يرفض التكرار (تذكّر سؤال idempotency في الإقليم ٠)؟ ولماذا id نصٌّ uuid؟ ننزل للإقليم ٤ نفتح الصندوقَ الثاني — قاعدةَ البيانات — من جذرها الرياضيّ: النموذج العلائقيّ و SQL، قبل أن تلمسها Prisma أصلاً.

الستاك الكامل · من باب الخادم إلى داخله ليش قبل كيف · صُمّم لـ يزيد