Как стать автором
Обновить

Spring Annotations: магия AOP

Время на прочтение7 мин
Количество просмотров20K
Сакральное знание о том, как работают аннотации, доступно далеко не каждому. Кажется, что это какая-то магия: поставил над методом/полем/классом заклинание с собачкой — и элемент начинает менять свои свойства и получать новые.

image

Сегодня мы научимся волшебству аннотаций на примере использования Spring Annotations: инициализация полей бинов.

Как обычно, в конце статьи есть ссылка на проект на GitHub, который можно будет скачать и посмотреть, как всё устроено.

В предыдущей статье я описывал работу библиотеки ModelMapper, которая позволяет конвертировать сущность и DTO друг в друга. Осваивать работу аннотаций мы будем на примере этого маппера.

В проекте нам потребуются пара связанных между собой сущностей и DTO. Я приведу выборочно одну пару.

Planet
@Entity
@Table(name = "planets")
@EqualsAndHashCode(callSuper = false)
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Planet extends AbstractEntity {

    private String name;
    private List<Continent> continents;

    @Column(name = "name")
    public String getName() {
        return name;
    }

    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "planet")
    public List<Continent> getContinents() {
        return continents;
    }
}


PlanetDto
@EqualsAndHashCode(callSuper = true)
@Data
public class PlanetDto extends AbstractDto {

    private String name;
    private List<ContinentDto> continents;
}


Маппер. Почему он устроен именно так, описано в соответствующей статье.

EntityDtoMapper
public interface EntityDtoMapper<E extends AbstractEntity, D extends AbstractDto> {

    E toEntity(D dto);

    D toDto(E entity);
}


AbstractMapper
@Setter
public abstract class AbstractMapper<E extends AbstractEntity, D extends AbstractDto> implements EntityDtoMapper<E, D> {

    @Autowired
    ModelMapper mapper;

    private Class<E> entityClass;
    private Class<D> dtoClass;

    AbstractMapper(Class<E> entityClass, Class<D> dtoClass) {
        this.entityClass = entityClass;
        this.dtoClass = dtoClass;
    }

    @PostConstruct
    public void init() {
    }

    @Override
    public E toEntity(D dto) {
        return Objects.isNull(dto)
                ? null
                : mapper.map(dto, entityClass);
    }

    @Override
    public D toDto(E entity) {
        return Objects.isNull(entity)
                ? null
                : mapper.map(entity, dtoClass);
    }

    Converter<E, D> toDtoConverter() {
        return context -> {
            E source = context.getSource();
            D destination = context.getDestination();
            mapSpecificFields(source, destination);
            return context.getDestination();
        };
    }

    Converter<D, E> toEntityConverter() {
        return context -> {
            D source = context.getSource();
            E destination = context.getDestination();
            mapSpecificFields(source, destination);
            return context.getDestination();
        };
    }

    void mapSpecificFields(E source, D destination) {
    }

    void mapSpecificFields(D source, E destination) {
    }
}


PlanetMapper
@Component
public class PlanetMapper extends AbstractMapper<Planet, PlanetDto> {

    PlanetMapper() {
        super(Planet.class, PlanetDto.class);
    }
}


Инициализация полей.


У абстрактного класса маппера есть два поля класса Class, которые нам необходимо проинициализировать в реализации.

    private Class<E> entityClass;
    private Class<D> dtoClass;

Сейчас мы делаем это через конструктор. Не самое изящное решение, хоть и вполне себе. Тем не менее, я предлагаю пойти дальше и написать аннотацию, которая будет сетить эти поля без конструктора.

Для начала, напишем саму аннотацию. Никаких дополнительных зависимостей добавлять не надо.

Для того, чтобы перед классом появилась магическая собачка, мы напишем следующее:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Documented
public @interface Mapper {

    Class<?> entity();

    Class<?> dto();
}

@Retention(RetentionPolicy.RUNTIME) — определяет политику, которой аннотация будет следовать при компиляции. Их три:

SOURCE — такие аннотации не будут учтены при компиляции. Нам такой вариант не подходит.

CLASS — аннотации будут применены при компиляции. Этот вариант является
политикой по умолчанию.

RUNTIME — аннотации будут учтены при компиляции, более того, виртуальная машина будет и дальше видеть их как аннотации, то есть, их можно будет вызвать рекурсивно уже во время исполнения кода, а поскольку мы собираемся работать с аннотациями через процессор, именно такой вариант нам подойдёт.

Target({ElementType.TYPE}) — определяет, на что эта аннотация может быть повешена. Это может быть класс, метод, поле, конструктор, локальная переменная, параметр и так далее — всего 10 вариантов. В нашем случае, TYPE означает класс (интерфейс).

В аннотации мы определяем поля. Поля могут иметь дефолтные значения (default «default field», например), тогда есть возможность их не заполнять. Если дефолтных значений нет, поле обязательно должно быть заполнено.

Теперь давайте повесим аннотацию на нашу реализацию маппера и заполним поля.

