Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Spring Boot @ConditionalOnMissingBean and generic types

Is there any way to get both of these beans instantiated:

@Bean
@ConditionalOnMissingBean
public Container<Book> bookContainer() {
    return new Container<>(new Book());
}

@Bean
@ConditionalOnMissingBean
public Container<Computer> computerContainer() {
    return new Container<>(new Computer());
}

@ConditionalOnMissingBean only takes the bean class into account, falsely making bookContainer and computerContainer the same type, thus only one of them gets registered.

I could give explicit qualifiers to each, but since this is a part of a Spring Boot Starter, it would make it annoying to use, as the user would then be forced to somehow know the exact name to override instead of the type only.

Potential approach:

Since it is possible to ask Spring for a bean by its full generic type, I might be able to implement a conditional factory that will try to get a fully-typed instance and produce one if it doesn't already exist. I'm now investigating if/how this can be done. Amazingly, when implementing a custom condition (to be used with @Conditional), it does not have access to the bean type...

While every other modern injection framework operates on full types, Spring somehow still works with raw classes and string names(!) which just blows my mind... Is there any workaround for this?

like image 771
kaqqao Avatar asked Feb 18 '19 11:02

kaqqao


People also ask

What is @ConditionalOnMissingBean in Spring boot?

The @ConditionalOnMissingBean annotation is a spring conditional annotation for registering beans only when they are not already in the application context. See the documentation: https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBean.html.

What is the use of @ConditionalOnMissingBean?

Annotation Type ConditionalOnMissingBean. @Conditional that only matches when no beans meeting the specified requirements are already contained in the BeanFactory . None of the requirements must be met for the condition to match and the requirements do not have to be met by the same bean.

What is the @bean annotation?

One of the most important annotations in spring is the @Bean annotation which is applied on a method to specify that it returns a bean to be managed by Spring context. Spring Bean annotation is usually declared in Configuration classes methods. This annotation is also a part of the spring core framework.

What is @ConditionalOnBean?

The @ConditionalOnBean annotation let a bean be included based on the presence of specific beans. By default Spring will search entire hierarchy ( SearchStrategy.


3 Answers

Update: Spring now supports this natively. See the accepted answer.

Original answer: Here's a fully working solution. I gave up on this approach, but I'm posting it in case someone finds it useful.

I made a custom annotation:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Conditional(MissingGenericBeanCondition.class)
public @interface ConditionalOnMissingGenericBean {

    Class<?> containerType() default Void.class;
    Class<?>[] typeParameters() default {};
}

And a custom condition to go with it:

public class MissingGenericBeanCondition implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        if (!metadata.isAnnotated(ConditionalOnMissingGenericBean.class.getName()) || context.getBeanFactory() == null) {
            return false;
        }
        Map<String, Object> attributes = metadata.getAnnotationAttributes(ConditionalOnMissingGenericBean.class.getName());
        Class<?> containerType = (Class<?>) attributes.get("containerType");
        Class<?>[] typeParameters = (Class<?>[]) attributes.get("typeParameters");

        ResolvableType resolvableType;
        if (Void.class.equals(containerType)) {
            if (!(metadata instanceof MethodMetadata) || !metadata.isAnnotated(Bean.class.getName())) {
                throw error();
            }
            //When resolving beans within the starter
            if (metadata instanceof StandardMethodMetadata) {
                resolvableType = ResolvableType.forType(((StandardMethodMetadata) metadata).getIntrospectedMethod().getGenericReturnType());
            } else {
                //When resolving beans in an application using the starter
                MethodMetadata methodMeta = (MethodMetadata) metadata;
                try {
                    // This might not be a safe thing to do. See the notes below.
                    Class<?> declaringClass = ClassUtils.forName(methodMeta.getDeclaringClassName(), context.getClassLoader());
                    Type returnType = Arrays.stream(declaringClass.getDeclaredMethods())
                            .filter(m -> m.isAnnotationPresent(Bean.class))
                            .filter(m -> m.getName().equals(methodMeta.getMethodName()))
                            .findFirst().map(Method::getGenericReturnType)
                            .orElseThrow(MissingGenericBeanCondition::error);
                    resolvableType = ResolvableType.forType(returnType);
                } catch (ClassNotFoundException e) {
                    throw error();
                }
            }
        } else {
            resolvableType = ResolvableType.forClassWithGenerics(containerType, typeParameters);
        }

        String[] names = context.getBeanFactory().getBeanNamesForType(resolvableType);
        return names.length == 0;
    }

    private static IllegalStateException error() {
        return new IllegalStateException(ConditionalOnMissingGenericBean.class.getSimpleName()
                + " is missing the explicit generic type and the implicit type can not be determined");
    }
}

These allow me to do the following:

@Bean
@ConditionalOnMissingGenericBean
public Container<Book> bookContainer() {
    return new Container<>(new Book());
}

@Bean
@ConditionalOnMissingGenericBean
public Container<Computer> computerContainer() {
    return new Container<>(new Computer());
}

Both beans will now be loaded as they're of different generic types. If the user of the starter would register another bean of Container<Book> or Container<Computer> type, then the default bean would not be loaded, exactly as desired.

As implemented, it is also possible to use the annotation this way:

@Bean
//Load this bean only if Container<Car> isn't present
@ConditionalOnMissingGenericBean(containerType=Container.class, typeParameters=Car.class)
public Container<Computer> computerContainer() {
    return new Container<>(new Computer());
}

Notes: Loading the configuration class in the condition might not be safe, but in this specific case it also might be... I have no clue. Didn't bother investigating further as I settled on a different approach altogether (different interface for each bean). If you have any info on this, please comment.

If explicit types are provided in the annotation, e.g.

@Bean
@ConditionalOnMissingGenericBean(containerType=Container.class, typeParameters=Computer.class)
public Container<Computer> computerContainer() {
    return new Container<>(new Computer());
}

this concern goes away, but this approach will not work if the type parameters are also generic, e.g. Container<List<Computer>>.

Another way to make this certainly safe would be to accept the declaring class in the annotation:

@ConditionalOnMissingGenericBean(declaringClass=CustomConfig.class)

and then use that instead of

Class<?> declaringClass = ClassUtils.forName(methodMeta.getDeclaringClassName(), context.getClassLoader());
like image 142
kaqqao Avatar answered Sep 21 '22 07:09

kaqqao


It's possible since Spring Boot v2.1.0.

That version introduced the new field parameterizedContainer on ConditionalOnMissingBean.

@Bean
@ConditionalOnMissingBean(value = Book.class, parameterizedContainer = Container.class)
public Container<Book> bookContainer() {
    return new Container<>(new Book());
}

@Bean
@ConditionalOnMissingBean(value = Computer.class, parameterizedContainer = Container.class)
public Container<Computer> computerContainer() {
    return new Container<>(new Computer());
}
like image 36
Sven Döring Avatar answered Sep 17 '22 07:09

Sven Döring


Is there any workaround for this?

Unfortunately, I think not. The problem is that generics in Java do not exist in the compiled bytecode - they are used only for type checking at compile time and they only exist in the source code. Therefore at runtime, when Spring Boot sees your bean configuration class, it only sees two bean definitions that both create a Container. It does not see any difference between them unless you provide it yourself by giving them identifiers.

like image 31
Richard Wild Avatar answered Sep 20 '22 07:09

Richard Wild