تمرير البيانات إلى طبقة العرض
في نمط MVC يكون الـ Servlet هو المتحكّم: فهو يجلب البيانات من النموذج (خدمة أو DAO أو قاعدة بيانات)، ثم يعبّئ هذه البيانات في خصائص مسمّاة، ثم يُعيد توجيه الطلب إلى صفحة JSP التي تُنشئ HTML. لا تتحدّث صفحة JSP أبدًا مع قاعدة البيانات — فهي لا تقرأ إلا ما وضعه الـ Servlet في أحد النطاقات المُدارة من قِبل الحاوية: نطاق الطلب (request)، أو الجلسة (session)، أو التطبيق (application)، أو الصفحة (page). إنّ فهم آلية عمل هذه النطاقات واختيار المناسب منها في كل موقف هو المهارة الجوهرية التي يُعلّمها هذا الدرس.
النطاقات الأربعة لـ JSP
تُخزَّن كل خاصية تضعها في أحد أربعة كائنات، لكل منها عمر مختلف:
- نطاق الصفحة (Page Scope) — يعيش فقط داخل صفحة JSP الحالية. نادرًا ما يُستخدم مباشرةً؛ هو داخلي لمكتبات الوسوم.
- نطاق الطلب (Request Scope) (
HttpServletRequest) — يعيش طوال دورة حياة طلب/استجابة HTTP واحدة. الخيار الأكثر شيوعًا لبيانات العرض.
- نطاق الجلسة (Session Scope) (
HttpSession) — يصمد عبر طلبات متعددة من نفس جلسة المتصفح. مناسب لبيانات هوية المستخدم: من هو المسجّل دخوله، وتفضيلاته، وعربة التسوق.
- نطاق التطبيق (Application Scope) (
ServletContext) — مشترك بين جميع المستخدمين وجميع الطلبات طوال عمر التطبيق. استخدمه فقط للبيانات العالمية الثابتة تقريبًا (مثل جدول بحث يُحمَّل عند بدء التشغيل).
ترتيب البحث الافتراضي في EL: حين تكتب ${username} في JSP، يبحث محرك EL في الترتيب التالي: page ← request ← session ← application، ويُعيد أول تطابق. هذا مريح لكنه قد يُخفي أخطاءً إذا عيّنت اسم خاصية متطابق في نطاقين. ضع الخصائص دائمًا في أضيق نطاق يُلبّي حاجتك.
تعيين خصائص الطلب في الـ Servlet
النمط دائمًا متطابق: (1) احصل على البيانات، (2) استدع request.setAttribute("name", value)، (3) أعِد توجيه الطلب إلى JSP عبر RequestDispatcher.
package com.example.web;
import com.example.model.Product;
import com.example.service.ProductService;
import jakarta.servlet.RequestDispatcher;
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.util.List;
@WebServlet("/products")
public class ProductListServlet extends HttpServlet {
private final ProductService productService = new ProductService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 1. جلب البيانات من طبقة النموذج
List<Product> products = productService.findAll();
int totalCount = products.size();
String categoryLabel = "All Products";
// 2. تخزين البيانات كخصائص للطلب
request.setAttribute("products", products);
request.setAttribute("totalCount", totalCount);
request.setAttribute("categoryLabel", categoryLabel);
// 3. إعادة التوجيه إلى صفحة JSP
RequestDispatcher rd = request.getRequestDispatcher("/WEB-INF/views/product-list.jsp");
rd.forward(request, response);
}
}
ضع صفحات JSP دائمًا تحت /WEB-INF/views/. الملفات داخل WEB-INF غير قابلة للوصول المباشر عبر URL — لا يمكن للمتصفح طلبها مباشرةً. هذا يعني أن المستخدمين لا يرون JSP إلا من خلال Servlet، وهو بالضبط ما تريده: المتحكّم يعمل أولًا دائمًا.
عرض خصائص الطلب بـ EL
في JSP تشير ببساطة إلى اسم الخاصية داخل ${ }. تتعامل EL مع القيم الفارغة (null) بأمان — الخاصية الغائبة تُعرض كسلسلة فارغة لا كـ NullPointerException.
<!-- /WEB-INF/views/product-list.jsp -->
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html lang="en">
<head><title>${categoryLabel}</title></head>
<body>
<h1>${categoryLabel}</h1>
<p>Showing ${totalCount} products</p>
<ul>
<c:forEach var="p" items="${products}">
<li>
<strong>${p.name}</strong> — $${p.price}
</li>
</c:forEach>
</ul>
</body>
</html>
تعبير النقطة في EL مثل (p.name) يستدعي الدالة getName() على كائن Product. صيغة الأقواس (p["name"]) مكافئة لها ومطلوبة حين يحتوي اسم الخاصية على شرطات أو حين يكون مخزّنًا في متغيّر.
تعيين خصائص الجلسة
للبيانات التي يجب أن تبقى عبر الطلبات — ملف شخصي للمستخدم المسجّل دخوله، أو عربة تسوق — استخدم HttpSession:
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String username = request.getParameter("username");
String password = request.getParameter("password");
User user = userService.authenticate(username, password);
if (user != null) {
// تخزين المستخدم المُوثَّق في الجلسة
HttpSession session = request.getSession(); // تنشئ جلسة إن لم تكن موجودة
session.setAttribute("currentUser", user);
session.setAttribute("role", user.getRole());
response.sendRedirect(request.getContextPath() + "/dashboard");
} else {
request.setAttribute("error", "Invalid credentials");
request.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(request, response);
}
}
}
في JSP تُصل إلى خصائص الجلسة بنفس صيغة EL تمامًا مثل خصائص الطلب:
<c:if test="${not empty currentUser}">
<p>Welcome back, ${currentUser.firstName}!</p>
</c:if>
لا تخزّن كائنات ضخمة أو غير قابلة للتسلسل في الجلسة. يجب أن تكون الجلسات قابلة للتسلسل (serializable) عندما تجمّعها الحاوية أو تحتفظ بها. كما تستهلك الكائنات الثقيلة ذاكرة الخادم لكل مستخدم نشط. خزّن ما تحتاجه فحسب — عادةً DTO صغير باسم UserSession، لا رسم بياني كامل لكيان JPA.
حذف الخصائص وإبطال الجلسات
استخدم removeAttribute لحذف خاصية واحدة، وinvalidate() لتدمير الجلسة بالكامل (ضروري عند تسجيل الخروج):
// حذف خاصية واحدة من الطلب
request.removeAttribute("tempError");
// حذف خاصية واحدة من الجلسة
session.removeAttribute("cart");
// تدمير الجلسة بالكامل (تسجيل الخروج)
HttpSession session = request.getSession(false); // false = لا تنشئ إن لم تكن موجودة
if (session != null) {
session.invalidate();
}
response.sendRedirect(request.getContextPath() + "/login");
تمرير false إلى getSession() عادة مهمة: إن لم تكن جلسة موجودة (المستخدم سجّل خروجه بالفعل)، فهذا يتجنّب إنشاء جلسة جديدة فارغة لمجرد إبطالها.
تمرير المجموعات والخرائط
تتعامل EL مع البنى المتداخلة بشكل طبيعي. لنفترض أنك عيّنت Map<String, List<Product>> مُجمَّعة حسب الفئة:
// في الـ Servlet
Map<String, List<Product>> productsByCategory = productService.groupedByCategory();
request.setAttribute("productsByCategory", productsByCategory);
<!-- في JSP -->
<c:forEach var="entry" items="${productsByCategory}">
<h2>${entry.key}</h2>
<ul>
<c:forEach var="p" items="${entry.value}">
<li>${p.name} — $${p.price}</li>
</c:forEach>
</ul>
</c:forEach>
كل من entry.key وentry.value عمليتا بحث بخاصية EL على كائن Map.Entry — يستدعي محرك EL تلقائيًا getKey() وgetValue().
مثال متكامل لدورة طلب كاملة
إليك التدفق الكامل لصفحة تفاصيل منتج، توضّح كيف تتعايش بيانات الطلب والجلسة في نفس صفحة العرض:
@WebServlet("/product")
public class ProductDetailServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String idParam = request.getParameter("id");
Product product = productService.findById(Long.parseLong(idParam));
if (product == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// نطاق الطلب: هذا المنتج لهذه الصفحة وحدها
request.setAttribute("product", product);
// نطاق الجلسة: يُعيَّن عند تسجيل الدخول، اقرأه فقط في JSP عبر ${currentUser}
request.getRequestDispatcher("/WEB-INF/views/product-detail.jsp")
.forward(request, response);
}
}
<!-- product-detail.jsp -->
<p>Logged in as: ${currentUser.email}</p> <!-- من الجلسة -->
<h1>${product.name}</h1> <!-- من الطلب -->
<p>${product.description}</p>
<p>Price: $${product.price}</p>
الخلاصة
تدفق البيانات من الـ Servlet إلى JSP واضح: يُعيّن الـ Servlet خصائص مسمّاة على كائن النطاق المناسب، يستدعي forward()، وتقرأ JSP تلك الخصائص عبر EL. استخدم نطاق الطلب لكل ما ينتمي إلى استجابة صفحة واحدة. استخدم نطاق الجلسة للهوية والحالة قصيرة المدى عبر الطلبات. تجنّب نطاق التطبيق إلا إذا كانت البيانات عالمية حقًا وللقراءة فقط. هذه القواعد تُبقي طبقات العرض بسيطة، وسيرفيلتاتك قابلة للاختبار، وبصمة الذاكرة قابلة للتوقع.