Привет! Сегодня мы рассмотрим достаточно важную и интересную тему: создание динамических прокси-классов в Java. Она не слишком простая, поэтому попробуем разобраться с ней на примерах :) Если нужен короткий ответ: динамический прокси это объект-надстройка, который Java создает прямо во время работы программы и который перехватывает вызовы методов оригинального объекта. Собирается он из двух частей: своего класса, реализующего InvocationHandler, и вызова Proxy.newProxyInstance(). Нужен он там, где к чужому классу надо добавить поведение, не трогая его код.

Кратко

  • Динамический прокси создается во время работы программы и перехватывает вызовы методов оригинального объекта, поэтому чужой класс можно обвесить своим поведением, не переписывая его.
  • Нужны две вещи: собственный класс, реализующий InvocationHandler, и вызов статического метода Proxy.newProxyInstance().
  • Все вызовы к прокси приходят в один-единственный метод invoke(Object proxy, Method method, Object[] args).
  • Чтобы после вашей вставки отработал настоящий метод, внутри invoke() вызывают method.invoke(оригинальныйОбъект, args). Без этой строки оригинал просто не выполнится.
  • Proxy.newProxyInstance() принимает три вещи: загрузчик классов, массив интерфейсов и сам перехватчик.
  • Главное ограничение: прокси создается только для интерфейсов. Привести его к классу нельзя, получите ClassCastException.

Что такое динамический прокси

Итак, самый важный вопрос: что такое динамические прокси и для чего они нужны? Прокси-класс это некоторая «надстройка» над оригинальным классом, которая позволяет нам при необходимости изменить его поведение. Что значит «изменить поведение» и как это работает? Рассмотрим простой пример. Допустим, у нас есть интерфейс Person и простой класс Man, реализующий этот интерфейс
public interface Person {

   public void introduce(String name);

   public void sayAge(int age);

   public void sayFrom(String city, String country);
}

public class Man implements Person {

   private String name;
   private int age;
   private String city;
   private String country;

   public Man(String name, int age, String city, String country) {
       this.name = name;
       this.age = age;
       this.city = city;
       this.country = country;
   }

   @Override
   public void introduce(String name) {

       System.out.println("Меня зовут " + this.name);
   }

   @Override
   public void sayAge(int age) {
       System.out.println("Мне " + this.age + " лет");
   }

   @Override
   public void sayFrom(String city, String country) {

       System.out.println("Я из города " + this.city + ", " + this.country);
   }

   //..геттеры, сеттеры, и т.д.
}
У нашего класса Man есть 3 метода: представиться, назвать свой возраст, и сказать, откуда ты родом. Представим, что этот класс мы получили в составе готовой JAR-библиотеки и не можем просто взять и переписать его код. Тем не менее, нам нужно изменить его поведение. К примеру, мы не знаем, какой именно метод будет вызван у нашего объекта, а потому хотим, чтобы при вызове любого из них человек сначала говорил «Привет!» (никто не любит невежливых). Розовая неоновая вывеска в виде облачка с надписью hello на черном фонеКак же нам в такой ситуации поступить?

Как создать динамический прокси

Нам понадобятся несколько вещей:

Шаг 1. InvocationHandler, перехватчик вызовов

Что это такое? Можно перевести дословно: «перехватчик вызовов». Это довольно точно опишет его предназначение. InvocationHandler это специальный интерфейс, который позволяет перехватить любые вызовы методов к нашему объекту и добавить нужное нам дополнительное поведение. Нам необходимо сделать собственный перехватчик, то есть создать класс и реализовать этот интерфейс. Это довольно просто:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class PersonInvocationHandler implements InvocationHandler {

   private Person person;

   public PersonInvocationHandler(Person person) {

       this.person = person;
   }

   @Override
   public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

       System.out.println("Привет!");
       return null;
   }
}
Нам нужно реализовать всего один метод интерфейса, invoke(). Он, собственно, и делает то, что нам нужно: перехватывает все вызовы методов к нашему объекту и добавляет необходимое поведение (здесь мы внутри метода invoke() выводим в консоль «Привет!»).