@Component
@Mapper(entity = Planet.class, dto = PlanetDto.class)
public class PlanetMapper extends AbstractMapper<Planet, PlanetDto> {

Мы указали, что сущность нашего маппера — Planet.class, а DTO — PlanetDto.class.

Для того, чтобы заинжектить параметры аннотации в наш бин, мы, конечно, полезем в BeanPostProcessor. Для тех, кто не знает — BeanPostProcessor исполняется при инициализации каждого бина. В интерфейсе присутствуют два метода:

postProcessBeforeInitialization() — исполняется перед инициализацией бина.

postProcessAfterInitialization() — исполняется после инициализации бина.

Более подробно этот процесс описан в видео известного Spring-потрошителя Евгения Борисова, которое так и называется: «Евгений Борисов — Spring-потрошитель.» Рекомендую посмотреть.

Так вот. У нас есть бин с аннотацией Mapper с параметрами, содержащими поля класса Class. В аннотации можно добавлять любые поля любых классов. Потом мы достанем эти значения полей и сможем делать с ними что угодно. В нашем случае, мы проинициализируем поля бина значениями аннотации.

Для этого мы создаём MapperAnnotationProcessor (по правилам Spring, все процессоры аннотаций должны заканчиваться на ...AnnotationProcessor) и наследуем его от BeanPostProcessor. При этом, нам будет необходимо переопределить те два метода.

@Component
public class MapperAnnotationProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(@Nullable Object bean, String beanName) {
        return Objects.nonNull(bean) ? init(bean) : null;
    }

    @Override
    public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
        return bean;
    }
}

Если бин есть, мы его инициализируем параметрами аннотации. Сделаем мы это в отдельном методе. Самый простой способ:

    private Object init(Object bean) {
        Class<?> managedBeanClass = bean.getClass();
        Mapper mapper = managedBeanClass.getAnnotation(Mapper.class);
        if (Objects.nonNull(mapper)) {
            ((AbstractMapper) bean).setEntityClass(mapper.entity());
            ((AbstractMapper) bean).setDtoClass(mapper.dto());
        }
        return bean;
    }

При инициализации бинов мы бежим по ним и если находим над бином аннотацию Mapper, мы инициализируем поля бина параметрами аннотации.

Этот метод прост, но не совершенен и содержит уязвимость. Мы не типизируем бин, а полагаемся на какие-то свои знания об этом бине. А любой код, в котором программист полагается на собственные умозаключения, плох и уязвим. Да и Идея ругается на Unchecked call.

Задача сделать всё правильно — сложная, но посильная.

В Spring есть замечательный компонент ReflectionUtils, который позволяет работать с рефлексией максимально безопасным способом. И мы будем сетить поля-классы через него.

Наш метод init() будет выглядеть так:

    private Object init(Object bean) {
        Class<?> managedBeanClass = bean.getClass();
        Mapper mapper = managedBeanClass.getAnnotation(Mapper.class);
        if (Objects.nonNull(mapper)) {
            ReflectionUtils.doWithFields(managedBeanClass, field -> {
                assert field != null;
                String fieldName = field.getName();
                if (!fieldName.equals("entityClass") && !fieldName.equals("dtoClass")) {
                    return;
                }
                ReflectionUtils.makeAccessible(field);
                Class<?> targetClass = fieldName.equals("entityClass") ? mapper.entity() : mapper.dto();
                Class<?> expectedClass = Stream.of(ResolvableType.forField(field).getGenerics()).findFirst()
                        .orElseThrow(() -> new IllegalArgumentException("Unable to get generic type for " + fieldName)).resolve();
                if (expectedClass != null && !expectedClass.isAssignableFrom(targetClass)) {
                    throw new IllegalArgumentException(String.format("Unable to assign Class %s to expected Class %s",
                            targetClass, expectedClass));
                }
                field.set(bean, targetClass);
            });
        }
        return bean;
    }

Как только мы выяснили, что наш компонент помечен аннотацией Mapper, мы вызываем ReflectionUtils.doWithFields, который будет сетить необходимые нам поля более изящным способом. Убеждаемся, что поле существует, получаем его имя и проверяем, что это имя — нужное нам.

assert field != null;
String fieldName = field.getName();
if (!fieldName.equals("entityClass") && !fieldName.equals("dtoClass")) {
    return;
}

Делаем поле доступным (оно ж приватное).

ReflectionUtils.makeAccessible(field);

Сетим значение в поле.

Class<?> targetClass = fieldName.equals("entityClass") ? mapper.entity() : mapper.dto();
field.set(bean, targetClass);

Этого уже достаточно, но мы можем дополнительно защитить будущий код от попыток сломать его, указав в параметрах маппера неправильную сущность или DTO (опционально). Мы проверяем, что класс, который мы собираемся сетить в поле, действительно подходит для этого.

Class<?> expectedClass = Stream.of(ResolvableType.forField(field).getGenerics()).findFirst()
        .orElseThrow(() -> new IllegalArgumentException("Unable to get generic type for " + fieldName)).resolve();
if (expectedClass != null && !expectedClass.isAssignableFrom(targetClass)) {
    throw new IllegalArgumentException(String.format("Unable to assign Class %s to expected Class: %s",
            targetClass, expectedClass));
}

Этих знаний вполне достаточно, чтобы создать какую-нибудь аннотацию и удивить коллег по проекту этим волшебством. Но аккуратнее — будьте готовы к тому, что оценят Ваш скил далеко не все :)

Проект на Github лежит тут: promoscow@annotations.git

Кроме примера с инициализацией бинов, в проекте также лежит реализация AspectJ. Я хотел включить в статью ещё и описание работы Spring AOP / AspectJ, но обнаружил, что на Хабре уже есть замечательная статья на этот счёт, поэтому не буду её дублировать. Ну а рабочий код и написанный тест я оставлю — возможно, это поможет кому-то разобраться в работе AspectJ.
Теги:
Хабы:
Всего голосов 9: ↑8 и ↓1+7
Комментарии4

Публикации

Истории

Работа

Java разработчик
347 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань