معمارية تطبيقات الويب: الطبقات و MVC
كيف تقسم تطبيق الويب إلى طبقات ومسؤوليات واضحة يسهل بناؤها وصيانتها؟ ⏱ نحو 50 دقيقة.
- تشرح الطبقات الثلاث ولماذا لا يتصل المتصفح بقاعدة البيانات.
- تميّز مسؤوليات النموذج والعرض والمتحكم.
- تقرأ تطبيق
MVCمتعدد الملفات وتكمل متحكمًا فيه. - تقابل
MVCفيLaravelوDjango، وتحلل بنية تطبيق موجود.
١العميل والخادم والطبقات الثلاث
هيكلية البرمجيات تعني كيف نقسم النظام إلى أجزاء، وما مسؤولية كل جزء، وكيف تتواصل. أشهر تقسيم لتطبيقات الويب الطبقات الثلاث: عرض في المتصفح، ومنطق عمل في الخادم، وبيانات في قاعدة البيانات. لا تتخطى طبقة جارتها: المتصفح لا يتصل بقاعدة البيانات مباشرة أبدًا.
٢نمط MVC
داخل الخادم نحتاج تنظيمًا أيضًا. نمط MVC (Model-View-Controller) يفصل الكود إلى ثلاث مسؤوليات، وهو النمط الذي يطلب مشروع الخطة بناء تطبيق به:
| الجزء | مسؤوليته | لا يفعل |
|---|---|---|
| النموذج Model | البيانات وقواعد العمل: الحفظ، والقراءة، والحساب (seatsLeft) | لا يعرف شيئًا عن HTML أو الطلبات |
| العرض View | تحويل البيانات إلى ما يراه المستخدم (HTML) | لا يقرأ من قاعدة البيانات ولا يتخذ قرارات |
| المتحكم Controller | يستقبل الطلب، ويسأل النموذج، ويختار العرض والرد | لا يكتب SQL ولا HTML بنفسه |
sequenceDiagram actor U as المستخدم participant R as المسارات Routes participant C as المتحكم Controller participant M as النموذج Model participant V as العرض View U->>R: GET /courses/1 R->>C: show(id = 1) C->>M: findById(1) M-->>C: كائن الدورة C->>V: detail(course) V-->>C: صفحة HTML C-->>U: 200 + الصفحة
تتبّع الطلب: المسار يوجّه إلى المتحكم، والمتحكم يسأل النموذج، ثم يعطي البيانات للعرض، ثم يرد. كل جزء لا يعرف إلا جاره.
٣MVC يعمل: خادم دورات
هذا تطبيق صغير مقسّم بنمط MVC في أربعة ملفات. افتح كل ملف، ولاحظ أن لكل منها مسؤولية واحدة، ثم انظر الطلبات الثلاثة:
const express = require('express');const app = express();const ctrl = require('./controllers/courseController'); app.get('/courses', ctrl.list);app.get('/courses/:id', ctrl.show); app.listen(3000);
// النموذج: البيانات وقواعدها فقطconst courses = [ { id: 1, title: 'تصميم الويب', capacity: 20, taken: 18 }, { id: 2, title: 'قواعد البيانات', capacity: 15, taken: 15 },];exports.all = () => courses;exports.findById = (id) => courses.find(c => c.id === id);exports.seatsLeft = (c) => c.capacity - c.taken;
// العرض: تحويل البيانات إلى HTML فقطexports.list = (courses) => '<h1>الدورات</h1><ul>' + courses.map(c => `<li><a href="/courses/${c.id}">${c.title}</a></li>`).join('') + '</ul>';exports.detail = (c, left) => `<h1>${c.title}</h1><p>المقاعد المتاحة: ${left}</p>`;exports.notFound = () => '<h1>الدورة غير موجودة</h1>';
// المتحكم: يستقبل الطلب، ويسأل النموذج، ويختار العرضconst Course = require('../models/course');const view = require('../views/courseView'); exports.list = (req, res) => { res.send(view.list(Course.all()));}; exports.show = (req, res) => { const c = Course.findById(Number(req.params.id)); if (!c) return res.status(404).send(view.notFound()); res.send(view.detail(c, Course.seatsLeft(c)));};
الطلب الثالث لدورة غير موجودة: النموذج أعاد لا شيء، فاختار المتحكم عرض «غير موجودة» مع الرمز 404. لو أردنا تغيير شكل الصفحة نعدّل views فقط، ولو انتقلت البيانات إلى MySQL نعدّل models فقط.
٤MVC في Laravel و Django
الأطر الكبيرة تفرض هذا التقسيم بأسماء مجلدات ثابتة، فيعرف أي مطور أين يجد كل شيء. هذا الكود نفسه في Laravel:
// routes/web.phpRoute::get('/courses/{id}', [CourseController::class, 'show']); // app/Http/Controllers/CourseController.phppublic function show($id) { $course = Course::findOrFail($id); // Model return view('courses.show', [ // View 'course' => $course, 'left' => $course->seatsLeft(), ]);}
| المسؤولية | Express (درسنا) | Laravel | Django |
|---|---|---|---|
| المسارات | server.js / routes | routes/web.php | urls.py |
| المتحكم | controllers/ | app/Http/Controllers | views.py (يسميه Django «view») |
| النموذج | models/ | app/Models (Eloquent) | models.py |
| العرض | views/ | resources/views (Blade) | templates/ (Django يسمي النمط MTV) |
٥دراسة حالة: تحليل بنية تطبيق موجود
تطلب الخطة «تحليل تطبيق ويب موجود لفهم بنيته وتحديد نقاط الضعف». عند فتح مشروع لم تكتبه، اسأل هذه الأسئلة:
| السؤال | علامة ضعف إن وجدت |
|---|---|
| أين تُكتب استعلامات قاعدة البيانات؟ | SQL داخل ملفات العرض أو المتحكمات ← لا يوجد نموذج حقيقي |
| ما حجم المتحكمات؟ | متحكم بمئات الأسطر فيه حسابات وقواعد ← «متحكم سمين»؛ انقل القواعد إلى النموذج |
| هل في العرض قرارات؟ | شروط صلاحيات أو حسابات داخل HTML ← تتكرر وتُنسى |
| أين تُفحص الصلاحيات؟ | في الواجهة فقط ← ثغرة؛ يجب أن تكون في الخادم |
| هل يتكرر الكود نفسه؟ | نفس الاستعلام في عشرة أماكن ← تعديل واحد يحتاج عشرة |
توقّع وتحقّق
أين يُحسب عدد المقاعد المتاحة في MVC؟
ما مسؤولية المتحكم؟
في Django، ملف views.py يقابل في MVC:
Django يسمي النمط MTV.وجدت استعلامات SQL داخل ملفات HTML في تطبيق تحلله. ما الاستنتاج؟
الخلاصة
- الطبقات الثلاث: عرض، ومنطق عمل، وبيانات؛ وكل طبقة تتصل بجارتها فقط.
- النموذج: البيانات وقواعدها. العرض:
HTML. المتحكم: ينسّق بينهما. Laravel: routesوControllersوModelsوBlade.Django: urlsوviews(متحكم) وmodelsوtemplates.- علامات الضعف:
SQLفي العرض، ومتحكم سمين، وصلاحيات في الواجهة فقط، وتكرار.
تحدٍّ برمجي: أكمل المتحكم
متوسطتطبيق «دورات» مقسّم بنمط MVC، لكن دالة show في المتحكم لم تُكتب بعد. أكملها دون أن تكسر فصل المسؤوليات.
GET /courses/1يعرض اسم الدورة والمقاعد المتاحة بالرمز 200.- دورة غير موجودة ← 404 بعرض «غير موجودة».
- المتحكم يستخدم
Course.findByIdوview.detail؛ ولا يكتب HTML ولا بيانات بنفسه.
👀 اعرض الاستجابات المطلوبة
GET /courses/1GET /courses/2GET /courses/9
- الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
- الدورة 1 ← 200
- تظهر «تصميم الويب» والمقاعد المتاحة 2
- الدورة 2 مقاعدها 0
- دورة غير موجودة ← 404
- … بعرض «غير موجودة»
- المتحكم يستخدم Course.findById
- المتحكم يستخدم view.detail
- لا HTML داخل المتحكم
💡 تلميح 1
Number(req.params.id).💡 تلميح 2
views/courseView.js: فيه detail(c, left) و notFound() جاهزتان.💡 تلميح 3
Course.seatsLeft(c).حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
const express = require('express');const app = express();const ctrl = require('./controllers/courseController'); app.get('/courses', ctrl.list);app.get('/courses/:id', ctrl.show); app.listen(3000);
// النموذج: البيانات وقواعدها فقطconst courses = [ { id: 1, title: 'تصميم الويب', capacity: 20, taken: 18 }, { id: 2, title: 'قواعد البيانات', capacity: 15, taken: 15 },];exports.all = () => courses;exports.findById = (id) => courses.find(c => c.id === id);exports.seatsLeft = (c) => c.capacity - c.taken;
// العرض: تحويل البيانات إلى HTML فقطexports.list = (courses) => '<h1>الدورات</h1><ul>' + courses.map(c => `<li><a href="/courses/${c.id}">${c.title}</a></li>`).join('') + '</ul>';exports.detail = (c, left) => `<h1>${c.title}</h1><p>المقاعد المتاحة: ${left}</p>`;exports.notFound = () => '<h1>الدورة غير موجودة</h1>';
// المتحكم: يستقبل الطلب، ويسأل النموذج، ويختار العرضconst Course = require('../models/course');const view = require('../views/courseView'); exports.list = (req, res) => { res.send(view.list(Course.all()));}; exports.show = (req, res) => { const c = Course.findById(Number(req.params.id)); if (!c) return res.status(404).send(view.notFound()); res.send(view.detail(c, Course.seatsLeft(c)));};