Proxy і target object: базова ідея

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

1. Proxy для технічної обгортки

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

Давайте уявимо фрагмент нашого ContextFlow. Є контракт NotificationSender і є реалізація ConsoleNotificationSender, яка просто виводить повідомлення в консоль. У якийсь момент нам захотілося для діагностики показувати ще й час, який забрала відправка. У реальному житті це міг би бути справжній email або SMS, а не консоль.

Найпрямолінійніший шлях для новачка виглядає так: додати вимірювання часу просто всередину ConsoleNotificationSender. Це працює, але швидко псує життя. По-перше, ми змішали бізнес-дію «відправити повідомлення» з технічним завданням «виміряти час». По-друге, якщо у нас три відправники (Email/SMS/Console), ми або копіюємо вимірювання тричі, або починаємо зводити спільний базовий клас, а потім дивуємося, чому «просто повідомлення» раптом успадковуються від абстрактної технічної бази.

Ще гірше, коли вимірювання часу починають вставляти в сервіс, який викликає відправника, наприклад у NotificationDispatchService. Тоді сервіс перетворюється на шаруватий пиріг із «справжньої логіки» та «обгортки», а друга частина з часом розповзається на аудит, статистику, логування, повторні спроби та інші «радощі» дорослого застосунку.

Proxy зʼявляється як дуже спокійна відповідь: не змінювати target object — той, хто справді вміє відправляти повідомлення, — а поставити перед ним обгортку, яка перехопить виклик і додасть поведінку до нього і після нього.

2. Proxy і target object: ролі

Слова proxy і target звучать грізно рівно доти, доки ви не перекладете їх людською мовою. Уявіть, що target object — це співробітник, який справді виконує роботу, наприклад пише звіт або відправляє повідомлення. А proxy — це, умовно, секретар або турнікет на вході: він першим зустрічає виклик, може щось перевірити, щось записати, увімкнути секундомір, а потім усе одно пропускає вас до співробітника. Секретар не виконує роботу співробітника. Він робить те, що має відбуватися навколо роботи, але не має жити в кожному бізнес-методі.

У термінах коду це виглядає так:

  • target object — це «справжня» реалізація, у якій міститься основна логіка.
  • proxy — це обʼєкт-обгортка, який для коду, що викликає, виглядає як той самий контракт (зазвичай той самий інтерфейс), але всередині делегує виклик target object, додаючи поведінку навколо.

Корисно один раз зафіксувати це у вигляді таблиці — вона часто рятує голову, коли ви дивитеся в налагоджувач і бачите «дивний клас», який ви не писали. А ви писали, просто не впізнаєте його в гримі.

Роль Що робить Що не має робити
target object Містить основну логіку, наприклад справді відправляє повідомлення Не має бути перевантажений службовою обгорткою «про всяк випадок»
proxy Перехоплює виклик, додає поведінку до/після, делегує виклик target-обʼєкту Не має «підміняти сенс» бізнес-логіки й перетворюватися на другий бізнес-сервіс

І тут важливий психологічний момент: proxy — не «магічний двійник», не «покращена версія класу», не «ще один шар Spring заради Spring». Proxy — просто інший обʼєкт, який опинився між вами та target object.

3. Межа proxy: шлях виклику

Щоб proxy перестав бути абстракцією, достатньо одного маршруту виклику: код, що викликає, звертається не безпосередньо до target object, а до обгортки перед ним. Proxy працює лише там, де виклик справді проходить через нього. Якщо виклик обминув proxy, жодного «до/після» не станеться.

flowchart LR
    %% Важливо: proxy перебуває на шляху виклику й першим отримує звернення від коду, що викликає
    Caller["Код, що викликає (наприклад, сервіс)"]
    Proxy["proxy (обгортка)"]
    Target["target object (основна логіка)"]

    Caller -->|виклик методу| Proxy
    Proxy -->|делегування| Target
    Proxy -->|повернення| Caller

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

4. Демонстрація на Java: NotificationSender

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

Спершу оголосимо контракт і просту реалізацію. Це буде наш target object.

// Контракт: код, що викликає, працює з інтерфейсом, щоб proxy міг "вдавати" той самий тип.
public interface NotificationSender {

    // Бізнес-сенс методу простий: "відправити повідомлення".
    void send(String message);
}
// Target object: реальна реалізація відправлення (тут — просто в консоль).
public class ConsoleNotificationSender implements NotificationSender {

