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

    На 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) на основе наших интерфейсов.

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

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

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

      0
      Очень сложная и специфическая тема. Мне не хватило инфы — зачем это надо, и где и как можно применить?
        0

        Неделю назад начал смотреть в сторону Hibernate-reactive и как ее связать со Spring. В принципе все заработало, но захотелось создать свои базовые репозитории, по аналогии Spring Data. Обидно, что поздно наткнулся на эту статью, пришлось лопатить код самому, как это реализовано дошел до истины с FactoryBean, и сейчас, перед сном решил почитать про это и наткнулся на эту и предидущию статьи. Спасибо, полезная информация.

        0
        Сделать свою библиотеку) хотя я скорее применяю эти знания при отладке, когда spring не создает нужные бины
          0
          Статья очень полезная, давно смотрю в сторону генерации кода и более широкого применения спринга в проектах, спасибо вам большое за труд =)

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

          Самое читаемое