نمط المتحكم الأمامي
مع نمو تطبيق ويب Jakarta EE تظهر مشكلة شائعة: كل ميزة تحصل على servlet خاص بها، وكل servlet يكرّر منطق التحقق من المصادقة والتسجيل والتوجيه، فيتحول الكود إلى تشابك من المسؤوليات المتداخلة. نمط المتحكم الأمامي (Front Controller) هو الحل المعماري لهذه المشكلة. بدلًا من ربط عناوين URL مباشرةً بـ servlets منفردة، يستقبل servlet واحد نقطة دخول جميعَ الطلبات، ويفحصها، ويفوّضها إلى معالج أوامر خفيف الوزن يعرف فقط ذلك الاستخدام المحدد. تعيش المخاوف المشتركة كالأمان والتسجيل والترميز في مكان واحد بالضبط.
هذا النمط ليس أكاديميًا. كل إطار عمل ويب Java الكبير مبني عليه: DispatcherServlet في Spring MVC، وActionServlet في Struts، وFacesServlet في JSF كلها متحكمات أمامية. تعلّم النمط الخام يمنحك النموذج الذهني اللازم لفهم أي منها وتصحيح أخطائه.
المشكلة بدون متحكم أمامي
تخيّل تطبيق متجر بسيط يحتوي على ثلاثة servlets: LoginServlet وProductServlet وCartServlet. كل واحد منها يجب أن يتحقق مما إذا كان المستخدم مسجلًا الدخول، ويضبط ترميز الاستجابة، ويسجّل الطلب. التكرار ليس مملًا فحسب — بل خطير. حين يتغير منطق المصادقة يجب تحديث ثلاثة أماكن. وحين تضيف servlet رابعًا يجب أن تتذكر إضافة كود المخاوف المشتركة. وعادةً ما تنسى.
هيكلة الحل
يتكون النمط من ثلاثة مكونات متعاونة:
- Servlet المتحكم الأمامي — الـ servlet الوحيد المربوط بـ
/* (أو بادئة مشتركة). يستخرج اسم الأمر من الطلب، ويجد المعالج المناسب، ويستدعيه.
- واجهة الأمر (المعالج) — واجهة صغيرة جدًا ينفّذها كل معالج. يعتمد المتحكم الأمامي على هذا التجريد فقط.
- المعالجات الملموسة — فئة واحدة لكل حالة استخدام (تسجيل الدخول، عرض المنتجات، إضافة للسلة). لا بنية تحتية لـ servlet؛ فقط منطق الأعمال وإعادة توجيه أو redirect في النهاية.
واجهة الأمر
package com.example.web.command;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public interface Command {
void execute(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException;
}
إبقاء الواجهة رفيعة بهذا القدر يعني أن المعالجات تبقى مركّزة. تضبط سمات الطلب، وتستدعي طبقة الخدمة، ثم إما تُعيد التوجيه forward إلى صفحة JSP أو تُنفّذ sendRedirect.
معالج ملموس
package com.example.web.command;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public class ListProductsCommand implements Command {
private final ProductService productService = new ProductService();
@Override
public void execute(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
var products = productService.findAll();
request.setAttribute("products", products);
request.getRequestDispatcher("/WEB-INF/views/products.jsp")
.forward(request, response);
}
}
Servlet المتحكم الأمامي
package com.example.web;
import com.example.web.command.*;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@WebServlet(urlPatterns = "/app/*")
public class FrontControllerServlet extends HttpServlet {
// سجل الأوامر: جزء مسار URL -> المعالج
private final Map<String, Command> commands = new HashMap<>();
@Override
public void init() {
commands.put("products", new ListProductsCommand());
commands.put("product", new ShowProductCommand());
commands.put("login", new LoginCommand());
commands.put("logout", new LogoutCommand());
commands.put("cart", new CartCommand());
}
@Override
protected void service(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
// ---- المخاوف المشتركة (تعمل لكل طلب) ----
res.setCharacterEncoding("UTF-8");
req.setCharacterEncoding("UTF-8");
// ---- استخلاص اسم الأمر من معلومات المسار ----
// URL: /app/products -> pathInfo = "/products"
String pathInfo = req.getPathInfo();
String commandName = "home";
if (pathInfo != null && pathInfo.length() > 1) {
commandName = pathInfo.substring(1);
int slash = commandName.indexOf('/');
if (slash > 0) commandName = commandName.substring(0, slash);
}
// ---- التوجيه ----
Command command = commands.get(commandName);
if (command == null) {
res.sendError(HttpServletResponse.SC_NOT_FOUND,
"Unknown command: " + commandName);
return;
}
command.execute(req, res);
}
}
استخدم service() وليس doGet()/doPost(). تجاوز service() يتيح للمتحكم الأمامي تمرير طريقة HTTP إلى المعالج دون تغيير. إذا احتاج معالجك للتمييز بين GET وPOST فبإمكانه قراءة request.getMethod() بنفسه، مما يُبقي تلك القرار في المكان المناسب.
المصادقة في مكان واحد
من أكبر مزايا المتحكم الأمامي هي المصادقة المركزية. أضف الفحص إلى service() قبل التوجيه، وسيكون كل معالج في التطبيق محميًا تلقائيًا:
private static final Set<String> PUBLIC_COMMANDS =
Set.of("login", "register", "home");
@Override
protected void service(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
res.setCharacterEncoding("UTF-8");
req.setCharacterEncoding("UTF-8");
String commandName = resolveCommand(req);
if (!PUBLIC_COMMANDS.contains(commandName)) {
HttpSession session = req.getSession(false);
if (session == null || session.getAttribute("user") == null) {
res.sendRedirect(req.getContextPath() + "/app/login");
return;
}
}
Command command = commands.get(commandName);
if (command == null) {
res.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
command.execute(req, res);
}
التعامل مع POST مقابل GET في المعالجات
المعالج الذي يدير نموذجًا عادةً ما يستجيب بشكل مختلف بحسب طريقة HTTP — GET يعرض النموذج الفارغ، وPOST يعالج الإرسال ويعيد التوجيه. يفعل المعالج ذلك بنظافة بدون أي بنية تحتية لـ servlet:
public class LoginCommand implements Command {
private final UserService userService = new UserService();
@Override
public void execute(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
if ("POST".equalsIgnoreCase(req.getMethod())) {
String username = req.getParameter("username");
String password = req.getParameter("password");
var user = userService.authenticate(username, password);
if (user != null) {
req.getSession(true).setAttribute("user", user);
res.sendRedirect(req.getContextPath() + "/app/products");
} else {
req.setAttribute("error", "Invalid credentials");
req.getRequestDispatcher("/WEB-INF/views/login.jsp")
.forward(req, res);
}
} else {
req.getRequestDispatcher("/WEB-INF/views/login.jsp")
.forward(req, res);
}
}
}
المقايضات ومتى تستخدم النمط
- ميزة: المخاوف المشتركة (المصادقة، الترميز، التسجيل، CSRF) تعيش في فئة واحدة بالضبط.
- ميزة: إضافة حالة استخدام جديدة تعني إضافة فئة معالج واحدة وسطر واحد في سجل الأوامر — بدون servlet جديد أو ربط URL جديد.
- ميزة: المعالجات كائنات Java عادية؛ من السهل اختبارها وحدة بدون حاوية servlet.
- عيب: سجل الأوامر في
init() قد يكبر كثيرًا. في تطبيق ناضج ستستبدل الخريطة اليدوية باكتشاف تلقائي قائم على الانعكاس أو حاوية حقن تبعيات.
- عيب: ربط نمط URL واحد بجميع الأوامر يعني أن دلالات طريقة HTTP لنقاط النهاية الفردية أقل وضوحًا للحاوية. وثّق اتفاقياتك بوضوح.
لا تضع منطق الأعمال داخل المتحكم الأمامي نفسه أبدًا. إذا بدأ FrontControllerServlet بإجراء استعلامات قاعدة بيانات أو بناء HTML، فقد أعدت إنشاء المشكلة الأصلية في فئة واحدة. المهمة الوحيدة للمتحكم الأمامي هي التوجيه وتطبيق المخاوف المشتركة.
التطور نحو أطر العمل
ما بنيته هو DispatcherServlet مصغّر. يضيف Spring MVC مسح التعليقات التوضيحية (@GetMapping، @PostMapping)، ومحللات الوسائط، ومحللات العرض، والمعترضات — لكن حلقة التوجيه في المركز هي بالضبط هذا النمط. فهم النسخة الخام يعني أنك تستطيع قراءة كود Spring المصدري، وتهيئته بشكل صحيح، وتشخيص أخطاء التوجيه الدقيقة بدلًا من أن تحيّرك.
الخلاصة
يحل نمط المتحكم الأمامي مشكلة تكرار المخاوف المشتركة بتوجيه جميع الطلبات عبر servlet توجيه واحد. يحل ذلك الـ servlet اسم الأمر من URL، ويطبّق المخاوف المشتركة (المصادقة، الترميز)، ويفوّض إلى معالج Command خفيف الوزن. النتيجة قاعدة كود تعني فيها إضافة ميزة إضافة فئة واحدة، وتحدث فيها تغييرات الأمان أو التسجيل في مكان واحد بالضبط. في الدرس الأخير من هذا البرنامج التعليمي ستجمع الجلسات والفلاتر والمتحكم الأمامي معًا في مشروع كامل لتسجيل الدخول ومنطقة محمية.