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 нс
}
}
Що тут відбулося концептуально:
- ConsoleNotificationSender — target: він робить «справжню роботу».
- TimingNotificationSenderProxy — proxy: він додав обгортку навколо виклику.
- 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 майже завжди намагається лишатися в межах контракту. Якщо контракту немає або ви його ігноруєте, замість гарної обгортки ви отримуєте «танці з кастами» та крихкість.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