البرمجة الجانبية في Spring

AOP التطبيقي: الأداء والأمان

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

AOP التطبيقي: الأداء والأمان

طبّق الدرس السابق AOP على التسجيل والتدقيق — وهي اهتمامات رصدية بحتة. يتناول هذا الدرس اهتمامَين يمكنهما تغيير نتيجة استدعاء الدالة فعليًا: قياس الأداء (الذي يُعلم قرارات التحسين) وفحوصات الأمان على مستوى الدالة (التي يمكنها إيقاف التنفيذ تمامًا). كلاهما اهتمامات متقاطعة نموذجية تنتمي إلى الجوانب (aspects) لا إلى كل دالة خدمة على حدة.

جانب قياس الوقت: قياس أداء الدوال

يلفّ جانب قياس الوقت استدعاءَ الدالة، ويسجّل الوقت المنقضي على الساعة، ثم إما يسجّله أو يخزّنه للتجميع. الأداة الطبيعية هي نصيحة @Around، لأنها النوع الوحيد من النصائح الذي يتحكم في متى تُنفَّذ الدالة الفعلية، وبالتالي يمكنه تأطير الاستدعاء بالطوابع الزمنية.

ابدأ بتعريف تعليق توضيحي يُمكّنك من تمييز الدوال الفردية بدلًا من استخدام أحرف البدل:

package com.example.aop; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Timed { /** اسم منطقي اختياري يظهر في السجل؛ يُعيد القيمة الافتراضية إلى اسم الدالة الكامل. */ String value() default ""; }

والآن اكتب الجانب نفسه:

