隐式扫描不到 Bean 的定义

  Java   14分钟   621浏览   0评论

隐式扫描不到 Bean 的定义

在构建 Web 服务时,我们常使用 Spring Boot 来快速构建。例如,使用下面的包结构和相关代码来完成一个简易的 Web 版 HelloWorld:

其中,负责启动程序的 Application 类定义如下:

package com.zou.application;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @author: 邹祥发
 * @date: 2022/6/20 20:39
 */
@SpringBootApplication
public class SpringbootApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootApplication.class, args);
    }
}

提供接口的 HelloWorldController 代码如下:

package com.zou.application;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author: 邹祥发
 * @date: 2022/6/20 20:40
 */
@RestController
public class HelloWorldController {
    @RequestMapping(path = "hi", method = RequestMethod.GET)
    public String hi() {
        return "hello!SpringBoot";
    }
}

上述代码即可实现一个简单的功能:访问http://localhost:8080/hi 返回 helloworld。两个关键类位于同一个包(即 application)中。其中 HelloWorldController 因为添加了@RestController,最终被识别成一个 Controller 的 Bean。

但是,假设有一天,当我们需要添加多个类似的 Controller,同时又希望用更清晰的包层次和结构来管理时,我们可能会去单独建立一个独立于 application 包之外的 Controller包,并调整类的位置。调整后结构示意如下:

实际上,我们没有改变任何代码,只是改变了包的结构,但是我们会发现这个 Web 应用失效了,即不能识别出 HelloWorldController 了。也就是说,我们找不到HelloWorldController 这个 Bean 了。这是为何?

解析

要了解 HelloWorldController 为什么会失效,就需要先了解之前是如何生效的。对于Spring Boot 而言,关键点在于 Application.java 中使用了 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 {
    //省略非关键代码
}

从定义可以看出,SpringBootApplication 开启了很多功能,其中一个关键功能就是ComponentScan,参考其配置如下:

@ComponentScan(
    excludeFilters = {@Filter(
    type = FilterType.CUSTOM,
    classes = {TypeExcludeFilter.class}
), @Filter(
    type = FilterType.CUSTOM,
    classes = {AutoConfigurationExcludeFilter.class}
)}
)

当 Spring Boot 启动时,ComponentScan 的启用意味着会去扫描出所有定义的 Bean,那么扫描什么位置呢?这是由 ComponentScan 注解的 basePackages 属性指定的,具体可参考如下定义:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Documented
@Repeatable(ComponentScans.class)
public @interface ComponentScan {
    @AliasFor("value")
    String[] basePackages() default {};
    //省略其他非关键代码
}

而在我们的案例中,我们直接使用的是 SpringBootApplication 注解定义的ComponentScan,它的 basePackages 没有指定,所以默认为空(即{})。此时扫描的是什么包?这里不妨带着这个问题去调试下(调试位置参考ComponentScanAnnotationParser#parse 方法),关键代码如下:

        Set<String> basePackages = new LinkedHashSet<>();
        // 获取@ComponentScan注解配置的basePackages属性值
        String[] basePackagesArray = componentScan.getStringArray("basePackages");
        // 将basePackages属性值加入Set集合内
        for (String pkg : basePackagesArray) {
            String[] tokenized = StringUtils.tokenizeToStringArray(
                    this.environment.resolvePlaceholders(pkg),
                    ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
            Collections.addAll(basePackages, tokenized);
        }
        // 获取@ComponentScan注解的basePackageClasses属性值
        for (Class<?> clazz : componentScan.getClassArray("basePackageClasses")) {
            // 获取basePackageClasses所在的package并加入Set集合内
            basePackages.add(ClassUtils.getPackageName(clazz));
        }
        // 如果并没有配置@ComponentScan的basePackages、basePackageClasses属性值
        if (basePackages.isEmpty()) {
            // 使用Application入口类的package作为basePackage
            basePackages.add(ClassUtils.getPackageName(declaringClass));
        }

当 basePackages 为空时,扫描的包会是 declaringClass 所在的包,在本案例中,declaringClass 就是 Application.class,所以扫描的包其实就是它所在的包,即 com.zou.application。

对比我们重组包结构前后,我们自然就找到了这个问题的根源:在调整前,HelloWorldController 在扫描范围内,而调整后,它已经远离了扫描范围(不和Application.java 一个包了),虽然代码没有一丝丝改变,但是这个功能已经失效了。所以,综合来看,这个问题是因为我们不够了解 Spring Boot 的默认扫描规则引起的。我们仅仅享受了它的便捷,但是并未了解它背后的故事,所以稍作变化,就可能玩不转了。

解决方案

针对这个案例,有了源码的剖析,我们可以快速找到解决方案了。当然了,我们所谓的解决方案肯定不是说把 HelloWorldController 移动回原来的位置,而是真正去满足需求。在这里,真正解决问题的方式是显式配置 @ComponentScan。具体修改方式如下:

package com.zou.application;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

/**
 * @author: 邹祥发
 * @date: 2022/6/20 20:39
 */
@SpringBootApplication
@ComponentScan(value = "com.zou.controller")
public class SpringbootApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootApplication.class, args);
    }
}

通过上述修改,我们显式指定了扫描的范围为com.zou.controller。不过需要注意的是,显式指定后,默认的扫描范围(即 com.zou.application)就不会被添加进去了。另外,我们也可以使用 @ComponentScans 来修复问题,使用方式如下:

@ComponentScans(value = {@ComponentScan(value = "com.zou.controller")})

顾名思义,可以看出 ComponentScans 相比较 ComponentScan 多了一个 s,支持多个包的扫描范围指定。此时,细心的你可能会发现:如果对源码缺乏了解,很容易会顾此失彼。以ComponentScan 为例,原有的代码扫描了默认包而忽略了其它包;而一旦显式指定其它包,原来的默认扫描包就被忽略了

本项目代码链接:https://github.com/Zou2021/springboot

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论