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

Разбираемся, как работает Spring Data Repository, и создаем свою библиотеку по аналогии

Java

На habr уже была статья о том, как создать библиотеку в стиле Spring Data Repository (рекомендую к чтению), но способы создания объектов довольно сильно отличаются от "классического" подхода, используемого в Spring Boot. В этой статье я постарался быть максимально близким к этому подходу. Такой способ создания бинов (beans) применяется не только в Spring Data, но и, например, в Spring Cloud OpenFeign.

Содержание

ТЗ

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

Итак, у нас прошли праздники, но мы хотим иметь возможность создавать на лету бины (beans), которые позволили бы нам поздравлять всех, кого мы в них перечислим.

Пример:

public interface FamilyCongratulator extends Congratulator {
    void сongratulateМамаAndПапа();
}

При вызове метода мы хотим получать:

Мама,Папа! Поздравляю с Новым годом! Всегда ваш

Или вот так

@Congratulate("С уважением, Пупкин")
public interface ColleagueCongratulator {
    @CongratulateTo("Коллега")
    void сongratulate();
}

и получать

Коллега! Поздравляю с Новым годом! С уважением, Пупкин

Т.е. мы должны найти все интерфейсы, которые расширяют интерфейс Congratulator или имеют аннотацию @Congratulate

В этих интерфейсах мы должны найти все методы, начинающиеся с congratulate , и сгенерировать для них метод, выводящий в лог соответствующее сообщение.

@Enable

Как и любая взрослая библиотека у нас будет аннотация, которая включает наш механизм (как @EnableFeignClients и @EnableJpaRepositories).

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {
...}

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(JpaRepositoriesRegistrar.class)
public @interface EnableJpaRepositories {
...}

Если посмотреть внимательно, то можно заметить, что обе этиx аннотации содержат @Import, где есть ссылка на класс, расширяющий интерфейс ImportBeanDefinitionRegistrar

public interface ImportBeanDefinitionRegistrar {
 default void registerBeanDefinitions(
    AnnotationMetadata importingClassMetadata,
     BeanDefinitionRegistry registry, 
    BeanNameGenerator importBeanNameGenerator) {
		registerBeanDefinitions(importingClassMetadata, registry);
	}
 default void registerBeanDefinitions(
    AnnotationMetadata importingClassMetadata,
    BeanDefinitionRegistry registry) {
	}
}

Напишем свою аннотацию

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Import(CongratulatorsRegistrar.class)
public @interface EnableCongratulation {
}

Не забудем прописать @Retention(RetentionPolicy.RUNTIME), чтобы аннотация была видна во время выполнения.

ImportBeanDefinitionRegistrar

Посмотрим, что происходит в ImportBeanDefinitionRegistrar у Spring Cloud Feign:

class FeignClientsRegistrar
		implements ImportBeanDefinitionRegistrar,
    ResourceLoaderAware, 
    EnvironmentAware {
...
  @Override
 public void registerBeanDefinitions(AnnotationMetadata metadata,
			BeanDefinitionRegistry registry) {
 //создаются beans для конфигураций по умолчанию
  registerDefaultConfiguration(metadata, registry);
 //создаются beans для создания клиентов
  registerFeignClients(metadata, registry);
}
...
  
 public void registerFeignClients(AnnotationMetadata metadata,
			BeanDefinitionRegistry registry) {
  LinkedHashSet<BeanDefinition> candidateComponents = new LinkedHashSet<>();
...
 //выполняется поиск кандидатов на создание
  ClassPathScanningCandidateComponentProvider scanner = getScanner();
  scanner.setResourceLoader(this.resourceLoader);
  scanner.addIncludeFilter(new AnnotationTypeFilter(FeignClient.class));
  Set<String> basePackages = getBasePackages(metadata);
  for (String basePackage : basePackages) {
    candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
  }
...
 for (BeanDefinition candidateComponent : candidateComponents) {
  if (candidateComponent instanceof AnnotatedBeanDefinition) {
...
  //заполняем контекст
   registerFeignClient(registry, annotationMetadata, attributes);
   }
  }
 }
  
 private void registerFeignClient(BeanDefinitionRegistry registry,
			AnnotationMetadata annotationMetadata, Map<String, Object> attributes) {
	String className = annotationMetadata.getClassName();
 //Создаем описание для Factory
	BeanDefinitionBuilder definition = BeanDefinitionBuilder
    .genericBeanDefinition(FeignClientFactoryBean.class);
...
  //Регистрируем это описание
 BeanDefinitionHolder holder = new BeanDefinitionHolder(
  beanDefinition, className, new String[] { alias });
 BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
 }
      
...
}

