⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7352792335904047131 「守望时空33」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

在使用Spring Boot开发后端应用程序时,很多时候我们使用四层架构来完成对单体应用程序的开发。虽然四层架构在SSM单体应用程序中能够很清晰明了地划分每一层,从数据到功能,最后到API。

但是随着我们单体项目功能的增加,项目仍然会变得更加臃肿,比如说当我们再打开很久之前的项目,或者接手其它项目时,看到侧边栏一长条的xxxDAO或者xxxService时,很多时候一时半会也是缓不过神的:

当然,这仅仅是一个还未开发完成的小型单体项目,如果功能再复杂一点,那么其臃肿程度我们将无法想象,也使得我们继续开发、维护变得困难。

这时我们可以对项目分模块,即按照功能拆分为多个Maven模块,然后可以通过依赖或者配置的方式将多个功能集成在主要模块上,甚至还可以控制是否启用某个功能。

需要注意的是,这里的应用拆分并不是把应用拆分成Spring Cloud分布式微服务多模块,而是仅对一个单体项目而言,它仍然是单体项目,但是每一个功能放在每个模块中,而不再是所有功能放在一个Spring Boot工程中。

要想实现Spring Boot模块化开发,我们可以借助@Import注解,实现在一个模块中,导入另一个模块中的类并将其也初始化为Bean注册到IoC容器。

下面,我们就通过一个简单的例子来学习一下。

1,再看@ComponentScan

在学习今天的内容之前,我们可以先回顾一下关于IoC容器扫描组件的基本知识。

1) IoC容器的扫描起点

相信大家对@SpringBootApplication这个注解并不陌生,我们创建的每个Spring Boot工程主类都长这样差不多:

package com.gitee.swsk33.mainmodule;

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

@SpringBootApplication
public class MainModuleApplication {

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

}

从初学Spring Boot开始,我们就知道要想让一个类被扫描并实例化成Bean交给IoC容器托管,除了给那些类标注相关的注解(比如@Component)之外,还需要将其放在主类(也就是标注了@SpringBootApplication的类)所在的软件包或者其子包层级下,这样在IoC容器初始化时,我们的类才会被扫描到。

可见@SpringBootApplication事实上标注了IoC容器创建Bean时扫描的起点,不过@SpringBootApplication是一个复杂的复合注解,它是下列注解的组合:

而事实上,真正起到标注扫描起点作用的注解是@ComponentScan,当该注解标注在一个类上时,这个类就会被标记为IoC容器的扫描起点,相信大家初学Spring时都写过这样类似的入门示例:

package com.gitee.swsk33.springdemo;

import com.gitee.swsk33.springdemo.service.MessageService;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.ApplicationContext;

@ComponentScan
public class Main {

public static void main(String[] args) {
// 创建基于注解的上下文容器实例,并传入配置类Main以实例化其它标注了Bean注解的类
ApplicationContext context = new AnnotationConfigApplicationContext(Main.class);
// 利用Spring框架,取出Bean:MessageService对象
MessageService messageService = context.getBean(MessageService.class);
// 这时,就可以使用了!
messageService.printMessage();
}

}

可见上述是我的主类,其标注了@ComponentScan注解,主类位于软件包com.gitee.swsk33.springdemo,那么IoC容器初始化时,就会递归扫描位于软件包com.gitee.swsk33.springdemo中及其下所有子包中标注了相关注解(例如@Component、@Service)的类,并将它们实例化为Bean放入IoC容器托管,上述代码中,MessageService位于com.gitee.swsk33.springdemo中的子包service下且标注了相关注解,因此能够被实例化为Bean并放入IoC容器,后续我们可以取出。

事实上,无论是@ComponentScan还是@SpringBootApplication注解,都是可以指定扫描位置的,比如说:

@SpringBootApplication(scanBasePackages = "com.gitee.swsk33.mainmodule")
public class MainModuleApplication {
// ...
}

这表示启动程序时指定扫描软件包com.gitee.swsk33.mainmodule中及其所有子包下对应的类,只不过平时大多数时候我们都缺省这个参数,这样默认情况下,@ComponentScan或者@SpringBootApplication就是以自身为起点向下扫描当前包以及所有的子包中的类了。

