Магия Spring Framework своими руками

Магия Spring Framework своими руками

https://t.me/ai_machinelearning_big_data

DISCLAIMER

Примеры кода в статье будут намеренно упрощены в угоду компактности изложения идеи, сама демонстрация идеи не страдает. Более пригодный для промышленной эксплуатации код можно найти в конце статьи и в репозитории с практикой.

TLDR

Прокси объекты являются основой "магии" Spring Framework. В качестве демонстрации реализована @JmxExporter аннотация, которая позволяет превратить любой Spring Bean в JMX MBean.


Мотивация

Spring Framework позволяет сфокусироваться на бизнес-логике, а вся настройка инфраструктуры выполняется силами самого фреймворка. Так, например, разработчик вешает аннотацию @RestController на бин, и бин начинает обрабатывать REST запросы, при этом разработчик не трогает Servlet Context, не настраивает цепочку фильтров или веб сервер: все конфигурируется автоматически. Автоматическая настройка инфраструктуры выполняется благодаря постобработке бинов. Зачастую для реализации дополнительной функциональности применяется Spring AOP - аспектно-ориентированное программирование.

Spring AOP удобен, когда необходимо выполнить код перед, после или вместо вызова метода бина. Управление транзакциями - классический пример использования Spring AOP: начать транзакцию перед вызовом метода и зафиксировать ее после завершения метода.

Spring AOP бин, который накручивает дополнительную функциональность другим Spring бинам, помечается аннотацией @Aspect, но как потом этот бин используется для добавления дополнительной функциональности в поток исполнения? Все работает благодаря тому, что в жизненный цикл бина можно вклиниться при помощи BeanPostProcessor.


Прокси объекты

Наследование

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


Proxy объекты в JDK

Другим способом создать прокси объект является класс java.lang.reflect.Proxy, который появился в Java 1.3. При помощи Proxy можно динамически создавать объекты, которые реализуют некоторый интерфейс. Так, программист не обязан предоставлять реализацию интерфейса: она может появиться в рантайме. Для создания прокси-объекта необходимо:


  • интерфейс, т.к. java.lang.reflect.Proxy не умеет работать с конкретными или абстрактными классами;
  • обработчик, который реализует интерфейс java.lang.reflect.InvocationHandler;
  • реализация метода java.lang.reflect.InvocationHandler#invoke для перехвата вызова методов оригинального интерфейса.

Пример "Hello, World!"

В качестве первого примера предлагается рассмотреть прокси объект, которым является строка "Hello, World!". Особенности:


  • строки в java являются объектами класса java.lang.String, что не подходит для Proxy, т.к. нужен интерфейс, поэтому прокси будет создаваться для интерфейса java.lang.CharSequence;
  • при печати строки неявно вызывается метод toString, следовательно именно этот метод необходимо проксировать;
  • вызов остальных методов будет делегирован строковой константе "Hello, World!".
  1. Реализация java.lang.reflect.InvocationHandler:
public class HelloWorldInvocationHandler implements InvocationHandler {
    public static final String HELLO_WORLD_MESSAGE = "Hello, World!";

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
        if ("toString".equals(method.getName())) {
            return HELLO_WORLD_MESSAGE;
        }
        return method.invoke(HELLO_WORLD_MESSAGE, args);
    }

}

Пояснения:

  • метод invoke принимает на вход три параметра:
  • сам прокси объект, для которого вызван метод. Методы могут быть как из списка методов реализуемого интерфейса, так и методы java.lang.Object;
  • объект java.lang.reflect.Method указывает на вызванный метод;
  • список аргументов, которые были переданы в метод при вызове.
  • метод invoke обрабатывает вызовы всех методов проксируемого объекта;
  • активно используется Java Reflection API.
  1. Создание прокси объекта:
public CharSequence helloWorldProxy() {
    return (CharSequence) Proxy.newProxyInstance(
        HelloWorldInvocationHandler.class.getClassLoader(),
        new Class[]{CharSequence.class},
        new HelloWorldInvocationHandler());
}

