在使用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注解具体作用是什么,以及该注解可以传入哪些类作为参数,以及其大致的工作流程。

扫码领红包

微信赞赏支付宝扫码领红包

发表回复

后才能评论