2) 导入其它模块作为依赖?

首先假设现在有一个Maven多模块工程,其中有三个Spring Boot工程如下:

上述是一个按照功能拆分的Spring Boot多模块的项目示例,main-module工程是主功能,而另外两个是两个子功能模块,主功能模块需要以Maven依赖的形式导入子功能模块,它们才能组成一个完整的系统。

如果说现在在上述主功能中,将功能1以Maven依赖形式引入,启动主功能,功能1模块中的FunctionOneService类也会被扫描到并实例化为Bean吗?

很显然并不会。因为主功能中主类位于软件包com.gitee.swsk33.mainmodule中,那么启动时就会扫描该软件包及其子包下的类,不可能说扫描到功能1中的软件包com.gitee.swsk33.functionone了。

当然,这个问题很好解决,我们可以在@SpringBootApplication注解中指定scanBasePackages字段将两个子模块的包路径加进去就行了,这样确实没有问题,但是好像总觉得不是很优雅:如果我需要按需停用或者启用功能,那就需要修改这个主类的注解中传入的参数。

有没有别的办法呢?当然,@Import注解也可以实现这个功能。

2,@Import注解的基本使用

@Import注解通常标注在配置类上,它可以在IoC容器初始化当前配置类的同时,将其它的指定类也引入进来并初始化为Bean,例如:

@Configuration
@Import(DemoFunction.class)
public class FunctionImportConfig {
}

可见上述FunctionImportConfig是一个配置类,该类会在IoC容器初始化时被扫描并初始化为Bean,那么在IoC容器扫描这个FunctionImportConfig的同时,也会读取到它上面的@Import注解,而@Import注解中指定了类DemoFunction,这就可以使得DemoFunction类也被加入扫描的候选类,最终也被实例化为Bean并交给IoC容器。

事实上,无论被标注@Import的类放在哪里,主要这个类能被扫描到,且标注了@Configuration等注解、能被实例化为Bean,那么其上的@Import注解中指定的类也会被连带着加入扫描以及初始化为Bean的候选。

当然,上述这个被导入的DemoFunction类也是有要求的,它必须是一个配置类,分下面两种情况讨论:

  • 被导入的DemoFunction是@Configuration标注的类: Spring会将这个DemoFuntion配置类初始化为Bean并加载到IoC容器中,这意味着只有该配置类本身、以及其中显示声明的Bean才会被加载到容器中,其他未声明的bean则不会被加载
  • 被导入的DemoFunction是@ComponentScan标注的类: Spring则会在导入该配置类同时,还会根据@ComponentScan指定的扫描包路径,扫描其指定的全部包下对应的类(标注了@Component等等注解的)并初始化为Bean,默认则是将该类及其所在包的所有子包下的相关类初始化为Bean

回到上面的多模块项目场景中,可见我们只需要使用@Import注解不就可以在主模块中,把功能1模块中的类全部导入并初始化为Bean吗?

下面,我们就来尝试一下。

1) 导入其它模块的@ComponentScan类

大家可以根据上述工程结构创建一个多模块Maven项目,先是创建一个父模块的pom.xml,然后主模块、功能模块1和功能模块2都继承这同一个父项目,这样它们之间可以相互引用。

首先我们来看功能模块1,该模块作为一个功能,不需要作为一个完整的Spring Boot应用程序启动,因此该模块中不需要主类,只编写起点配置类和功能代码(比如Service层的类)即可,删除功能模块1的全部依赖,然后只加一个spring-boot-starter作为一些注解的基本支持即可:

然后删除功能模块1的主方法main,并将@SpringBootApplication改成@ComponentScan,仅作为扫描起点类即可,该类位于功能模块1最顶层软件包中,其中内容如下:

package com.gitee.swsk33.functionone;

import org.springframework.context.annotation.ComponentScan;

@ComponentScan
public class FunctionOneApplication {
}

然后再给功能模块1开发一个Service类,内容如下:

package com.gitee.swsk33.functionone.service;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class FunctionOneService {

@PostConstruct
private void init() {
log.info("功能1,启动!");
}

}

可见使其被初始化为Bean时打印一句话,让我们知道该类被扫描并且被初始化即可。

现在回到主模块,在其中将功能模块1以依赖形式引入:

然后在主模块中创建一个配置类,使用@Import导入功能模块1中的扫描起点(标注了@ComponentScan的类):

package com.gitee.swsk33.mainmodule.config;

import com.gitee.swsk33.functionone.FunctionOneApplication;
import com.gitee.swsk33.functiontwo.FunctionTwoApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

/**
* 用于导入其它模块的配置,使得其它模块中的Bean也能够交给IoC托管
*/
@Configuration
@Import(FunctionOneApplication.class)
public class FunctionImportConfig {
}

事实上,@Import可以导入多个类,传入数组形式即可,这里我们只导入模块1的起点类。

现在,启动主模块,可见模块1中的服务类也被成功扫描到并初始化为Bean了:

可见当我们的主模块启动时:

  • 首先初始化主模块中的配置类FunctionImportConfig,同时读取到该配置类上的@Import注解中指定的模块1中的类FunctionOneApplication
  • 模块1中的类FunctionOneApplication被@ComponentScan标注,因此新增扫描起点,将FunctionOneApplication所在的包及其所有子包也加入扫描路径
  • 这样不仅仅主模块自身,还有模块1下所有标注了对应注解的类都被扫描并初始化为了Bean,并加入了IoC容器中
  • 这样,我们就可以在主模块中,自动装配模块1中的类了

可见@Import注解可以很方便地将一个其它模块,甚至其它外部库中的对应配置类导入,并加入扫描初始化为Bean,加入到我们当前的IoC容器中去,并且在我们使用@Import导入@ComponentScan标注的类时,可以实现新增一个扫描起点的效果,而不仅仅是只扫描我们当前项目中的包路径,这样就将其它模块中的包路径也加入扫描。

2) 封装@Import注解

事实上,@EnableAsync以及@EnableDiscoveryClient这些注解,都是基于@Import实现的,当我们给自己项目的主类或者某个配置类打上该注解时,就能够启用某些功能,反之对应功能不会加载。

我们也可以来封装一个@EnableFunctionOne注解,在主模块中编写该注解代码如下:

package com.gitee.swsk33.mainmodule.annotation;

import com.gitee.swsk33.functionone.FunctionOneApplication;
import org.springframework.context.annotation.Import;

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

/**
* 结合@Import注解,实现注解控制功能模块1是否启用
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(FunctionOneApplication.class)
public @interface EnableFunctionOne {
}

可见我们定义一个注解类,然后在这个注解类上标注@Import注解,并在其中指定需要导入的类,比如功能1的扫描起点。

现在我们可以删除之前主模块中的FunctionImportConfig,而是在主模块启动类上标注我们这个自定义注解:

启动项目,可以达到相同的效果:

可见使用这种方式似乎更加地“优雅”了,我们也可以通过是否标注注解,来控制某个功能的开启或者关闭。

这也说明Spring在扫描注解时是会递归解析注解的,当其扫描到读取到主类的@EnableFunctionOne时,也会读取到@EnableFunctionOne中的@Import注解,并获取要导入的类的信息,完成导入。

事实上,大家还可以尝试将这个@EnableFunctionOne放在别的地方,比如某个配置类上,也可以起到一样的效果。

3,动态导入

上面只有在@Import中声明的类就会被导入,那么能不能更加灵活一点控制类的导入呢?事实上也是可以的。

事实上,@Import中指定的类,可以有三种:

  • @Configuration或者@ComponentScan标注的配置类
  • 实现了ImportSelector接口的类
  • 实现了ImportBeanDefinitionRegistrar接口的类

上面我们只是涉及到了第1种用法,而另外的用法可以通过自定义代码的方式,实现自定义的导入逻辑。

下面,我们就来一一探索一下其它的用法。

现在大家可以新建一个模块2,和模块1一样只有一个起点类,和一个服务类,并在服务类中通过@PostConstruct在启动时打印一个消息。

1) 指定实现了ImportSelector接口的类

ImportSelector接口是Spring中的一个扩展接口,用于动态地控制哪些配置类应该被导入。通过实现ImportSelector接口,我们就可以根据特定的条件或逻辑在运行时决定要导入的配置类。

这个接口定义了一个方法selectImports,该方法返回一个字符串数组,数组中就包含了需要导入的配置类的全限定类名。Spring在加载配置类时会调用selectImports方法,并根据方法返回的类名动态地导入对应的类并初始化为Bean。

我们在主模块中新建一个实现了ImportSelector接口的类如下:

package com.gitee.swsk33.mainmodule.selector;

import org.springframework.context.annotation.ImportSelector;

的全限定名放在字符串数据返回,则会导入返回的数组中指定的类
*/
@Override
public String[] selectImports(AnnotationMetadata importingClassMetadata) {
// 打印被标注@Import的类的元数据
System.out.println("被标注@Import的类名:" + importingClassMetadata.getClassName());
// 直接引入第一个和第二个功能的主类
return new String[]{
"com.gitee.swsk33.functionone.FunctionOneApplication",
"com.gitee.swsk33.functiontwo.FunctionTwoApplication"
};
}

}

