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

نصيحة @Around ونقطة الانضمام المتقدمة ProceedingJoinPoint

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

نصيحة @Around ونقطة الانضمام المتقدمة ProceedingJoinPoint

@Around هي أقوى أنواع النصائح في Spring AOP. على خلاف @Before أو @After اللتين تراقبان تنفيذ الميثود من الخارج، تُغلّف @Around الاستدعاء بالكامل. يُنفَّذ كود النصيحة قبل الميثود المستهدفة، ويقرر ما إذا كان سيُنفّذها أصلًا، ثم يُنفَّذ مرة أخرى بعد عودتها — مع وصول كامل إلى قيمة الإرجاع وأي استثناء قد يُرمى. يُعبَّر عن هذه السيطرة من خلال كائن وحيد: ProceedingJoinPoint.

توقيع الميثود

يجب أن يُعلن ميثود @Around عن ProceedingJoinPoint كمعامله الأول، وأن يُرجع Object (قيمة إرجاع الميثود المُنصَّح عليها، ربما بعد تعديلها).

import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Aspect @Component public class TimingAspect { @Around("execution(* com.example.service.*.*(..))") public Object measureExecutionTime(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); // استدعاء الميثود الفعلية long elapsed = System.currentTimeMillis() - start; System.out.printf("[TIMING] %s.%s took %d ms%n", pjp.getTarget().getClass().getSimpleName(), pjp.getSignature().getName(), elapsed); return result; // إعادة قيمة الإرجاع إلى المُستدعي } }
يجب عليك استدعاء pjp.proceed() وإرجاع نتيجتها. إن نسيت أحدهما، فإن الميثود الأصلية لن تُنفَّذ أبدًا (أو ستُهدر قيمة إرجاعها بصمت)، وهو خطأ في الغالب. لن يُحذّرك المُترجم — هذه مسؤولية وقت التشغيل.

كيف تعمل proceed()

تُفوّض pjp.proceed() إلى النصيحة التالية في السلسلة (أو إلى الميثود المستهدفة الحقيقية إن لم تكن ثمة نصائح أخرى). تُعيد نشر أي استثناء يُرمى من الميثود المستهدفة، ولهذا يجب أن يُعلن توقيع الميثود عن throws Throwable. يمكنك التقاط استثناءات محددة ومعالجتها أو إعادة رميها أو ابتلاعها كليًا — لكن الابتلاع خاطئ تقريبًا في كل الحالات خارج أنماط إعادة المحاولة المحددة جدًا.

فحص نقطة الانضمام

تكشف ProceedingJoinPoint عن سياق ثري حول الاستدعاء المُعترَض:

  • pjp.getSignature() — اسم الميثود والنوع المُعلِن ونوع الإرجاع.
  • pjp.getArgs() — قيم المعاملات الفعلية وقت الاستدعاء.
  • pjp.getTarget() — الـ bean المستهدف (الكائن الذي يُستدعى ميثوده).
  • pjp.getThis() — الـ proxy نفسه (نادرًا ما تحتاجه).

تعديل المعاملات قبل المتابعة

يمكنك استبدال مصفوفة المعاملات قبل استدعاء proceed(Object[]). تقبل النسخة المُحمَّلة مصفوفة معاملات جديدة يمررها Spring إلى الميثود المستهدفة بدلًا من الأصلية. هذا مفيد لتعقيم المدخلات أو تطبيعها أو حقن البيانات الوصفية للتدقيق.

@Around("execution(* com.example.service.UserService.createUser(..))") public Object sanitiseInput(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); // إزالة المسافات البيضاء من كل معامل من نوع String for (int i = 0; i < args.length; i++) { if (args[i] instanceof String s) { args[i] = s.strip(); } } return pjp.proceed(args); // تمرير المعاملات المعدَّلة إلى الميثود الفعلية }
يُفضَّل استبدال المعاملات بطريقة غير مُتلفة للأصل. انسخ المصفوفة قبل تعديلها (Object[] args = pjp.getArgs().clone()) حتى لا يتأثر النسخة الأصلية في حالة فحصها من قِبَل نصائح أخرى.

تعديل قيمة الإرجاع

ما تُرجعه pjp.proceed() هو مجرد Object. يمكنك فحصه أو استبداله أو تغليفه قبل إرجاعه للمُستدعي. حالة الاستخدام الشائعة هي التخزين المؤقت: تفحص ذاكرة التخزين المؤقت قبل استدعاء proceed()، ثم تُخزّن النتيجة بعده.

@Around("@annotation(com.example.annotation.Cacheable)") public Object cacheResult(ProceedingJoinPoint pjp) throws Throwable { String key = buildKey(pjp); Object cached = localCache.get(key); if (cached != null) { return cached; // اختصار — الميثود المستهدفة لن تُستدعى } Object result = pjp.proceed(); localCache.put(key, result); return result; }

لاحظ أنه عند وجود القيمة في التخزين المؤقت، لا يُستدعى proceed() أبدًا. هذا مقصود وصحيح تمامًا — لكن وثّقه بوضوح، لأنه غير مرئي لمن يقرأ الميثود المستهدفة فقط.

التعامل مع الاستثناءات داخل @Around

يمكنك التقاط الاستثناءات التي ترميها الميثود المستهدفة وتقرير ما تفعله:

@Around("execution(* com.example.service.PaymentService.*(..))") public Object resilient(ProceedingJoinPoint pjp) throws Throwable { try { return pjp.proceed(); } catch (TransientPaymentException ex) { log.warn("Transient payment error, returning fallback: {}", ex.getMessage()); return PaymentResult.PENDING; // ابتلع الاستثناء وأرجع قيمة آمنة بديلة } // باقي الاستثناءات تنتشر بشكل طبيعي }
كن حذرًا عند ابتلاع الاستثناءات. إذا كان المُستدعي يتوقع استثناءً محققًا للإشارة إلى فشل ما، فإن استبداله بصمت بقيمة افتراضية يُخفي المشكلة. احتفظ بهذا النمط للأعطال العابرة المفهومة جيدًا حيث تكون القيمة الاحتياطية آمنة فعلًا.

مثال كامل: جانب التوقيت الاحترافي

إليك جانب توقيت جاهز للإنتاج يستخدم SLF4J، ويتجاهل الميثودات السريعة دون عتبة معينة، ويُسجّل تحذيرًا للميثودات البطيئة:

import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @Aspect @Component public class SlowMethodAspect { private static final Logger log = LoggerFactory.getLogger(SlowMethodAspect.class); private static final long WARN_THRESHOLD_MS = 200; @Around("within(com.example.service..*)") public Object track(ProceedingJoinPoint pjp) throws Throwable { long start = System.nanoTime(); try { return pjp.proceed(); } finally { long elapsedMs = (System.nanoTime() - start) / 1_000_000; if (elapsedMs > WARN_THRESHOLD_MS) { log.warn("SLOW: {}.{}() took {} ms", pjp.getTarget().getClass().getSimpleName(), pjp.getSignature().getName(), elapsedMs); } } } }

يضمن حقل finally تسجيل التوقيت سواء أعادت الميثود قيمة بشكل طبيعي أم رمت استثناءً. استخدام System.nanoTime() بدلًا من System.currentTimeMillis() يتجنب قفزات ساعة الجدار الناجمة عن تعديلات NTP.

المقايضات: متى تستخدم @Around

  • استخدم @Around حين تحتاج لقياس الوقت المنقضي، أو التخطي الشرطي للميثود، أو تعديل المعاملات أو قيمة الإرجاع، أو تطبيق منطق إعادة المحاولة أو الاحتياط.
  • يُفضَّل @Before / @After للاهتمامات المتقاطعة الأبسط كتسجيل الدخول والخروج، لأنها لا تستطيع عن طريق الخطأ ابتلاع الاستثناءات أو نسيان استدعاء الميثود المستهدفة.
  • الأداء: تُضيف @Around استدعاء ميثود إضافيًا واحدًا لكل نقطة انضمام مطابقة. عادةً ما تكون التكلفة بضعة ميكروثوانٍ — لا يُذكر إلا في الحلقات الداخلية المكثفة. ضيّق نقطة الاقتطاع لتجنب مطابقة مسارات الكود الحرجة دون ضرورة.

الخلاصة

تمنحك @Around غلافًا كاملًا حول الميثود المُعترَضة. تُسلّم ProceedingJoinPoint.proceed() التحكم للميثود المستهدفة؛ ونسختها المُحمَّلة proceed(Object[]) تتيح استبدال المعاملات أولًا؛ وقيمة الإرجاع Object قابلة للاستبدال قبل وصولها للمُستدعي. عند الاستخدام الصحيح — مع finally للتنظيف، ونموذج ذهني واضح لما يحدث حين لا يُستدعى proceed() — تُشكّل @Around أساس اهتمامات التسجيل والتوقيت والتخزين المؤقت وإعادة المحاولة والأمان المتقاطعة في تطبيقات Spring الإنتاجية.