مشروع: تطبيق ويب MVC باستخدام JSP
يجمع هذا الدرس الختامي كل المفاهيم التي مررنا بها في البرنامج التعليمي — Servlets بوصفها وحدات تحكّم (controllers)، وصفحات JSP بوصفها طبقة العرض (views)، وEL وJSTL والتضمينات ونمط MVC — في مشروع واحد متماسك وقابل للتشغيل. ستبني تطبيق كتالوج المنتجات المصغّر: عرض كل المنتجات، وعرض تفاصيل منتج بعينه، وإضافة منتج جديد عبر نموذج. الهدف ليس مجرد كود يعمل، بل كود يمكن لمحترف أن يضمّه إلى مستودعه دون تحفّظ.
هيكل المشروع
يُبقي التخطيط النظيف لـ MVC منطق التحكم وقوالب العرض وفئات النموذج في طبقاتها المنفصلة. فيما يلي شجرة الدليل لملف WAR الذي ستبنيه:
src/main/
├── java/com/example/catalog/
│ ├── model/
│ │ └── Product.java <-- Java bean بسيط (النموذج)
│ ├── dao/
│ │ └── ProductDao.java <-- كائن الوصول إلى البيانات (طبقة خدمة رفيعة)
│ └── web/
│ ├── ProductListServlet.java <-- GET /products
│ ├── ProductDetailServlet.java <-- GET /products/{id}
│ └── ProductFormServlet.java <-- GET + POST /products/new
└── webapp/
├── WEB-INF/
│ ├── web.xml <-- تعيينات Servlet (أو استخدم الحواشي)
│ └── views/
│ ├── layout/
│ │ ├── header.jspf <-- ترويسة مشتركة مضمَّنة
│ │ └── footer.jspf <-- تذييل مشترك مضمَّن
│ ├── productList.jsp
│ ├── productDetail.jsp
│ └── productForm.jsp
└── static/
└── style.css
لماذا نضع ملفات JSP داخل WEB-INF؟ الملفات الموجودة تحت WEB-INF لا يمكن الوصول إليها مباشرةً عبر HTTP — لا يستطيع إعادة توجيهها إلا الكود على جانب الخادم. هذا يُجبر كل طلب على المرور بوحدة تحكم Servlet؛ فلن يتمكن المستخدم أبدًا من تجاوز وحدة التحكم والوصول إلى عنوان URL خاص بـ JSP مباشرةً.
النموذج: Product.java
النموذج هو Java record بسيط وغير قابل للتغيير (Jakarta EE 10 / Java 17+). تتخلص السجلات من الكود المتكرر للـ getters وتعمل بشكل مثالي مع EL في JSP.
package com.example.catalog.model;
public record Product(
int id,
String name,
String description,
double price,
int stock
) {}
طبقة DAO
يُخفي الـ DAO وحدة التحكم عن تفاصيل SQL. في مشروع حقيقي سيستخدم JDBC أو JPA؛ هنا نُعبّئ بيانات في الذاكرة للتركيز على توصيلات MVC.
package com.example.catalog.dao;
import com.example.catalog.model.Product;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
public class ProductDao {
private static final AtomicInteger COUNTER = new AtomicInteger(3);
private static final List<Product> STORE = new CopyOnWriteArrayList<>(List.of(
new Product(1, "لوحة مفاتيح ميكانيكية", "TKL، مفاتيح Cherry MX Brown", 129.99, 42),
new Product(2, "محور USB-C", "7 في 1، HDMI 4K، PD 100W", 49.99, 98),
new Product(3, "كاميرا ويب HD", "1080p، إلغاء ضوضاء مدمج", 79.99, 15)
));
public List<Product> findAll() {
return List.copyOf(STORE);
}
public Optional<Product> findById(int id) {
return STORE.stream().filter(p -> p.id() == id).findFirst();
}
public Product save(String name, String description, double price, int stock) {
Product p = new Product(COUNTER.incrementAndGet(), name, description, price, stock);
STORE.add(p);
return p;
}
}
وحدة التحكم الأولى — ProductListServlet
تُحمّل هذه الوحدة قائمة المنتجات الكاملة، وتضعها في نطاق الطلب، وتُعيد التوجيه إلى عرض JSP. لاحظ الفصل النظيف: لا HTML هنا، ولا منطق أعمال في JSP.
package com.example.catalog.web;
import com.example.catalog.dao.ProductDao;
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;
@WebServlet("/products")
public class ProductListServlet extends HttpServlet {
private final ProductDao dao = new ProductDao();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setAttribute("products", dao.findAll());
req.setAttribute("pageTitle", "كتالوج المنتجات");
req.getRequestDispatcher("/WEB-INF/views/productList.jsp")
.forward(req, resp);
}
}
العرض: productList.jsp
تستخدم صفحة JSP وسم JSTL <c:forEach> للتكرار وEL لقراءة الخصائص — بلا scriptlets ولا استيرادات Java داخل القالب.
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<title>${pageTitle}</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/style.css">
</head>
<body>
<%@ include file="/WEB-INF/views/layout/header.jspf" %>
<h1>${pageTitle}</h1>
<a href="${pageContext.request.contextPath}/products/new" class="btn">+ إضافة منتج</a>
<c:choose>
<c:when test="${empty products}">
<p class="empty">لا توجد منتجات بعد.</p>
</c:when>
<c:otherwise>
<table>
<thead>
<tr><th>المعرف</th><th>الاسم</th><th>السعر</th><th>المخزون</th><th></th></tr>
</thead>
<tbody>
<c:forEach var="p" items="${products}">
<tr>
<td>${p.id}</td>
<td>${fn:escapeXml(p.name)}</td>
<td>
<fmt:formatNumber value="${p.price}" type="currency" currencySymbol="$"/>
</td>
<td>${p.stock}</td>
<td>
<a href="${pageContext.request.contextPath}/products/${p.id}">عرض</a>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</c:otherwise>
</c:choose>
<%@ include file="/WEB-INF/views/layout/footer.jspf" %>
</body>
</html>
استخدم دائمًا fn:escapeXml() على السلاسل النصية التي أدخلها المستخدم عند عرضها في HTML. اسم منتج يحتوي على <script> سينفَّذ في المتصفح بدون هذا الإجراء — وهذه ثغرة XSS مخزّنة كلاسيكية. أضف مكتبة الوسوم fn: <%@ taglib prefix="fn" uri="jakarta.tags.functions" %>.
وحدة التحكم الثانية — ProductDetailServlet
تستخرج هذه الوحدة معامل المسار من عنوان URL (مثل /products/2)، وتبحث عن المنتج، وتتعامل صراحةً مع حالة 404.
@WebServlet("/products/*")
public class ProductDetailServlet extends HttpServlet {
private final ProductDao dao = new ProductDao();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String pathInfo = req.getPathInfo();
if (pathInfo == null || pathInfo.equals("/")) {
resp.sendRedirect(req.getContextPath() + "/products");
return;
}
try {
int id = Integer.parseInt(pathInfo.substring(1));
dao.findById(id).ifPresentOrElse(
product -> {
req.setAttribute("product", product);
try {
req.getRequestDispatcher("/WEB-INF/views/productDetail.jsp")
.forward(req, resp);
} catch (Exception e) {
throw new RuntimeException(e);
}
},
() -> {
try { resp.sendError(HttpServletResponse.SC_NOT_FOUND, "المنتج غير موجود"); }
catch (IOException e) { throw new RuntimeException(e); }
}
);
} catch (NumberFormatException e) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "معرّف منتج غير صالح");
}
}
}
وحدة التحكم الثالثة — ProductFormServlet (GET + POST)
تتعامل وحدة تحكم النموذج مع GET (عرض النموذج فارغًا) وPOST (التحقق والحفظ وإعادة التوجيه). يمنع نمط Post/Redirect/Get الإرسال المزدوج عند تحديث المتصفح.
@WebServlet("/products/new")
public class ProductFormServlet extends HttpServlet {
private final ProductDao dao = new ProductDao();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.getRequestDispatcher("/WEB-INF/views/productForm.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String name = req.getParameter("name") == null ? "" : req.getParameter("name").trim();
String desc = req.getParameter("desc") == null ? "" : req.getParameter("desc").trim();
String priceStr = req.getParameter("price");
String stockStr = req.getParameter("stock");
List<String> errors = new ArrayList<>();
if (name.isEmpty()) errors.add("الاسم مطلوب.");
double price = 0;
int stock = 0;
try { price = Double.parseDouble(priceStr); if (price < 0) errors.add("يجب أن يكون السعر غير سالب."); }
catch (NumberFormatException e) { errors.add("يجب أن يكون السعر رقمًا."); }
try { stock = Integer.parseInt(stockStr); if (stock < 0) errors.add("يجب أن يكون المخزون غير سالب."); }
catch (NumberFormatException e) { errors.add("يجب أن يكون المخزون عددًا صحيحًا."); }
if (!errors.isEmpty()) {
req.setAttribute("errors", errors);
req.setAttribute("form", Map.of("name", name, "desc", desc,
"price", priceStr, "stock", stockStr));
req.getRequestDispatcher("/WEB-INF/views/productForm.jsp").forward(req, resp);
return;
}
dao.save(name, desc, price, stock);
resp.sendRedirect(req.getContextPath() + "/products");
}
}
عرض النموذج: productForm.jsp
تُعيد صفحة JSP ملء الحقول وتعرض أخطاء التحقق عندما تُعيد وحدة التحكم التوجيه إليها بعد POST فاشل.
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
...
<c:if test="${not empty errors}">
<ul class="errors">
<c:forEach var="e" items="${errors}">
<li>${fn:escapeXml(e)}</li>
</c:forEach>
</ul>
</c:if>
<form method="post" action="${pageContext.request.contextPath}/products/new">
<label>الاسم
<input type="text" name="name"
value="${fn:escapeXml(form.name)}" required>
</label>
<label>الوصف
<textarea name="desc">${fn:escapeXml(form.desc)}</textarea>
</label>
<label>السعر
<input type="number" name="price" step="0.01"
value="${fn:escapeXml(form.price)}">
</label>
<label>المخزون
<input type="number" name="stock"
value="${fn:escapeXml(form.stock)}">
</label>
<button type="submit">حفظ المنتج</button>
</form>
الأنماط الرئيسية التي يُظهرها هذا المشروع
- وحدات تحكم ذات مسؤولية واحدة: كل Servlet تتعامل مع مورد واحد وأقصاه طريقتا HTTP (
GET / POST). منطق الأعمال ينتمي إلى DAO لا إلى Servlet.
- العروض خلف WEB-INF: لا يمكن الوصول إلى ملفات JSP مباشرةً أبدًا — دائمًا عبر تمرير وحدة التحكم.
- EL + JSTL بدلًا من scriptlets: لا تحتوي صفحات JSP على أي كود Java، مما يجعلها قابلة للصيانة لأي شخص يقرأ HTML.
- Post/Redirect/Get: عمليات الإرسال الناجحة للنماذج تُعيد التوجيه لمنع الحفظ المزدوج عند التحديث.
- الوقاية من XSS: كل سلسلة نصية مُدخَلة من المستخدم وتُعرض في HTML تمر عبر
fn:escapeXml().
- معالجة الأخطاء المنظّمة: تُجمَّع أخطاء التحقق في قائمة وتُخزَّن كخاصية طلب وتعرضها الصفحة — لا تتناثر عبر وحدة التحكم كـ HTML مضمّن.
نسخة DAO المشتركة مناسبة لهذا العرض التوضيحي لكنها غير آمنة للخيوط بشكل عام. التطبيق الحقيقي يحقن DAO لكل طلب (CDI أو Spring) أو يضمن أن DAO نفسه عديم الحالة ويُفوّض إلى مورد آمن للخيوط كتجمّع اتصالات. نسخ Servlet مشتركة عبر خيوط — لا تخزن حالة قابلة للتغيير خاصة بطلب معين في حقول النسخة أبدًا.
تشغيل المشروع
انشر ملف WAR على Tomcat 10+ (الذي يدعم فضاء أسماء Jakarta EE 10). انتقل إلى http://localhost:8080/catalog/products. يجب أن ترى قائمة المنتجات المُعبَّأة مسبقًا، وأن تتمكن من النقر للوصول إلى صفحة التفاصيل وإرسال نموذج إضافة المنتج — مع تغذية راجعة لأخطاء التحقق إن أغفلت الحقول المطلوبة.
الخطوات التالية
يستخدم هذا المشروع عمدًا Servlets وJSP الخام كي تظهر كل الأجزاء المتحركة واضحةً. في المشاريع الاحترافية يُنفَّذ نمط MVC ذاته بأطر عمل كـ Spring MVC (الذي يُضيف DispatcherServlet ووحدات تحكم تعتمد على الحواشي وقوالب Thymeleaf أو FreeMarker) أو Jakarta Faces (JSF). إن فهم الأسس التي بنيناها في هذا البرنامج التعليمي يجعل تلك الأطر شفافة لا سحرية — أنت تعرف ما الذي تُؤتمته نيابةً عنك.