В Spring Cloud OpenFeign сначала создаются бины конфигурации, затем выполняется поиск кандидатов и для каждого кандидата создается Factory.

В Spring Data подход аналогичный, но так как Spring Data состоит из множества модулей, то основные моменты разнесены по разным классам (см. например org.springframework.data.repository.config.RepositoryBeanDefinitionBuilder#build)

Можно заметить, что сначала создаются Factory, а не сами bean. Это происходит потому, что мы не можем в BeanDefinitionHolder описать, как должен работать наш bean.

Сделаем по аналогии наш класс (полный код класса можно посмотреть здесь)

public class CongratulatorsRegistrar implements 
        ImportBeanDefinitionRegistrar,
        ResourceLoaderAware, //используется для получения ResourceLoader
        EnvironmentAware { //используется для получения Environment
    private ResourceLoader resourceLoader;
    private Environment environment;

    @Override
    public void setResourceLoader(ResourceLoader resourceLoader) {
        this.resourceLoader = resourceLoader;
    }

    @Override
    public void setEnvironment(Environment environment) {
        this.environment = environment;
    }
...

ResourceLoaderAware и EnvironmentAware используется для получения объектов класса ResourceLoader и Environment соответственно. При создании экземпляра CongratulatorsRegistrar Spring вызовет соответствующие set-методы.

Чтобы найти требуемые нам интерфейсы, используется следующий код:

//создаем scanner
ClassPathScanningCandidateComponentProvider scanner = getScanner();
scanner.setResourceLoader(this.resourceLoader);

//добавляем необходимые фильтры 
//AnnotationTypeFilter - для аннотаций
//AssignableTypeFilter - для наследования
scanner.addIncludeFilter(new AnnotationTypeFilter(Congratulate.class));
scanner.addIncludeFilter(new AssignableTypeFilter(Congratulator.class));

//указываем пакет, где будем искать
//importingClassMetadata.getClassName() - возвращает имя класса,
//где стоит аннотация @EnableCongratulation
String basePackage = ClassUtils.getPackageName(
  importingClassMetadata.getClassName());

//собственно сам поиск
LinkedHashSet<BeanDefinition> candidateComponents = 
  new LinkedHashSet<>(scanner.findCandidateComponents(basePackage));

...
private ClassPathScanningCandidateComponentProvider getScanner() {
  return new ClassPathScanningCandidateComponentProvider(false, 
                                                   this.environment) {
    @Override
    protected boolean isCandidateComponent(
      AnnotatedBeanDefinition beanDefinition) {
      //требуется, чтобы исключить родительский класс - Congratulator
      return !Congratulator.class.getCanonicalName()
        .equals(beanDefinition.getMetadata().getClassName());
    }
  };
}

Регистрация Factory:

String className = annotationMetadata.getClassName();
// Используем класс CongratulationFactoryBean как наш Factory, 
// реализуем в дальнейшем
BeanDefinitionBuilder definition = BeanDefinitionBuilder
.genericBeanDefinition(CongratulationFactoryBean.class);
// описываем, какие параметры и как передаем,
// здесь выбран - через конструктор
definition.addConstructorArgValue(className);
definition.addConstructorArgValue(configName);
AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();
beanDefinition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, className);
// aliasName - создается из наших Congratulator
String aliasName = AnnotationBeanNameGenerator.INSTANCE.generateBeanName(
  candidateComponent, registry);
String name = BeanDefinitionReaderUtils.generateBeanName(
  beanDefinition, registry);
BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition,
  name, new String[]{aliasName});
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);

Попробовав разные способы, я советую остановиться на передаче параметров через конструктор, этот способ работает наиболее стабильно. Если вы захотите передать параметры не через конструктор, а через поля, то в параметры (beanDefinition.setAttribute) обязательно надо положить переменную FactoryBean.OBJECT_TYPE_ATTRIBUTE и соответствующий класс (именно класс, а не строку). Без этого наш Factory создаваться не будет. И Sping Data и Spring Feign передают строку: скорее всего это действует как соглашение, так как найти место, где эта строка используется, я не смог (если кто подскажет - дополню).