Пояснения:

  • Создание proxy объекта выполняется при помощи Proxy#newProxyInstance, аргументами которого являются:
  • загрузчик классов для загрузки создаваемого прокси объекта;
  • список интерфейсов, которые реализует прокси объект;
  • обработчик, в метод invoke которого будут направляться вызовы всех методов прокси объекта.
  • Метод Proxy#newProxyInstance возвращает результат типа java.lang.Object, поэтому необходимо явно привести его тип к реализуемому интерфейсу.

Пример "Генератор паролей"

Реализовывать можно также и пользовательские интерфейсы. В качестве примера будет реализован интерфейс PasswordGenerator с одним методом getPassword:


  1. Определение интерфейса генератора паролей:
public interface PasswordGenerator {
    String getPassword();
}
  1. Реализация java.lang.reflect.InvocationHandler для генератора паролей:
public class PasswordGeneratorInvocationHandler implements InvocationHandler {
    private final String password;

    public PasswordGeneratorInvocationHandler(int size) {
        this.password = generatePassword(size);
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if ("getPassword".equals(method.getName())) return password;

        return method.invoke(password, args);
    }

    private static String generatePassword(int size) {
        // реализация опущена
    }

}

Пояснения:

  • PasswordGeneratorInvocationHandler может генерировать пароли указанной длины, которая задается в конструкторе;
  • пароль генерируется во время создания объекта PasswordGeneratorInvocationHandler при помощи вспомогательного метода generatePassword;
  • реализация generatePassword опущена в целях экономии места, код можно посмотреть в репозитории;
  • метод PasswordGeneratorInvocationHandler#invoke проверяет какой метод был вызван:
  • если был вызван метод getPassword, то вернуть заготовленный пароль из поля password;
  • в противном случае делегировать вызов метода заготовленному объекту password.
  • метод getPassword, вызванный на одном объекте на базе PasswordGeneratorInvocationHandler, будет возвращать всегда одно и то же значение.
  1. Создание прокси объекта:
public PasswordGenerator passwordGenerator() {
    return (PasswordGenerator) Proxy.newProxyInstance(
        PasswordGeneratorInvocationHandler.class.getClassLoader(),
        new Class[]{PasswordGenerator.class},
        new PasswordGeneratorInvocationHandler(32));
}

Создание прокси объекта аналогично созданию прокси объекта из предыдущего раздела.

Задание


Прокси как обёртка

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


  • нет возможности изменить реализацию метода, а наследование не годится (например, для final классов);
  • необходимо отделить бизнес-логику от сервисного кода (логирование, метрики), что очень похоже на аспектно-ориентированное программирование.

Для реализации обёртки необходимо:


  • в конструктор обработчика прокси объектов передать готовый объект,
  • в методе invoke делегировать все вызовы готовому объекту,
  • выполнить сервисный код перед или после вызова метода.

В качестве примера можно рассмотреть логирование методов произвольного объекта, тогда:


  1. реализация InvocationHandler для обертки:
public class LoggerWrapperInvocationHandler<T> implements InvocationHandler {
    private final static Logger LOG = LoggerFactory.getLogger(LoggerWrapperInvocationHandler.class);

    private final T delegate;

    public LoggerWrapperInvocationHandler(T delegate) {
        this.delegate = delegate;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        LOG.info("Start executing {}", method.getName());
        final var start = LocalDateTime.now();
        try {
            return method.invoke(delegate, args);
        }
        finally {
            final var end = LocalDateTime.now();
            LOG.info("End executing {} which took {}ns",
                method.getName(),
                Duration.between(start, end).toNanos());
        }
    }
}

Пояснения:

  • класс LoggerWrapperInvocationHandler параметризован типом обёртываемого объекта,
  • метод invoke делегирует вызов каждого метода объекту delegate,
  • перед вызовом любого метода фиксируется факт начала исполнения метода, а также запускается таймер,
  • после завершения метода фиксируется информация о времени, затраченном на исполнение метода.
  1. создание прокси объекта:
