JavaRush /Курсы /Spring Core /Границы proxy: final

Границы proxy: final, private, this

Spring Core
21 уровень , 3 лекция
Открыта

1. Граница proxy: что перехватывается

Когда в первый раз знакомишься с proxy, есть соблазн думать: “О, теперь я могу оборачивать любые вызовы любых методов как захочу”. Это ощущение похоже на то, как новичок ставит брейкпоинт и на секунду верит, что теперь он контролирует вселенную. Реальность спокойнее: proxy перехватывает только те вызовы, которые реально проходят через него, то есть пересекают границу “вызывающий код → proxy”.

Давайте зафиксируем простую модель. Представьте proxy как охранника на входе в офис: если вы заходите через дверь, охранник вас видит. Если же вы пробрались через вентиляцию (внутренний вызов внутри объекта), охранник может быть очень хорошим специалистом — но он буквально не в курсе, что вы уже внутри.

sequenceDiagram
    participant C as "Caller (клиент)"
    participant P as Proxy
    participant T as Target

    C->>P: "placeOrder()"
    P->>T: "placeOrder()"
    T->>T: "saveOrder() (this.saveOrder)"
    T-->>P: return
    P-->>C: return

Здесь proxy увидел только placeOrder(), потому что именно этот вызов пришёл “снаружи”. А внутренний this.saveOrder() произошёл внутри target: proxy там не стоял, в этот момент “дверь офиса” вообще не использовалась.

Для дальнейшего понимания полезно держать в голове очень практичную формулу: proxy перехватывает только внешние вызовы через proxy-ссылку. Всё, что “внутри target”, может оказаться вне зоны видимости.

2. final class и proxy по классу

final в Java — это такая печать “не трогать, я так решил”. Иногда она оправдана, но в сервисных классах чаще появляется по привычке или из эстетики: “пусть никто не наследуется, я люблю порядок”. Проблема в том, что class-based proxy (который мы обсуждали во 2-й лекции) очень часто строится через наследование: proxy становится подклассом target и переопределяет методы.

А final class в Java нельзя расширить. Вообще. Никак. И тут Spring уже не “виноват”: это ограничение языка. Если контейнеру нужен proxy именно по классу (а такое бывает, когда у бина нет интерфейса или когда wiring завязан на конкретный класс), то final превращается в железную дверь без ключа.

Простейшая иллюстрация (компилируется, потому что мы не пытаемся наследоваться реально, только показываем идею):

package com.example.contextflow.application.service;

public final class FinalAuditService {
    // Важно: final-класс нельзя расширить, значит class-based proxy физически не сможет стать подклассом.
    public void write(String message) {
        // Тут любая "обёртка" через override невозможна, потому что не будет самого подкласса.
        System.out.println("AUDIT: " + message);
    }

    // class-based proxy не сможет сделать так:
    // class TimedAuditService extends FinalAuditService { ... } // не скомпилируется
}

В проекте ContextFlow это важно по очень приземлённой причине: application-сервисы (OrderPlacementService, OrderCancellationService, ReportingService) обычно не делают интерфейсы “на каждый класс”, потому что интерфейс ради интерфейса — это не победа. Но отсутствие интерфейса автоматически повышает шанс, что при необходимости “обернуть” сервис контейнер будет вынужден использовать proxy по классу.

Поэтому здоровая привычка в Spring-мире звучит почти скучно: не делайте @Service-классы final без реальной причины. Намного полезнее и безопаснее делать final поля зависимостей и держать класс как обычный.

3. final method и перехват в proxy

Если final class — это запрет на наследование, то final method — запрет на переопределение конкретного метода. И здесь ситуация похожая: в proxy по классу контейнер добавляет поведение, переопределяя метод и оборачивая вызов super.method(). Но final-метод переопределить нельзя, а значит обернуть “честно через override” — тоже нельзя.

Это часто удивляет, потому что выглядит как мелочь: “Ну подумаешь, final на одном методе”. Но для proxy-модели это буквально табличка “вход закрыт”.

Посмотрим на мини-пример:

package com.example.contextflow.application.reporting;

public class ReportService {
    // Важно: final-метод нельзя override-ить, значит class-based proxy не сможет "обернуть" вызов generate().
    public final String generate() {
        return "daily-report";
    }

