Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce support for non-@Primary fallback beans #26241

Closed
raphw opened this issue Dec 8, 2020 · 15 comments
Closed

Introduce support for non-@Primary fallback beans #26241

raphw opened this issue Dec 8, 2020 · 15 comments
Assignees
Labels
in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement
Milestone

Comments

@raphw
Copy link

raphw commented Dec 8, 2020

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.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Dec 8, 2020
@quaff
Copy link
Contributor

quaff commented Dec 9, 2020

I have a similar request, see #18201

@quaff
Copy link
Contributor

quaff commented Dec 9, 2020

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;

	}

}

@OLibutzki
Copy link

OLibutzki commented Dec 9, 2020

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?

@quaff
Copy link
Contributor

quaff commented Dec 9, 2020

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.

@msangel

This comment has been minimized.

@snicoll

This comment has been minimized.

@onacit
Copy link

onacit commented Nov 10, 2021

I want my @NonPrimary annotation. Thanks.

@rstoyanchev rstoyanchev added the in: core Issues in core modules (aop, beans, core, context, expression) label Nov 10, 2021
@hjohn
Copy link

hjohn commented Aug 10, 2022

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: #26528 (comment)

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.

@deathwaiting
Copy link

almost 3 years later and no updates here ?

@sbrannen sbrannen changed the title Introduce @Secondary beans in ressemblence of @Primary beans. Introduce support for @Secondary fallback beans Sep 26, 2023
@FyiurAmron
Copy link

@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
Copy link

FyiurAmron commented Nov 2, 2023

related: #31544 (discussion about a simple and more versatile solution for a particular subcase)

@hjohn
Copy link

hjohn commented Nov 2, 2023

Or see this potential solution, inspired by CDI: #26528 (comment)

@hjohn
Copy link

hjohn commented Nov 3, 2023

@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 {
}

@FyiurAmron
Copy link

FyiurAmron commented Nov 3, 2023

@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).

@jhoeller jhoeller self-assigned this Nov 24, 2023
@jhoeller jhoeller added type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Dec 21, 2023
@jhoeller jhoeller added this to the 6.2.x milestone Dec 21, 2023
@jhoeller
Copy link
Contributor

jhoeller commented Feb 5, 2024

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 jhoeller changed the title Introduce support for @Secondary fallback beans Introduce support for non-@Primary fallback beans Feb 5, 2024
@jhoeller jhoeller modified the milestones: 6.2.x, 6.2.0-M1 Feb 5, 2024
@bclozel bclozel changed the title Introduce support for non-@Primary fallback beans Introduce support for non-@Primary fallback beans Feb 14, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests