在使用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;
import org.springframework.core.type.AnnotationMetadata;
/**
* 实现ImportSelector接口后,可在其中自定义条件决定是否导入特定的类
*/
public class DemoImportSelector implements ImportSelector {
/**
* 自定义导入某些类的逻辑
*
* @param importingClassMetadata 表示被标注@Import注解的类的元数据信息
* @return 将需要导入的类的全限定名放在字符串数据返回,则会导入返回的数组中指定的类
*/
@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
注解具体作用是什么,以及该注解可以传入哪些类作为参数,以及其大致的工作流程。
微信赞赏支付宝扫码领红包