    @Override
    public void send(String message) {
        // Тут немає обгортки — лише "справжня робота".
        System.out.println("НАДІСЛАНО: " + message); // НАДІСЛАНО: Замовлення створено
    }
}

Тепер зробимо proxy, який додасть замір часу навколо виклику. Зверніть увагу на принцип: proxy реалізує той самий інтерфейс, що й target, і всередині тримає посилання на target. Він не «знає», як відправляти повідомлення — він знає лише, як обгорнути виклик.

// Proxy: додає службову поведінку (замір часу) навколо реального виклику.
public class TimingNotificationSenderProxy implements NotificationSender {

    // Target object, якому ми делегуємо реальну роботу.
    private final NotificationSender target;

    public TimingNotificationSenderProxy(NotificationSender target) {
        // Важливо: proxy зберігає посилання на target, а не наслідується від нього.
        this.target = target;
    }

    @Override
    public void send(String message) {
        // До виклику: починаємо вимірювання часу.
        long started = System.nanoTime();

        // Головна дія: делегування target-обʼєкту.
        target.send(message);

        // Після виклику: фіксуємо час і записуємо службову метрику.
        long finished = System.nanoTime();
        System.out.println("Тривалість: " + (finished - started) + " нс");
    }
}

І тепер найсмачніше та найважливіше для ментальної моделі: код, що викликає, працює з інтерфейсом NotificationSender і взагалі не зобовʼязаний знати, proxy там чи не proxy.

public class ProxyDemo {

    public static void main(String[] args) {
        // Тип посилання — інтерфейс: коду, що викликає, байдуже, proxy це чи target.
        NotificationSender sender =
                // Ззовні ми "запаковуємо" target у proxy.
                new TimingNotificationSenderProxy(new ConsoleNotificationSender());

        // Виклик іде в proxy, а вже він вирішує, що зробити до/після і коли покликати target.
        sender.send("Замовлення створено");
        // НАДІСЛАНО: Замовлення створено
        // Тривалість: 12345 нс
    }
}

Що тут відбулося концептуально:

  • ConsoleNotificationSendertarget: він робить «справжню роботу».
  • TimingNotificationSenderProxyproxy: він додав обгортку навколо виклику.
  • ProxyDemo — код, що викликає: він тримає посилання типу NotificationSender і викликає send() як завжди.

Якщо ви зараз подумки заміните ConsoleNotificationSender на EmailNotificationSender, а замір часу — на перевірку прав або логування, ви отримаєте той самий патерн. Це дуже універсальна річ, просто зазвичай у великих фреймворках її не пишуть вручну щоразу, а автоматизують.

І ось тут починаються хороші новини для контейнера: коли ви збираєте застосунок через Spring, він опиняється в ідеальній позиції, щоб централізовано підсунути вам такий proxy до залежностей. Але перш ніж іти далі, треба закріпити дисципліну.

Proxy не має «розумнішати» за target. На емоціях легко зробити таку «допомогу»:

// Поганий proxy: формально це обгортка, але вона втручається в бізнес-сенс повідомлення.
public class BadProxyNotificationSender implements NotificationSender {

    private final NotificationSender target;

    public BadProxyNotificationSender(NotificationSender target) {
        this.target = target;
    }

    @Override
    public void send(String message) {
        // Антипатерн: proxy тихо змінює дані, з якими працює бізнес-логіка.
        target.send("[ВАЖЛИВО] " + message); // змінюємо сенс повідомлення!
    }
}

Формально це працює. Практично — ви сховали бізнес-рішення («як формувати повідомлення») у технічний шар. За тиждень ви забудете, що десь дописується префікс, і будете шукати «хто псує рядки» з обличчям детектива з дешевого серіалу. Proxy має залишатися зоною «службової обгортки», а не тихим місцем, де живуть раптові бізнес-правила.

5. Proxy у контейнері: користь

Якщо подивитися на все це очима Spring, стає зрозуміло, чому proxy — не примха і не «складність заради складності». Контейнер якраз займається тим, що створює обʼєкти, зберігає посилання на них і роздає їх іншим обʼєктам як залежності. Тобто він буквально контролює, хто кому передає посилання. А отже, він може вирішити: «Я створю target object, але замість нього віддаватиму proxy, який делегуватиме виклик target-обʼєкту».

