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

كيف يعمل Spring AOP (البروكسيات)

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

كيف يعمل Spring AOP (البروكسيات)

عاملت كل الدروس السابقة Spring AOP كصندوق أسود: تُزيّن فئتك بـ @Aspect، تكتب بعض النصائح، وتقوم Spring بطريقة ما باعتراض استدعاءات الدوال التي حددتها. هذا الدرس يفتح ذلك الصندوق الأسود. إن فهم استراتيجيتَي البروكسي اللتين تستخدمهما Spring — بروكسيات JDK الديناميكية وبروكسيات CGLIB — والفخ الذي يخلقانه في حالة استدعاء الدالة لنفسها سيجنّبك أخطاء خفية يصعب جدًا تشخيصها دون هذه المعرفة.

الفكرة الأساسية: كائنات البروكسي

لا تُعدّل Spring AOP كودك البايتي في وقت التصريف (ذاك هو أسلوب النسيج في AspectJ). بدلًا من ذلك، تُغلّف Spring في وقت التشغيل الـ bean الخاص بك في كائن بروكسي — فئة مولَّدة تُنفّذ نفس الواجهة (أو ترث من نفس الفئة الملموسة) الخاصة بالكائن الأصلي. حين يطلب مُستدعٍ من حاوية Spring الخاص بـ OrderService، يحصل على البروكسي لا الكائن الحقيقي. يعترض البروكسي كل استدعاء، يُنفّذ أي نصيحة مُطابقة، ثم يُفوّض إلى الكائن الحقيقي المخفي بداخله.

النموذج الذهني الأساسي: البروكسي هو الـ bean من منظور كل مُستدعٍ. كائن التنفيذ الحقيقي يجلس بداخل البروكسي، غير مرئي للعالم الخارجي. تُنفَّذ النصيحة في طبقة البروكسي، قبل الدالة الحقيقية و/أو بعدها.

الاستراتيجية الأولى: بروكسيات JDK الديناميكية

بروكسيات JDK الديناميكية مدمجة في مكتبة Java القياسية (java.lang.reflect.Proxy). تعمل بتوليد فئة في وقت التشغيل تُنفّذ قائمة واجهات مُحددة. تُوجّه الفئة المولَّدة كل استدعاء دالة عبر InvocationHandler — تُزوّد Spring بمعالجها الخاص الذي يُطبّق النصيحة ثم يستدعي الدالة الحقيقية عبر الانعكاس.

الشرط: يجب أن يُنفّذ الـ bean المستهدف واجهةً واحدةً على الأقل. يُنفّذ البروكسي تلك الواجهة؛ يحتفظ المُستدعون بمرجع مُكتّب بنوع الواجهة.

// الواجهة — يُنفّذها بروكسي JDK public interface PaymentService { void process(Order order); } // التنفيذ الحقيقي — تُغلّفه Spring @Service public class PaymentServiceImpl implements PaymentService { @Override public void process(Order order) { /* ... */ } } // المُستدعي — يستلم بروكسي JDK، لا PaymentServiceImpl @Component public class CheckoutFacade { private final PaymentService paymentService; // هو في الواقع Proxy$N في وقت التشغيل public CheckoutFacade(PaymentService paymentService) { this.paymentService = paymentService; } }

لأن البروكسي يُنفّذ الواجهة المُصرَّح بها فحسب، لا يمكن للمُستدعين تحويله (cast) إلى PaymentServiceImpl. أي محاولة ترمي ClassCastException في وقت التشغيل — خطأ شائع حين يحاول أحدهم استدعاء دالة ملموسة غير مُدرجة في الواجهة.

الاستراتيجية الثانية: بروكسيات CGLIB

حين لا يُنفّذ الـ bean واجهةً — أو حين تُهيّئ proxyTargetClass = true — تلجأ Spring إلى CGLIB (مكتبة توليد الكود). تُولّد CGLIB فئةً فرعيةً من فئتك الملموسة في وقت التشغيل وتتجاوز دواليَّها لإدراج سلسلة النصائح. لأنها فئة فرعية، يمكن لـ CGLIB عمل بروكسي لأي فئة غير نهائية دون الحاجة لواجهة.

// بلا واجهة — تستخدم Spring Boot CGLIB تلقائيًا @Service public class InventoryService { public void reserve(String sku, int qty) { /* ... */ } }

يجعل Spring Boot 2+ من بروكسيات CGLIB الإعداد الافتراضي لجميع الـ beans (يضبط spring.aop.proxy-target-class=true افتراضيًا). ستجد إذن بروكسيات CGLIB في كل مكان في تطبيق Spring Boot نموذجي، حتى للـ beans التي تمتلك واجهات.

قيود CGLIB التي يجب تذكّرها:
  • يجب ألا تكون الفئة أو أي دالة مُوكَّلة بها final — لا يمكن توريث الفئة النهائية أو الدالة النهائية، لذا لا تستطيع CGLIB اعتراضها.
  • تُنشئ CGLIB نسخة من الفئة الفرعية وتحتاج إلى مُنشئ بلا معاملات (أو مُنشئ يمكن لـ Spring إشباعه عبر الحقن). إذا أضفت مُنشئًا بمعاملات مطلوبة وحذفت النسخة بلا معاملات، قد تفشل Spring في إنشاء البروكسي في الإعدادات القديمة. يُزيل Spring 6 مع Objenesis هذا القيد في معظم الحالات، لكن من المفيد معرفته.

الاختيار بين الاستراتيجيتين

