Spring - 常见编程错误之Bean的定义

30 篇文章 3 订阅
24 篇文章 8 订阅

问题一: 启动类没有扫描到 Bean

案例演示

项目结构:注意写个配置文件。
在这里插入图片描述

1.pom文件:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.2.RELEASE</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

2.启动类:

@SpringBootApplication
public class Main8080 {
    public static void main(String[] args) {
        SpringApplication.run(Main8080.class, args);
    }
}

3.Controller类:

@Controller
public class MyController {
    @GetMapping("/hello")
    @ResponseBody
    public String hello() {
        return "hello world";
    }
}

结果如下:
在这里插入图片描述

原理分析

从启动类的注解@SpringBootApplication入手,来看下它引入了什么:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
	// ... 省略
	@AliasFor(annotation = ComponentScan.class, attribute = "basePackages")
	String[] scanBasePackages() default {};
}

我们都知道用@ComponentScan这个注解可以指定扫描包的路径。也看到了@SpringBootApplication注解里面包含了scanBasePackages这个属性,允许我们在启动的时候指定对应的扫描包路径。只不过我们的案例中并没有去显示的调用,因此默认是{}

因此我们再来看下@EnableAutoConfiguration这个注解,它主要将各种项目中需要用到的类自动导入进来:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {}

我们关注其中的@AutoConfigurationPackage注解:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(AutoConfigurationPackages.Registrar.class)
public @interface AutoConfigurationPackage {}

这里可见通过@Import注解引入了Registrar这个类:

static class Registrar implements ImportBeanDefinitionRegistrar, DeterminableImports {
	// 进行Bean的注册
	@Override
	public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
		register(registry, new PackageImports(metadata).getPackageNames().toArray(new String[0]));
	}
	// ...
}

我们可以看出,在注册Bean的时候,通过构造函数创建了一个PackageImports类,重点来了,我们看下这个构造:

PackageImports(AnnotationMetadata metadata) {
	AnnotationAttributes attributes = AnnotationAttributes
			.fromMap(metadata.getAnnotationAttributes(AutoConfigurationPackage.class.getName(), false));
	List<String> packageNames = new ArrayList<>();
	// ...
	// 因为我们没有去指定任何的扫描包路径,因此这里的packageNames是空的
	if (packageNames.isEmpty()) {
		packageNames.add(ClassUtils.getPackageName(metadata.getClassName()));
	}
	this.packageNames = Collections.unmodifiableList(packageNames);
}

如果我们在启动类上没有声明任何的扫描包路径,那么就会根据当前这个启动类的元数据信息,去创建出对应的扫描路径:
在这里插入图片描述
此时对应的扫描路径为:com.application
在这里插入图片描述
Spring会扫描com.application这个路径以及其子路径下的所有Bean。而我们项目中的Controller类所在路径,并不在这个扫描路径下,因此它并不会被加载到Spring容器中。因此我们调用它的相关方法是不可行的。

解决方案

Controller类(以及你Spring项目中需要用到的Bean)放入到启动类所在路径及之下:
在这里插入图片描述
重新访问对应路径:http://localhost:8080/hello
在这里插入图片描述
切记:启动类应该放到项目的最外层。 也可以顺带复习下我写的另一篇文章:SpringBoot自动装配原理

问题二: 有参构造 Bean 报错

案例演示

创建一个带有 有参构造 函数的类

@Component
public class User {
    private String name;

    public User(String name) {
        this.name = name;
    }
}

运行起来出错(当你代码这么写的时候就已经提示出错了):
在这里插入图片描述

原理分析

背景:我们给Bean创建了一个有参构造函数。

这里做一个简单的介绍,Spring创建Bean的时候,都是通过AbstractAutowireCapableBeanFactory.createBean()方法中实现的

@Override
protected Object createBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args)
		throws BeanCreationException {
	try {
		// 创建bean
		Object beanInstance = doCreateBean(beanName, mbdToUse, args);
		// ..
		return beanInstance;
	}
	// ...catch
}