    // proxy по классу не сможет переопределить generate()
}

Важно аккуратно проговорить нюанс (без ухода в дебри). Если proxy построен по интерфейсу (JDK dynamic proxy), то он вообще не обязан переопределять методы target-класса: он перехватывает вызов на уровне интерфейса и делегирует в target через reflection. Поэтому final на методе target сам по себе не “магический стоп-кран” для интерфейсного proxy. Но в реальном Spring-приложении вам не всегда удаётся гарантировать, что будет именно интерфейсный proxy: многое зависит от того, есть ли интерфейс, как вы инжектите зависимости (по интерфейсу или по классу), и какую proxy-стратегию выбирает инфраструктура.

Для ContextFlow практический вывод такой: не ставьте final на методах сервисного слоя, если нет очень серьёзной причины. В обычном учебном (и большинстве рабочих) приложений это не даёт ощутимой пользы, но может стать неожиданной миной под контейнерной механикой.

4. private методы и точки перехвата

С private всё даже проще и логичнее, чем с final. Private-метод — это не “контракт”, это внутренняя деталь класса. Он недоступен извне и не участвует в обычной модели полиморфизма: подкласс не может переопределить private-метод родителя, потому что он его даже “не видит” как точку расширения. И снова: никакой мистики Spring, просто правила Java.

Давайте соберём пример, который выглядит очень по-человечески (и очень по-джуновски): “хочу спрятать шаги внутрь, чтобы внешний метод был красивым”.

package com.example.contextflow.application.service;

public class BillingService {
    public void placeOrder() {
        // Внешний метод может быть перехвачен proxy (если вызов пришёл через proxy-ссылку).
        validate();
        System.out.println("placeOrder done");
    }

    private void validate() {
        // Private-метод — внутренняя кухня: снаружи его не вызвать, override невозможен.
        System.out.println("validate ok");
    }
}

Если у вас есть proxy вокруг placeOrder(), он может перехватить placeOrder() как внешний вызов. Но он не сможет “отдельно” перехватить validate(), потому что validate() вызывается внутри target, да ещё и является private. Proxy не может “залезть внутрь” и подменить этот вызов.

Иногда студенты пытаются “вылечить” это тем, что делают validate() публичным. Технически это действительно создаёт точку входа, но почти всегда ухудшает дизайн: вы открываете наружу метод, который вообще-то не должен быть частью API класса. Нормальный инженерный путь здесь другой: если validate или любая другая часть логики должна быть отдельно оборачиваемой/расширяемой, то чаще всего это сигнал, что ей место в отдельном компоненте (например, OrderValidator), а не в private-методе.

В ContextFlow как раз принято держать доменную логику тонкой и выносить “настраиваемые” части в отдельные сервисы/порты (DiscountPolicy, NotificationSender, AuditWriter). Это решение полезно не только для DI, но и для прозрачности границ, где контейнер может что-то оборачивать.

5. Внутренние вызовы через this

Теперь самое коварное ограничение, потому что оно выглядит как “ну я же просто вызвал метод”. Внутренний вызов this.someMethod() происходит внутри target object. А proxy — это внешний объект-обёртка. В результате вызов не пересекает границу proxy и не может быть перехвачен. Это явление часто называют self-invocation (самовызов), но по сути это просто “вызов метода внутри того же объекта”.

Чтобы почувствовать это кожей, сделаем маленький демонстрационный пример на чистой Java. Он не про Spring напрямую, зато показывает механику максимально честно.

Сначала контракт и реализация:

package com.example.contextflow.support.proxydemo;

public interface OrderWorkflow {
    // Контракт: proxy будет перехватывать именно вызовы методов интерфейса.
    void create();
    void save();
}
package com.example.contextflow.support.proxydemo;

public class SimpleOrderWorkflow implements OrderWorkflow {
    @Override
    public void create() {
        System.out.println("create: start");
        // Ключевой момент: это внутренний вызов внутри target-объекта, proxy его не увидит.
        this.save();
        System.out.println("create: end");
    }

    @Override
    public void save() {
        System.out.println("save");
    }
}

Теперь делаем proxy по интерфейсу и логируем “до/после”:

package com.example.contextflow.support.proxydemo;

import java.lang.reflect.Proxy;