public<K, V> Map<K, V> wrappedMap(Map<K, V> map) {
    //noinspection unchecked
    return (Map<K, V>) Proxy.newProxyInstance(
        LoggerWrapperInvocationHandler.class.getClassLoader(),
        new Class[]{Map.class},
        new LoggerWrapperInvocationHandler<>(map));

Код создания прокси объекта идентичен коду из предыдущих разделов, основные отличия:

  • метод wrappedMap принимает в качестве аргумента произвольный объект map типа Map<K, V>;
  • прокси объект реализует интерфейс Map;
  • объект map передается в конструктор LoggerWrapperInvocationHandler.

Прокси объекты в Spring Framework

Spring полагается на проксирование объектов как на основной способ реализации служебного кода, в качестве примеров можно выделить:


  • работа с базой данных через @Repository: программист описывает в своем коде интерфейс для работы с базой данных, а Spring генерирует типовую реализацию методов интерфейса;
  • управление транзакциями через @Transactional: программист добавляет аннотацию @Transactional к своим методам, а Spring создает обёртки вокруг них, где стартует и фиксирует транзакции;
  • обработка REST API через @RestController: программист добавляет аннотацию @RestController к своему классу, а также указывает какие методы класса какие запросы обрабатывают, а Spring поднимает ServletContext;
  • чтение из Kafka через @KafkaListener, работа с потоками через @Async, работа с задачами по расписанию @Scheduled и многое другое - всё работает через прокси объекты.

Замечание:


Внутри Spring использует по умолчанию CGlib в качестве библиотеки для создания прокси объектов, что позволяет создавать прокси объекты даже для классов, а не только для интерфейсов как в java.lang.reflect.Proxy.
Использование cglib избавило программистов от необходимости объявлять интерфейсы к Spring бинам.
Рассмотрение возможностей библиотеки cglib выходит за пределы статьи, поэтому проксирование будет выполняться при помощи класса Proxy из стандартной поставки JDK, что в любом случае позволит продемонстрировать идею.

Точки расширения

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

Spring Framework стратегически расположил несколько точек расширения, в которых программист может выполнить свой код. Код может быть определен в самом классе бина, для этого необходимо на методы класса повесить аннотации:

  • @PostConstruct - выполнить код после того, как бин создан;
  • @PreDestroy - выполнить код перед тем, как приложение завершит свою работу, а бин будет уничтожен.

BeanPostProcessor

Подход с выполнением кода напрямую в бине можно рассматривать как локальную точку расширения. Локальная точка расширения не годится, когда необходимо выполнить один и тот же код для всех бинов. В этом случае в дело вступает org.springframework.beans.factory.config.BeanPostProcessor - глобальная точка расширения.

Бин BeanPostProcessor пропускает через себя все остальные Spring бины и позволяет выполнить один и тот же код для них. При этом можно фильтровать бины по метаданным, записанными в виде аннотаций. Например, можно собрать все @RestConroller бины и создать для них сервлеты, или собрать все @KafkaListener бины и зарегистрировать их как Kafka консьюмеры. Объектов типа BeanPostProcessor может быть много, каждый из которых выполняет свою работу, и в этом заключается "магия" Spring Framework.

Пример

В качестве примера использования BeanPostProcessor предлагается рассмотреть автоматическую конфигурацию JMX MBean объектов для каждого бина, помеченного аннотацией @JmxExporter:


@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface JmxExporter {
    String name() default "";
}

Java Management Extensions (JMX)

Java Management Extensions (JMX) - это технология, которая позволяет управлять java приложением в реальном времени. Технология JMX вводит понятие MBean, что представляет собой объект, который может вызывать код приложения. Все MBean бины регистрируются на сервере, к которому можно подключиться извне и выполнить код приложения через один из зарегистрированных MBean бинов.

Самым большим ограничением работы с MBean бинами является список доступных типов:

  • примитивы (intbooleanchar и т.д.),
  • обёртки над примитивами (IntegerBooleanCharacter и т.д.),
  • java.lang.String
  • byte[].

Для обхода этого ограничения при работе с более сложными типами можно сериализовать объекты в json и обратно, что потребует дополнительной работы во время регистрации и вызова MBean объектов.


Самый простой JMX клиент - это jconsole, который входит в стандартную поставку JDK.

Регистрация MBean

Любой Spring бин, который помечен аннотацией @JmxExporter должен выставить соответствующий ему MBean, т.е. необходимо выполнить одинаковый код для неизвестного числа бинов, а значит необходимо реализовать BeanPostProcessor:


public class JmxExporterPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessAfterInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
        registerMBean(bean, beanName);
        return bean;
    }

    private void registerMBean(Object bean, String beanName) {
        final Class<?> aClass = bean.getClass();
        final var annotation = aClass.getAnnotation(JmxExporter.class);
        if (annotation == null) return;

        try {
            final var name = StringUtils.hasText(annotation.name())
                    ? annotation.name()
                    : beanName;

            final var objectName = new ObjectName(aClass.getPackageName() + ":type=basic,name=" + name);

            final var platformMBeanServer = ManagementFactory.getPlatformMBeanServer();
            final var proxy = getDynamicMBean(bean);
            platformMBeanServer.registerMBean(proxy, objectName);
        }
        catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private DynamicMBean getDynamicMBean(Object bean) {
        // реализация приведена ниже
        return null;
    }
}

