1. Dwa rodzaje proxy w Spring
Gdy nowicjusz słyszy „proxy", często wyobraża sobie coś w rodzaju „magicznego obiektu, który Spring podrzucił". Ale w praktyce proxy — to po prostu zwykły obiekt Javy, a w Javie są dwa podstawowe sposoby na zrobienie „obiektu-wrappera". Jeden sposób opiera się na interfejsach (i jest wbudowany w JDK), drugi — opiera się na klasach (i konceptualnie przypomina dziedziczenie z nadpisywaniem metod). Spring potrafi korzystać z obu, a od tego, który dokładnie proxy się pojawił, zależą twoje oczekiwania: jakiego kontraktu możesz użyć i gdzie czeka cię „niespodzianka" w postaci nieoczekiwanego typu obiektu.
Żeby trzymać obrazek w głowie, warto jeszcze raz narysować minimalny przepływ wywołania:
%% Minimalna schemat: wywołanie idzie przez proxy do target (to właśnie cała idea)
flowchart LR
Caller["Kod wywołujący"] --> P["Proxy (wrapper)"]
P --> T["Target object (obiekt źródłowy)"]
Dalej cały wykład — o tym, jak dokładnie wygląda blok Proxy w Javie w dwóch różnych wariantach.
2. JDK dynamic proxy: proxy przez interfejsy
JDK dynamic proxy — to w zasadzie „oficjalna" technologia wbudowana w Javę, która pozwala w runtime stworzyć obiekt, implementujący jeden lub więcej interfejsów, i przechwytywać wywołania metod tych interfejsów przez specjalny handler. To nie jest specyfika Springa, to część standardowej biblioteki (java.lang.reflect.Proxy). Ważne, żeby od razu zapamiętać prostą zasadę: JDK proxy działa tylko przez interfejsy. Jeśli masz dobry kontrakt interfejsowy — to bardzo wygodna i przejrzysta ścieżka, która świetnie pasuje do stylu DI „zależy od abstrakcji".
Budowa JDK proxy „ręcznie"
JDK proxy tworzy się przez Proxy.newProxyInstance(...). Podajesz mu:
1) ClassLoader (zwykle od interfejsu),
2) listę interfejsów, które proxy ma implementować,
3) handler wywołań (InvocationHandler) — funkcję, która otrzyma Method i argumenty i zdecyduje, co zrobić.
W naszym ContextFlow mamy klasyczny port interfejsowy — NotificationSender. Weźmy obiekt docelowy (target) i opakujmy go obiektem proxy, który wydrukuje „before/after" wokół wysyłki.
import java.lang.reflect.Proxy;
import com.example.contextflow.domain.ports.NotificationSender;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;
// Target: prawdziwa implementacja, która wykonuje robotę
NotificationSender target = new ConsoleNotificationSender();
// Proxy: obiekt-wrapper, który wygląda jak NotificationSender,
// ale w rzeczywistości przechwytuje wywołania przez InvocationHandler
NotificationSender proxy = (NotificationSender) Proxy.newProxyInstance(
NotificationSender.class.getClassLoader(), // Bierzemy ClassLoader interfejsu
new Class<?>[]{NotificationSender.class}, // Mówimy: proxy implementuje ten interfejs
(p, method, args) -> {
// Tu możemy dodać techniczne opakowanie PRZED wywołaniem
System.out.println("[before] " + method.getName()); // [before] send
// Delegujemy wywołanie do prawdziwego target
Object result = method.invoke(target, args);
// I dodajemy opakowanie PO wywołaniu
System.out.println("[after] " + method.getName()); // [after] send
return result; // Zwracamy wynik do kodu wywołującego
}
);
// Ważne: kod wywołujący pracuje z proxy jak ze zwykłym NotificationSender
proxy.send("Order created"); // SEND: Order created
Tu ważne jest, żeby zrozumieć nie składnię (tę można „podejrzeć"), lecz model. Wywołanie proxy.send("Order created") nie trafia od razu do ConsoleNotificationSender. Najpierw trafia do handlera, a handler już decyduje „czy delegować dalej". Jeśli deleguje — wywołuje method.invoke(target, args).
Jeśli teraz czujesz „to prawie jak dekorator!", to jesteś na właściwej drodze. Tak, znaczeniowo to jest bardzo bliskie wzorcowi Decorator, tylko zamiast ręcznej klasy-wrappera używasz mechanizmu JDK, który tworzy klasę proxy za ciebie.
Interfejsy w Spring: kiedy są przydatne
W ContextFlow wiele kluczowych punktów zmienności jest wyrażonych właśnie interfejsami: NotificationSender, AuditWriter, DiscountPolicy, OrderStore, OrderIdGenerator. Nie dlatego, że „w Springu tak się robi", lecz dlatego, że to są prawdziwe granice architektoniczne między aplikacją a infrastrukturą/strategiami. I to automatycznie czyni JDK proxy naturalnym kandydatem: działa idealnie, gdy kod wywołujący komunikuje się z zależnością przez interfejs.
Ale ważne, żeby nie popaść w „interfejs na każdy kichnięcie". Jeśli zrobisz interfejs do każdej klasy tylko dlatego, że „a nuż proxy", dostaniesz projekt, w którym nawigacja po kodzie będzie wyglądała jak wycieczka przez labirynt: „implementacja" w jednym miejscu, „interfejs" w innym, a sensu — mało. W naszym projekcie interfejsy — to porty i strategie, a nie „dekoracja".
Niuans, który warto zauważyć
JDK proxy implementuje interfejs(y), ale nie musi być instancją konkretnej klasy implementacji. Czyli zachowujesz kontrakt interfejsu, ale „dokładna klasa" może okazać się nieoczekiwana. Przez to niemal od razu pojawia się praktyczne pytanie: co dokładnie pokażą getClass() i instanceof, gdy w zmiennej leży proxy, a nie znana implementacja.
3. Class-based proxy: proxy przez klasę
Czasem chcesz opakować obiekt, ale interfejsu nie ma. Albo interfejs jest, ale historycznie zależy od klasy (bywa w legacy, bywa w przykładach edukacyjnych, bywa „bo tak wyszło"). W takim przypadku pojawia się drugie podejście: proxy „przez klasę". Konceptualnie buduje się wokół dziedziczenia: proxy — to podklasa, która nadpisuje metody i dodaje zachowanie przed/po wywołaniu oryginalnej logiki. Spring w rzeczywistości generuje takie proxy dynamicznie (przez narzędzia bytecode), ale teraz ważna jest zasada, a nie kuchnia generowania klas.
Żeby poczuć to na własnej skórze, zróbmy maksymalnie uczciwą „ręczną" ilustrację na klasie bez interfejsu. Niech będzie serwis, który generuje ciąg znaków raportu (uproszczone — „report-as-string", jak we wczesnych stadiach projektu).
public class ReportingService {
public String generateDailyReport() {
// Tu jest „prawdziwa" logika serwisu (uproszczona)
return "daily-report";
}
}
Teraz „wrapper przez klasę" będzie wyglądał jak podklasa z nadpisaniem:
public class TimedReportingService extends ReportingService {
@Override
public String generateDailyReport() {
// Techniczne opakowanie: pomiar czasu PRZED wywołaniem
long started = System.nanoTime();
// Ważne: wywołujemy oryginalną implementację przez super
String result = super.generateDailyReport();
// Techniczne opakowanie: logujemy PO wywołaniu
System.out.println("Took: " + (System.nanoTime() - started)); // Took: 12345
return result;
}
}
I użycie pozostaje „przez typ bazowy", jak przystało polimorfizmowi:
// Referencja typu bazowego, ale prawdziwy obiekt — podklasa (proxy przez klasę)
ReportingService service = new TimedReportingService();
System.out.println(service.generateDailyReport()); // daily-report
Sens class-based proxy: nadal trzymasz referencję typu ReportingService, ale prawdziwy obiekt — to potomek, który dodaje zachowanie.
Kiedy potrzebny jest class-based proxy
Bardzo wygodnie byłoby powiedzieć: „zawsze rób interfejsy, a będzie szczęście". Ale Spring żyje w świecie, w którym:
- są klasy legacy bez interfejsów (i nie można ich szybko przepisać);
- są klasy zewnętrzne, których nie kontrolujesz;
- są klasy, gdzie kontrakt interfejsowy jest sztuczny i tylko utrudnia czytanie.
Dlatego Spring potrafi tworzyć class-based proxy. Ale za ten komfort płacisz tym, że dziedziczenie ma zasady i ograniczenia Javy. To właśnie one potem decydują, które metody można naprawdę owijać, a których nie. Na razie ważne jest tylko utrwalenie: jeśli proxy buduje się przez klasę, konceptualnie „przypomina podklasę".
4. Wybór proxy w ContextFlow
W wyjaśnieniach edukacyjnych często chcemy wybrać „właściwą odpowiedź na wszystkie przypadki". Ale w inżynierii prawdziwa „właściwa odpowiedź" prawie zawsze brzmi: „zależy". W ContextFlow celowo budowaliśmy architekturę tak, żeby kluczowe punkty zależności były wyrażone interfejsami. Daje to elastyczność zarówno dla DI, jak i dla testowalności, i (jako bonus) dla interfejsowych proxy. Dlatego praktyczna rekomendacja jest prosta: tam, gdzie masz port/strategię, trzymaj się interfejsu; tam, gdzie masz prosty wewnętrzny serwis bez zmienności, nie musisz robić interfejsu „na wszelki wypadek".
Spójrz na przykład z projektu (bardzo typowy dla Springa i bardzo użyteczny dla proxy-myślenia). Serwis zależy nie od ConsoleNotificationSender, lecz od kontraktu NotificationSender:
import org.springframework.stereotype.Service;
import com.example.contextflow.domain.ports.NotificationSender;
@Service
public class NotificationDispatchService {
// Zależność przez interfejs: to kluczowy moment dla JDK proxy i DI w ogóle
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
// Wstrzyknięcie zależności przez konstruktor:
// kontener może podłożyć zarówno prawdziwą implementację, jak i wrapper proxy
this.sender = sender;
}
}
Dlaczego to ważne właśnie w kontekście proxy? Ponieważ jeśli kontener (lub warstwa infrastrukturalna) zwróci ci JDK proxy, będzie „widziany" jako NotificationSender. I wszystko gra. A gdybyś chciał zależeć od konkretnego ConsoleNotificationSender, mocno przywiązałbyś się do klasy — i każdy interfejsowy wrapper mógłby być problemem.
Przy tym nie namawiamy do robienia „interfejsu dla OrderPricingService tylko dlatego, że jest service". Wewnętrzne serwisy aplikacji mogą spokojnie pozostawać klasami, i jeśli kiedykolwiek pojawi się wokół nich class-based proxy — to też normalnie. Po prostu ważne jest, żeby rozumieć, co się zyskuje w zamian: JDK proxy lepiej dogaduje się z zależnościami interfejsowymi, class-based proxy — z zależnościami przez klasę.
Porównanie JDK proxy i class-based proxy
Gdy w głowie jednocześnie żyją terminy „proxy", „target", „interfejsy", „dziedziczenie", „kontener", mózg czasem próbuje wziąć urlop bez ostrzeżenia. Tabela poniżej — to nie „kujon", lecz ściągawka, do której warto wracać, gdy czytasz cudzy kod albo dziwny stack trace. Teraz chodzi o utrwalenie kluczowych różnic właśnie na poziomie zachowania i wymagań, bez bytecodu i wnętrzności Springa.
| Kryterium | JDK dynamic proxy | Class-based proxy |
|---|---|---|
| Na czym opiera się kontrakt | Na interfejsach | Na klasie (i jej metodach) |
| Co potrzebne, żeby stworzyć proxy | Potrzebny co najmniej jeden interfejs | Interfejs nie jest wymagany |
| Jak przechwytywane są wywołania | Przez InvocationHandler (refleksja Method) | Konceptualnie przez nadpisanie (w Spring — przez wygenerowaną podklasę) |
| Jakiego typu „wygodniej" używać w DI | NotificationSender | ReportingService |
| Co zwykle dobrze projektować tak w projekcie | Porty i strategie (*Sender, *Policy, *Store) | Wewnętrzne serwisy bez interfejsów, klasy legacy |
| Najczęstszy błąd myślowy | „Można zrobić JDK proxy dla klasy bez interfejsu" | „To po prostu obiekt tej samej klasy, nic się nie zmieniło" |
Ta tabela wystarczy, żeby dalej nie mylić dwóch rodzin proxy i rozumieć, dlaczego Spring „nie mógł wybrać jednego wariantu i żyć spokojnie". Wybrałby — ale prawdziwy świat nie pozwolił.
5. Mini-praktyka bez Springa
Bardzo łatwo przeczytać o proxy i udawać, że „no tak, rozumiem". A potem po raz pierwszy zobaczyć Proxy.newProxyInstance — i poczuć, że Java nagle zaczęła przypominać film szpiegowski. Dlatego warto zrobić małą praktykę w stylu „na kolanach, ale uczciwie": zbudować dwa wrappery, jeden — JDK dynamic proxy dla interfejsu, drugi — class-based proxy przez dziedziczenie. Na razie ważne są same formy proxy, bez ingerencji kontenera.
Wyobraźmy sobie, że chcemy wygodnie dostawać „opakowanego nadawcę powiadomień". Można zrobić małą fabrykę (w naszym projekcie mogłoby to żyć gdzieś w support.*, ale teraz ważniejsza jest forma, a nie dokładne miejsce).
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("Took: " + (System.nanoTime() - started));
return result;
}
);
}
}
I użycie wygląda prosto i „przez interfejs":
import com.example.contextflow.domain.ports.NotificationSender;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;
NotificationSender sender =
NotificationSenderProxies.withTiming(new ConsoleNotificationSender());
sender.send("Order cancelled"); // SEND: Order cancelled
Teraz „podobna" praktyka dla class-based proxy — to po prostu tworzenie podklasy (jak już pokazywaliśmy):
ReportingService service = new TimedReportingService();
System.out.println(service.generateDailyReport()); // daily-report
W obu przypadkach dostałeś wrapper wokół wywołania metody. Ale „punkt oparcia" jest różny: w jednym przypadku stoisz na interfejsie, w drugim — na klasie. I ta różnica będzie nam stale towarzyszyć w kodzie Spring.
6. Typowe błędy przy wyborze modelu proxy
Błąd nr 1: oczekiwać JDK proxy tam, gdzie nie ma interfejsu.
To klasyczna pułapka: „no przecież mogę opakować obiekt… czemu nie można zrobić proxy?". Można, ale JDK dynamic proxy umie być proxy tylko dla interfejsów. Jeśli interfejsu nie ma, Java nie będzie mogła stworzyć obiektu „tego samego typu" bez uciekania się do dziedziczenia/generowania podklasy. Praktyczny wniosek jest prosty: jeśli chcesz model interfejsowy — daj mu kontrakt interfejsowy, ale rób to tam, gdzie kontrakt naprawdę jest potrzebny.
Błąd nr 2: zależeć od konkretnej klasy tam, gdzie wystarczy interfejs.
W kodzie DI wygląda to niewinnie: „no przecież mam dokładnie ConsoleNotificationSender, po co mi twój NotificationSender?". A potem nagle pojawia się wrapper i zależność od konkretnej klasy zamienia się w problem. W ContextFlow celowo robiliśmy zależności przez porty-interfejsy, żeby nie wiązać warstwy aplikacji z detalami infrastruktury i żeby takie wrappery nie łamały kodu.
Błąd nr 3: próbować „dodać zachowanie" i przypadkowo przenieść logikę biznesową do proxy.
Proxy — to złe miejsce dla reguł biznesowych. Dziś dodałeś logowanie, jutro — „trochę sprawdzimy status zamówienia", pojutrze — „tu przeliczmy zniżkę"… i oto masz dwa źródła logiki biznesowej: w target i w proxy. W efekcie debugowanie zamienia się w grę „zgadnij, gdzie naprawdę zaszło działanie". Proxy powinno robić techniczne opakowanie i zostawiać sens targetowi.
Błąd nr 4: pisać kod proxy tak, że staje się bardziej skomplikowany niż target.
Szczególnie widać to w InvocationHandler: można bardzo szybko zamienić go w potwora z masą if, obsługą dziesiątek metod i ręcznym rozwiązywaniem wszystkiego. Jeśli opakowanie staje się skomplikowane, to sygnał, że albo wybrałeś zły poziom abstrakcji, albo potrzebujesz bardziej ustrukturyzowanego mechanizmu niż „jedna lambda na wszystkie przypadki". W przykładach edukacyjnych trzymamy kod proxy krótkim i przewidywalnym — i to dobry nawyk.
Błąd nr 5: zapominać, że class-based proxy opiera się na nadpisywaniu metod.
Na poziomie idei wszystko wydaje się proste: „zrobiliśmy dziedzica — nadpisaliśmy metodę — owinęliśmy". Ale w Javie są ograniczenia: nie wszystkiego można nadpisać, nie wszystkiego można „zobaczyć" przez dziedziczenie, i są wywołania, które w ogóle omijają zewnętrzny wrapper. To nie „kaprysy Springa", lecz reguły języka. Dlatego model proxy ma realne granice i ważne jest, żeby mieć je z tyłu głowy zawczasu, a nie dziwić się im już w trakcie debugowania.
GO TO FULL VERSION