public class ProxyBoundaryDemo {
    public static void main(String[] args) {
        // Это "реальный" объект (target), который будет исполнять логику.
        OrderWorkflow target = new SimpleOrderWorkflow();

        // Это JDK dynamic proxy: он перехватывает внешние вызовы, которые приходят через proxy-ссылку.
        OrderWorkflow proxy = (OrderWorkflow) Proxy.newProxyInstance(
                OrderWorkflow.class.getClassLoader(),
                new Class<?>[]{OrderWorkflow.class},
                (obj, method, a) -> {
                    // Обёртка "до" вызова.
                    System.out.println("before " + method.getName());
                    // Делегирование в target (внутри этого invoke target может вызывать this.* — и это уже не через proxy).
                    Object result = method.invoke(target, a);
                    // Обёртка "после" вызова.
                    System.out.println("after " + method.getName());
                    return result;
                }
        );

        // Важно: вызываем через proxy, иначе не будет перехвата вообще.
        proxy.create();
    }
}

Если вы мысленно выполните этот код, получится примерно такой вывод:

before create
create: start
save
create: end
after create

Обратите внимание на самое важное: нет before save и after save. Хотя save() реально был вызван. Он просто был вызван изнутри target, через this.save(), поэтому proxy не увидел этот вызов.

И вот это — ключ к пониманию “границы proxy”. Proxy не является “магическим перехватчиком всех JVM-вызовов”. Он перехватывает только то, что проходит через него как через объект.

Как это выглядит в ContextFlow

В нашем проекте этот принцип можно встретить в очень бытовой форме. Например, если бы OrderPlacementService попытался сделать себе “красивую” внутреннюю архитектуру через вызовы this.* , то любые “обёртки” на уровне контейнера могли бы увидеть только внешний метод, но не внутренние шаги.

Сравните два стиля. Плохой не потому, что он не работает, а потому что он делает границы невидимыми:

package com.example.contextflow.application.service;

public class OrderPlacementService {
    public void placeOrder() {
        // Внутренние шаги вызываются через this -> это остаётся внутри target и может быть невидимо для proxy.
        this.calculateTotal();
        this.writeAudit();
    }

    void calculateTotal() { /* ... */ }
    void writeAudit() { /* ... */ }
}

Более здоровый для DI и контейнерной модели — делегировать шаги отдельным компонентам:

package com.example.contextflow.application.service;

import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {
    private final OrderPricingService pricingService;

    public OrderPlacementService(OrderPricingService pricingService) {
        // Зависимость внедряется контейнером: на этом месте может приехать уже proxy.
        this.pricingService = pricingService;
    }

    public void placeOrder() {
        // Вызов идёт через ссылку pricingService -> это пересекает границу proxy (если он есть).
        pricingService.price();
    }
}

Здесь OrderPricingService — отдельный bean. И если (по каким-то причинам) контейнер вернёт вам pricingService уже как proxy, то вызов pricingService.price() пересечёт границу proxy и будет наблюдаемым. А this.calculateTotal() — нет.

Название механизма здесь не принципиально. Важно другое: proxy видит вызовы через ссылку, а внутренний вызов — это вызов “внутри объекта”.

6. Таблица и мини-диагностика

Таблица ограничений

После нескольких примеров полезно собрать всё в одну компактную карту, чтобы голова не держала это как разрозненные “факты”. Таблица ниже нарочно простая: она не претендует на полный справочник, но отлично помогает студенту не путать причины и последствия.

Ограничение Почему это ограничение вообще существует Сильнее всего бьёт по Что делать в ContextFlow
final class нельзя наследоваться от класса class-based proxy не делайте @Service-классы final без крайней нужды; опирайтесь на интерфейсы там, где это естественно
final method нельзя переопределить метод class-based proxy избегайте final на методах сервисного слоя; используйте final на полях, а не на поведении
private method private — не точка расширения и не внешний контракт class-based proxy и вообще “перехват внутренних шагов” не пытайтесь сделать private-метод “точкой инфраструктурного поведения”; при необходимости выделяйте отдельный компонент
this.someMethod() вызов происходит внутри target, не через proxy любая proxy-модель не стройте важные шаги сценария как внутренние вызовы ради “красоты”; лучше делегировать в другие beans/коллабораторы

