spring-framework icon indicating copy to clipboard operation
spring-framework copied to clipboard

Introduce @Secondary beans in ressemblence of @Primary beans.

Open raphw opened this issue 3 years ago • 8 comments

Sometimes, one wants to be able to register a bean of a given type without breaking existing code, especially in multi-module projects. Assuming that a bean is already available:

@Bean
public SomeType someTypeBean() {
  return new SomeType();
}

and used as in:

@Bean
public SomeOtherType someOtherTypeBean(SomeType val) {
  return new SomeOtherType(val);
}

I would like to being able to register a bean:

@Bean
@Secondary // mirrored behavior of @Primary
public SomeType someNewTypeBean() {
  return new SomeType();
}

without disturbing existing code. If the someTypeBean is missing, it should fallback to someNewTypeBean, this would also allow for much smoother migrations in the case of multiple profiles.

raphw avatar Dec 08 '20 12:12 raphw

I have a similar request, see https://github.com/spring-projects/spring-framework/issues/18201

quaff avatar Dec 09 '20 03:12 quaff

FYI, I'm implementing this by introducing a customized @PriorityQualifier

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ ElementType.FIELD, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface PriorityQualifier {

	String[] value() default {};

}
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;
import java.util.function.Supplier;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.Ordered;
import org.springframework.core.PriorityOrdered;
import org.springframework.core.ResolvableType;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class PriorityQualifierPostProcessor implements BeanPostProcessor, PriorityOrdered, BeanFactoryAware {

	private BeanFactory beanFactory;

	private Map<String, Boolean> logged = new ConcurrentHashMap<>();

	@Override
	public int getOrder() {
		return Ordered.HIGHEST_PRECEDENCE + 1;
	}

	@Override
	public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
		this.beanFactory = beanFactory;
	}

	@Override
	public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
		ReflectionUtils.doWithFields(bean.getClass(), field -> {
			inject(bean, beanName, field);
		}, this::filter);

		ReflectionUtils.doWithMethods(bean.getClass(), method -> {
			inject(bean, beanName, method);
		}, this::filter);
		return bean;
	}

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

	private void inject(Object bean, String beanName, Method method) {
		ReflectionUtils.makeAccessible(method);
		doInject(bean, beanName, method, () -> {
			String methodName = method.getName();
			if (methodName.startsWith("set") && methodName.length() > 3)
				methodName = StringUtils.uncapitalize(methodName.substring(3));
			return methodName;
		}, () -> ResolvableType.forMethodParameter(method, 0), (b, candidate) -> {
			try {
				method.invoke(b, candidate);
			} catch (IllegalAccessException | InvocationTargetException e) {
				throw new RuntimeException(e);
			}
		});
	}

	private void inject(Object bean, String beanName, Field field) {
		ReflectionUtils.makeAccessible(field);
		doInject(bean, beanName, field, field::getName, () -> ResolvableType.forField(field), (b, candidate) -> {
			try {
				field.set(b, candidate);
			} catch (IllegalAccessException e) {
				throw new RuntimeException(e);
			}
		});
	}

	private void doInject(Object bean, String beanName, AccessibleObject methodOrField,
			Supplier<String> defaultCandidate, Supplier<ResolvableType> typeSupplier,
			BiConsumer<Object, Object> injectConsumer) {
		String injectPoint = defaultCandidate.get();
		PriorityQualifier pq = methodOrField.getAnnotation(PriorityQualifier.class);
		String[] candidates = pq.value();
		if (candidates.length == 0)
			candidates = new String[] { injectPoint };
		for (String name : candidates) {
			if (beanFactory.containsBean(name)) {
				ResolvableType rt = typeSupplier.get();

				boolean typeMatched = beanFactory.isTypeMatch(name, rt);
				if (!typeMatched) {
					Class<?> rawClass = rt.getRawClass();
					typeMatched = (rawClass != null) && beanFactory.isTypeMatch(name, rawClass);
				}
				if (typeMatched) {
					injectConsumer.accept(bean, beanFactory.getBean(name));
					if (logged.putIfAbsent(beanName + "." + injectPoint, true) == null) {
						// remove duplicated log for prototype bean
						log.info("Injected @PrioritizedQualifier(\"{}\") for field[{}] of bean[{}]", name, injectPoint,
								beanName);
					}
					break;
				} else {
					log.warn("Ignored @PrioritizedQualifier(\"{}\") for {} because it is not type of {}, ", name,
							beanName, rt);
				}
			}
		}
	}

	private boolean filter(AccessibleObject methodOrField) {
		return methodOrField.isAnnotationPresent(Autowired.class)
				&& methodOrField.isAnnotationPresent(PriorityQualifier.class);
	}

}
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = PriorityQualifierTest.TestConfiguration.class)
public class PriorityQualifierTest {

