JavaRush /Курси /Spring Core /JDK і class-based proxy

JDK і class-based proxy

Spring Core
Рівень 21 , Лекція 1
Відкрита

1. Два види proxy в Spring

Коли новачок чує «proxy», він часто уявляє щось на кшталт «магічного об’єкта, який Spring підклав». Але на практиці proxy — це просто звичайний Java-об’єкт. У Java є два базові способи зробити «об’єкт-обгортку»: один спирається на інтерфейси (і вбудований у JDK), інший — на класи (і концептуально нагадує успадкування з перевизначенням методів). Spring уміє користуватися обома, а отже від того, який саме proxy зʼявився, залежать ваші очікування: який контракт ви зможете використовувати і де на вас чекатиме «сюрприз» у вигляді неочікуваного типу об’єкта.

Щоб тримати картинку в голові, корисно ще раз намалювати мінімальний потік виклику:

%% Мінімальна схема: виклик іде через proxy до target (це і є вся ідея)
flowchart LR
    Caller["Код, що викликає"] --> P["Proxy (обгортка)"]
    P --> T["Target object (вихідний об’єкт)"]

Далі вся лекція — про те, як саме виглядає блок Proxy в Java у двох різних варіантах.

2. JDK dynamic proxy: проксі за інтерфейсами

JDK dynamic proxy — це, по суті, офіційна вбудована в Java технологія, яка дає змогу під час виконання створити об’єкт, що реалізує один або кілька інтерфейсів, і перехоплювати виклики методів цих інтерфейсів через спеціальний обробник. Це не Spring-специфіка, а частина стандартної бібліотеки (java.lang.reflect.Proxy). Важливо одразу запам’ятати просте правило: JDK proxy працює тільки через інтерфейси. Якщо у вас є хороший інтерфейсний контракт, це дуже зручний і прозорий шлях, який чудово лягає на DI-стиль «залежить від абстракцій».

Будова JDK proxy «вручну»

JDK proxy створюється через Proxy.newProxyInstance(...). Ви передаєте йому:

1) ClassLoader (зазвичай — від інтерфейсу),

2) список інтерфейсів, які proxy має реалізовувати,

3) обробник викликів (InvocationHandler) — функцію, яка отримає Method та аргументи й вирішить, що робити.

У нашому ContextFlow є класичний інтерфейсний порт — NotificationSender. Візьмімо цільовий об’єкт (target) і обгорнемо його proxy-об’єктом, який друкуватиме «before/after» навколо надсилання.

import java.lang.reflect.Proxy;
import com.example.contextflow.domain.ports.NotificationSender;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;

// Target: реальна реалізація, яка виконує роботу
NotificationSender target = new ConsoleNotificationSender();

// Proxy: об’єкт-обгортка, який виглядає як NotificationSender,
// але насправді перехоплює виклики через InvocationHandler
NotificationSender proxy = (NotificationSender) Proxy.newProxyInstance(
        NotificationSender.class.getClassLoader(),              // Беремо ClassLoader інтерфейсу
        new Class
  []{NotificationSender.class},               // Кажемо: проксі реалізує цей інтерфейс
        (p, method, args) -> {
            // Тут ми можемо додати технічну обгортку ДО виклику
            System.out.println("[до] " + method.getName()); // [до] send

            // Делегуємо виклик у справжній target
            Object result = method.invoke(target, args);

            // І додаємо обгортку ПІСЛЯ виклику
            System.out.println("[після] " + method.getName());  // [після] send
            return result;                                      // Повертаємо результат коду, що викликає
        }
);

// Важливо: код, що викликає, працює з proxy як зі звичайним NotificationSender
proxy.send("Створено замовлення"); // SEND: Створено замовлення

Тут важливо зрозуміти не синтаксис (його можна «підглянути»), а модель. Виклик proxy.send("Створено замовлення") не йде одразу в ConsoleNotificationSender. Спочатку він потрапляє в обробник, а вже обробник вирішує, чи делегувати далі. Якщо делегує — викликає method.invoke(target, args).