Пояснения:

  • каждый Spring бин после создания пройдет по всем BeanPostProcessor объектам и будет передан в метод postProcessAfterInitialization;
  • регистрация MBean объекта для бина выполняется в registerMBean и вынесена в отдельный метод, чтобы повысить уровень читаемости кода;
  • Spring бины без аннотации @JmxExporter пропускаются;
  • если имя не указано через аннотацию @JmxExporter, то имя Spring бина будет использоваться в качестве имени объекта MBean;
  • метод ManagementFactory#getPlatformMBeanServer возвращает текущий объект MBeanServer, который является singleton объектом;
  • объект MBean реализует интерфейс javax.management.DynamicMBean и создается в методе getDynamicMBean;

Создание MBean

Объект MBean реализует интерфейс DynamicMBean, а значит является кандидатом для создания прокси объекта. Создание объекта MBean выполняется в методе JmxExporterPostProcessor#getDynamicMBean:


private DynamicMBean getDynamicMBean(Object bean) {
    return (DynamicMBean) Proxy.newProxyInstance(
            JmxExporterPostProcessor.class.getClassLoader(),
            new Class[]{DynamicMBean.class},
            new JmxWrapperInvocationHandler(bean));
}

Создание MBean объекта аналогично созданию прокси объекта из предыдущих разделов, отличием является тот факт, что Spring бин передается в JmxWrapperInvocationHandler, где бин используется в качестве делегата для вызова методов на прокси объекте.

InvocationHandler для MBean прокси объектов

Реализация InvocationHandler для MBean бинов:


public class JmxWrapperInvocationHandler implements InvocationHandler {

    private final Object bean;

    public JmxWrapperInvocationHandler(Object bean) {
        this.bean = bean;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        return switch (method.getName()) {
            case "getAttribute", "setAttribute", "getAttributes", "setAttributes" -> null;
            case "getMBeanInfo" ->
                    new MBeanInfo(bean.getClass().getName(),
                            bean.getClass().getName(),
                            new MBeanAttributeInfo[0],
                            new MBeanConstructorInfo[0],
                            // получить публичные методы
                            MBeanUtils.operations(bean.getClass()),
                            new MBeanNotificationInfo[0]);
            case "invoke" -> invokeProxy(args);
            default -> throw new UnsupportedOperationException(method.getName());
        };
    }

    private Object invokeProxy(Object[] args) throws Exception {
        final var actionName = (String) args[0];
        final var params = (Object[]) args[1];
        
        final Class<?>[] paramTypes = Arrays.stream(params)
                .map(Object::getClass)
                .toArray(Class<?>[]::new);
        
        final var declaredMethod = bean.getClass().getDeclaredMethod(actionName, paramTypes);
        return declaredMethod.invoke(bean, params);
    }
}

