أساسيات جافا للويب والـ Servlets

مشروع: تطبيق ويب بسيط بالـ Servlet

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

مشروع: تطبيق ويب بسيط بالـ Servlet

يجمع هذا الدرس الأخير كل ما تعلّمته في البرنامج التعليمي — دورة الحياة، ومعالجة الطلبات، ومعالجة النماذج، وServletContext، والتوجيه الداخلي، وبناء الاستجابات — في تطبيق متعدد الصفحات صغير لكنه واقعي: مدير المهام. يستطيع المستخدم عرض قائمة المهام، وإضافة مهمة جديدة عبر نموذج، وحذف مهمة بمعرّفها. لا توجد قاعدة بيانات؛ تعيش المهام في نطاق التطبيق حتى تتمكن من التركيز على طبقة الـ Servlet دون تعقيدات JDBC.

هدف هذا الدرس: مشاهدة كيفية تشابك المفاهيم الفردية لتشكيل تطبيق ويب فعّال، وفهم القرارات المعمارية التي يتخذها المطوّر حين تجتمع كل القطع معًا.

هيكل المشروع

يتبع المشروع النهائي تخطيط Maven القياسي لتطبيقات الويب:

task-manager/ ├── pom.xml └── src/main/ ├── java/com/example/tasks/ │ ├── model/Task.java │ ├── store/TaskStore.java <!-- singleton في نطاق التطبيق --> │ ├── AppInitListener.java <!-- ServletContextListener --> │ ├── ListTasksServlet.java │ ├── AddTaskServlet.java │ └── DeleteTaskServlet.java └── webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ │ ├── task-list.jsp │ └── add-task.jsp └── index.jsp <!-- إعادة توجيه إلى /tasks -->

النموذج والمخزن

Task هو سجل Java بسيط — غير قابل للتغيير بطبيعته، بلا أكواد معيارية زائدة:

package com.example.tasks.model; public record Task(int id, String title, String priority) {}

TaskStore هو مخزن في الذاكرة آمن للخيوط المتعددة يعيش كسمة في ServletContext:

package com.example.tasks.store; import com.example.tasks.model.Task; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; public class TaskStore { private final CopyOnWriteArrayList<Task> tasks = new CopyOnWriteArrayList<>(); private final AtomicInteger idSeq = new AtomicInteger(1); public void add(String title, String priority) { tasks.add(new Task(idSeq.getAndIncrement(), title, priority)); } public boolean delete(int id) { return tasks.removeIf(t -> t.id() == id); } public List<Task> all() { return List.copyOf(tasks); // نسخة دفاعية } }
لماذا CopyOnWriteArrayList؟ حاويات Servlet متعددة الخيوط — قد يصل طلبان في الوقت ذاته. يجعل CopyOnWriteArrayList عمليات القراءة خالية من الأقفال وعمليات الكتابة آمنة دون الحاجة إلى كتلة synchronized صريحة. وهذا هو المقايضة المناسبة لقائمة كثيفة القراءة.

تهيئة المخزن عند بدء التشغيل

بدلًا من تهيئة المخزن بشكل كسول داخل servlet، سجّل ServletContextListener حتى يكون المخزن جاهزًا قبل وصول أول طلب:

package com.example.tasks; import com.example.tasks.store.TaskStore; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; import jakarta.servlet.annotation.WebListener; @WebListener public class AppInitListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { TaskStore store = new TaskStore(); store.add("Write project README", "high"); store.add("Add unit tests", "medium"); store.add("Deploy to staging", "low"); ServletContext ctx = sce.getServletContext(); ctx.setAttribute("taskStore", store); ctx.log("TaskStore initialised with " + store.all().size() + " seed tasks."); } @Override public void contextDestroyed(ServletContextEvent sce) { sce.getServletContext().removeAttribute("taskStore"); } }

ListTasksServlet — قراءة القائمة

package com.example.tasks; import com.example.tasks.store.TaskStore; 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("/tasks") public class ListTasksServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { TaskStore store = (TaskStore) getServletContext().getAttribute("taskStore"); req.setAttribute("tasks", store.all()); // تمرير البيانات إلى العرض req.setAttribute("priority", req.getParameter("priority")); req.getRequestDispatcher("/WEB-INF/views/task-list.jsp") .forward(req, resp); } }

لاحظ النمط: يجلب الـ Servlet البيانات، ويضعها كسمات للطلب، ثم يُعيد توجيهها داخليًا إلى JSP. لا تلمس JSP المخزن مباشرةً — الـ Servlets تمتلك منطق الأعمال، والـ JSPs تمتلك العرض.

AddTaskServlet — معالجة النموذج

package com.example.tasks; import com.example.tasks.store.TaskStore; 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("/tasks/add") public class AddTaskServlet extends HttpServlet { /** GET — عرض النموذج الفارغ */ @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.getRequestDispatcher("/WEB-INF/views/add-task.jsp").forward(req, resp); } /** POST — التحقق من الصحة، التخزين، إعادة التوجيه */ @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String title = req.getParameter("title"); String priority = req.getParameter("priority"); if (title == null || title.isBlank()) { req.setAttribute("error", "Title is required."); req.getRequestDispatcher("/WEB-INF/views/add-task.jsp").forward(req, resp); return; } if (!List.of("high", "medium", "low").contains(priority)) { priority = "medium"; // تنقية القيم غير المتوقعة } TaskStore store = (TaskStore) getServletContext().getAttribute("taskStore"); store.add(title.strip(), priority); // نمط PRG — إعادة التوجيه بعد الكتابة لمنع إعادة الإرسال عند التحديث resp.sendRedirect(req.getContextPath() + "/tasks"); } }
طبّق دائمًا نمط Post/Redirect/Get (PRG) بعد POST ناجح. بدونه، يُعيد المتصفح إرسال النموذج عند الضغط على زر التحديث أو الرجوع — يُنشئ المستخدمون سجلات مكررة دون أن يفهموا السبب. sendRedirect يُصدر HTTP 302 بحيث يكون الطلب التالي دائمًا GET.

DeleteTaskServlet — التعديل عبر POST

package com.example.tasks; import com.example.tasks.store.TaskStore; 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("/tasks/delete") public class DeleteTaskServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String raw = req.getParameter("id"); try { int id = Integer.parseInt(raw); TaskStore store = (TaskStore) getServletContext().getAttribute("taskStore"); store.delete(id); } catch (NumberFormatException ignored) { // مدخل غير صالح — تجاهل وإعادة التوجيه } resp.sendRedirect(req.getContextPath() + "/tasks"); } }

عمليات الحذف دائمًا عبر POST، لا GET. الحذف عبر GET يمكن أن يُشغَّل بواسطة متصفح يجلب الروابط مسبقًا، أو محرك بحث يتصفح URLs، أو مستخدم يضغط على رابط بالخطأ — وكل ذلك يُدمّر البيانات بصمت.

عروض JSP

يتكرر task-list.jsp على قائمة المهام التي وضعها الـ Servlet في نطاق الطلب:

<%@ page contentType="text/html; charset=UTF-8" %> <%@ taglib prefix="c" uri="jakarta.tags.core" %> <!DOCTYPE html> <html><head><title>Task Manager</title></head> <body> <h1>Tasks</h1> <a href="${pageContext.request.contextPath}/tasks/add">+ Add Task</a> <table> <tr><th>ID</th><th>Title</th><th>Priority</th><th></th></tr> <c:forEach var="t" items="${tasks}"> <tr> <td>${t.id}</td> <td><c:out value="${t.title}"/></td> <td>${t.priority}</td> <td> <form method="post" action="${pageContext.request.contextPath}/tasks/delete"> <input type="hidden" name="id" value="${t.id}"/> <button type="submit">Delete</button> </form> </td> </tr> </c:forEach> </table> </body></html>
<c:out> يحمي من XSS. عرّض دائمًا النصوص التي أدخلها المستخدم باستخدام <c:out value="..."/> لا بالتعبير المباشر ${expression}. تقوم وسم JSTL بترميز HTML للقيمة، فيُعرض عنوان المهمة الذي يحتوي على <script>alert(1)</script> كنص حرفي بدلًا من تنفيذه.

تجميع القطع: ماذا بنيت للتو

تراجع خطوة للوراء وانظر إلى تدفق البيانات في جولة "إضافة مهمة" الكاملة:

  1. يزور المستخدم /tasks/add (GET) ← AddTaskServlet.doGet يُعيد التوجيه الداخلي إلى add-task.jsp.
  2. يملأ المستخدم النموذج ويُرسله (POST إلى /tasks/add) ← AddTaskServlet.doPost يتحقق من الصحة، يكتب في TaskStore، ثم يستدعي sendRedirect("/tasks").
  3. يتبع المتصفح الاستجابة 302 بطلب GET إلى /tasksListTasksServlet.doGet يقرأ من TaskStore، يضع سمات الطلب، يُعيد التوجيه الداخلي إلى task-list.jsp.
  4. تُصيّر JSP HTML ويعرض المتصفح القائمة المحدّثة.

كل مفهوم من مفاهيم هذا البرنامج التعليمي يظهر في ذلك التدفق: ربط الـ Servlet بالتعليقات التوضيحية، ودورة الحياة عبر المستمع، وإرسال doGet/doPost، وقراءة المعاملات، وسمات الطلب، وRequestDispatcher.forward، وsendRedirect، والحالة المشتركة في نطاق التطبيق عبر ServletContext.

إلى أين تتجه من هنا

لا يوجد في هذا التطبيق مصادقة ولا استمرارية ولا صفحات أخطاء — وكل التطبيقات الحقيقية تحتاجها. الخطوة التالية الطبيعية هي استبدال TaskStore بـ DAO عبر JDBC، وإضافة تسجيل دخول يعتمد على الجلسات، وتقديم معالج استثناءات عام. وهذه بالضبط موضوعات البرامج التعليمية اللاحقة في هذا التخصص.

أبقِ الـ Servlets نحيلة. حين ينمو منطق الأعمال لأكثر من بضعة أسطر، استخرجه إلى فئة خدمة Java عادية (مثل TaskStore) لا تعتمد على الـ Servlet. تصبح هذه الخدمة قابلة للاختبار الوحدوي دون الحاجة إلى تشغيل حاوية — مما يُسرّع دورة التغذية الراجعة بشكل كبير.

الخلاصة

لقد بنيت تطبيق Java Servlet متكاملًا متعدد الصفحات: يُهيّئ ServletContextListener الحالة المشتركة عند بدء التشغيل؛ وتعالج ثلاثة Servlets طلبات GET وPOST لعرض المهام وإضافتها وحذفها؛ وتُصيّر عروض JSP HTML من سمات الطلب؛ ويمنع نمط PRG الإرسال المزدوج غير المقصود. هذه البنية — Servlets نحيلة تُفوّض إلى خدمات، وعروض تُصيّر البيانات فحسب — تتوسع بنظافة إلى أنظمة الإنتاج الحقيقية، وهي الأساس الذي بُنيت عليه أُطر عمل كـ Spring MVC.