Якщо ви зараз ловите відчуття «це ж майже декоратор!», то ви на правильному шляху. Так, за змістом це дуже близько до патерна Decorator, тільки замість ручного класу-обгортки ви використовуєте механізм JDK, який створює proxy-клас за вас.

Інтерфейси в Spring: коли корисні

У ContextFlow багато ключових точок варіативності оформлено саме інтерфейсами: NotificationSender, AuditWriter, DiscountPolicy, OrderStore, OrderIdGenerator. Це не тому, що «в Spring так заведено», а тому, що це реальні архітектурні межі між застосунком та інфраструктурою або стратегіями. І це автоматично робить JDK proxy природним кандидатом: він ідеально працює, коли код, що викликає, спілкується із залежністю через інтерфейс.

Але важливо не скочуватися в «інтерфейс на кожен чих». Якщо ви зробите інтерфейс для кожного класу просто заради «а раптом проксі», ви отримаєте проєкт, у якому навігація по коду буде схожа на екскурсію лабіринтом: «реалізація» в одному місці, «інтерфейс» — в іншому, а сенсу мало. У нашому проєкті інтерфейси — це порти і стратегії, а не декорація.

Нюанс, який варто помітити

JDK proxy реалізує інтерфейс(и), але не зобов’язаний бути екземпляром конкретного класу реалізації. Тобто у вас зберігається контракт інтерфейсу, але «точний клас» може виявитися неочікуваним. Через це майже одразу виникає практичне питання: що саме покажуть getClass() та instanceof, коли в змінній лежить proxy, а не знайома реалізація.

3. Class-based proxy: проксі за класом

Іноді хочеться обгорнути об’єкт, але інтерфейсу в нього немає. Або інтерфейс є, але історично ви залежите від класу (так буває в legacy, так буває в навчальних прикладах, так буває «бо так склалося»). У такому разі з’являється другий підхід: proxy «за класом». Концептуально він будується навколо успадкування: proxy — це підклас, який перевизначає методи та додає поведінку до або після виклику оригінальної логіки. Spring у реальності генерує такі проксі динамічно (через bytecode-інструменти), але нам зараз важливий принцип, а не внутрішня кухня генерації класів.

Щоб відчути це руками, зробімо максимально чесну «ручну» ілюстрацію на класі без інтерфейсу. Нехай у нас є сервіс, який генерує рядок звіту (спрощено — «report-as-string», як на ранніх етапах нашого проєкту).

public class ReportingService {

    public String generateDailyReport() {
        // Тут міститься "справжня" логіка сервісу (спрощено)
        return "daily-report";
    }
}

Тепер «обгортка за класом» буде виглядати як підклас із перевизначенням:

public class TimedReportingService extends ReportingService {

    @Override
    public String generateDailyReport() {
        // Технічна обгортка: вимірюємо час ДО виклику
        long started = System.nanoTime();

        // Важливо: викликаємо оригінальну реалізацію через super
        String result = super.generateDailyReport();

        // Технічна обгортка: логуємо ПІСЛЯ виклику
        System.out.println("Час виконання: " + (System.nanoTime() - started)); // Час виконання: 12345
        return result;
    }
}

І використання залишається «за базовим типом», як і належить поліморфізму:

// Посилання базового типу, але реальний об’єкт — підклас (proxy за класом)
ReportingService service = new TimedReportingService();

System.out.println(service.generateDailyReport()); // daily-report

Сенс class-based proxy: ви й далі тримаєте посилання типу ReportingService, але реальний об’єкт — нащадок, який додає поведінку.

Коли потрібен class-based proxy

Було б дуже зручно сказати: «завжди робіть інтерфейси, і буде вам щастя». Але Spring живе у світі, де:

- є legacy-класи без інтерфейсів (і їх не можна швидко переписати);

- є сторонні класи, які ви не контролюєте;

- є класи, де інтерфейсний контракт штучний і лише заважає читанню.

Тому Spring уміє робити class-based proxy. Але за цей комфорт ви платите тим, що в успадкування є правила й обмеження Java. Саме вони потім і визначають, які методи реально можна обгорнути, а які — ні. Поки нам важливо лише зафіксувати: якщо proxy будується за класом, він концептуально «схожий на підклас».

4. Вибір proxy у ContextFlow

