🍃 Розділ 5 · Питання #13

Що таке proxy в Spring

Проксі дозволяє реалізувати концепцію AOP (Aspect-Oriented Programming) — виносити наскрізну функціональність (Cross-Cutting Concerns) за межі чистої бізнес-логіки:


🟢 Junior Level

Проксі (Proxy) у Spring — це згенерований у рантаймі об’єкт-обгортка (делегат), який перехоплює виклики методів цільового біна (Target Object), виконує наскрізну системну функціональність (відкриття транзакцій, логування, перевірка прав доступу, кешування) і лише потім передає керування реальному об’єкту.

Навіщо потрібен Proxy

Проксі дозволяє реалізувати концепцію AOP (Aspect-Oriented Programming) — виносити наскрізну функціональність (Cross-Cutting Concerns) за межі чистої бізнес-логіки:

  • @Transactional — відкриває транзакцію, фіксує її (commit) або відкочує (rollback).
  • @Async — запускає метод в окремому потоці з пулу задач.
  • @Cacheable — перевіряє наявність даних у кеші до виконання методу.
  • @Secured / @PreAuthorize — перевіряє ролі та права поточного користувача перед виконанням.
Клієнтський код (Controller)
             ↓
[ Dynamic Proxy (Обгортка) ] ──> Перевірка ролей, відкриття транзакції
             ↓
[ Target Object (Ваш Service) ] ──> Виконання бізнес-логіки
             ↓
[ Dynamic Proxy (Обгортка) ] ──> Фіксація (commit) транзакції, логування
             ↓
Повернення результату клієнту

Приклад із практики

package com.example.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    @Transactional
    public void createOrder(Long userId, Long itemId) {
        // Бізнес-логіка створення замовлення
        System.out.println("Виконання методу createOrder у реальному об'єкті");
    }
}

Коли інший компонент впроваджує OrderService:

@Autowired
private OrderService orderService; // Spring впроваджує не напряму OrderService, а згенерований PROXY!

🟡 Middle Level

Два механізми створення проксі у Spring

Spring підтримує дві технології динамічного проксіювання:

                  ┌───────────────────────────────────────────────┐
                  │          Як Spring створює Проксі?            │
                  └───────────────────────┬───────────────────────┘
                                          │
                  ┌───────────────────────┴───────────────────────┐
                  ▼                                               ▼
     ┌────────────────────────┐                      ┌────────────────────────┐
     │   JDK Dynamic Proxy    │                      │     CGLIB Proxy        │
     ├────────────────────────┤                      ├────────────────────────┤
     │ • Стандарт Java        │                      │ • Стороння бібліотека │
     │ • Лише інтерфейси      │                      │ • Через успадкування   │
     │ • Реалізує інтерфейс   │                      │ • Створює клас-нащадок │
     └────────────────────────┘                      └────────────────────────┘
Характеристика JDK Dynamic Proxy CGLIB Proxy
Базова технологія java.lang.reflect.Proxy (частина JDK) Репакетований CGLIB / ByteBuddy
Принцип роботи Генерує клас, що реалізує ті самі інтерфейси Генерує підклас-нащадок цільового класу
Вимога до цільового класу Зобов’язаний реалізовувати хоча б один інтерфейс Клас і методи не можуть бути final
Ін’єкція за типом класу ❌ Неможлива (впаде з помилкою типів) ✅ Можлива (проксі є нащадком класу)
Ім’я згенерованого класу jdk.proxy2.$Proxy123 OrderService$$SpringCGLIB$$0

Налаштування за замовчуванням: чому Spring Boot обрав CGLIB

Починаючи зі Spring Boot 2.0+ (і в Spring Boot 3.x), властивість spring.aop.proxy-target-class за замовчуванням виставлена в true.

Чому CGLIB став стандартом де-факто: У старих версіях Spring (де за замовчуванням використовувався JDK Dynamic Proxy), якщо сервіс реалізовував інтерфейс:

public interface OrderService { void process(); }

@Service
public class OrderServiceImpl implements OrderService { ... }

Спроба впровадити сервіс за конкретним типом класу реалізації:

@Autowired
private OrderServiceImpl orderService; // ПОМИЛКА: BeanNotOfRequiredTypeException!

призводила до аварійної зупинки застосунку, оскільки JDK-проксі реалізовував OrderService, але не був нащадком OrderServiceImpl. CGLIB усунув цю проблему, створюючи підклас OrderServiceImpl, що дозволяє інжектувати залежність як за інтерфейсом, так і за класом.


🔴 Senior Level

Внутрішня архітектура перехоплення (Interceptor Chain)

Усередині Spring AOP проксі є реалізацією інтерфейсу Advised:

  1. Виклик методу перехоплюється згенерованим проксі-об’єктом.
  2. Проксі запитує у фабрики ланцюжок перехоплювачів (List<MethodInterceptor>), які застосовні до цього методу (на основі Pointcut-виразів).
  3. Створюється об’єкт виклику ReflectiveMethodInvocation.
  4. Метод proceed() по черзі викликає кожен перехоплювач у ланцюжку (Chain of Responsibility):
MethodInvocation.proceed():
  1. SecurityInterceptor (перевірка прав)
  2. TransactionInterceptor (TransactionManager.getTransaction())
  3. CustomAspect (логування / метрики)
  4. Method.invoke(target, args) -> виклик вихідного бізнес-коду
  5. TransactionInterceptor (TransactionManager.commit())

Обмеження Spring AOP та приховані баги в Production

Оскільки Spring AOP працює через проксі часу виконання, він має фундаментальні архітектурні обмеження:

1. Тихий баг із final-методами у CGLIB

CGLIB генерує клас-нащадок і перевизначає методи. У Java методи з модифікатором final не можуть бути перевизначені.