Шаг 2. Оригинальный объект и его прокси

Создадим оригинальный объект Man и «надстройку» (прокси) для него:
import java.lang.reflect.Proxy;

public class Main {

   public static void main(String[] args) {

       //Создаем оригинальный объект
       Man vasia = new Man("Вася", 30, "Санкт-Петербург", "Россия");

       //Получаем загрузчик класса у оригинального объекта
       ClassLoader vasiaClassLoader = vasia.getClass().getClassLoader();

       //Получаем все интерфейсы, которые реализует оригинальный объект
       Class<?>[] interfaces = vasia.getClass().getInterfaces();

       //Создаем прокси нашего объекта vasia
       Person proxyVasia = (Person) Proxy.newProxyInstance(vasiaClassLoader, interfaces, new PersonInvocationHandler(vasia));

       //Вызываем у прокси объекта один из методов нашего оригинального объекта
       proxyVasia.introduce(vasia.getName());

   }
}
Выглядит не очень просто! Я специально написал к каждой строке кода комментарий: давай разберемся подробнее, что там происходит. В первой строке мы просто делаем оригинальный объект, для которого будем создавать прокси. Следующие две строки могут вызвать у тебя затруднение:
//Получаем загрузчик класса у оригинального объекта
ClassLoader vasiaClassLoader = vasia.getClass().getClassLoader();

//Получаем все интерфейсы, которые реализует оригинальный объект
Class<?>[] interfaces = vasia.getClass().getInterfaces();
Но на самом деле ничего особенного здесь не происходит :) Для создания прокси нам нужен ClassLoader (загрузчик классов) оригинального объекта и список всех интерфейсов, которые реализует наш оригинальный класс (то есть Man). Если ты не знаешь что такое ClassLoader, можешь почитать эту статью о загрузке классов в JVM или эту на Хабре, но пока не особо с этим заморачивайся. Просто запомни, что мы получаем немного дополнительной информации, которая потом будет нужна для создания прокси-объекта. В четвертой строке мы используем специальный класс Proxy и его статический метод newProxyInstance():
//Создаем прокси нашего объекта vasia
Person proxyVasia = (Person) Proxy.newProxyInstance(vasiaClassLoader, interfaces, new PersonInvocationHandler(vasia));
Этот метод как раз создает наш прокси-объект. В метод мы передаем ту информацию об оригинальном классе, которую получили на прошлом шаге (его ClassLoader и список его интерфейсов), а также объект созданного нами ранее перехватчика, InvocationHandler’a. Главное, не забудь передать перехватчику наш оригинальный объект vasia, иначе ему нечего будет «перехватывать» :) Если собрать все три аргумента вместе, получится так.
Аргумент Что это Откуда взять в примере
ClassLoader loader загрузчик, которым будет загружен сгенерированный класс прокси vasia.getClass().getClassLoader()
Class<?>[] interfaces интерфейсы, которые прокси станет реализовывать. Перехватывать он сможет только их методы vasia.getClass().getInterfaces()
InvocationHandler h перехватчик, в который попадут все вызовы. Оригинальный объект передают именно ему new PersonInvocationHandler(vasia)
Что же у нас в итоге получилось? У нас теперь есть прокси-объект proxyVasia. Он может вызывать любые методы интерфейса Person. Почему? Потому что мы передали ему список всех интерфейсов, вот здесь:
//Получаем все интерфейсы, которые реализует оригинальный объект
Class<?>[] interfaces = vasia.getClass().getInterfaces();

