学习 Spring Boot 时,使用 https://start.spring.io/ 创建示例时都非常顺利...但很多时候一旦自己按照业务要求创建多模块项目后就出现了 Spring Boot 项目正常启动了也没报任何的错误,但是访问 Controller 时就会报404。这到底是怎么回事呢?本文希望从各方面给你一个全景的解决视图。

阅读文章的过程中如果有任何疑问,欢迎添加笔者为好友,拉您进【七日书摘】微信交流群,一起交流技术,一起打造高质量的职场技术交流圈子,抱团取暖,共同进步。
七日书摘官方群.jpg

项目目录结构不正确(@SpringBootApplication 注解的入口类未放置到正确的包名下)

比如创建了一个如下图的项目,
Spring Boot starts 404.jpg
如上图中所示,项目的启动类在 com.saiyueze.chapter.simple2 中,此时启动 ChapterSimple2Application 可正常运行,并且也可以正常访问到com.saiyueze.chapter.simple2.web这个包中的Controller方法。

如果由于某些原因,启动类ChapterSimple2Application 被移动到了非 com.saiyueze.chapter.simple2 中,此时 ChapterSimple2Application 类可以正常启动,但是此时访问 com.saiyueze.chapter.simple2.web 中的任何Controller方法都将返回 404 错误。

问题分析

此类情况是@SpringBootApplication 注解的入口类未在最外层包名下,因此造成 Spring Boot 项目时无法访问不在 @SpringBootApplication 注解的入口类所在的包名以及该包名下的所有子包中。

解决办法

可以直接把@SpringBootApplication 注解的入口类移动到最外层的包名中。
比如示例中 com.saiyueze.chapter.simple2是最外层包名,此时可以把ChapterSimple2Application移动到com.saiyueze.chapter.simple2中即可解决。

当然也可以参考使用@ComponentScan进行解决,可以在ChapterSimple2Application中添加@ComponentScan注解,并把最外层包名即com.saiyueze.chapter.simple2添加到basePackages中,以告诉 Spring Boot 项目启动时扫描该包及其子包中的类。

参考代码片段如下:

package com.saiyueze.chapter.simple2;

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

@SpringBootApplication
@ComponentScan(basePackages = {"com.saiyueze.chapter.simple2"})
public class ChapterSimple2Application {

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

}

使用 Maven 管理的多模块项目

使用 Maven 进行项目管理的工程中,通常会使用多 module 的架构设计,以满足项目的分层设计。

多 module 设计的项目,通常情况下,@SpringBootApplication 注解的入口类所在的包名和其他模块的包名都会存在差异,同时该包名也通常不是最所有模块最外层的包名,因此此时@SpringBootApplication 注解的入口类启动后,是无法访问其他模块的。

问题分析

由于多 module 项目,通常都会依据功能进行分模块设计,例如创建了如下实例结构项目:

mall-data //数据访问层
    com.saiyueze.data  //最外层包名
mall-service //业务逻辑层
    conm.saiyueze.service  //最外层包名
mall-portal //服务portal
    com.saiyueze.web  //最外层包名
        MallPortalApplication.java    //入口类
    com.saiyueze.web.controller //controller包名
        IndexController.java

在该示例中,mall-portal 会引入 mall-datamall-service 工程,并调用数据访问层和业务逻辑层代码。
其中默认情况下MallPortalApplication.java作为入口类启动时一般会出现无法正确注册 mall-datamall-service 中的服务接口Bean.
错误信息一般如下:

Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'xxxxApplication': Unsatisfied dependency expressed through field 'xxxx'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'xxxx': Unsatisfied dependency expressed through field 'xxxx'; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.xx.xx.xx' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}

解决办法

@SpringBootApplication 注解的入口类中添加@ComponentScan注解,并把最外层或者模块最外层包名添加到 basePackages 中,以告诉 Spring Boot 项目启动时扫描 basePackages 中配置的包名及其子包中的类。
示例如下:

package com.saiyueze.portal;

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

