JSP وJSTL وطبقة العرض

نمط MVC مع Servlets و JSP

18 دقيقة الدرس 7 من 13

نمط MVC مع Servlets و JSP

أصبحت الآن تعرف كيف تكتب Servlet يعالج طلب HTTP وكيف تكتب JSP تُصيّر HTML. السؤال الطبيعي التالي هو: مَن المسؤول عن ماذا؟ هذا تحديدًا ما يُجيب عنه نمط Model-View-Controller (MVC)، وهو العمود الفقري المعماري لكل تطبيق ويب قائم على JSP يستحق الصيانة.

ما يعنيه MVC في سياق Servlet/JSP

تتوزّع الأدوار الثلاثة بوضوح على التقنيات التي تعرفها:

  • Model (النموذج) — كائنات Java عادية (تُعرف غالبًا بـ beans) تحمل البيانات وتُغلّف منطق العمل. لا تعرف شيئًا عن HTTP أو HTML.
  • View (العرض) — ملف JSP. مهمته الوحيدة تصيير البيانات التي تُسلَّم إليه. لا يحتوي على SQL ولا تحليلًا لـ HttpServletRequest ولا قواعد عمل.
  • Controller (المتحكم) — الـ Servlet. يستقبل الطلب، يتحقق من المدخلات، يستدعي طبقة النموذج (service / DAO)، يضع النتائج في النطاق المناسب، ثم يُحيل إلى JSP المناسب.
القاعدة الأساسية: يجب ألا يُنشئ JSP أبدًا اتصالًا بقاعدة البيانات. ويجب ألا يُخرج Servlet HTML خامًا أبدًا. عند كسر هذه القواعد ينتهي بك الأمر بكود يستحيل اختباره ومؤلم عند التعديل.

مثال واقعي: صفحة قائمة المنتجات

لنبنِ شريحة كاملة قابلة للعمل: يزور المستخدم /products، يحمّل المتحكّم قائمة المنتجات، ويصيّر JSP النتيجة. سنكتب bean النموذج وDAO بسيطًا وServlet المتحكّم وJSP العرض.

الخطوة 1 — Bean النموذج

package com.example.model; public class Product { private int id; private String name; private double price; private int stock; public Product(int id, String name, double price, int stock) { this.id = id; this.name = name; this.price = price; this.stock = stock; } // Getters فقط — الكائن للقراءة بعد البناء public int getId() { return id; } public String getName() { return name; } public double getPrice() { return price; } public int getStock() { return stock; } }

الـ bean كائن Java خالص: لا imports خاصة بـ Jakarta ولا annotations. هذا يجعله سهل الاختبار الفردي للغاية.

الخطوة 2 — طبقة الوصول إلى البيانات (DAO)

package com.example.dao; import com.example.model.Product; import javax.sql.DataSource; import java.sql.*; import java.util.ArrayList; import java.util.List; public class ProductDao { private final DataSource ds; public ProductDao(DataSource ds) { this.ds = ds; } public List<Product> findAll() throws SQLException { String sql = "SELECT id, name, price, stock FROM products ORDER BY name"; List<Product> list = new ArrayList<>(); try (Connection conn = ds.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(new Product( rs.getInt("id"), rs.getString("name"), rs.getDouble("price"), rs.getInt("stock") )); } } return list; } }

الخطوة 3 — Servlet المتحكّم