У навчальних поясненнях часто хочеться вибрати «правильну відповідь на всі випадки». Але в інженерії реальна «правильна відповідь» майже завжди звучить так: «залежить». У ContextFlow ми спеціально будували архітектуру так, щоб ключові залежні точки були виражені інтерфейсами. Це дає нам гнучкість і для DI, і для тестованості, і (як бонус) для інтерфейсних proxy. Тому практична рекомендація тут проста: там, де у вас порт/стратегія, тримайтеся за інтерфейс; там, де у вас простий внутрішній сервіс без варіативності, не обов’язково робити інтерфейс «про всяк випадок».

Подивіться на приклад із проєкту (він дуже типовий для Spring і дуже корисний для proxy-мислення). Сервіс залежить не від ConsoleNotificationSender, а від контракту NotificationSender:

import org.springframework.stereotype.Service;
import com.example.contextflow.domain.ports.NotificationSender;

@Service
public class NotificationDispatchService {

    // Залежність через інтерфейс: це ключовий момент для JDK proxy і DI загалом
    private final NotificationSender sender;

    public NotificationDispatchService(NotificationSender sender) {
        // Впровадження залежності через конструктор:
        // контейнер може підкласти як реальну реалізацію, так і proxy-обгортку
        this.sender = sender;
    }
}

Чому це важливо саме в контексті proxy? Тому що якщо контейнер (або інфраструктурний шар) поверне вам JDK proxy, то він буде «видимий» як NotificationSender. І все чудово. А якщо б ви захотіли залежати від конкретного ConsoleNotificationSender, ви б жорстко прив’язалися до класу — і будь-яка інтерфейсна обгортка могла б стати проблемою.

При цьому я не закликаю робити «інтерфейс для OrderPricingService тільки тому, що він service». Внутрішні application-сервіси цілком можуть залишатися класами, і якщо колись навколо них зʼявиться class-based proxy, це теж нормально. Просто важливо розуміти, що ви отримуєте взамін: JDK proxy краще дружить з інтерфейсними залежностями, class-based proxy — із залежностями від класу.

Порівняння JDK proxy і class-based proxy

Коли в голові одночасно живуть терміни «proxy», «target», «інтерфейси», «успадкування», «контейнер», мозок іноді намагається піти у відпустку без попередження. Таблиця нижче — це не «зубрилка», а шпаргалка, до якої корисно повертатися, коли ви читаєте чужий код або дивний stack trace. Зараз нам важливо зафіксувати ключові відмінності саме на рівні поведінки та вимог, без байткоду й внутрішніх механізмів Spring.

Критерій JDK dynamic proxy Class-based proxy
На чому тримається контракт На інтерфейсах На класі (і його методах)
Що потрібно, щоб зробити proxy Потрібен хоча б один інтерфейс Інтерфейс не обов’язковий
Як перехоплюються виклики Через InvocationHandler (рефлексія Method) Концептуально через перевизначення (у Spring — через згенерований підклас)
Який тип «зручніше» використовувати в DI NotificationSender ReportingService
Що зазвичай варто проєктувати саме так Порти та стратегії (*Sender, *Policy, *Store) Внутрішні сервіси без інтерфейсів, legacy-класи
Найчастіша помилка мислення «Можна зробити JDK proxy для класу без інтерфейсу» «Це просто об’єкт того самого класу, нічого не змінилося»

Цієї таблиці достатньо, щоб далі не плутати два сімейства proxy і розуміти, чому Spring «не міг вибрати один варіант і жити спокійно». Він би вибрав — але реальний світ не дозволив.

5. Мініпрактика без Spring

Дуже легко прочитати про proxy й зробити вигляд, що «ну так, зрозуміло». А потім уперше побачити Proxy.newProxyInstance — і відчути, що Java раптом стала схожою на фільм про шпигунів. Тому корисно зробити маленьку практику в стилі «на коліні, але чесно»: зібрати дві обгортки, одну — JDK dynamic proxy для інтерфейсу, другу — class-based proxy через успадкування. Поки нам важливі самі форми proxy, без втручання контейнера.