@SpringBootApplication
@ComponentScan(basePackages = {"com.saiyueze.data","com.saiyueze.service"})
public class MallPortalApplication {
    public static void main(String[] args) {
        SpringApplication.run(MallPortalApplication.class, args);
    }
}

特别说明:使用@ComponentScan注解时 basePackages 中通配符问题

从文章中可以看出来,示例中的@ComponentScan(basePackages = {"com.saiyueze.data","com.saiyueze.service"}) 中未使用通配符*,同样当你在阅读其他文章的时候,一定会有人告诉你需要修改成@ComponentScan(basePackages = {"com.saiyueze.data.*","com.saiyueze.service.*"}) 这样子,然后你又会发现一些其他的问题。而我文中提供的示例为什么又不会有后续问题呢??

抱着这样的疑问,咋们一起来看如下的示例:

配置一:`@ComponentScan(basePackages = {"com.saiyueze.chapter.simple2"})

配置二@ComponentScan(basePackages = {"com.saiyueze.chapter.simple2.*"})

配置三@ComponentScan(basePackages = {"com.saiyueze.chapter.simple2.**"})

要解决该问题,可以从ClassPathScanningCandidateComponentProvider.java中的scanCandidateComponents中我们可以找到这三个配置的异同性。

private Set<BeanDefinition> scanCandidateComponents(String basePackage) {
        Set<BeanDefinition> candidates = new LinkedHashSet<>();
        try {
            String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
                    resolveBasePackage(basePackage) + '/' + this.resourcePattern;
            Resource[] resources = getResourcePatternResolver().getResources(packageSearchPath);
            boolean traceEnabled = logger.isTraceEnabled();
            boolean debugEnabled = logger.isDebugEnabled();
            for (Resource resource : resources) {
                if (traceEnabled) {
                    logger.trace("Scanning " + resource);
                }
                if (resource.isReadable()) {
                    try {
                        MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource);
                        if (isCandidateComponent(metadataReader)) {
                            ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
                            sbd.setResource(resource);
                            sbd.setSource(resource);
                            if (isCandidateComponent(sbd)) {
                                if (debugEnabled) {
                                    logger.debug("Identified candidate component class: " + resource);
                                }
                                candidates.add(sbd);
                            }
                            else {
                                if (debugEnabled) {
                                    logger.debug("Ignored because not a concrete top-level class: " + resource);
                                }
                            }
                        }
                        else {
                            if (traceEnabled) {
                                logger.trace("Ignored because not matching any filter: " + resource);
                            }
                        }
                    }
                    catch (Throwable ex) {
                        throw new BeanDefinitionStoreException(
                                "Failed to read candidate component class: " + resource, ex);
                    }
                }
                else {
                    if (traceEnabled) {
                        logger.trace("Ignored because not readable: " + resource);
                    }
                }
            }
        }
        catch (IOException ex) {
            throw new BeanDefinitionStoreException("I/O failure during classpath scanning", ex);
        }
        return candidates;
    }

在该方法中,我们重点看

String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
                    resolveBasePackage(basePackage) + '/' + this.resourcePattern;

这句代码,如果你进行 debug 时你会发现如下现象:

配置一的 packageSearchPath 值是:

classpath*:com.saiyueze.chapter.simple2/**/*.class

配置二的 packageSearchPath 值是:

classpath*:com.saiyueze.chapter.simple2/*/**/*.class

配置三的 packageSearchPath 值是:

classpath*:com.saiyueze.chapter.simple2/**/**/*.class

根据这个scanCandidateComponents方法一路跟踪下去,你会发现**匹配任意 .class 文件和包,而*只能匹配包,因此无法扫描到包下的类,因此也就无法被Spring 容器管理。

------完------

推荐阅读:

Spring MVC面试题集(2020年2月最新版)

Spring 面试题集(2020年2月最新版)

Spring Boot 面试题集(2020年2月最新版)

更多学习讨论欢迎进入七日书摘官方群: 七日书摘官方群

七日书摘官方群群聊二维码.png