كيف يعمل Spring AOP (البروكسيات)
كيف يعمل Spring AOP (البروكسيات)
عاملت كل الدروس السابقة Spring AOP كصندوق أسود: تُزيّن فئتك بـ @Aspect، تكتب بعض النصائح، وتقوم Spring بطريقة ما باعتراض استدعاءات الدوال التي حددتها. هذا الدرس يفتح ذلك الصندوق الأسود. إن فهم استراتيجيتَي البروكسي اللتين تستخدمهما Spring — بروكسيات JDK الديناميكية وبروكسيات CGLIB — والفخ الذي يخلقانه في حالة استدعاء الدالة لنفسها سيجنّبك أخطاء خفية يصعب جدًا تشخيصها دون هذه المعرفة.
الفكرة الأساسية: كائنات البروكسي
لا تُعدّل Spring AOP كودك البايتي في وقت التصريف (ذاك هو أسلوب النسيج في AspectJ). بدلًا من ذلك، تُغلّف Spring في وقت التشغيل الـ bean الخاص بك في كائن بروكسي — فئة مولَّدة تُنفّذ نفس الواجهة (أو ترث من نفس الفئة الملموسة) الخاصة بالكائن الأصلي. حين يطلب مُستدعٍ من حاوية Spring الخاص بـ OrderService، يحصل على البروكسي لا الكائن الحقيقي. يعترض البروكسي كل استدعاء، يُنفّذ أي نصيحة مُطابقة، ثم يُفوّض إلى الكائن الحقيقي المخفي بداخله.
الاستراتيجية الأولى: بروكسيات JDK الديناميكية
بروكسيات JDK الديناميكية مدمجة في مكتبة Java القياسية (java.lang.reflect.Proxy). تعمل بتوليد فئة في وقت التشغيل تُنفّذ قائمة واجهات مُحددة. تُوجّه الفئة المولَّدة كل استدعاء دالة عبر InvocationHandler — تُزوّد Spring بمعالجها الخاص الذي يُطبّق النصيحة ثم يستدعي الدالة الحقيقية عبر الانعكاس.
الشرط: يجب أن يُنفّذ الـ bean المستهدف واجهةً واحدةً على الأقل. يُنفّذ البروكسي تلك الواجهة؛ يحتفظ المُستدعون بمرجع مُكتّب بنوع الواجهة.
لأن البروكسي يُنفّذ الواجهة المُصرَّح بها فحسب، لا يمكن للمُستدعين تحويله (cast) إلى PaymentServiceImpl. أي محاولة ترمي ClassCastException في وقت التشغيل — خطأ شائع حين يحاول أحدهم استدعاء دالة ملموسة غير مُدرجة في الواجهة.
الاستراتيجية الثانية: بروكسيات CGLIB
حين لا يُنفّذ الـ bean واجهةً — أو حين تُهيّئ proxyTargetClass = true — تلجأ Spring إلى CGLIB (مكتبة توليد الكود). تُولّد CGLIB فئةً فرعيةً من فئتك الملموسة في وقت التشغيل وتتجاوز دواليَّها لإدراج سلسلة النصائح. لأنها فئة فرعية، يمكن لـ CGLIB عمل بروكسي لأي فئة غير نهائية دون الحاجة لواجهة.
يجعل Spring Boot 2+ من بروكسيات CGLIB الإعداد الافتراضي لجميع الـ beans (يضبط spring.aop.proxy-target-class=true افتراضيًا). ستجد إذن بروكسيات CGLIB في كل مكان في تطبيق Spring Boot نموذجي، حتى للـ beans التي تمتلك واجهات.
- يجب ألا تكون الفئة أو أي دالة مُوكَّلة بها
final— لا يمكن توريث الفئة النهائية أو الدالة النهائية، لذا لا تستطيع CGLIB اعتراضها. - تُنشئ CGLIB نسخة من الفئة الفرعية وتحتاج إلى مُنشئ بلا معاملات (أو مُنشئ يمكن لـ Spring إشباعه عبر الحقن). إذا أضفت مُنشئًا بمعاملات مطلوبة وحذفت النسخة بلا معاملات، قد تفشل Spring في إنشاء البروكسي في الإعدادات القديمة. يُزيل Spring 6 مع Objenesis هذا القيد في معظم الحالات، لكن من المفيد معرفته.
الاختيار بين الاستراتيجيتين
| الجانب | بروكسي JDK الديناميكي | بروكسي CGLIB |
|---|---|---|
| يتطلب واجهة | نعم | لا |
يعمل مع الفئات final |
لا (يجب أن يكون واجهة) | لا |
يعمل مع الدوال final |
غير مُطبَّق — ليست في البروكسي | لا — لا يمكن تجاوزها |
| الافتراضي في Spring Boot | لا (منذ Boot 2) | نعم |
| تحويل المُستدعي للنوع الملموس | غير ممكن | ممكن (فئة فرعية) |
لفرض بروكسيات JDK على مستوى المشروع بالكامل، أضف في application.properties:
لفرض CGLIB على تهيئة @EnableAspectJAutoProxy مُحددة (مفيد في Spring العادي، لا Spring Boot):
مشكلة الاستدعاء الذاتي
تشترك كلتا استراتيجيتَي البروكسي في قيد لا مفرّ منه: الاستدعاء الذاتي يتجاوز البروكسي كليًا. هذا هو أكثر أخطاء AOP شيوعًا في كود الإنتاج.
حين تستدعي دالةٌ في الـ bean الخاص بك دالةً أخرى على نفس الـ bean باستخدام this، لا يمر الاستدعاء أبدًا عبر البروكسي — بل يذهب مباشرةً إلى الكائن الحقيقي. أي نصيحة مُهيَّأة لتلك الدالة الثانية تُتجاهل في صمت.
هنا، يُستدعى createOrder عبر البروكسي (تُنفَّذ النصيحة)، لكن استدعاءه الداخلي لـ save يذهب مباشرةً إلى this — كائن OrderService الحقيقي — متجاوزًا البروكسي كليًا. @Transactional على save لا تُنفَّذ أبدًا من هذا المسار البرمجي.
@Transactional، @Cacheable، @Async، @Secured، وأي جانب مخصص تكتبه. العَرَض هو أن النصيحة تُنفَّذ حين تستدعي الدالة من فئة أخرى لكنها لا تفعل شيئًا حين تُستدعى من داخل نفس الفئة.
حلول الاستدعاء الذاتي
ثلاثة أنماط عملية لكسر الاستدعاء الذاتي:
1. حقن البروكسي في نفسه (البحث في ApplicationContext)
2. الاستخراج إلى bean منفصل — الأنظف والأكثر شيوعًا. انقل save إلى bean مُدارة بـ Spring خاصة بها كـ OrderPersistenceService. كل استدعاء من OrderService يمر الآن عبر بروكسي مختلف، وتُطبَّق النصيحة بشكل صحيح. يُحسّن هذا أيضًا من تماسك الكود.
3. استخدام نسيج AspectJ في وقت التصريف أو التحميل — يُدمج AspectJ الكامل النصيحة مباشرةً في الكود البايتي. الاستدعاء الذاتي لم يعد مشكلةً لأنه لا يوجد بروكسي أصلًا. التكلفة هي تعقيد عملية البناء (النسيج في وقت التصريف يتطلب مُصرِّف AspectJ؛ النسيج في وقت التحميل يتطلب Java agent). معظم الفرق تختار الخيار الثاني بدلًا من ذلك.
التحقق من نوع البروكسي المستخدم
أثناء التصحيح يمكنك طباعة الفئة الفعلية لـ bean في Spring لتأكيد استراتيجية البروكسي المُستخدمة:
اللاحقة $$SpringCGLIB$$ تؤكد CGLIB؛ البادئة $Proxy تؤكد بروكسي JDK الديناميكي.
الخلاصة
يعمل Spring AOP بتغليف الـ beans في كائنات بروكسي في وقت التشغيل. تتطلب بروكسيات JDK الديناميكية واجهةً وهي جزء من Java القياسية؛ تُنشئ CGLIB فئةً فرعيةً من الفئة الملموسة وهي الإعداد الافتراضي في Spring Boot. تشترك كلتا استراتيجيتَي البروكسي في نفس القيد: الدالة التي تستدعي دالةً أخرى على this تتجاوز البروكسي، لذا تُتجاهل أي نصيحة على الدالة الثانية في صمت. الحل الأنظف هو نقل الدالة الثانية إلى bean منفصل حتى يعبر الاستدعاء حدود البروكسي. معرفة هذه الآلية تجعل كل ميزة Spring تعتمد على AOP — المعاملات، التخزين المؤقت، التنفيذ غير المتزامن، الأمان — قابلةً للتنبؤ والتصحيح.