Заметьте, что везде повторяется одна мысль: proxy живёт на границе, а ограничения — это всего лишь разные способы “не пересечь” эту границу.

Мини-диагностика

Когда у вас в приложении появляется поведение, которое “как будто должно было сработать, но не сработало”, самый частый диагноз — вызов не прошёл через proxy. И здесь полезно иметь пару простых проверок, которые не требуют ни героизма, ни чтения исходников Spring.

Для интерфейсных proxy можно быстро проверить, что перед вами вообще proxy-класс:

import java.lang.reflect.Proxy;

// bean — это ваш объект из контейнера (например, внедрённый Spring bean).
boolean isJdkProxy = Proxy.isProxyClass(bean.getClass());
System.out.println("isJdkProxy = " + isJdkProxy); // например: true

А для внутреннего вызова самый честный тест — вывести логи “до/после” вокруг внешнего метода, а затем убедиться, что внутренний шаг не оборачивается. Мы это сделали в примере ProxyBoundaryDemo: save() вызвался, но “before/after” вокруг него не сработали.

Внутри ContextFlow это превращается в очень спокойную привычку: когда вы сомневаетесь, “тот ли объект у меня в руках”, вы не спорите с реальностью, а спрашиваете у неё: getClass(), instanceof, и где именно находится вызов — снаружи или внутри target.

7. Типичные ошибки при работе с границами proxy

Ошибки в этой теме почти всегда не про “я не выучил аннотацию”, а про ожидания. Человек видит красивую идею “обёртка вокруг метода”, а затем начинает бессознательно ожидать, что эта обёртка может видеть вообще всё. Поэтому типичные проблемы обычно возникают не на уровне синтаксиса, а на уровне mental model: где проходит граница и что именно вы вызываете в рантайме.

Ошибка №1: делать сервисы final, а потом удивляться, что контейнер не может их обернуть.
В обычном Java-коде final кажется хорошей защитой от наследования. В контейнерном мире это иногда превращается в бетонную плиту на дороге: proxy по классу физически не может появиться. В сервисном слое почти всегда достаточно сделать final зависимости (поля), а не класс.

Ошибка №2: ставить final на метод “для дисциплины”, не понимая, что метод становится непереопределяемым.
Если метод — потенциальная точка, где контейнеру нужно добавить поведение вокруг вызова, final просто запрещает этот механизм. И даже если прямо сейчас вам “ничего не надо”, вы закладываете ограничение в архитектуру без пользы. Для учебного (и большинства рабочих) сервисов это обычно лишнее.

Ошибка №3: пытаться перехватывать private-методы или делать их публичными ради proxy.
Private-метод — это внутренняя кухня класса. Proxy не обязан (и чаще всего не может) “стоять у вас на кухне и комментировать каждое движение”. Делать private-метод публичным ради прокси — это как вынести кастрюлю с пловом в коридор, чтобы охранник увидел, что вы мешаете рис. Если нужна отдельная точка поведения — выделяйте отдельный компонент.

Ошибка №4: строить сценарий из внутренних вызовов this.* и ожидать, что proxy увидит каждый шаг.
Внутренний вызов не пересекает границу proxy, поэтому обёртки вокруг внутреннего метода не сработают. Это не баг, а прямое следствие того, что proxy — внешний объект. Если вам важно, чтобы шаги были “внешними” и проходили через границу, разделяйте ответственность на отдельные beans/классы и вызывайте их через внедрённые зависимости.

Ошибка №5: лечить непонимание границы proxy догадками вместо диагностики.
Когда что-то не срабатывает, новичок иногда начинает менять всё подряд: модификаторы доступа, аннотации, порядок методов. Гораздо дешевле сначала посмотреть на факты: какой runtime type у объекта, как именно вызывается метод (через ссылку на bean или через this), и где находится вызывающий код — снаружи или внутри target.

1
Задача
Spring Core, 21 уровень, 3 лекция
Недоступна
Граница для `private` и `final` в proxy-подобном подклассе
Граница для `private` и `final` в proxy-подобном подклассе
1
Задача
Spring Core, 21 уровень, 3 лекция
Недоступна
Внутренний вызов через `this` обходит proxy
Внутренний вызов через `this` обходит proxy
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