上述代码中,大家可以通过注释了解一下该接口方法及其参数、返回值的意义,这里我直接返回了字符串数组,其中指定了需要导入的其它模块的起点类的全限定名。

然后再创建一个配置类使用@Import导入上述实现了ImportSelector接口的类即可:

package com.gitee.swsk33.mainmodule.config;

import com.gitee.swsk33.mainmodule.selector.DemoImportSelector;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;


/**
* 指定导入实现了ImportSelector的类,然后就会根据其中selectImports方法返回值,实现自定义导入指定类
*/
@Configuration
@Import(DemoImportSelector.class)
public class FunctionImportConfig {
}

启动:

可见这一次我们的应用程序初始化时,在加载FunctionImportConfig配置类时,读取@Import注解,而其中指定的类是一个实现了ImportSelector的类,那么这时Spring框架就会执行实现了ImportSelector的类中的接口方法selectImports,并获取其返回值,根据返回值指定的全限定类名引入相关的类,并初始化为Bean。

需要注意的是:

  • 实现ImportSelector接口的类无需标注@Component等注解
  • 接口方法selectImports返回的需要导入的类,也无需一定要是配置类,而可以是任何标注了@Component等等相关Bean注解的类

大家也可以将上述这个@Import注解进行封装,实现一个自己的@EnableXXX注解。

2) 指定实现了ImportBeanDefinitionRegistrar接口的类

ImportBeanDefinitionRegistrar接口是Spring中的另一个扩展接口,它允许我们在运行时动态地注册BeanDefinition,从而实现更高级的配置管理。与ImportSelector不同的是,ImportBeanDefinitionRegistrar不仅可以导入配置类,还可以动态地注册Bean定义。

ImportBeanDefinitionRegistrar接口定义了一个方法registerBeanDefinitions,该方法接受两个参数:

  • AnnotationMetadata 用于获取当前类的注解信息
  • BeanDefinitionRegistry 用于注册Bean定义

通过实现ImportBeanDefinitionRegistrar接口,我们就可以根据特定的条件或逻辑在运行时注册Bean定义,从而实现更加灵活和动态的配置管理。

现在,我们在主模块创建一个实现了ImportBeanDefinitionRegistrar接口的类如下:

package com.gitee.swsk33.mainmodule.selector;

import com.gitee.swsk33.functionone.FunctionOneApplication;
import com.gitee.swsk33.functiontwo.FunctionTwoApplication;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.GenericBeanDefinition;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.type.AnnotationMetadata;

/**
* 实现ImportBeanDefinitionRegistrar接口后,可在其中使用自定义的逻辑,实现动态地将对应类注册为Bean
*/
public class DemoImportSelector implements ImportBeanDefinitionRegistrar {

/**
* 定义一个自定义逻辑,在其中可以动态地将对应的类注册为Bean
*
* @param importingClassMetadata 标注了@Import注解的类的元数据
* @param registry 用于将指定类注册到IoC容器
*/
@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
// 注册两个功能模块中标注了@ComponentScan的类为Bean
// 定义一个Bean定义对象,传入第一个模块的@ComponentScan配置类
GenericBeanDefinition functionOneScanBean = new GenericBeanDefinition();
functionOneScanBean.setBeanClass(FunctionOneApplication.class);
// 表示第二个模块的Bean定义对象
GenericBeanDefinition functionTwoScanBean = new GenericBeanDefinition();
functionTwoScanBean.setBeanClass(FunctionTwoApplication.class);
// 将两个定义对象进行注册,这样上述两个类就会被注册为Bean
registry.registerBeanDefinition("functionOneComponentScan", functionOneScanBean);
registry.registerBeanDefinition("functionTwoComponentScan", functionTwoScanBean);
}

}

可见这个类和上述实现了ImportSelector接口的类作用一样,都是自定义导入其它类的逻辑,不过方式不一样,首先我们创建GenericBeanDefinition实例,并指定需要导入的类,然后借助BeanDefinitionRegistry参数传入我们的GenericBeanDefinition实例,实现将对应的类导入并注册为Bean。

同样地,现在只需要再在某个配置类中使用@Import指定这个实现了ImportBeanDefinitionRegistrar接口的类即可:

package com.gitee.swsk33.mainmodule.config;

import com.gitee.swsk33.mainmodule.selector.DemoImportSelector;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;


/**
* 指定导入实现了ImportSelector的类,然后就会根据其中selectImports方法返回值,实现自定义导入指定类
*/
@Configuration
@Import(DemoImportSelector.class)
public class FunctionImportConfig {
}

启动:

可见这和ImportSelector大同小异,过程当然也是差不多的:初始化配置类FunctionImportConfig时,读取到@Import注解中指定的类,并运行该类接口方法registerBeanDefinitions,完成对Bean的注册。

4,结合Spring Bean条件注解

除了实现对应的接口来实现自定义的导入逻辑之外,事实上我们还可以借助Spring Bean的条件注解来通过配置或者其它方式来控制@Import是否触发生效。

比如说在标注@Import的配置类上使用@ConditionalOnProperty注解:

package com.gitee.swsk33.mainmodule.config;

import com.gitee.swsk33.functionone.FunctionOneApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

/**
* 用于导入其它模块的配置,使得其它模块中的Bean也能够交给IoC托管
* 还可以借助Spring的条件注解例如@ConditionalOnProperty,实现通过配置或者其它条件动态控制这个配置类是否加载,进而实现控制@Import是否生效
*/
@Configuration
@Import(FunctionOneApplication.class)
@ConditionalOnProperty(prefix = "com.gitee.swsk33.function-one", name = "enabled")
public class FunctionImportConfig {
}

@ConditionalOnProperty注解可以用来根据配置文件条件,控制某个类是否被初始化为Bean,例如上述注解配置表示:配置文件中必须存在配置项com.gitee.swsk33.function-one.enabled且其值必须为true时,这个配置类FunctionImportConfig才会被加载并实例化为Bean,只有这样@Import才会被读取到,进而触发导入。

这时,就可以通过application.properties控制是否导入功能模块1了:

# 开启功能1
com.gitee.swsk33.function-one.enabled=true

事实上,Spring提供了好几个能够控制Bean是否加载和实例化的条件注解,我们可以使用这些注解设定一些条件,使得Bean可以根据我们的条件来决定是否加载并实例化。

5,总结

可见借助@Import注解,可以很方便地实现自定义导入对应的配置类,甚至是新增扫描起点,这对于我们Spring Boot模块化开发是一个有利的工具。也可见该注解功能非常强大,可以通过实现对应接口方法,完成灵活地自定义导入。

大家需要理解@Import注解具体作用是什么,以及该注解可以传入哪些类作为参数,以及其大致的工作流程。

文章目录
  1. 1. 1,再看@ComponentScan
    1. 1.0.1. 1) IoC容器的扫描起点
    2. 1.0.2. 2) 导入其它模块作为依赖?
  • 2. 2,@Import注解的基本使用
    1. 2.0.1. 1) 导入其它模块的@ComponentScan类
    2. 2.0.2. 2) 封装@Import注解
  • 3. 3,动态导入
    1. 3.0.1. 1) 指定实现了ImportSelector接口的类
    2. 3.0.2. 2) 指定实现了ImportBeanDefinitionRegistrar接口的类
  • 4. 4,结合Spring Bean条件注解
  • 5. 5,总结