//Создаем прокси нашего объекта vasia
Person proxyVasia = (Person) Proxy.newProxyInstance(vasiaClassLoader, interfaces, new PersonInvocationHandler(vasia));
Теперь он «в курсе» всех методов интерфейса Person. Кроме того, мы передали нашему прокси объект PersonInvocationHandler, настроенный на работу с объектом vasia:
//Создаем прокси нашего объекта vasia
Person proxyVasia = (Person) Proxy.newProxyInstance(vasiaClassLoader, interfaces, new PersonInvocationHandler(vasia));
Теперь, если мы вызовем у прокси-объекта любой метод интерфейса Person, наш перехватчик «словит» этот вызов и выполнит вместо него свой метод invoke(). Давай попробуем запустить метод main()! Вывод в консоль: Привет! Отлично! Мы видим, что вместо настоящего метода Person.introduce() вызван метод invoke() нашего PersonInvocationHandler():
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

   System.out.println("Привет!");
   return null;
}
И в консоль было выведено «Привет!»

Как вызвать оригинальный метод из invoke()

Но это не совсем то поведение, которое мы хотели получить :/ По нашей задумке сначала должно быть выведено «Привет!», а после сработать сам метод, который мы вызываем. Иными словами, вот этот вызов метода:
proxyVasia.introduce(vasia.getName());
должен выводить в консоль «Привет! Меня зовут Вася», а не просто «Привет!» Как же нам добиться этого? Ничего сложного: просто придется немного похимичить над нашим перехватчиком и методом invoke() :) Обрати внимание, какие аргументы передаются в этот метод:
public Object invoke(Object proxy, Method method, Object[] args)
У метода invoke() есть доступ к методу, вместо которого он вызывается, и ко всем его аргументам (Method method, Object[] args). Иными словами, если мы вызываем метод proxyVasia.introduce(vasia.getName()), и вместо метода introduce() вызывается метод invoke(), внутри этого метода у нас есть доступ и к оригинальному методу introduce(), и к его аргументу! В результате мы можем сделать вот так:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class PersonInvocationHandler implements InvocationHandler {

   private Person person;

   public PersonInvocationHandler(Person person) {

       this.person = person;
   }

   @Override
   public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
       System.out.println("Привет!");
       return method.invoke(person, args);
   }
}
Теперь мы добавили в метод invoke() вызов оригинального метода через рефлексию. Если мы попробуем сейчас запустить код из нашего предыдущего примера:
import java.lang.reflect.Proxy;

public class Main {

   public static void main(String[] args) {

       //Создаем оригинальный объект
       Man vasia = new Man("Вася", 30, "Санкт-Петербург", "Россия");

       //Получаем загрузчик класса у оригинального объекта
       ClassLoader vasiaClassLoader = vasia.getClass().getClassLoader();

       //Получаем все интерфейсы, которые реализует оригинальный объект
       Class<?>[] interfaces = vasia.getClass().getInterfaces();

       //Создаем прокси нашего объекта vasia
       Person proxyVasia = (Person) Proxy.newProxyInstance(vasiaClassLoader, interfaces, new PersonInvocationHandler(vasia));

       //Вызываем у прокси объекта один из методов нашего оригинального объекта
       proxyVasia.introduce(vasia.getName());
   }
}
то увидим, что теперь все работает как надо :) Вывод в консоль: Привет! Меня зовут Вася

Где применяются динамические прокси

Где это может тебе понадобиться? На самом деле, много где. Паттерн проектирования «динамический прокси» активно используется в популярных технологиях...а я, кстати, и забыл тебе сказать, что Dynamic Proxy это реализация паттерна Proxy средствами самой Java! Поздравляю, ты выучил еще один! :) Рука держит связку разноцветных воздушных шаров с лентамиТак вот, он активно используется в популярных технологиях и фреймворках, связанных с безопасностью. Представь, что у тебя есть 20 методов, которые могут выполнять только залогиненные пользователи твоей программы. С помощью изученных приемов ты легко сможешь добавить в эти 20 методов проверку того, ввел ли пользователь логин и пароль, не дублируя код проверки отдельно в каждом методе. Или, к примеру, если ты хочешь создать журнал, куда будут записываться все действия пользователей, это также легко сделать с использованием прокси. Можно даже сейчас: просто допиши в пример код, чтобы название метода выводилось в консоль при вызове invoke(), и ты получишь простенький журнал логов нашей программы :)

