一、背景
在当今的软件开发中,服务接口通常需要对应多个实现类,以满足不同的需求和场景。举例来说,假设我们是一家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 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 { @Override public String uploadObject(File file, String bucketName, String objectKey) { // 默认实现上传逻辑 return "Default implementation: Upload successful"; } @Override public File downloadObject(String bucketName, String objectKey) { // 默认实现下载逻辑 return new File("default-file.txt"); } }
@Slf4j @Service public class AliyunObjectStorageServiceImpl implements ObjectStorageService { @Override public String uploadObject(File file, String bucketName, String objectKey) { // 阿里云实现上传逻辑 return "Aliyun implementation: Upload successful"; } @Override public File downloadObject(String bucketName, String objectKey) { // 阿里云实现下载逻辑 return new File("aliyun-file.txt"); } }
@Slf4j @Service public class S3ObjectStorageServiceImpl implements ObjectStorageService { @Override public String uploadObject(File file, String bucketName, String objectKey) { // S3实现上传逻辑 return "S3 implementation: Upload successful"; } @Override public File downloadObject(String bucketName, String objectKey) { // S3实现下载逻辑 return new File("s3-file.txt"); } }
- 最后再创建一个Controller类通过@Autowired注解注入ObjectStorageService,并对外开放接口,代码如下:
@Slf4j @RestController public class StorageController { @Autowired private 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 @Resource private 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 { @Override public String uploadObject(File file, String bucketName, String objectKey) { // 默认实现上传逻辑 return "Default implementation: Upload successful"; } @Override public File downloadObject(String bucketName, String objectKey) { // 默认实现下载逻辑 return new File("default-file.txt"); } }
- StorageController控制层恢复为最初形态,代码如下:
@Slf4j @RestController public class StorageController { @Autowired private 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 */ @Override public 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化转型:标准化与机构个性化定制解决方案
还没有评论,来说两句吧...