package com.example.controller; import com.example.dao.ProductDao; import com.example.model.Product; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.sql.SQLException; import java.util.List; @WebServlet("/products") public class ProductController extends HttpServlet { private ProductDao productDao; @Override public void init() throws ServletException { // DataSource تُسترجع من JNDI أو مصنع — لا تُنشأ مباشرة هنا javax.sql.DataSource ds = (javax.sql.DataSource) getServletContext().getAttribute("dataSource"); productDao = new ProductDao(ds); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { try { List<Product> products = productDao.findAll(); // وضع البيانات في نطاق الطلب لكي يستطيع JSP قراءتها req.setAttribute("products", products); req.setAttribute("count", products.size()); // الإحالة إلى العرض — انتهت مهمة Servlet req.getRequestDispatcher("/WEB-INF/views/products.jsp") .forward(req, resp); } catch (SQLException e) { throw new ServletException("Could not load products", e); } } }
ضع ملفات JSP دائمًا داخل /WEB-INF/. الملفات داخل WEB-INF غير مرئية للمتصفح — لا يمكن الوصول إليها إلا عبر forward أو include من كود الخادم. هذا يمنع المستخدم من تجاوز المتحكّم والوصول إلى JSP مباشرة دون بيانات نموذج.

الخطوة 4 — JSP العرض

<%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <!DOCTYPE html> <html lang="ar" dir="rtl"> <head> <meta charset="UTF-8"> <title>المنتجات (${count})</title> </head> <body> <h1>كتالوج المنتجات — ${count} منتج(ات)</h1> <c:choose> <c:when test="${empty products}"> <p>لا توجد منتجات.</p> </c:when> <c:otherwise> <table border="1" cellpadding="6"> <tr> <th>المعرّف</th><th>الاسم</th><th>السعر</th><th>المخزون</th> </tr> <c:forEach var="p" items="${products}"> <tr> <td>${p.id}</td> <td><c:out value="${p.name}"/></td> <td><fmt:formatNumber value="${p.price}" type="currency"/></td> <td>${p.stock}</td> </tr> </c:forEach> </table> </c:otherwise> </c:choose> </body> </html>

لاحظ ما لا يحتويه JSP: لا جمل import، لا SQL، لا منطق عمل، لا وسوم scriptlet (<% %>). يقرأ من ${products} عبر EL ويكرّر باستخدام JSTL — وسوم نظيفة قابلة للاختبار.

قرار Forward مقابل Redirect

بعد معالجة طلب GET يستخدم المتحكّم forward. لطلب إرسال نموذج (POST) يُعدّل حالة البيانات، النمط المعياري هو Post/Redirect/Get (PRG): يعالج المتحكّم النموذج، ثم يُصدر sendRedirect إلى عنوان URL من نوع GET. هذا يمنع رسالة "إعادة إرسال النموذج؟" عند تحديث الصفحة.

// داخل doPost — بعد حفظ المنتج الجديد resp.sendRedirect(req.getContextPath() + "/products"); // يتابع المتصفح إعادة التوجيه بطلب GET، يعمل المتحكّم مجددًا، يصيّر JSP البيانات المحدّثة
لا تستخدم forward أبدًا بعد POST يُعدّل البيانات. إذا حدّث المستخدم الصفحة المُحالة، يُعيد المتصفح إرسال POST مما يتسبب في كتابات مكررة. أعِد التوجيه دائمًا بعد عملية كتابة ناجحة.

استراتيجية النطاق: Request مقابل Session مقابل Application

مكان وضع بيانات النموذج يحدد مدة حياتها:

  • نطاق الطلب (req.setAttribute) — تعيش البيانات لدورة طلب/استجابة واحدة. استخدمه لنتائج الاستعلامات وأخطاء التحقق من النموذج وكل ما يخص الصفحة فقط.
  • نطاق الجلسة (req.getSession().setAttribute) — تعيش البيانات حتى تنتهي الجلسة أو تُبطَل. استخدمه للمستخدم المسجّل دخوله وسلة التسوق وتفضيلات المستخدم.
  • نطاق التطبيق (getServletContext().setAttribute) — تعيش البيانات طوال حياة التطبيق. استخدمه باعتدال: جداول البحث والإعداد المشترك والبيانات المرجعية المخزنة مؤقتًا. سلامة الخيوط مسؤوليتك.

ربط DataSource باستخدام Context Listener

في المثال أعلاه، يسترجع المتحكّم DataSource من ServletContext. المكان المعياري لتهيئة الموارد المشتركة هو ServletContextListener:

package com.example.listener; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; import jakarta.servlet.annotation.WebListener; @WebListener public class AppContextListener implements ServletContextListener { private HikariDataSource pool; @Override public void contextInitialized(ServletContextEvent sce) { HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl(System.getenv("DB_URL")); cfg.setUsername(System.getenv("DB_USER")); cfg.setPassword(System.getenv("DB_PASS")); cfg.setMaximumPoolSize(10); pool = new HikariDataSource(cfg); // إتاحة التجمّع لجميع Servlets sce.getServletContext().setAttribute("dataSource", pool); } @Override public void contextDestroyed(ServletContextEvent sce) { if (pool != null) pool.close(); } }

يعمل المستمع مرة واحدة عند بدء التطبيق، يُنشئ تجمّع الاتصالات، ويغلقه بشكل نظيف عند إيقاف الخادم. بعد ذلك، يسترجع كل Servlet التجمّع المشترك من ServletContext — لا singletons ثابتة ولا إدارة دورة حياة يدوية في كل servlet.

الخلاصة

في تطبيق Servlet + JSP بنمط MVC يكون توزيع المسؤوليات صارمًا: يملك Servlet (المتحكّم) تحليل الطلب والتحقق من المدخلات واستدعاء النموذج وتعيين النطاق؛ ويملك JSP (العرض) التصيير فقط؛ ويملك الـ bean (النموذج) البيانات ومنطق العمل. إنّ تطبيق هذا الفصل ينتج كودًا سهل الاختبار وسهل الاستبدال وسهل التنقّل فيه لأي عضو جديد في الفريق. في الدرس القادم ستتعمق في كيفية تدفق البيانات من المتحكّم إلى العرض — معالجة المجموعات والكائنات المتداخلة واسترجاع السمات بأمان من حيث النوع.