Важное ограничение: прокси создается для интерфейса

В завершение лекции, обрати внимание на одно важное ограничение. Создание прокси объекта происходит на уровне интерфейсов, а не классов. Прокси создается для интерфейса. Взгляни на этот код:
//Создаем прокси нашего объекта vasia
Person proxyVasia = (Person) Proxy.newProxyInstance(vasiaClassLoader, interfaces, new PersonInvocationHandler(vasia));
Здесь мы создаем прокси именно для интерфейса Person. Если попробуем создать прокси для класса, то есть поменяем тип ссылки и попытаемся сделать приведение к классу Man, у нас ничего не выйдет.
Man proxyVasia = (Man) Proxy.newProxyInstance(vasiaClassLoader, interfaces, new PersonInvocationHandler(vasia));

proxyVasia.introduce(vasia.getName());
Exception in thread "main" java.lang.ClassCastException: class jdk.proxy1.$Proxy0 cannot be cast to class Man (jdk.proxy1.$Proxy0 is in module jdk.proxy1 of loader 'app'; Man is in unnamed module of loader 'app') Само имя пакета документация не фиксирует, так что в разных версиях оно отличается: до Java 9 прокси-классы попадали в com.sun.proxy, сейчас в отдельный модуль вида jdk.proxy1. Суть сообщения от этого не меняется. Наличие интерфейса это обязательное требование. Прокси работает на уровне интерфейсов. На этом на сегодня все :) В качестве дополнительного материала по теме прокси могу порекомендовать тебе отличное видео, и также неплохую статью. Ну, а теперь было бы неплохо решить несколько задач! :) До встречи!

Вопросы и ответы

Что такое динамический прокси в Java?

Это объект, который Java создает на лету, во время работы программы, и который подставляется вместо оригинального. Снаружи он выглядит как обычная реализация нужного интерфейса, но каждый вызов метода он переадресует вашему перехватчику. Так к чужому классу добавляют поведение, не имея доступа к его исходникам.

Чем динамический прокси отличается от обычного класса-обертки?

Обертку вы пишете руками, и в ней придется продублировать все методы интерфейса: три метода это три метода, двадцать это двадцать. Динамический прокси генерируется сам, а вся логика умещается в одном методе invoke(), каким бы большим ни был интерфейс. Плата за это, потеря проверок на этапе компиляции и работа через рефлексию.

Что делает метод invoke() и какие у него аргументы?

В invoke() попадает любой вызов метода прокси. Аргументов три: сам прокси-объект, объект Method того метода, который пытались вызвать, и массив его аргументов. Благодаря второму и третьему аргументу перехватчик может не только добавить свое поведение, но и запустить оригинальный метод.

Как вызвать из invoke() оригинальный метод?

Через рефлексию: return method.invoke(person, args);, где person это оригинальный объект, переданный перехватчику в конструкторе. Если этой строки нет, а invoke() возвращает null, оригинальный метод просто не отработает, и в консоли вы увидите только свою вставку.

Почему прокси можно создать только для интерфейса?

Потому что Proxy.newProxyInstance() генерирует класс, который реализует переданные интерфейсы, а наследником вашего класса он не является. Поэтому привести результат к Man не выйдет, будет ClassCastException, а к Person выйдет. Если интерфейса у класса нет, штатный механизм не подойдет.

Где на практике применяются динамические прокси?

Везде, где одно и то же поведение надо навесить сразу на много методов. Проверка того, залогинен ли пользователь, запись действий в журнал, замер времени выполнения, кеширование, открытие и закрытие транзакции. На этом механизме держится изрядная часть популярных фреймворков, поэтому понимать его полезно даже до того, как вы возьметесь за них.

Читайте также