الجانب بروكسي JDK الديناميكي بروكسي CGLIB
يتطلب واجهة نعم لا
يعمل مع الفئات final لا (يجب أن يكون واجهة) لا
يعمل مع الدوال final غير مُطبَّق — ليست في البروكسي لا — لا يمكن تجاوزها
الافتراضي في Spring Boot لا (منذ Boot 2) نعم
تحويل المُستدعي للنوع الملموس غير ممكن ممكن (فئة فرعية)

لفرض بروكسيات JDK على مستوى المشروع بالكامل، أضف في application.properties:

spring.aop.proxy-target-class=false

لفرض CGLIB على تهيئة @EnableAspectJAutoProxy مُحددة (مفيد في Spring العادي، لا Spring Boot):

@Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) public class AopConfig { }

مشكلة الاستدعاء الذاتي

تشترك كلتا استراتيجيتَي البروكسي في قيد لا مفرّ منه: الاستدعاء الذاتي يتجاوز البروكسي كليًا. هذا هو أكثر أخطاء AOP شيوعًا في كود الإنتاج.

حين تستدعي دالةٌ في الـ bean الخاص بك دالةً أخرى على نفس الـ bean باستخدام this، لا يمر الاستدعاء أبدًا عبر البروكسي — بل يذهب مباشرةً إلى الكائن الحقيقي. أي نصيحة مُهيَّأة لتلك الدالة الثانية تُتجاهل في صمت.

@Service public class OrderService { @Transactional // تعمل بشكل جيد عند الاستدعاء من الخارج public void createOrder(Order order) { save(order); // تستدعي this.save() — تتجاوز البروكسي! } @Transactional(readOnly = true) // لا تُطبَّق أبدًا عند الاستدعاء من createOrder() public Order save(Order order) { // ... return order; } }

هنا، يُستدعى createOrder عبر البروكسي (تُنفَّذ النصيحة)، لكن استدعاءه الداخلي لـ save يذهب مباشرةً إلى this — كائن OrderService الحقيقي — متجاوزًا البروكسي كليًا. @Transactional على save لا تُنفَّذ أبدًا من هذا المسار البرمجي.

هذا هو الفخ الأول في AOP. يؤثر على كل ميزة Spring تعتمد على البروكسي: @Transactional، @Cacheable، @Async، @Secured، وأي جانب مخصص تكتبه. العَرَض هو أن النصيحة تُنفَّذ حين تستدعي الدالة من فئة أخرى لكنها لا تفعل شيئًا حين تُستدعى من داخل نفس الفئة.

حلول الاستدعاء الذاتي

ثلاثة أنماط عملية لكسر الاستدعاء الذاتي:

1. حقن البروكسي في نفسه (البحث في ApplicationContext)

@Service public class OrderService implements ApplicationContextAware { private ApplicationContext ctx; @Override public void setApplicationContext(ApplicationContext ctx) { this.ctx = ctx; } @Transactional public void createOrder(Order order) { // اطلب من Spring البروكسي — ستُنفَّذ النصيحة على save() الآن ctx.getBean(OrderService.class).save(order); } @Transactional(readOnly = true) public Order save(Order order) { /* ... */ return order; } }

2. الاستخراج إلى bean منفصل — الأنظف والأكثر شيوعًا. انقل save إلى bean مُدارة بـ Spring خاصة بها كـ OrderPersistenceService. كل استدعاء من OrderService يمر الآن عبر بروكسي مختلف، وتُطبَّق النصيحة بشكل صحيح. يُحسّن هذا أيضًا من تماسك الكود.

3. استخدام نسيج AspectJ في وقت التصريف أو التحميل — يُدمج AspectJ الكامل النصيحة مباشرةً في الكود البايتي. الاستدعاء الذاتي لم يعد مشكلةً لأنه لا يوجد بروكسي أصلًا. التكلفة هي تعقيد عملية البناء (النسيج في وقت التصريف يتطلب مُصرِّف AspectJ؛ النسيج في وقت التحميل يتطلب Java agent). معظم الفرق تختار الخيار الثاني بدلًا من ذلك.

التحقق من نوع البروكسي المستخدم

أثناء التصحيح يمكنك طباعة الفئة الفعلية لـ bean في Spring لتأكيد استراتيجية البروكسي المُستخدمة:

@Component public class ProxyInspector implements ApplicationRunner { @Autowired private OrderService orderService; @Override public void run(ApplicationArguments args) { System.out.println(orderService.getClass().getName()); // JDK: com.sun.proxy.$Proxy42 // CGLIB: com.example.OrderService$$SpringCGLIB$$0 } }

اللاحقة $$SpringCGLIB$$ تؤكد CGLIB؛ البادئة $Proxy تؤكد بروكسي JDK الديناميكي.

الخلاصة

يعمل Spring AOP بتغليف الـ beans في كائنات بروكسي في وقت التشغيل. تتطلب بروكسيات JDK الديناميكية واجهةً وهي جزء من Java القياسية؛ تُنشئ CGLIB فئةً فرعيةً من الفئة الملموسة وهي الإعداد الافتراضي في Spring Boot. تشترك كلتا استراتيجيتَي البروكسي في نفس القيد: الدالة التي تستدعي دالةً أخرى على this تتجاوز البروكسي، لذا تُتجاهل أي نصيحة على الدالة الثانية في صمت. الحل الأنظف هو نقل الدالة الثانية إلى bean منفصل حتى يعبر الاستدعاء حدود البروكسي. معرفة هذه الآلية تجعل كل ميزة Spring تعتمد على AOP — المعاملات، التخزين المؤقت، التنفيذ غير المتزامن، الأمان — قابلةً للتنبؤ والتصحيح.