Уявімо, що ми хочемо зручно отримувати «обгорнутий відправник повідомлень». Можна зробити маленьку фабрику (у нашому проєкті це могло б жити десь у support.*, але зараз важливіша форма, а не точне місце).

import java.lang.reflect.Proxy;
import com.example.contextflow.domain.ports.NotificationSender;

public class NotificationSenderProxies {

    public static NotificationSender withTiming(NotificationSender target) {
        return (NotificationSender) Proxy.newProxyInstance(
                NotificationSender.class.getClassLoader(),
                new Class
  []{NotificationSender.class},
                (p, method, args) -> {
                    long started = System.nanoTime();
                    Object result = method.invoke(target, args);
                    System.out.println("Час виконання: " + (System.nanoTime() - started));
                    return result;
                }
        );
    }
}

І використання виглядає просто й «за інтерфейсом»:

import com.example.contextflow.domain.ports.NotificationSender;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;

NotificationSender sender =
        NotificationSenderProxies.withTiming(new ConsoleNotificationSender());

sender.send("Замовлення скасовано"); // SEND: Order cancelled

Тепер «схожа» практика для class-based proxy — це просто створення підкласу (як ми вже показували):

ReportingService service = new TimedReportingService();

System.out.println(service.generateDailyReport()); // daily-report

В обох випадках ви отримали обгортку навколо виклику методу. Але «точка опори» різна: в одному випадку ви стоїте на інтерфейсі, в іншому — на класі. І цю відмінність ми далі постійно зустрічатимемо в Spring-коді.

6. Типові помилки під час вибору proxy-моделі

Помилка №1: очікувати JDK proxy там, де немає інтерфейсу.
Це класична пастка: «ну я ж можу обгорнути об’єкт… чому не можна просто зробити proxy?». Можна, але JDK dynamic proxy уміє бути proxy тільки для інтерфейсів. Якщо інтерфейсу немає, Java не зможе створити об’єкт «того самого типу», не вдаючись до успадкування або генерації підкласу. Практичний висновок простий: якщо ви хочете інтерфейсну модель — дайте їй інтерфейсний контракт, але робіть це там, де контракт справді потрібен.

Помилка №2: залежати від конкретного класу там, де достатньо інтерфейсу.
У DI-коді це виглядає нешкідливо: «ну в мене ж точно ConsoleNotificationSender, навіщо мені ваш NotificationSender?». А потім раптом зʼявляється обгортка, і залежність від конкретного класу перетворюється на проблему. У ContextFlow ми спеціально будували залежності через порти-інтерфейси, щоб не прив’язувати application-шар до деталей інфраструктури й щоб такі обгортки не ламали код.

Помилка №3: намагатися «додати поведінку» і випадково перенести бізнес-логіку в proxy.
Proxy — погане місце для бізнес-правил. Сьогодні ви додали логування, завтра — «трохи перевіримо статус замовлення», післязавтра — «давайте тут же перерахуємо знижку»… і ось у вас два джерела бізнес-логіки: в target і в proxy. У результаті налагодження перетворюється на гру «вгадай, де саме відбулася дія». Proxy має робити технічну обгортку і залишати сенс target’у.

Помилка №4: писати proxy-код так, що він стає складнішим за target.
Особливо це помітно в InvocationHandler: можна дуже швидко перетворити його на монстра з купою if, обробкою десятка методів і ручним розв’язанням усього підряд. Якщо обгортка стає складною, це сигнал, що ви або обрали не той рівень абстракції, або вам потрібен більш структурований механізм, ніж «одна лямбда на всі випадки життя». У навчальних прикладах ми тримаємо proxy-код коротким і передбачуваним — і це добра звичка.

Помилка №5: забувати, що class-based proxy спирається на перевизначення методів.
На рівні ідеї все здається простим: «зробили нащадка — перевизначили метод — обгорнули». Але в Java є обмеження: не все можна перевизначити, не все можна «побачити» через успадкування, і є виклики, які взагалі обходять зовнішню обгортку. Це не «капризи Spring», а правила мови. Тому в proxy-моделі є реальні межі, і важливо тримати їх у голові заздалегідь, а не дивуватися їм уже під час дебагу.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