Сегодня мы научимся волшебству аннотаций на примере использования Spring Annotations: инициализация полей бинов.
Как обычно, в конце статьи есть ссылка на проект на GitHub, который можно будет скачать и посмотреть, как всё устроено.
В предыдущей статье я описывал работу библиотеки ModelMapper, которая позволяет конвертировать сущность и DTO друг в друга. Осваивать работу аннотаций мы будем на примере этого маппера.
В проекте нам потребуются пара связанных между собой сущностей и DTO. Я приведу выборочно одну пару.
@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;
}
}
@EqualsAndHashCode(callSuper = true)
@Data
public class PlanetDto extends AbstractDto {
private String name;
private List<ContinentDto> continents;
}
Маппер. Почему он устроен именно так, описано в соответствующей статье.
public interface EntityDtoMapper<E extends AbstractEntity, D extends AbstractDto> {
E toEntity(D dto);
D toDto(E entity);
}
@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) {
}
}
@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.
Комментарии (4)
b_oberon
13.02.2019 23:55+1Спасибо за статью, довольно интересно. Тем не менее, мне кажется, что сам пример не слишком удачный.
Во-первых, вы заменили обычную инициализацию в конструкторе (заметьте, что у вас там был вызов super с константными аргументами) на сеттеры. Конструктор мне представляется предпочтительным, потому что в случае использования сеттеров можно получить не до конца инициализированный объект. Да и смысла в такой замене я не вижу: возможна ли ситуация, в которой PlanetMapper, унаследованный от AbstractMapper<Planet, PlanetDto>, будет использовать другие классы entity и dto?
Во-вторых, добавление новой сущности (аннотации) и замена простого вызова сеттера на магию BeanPostProcessor и reflection затрудняет отладку и поддержку кода, потому что в таком коде намного сложнее разобраться. Вероятно, «оценят Ваш скилл далеко не все» относится в первую очередь к этому аспекту. Кстати, имена полей в строковых константах, как в коде fieldName.equals(«entityClass»), обещают добавить веселья при рефакторинге: при переименовании компилятор не увидит проблемы, но в рантайме поле перестанет корректно инициализироваться.
Наконец, я так и не смог понять, какую проблему вы пытаетесь решить при помощи аннотации.
PS AOP в статье не увидел.
shapovalex
Когда узнаешь про рефлексию, пост процессоры и так далее — сразу возникает желание что-нибудь такое сделать в проде.
Но не надо!
Кастомный DSL уместен при разработке фреймворков (пусть и для внутренних нужд), но абсолютно вреден при повсеместной работе. В случае каких-либо багов приходится искать место где происходит магия, а это значительная потеря времени. Особенно для новичков.
xpendence Автор
Что поделать, большинству действительно хватает Java 7, но определённому проценту разработчиков хочется вырасти за рамки «if not null». Несомненно, они и их код являются объектом ненависти со стороны первых, но их не остановить.
shapovalex
Речь же не про джаву, а про то, что код должен быть понятным и простым.
Пару лет назад я был таким же с шашкой наголо, но потом понял, что разработчикам не интересно учить DSL конкретного проекта, который больше нигде они не увидят.
Понятно что DSL Spring, Lombok, Hibernate и так далее учить нужно — они широко применяются.
Но широко применение собственных аннотаций в проектах — это зло