الإقليم ٣ — من http الخام إلى Express: لماذا "إطار"؟
النبذة
في الإقليم ٢ بنيتَ خادم 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) => { ... }
كلُّ حلقةٍ في السلسلة تفعل شيئاً بالطلب/الردّ، ثم إمّا:
- تنادي
next()لتمرّر التحكّم للحلقة التالية، أو - تنهي الطلب بـ
res.json(...)فلا تناديnext(توقّفت السلسلة)، أو - تنادي
next(err)فتقفز إلى معالِج الأخطاء.
هذا كلّ شيء. لاحظ كم يلتقط هذا التجريدُ الواحد:
| الحاجة | مجرّد 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 الخام — اقرأه كاشتقاق، ثم ستبني نسختك في اللغز:
jsimport 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 من الإقليم ٢:
jsfunction 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" })); } }); }
استعماله — انظر كم يطابق مشروعك:
jsconst 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، بنفس الدلالة:
tsimport 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);
express.json()= محلّلُ جسمك، أمتنُ وأشمل.res.status(201).json(obj)= دالةُjsonيدويّتك، تضبط الرمزَ والترويساتِ والجسم.req.params.id،req.query(ما بعد?)،req.body— نفس ما ملأته بيدك.Request,Response,NextFunctionأنواعُ TypeScript (من@types/express، تذكّر الإقليم ٢) تحرس توقيعاتك وقتَ الكتابة وتُمحى وقتَ التشغيل.
الموجِّه (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. يوضع آخر السلسلة، فيوحّد كلَّ الأخطاء في مظروفٍ ورمزٍ واحد:
tsapp.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. لماذا أربعة ملفّاتٍ لا واحد؟ فصلُ مسؤوليّاتٍ مقصود، كلُّ طبقةٍ تعرف شيئاً واحداً:
- routes: خريطةٌ صرفة — أيُّ فعلٍ+مسارٍ ينادي أيَّ معالِج. لا منطق.
- controller: طبقةُ HTTP — يقرأ
req، يستدعي الخدمة، يكتبresبالرمز والمظروف. لا منطقَ عملٍ هنا، فقط ترجمةٌ بين HTTP والمجال. - service: منطقُ العمل الخالص — لا يعرف شيئاً عن
req/res. يأخذ بياناتٍ نظيفة، يتعامل مع قاعدة البيانات (Prisma)، يرجّع نتيجة. قابلٌ للاختبار وحده بلا خادمٍ ولا HTTP. - schema: عقدُ الإدخال (Zod، الإقليم ٦).
ليش يهمّك لإدارة المشروع؟ لأن الـ HTTP يجب أن يكون قشرةً رقيقة. لو خبّأتَ منطقَ التسجيل داخل req/res، لما استطعتَ اختباره ولا إعادةَ استخدامه (من سطر أوامر، من مهمّةٍ مجدولة...). الفصلُ يجعل كلَّ طبقةٍ مستقلّةً مفهومة. (ملاحظةٌ على الواقع: user.service.ts في مشروعك فارغٌ حالياً، ومنطقُ Prisma موضوعٌ مؤقّتاً في المتحكّم — هذا بالضبط نوعُ القرار الذي تملكه الآن: تنقل منطق prisma.user.create إلى الخدمة، فيبقى المتحكّم قشرةً.)
دورة حياة الطلب كاملةً (اجمع الصورة)
POST /api/v1/auth/register يمرّ بالسلسلة بالترتيب:
كلُّ صندوقٍ دالةٌ في السلسلة. هذا الخطّ هو تدفّق البيانات داخل الخادم الذي وعدتك به الخريطةُ الأم — وسنكمله من المتصفّح إلى Postgres ورجوعاً في الإقليم ٩.
كيف: middleware النظام البيئيّ في مشروعك (كلٌّ حلقةٌ في السلسلة)
تبعيّات مشروعك ليست أسماءً تُحفظ — كلٌّ middleware بدور:
- helmet: يضبط ترويساتِ أمنٍ على كل ردّ (تمنع أصنافاً من الهجمات عبر المتصفّح). حلقةٌ تضيف ترويساتٍ ثم
next(). - cors: يقرّر مَن (أيُّ أصلٍ/origin) يحقّ له مناداةُ الـ API من المتصفّح. لماذا تحتاجه؟ واجهتك على
localhost:5173والـ API علىlocalhost:3000— أصلان مختلفان (المنفذ جزءٌ من الأصل). المتصفّح يمنع الطلبات عبر الأصول افتراضياً (Same-Origin Policy) حمايةً لك؛corsيضيف ترويساتٍ تقول "أسمحُ لهذا الأصل". (يربط الإقليم ٠: الأصل = بروتوكول+مضيف+منفذ.) - cookie-parser: يقرأ ترويسة
Cookieويضعreq.cookies— خيارٌ لحمل الـ token (الإقليم ٧). - express-rate-limit: يعدّ طلباتِ عميلٍ في نافذةٍ زمنية ويردّ
429 Too Many Requestsعند التجاوز. لماذا علىloginخصوصاً؟ ضدّ تخمين كلمات المرور (brute force): مهاجمٌ يجرّب آلافَ الكلمات في الثانية؛ الحدُّ يخنقه. درسٌ أمنيٌّ حيّ، ويربط بحدود الموارد ضد DoS التي تعرفها من منهج Docker.
كلُّها — بلا استثناء — دوالٌّ بتوقيع (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:
tsimport 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 أصلاً.