@Service
public class BillingService {

    @Transactional
    public final void chargeCustomer(Long id) {
        // ПОМИЛКА: Spring Boot використовує CGLIB. 
        // Метод final НЕ може бути перевизначений проксі!
        // Виклик піде НАПРЯМУ в BillingService в обхід TransactionInterceptor!
    }
}

Увага: Компілятор не видасть помилки, Spring успішно запуститься, але метод виконуватиметься БЕЗ транзакції!

2. Проблема Self-Invocation (виклик усередині одного класу)

Коли один метод класу викликає інший метод того самого класу через this:

@Service
public class OrderService {

    public void processOrder() {
        // Виклик this.saveToDatabase() оминає проксі!
        saveToDatabase(); 
    }

    @Transactional
    public void saveToDatabase() {
        // Транзакція НЕ відкриється!
    }
}

Ключове слово this посилається на реальний цільовий об’єкт (Target), а не на Proxy. Виклик оминає ланцюжок інтерцепторів.

3. private-методи

Spring AOP не може проксіювати private-методи:

  • JDK Proxy проксіює лише методи інтерфейсів (де всі методи публічні).
  • CGLIB не може перевизначити private-методи батьківського класу.
  • Анотації @Transactional, @Async та @Cacheable на private-методах ігноруються.

Програмна інспекція проксі в коді

Для роботи з проксі-об’єктами Spring надає утилітарні класи AopUtils та AopProxyUtils:

import org.springframework.aop.support.AopUtils;
import org.springframework.aop.framework.AopProxyUtils;

public void inspectBean(Object bean) {
    boolean isProxy = AopUtils.isAopProxy(bean);           // true, якщо об'єкт є будь-яким AOP-проксі
    boolean isCglib = AopUtils.isCglibProxy(bean);         // true, якщо CGLIB
    boolean isJdk = AopUtils.isJdkDynamicProxy(bean);      // true, якщо JDK Dynamic Proxy
    
    // Отримати реальний базовий клас сутності без проксі-обгорток
    Class<?> targetClass = AopUtils.getTargetClass(bean);
    
    // Отримати вихідний сирий об'єкт (unproxied target)
    Object targetObject = AopProxyUtils.getSingletonTarget(bean);
}

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

Проксі у Spring — це об’єкт-посередник, що обгортає бін для декларативного впровадження наскрізної функціональності (AOP: транзакції, кешування, асинхронність, безпека). У Spring є два типи проксі:

  1. JDK Dynamic Proxy: генерується на основі інтерфейсів через java.lang.reflect.Proxy.
  2. CGLIB Proxy: генерується на основі створення підкласу (успадкування). У Spring Boot 2.x/3.x за замовчуванням використовується CGLIB (proxy-target-class=true). Проксі перехоплює виклики через ланцюжок MethodInterceptor. Не працює для private-методів, final-методів (у CGLIB) та при внутрішніх викликах (self-invocation).

4 каверзних питання з відповідями

1. Чому в Spring Boot за замовчуванням увімкнено CGLIB (proxy-target-class=true), а не JDK Dynamic Proxy?

Відповідь: Щоб уникнути помилок невідповідності типів (BeanNotOfRequiredTypeException). При використанні JDK-проксі об’єкт реалізує лише інтерфейси і не є екземпляром конкретного класу. Тому ін’єкція за типом реалізації (@Autowired MyServiceImpl) падає. CGLIB створює підклас цільового класу, що дозволяє інжектувати залежність як за інтерфейсом, так і за класом.

2. Що станеться, якщо розмістити @Transactional на final-методі в сервісі на Spring Boot?

Відповідь: Spring Boot використовує CGLIB, який перевизначає методи в підкласі. Оскільки метод оголошено як final, перевизначити його неможливо. У рантаймі виклик методу піде напряму в оригінальний об’єкт, оминаючи інтерцептор транзакцій. Транзакція не відкриється, при цьому жодних винятків викинуто не буде (тихий дефект).

3. Чому Spring AOP не перехоплює private-методи?

Відповідь: Spring AOP базується на динамічному проксіюванні під час виконання. JDK Proxy реалізує інтерфейси (у яких немає приватних методів), а CGLIB успадковує клас і не бачить приватних методів суперкласу для їх перевизначення. Якщо потрібне перехоплення private-методів, необхідно використовувати повноцінний AspectJ (Compile-Time / Load-Time Weaving).

4. Як визначити, що бін є проксі-об’єктом, і як отримати його реальний клас?

Відповідь: За допомогою утиліти AopUtils.isAopProxy(bean). Щоб дізнатися реальний клас цільового об’єкта без суфіксів $$SpringCGLIB$$, використовується AopUtils.getTargetClass(bean).


Червоні прапорці (чого категорично не можна говорити)

  • ❌ «Spring обгортає в проксі абсолютно кожен створений бін» — проксі створюються лише для бінів, що підпадають під Pointcut аспектів (@Transactional, @Async, кастомні @Aspect).
  • ❌ «CGLIB без проблем проксіює final-класи та методи» — у Java успадкування від final-класів та перевизначення final-методів заборонено JVM на рівні байт-коду.
  • ❌ «Self-invocation можна вирішити переведенням проєкту з CGLIB на JDK Proxy» — self-invocation ламає перехоплення в обох типах проксі, оскільки this завжди вказує на цільовий об’єкт, а не на проксі.
  • ❌ «JDK Dynamic Proxy працює швидше за CGLIB, тому йому завжди треба надавати перевагу» — у сучасних версіях Java/ByteBuddy різниця у швидкості виклику нівелюється JIT-компілятором, а CGLIB позбавляє від проблем типізації при впровадженні.

Пов’язані теми