Что, если мы хотим иметь возможность получать наши beans по имени, например, так

@Autowired
private Congratulator familyCongratulator;

это тоже возможно, так как во время создания Factory в качестве alias было передано имя bean (AnnotationBeanNameGenerator.INSTANCE.generateBeanName(candidateComponent, registry))

FactoryBean

Теперь займемся Factory.

Стандартный интерфейс FactoryBean имеет 2 метода, которые нужно имплементировать

public interface FactoryBean<T> {
  Class<?> getObjectType();
  T getObject() throws Exception;
  default boolean isSingleton() {
		return true;
	}
}

Заметим, что есть возможность указать, является ли объект, который будет создаваться, Singleton или нет.

Есть абстрактный класс (AbstractFactoryBean), который расширяет интерфейс дополнительной логикой (например, поддержка destroy-методов). Он так же имеет 2 абстрактных метода

public abstract class AbstractFactoryBean<T> implements FactoryBean<T>{
...
	@Override
	public abstract Class<?> getObjectType();

	protected abstract T createInstance() throws Exception;
}

Первый метод getObjectType требует вернуть класс возвращаемого объекта - это просто, его мы передали в конструктор.

@Override
public Class<?> getObjectType() {
	return type;
}

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

Сначала создадим обработчик для каждого метода:

Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<>();
for (Method method : type.getMethods()) {
    if (!AopUtils.isEqualsMethod(method) &&
            !AopUtils.isToStringMethod(method) &&
            !AopUtils.isHashCodeMethod(method) &&
            !method.getName().startsWith(СONGRATULATE)
    ) {
        throw new UnsupportedOperationException(
        "Method " + method.getName() + " is unsupported");
    }
    String methodName = method.getName();
    if (methodName.startsWith(СONGRATULATE)) {
         if (!"void".equals(method.getReturnType().getCanonicalName())) {
            throw new UnsupportedOperationException(
              "Congratulate method must return void");
        }

        List<String> members = new ArrayList<>();
        CongratulateTo annotation = method.getAnnotation(
          CongratulateTo.class);
        if (annotation != null) {
            members.add(annotation.value());
        }
        members.addAll(Arrays.asList(methodName.replace(СONGRATULATE, "").split(AND)));
        MethodHandler handler = new MethodHandler(sign, members);
        methodToHandler.put(method, handler);
    }
}

Здесь MethodHandler - простой класс, который мы создаем сами.

Теперь нам нужно создать объект. Можно, конечно, напрямую вызвать Proxy.newInstance, но лучше воспользоваться классами Spring, которые, например, дополнительно создадут для нас методы hashCode и equals.

//Класс Spring для создания proxy-объектов
ProxyFactory pf = new ProxyFactory();
//указываем список интерфейсов, которые этот bean должен реализовывать
pf.setInterfaces(type);
//добавляем advice, который будет вызываться при вызове любого метода proxy-объекта
pf.addAdvice((MethodInterceptor) invocation -> {
    Method method = invocation.getMethod();

    //добавляем какой-нибудь toString метод
    if (AopUtils.isToStringMethod(method)) {
        return "proxyCongratulation, target:" + type.getCanonicalName();
    }

    //находим и вызываем наш созданный ранее MethodHandler
    MethodHandler methodHandler = methodToHandler.get(method);
    if (methodHandler != null) {
        methodHandler.congratulate();
        return null;
    }
    return null;
});

target = pf.getProxy();

Объект готов.

Теперь при старте контекста Spring создает бины (beans) на основе наших интерфейсов.

Исходный код можно посмотреть здесь.

Полезные ссылки

Теги:javaspringspring-boot
Хабы: Java
Всего голосов 8: ↑8 и ↓0 +8
Просмотры3.1K

Комментарии 4

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Похожие публикации

Backend Engineer (Node.js/Spring, Blockchain)
от 2 000 ₽ConvexityМожно удаленно
Senior Java разработчик (трайб Enterprise IT)
от 220 000 ₽ОТП БанкМоскваМожно удаленно
Team Lead (Java)
от 250 000 ₽NedraСанкт-ПетербургМожно удаленно
Java разработчик
от 160 000 до 250 000 ₽ГринатомМожно удаленно
Java разработчик
от 150 000 до 180 000 ₽Цифровые сервисыСочи

Лучшие публикации за сутки