Пояснения:

  • Реализация InvocationHandler для MBean бинов аналогична реализации обработчика для обёртки над готовым объектом;
  • интерфейс javax.management.DynamicMBean содержит шесть методов, но только два из них имеют нетривиальную обработку:
  • getMBeanInfo возвращает объект MBeanInfo - метаданные MBean бина, в том числе и список доступных операций (каждая операция имеет метод в классе переданного Spring бина);
  • invoke вызывает одну из доступных операций.
  • метод invokeProxy обрабатывает вызов операции;
  • поиск метода Spring бина для вызова выполняется по имени операции и сигнатуре;
  • сигнатура метода Spring бина вычисляется по массиву классов переданных аргументов.

Приведенная реализация JmxWrapperInvocationHandler намеренно упрощена в угоду компактности. Она не будет работать, если Spring бин выставляет методы, в сигнатуре которых есть типы, отличные от примитивных, String и byte[]. Более пригодную для промышленной эксплуатации версию можно найти в конце статьи или в репозитории с практикой.


Активация JMX

Демонстрация конфигурации MBean бинов по Spring бинам будет выполняться на примере REST контроллера:


@JmxExporter
@RestController
public class CustomerController {
    private final Map<Integer, Customer> customers = new HashMap<>();

    @PostMapping("/customers")
    public void add(@RequestBody Customer customer) {
        customers.put(customer.id(), customer);
    }

    @GetMapping("/customers/{id}")
    public Customer get(@PathVariable("id") int id) {
        return customers.get(id);
    }

    @GetMapping("/customers")
    public Collection<Customer> list() {
        return customers.values();
    }
}

Класс является типовым REST контроллером, поэтому дополнительные пояснения излишни. Стоит отметить лишь, что в дополнение к аннотации @RestController появилась аннотация @JmxExporter.

Customer - это record с тремя полями:

public record Customer(int id, String name, boolean active) {
}

Демонстрация работы

Запустив приложение, можно подключиться к нему по JMX и увидеть, что CustomerController зарегистрировался в качестве MBean бина, который имеет все три операции: addgetlist.


  1. Добавить клиента через curl, получить через JMX:
curl http://localhost:8080/customers \
    -X POST \
    -H "Content-type: application/json" \
    -d '{"id":1,"name":"Hello, World!", "active": true}'
JMX get operation
  1. Добавить клиента через JMX, получить через curl:

JMX add operation

curl -s http://localhost:8080/customers/2 | json_pp
{
   "active" : true,
   "id" : 2,
   "name" : "Jmx Client!"
}
  1. Получить список всех клиентов
curl -s http://localhost:8080/customers | json_pp
[
   {
      "active" : true,
      "id" : 1,
      "name" : "Hello, World!"
   },
   {
      "active" : true,
      "id" : 2,
      "name" : "Jmx Client!"
   }
]

Заключение

В статье было продемонстрировано, как при помощи прокси BeanPostProcessor можно создать "магию" Spring своими руками. Полный проект можно найти в репозитории с практикой, где так же добавлены тесты и параметры запуска. В дополнение можно запустить проект на Gitpod, виртуальной машине в облаке (50 часов в месяц бесплатно).



Дополнительно

Код в статье был намеренно упрощен в угоду компактности и фокусировке на основной теме, а поэтому если переносить его один-в-один, то не все может работать. Более полную версию можно получить в репозитории с практикой. Ниже в спойлерах приведен полный код для историчности или на случай, если репозиторий на GitHub пропадет или станет недоступен.


JmxExporterPostProcessor.java



JmxWrapperInvocationHandler.java



MBeanInvocable.java



MBeanUtils.java


Задание для самостоятельной работы

В качестве задания для самостоятельной работы предлагается внедрить прокси объект типа org.slf4j.Logger во все поля Spring бинов, которые помечены аннотацией @WithLogger.


@Service
public class MyService {
    @WithLogger
    private org.slf4j.Logger logger;

    void action() {
        logger.info("Action!");
    }
}

Источник

Report Page