一、背景

在我们之前的文章产品SDK化转型:标准化与机构个性化定制解决方案中,我们探讨了一种基于SDK的灵活架构设计,旨在协调产品迭代与定制化功能之间的矛盾,并且具备良好的可维护性和可扩展性。

然而,在实际开发中,我们面临一个亟待解决的关键问题:即在机构定制化过程中,必须涉及对SDK内部进行改造的情况。举例来说,假设SDK中提供了用户密码加密的 /user/encryption 接口,默认的加密算法是MD5,但南京银行需要使用HASH算法进行加密。在这种情况下,我们既不能更改 /user/encryption 接口的默认实现,也不能直接修改SDK中的代码,因为这样做会影响其他合作机构的使用。因此,我们需要一种成本低、实现优雅的解决方案。

本文的目的在于提供针对这类问题的解决方案。

以下示例均可在 GitHub#inject-sdk 仓库上找到。

二、解决方案

针对这种常见情况,我们提供了两种解决方案:

  1. JDK中的SPI机制:通过Service Provider Interface(SPI)机制,实现动态加载。这种方法允许我们在运行时动态地发现和加载服务实现,为定制化提供了灵活的解决方案。
  2. Spring 的@Conditional条件注解:通过Spring自身提供的 @Conditional条件注解,根据配置文件将实现注入。这种方式使得我们可以根据特定的配置条件选择性地加载和使用不同的实现,以满足各种定制化需求。

为了便于阅读后续内容,建议读者先阅读以下两篇文章先了解这两种方案涉及到的基本概念:

  • Java SPI解读:揭秘服务提供接口的设计与应用
  • 告别硬编码:Spring 条件注解优雅应对多类场景

三、实现前提

基于以上两种解决方案,无论选择哪种都有一个实现的前提:需要将SDK模块中的具体操作层抽象成接口,即全面面向接口编程。

  1. 以上面的举例来说,SDK的 /user/encryption 接口,在其控制层(controller)中的代码如下所示:
@Slf4j
@RestController
@RequestMapping(value = "/user")
public class UserController {
    @Autowired
    private UserEncryptionService userEncryptionService;

    @GetMapping("/encryption")
    public void encryption(String password) {
        log.info("userEncryptionService: {}", userEncryptionService);
        userEncryptionService.encryptPassword(password);
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  1. 在上述代码中,可以看到,控制层调用了 userEncryptionService 接口,该接口的内容如下:
/**
 * 用户加密服务接口
 */
public interface UserEncryptionService {

    /**
     * 将用户输入的密码进行加密处理
     * @param password 用户输入的密码
     * @return 加密后的密码
     */
    String encryptPassword(String password);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  1. SDK 提供了一个默认的 MD5 加密实现类 DefaultUserEncryptionServiceImpl,代码如下:
import org.springframework.stereotype.Service;
/**
 * 默认的用户加密服务实现类,使用 MD5 加密
 */
@Service
public class DefaultUserEncryptionServiceImpl implements UserEncryptionService {

    @Override
    public String encryptPassword(String password) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(password.getBytes());
            byte[] digest = md.digest();
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            return null;
        }
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.

这种开发流程将业务操作抽象成service接口并提供默认实现,通过Spring的 @Service 注解注入到控制层,是典型的面向接口编程,也是后续解决方案实践的前提操作。

四、Spring条件注解方案实践

4.1、SDK仓库

  1. 基于上面的实现前提,首先,在SDK中创建了 WebAutoConfiguration 自动装配类,示例代码如下:
/**
 * 自动装配类
 */
@Slf4j
@Configurable
@ComponentScan
public class WebAutoConfiguration {
	@Bean
	@ConditionalOnClassName(name = UserEncryptionService.class, matchIfMissing = true)
	public UserEncryptionService userEncryptionService() {
		return new DefaultUserEncryptionServiceImpl();
	}
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

请注意,@ConditionalOnClassName 注解为自定义条件注解,其实现原理和机制在我们的文章  告别硬编码:Boot 条件注解优雅应对多类场景 中有详细介绍。

  1. 接着,在SDK的 resource 目录下创建了 META-INF/spring.factories 文件,内容如下,用于在机构仓库中自动装配:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.example.sdk.starter.WebAutoConfiguration
  • 1.
  • 2.

该文件的实现原理和机制在我们的文章产品SDK化转型:标准化与机构个性化定制解决方案中有详细介绍。

  1. DefaultUserEncryptionServiceImpl 实现类中删除了 @Service 注解,因为其注入的控制权已经交给了 WebAutoConfiguration 中。示例代码如下:
/**
 * 默认的用户加密服务实现类,使用 MD5 加密
 */
//@Service
public class DefaultUserEncryptionServiceImpl implements UserEncryptionService {
	// 省略
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

至此,SDK仓库的改动结束,接下来看机构仓库。

4.2、机构仓库

  1. 在机构仓库中,首先继承SDK仓库的 UserEncryptionService 接口,实现了使用哈希加密的用户加密服务。示例代码如下:
import java.util.Base64;

/**
 * 使用哈希加密的用户加密服务实现类
 */
public class HashUserEncryptionServiceImpl implements UserEncryptionService {

    @Override
    public String encryptPassword(String password) {
        // 这里使用示例中的一种哈希算法,您可以根据需要选择其他哈希算法
        byte[] hashedBytes = Base64.getEncoder().encode(password.getBytes());
        return new String(hashedBytes);
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  1. 接着,在机构仓库中创建装配类以进行注入控制。示例代码如下:
/**
 * 装配类
 */
@Slf4j
@Configuration
public class WebConfiguration {
    @Bean
    @ConditionalOnClassName(name = UserEncryptionService.class, havingValue = "aliyun")
    public UserEncryptionService userEncryptionService() {
        return new HashUserEncryptionServiceImpl();
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  1. 然后,在机构仓库的 application.properties 文件中编写配置,示例如下:
org.example.sdk.starter.service.UserEncryptionService=aliyun
  • 1.

请注意,org.example.sdk.starter.service.UserEncryptionService配置属性是为了配合@ConditionalOnClassName 自定义条件注解,其实现原理和机制在我们的文章 告别硬编码:Boot 条件注解优雅应对多类场景 中有详细介绍。

  1. 最后,启动机构仓库应用,并输入以下命令进行测试:
curl --location 'http://localhost:8080/user/encryption?password=example'
  • 1.

输出结果如下:

o.e.s.starter.controller.UserController  : userEncryptionService: org.example.sdk.njbank.service.impl.HashUserEncryptionServiceImpl@18ea9326
  • 1.

机构实现类注入成功

4.3、总结

通过以上步骤,我们成功地将机构自定义的实现类注入到了SDK的 UserEncryptionService 接口中,并且执行了机构端的实现逻辑,原理如下:

[SDK仓库]                                      [机构仓库]
  |                                                |
  | 创建自动装配类 WebAutoConfiguration              |
  |--------------------------------------------->  |
  |                                                |
  | 自动装配类扫描并加载机构端实现类配置                 |
  |--------------------------------------------->  |
  |                                                |
  | 检查配置文件中是否存在机构端实现类的指定配置项         |
  |--------------------------------------------->  |
  |                                                |
  | 如果配置项不存在,自动装配类使用默认实现类            |
  |--------------------------------------------->  |
  |                                                |
  | 如果配置项存在,自动装配类使用机构端指定的实现类       |
  |--------------------------------------------->  |
  |                                                |
  | 创建机构端指定的实现类 HashUserEncryptionServiceImpl |
  |--------------------------------------------->  |
  |                                                |
  | 将机构端实现类注入到SDK控制层的UserEncryptionService接口 |
  |--------------------------------------------->  |
  |                                                |
  | 注入成功,SDK控制层现在使用机构端提供的实现逻辑   	 |
  |<---------------------------------------------  |
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

这种方式几乎不需要对SDK仓库进行编码,只需在SDK端提前预留好接口,并在自动装配类中开好口子即可。随后,通过在机构仓库中使用配置文件或继承接口来自定义实现,就能轻松完成逻辑切换。这种方式对开发人员非常友好,简化了开发流程,提高了开发效率。

五、SPI方案实践

为了和上面用户加密接口场景进行区分,SPI方案实践使用新场景:假设我们是一家2B公司,公司的产品具备对象存储服务的能力。然而,在不同的合作机构部署时,发现每家公司底层的对象存储服务都不相同,比如南京银行使用阿里云,华瑞银行使用AWS S3等。针对这种情况,我们在SDK标准库中提供一个默认的对象存储服务,而在各个机构仓库中实现对应的机构实现即可。

5.1、SDK仓库

  1. 首先在SDK中新增对象存储服务接口,代码如下:
/**
* 对象存储服务接口
*/
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);

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  1. SDK提供一个默认对象存储服务实现类,代码如下:
/**
* 默认对象存储服务实现类
*/
@Slf4j
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");
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  1. 再创建对象存储服务对外接口,代码如下:
/**
 * 对象存储对外接口
 */
@Slf4j
@RestController
@RequestMapping(value = "/storage")
public class StorageController {
    @Autowired
    private ObjectStorageService objectStorageService;
    @GetMapping("/download")
    public void downloadObject(String bucketName, String objectKey) {
        log.info("objectStorageService: {}", objectStorageService);
        objectStorageService.downloadObject(bucketName, objectKey);
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

至此,SPI方案前提准备工作结束,接下来开始编写SPI方案核心部分。

  1. 创建SPI服务发现工具类,为后续机构端扩展做准备,代码如下:
/**
* spi服务发现工具类
*/
public final class DiscoveryUtils {

    private DiscoveryUtils() {
    }

    /** 按类型匹配, 取默认第一个 */
    public static <T> T discoverService(Class<T> serviceClass, T defaultService) {
        ServiceLoader<T> serviceLoader = ServiceLoader.load(serviceClass);
        if (serviceLoader.iterator().hasNext()) {
            return serviceLoader.iterator().next();
        }
        return defaultService;
    }

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  1. WebAutoConfiguration自动装备类中编写对象接口层控制注入,代码如下:
/**
 * 自动装配类
 */
@Slf4j
@Configurable
@ComponentScan
public class WebAutoConfiguration {
	@Bean
	public ObjectStorageService objectStorageService() {
        // 通过SPI服务发现工具类获取其他机构端扩展实现
		return DiscoveryUtils.discoverService(ObjectStorageService.class, new DefaultObjectStorageServiceImpl());
	}
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

请注意,关于以上SPI中的DiscoveryUtils服务发现机制,其实现原理和机制在我们的文章 Java SPI解读:揭秘服务提供接口的设计与应用 中有详细介绍。

至此,SDK仓库的改动结束,接下来看机构仓库。

5.2、机构仓库

  1. 假设南京银行内部使用阿里云作为对象存储服务,机构仓库应继承SDK的 ObjectStorageService 接口并创建阿里云实现类,代码如下:
/**
* 阿里云对象存储服务实现类
*/
@Slf4j
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");
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  1. 在机构仓库的 resources 目录下创建 META-INF/services 目录,并在其中创建 org.example.sdk.starter.service.ObjectStorageService 文件,文件内容如下:
org.example.sdk.njbank.service.impl.AliyunObjectStorageServiceImpl
  • 1.

请注意,这种通过在META-INF/services目录下创建接口名文件的做法是SPI的服务发现机制,其实现原理和机制在我们的文章 Java SPI解读:揭秘服务提供接口的设计与应用 中有详细介绍。

至此机构端改动结束。

  1. 最后,启动机构仓库应用,并输入以下命令进行测试:
curl --location 'http://localhost:8080/storage/download?bucketName=example&objectKey=example'
  • 1.

输出结果如下:

o.e.s.s.controller.StorageController     : objectStorageService: org.example.sdk.njbank.service.impl.AliyunObjectStorageServiceImpl@3ea70627
  • 1.

可以看到,机构实现类成功注入到了SDK的 ObjectStorageService 接口中并执行了阿里云的实现逻辑。

5.3、总结

通过以上步骤,我们成功地将机构自定义的实现类注入到了SDK的 ObjectStorageService 接口中,并且执行了机构端的实现逻辑,原理如下:

graph TD;
    A[SDK仓库] -->|1. 创建对象存储服务接口ObjectStorageService| B((机构仓库))
    B -->|2. 继承接口创建阿里云实现类AliyunObjectStorageServiceImpl| C
    C -->|3. 创建SPI配置文件META-INF/services/ObjectStorageService| D
    D -->|4. 写入机构端实现类路径org.example.sdk.njbank.service.impl.AliyunObjectStorageServiceImpl| E
    E -->|5. SPI服务发现工具类DiscoveryUtils| F
    F -->|6. 自动装备类 WebAutoConfiguration| G
    G -->|7. 控制注入ObjectStorageService| H
    H -->|8. 检查SPI配置| I
    I -->|9. 加载机构端实现类| J
    J -->|10. 初始化| K
    K -->|11. SDK使用机构端实现类| L
    L -->|12. 执行| M
    M -->|13. 返回执行结果或错误信息| N
    I -->|未配置| O
    O -->|14. 使用默认实现类| P
    P -->|15. SDK使用默认实现类| Q
    Q -->|16. 执行| R
    R -->|17. 返回执行结果或错误信息| S
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

这种方式的优点主要有两个:

  1. 灵活性: 通过 SPI 方式,SDK 仓库不需要关注具体的实现类,而是依赖于接口。这使得机构端可以根据自身需求选择合适的实现类,并通过配置文件指定,而不需要修改 SDK 的源代码。
  2. 可扩展性: 新的机构端实现类可以轻松添加到系统中,只需在 SPI 配置文件中添加相应的路径即可,不需要修改 SDK 的代码。

六、总结

以上两种方案均可以解决产品迭代与定制化功能之间的矛盾性,这两种方式的对比总结如下:

  1. SPI 方案
  • 优点:
  • 灵活性强:机构端可以通过 SPI 服务发现机制自由扩展功能,不影响 SDK 本身的代码。
  • 解耦性好:SDK 与机构端实现类之间松耦合,SDK 仅需提供接口,不关心具体实现。
  • 可维护性高:SDK 仅需关注接口定义,机构端实现类由机构方自行管理,易于维护和更新。
  • 缺点:
  • 配置复杂:需要机构端通过配置文件或者实现接口来指定使用哪种具体实现,不够直观。
  • 可读性差:如果机构端实现类过多,会导致 SPI 配置文件过于繁琐,不易于阅读和管理。
  1. Spring 条件注解方式
  • 优点:
  • 配置简单:通过 Spring 的条件注解方式,机构端可以直接在代码中指定使用哪种具体实现,更加直观。
  • 易读性强:条件注解直接写在代码中,一目了然,便于理解和维护。
  • 灵活性:可以根据不同的条件选择不同的实现,灵活性较高。
  • 缺点:
  • 引入了 Spring 框架依赖:如果 SDK 不依赖 Spring,引入 Spring 框架会增加额外的依赖。
  • 依赖性较强:机构端需要了解 Spring 框架的相关知识,并且依赖于 Spring 框架。

笔者更推荐使用 Spring 条件注解方式,因为大部分服务端开发天然支持 Spring 技术栈。此外,由于大多数项目都使用配置中心,如 Nacos 或 Apollo,相比 SPI 的配置方式,使用 Spring 条件注解更加方便易用。总之,开发人员应根据自身业务场景进行选择。

七、相关资料

  • 产品SDK化转型:标准化与机构个性化定制解决方案
  • Java SPI解读:揭秘服务提供接口的设计与应用
  • 告别硬编码:Spring 条件注解优雅应对多类场景