Магия Spring Framework своими руками
https://t.me/ai_machinelearning_big_dataDISCLAIMER
Примеры кода в статье будут намеренно упрощены в угоду компактности изложения идеи, сама демонстрация идеи не страдает. Более пригодный для промышленной эксплуатации код можно найти в конце статьи и в репозитории с практикой.
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!"
.
- Реализация
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.
- Создание прокси объекта:
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
:
- Определение интерфейса генератора паролей:
public interface PasswordGenerator { String getPassword(); }
- Реализация
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
, будет возвращать всегда одно и то же значение.
- Создание прокси объекта:
public PasswordGenerator passwordGenerator() { return (PasswordGenerator) Proxy.newProxyInstance( PasswordGeneratorInvocationHandler.class.getClassLoader(), new Class[]{PasswordGenerator.class}, new PasswordGeneratorInvocationHandler(32)); }
Создание прокси объекта аналогично созданию прокси объекта из предыдущего раздела.
Задание
Прокси как обёртка
Прокси объекты могут выступать обёртками над реальными объектами. Такой способ может быть полезным в случаях, когда:
- нет возможности изменить реализацию метода, а наследование не годится (например, для
final
классов); - необходимо отделить бизнес-логику от сервисного кода (логирование, метрики), что очень похоже на аспектно-ориентированное программирование.
Для реализации обёртки необходимо:
- в конструктор обработчика прокси объектов передать готовый объект,
- в методе
invoke
делегировать все вызовы готовому объекту, - выполнить сервисный код перед или после вызова метода.
В качестве примера можно рассмотреть логирование методов произвольного объекта, тогда:
- реализация
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
, - перед вызовом любого метода фиксируется факт начала исполнения метода, а также запускается таймер,
- после завершения метода фиксируется информация о времени, затраченном на исполнение метода.
- создание прокси объекта:
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 бинами является список доступных типов:
- примитивы (
int
,boolean
,char
и т.д.), - обёртки над примитивами (
Integer
,Boolean
,Character
и т.д.), 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
бина, который имеет все три операции: add
, get
, list
.
- Добавить клиента через curl, получить через JMX:
curl http://localhost:8080/customers \ -X POST \ -H "Content-type: application/json" \ -d '{"id":1,"name":"Hello, World!", "active": true}'
- Добавить клиента через JMX, получить через
curl
:
curl -s http://localhost:8080/customers/2 | json_pp { "active" : true, "id" : 2, "name" : "Jmx Client!" }
- Получить список всех клиентов
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!"); } }