package com.example.aop; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.util.StopWatch; import java.lang.reflect.Method; @Aspect @Component public class TimingAspect { private static final Logger log = LoggerFactory.getLogger(TimingAspect.class); @Around("@annotation(com.example.aop.Timed)") public Object measureExecutionTime(ProceedingJoinPoint pjp) throws Throwable { MethodSignature sig = (MethodSignature) pjp.getSignature(); Method method = sig.getMethod(); Timed timed = method.getAnnotation(Timed.class); String label = timed.value().isBlank() ? sig.getDeclaringTypeName() + "#" + sig.getName() : timed.value(); StopWatch sw = new StopWatch(label); sw.start(); try { Object result = pjp.proceed(); sw.stop(); log.info("[TIMING] {} اكتمل في {} ms", label, sw.getLastTaskTimeMillis()); return result; } catch (Throwable ex) { sw.stop(); log.warn("[TIMING] {} فشل بعد {} ms — {}: {}", label, sw.getLastTaskTimeMillis(), ex.getClass().getSimpleName(), ex.getMessage()); throw ex; // أعد الرمي حتى لا يتأثر التعامل الطبيعي مع الأخطاء } } }

طبّقه على أي دالة خدمة تريد قياسها:

@Service public class ReportService { @Timed("report.generate") public byte[] generateMonthlyReport(int year, int month) { // منطق توليد PDF الذي قد يكون بطيئًا } }
لماذا StopWatch بدلًا من System.nanoTime()؟ ساعة التوقف الخاصة بـ Spring ليست آمنة للخيوط المتعددة ولا ينبغي مشاركتها عبر الخيوط، لكنها مثالية داخل استدعاء نصيحة واحد: تُخفي حساب الوقت الخام وتُسمّي المهمة وتُنسّق النتيجة بشكل نظيف. للمقاييس على مستوى الإنتاج، انشر إلى Micrometer (Timer.record(...)) بدلًا من التسجيل — مما يمنحك لوحات معلومات Prometheus/Grafana مجانًا.

تجميع بيانات التوقيت مع Micrometer

تسجيل التوقيتات الفردية مفيد خلال التطوير، لكن في الإنتاج تحتاج إلى النسب المئوية والرسوم البيانية. استبدل جملة التسجيل بـ Timer من Micrometer:

import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; @Aspect @Component public class MicrometerTimingAspect { private final MeterRegistry registry; public MicrometerTimingAspect(MeterRegistry registry) { this.registry = registry; } @Around("@annotation(com.example.aop.Timed)") public Object record(ProceedingJoinPoint pjp) throws Throwable { MethodSignature sig = (MethodSignature) pjp.getSignature(); Timed timed = sig.getMethod().getAnnotation(Timed.class); String name = timed.value().isBlank() ? sig.getName() : timed.value(); Timer.Sample sample = Timer.start(registry); try { return pjp.proceed(); } finally { // 'finally' يضمن توقف المؤقت حتى عند حدوث استثناء sample.stop(Timer.builder(name) .description("وقت تنفيذ " + name) .register(registry)); } } }

مع وجود Spring Boot Actuator في مسار الفئات، يظهر المقياس تلقائيًا عند /actuator/metrics/{name} ويُجمعه Prometheus إذا أضفت micrometer-registry-prometheus.

الأمان على مستوى الدالة باستخدام AOP

يوفر Spring Security بالفعل @PreAuthorize و@Secured و@RolesAllowed — كلها مُنفَّذة داخليًا كنصائح AOP. فهم كيفية بناء جانب أمان خاص بك يُعلّمك ما تفعله Spring Security داخليًا ويسمح لك بتنفيذ منطق تحكم وصول مخصص (قواعد عمل، فحوصات ملكية، أعلام مميزات) لا تستطيع لغة تعبير Spring Security التعبير عنها بشكل نظيف.

عرّف تعليقًا توضيحيًا مخصصًا يحمل سلسلة صلاحية مطلوبة:

package com.example.aop; import java.lang.annotation.*; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresPermission { String value(); // مثال: "REPORT_EXPORT"، "USER_DELETE" }

سياق أمني بسيط (استبدله بمصدر المدير الحقيقي الخاص بك):

package com.example.aop; import java.util.Set; /** حامل ThreadLocal — في تطبيق حقيقي يُغلّف SecurityContextHolder الخاص بـ Spring Security. */ public class SecurityContext { private static final ThreadLocal<Set<String>> PERMISSIONS = new ThreadLocal<>(); public static void setPermissions(Set<String> perms) { PERMISSIONS.set(perms); } public static Set<String> getPermissions() { Set<String> p = PERMISSIONS.get(); return p != null ? p : Set.of(); } public static void clear() { PERMISSIONS.remove(); } }

والآن الجانب الذي يُطبّق التعليق التوضيحي:

package com.example.aop; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; @Aspect @Component public class SecurityAspect { @Before("@annotation(com.example.aop.RequiresPermission)") public void checkPermission(JoinPoint jp) { MethodSignature sig = (MethodSignature) jp.getSignature(); RequiresPermission ann = sig.getMethod().getAnnotation(RequiresPermission.class); String required = ann.value(); if (!SecurityContext.getPermissions().contains(required)) { throw new AccessDeniedException( "الصلاحية '" + required + "' مطلوبة لاستدعاء " + sig.getDeclaringTypeName() + "#" + sig.getName() ); } } }
لماذا @Before وليس @Around؟ فحوصات الأمان هي بوابة ثنائية: إما يُسمح للمُستدعي بالمتابعة أو لا يُسمح له. يُعبّر @Before عن هذه النية مباشرةً — ارمِ استثناءً للحجب، وعُد بشكل طبيعي للسماح. سيعمل @Around أيضًا لكنه يُضيف نموذجًا معياريًا غير ضروري (pjp.proceed()، ومعالجة قيمة الإرجاع) لاهتمام لا يحتاج أبدًا إلى فحص قيمة الإرجاع.

ضع تعليقًا توضيحيًا على أي دالة خدمة تحتاج حمايةً:

@Service public class AdminService { @RequiresPermission("USER_DELETE") public void deleteUser(Long userId) { // تُنفَّذ فقط عندما يمتلك الخيط الحالي صلاحية USER_DELETE } @Timed("admin.exportReport") @RequiresPermission("REPORT_EXPORT") public byte[] exportReport(String format) { // كلا الجانبين يُطبَّقان: فحص الأمان أولًا، ثم قياس الوقت } }

ترتيب الجوانب عند التركيب المتعدد

عندما يُضاف إلى دالة تعليق توضيحي @RequiresPermission و@Timed معًا، يلفّ وكيلان منفصلان (أو وكيل واحد بمعترضَين) الدالة. يجب أن تضمن تشغيل فحص الأمان قبل بدء المؤقت — لا تريد تسجيل توقيت استدعاءات مرفوضة.

استخدم @Order على فئة الجانب للتحكم في الأسبقية. تُنفَّذ الأرقام الأصغر في الموضع الخارجي لنصائح @Before و@Around:

import org.springframework.core.annotation.Order; @Aspect @Component @Order(1) // يُنفَّذ أولًا — الغلاف الأخارجي public class SecurityAspect { /* ... */ } @Aspect @Component @Order(2) // يُنفَّذ ثانيًا — داخل غلاف الأمان public class TimingAspect { /* ... */ }
ترتيب الجوانب غير المرتّبة غير محدد. إذا أضفت جانبًا ثالثًا بدون @Order، فقد يُشغّله Spring في أي موضع نسبيًا للجوانب الأخرى، وقد يتغيّر الموضع بين عمليات إعادة التشغيل. طبّق @Order دائمًا على أي جانب تهمّه مواضعه النسبية — جوانب الأمان والمعاملات أكثر الأمثلة شيوعًا.

المفاضلات والمخاطر الشائعة

  • الاستدعاء الذاتي يتجاوز الجوانب. إذا استدعت deleteUser() دالةً أخرى على نفس الـ bean، يذهب الاستدعاء الداخلي مباشرةً إلى الكائن الهدف — لا عبر الوكيل — لذا تُتخطّى أي جوانب على الدالة الداخلية بصمت. أعد الهيكلة بحقن الـ bean في نفسه، أو استخدم AopContext.currentProxy() (يتطلب exposeProxy = true على @EnableAspectJAutoProxy).
  • التكلفة لكل استدعاء. تُضيف عكوس AOP تكلفةً صغيرة لكل استدعاء (عادةً بضعة ميكروثوانٍ). للدوال المُستدعاة آلاف المرات في الثانية، فضّل تعليق Micrometer التوضيحي @Timed (الذي يستخدم مسارًا محسّنًا مخصصًا) على جانب مخصص.
  • شفافية الاستثناءات. يجب أن تُعيد نصيحتك رمي أي استثناء محدد يُعلن عنه الدالة الهدف. إذا اصطدت Throwable في @Around، أعد رميها دائمًا — وإلا يفقد المُستدعي نوع الاستثناء الأصلي.
  • اختبار الجوانب بمعزل. ضع تعليقًا توضيحيًا على فئة الجانب بـ @SpringBootTest وشريحة بسيطة، أو استخدم Aspects.aspectOf() من AspectJ للحصول على نموذج الجانب مباشرةً واستدعاء دوال نصائحه في اختبار وحدة عادي.

الخلاصة

جوانب قياس الوقت وجوانب الأمان هما أكثر تطبيقات AOP العملية أثرًا. يمنحك جانب @Around المدفوع بـ @Timed قياسًا للأداء بدون ضوضاء عبر أي عدد من الدوال — والربط بـ Micrometer يحوّله إلى إمكانية ملاحظة على مستوى الإنتاج. يمنحك جانب @Before المدفوع بـ @RequiresPermission تحكمًا تصريحيًا في الوصول يُطبَّق مركزيًا دون تلويث منطق الأعمال. ادمجهما مع إعلانات @Order الصريحة وستحصل على طبقة اهتمامات متقاطعة قابلة للتركيب وسهلة الصيانة.