一、背景
在当今的软件开发中,服务接口通常需要对应多个实现类,以满足不同的需求和场景。举例来说,假设我们是一家2B公司,公司的产品具备对象存储服务的能力。然而,在不同的合作机构部署时,发现每家公司底层的对象存储服务都不相同,比如机构A使用阿里云,机构B使用AWS S3等。针对这种情况,公司应用底层需要支持多种云存储平台,如阿里云、AWS S3等。
又由于每种云存储平台都拥有独特的API和特性,因此在设计软件时必须考虑到系统的可扩展性。通常情况下,我们会编写一个对外开放的openAPI接口,而应用底层需要根据不同的需求选择合适的实现类。
在这种情况下,如何避免硬编码并以一种优雅的方式实现上述需求成为了本篇博客要讨论的问题。
以下示例均可在 gitHub#inject-condition 仓库上找到。
二、解决方案
由于应用需要对外提供服务,我们以业内常见的Spring Boot服务应用为前提进行讨论。
在这种情况下,常见的解决方案可分为两类:SPI 和 Spring条件注解。
- SPI(Service Provider Interface):
- SPI 是一种标准的Java扩展机制,允许第三方实现提供服务的接口,并由应用程序在运行时动态加载。
- 在Spring Boot应用中,我们可以定义一个服务接口,然后多个实现类分别实现这个接口。使用SPI机制,我们可以在配置文件中指定想要使用的实现类。
- 优点:灵活性高,支持动态加载和配置。
- 缺点:需要手动管理配置文件,并且在服务实现类数量较多时,容易出现配置混乱的问题。
- Spring条件注解:
- Spring提供了一系列的条件注解,如
@ConditionalOnProperty
、@ConditionalOnClass
等,用于根据应用程序的配置或环境条件来动态地选择加载或配置Bean。 - 我们可以使用条件注解来根据应用程序的配置来选择合适的实现类。比如,可以根据配置文件中的属性来决定使用哪个实现类。
- 优点:无需手动管理配置文件,能够根据配置自动选择合适的实现类。
- 缺点:相比SPI,条件注解的动态加载能力稍逊,使用上稍显复杂,需要了解和掌握Spring的条件注解机制。
- Spring提供了一系列的条件注解,如
综上所述,针对Spring Boot服务应用中服务接口对应多个实现类的需求,我们可以选择SPI或Spring条件注解作为解决方案。
由于SPI已在另一篇博客中有详细讲解,本文将重点讲解Spring条件注解。更多关于SPI的内容可参考笔者的另一篇博客:Java SPI解读:揭秘服务提供接口的设计与应用
三、示例
3.1、场景模拟
- 在应用中新建一个
ObjectStorageService
存储接口,代码如下:
import java.io.File;public interface ObjectStorageService {/*** 上传文件到对象存储* @param file 文件* @param bucketName 存储桶名称* @param objectKey 对象键(文件名)* @return 文件在对象存储中的URL*/String uploadObject(File file, String bucketName, String objectKey);/*** 从对象存储下载文件* @param bucketName 存储桶名称* @param objectKey 对象键(文件名)* @return 文件*/File downloadObject(String bucketName, String objectKey);
}
- 接下来,我们创建了三个通过
@Service
注入的实现类。首先是默认实现类DefaultObjectStorageServiceImpl
,其次是阿里云存储服务的实现类AliyunObjectStorageServiceImpl
,最后是S3存储服务的实现类S3ObjectStorageServiceImpl
。具体的代码实现:
@Slf4j
@Service
public class DefaultObjectStorageServiceImpl implements ObjectStorageService {@Overridepublic String uploadObject(File file, String bucketName, String objectKey) {// 默认实现上传逻辑return "Default implementation: Upload successful";}@Overridepublic File downloadObject(String bucketName, String objectKey) {// 默认实现下载逻辑return new File("default-file.txt");}
}
@Slf4j
@Service
public class AliyunObjectStorageServiceImpl implements ObjectStorageService {@Overridepublic String uploadObject(File file, String bucketName, String objectKey) {// 阿里云实现上传逻辑return "Aliyun implementation: Upload successful";}@Overridepublic File downloadObject(String bucketName, String objectKey) {// 阿里云实现下载逻辑return new File("aliyun-file.txt");}
}
@Slf4j
@Service
public class S3ObjectStorageServiceImpl implements ObjectStorageService {@Overridepublic String uploadObject(File file, String bucketName, String objectKey) {// S3实现上传逻辑return "S3 implementation: Upload successful";}@Overridepublic File downloadObject(String bucketName, String objectKey) {// S3实现下载逻辑return new File("s3-file.txt");}
}
- 最后再创建一个Controller类通过
@Autowired
注解注入ObjectStorageService
,并对外开放接口,代码如下:
@Slf4j
@RestController
public class StorageController {@Autowiredprivate ObjectStorageService objectStorageService;@GetMapping("/example")public void example() {log.info("objectStorageService: {}", objectStorageService);}
}
- 此时运行应用报错信息如下:
***************************
APPLICATION FAILED TO START
***************************Description:Field objectStorageService in org.example.inject.web.controller.StorageController required a single bean, but 3 were found:- aliyunObjectStorageServiceImpl: defined in file [D:\IdeaProjects\inject-examples\inject-condition\target\classes\org\example\inject\web\service\impl\AliyunObjectStorageServiceImpl.class]- defaultObjectStorageServiceImpl: defined in file [D:\IdeaProjects\inject-examples\inject-condition\target\classes\org\example\inject\web\service\impl\DefaultObjectStorageServiceImpl.class]- s3ObjectStorageServiceImpl: defined in file [D:\IdeaProjects\inject-examples\inject-condition\target\classes\org\example\inject\web\service\impl\S3ObjectStorageServiceImpl.class]Action:Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed
错误提示StorageController
需要一个objectStorageService
bean,但是却找到了3个可用的bean:aliyunObjectStorageServiceImpl
、defaultObjectStorageServiceImpl
和s3ObjectStorageServiceImpl
。spring也提示了解决方案:
- 在其中一个实现类上添加
@Primary
注解,指示Spring优先选择这个bean。 - 修改
StorageController
以接受多个objectStorageService
,或者使用@Qualifier
注解指定要注入的特定bean。
3.2、@Qualifier解决方案
@Autowired
是Spring2.5 引入的注解,@Autowired
注解只根据类型进行注入,不会根据名称匹配。当类型无法辨别注入对象时,可以使用@Qualifier
或@Primary
注解来修饰,修改后代码如下:
@Slf4j
@RestController
public class StorageController {@Autowired@Qualifier("aliyunObjectStorageServiceImpl")private ObjectStorageService objectStorageService;@GetMapping("/example")public void example() {log.info("objectStorageService: {}", objectStorageService);}
}
@Qualifier注解中的参数是BeanID,即@Service注解所注入的实现类的名称。
- 运行应用后一切正常,命令行输入:
curl http://localhost:8080/example
,日志打印:注入成功
objectStorageService: org.example.inject.condition.service.impl.AliyunObjectStorageServiceImpl@6f2aa58b
- 遗憾的是,
@Qualifier
注解并不支持变量赋值,只能通过硬编码的方式指定具体的实现类。下面是一个错误示例:
@Slf4j
@RestController
public class StorageController {@Value("${storage.provider}")private String storageProvider;@Autowired@Qualifier("${storageProvider}")private ObjectStorageService objectStorageService;@GetMapping("/example")public void example() {log.info("objectStorageService: {}", objectStorageService);}
}
虽然我们希望通过配置变量的方式来指定具体的实现类,但是由于@Qualifier
注解的限制,这种方案并不可行,因此不推荐使用。
3.3、@Resource解决方案
- 在Spring Boot应用中,除了
@Autowired
,还可以使用@Resource
来进行依赖注入,代码如下:
import javax.annotation.Resource;@Slf4j
@RestController
public class StorageController {// @Autowired@Resourceprivate ObjectStorageService objectStorageService;@GetMapping("/example")public void example() {log.info("objectStorageService: {}", objectStorageService);}
}
-
@Resource
与@Autowired
区别在于:-
@Resource
是 JDK 原生的注解,而@Autowired
是 Spring 2.5 引入的注解。 -
@Resource
注解有两个属性:name
和type
。Spring 将@Resource
注解的name
属性解析为 bean 的名称,而type
属性则解析为 bean 的类型。因此,如果使用name
属性,则采用 byName 的自动注入策略;如果使用type
属性,则采用 byType 的自动注入策略。如果既不指定name
也不指定type
属性,则将通过反射机制使用 byName 自动注入策略。 -
@Autowired
注解只根据类型进行注入,不会根据名称匹配。当类型无法辨别注入对象时,可以使用@Qualifier
或@Primary
注解来修饰。
-
-
所以我们可以通过
@Resource
注解指定name
属性从而实现指定实现类注入,代码如下:
@Slf4j
@RestController
public class StorageController {// @Autowired@Resource(name = "aliyunObjectStorageServiceImpl")private ObjectStorageService objectStorageService;@GetMapping("/example")public void example() {log.info("objectStorageService: {}", objectStorageService);}
}
- 运行应用后一切正常,命令行输入:
curl http://localhost:8080/example
,日志打印:注入成功
objectStorageService: org.example.inject.condition.service.impl.AliyunObjectStorageServiceImpl@6f2aa58b
- 遗憾的是,
@Resource
注解也不支持变量赋值,只能通过硬编码的方式指定具体的实现类,因此不推荐使用。
3.4、@Primary解决方案
-
@Primary
是一个 Spring 框架中的注解,用于解决多个 Bean 实例同一类型的自动装配问题。当一个接口或者类有多个实现时,Spring 在自动装配时可能会出现歧义,不知道选择哪个 Bean 注入。这时候,可以使用@Primary
注解来指定首选的 Bean,这样在自动装配时就会选择这个首选的 Bean。 -
将
DefaultObjectStorageServiceImpl
设置为首选实现类,代码如下:
import org.springframework.context.annotation.Primary;@Slf4j
@Service
@Primary
public class DefaultObjectStorageServiceImpl implements ObjectStorageService {@Overridepublic String uploadObject(File file, String bucketName, String objectKey) {// 默认实现上传逻辑return "Default implementation: Upload successful";}@Overridepublic File downloadObject(String bucketName, String objectKey) {// 默认实现下载逻辑return new File("default-file.txt");}
}
StorageController
控制层恢复为最初形态,代码如下:
@Slf4j
@RestController
public class StorageController {@Autowiredprivate ObjectStorageService objectStorageService;@GetMapping("/example")public void example() {log.info("objectStorageService: {}", objectStorageService);}
}
-
运行应用,命令行输入:
curl http://localhost:8080/example
,日志打印:默认实现类注入成功objectStorageService: org.example.inject.condition.service.impl.DefaultObjectStorageServiceImpl@633df06
-
遗憾的是,
@Primary
注解也是只能通过硬编码的方式指定具体的实现类,因此不推荐使用。
3.5、@Conditional解决方案[推荐]
-
@Conditional
注解是 Spring 框架提供的一种条件化装配的机制,它可以根据特定的条件来决定是否创建一个 Bean 实例。通过@Conditional
注解,可以在 Spring 容器启动时根据一些条件来动态地确定是否创建某个 Bean,从而实现更灵活的 Bean 装配。 -
在 Spring 中,有一系列内置的条件注解,例如:
@ConditionalOnClass
:当类路径中存在指定的类时,才创建该 Bean。@ConditionalOnMissingClass
:当类路径中不存在指定的类时,才创建该 Bean。@ConditionalOnBean
:当容器中存在指定的 Bean 时,才创建该 Bean。@ConditionalOnMissingBean
:当容器中不存在指定的 Bean 时,才创建该 Bean。@ConditionalOnProperty
:当指定的配置属性满足一定条件时,才创建该 Bean。@ConditionalOnExpression
:当指定的 SpEL 表达式为 true 时,才创建该 Bean。
-
我们希望达到的效果是通过
application.properties
或application.yml
配置文件的一个配置项就可以指定具体实现类,而非通过硬编码的形式来实现,所以我们将使用@ConditionalOnProperty
配置属性条件注解实现。其余注解可参考:官网介绍 -
先看下
@ConditionalOnProperty
注解的几个入参介绍:@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) @Documented @Conditional({OnPropertyCondition.class}) public @interface ConditionalOnProperty {/*** 配置文件中 key 的前缀,可与 value 或 name 组合使用。*/String prefix() default "";/*** 与 value 作用相同,但不能与 value 同时使用。*/String[] name() default {};/*** 与 value 或 name 组合使用,只有当 value 或 name 对应的值与 havingValue 的值相同时,注入生效。*/String havingValue() default "";/*** 当该属性为 true 时,配置文件中缺少对应的 value 或 name 的属性值,也会注入成功。*/boolean matchIfMissing() default false; }
-
接下来定义配置key,在
application.properties
或application.yml
配置文件新增如下内容:storage.provider=aliyun
-
在各个实现类中新增
@ConditionalOnProperty
注解,代码如下:import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;@Slf4j @Service //@Primary @ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "default", matchIfMissing = true) public class DefaultObjectStorageServiceImpl implements ObjectStorageService {// 省略 }@Slf4j @Service @ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "aliyun") public class AliyunObjectStorageServiceImpl implements ObjectStorageService {// 省略 }@Slf4j @Service @ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "s3") public class S3ObjectStorageServiceImpl implements ObjectStorageService {// 省略 }
-
运行应用,命令行输入:
curl http://localhost:8080/example
,日志打印:objectStorageService: org.example.inject.condition.service.impl.AliyunObjectStorageServiceImpl@3b46e282
-
如果在
application.properties
或application.yml
配置文件中没有配置storage.provider
属性,则会注入DefaultObjectStorageServiceImpl
实现类。这是因为DefaultObjectStorageServiceImpl
实现类的matchIfMissing = true
属性已经指定了。 -
上述注解的实现方式是配置在每个实现类中,这种方式过于分散。为了让开发人员更清晰地了解应用的注入关系,我们应该通过
@Configuration
整合所有实现类的配置。以下是新增的WebConfiguration
配置类的代码:import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration;/*** 自动装配类*/ @Configuration public class WebConfiguration {@Bean@ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "default", matchIfMissing = true)public ObjectStorageService defaultObjectStorageServiceImpl() {return new DefaultObjectStorageServiceImpl();}@Bean@ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "aliyun")public ObjectStorageService aliyunObjectStorageServiceImpl() {return new AliyunObjectStorageServiceImpl();}@Bean@ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "s3")public ObjectStorageService s3ObjectStorageServiceImpl() {return new S3ObjectStorageServiceImpl();} }
再将各个实现类中的
@Service
,@ConditionalOnProperty
注解去掉,更改后代码如下:import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;@Slf4j public class DefaultObjectStorageServiceImpl implements ObjectStorageService {// 省略 }@Slf4j public class AliyunObjectStorageServiceImpl implements ObjectStorageService {// 省略 }@Slf4j public class S3ObjectStorageServiceImpl implements ObjectStorageService {// 省略 }
运行应用,命令行输入:
curl http://localhost:8080/example
,日志打印:objectStorageService: org.example.inject.condition.service.impl.AliyunObjectStorageServiceImpl@3b46e282
-
通过
@ConditionalOnProperty
注解和WebConfiguration
统一装配类,我们基本实现了可配置化注入实现类的方案,初步实现了我们的目标。
3.6、自定义@Conditional解决方案[强烈推荐]
在上面的示例中,我们是通过在配置文件中定义属性来决定实现类,这需要在配置文件中定义一份属性,并在各个 @ConditionalOnProperty
注解中配置 prefix
和 name
属性。以前面的示例为例,就需要进行4次配置。然而,这种方式容易出错,特别是当服务有多个接口需要配置多个实现类时,需要配置更多的属性,增加了配置的复杂性和出错的可能性,如下图所示:
根据上图中的三个接口,需要配置三个配置项以及7次 @ConditionalOnProperty
注解;因此,我们需要采用一种简化的方式来减少配置,只需要在配置文件中配置一次即可,而无需更改@ConditionalOnProperty
注解。
-
要满足上述需求,首先需要重点关注配置文件中的属性。以上面的对象存储的情景举例,一个重要的配置项是
storage.provider=aliyun
。为了更通用地解决所有接口的配置需求,建议统一将配置项命名为接口的全限定名。这种做法不仅能够确保配置项的唯一性,同时也让人一目了然,清晰明了。以上面对象存储场景为例,修改后的配置如下所示:org.example.inject.condition.service.ObjectStorageService=aliyun
-
其次希望简化
@ConditionalOnProperty
注解的编写,不再需要指定prefix = "storage", name = "provider"
等属性。而是根据注解所在位置自动分析当前返回值类的全限定名称,然后直接从配置文件中读取相应的配置项。示例如下:@Bean @ConditionalOnProperty(name = ObjectStorageService.class, matchIfMissing = true) public ObjectStorageService defaultObjectStorageServiceImpl() {return new DefaultObjectStorageServiceImpl(); }@Bean @ConditionalOnProperty(name = ObjectStorageService.class, havingValue = "aliyun") public ObjectStorageService aliyunObjectStorageServiceImpl() {return new AliyunObjectStorageServiceImpl(); }@Bean @ConditionalOnProperty(name = ObjectStorageService.class, havingValue = "s3") public ObjectStorageService s3ObjectStorageServiceImpl() {return new S3ObjectStorageServiceImpl(); }
可以观察到,除了需要配置
havingValue
属性外,其他配置项无需手动设置,使得配置变得十分简洁。 -
注意,目前Spring并未提供类似的能力来实现我们需要的条件判断,因此我们需要自定义条件注解。幸运的是,Spring 提供了条件接口,让我们可以自行创建自定义的条件类来实现所需的条件判断逻辑。首先,我们创建一个自定义条件类,它继承
Condition
接口,并编写自定义的条件判断逻辑。代码如下:import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.core.type.AnnotatedTypeMetadata; import org.springframework.util.StringUtils;/*** 自定义的条件判断类,用于根据指定类名的配置值判断是否应用某个配置。*/ public class ConditionalOnClassNameCustom implements Condition {/*** 判断是否满足条件。** @param context 条件上下文* @param metadata 注解元数据* @return 如果满足条件,则返回true;否则返回false*/@Overridepublic boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {// 获取ConditionalOnClassName注解的属性值Class<?>[] annotationValues = (Class<?>[]) metadata.getAnnotationAttributes(ConditionalOnClassName.class.getName()).get("name");String annotationClassName = annotationValues[0].getName(); // 获取类的全限定名String havingValue = (String) metadata.getAnnotationAttributes(ConditionalOnClassName.class.getName()).get("havingValue");boolean matchIfMissing = (boolean) metadata.getAnnotationAttributes(ConditionalOnClassName.class.getName()).get("matchIfMissing");// 获取配置项对应的配置值String propertyValue = context.getEnvironment().getProperty(annotationClassName);// 检查配置值是否符合预期if (StringUtils.hasText(propertyValue)) {return havingValue.equals(propertyValue);} else {return matchIfMissing;}} }
-
借助这个条件判断逻辑,我们接下来设计一个全新的条件配置注解:
ConditionalOnClassName
,它将使用前述的ConditionalOnClassNameCustom
实现类。具体代码如下:import org.springframework.context.annotation.Conditional;import java.lang.annotation.*;/*** 定义一个自定义条件注解,用于根据指定类名的配置值判断是否应用某个配置。*/ @Target({ ElementType.TYPE, ElementType.METHOD }) // 注解可以应用于类和方法 @Retention(RetentionPolicy.RUNTIME) // 注解会在运行时保留 @Documented // 注解会被包含在javadoc中 @Conditional(ConditionalOnClassNameCustom.class) // 该注解条件受到 ConditionalOnClassNameCustom 类的限制 public @interface ConditionalOnClassName {Class<?>[] value() default {}; // 作为 value 属性的别名,用于更简洁地指定需要检查的类Class<?>[] name(); // 需要检查的类的全限定名数组String havingValue() default "default"; // 期望的配置值,默认为 "default"boolean matchIfMissing() default false; // 如果配置值缺失是否匹配,默认为 false }
-
完成了上述准备工作后,接下来是验证新创建的注解。我们需要修改
WebConfiguration
配置类。代码如下:/*** 自动装配类*/ @Configuration public class WebConfiguration {@Bean@ConditionalOnClassName(name = ObjectStorageService.class, matchIfMissing = true)public ObjectStorageService defaultObjectStorageServiceImpl() {return new DefaultObjectStorageServiceImpl();}@Bean@ConditionalOnClassName(name = ObjectStorageService.class, havingValue = "aliyun")public ObjectStorageService aliyunObjectStorageServiceImpl() {return new AliyunObjectStorageServiceImpl();}@Bean@ConditionalOnClassName(name = ObjectStorageService.class, havingValue = "s3")public ObjectStorageService s3ObjectStorageServiceImpl() {return new S3ObjectStorageServiceImpl();} }
-
接下来定义配置key,在
application.properties
或application.yml
配置文件新增如下内容:org.example.inject.condition.service.ObjectStorageService=aliyun
-
运行应用,命令行输入:
curl http://localhost:8080/example
,日志打印:objectStorageService: org.example.inject.condition.service.impl.AliyunObjectStorageServiceImpl@4cf4e0a
在这个示例中,我们利用自定义条件注解简化了@ConditionalOnProperty
注解的配置,同时统一了配置文件属性命名,实现了一次配置多处使用。这种优化提高了配置的简洁性和可维护性,同时减少了配置的复杂度和错误可能性。
四、总结
本文通过自定义条件注解,简化了@ConditionalOnProperty
注解的配置,同时统一了配置文件属性命名。这一优化方案提高了系统的可维护性和稳定性。以往的配置模式需要在不同的类或方法上重复配置属性的前缀和名称,容易出错且繁琐。通过优化后的方案,只需在配置文件中一次性配置,即可在多处重复使用,简化了配置过程。这种优化提高了开发效率,降低了配置错误的风险,尤其适用于大型项目。
总的来说,通过自定义条件注解来简化配置,统一配置文件属性命名,是一种非常实用的优化方案。它不仅提高了系统的可维护性和稳定性,还能够提升开发效率,减少配置错误的可能性,是服务开发中值得推广的实践之一。
五、相关资料
- Java SPI解读:揭秘服务提供接口的设计与应用
- Spring条件注解官网介绍
- 产品SDK化转型:标准化与机构个性化定制解决方案