	@Autowired
	@PriorityQualifier("prioritizedTestBean")
	private TestBean testBean;

	@Autowired
	@PriorityQualifier
	private TestBean prioritizedTestBean;

	private TestBean testBean2;

	private TestBean testBean3;

	@Autowired
	@PriorityQualifier("prioritizedTestBean")
	void setTestBean(TestBean testBean) {
		this.testBean2 = testBean;
	}

	@Autowired
	@PriorityQualifier
	void setPrioritizedTestBean(TestBean prioritizedTestBean) {
		this.testBean3 = prioritizedTestBean;
	}

	@Test
	public void testExplicitFieldInjection() {
		assertThat(testBean.getName(), is("prioritizedTestBean"));
	}

	@Test
	public void testImplicitFieldInjection() {
		assertThat(prioritizedTestBean.getName(), is("prioritizedTestBean"));
	}

	@Test
	public void testExplicitSetterInjection() {
		assertThat(testBean2.getName(), is("prioritizedTestBean"));
	}

	@Test
	public void testImplicitSetterInjection() {
		assertThat(testBean3.getName(), is("prioritizedTestBean"));
	}

	@Configuration
	static class TestConfiguration {

		@Bean
		static PriorityQualifierPostProcessor priorityQualifierPostProcessor() {
			return new PriorityQualifierPostProcessor();
		}

		@Bean
		@Primary
		public TestBean testBean() {
			return new TestBean("testBean");
		}

		@Bean
		public TestBean prioritizedTestBean() {
			return new TestBean("prioritizedTestBean");
		}

	}

	@RequiredArgsConstructor
	static class TestBean {

		@Getter
		private final String name;

	}

}

quaff avatar Dec 09 '20 03:12 quaff

Annotating someNewTypeBean with @javax.annotation.Priority to tweak the priority should do the trick.

Edit: Args, @Priority cannot be declared on methods. So you need to use a component...

So maybe Spring might introduce its own @Priority annotation in order to allow annotating beans?

OLibutzki avatar Dec 09 '20 05:12 OLibutzki

Annotating someNewTypeBean with @javax.annotation.Priority to tweak the priority should do the trick.

Edit: Args, @Priority cannot be declared on methods. So you need to use a component...

So maybe Spring might introduce its own @Priority annotation in order to allow annotating beans?

My solution defer resolution of priority at injection point, so someNewTypeBean maybe top priority at this injection and lowest priority at another injection.

quaff avatar Dec 09 '20 06:12 quaff

In case if there tooo many default bean dependencies with autoconfigure, adding any

@Bean
public DataSource dataSource() {
....
}

will cause all the autoconfiguration fail where

@ConditionalOnMissingBean(DataSource.class)

So adding this annotation will allow such conditions to be treated as the bean is missing (and the existed one is secondary).

See also https://github.com/spring-projects/spring-boot/issues/9528 its about the same problem

msangel avatar Apr 08 '21 22:04 msangel

@msangel I am not sure how this is related to this issue. Please review the Javadoc of @ConditionalOnMissingBeanthat states this shouldn't be used on user configuration.

snicoll avatar Apr 09 '21 06:04 snicoll

I want my @NonPrimary annotation. Thanks.

onacit avatar Nov 10 '21 02:11 onacit

If you are willing to customize Spring a bit, I wrote a ContextAnnotationAutowireCandidateResolver that supports a NotDefault annotation to indicate that a bean is "private" and will never be injected unless all qualifiers match.

See here: https://github.com/spring-projects/spring-framework/issues/26528#issuecomment-1149741516

The semantics might be slightly different than Secondary (if I understand it correctly), but it might still be close to what you are looking for (or give you an idea on how to change the code). I use this for a similar purpose though.

hjohn avatar Aug 10 '22 10:08 hjohn

almost 3 years later and no updates here ?

deathwaiting avatar Sep 24 '23 20:09 deathwaiting

@OLibutzki

Annotating someNewTypeBean with @javax.annotation.Priority to tweak the priority should do the trick.

Edit: Args, @Priority cannot be declared on methods. So you need to use a component...

Sadly, it seems that's not the case. If you set @Priority to any value (including LOWEST_PRECEDENCE), the Component will still have precedence over components and beans with no explicit priority (and will be loaded before any with @Ordered etc. that doesn't have @Priority) - as a result, you can only increase the priority of the Component versus unannotated ones this way, not decrease it (unless you explicitly provide @Priority for all the other beans and components as well)

FyiurAmron avatar Nov 02 '23 12:11 FyiurAmron

related: https://github.com/spring-projects/spring-framework/issues/31544 (discussion about a simple and more versatile solution for a particular subcase)

FyiurAmron avatar Nov 02 '23 13:11 FyiurAmron

Or see this potential solution, inspired by CDI: https://github.com/spring-projects/spring-framework/issues/26528#issuecomment-1747099987

hjohn avatar Nov 02 '23 13:11 hjohn

@FyiurAmron if you are interested in testing this in a real Spring application, the code you need to do so is:

package notdefault;

import org.springframework.beans.SimpleTypeConverter;
import org.springframework.beans.factory.config.BeanDefinitionHolder;
import org.springframework.beans.factory.config.DependencyDescriptor;
import org.springframework.beans.factory.support.AutowireCandidateResolver;
import org.springframework.context.annotation.ContextAnnotationAutowireCandidateResolver;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationUtils;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;

/**
 * {@link AutowireCandidateResolver} implementation based on {@link ContextAnnotationAutowireCandidateResolver}
 * that allows candidates to be annotated with a {@link NotDefault} annotation to indicate they should never
 * be wired into injection points which have no annotations. If the injection point has at least one qualifier
 * annotation which matches with a qualifier on such a not default candidate, injection is allowed as normal.
 */
class NotDefaultSupportingQualifierAnnotationAutowireCandidateResolver extends ContextAnnotationAutowireCandidateResolver {
    private static final Annotation NOT_DEFAULT = AnnotationUtils.synthesizeAnnotation(NotDefault.class);

    @Override
    protected boolean checkQualifiers(BeanDefinitionHolder bdHolder, Annotation[] annotationsToSearch) {
        if (!super.checkQualifiers(bdHolder, annotationsToSearch)) {
            return false;
        }

        /*
         * The qualifiers (if any) on the injection point matched the candidate's qualifiers according
         * to the standard rules (a candidate always must have at least all qualifiers specified by the
         * injection point).
         */

        if (annotationsToSearch != null) {

            /*
             * If there was at least one qualifier on the injection point, or it has the Any annotation,
             * then proceed with injection (note: we only need to find if a qualifier was *present* here, as
             * all were already matched by checkQualifiers at the start of this method).
             */

            for (Annotation annotation : annotationsToSearch) {
                Class<? extends Annotation> annotationType = annotation.annotationType();

                if (annotationType == Any.class || isQualifier(annotationType)) {
                    return true;
                }
            }
        }

        /*
         * There were no qualifiers on the injection point at all. This means the injection point expects
         * a default candidate. Any candidate is a default candidate unless specifically annotated with NotDefault:
         */

        return !checkQualifier(bdHolder, NOT_DEFAULT, new SimpleTypeConverter());
    }

    @Override
    public boolean isAutowireCandidate(BeanDefinitionHolder bdHolder, DependencyDescriptor descriptor) {

        /*
         * Note: this method does not call super, but integrates all the super code into this method.
         * This is because the code in QualifierAnnotationAutowireCandidateResolver is calling
         * checkQualifiers twice (once with annotations on the field/parameter, and another time
         * with the annotations on the method/constructor (if applicable)) and this causes the
         * second check to often fail for NotDefault beans (as there are often no annotations). Instead,
         * for proper NotDefault support, this must be a single check with all annotations concatenated.
         */

        if (!bdHolder.getBeanDefinition().isAutowireCandidate()) {
            return false;
        }

        if (!checkGenericTypeMatch(bdHolder, descriptor)) {
            return false;
        }

        Annotation[] annotations = descriptor.getAnnotations();
        MethodParameter methodParam = descriptor.getMethodParameter();

        if (methodParam != null) {
            Method method = methodParam.getMethod();

            if (method == null || void.class == method.getReturnType()) {
                Annotation[] methodAnnotations = methodParam.getMethodAnnotations();

                if (methodAnnotations.length != 0) {
                    int originalLength = annotations.length;

                    annotations = Arrays.copyOf(annotations, originalLength + methodAnnotations.length);

                    System.arraycopy(methodAnnotations, 0, annotations, originalLength, methodAnnotations.length);
                }
            }
        }

        return checkQualifiers(bdHolder, annotations);
    }
}

And to use it:

package notdefault;

import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.support.GenericApplicationContext;

/**
 * Installs the {@link NotDefaultSupportingQualifierAnnotationAutowireCandidateResolver}
 * via a Spring {@link ApplicationContextInitializer}.
 */
public class NotDefaultBeansInitializer implements ApplicationContextInitializer<GenericApplicationContext> {

    @Override
    public void initialize(GenericApplicationContext applicationContext) {
        applicationContext
            .getDefaultListableBeanFactory()
            .setAutowireCandidateResolver(new NotDefaultSupportingQualifierAnnotationAutowireCandidateResolver());
    }

}

And the annotation classes:

package notdefault;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Bean definition annotation which indicates this bean should not be injected into injection points
 * which do not have at least one matching qualifier. Beans defined in this way will therefore not
 * serve as default beans for injection points even if their types match.
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
public @interface NotDefault {
}
package notdefault;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Injection point annotation to indicate that even not default candidates (annotated with {@link NotDefault})
 * are suitable for injection. Any other qualifiers on the injection point must still match.
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
public @interface Any {
}

hjohn avatar Nov 03 '23 07:11 hjohn

@hjohn I went with a custom override to the determineHighestPriorityCandidate method in BeanFactory, assigning default fallback priority to beans/components with no @Priority annotations when using @Priority:

import java.util.*;
import javax.annotation.Nonnull;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;

public class CommonBeanFactory extends DefaultListableBeanFactory {
    public static final int DEFAULT_PRIORITY = 0;

    // already more than one candidate if executing this
    @Override
    protected String determineHighestPriorityCandidate(
            @Nonnull Map<String, Object> candidates, @Nonnull Class<?> requiredType) {
        String highestPriorityBeanName = null;
        Integer highestPriority = null; // note: higher priority == lower number
        int highestPriorityCount = 0;

        List<Map.Entry<String, Object>> defaultFallbacks = new ArrayList<>();
        for (Map.Entry<String, Object> entry : candidates.entrySet()) {
            String candidateBeanName = entry.getKey();
            Object beanInstance = entry.getValue();
            if (beanInstance == null) {
                continue;
            }
            Integer candidatePriority = getPriority(beanInstance);
            if (candidatePriority == null) {
                defaultFallbacks.add(entry);
                continue;
            }
            if (highestPriority == null || candidatePriority < highestPriority) {
                highestPriority = candidatePriority;
                highestPriorityCount = 1;
                highestPriorityBeanName = candidateBeanName;
            } else if (candidatePriority.equals(highestPriority)) {
                highestPriorityCount++;
            }
        }

        if (highestPriorityCount == 0) {
            return null; // no explicit priorities at all, fall back to default handling
        }

        if (highestPriority <= DEFAULT_PRIORITY || defaultFallbacks.isEmpty()) {
            if (highestPriorityCount > 1) {
                throw new NoUniqueBeanDefinitionException(
                        requiredType,
                        candidates.size(),
                        highestPriorityCount
                                + " beans found with the same priority ('"
                                + highestPriority
                                + "') among candidates: "
                                + candidates.keySet());
            }
            return highestPriorityBeanName;
        }

        return (defaultFallbacks.size() == 1)
                ? defaultFallbacks.get(0).getKey()
                : null; // all explicit priorities are worse than multiple eligible defaults; exception will be thrown outside
    }
}

This gets injected via spring.factories providing custom ApplicationContextFactory injecting the BeanFactory with the above override via c-tor of AnnotationConfigServletWebServerApplicationContext on app startup.

BTW I'm open for comments, this is the WIP implementation (although it does work nicely so far in both tests and organic code).

FyiurAmron avatar Nov 03 '23 12:11 FyiurAmron

Along with #26528, I'm considering a @Fallback qualifier annotation that effectively serves as a @Primary companion that will be evaluated when no primary bean has been found, filtering out fallback beans and seeing whether have a single non-fallback bean left then (or in case of multiple being left, continuing with the priority selection from there).

jhoeller avatar Feb 05 '24 08:02 jhoeller