Це дає кілька дуже практичних наслідків.

По-перше, контейнер отримує можливість додавати поведінку, не змінюючи ваш вихідний клас. Ви не лізете в ConsoleNotificationSender, не переписуєте ReportingService, не додаєте до кожного методу таймер. Ваші класи залишаються простими POJO, а технічні завдання розвʼязуються зовні.

По-друге, додавання поведінки стає централізованим. Якщо ви хочете вмикати або вимикати обгортку, ви робите це в одному місці — в інфраструктурі — а не бігаєте по всьому проєкту. У навчальному ContextFlow це здається невеликою вигодою, але в реальності перетворюється на порятунок: якщо логування, замір часу чи перевірки прав розмазано по сотні сервісів, то «змінити формат логів» перетворюється на археологічну експедицію.

По-третє, зберігається контракт. В ідеальному світі код, що викликає, і далі бачить той самий інтерфейс NotificationSender, той самий ReportOperations, той самий контракт сервісного шару. Він не знає, що перед ним обгортка, і це нормально. Для розробника важливіше, щоб контракт залишався стабільним, а додаткову поведінку можна було додавати як інфраструктуру.

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

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

Якщо хочеться зовсім приземлити це на ContextFlow, то в нашій архітектурі вже є хороші передумови: ми залежимо від інтерфейсів (NotificationSender, AuditWriter, DiscountPolicy), а сервіси отримують залежності через конструктори. Це означає, що обгортку можна підсунути ззовні — і бізнес-сервіс навіть не дізнається, що спілкується з proxy. І це одна з причин, чому контрактність і конструкторне впровадження такі важливі: proxy-модель погано дружить із хаотичним «давайте все зробимо конкретними класами і new-ами всередині методів».

6. Типові помилки під час першого знайомства з proxy

Помилка № 1: вважати proxy «покращеною версією target object» і чекати від нього бізнес-логіки.
Коли новачок уперше бачить слово «proxy», хочеться думати, що це «просунутий обʼєкт», який «уміє більше». Через це зʼявляються спроби вкладати туди бізнес-рішення: змінювати текст повідомлення, обирати канал сповіщення, вирішувати, кому відправляти. Правильна думка протилежна: proxy має бути нудним, як бухгалтерія. Він робить службове і не втручається в сенс.

Помилка № 2: плутати «обʼєкт, який я написав», і «обʼєкт, який я тримаю в руках».
У Java ми звикли: new ConsoleNotificationSender() — отже, у мене точно ConsoleNotificationSender. З proxy-моделлю це перестає бути гарантією на рівні контейнера: ви думаєте, що у вас «сервіс», а в руках може опинитися «обгортка над сервісом». Поки ми не обговорюємо деталі getClass() та instanceof, але вже зараз корисно прийняти факт: контейнер може повернути не той самий клас, який ви очікуєте побачити, і так і має бути.

Помилка № 3: робити proxy, який не делегує виклик target object.
Звучить смішно, але на практиці трапляється: «Я зробив proxy для логів», а потім усередині методу забули викликати target.method(). У результаті proxy починає жити власним життям і перетворюється на підроблений сервіс. Гарна звичка: коли пишете ручний proxy, спочатку напишіть рядок делегування, а вже потім додавайте код «до/після». Так менше шансів забути головну дію.

Помилка № 4: намагатися лікувати погану архітектуру proxy-обгорткою.
Proxy — потужний інструмент, але це не «чарівний пластир» від усіх проблем. Якщо у вас у NotificationDispatchService пʼять відповідальностей, proxy не зробить його чистим. Якщо у вас у домені безлад, proxy не додасть ясності. Proxy допомагає відокремити технічну поведінку навколо виклику, але не замінює нормальну декомпозицію класів і зрозумілі контракти.

Помилка № 5: привʼязатися до конкретного класу замість контракту і тим самим ускладнити життя proxy-моделі.
Навіть без деталей про види proxy можна сказати безпечне правило: якщо ви проєктуєте код так, що він постійно вимагає «саме ConsoleNotificationSender», а не «NotificationSender», ви самі собі зачиняєте двері. Proxy майже завжди намагається лишатися в межах контракту. Якщо контракту немає або ви його ігноруєте, замість гарної обгортки ви отримуєте «танці з кастами» та крихкість.

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