Магия 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.Stringbyte[].
Для обхода этого ограничения при работе с более сложными типами можно сериализовать объекты в 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!");
}
}