الفلاتر العملية
في الدرس السابق تعرّفت على ماهية الفلتر في Jakarta EE. الآن ستبني أربعة فلاتر على مستوى الإنتاج ستلجأ إليها في كل مشروع تقريبًا: فلتر لتسجيل الطلبات والاستجابات، وفلتر لحراسة المصادقة، وفلتر لضغط GZIP، ومعالج CORS. كل واحد منها يُوضّح نمط تفاعل مختلف مع سلسلة الفلاتر ويُريك المفاضلات التي تحتاج إلى فهمها عند نشره في الإنتاج.
1. فلتر تسجيل الطلبات والاستجابات
يقف فلتر التسجيل في الحلقة الخارجية من السلسلة. يُسجّل الطلب الوارد، يدع السلسلة تعمل، ثم يُسجّل الاستجابة. التحدي الجوهري هو أنّ HttpServletResponse لا يُتيح لك قراءة الجسم بعد كتابته إلى العميل — وتحتاج إلى مُغلّف (wrapper) لالتقاطه أولًا.
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.*;
import java.io.*;
import java.time.Instant;
import java.util.logging.Logger;
@WebFilter("/*")
public class LoggingFilter implements Filter {
private static final Logger LOG = Logger.getLogger(LoggingFilter.class.getName());
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) req;
HttpServletResponse httpRes = (HttpServletResponse) res;
long start = System.currentTimeMillis();
String requestLine = httpReq.getMethod() + " " + httpReq.getRequestURI();
// نُغلّف الاستجابة لالتقاط رمز الحالة بعد تشغيل السلسلة
StatusCapturingResponseWrapper wrapper = new StatusCapturingResponseWrapper(httpRes);
try {
chain.doFilter(httpReq, wrapper);
} finally {
long elapsed = System.currentTimeMillis() - start;
LOG.info(String.format("[%s] %s -> %d (%d ms)",
Instant.now(), requestLine, wrapper.getStatus(), elapsed));
}
}
}
// مُغلّف بسيط — يلتقط رمز الحالة الذي يكتبه الـ servlet
class StatusCapturingResponseWrapper extends HttpServletResponseWrapper {
private int status = 200;
StatusCapturingResponseWrapper(HttpServletResponse response) {
super(response);
}
@Override public void setStatus(int sc) { this.status = sc; super.setStatus(sc); }
@Override public void sendError(int sc) throws IOException { this.status = sc; super.sendError(sc); }
@Override public void sendError(int sc, String msg) throws IOException { this.status = sc; super.sendError(sc, msg); }
public int getStatus() { return status; }
}
لماذا نُغلّف الاستجابة؟ HttpServletResponse للكتابة فقط بمجرد فتح دفق الإخراج. يُفوّض HttpServletResponseWrapper جميع الاستدعاءات إلى الاستجابة الحقيقية مع السماح لك باعتراض أساليب محددة. هذا هو نمط Jakarta EE المعياري كلما احتاج الفلتر إلى مراقبة الاستجابة أو تعديلها بعد أن تغادر الـ servlet.
لاحظ حقل try/finally: يجب أن يحدث التسجيل حتى حين يرمي الـ servlet استثناءً. بما أنّ هذا الفلتر مُعيَّن على /* فإنه يعترض كل طلب — بما فيها الملفات الثابتة. في التطبيق العملي ستُصفّي حسب نوع المحتوى أو بادئة URI للحدّ من الضجيج.
2. فلتر حراسة المصادقة
يعترض فلتر المصادقة عناوين URL المحمية ويُعيد توجيه المستخدمين غير المصادق عليهم إلى صفحة تسجيل الدخول. يجب ألا يستدعي chain.doFilter للطلبات غير المصرّح بها — هذا ما يجعله حارسًا.
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.*;
import java.io.IOException;
@WebFilter(urlPatterns = {"/dashboard/*", "/api/admin/*"})
public class AuthenticationFilter implements Filter {
private static final String LOGIN_PAGE = "/login";
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) req;
HttpServletResponse httpRes = (HttpServletResponse) res;
HttpSession session = httpReq.getSession(false); // false = لا تُنشئ جلسة جديدة
boolean loggedIn = session != null && session.getAttribute("user") != null;
if (!loggedIn) {
String target = httpReq.getRequestURI();
// عملاء API يتوقعون 401، عملاء المتصفح يحصلون على إعادة توجيه
if (target.startsWith("/api/")) {
httpRes.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication required");
} else {
String loginUrl = httpReq.getContextPath() + LOGIN_PAGE
+ "?redirect=" + java.net.URLEncoder.encode(target, "UTF-8");
httpRes.sendRedirect(loginUrl);
}
return; // ← أوقف السلسلة؛ لن يعمل الـ servlet أبدًا
}
chain.doFilter(req, res); // مصادَق عليه — المتابعة بشكل طبيعي
}
}
استدعِ getSession(false) دائمًا في فلتر الحراسة. يُنشئ getSession() الافتراضي جلسة جديدة لكل زائر غير مصادَق عليه، مما يُضخّم مخزن الجلسات ويُسهّل هجمات تثبيت الجلسة. مرّر false وتعامل مع القيمة المُعادة null على أنها "لا توجد جلسة."
يُعامل الفلتر مسارات API ومسارات المتصفح بشكل مختلف — نمط ذكي للتطبيقات التي تخدم كليهما. لا يستطيع عملاء API اتباع إعادة التوجيه؛ يحتاجون إلى رمز حالة HTTP يمكن للكود التعامل معه.
3. فلتر ضغط GZIP
تعترض فلاتر الضغط دفق إخراج الاستجابة وتُغلّفه بـ GZIPOutputStream، فتضغط شفافيًا كل ما يكتبه الـ servlet. يمكن أن يُقلّل هذا حجم الاستجابة بنسبة 60–80% لحمولات النصوص.
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.*;
import java.io.*;
import java.util.zip.GZIPOutputStream;
@WebFilter("/*")
public class GzipFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) req;
HttpServletResponse httpRes = (HttpServletResponse) res;
String acceptEncoding = httpReq.getHeader("Accept-Encoding");
boolean clientAcceptsGzip = acceptEncoding != null
&& acceptEncoding.toLowerCase().contains("gzip");
if (!clientAcceptsGzip) {
chain.doFilter(req, res); // العميل لا يدعم الفك — تمرير مباشر
return;
}
httpRes.setHeader("Content-Encoding", "gzip");
httpRes.setHeader("Vary", "Accept-Encoding"); // أخبر الكاشات أن الاستجابة تتباين
// غلّف دفق الإخراج بدفق gzip
try (GzipResponseWrapper gzipWrapper = new GzipResponseWrapper(httpRes)) {
chain.doFilter(httpReq, gzipWrapper);
}
}
}
// مُغلّف يستبدل دفق إخراج الـ servlet بدفق GZIP
class GzipResponseWrapper extends HttpServletResponseWrapper implements Closeable {
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
private final GZIPOutputStream gzip;
private final PrintWriter writer;
GzipResponseWrapper(HttpServletResponse response) throws IOException {
super(response);
gzip = new GZIPOutputStream(buffer);
writer = new PrintWriter(new OutputStreamWriter(gzip, "UTF-8"));
}
@Override
public PrintWriter getWriter() { return writer; }
@Override
public ServletOutputStream getOutputStream() {
return new ServletOutputStream() {
public void write(int b) throws IOException { gzip.write(b); }
public void write(byte[] b, int off, int len) throws IOException { gzip.write(b, off, len); }
public boolean isReady() { return true; }
public void setWriteListener(WriteListener l) {}
};
}
@Override
public void close() throws IOException {
writer.flush();
gzip.finish();
byte[] compressed = buffer.toByteArray();
getResponse().setContentLength(compressed.length);
getResponse().getOutputStream().write(compressed);
}
}
اضبط ترويسة Vary: Accept-Encoding. بدونها قد يُقدّم وكيل التخزين المؤقت النسخة المضغوطة لعميل طلب نصًّا عاديًا (أو العكس)، مما يتسبب في إخراج مشوّه. هذه الترويسة الواحدة تُخبر كل كاش وسيط بأن محتوى الاستجابة يتباين وفقًا لترويسة Accept-Encoding الخاصة بالعميل.
لا تضغط أنواع المحتوى المضغوطة أصلًا (JPEG، PNG، ZIP، فيديو). تحقق من getContentType() قبل التغليف، أو طبّق الفلتر فقط على عناوين text/* وapplication/json.
4. فلتر CORS
يجب ضبط ترويسات مشاركة الموارد عبر الأصول المختلفة (CORS) قبل كتابة جسم الاستجابة — وللطلبات التمهيدية OPTIONS يجب إعادة الاستجابة فورًا دون الوصول إلى الـ servlet على الإطلاق. الفلتر هو المكان المعياري للتعامل مع كلا المتطلبين.
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.util.Set;
@WebFilter("/api/*")
public class CorsFilter implements Filter {
// اسمح بأصول محددة — لا تستخدم "*" في الإنتاج مع بيانات الاعتماد أبدًا
private static final Set<String> ALLOWED_ORIGINS = Set.of(
"https://app.example.com",
"https://admin.example.com"
);
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) req;
HttpServletResponse httpRes = (HttpServletResponse) res;
String origin = httpReq.getHeader("Origin");
if (origin != null && ALLOWED_ORIGINS.contains(origin)) {
httpRes.setHeader("Access-Control-Allow-Origin", origin);
httpRes.setHeader("Access-Control-Allow-Credentials", "true");
httpRes.setHeader("Vary", "Origin");
// مطلوب فقط في الطلبات التمهيدية لكنه غير ضار على جميع الاستجابات
httpRes.setHeader("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS");
httpRes.setHeader("Access-Control-Allow-Headers",
"Authorization, Content-Type, X-Requested-With");
httpRes.setHeader("Access-Control-Max-Age", "3600"); // كاش الطلب التمهيدي لساعة
}
// طلب تمهيدي: أجب هنا، ولا تُمرّره للـ servlet أبدًا
if ("OPTIONS".equalsIgnoreCase(httpReq.getMethod())) {
httpRes.setStatus(HttpServletResponse.SC_NO_CONTENT); // 204
return;
}
chain.doFilter(req, res);
}
}
لا تضبط Access-Control-Allow-Origin: * أبدًا حين يكون Access-Control-Allow-Credentials: true مضبوطًا أيضًا. تُرفض هذه التوليفة في المتصفحات. استخدم قائمة مسموحة من الأصول المحددة وأعِد إرسال الأصل المُطابق. اضبط أيضًا Vary: Origin حتى لا تُقدّم الكاشات استجابة أصل واحد لأصل آخر.
ترتيب الفلاتر
حين تتطابق فلاتر متعددة مع نفس عنوان URL فإنّ الترتيب الذي تعمل به مهم. مع تعليقات @WebFilter لا يضمن حاوي الـ servlet الترتيب. للحصول على ترتيب محدد أعلن الفلاتر في web.xml باستخدام عناصر <filter-mapping> — ترتيبها في XML هو ترتيب تنفيذها. في Spring Boot، اضبط تعليق @Order أو نفّذ Ordered على FilterRegistrationBean.
ترتيب منطقي افتراضي للفلاتر أعلاه:
- LoggingFilter — الأبعد للخارج، يرى كل شيء بما في ذلك فشل المصادقة
- CorsFilter — يجب ضبط الترويسات قبل إتمام أي استجابة
- GzipFilter — يُغلّف دفق الاستجابة قبل أن يكتب الـ servlet
- AuthenticationFilter — الحارس الأعمق، الأقرب إلى الموارد المحمية
الخلاصة
يُمثّل التسجيل والمصادقة والضغط و CORS أكثر أربعة اهتمامات متقاطعة شيوعًا ستحتاج إلى تنفيذها كمطوّر ويب قائم على الـ servlet. يتبع كل واحد منها نفس النمط البنيوي — غلّف الطلب أو الاستجابة، استدعِ chain.doFilter بشروط، راقب النتيجة — لكنه يختلف في موضعه في السلسلة وما إذا كان يسمح للسلسلة بالمتابعة. أتقن هذه الأربعة وستملك قالبًا لكل فلتر مخصص ستكتبه في المستقبل.