doCreateBean()函数主要是三个步骤(这里很重要,也是个重点):

  1. createBeanInstance()创建实例
  2. populateBean()属性注入
  3. initializeBean()初始化

那么毫无疑问的,构造函数的调用就是属于第一个步骤,创建实例阶段了。

protected BeanWrapper createBeanInstance(String beanName, RootBeanDefinition mbd, @Nullable Object[] args) {
	// 1.根据Class属性来解析Class
	Class<?> beanClass = resolveBeanClass(mbd, beanName);
	// ...
	// 根据参数来解析构造函数
	Constructor<?>[] ctors = determineConstructorsFromBeanPostProcessors(beanClass, beanName);
	if (ctors != null || mbd.getResolvedAutowireMode() == AUTOWIRE_CONSTRUCTOR ||
			mbd.hasConstructorArgumentValues() || !ObjectUtils.isEmpty(args)) {
		// 构造函数注入
		return autowireConstructor(beanName, mbd, ctors, args);
	}

	// 构造函数注入
	ctors = mbd.getPreferredConstructors();
	if (ctors != null) {
		return autowireConstructor(beanName, mbd, ctors, null);
	}

	// 若以上都不命中,则使用默认构造来创建
	return instantiateBean(beanName, mbd);
}

这里我们主要关注两类代码:

  • autowireConstructor(beanName, mbd, ctors, args);:根据名称、构造函数、对应的构造参数去创建一个实例。
  • instantiateBean(beanName, mbd);:调用无参构造。

那么问题来了,我们案例提供的User类,自带一个有参构造函数,参数是name,那么这里对应的应该走有参构造的实例创建逻辑,如图:(只要显式地创建了有参构造,都会优先走有参构造的逻辑)
在这里插入图片描述
可是呢,再看下args参数:它是null
在这里插入图片描述
由于传入的args参数为null,因此无法正确地执行有参构造函数,因此就报错了,并且还附带建议:

Consider defining a bean of type 'java.lang.String' in your configuration.

什么意思呢?来看下下面的解决方案.

解决方案

第一种,根据人家给的建议,给出一个String类型的Bean,作为User这个有参构造的参数。我们可以写个自定义的配置类:

@Configuration
public class MyConfig {
    @Bean
    public String userName() {
        return "Hello";
    }
}

Controller类做出修改:(User类加个get/set方法)

@Controller
public class MyController {
    @Autowired
    private User user;

    @GetMapping("/hello")
    @ResponseBody
    public String hello() {
        return user.getUserName();
    }
}

结果如下:
在这里插入图片描述
这种方式并不推荐,一般情况下,我们对于SpringBean,是不会自己去定义一个有参构造的,那么第二种:我们只需要把有参构造删除即可,让程序走默认的无参构造逻辑。

最后,关于SpringBean的创建加载原理,可以看下我写的这篇文章Spring源码系列:Bean的加载

问题三: 原型 Bean 竟指向同一个对象

案例演示

User类:指定其Scopeprototype。(即多例,希望每次调用都是一个不同的对象,默认情况下Spring容器中的Bean都是单例的

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class User {
    
}

Controller类:

@Autowired
private User user;

@GetMapping("/hello")
@ResponseBody
public String hello() {
    return Math.random() + user.toString();
}

结果如下:
在这里插入图片描述
可以发现,不管调用几次,都是同一个User对象。原型 prototype 失效了。

原理分析

代码里我们通过@Autowired注解引入的userBean,那么我们看下@Autowired注解源码:
在这里插入图片描述
其本身没啥特殊的,但是呢但是呢,上面的注释写着,请看AutowiredAnnotationBeanPostProcessor这个类,我们看下这个类的类关系图:
在这里插入图片描述
它是BeanPostProcessor的一个实现类,BeanPostProcessor用于动态的修改应用程序上下文bean即做后处理操作,这时候bean已经实例化成功。

那么就可以知道,通过@Autowired注解引入的userBean,必定是经过了一个后处理动作。 我们看下它实现的MergedBeanDefinitionPostProcessor接口下的postProcessMergedBeanDefinition方法。

@Override
public void postProcessMergedBeanDefinition(RootBeanDefinition beanDefinition, Class<?> beanType, String beanName) {
	InjectionMetadata metadata = findAutowiringMetadata(beanName, beanType, null);
	metadata.checkConfigMembers(beanDefinition);
}
↓↓↓↓↓↓↓↓↓↓findAutowiringMetadata↓↓↓↓↓↓↓↓↓↓
private InjectionMetadata findAutowiringMetadata(String beanName, Class<?> clazz, @Nullable PropertyValues pvs) {
	// Fall back to class name as cache key, for backwards compatibility with custom callers.
	String cacheKey = (StringUtils.hasLength(beanName) ? beanName : clazz.getName());
	// Quick check on the concurrent map first, with minimal locking.
	InjectionMetadata metadata = this.injectionMetadataCache.get(cacheKey);
	if (InjectionMetadata.needsRefresh(metadata, clazz)) {
		synchronized (this.injectionMetadataCache) {
			metadata = this.injectionMetadataCache.get(cacheKey);
			if (InjectionMetadata.needsRefresh(metadata, clazz)) {
				if (metadata != null) {
					metadata.clear(pvs);
				}
				// 可以猜以下,这行代码是这里最为关键的地方
				metadata = buildAutowiringMetadata(clazz);
				this.injectionMetadataCache.put(cacheKey, metadata);
			}
		}
	}
	return metadata;
}

最终的重要逻辑在于buildAutowiringMetadata()函数,关于元数据的构建:

private InjectionMetadata buildAutowiringMetadata(final Class<?> clazz) {
	if (!AnnotationUtils.isCandidateClass(clazz, this.autowiredAnnotationTypes)) {
		return InjectionMetadata.EMPTY;
	}

	List<InjectionMetadata.InjectedElement> elements = new ArrayList<>();
	// 要处理的目标对象
	Class<?> targetClass = clazz;
	// 这一块本质就是区别字段属性和方法,通过反射拿到他们。
	do {
		final List<InjectionMetadata.InjectedElement> currElements = new ArrayList<>();
		// 拿到字段元数据信息,并放入到集合elements中
		ReflectionUtils.doWithLocalFields(targetClass, field -> {
			// ...
		});
		// 拿到方法元数据信息,并放入到集合elements中
		ReflectionUtils.doWithLocalMethods(targetClass, method -> {
			// ...
		});

		elements.addAll(0, currElements);
		targetClass = targetClass.getSuperclass();
	}
	while (targetClass != null && targetClass != Object.class);
	// 将元数据信息和目标类封装成InjectionMetadata对象
	return InjectionMetadata.forElements(elements, clazz);
}

public static InjectionMetadata forElements(Collection<InjectedElement> elements, Class<?> clazz) {
	return (elements.isEmpty() ? InjectionMetadata.EMPTY : new InjectionMetadata(clazz, elements));
}

到这里为止,目标类所需的相关字段、方法的元数据信息已经拿到并且封装好,并且加入到一个集合中。接下来就需要进行属性的注入操作了。

上文提到过,创建一个bean一共有三个步骤,第二个步骤就是属性的注入操作。也就是执行populateBean函数:

protected void populateBean(String beanName, RootBeanDefinition mbd, @Nullable BeanWrapper bw) {
	// ..
	for (BeanPostProcessor bp : getBeanPostProcessors()) {
		if (bp instanceof InstantiationAwareBeanPostProcessor) {
			InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp;
			PropertyValues pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName);
			// ..
			pvs = pvsToUse;
		}
	}
	// ..
}

可见这里我们执行了postProcessProperties()方法,巧的是,AutowiredAnnotationBeanPostProcessor这个类中就有对应的实现:

@Override
public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) {
	// ..
	metadata.inject(bean, beanName, pvs);
	return pvs;
}

public void inject(Object target, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {
	Collection<InjectedElement> checkedElements = this.checkedElements;
	Collection<InjectedElement> elementsToIterate =
			(checkedElements != null ? checkedElements : this.injectedElements);
	// 这里就是第一步中,关于实例的创建过程中,元数据集合。里面包含目标类所需的字段、方法等信息。
	if (!elementsToIterate.isEmpty()) {
		for (InjectedElement element : elementsToIterate) {
			// ..
			element.inject(target, beanName, pvs);
		}
	}
}

到这里为止:

protected void inject(Object target, @Nullable String requestingBeanName, @Nullable PropertyValues pvs)
				throws Throwable {
	// 反射去将值赋值给对应的字段
	if (this.isField) {
		Field field = (Field) this.member;
		ReflectionUtils.makeAccessible(field);
		field.set(target, getResourceToInject(target, requestingBeanName));
	}
	else {
		// 如果是方法,就通过反射的方式执行对应的方法即可。
		if (checkPropertySkipping(pvs)) {
			return;
		}
		try {
			Method method = (Method) this.member;
			ReflectionUtils.makeAccessible(method);
			method.invoke(target, getResourceToInject(target, requestingBeanName));
		}
		catch (InvocationTargetException ex) {
			throw ex.getTargetException();
		}
	}
}

总结以下很简单:

  1. 通过@Autowired注解引入的类,Spring会执行对应的后处理动作。相关入口在于AutowiredAnnotationBeanPostProcessor这个类。
  2. AutowiredAnnotationBeanPostProcessor类实现了两个接口,主要做两件事。
  3. postProcessMergedBeanDefinition方法负责将目标类的字段和方法元数据信息收集起来。
  4. postProcessProperties()负责将对应的属性进行赋值。如果是方法,就执行一遍。

那么回到案例本身,为什么我指定了prototype原型,每次调用的对象还是同一个呢?

  1. 这个对象通过@Autowired注解引入。会对这个类做对应的后处理操作。
  2. 最后通过反射的方式,对这个类的字段进行赋值,方法被调用。但是这个过程只执行了一次。即通过@Autowired注解引入这一个时机。之后这个值就被固定下来了。
  3. 可以这么说,通过@Autowired注解引入的对象一定是单例的。

解决方案

我们只需要解决这一点:

  • 我们换一种方式去引入这个bean。保证每次请求,拿到的bean都是新的,而不是通过@Autowired注解被固定住的。

方案一:

 @Autowired
private ApplicationContext applicationContext;

@GetMapping("/hello")
@ResponseBody
public String hello() {
    return Math.random() + getUser().toString();
}

public User getUser() {
    return applicationContext.getBean(User.class);
}

结果如下:
在这里插入图片描述

方式二:通过Lookup注解:

@GetMapping("/hello")
@ResponseBody
public String hello() {
    return Math.random() + getUser().toString();
}

@Lookup
public User getUser() {
    return null;
}

这种方式的原理就不多解释了,大概就是:

  1. @Lookup注解代表可以在运行时用新方法代替现有的方法。
  2. 最后会通过 BeanFactory 来获取 Bean。因此能保证每次请求都能重新获取一次Bean

详细可以看Spring源码系列:标签的解析原理中关于 lookup-method标签的作用的讲解。

总结

  1. Springboot中的启动类,切记要放到项目架构的最外层。因为默认会根据你启动类所在的包路径去扫描。
  2. 项目中没有事不要在声明Bean的时候害带有参构造,如果用了请注意传参问题。否则就会报错。另外,如果有多个有参构造,Spring会默认使用无参构造。它并不知道你使用的究竟是哪一个。
  3. 使用@Autowired引入的bean是个单例,哪怕你对其设置了原型prototype也没有用。因为它做了后处理操作,通过反射机制来进行赋值,这个过程只执行一次,因此使用@Autowired引入的bean是固定不变的。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zong_0915

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值