适配器模式 + Nacos动态配置,实现多源 OSS 无感切换

21 阅读5分钟

一、 前言

在一个微服务项目里,我们的 OSS 云存储服务常常需要配置诸如阿里云、腾讯云、minio 等多个云存储厂商的业务代码,而且后续无法确保是否会增添新的云存储厂商。

此时,倘若我们要修改具体使用的云存储厂商,就会致使 controller 层和 service 层发生变动,这并不符合低耦合的理念。在这种情况下,我们完全可以采用适配器模式来开展项目开发!

二、适配器模式改造

MinioUtilsAliyunUtils被适配者类作为源接口执行原子性操作的具体逻辑各不相同,想要把多个OSS共用一个相同的接口返回,就需要使用到适配器模式。

被适配器类

@Component
publicclassMinioUtil{
    @Resource
    private MinioClient minioClient;

    /**
     * 创建Bucket桶(文件夹目录)
     */
    publicvoidcreateBucket(String bucket)throws Exception {
        boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
        if(!exists) { //不存在创建
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
        }
    }

    /**
     * 上传文件
     * inputStream:处理文件的输入流
     * bucket:桶名称
     * objectName:桶中的对象名称,也就是上传后的文件在存储桶中的存储路径和文件名。
     * stream(inputStream:处理文件的输入流,-1:指定缓冲区大小的参数[-1为默认大小], 5242889L:指定文件内容长度的上限)
     */
    publicvoiduploadFile(InputStream inputStream, String bucket, String objectName)throws Exception {
        minioClient.putObject(PutObjectArgs.builder().bucket(bucket).object(objectName)
                .stream(inputStream, -15242889L).build());
    }


}

这是目标接口 (目标抽象类,即客户需要的方法),我们想要的不同OSS都可通过该接口进行操作:

/**
 * 为了方便切换任何一个oss,我们将公共方法抽取为接口,由某个oss的实现类去编写具体逻辑
 */
publicinterfaceStorageAdapter{
    /**
     * 创建bucket
     * @param bucket
     */
    voidcreateBucket(String bucket);

    /**
     * 上传文件
     * @param multipartFile
     * @param bucket
     * @param objectName
     */
    voiduploadFile(MultipartFile multipartFile, String bucket, String objectName);

    /**
     * 获取文件在oss中的url
     * @param bucket
     * @param objectName
     * @return
     */
    String getUrl(String bucket, String objectName);

}

Minio适配器类:  通过继承或者组合方式,将被适配者类(minioUtils)的接口与目标抽象类的接口转换起来,使得客户端可以按照目标抽象类的接口进行操作。

/**
 * Minio相关操作的具体逻辑
 */
@Log4j2
publicclassMinioStorageAdapterimplementsStorageAdapter{

    @Resource
    private MinioUtil minioUtil;

    @Value("${minio.url}")
    private String url;

    @Override
    @SneakyThrows//Lombok中的注解 会在编译期补上异常处理
    publicvoidcreateBucket(String bucket){
        minioUtil.createBucket(bucket);
    }

    /**
     * 上传文件
     * @param multipartFile
     * @param bucket
     * @param objectName 为空,文件路径为根目录;不为空,文件路径为objectName目录下
     */
    @Override
    @SneakyThrows
    publicvoiduploadFile(MultipartFile multipartFile, String bucket, String objectName){
        minioUtil.createBucket(bucket);
        if(objectName != null) {
            minioUtil.uploadFile(multipartFile.getInputStream(), bucket, objectName + "/" + multipartFile.getOriginalFilename());
        } else {
            minioUtil.uploadFile(multipartFile.getInputStream(), bucket, multipartFile.getOriginalFilename());
        }
    }

    /**
     * 获取文件在oss中的url
     * @param bucket
     * @param objectName
     * @return
     */
    @Override
    public String getUrl(String bucket, String objectName){
        return url + "/" + bucket + "/" + objectName;
    }
}

Aliyun适配器类

/**
 * 阿里云oss 具体实现逻辑
 */
publicclassAliStorageAdapterimplementsStorageAdapter{

    @Override
    publicvoidcreateBucket(String bucket){
        System.out.println("aliyun");
    }

    @Override
    publicvoiduploadFile(MultipartFile multipartFile, String bucket, String objectName){

    }

    @Override
    public String getUrl(String bucket, String objectName){
        return"aliyun";
    }

}

三、定义StorageConfig类来获取指定的文件适配器

通过Nacos的动态配置读取来得到当前的storageType

此时如果想再加入一个新的OSS对象(得到xxUtils jar包等,我们无法进行修改),只需新增一个xxadapter适配器类且在@Bean注解的方法中加一个else即可。

注意:这里直接使用new的方式创建实现类(实现类也不需要使用@Service注解),而不是先把所有的实现类通过注解定义出来,再直接返回对象,这样如果新增一个OSS的话,不光要加else,还需再把实现类通过直接定义出来。

@Configuration
publicclassStorageConfig{
    @Value("${storage.service.type}")
    private String storageType;
    @Bean
    public StorageAdapter storageAdapter(){
        if("minio".equals(storageType)) {
            returnnew MinioStorageAdapter();
        } elseif("aliyun".equals(storageType)) {
            returnnew AliStorageAdapter();
        } else {
            thrownew IllegalArgumentException("为找到对应的文件存储处理器");
        }
    }
}

四、新增FileService防腐

提高可维护性

/**
 * FileService防腐层 
 使用fileService(相当于domain防腐层)与adapter(相当于service层只做原子性操作)进行交互、Utils相当于dao层
 */
@Component
publicclassFileService{
    /**
     * 通过构造函数注入
     */
    privatefinal StorageAdapter storageAdapter;

    publicFileService(StorageAdapter storageAdapter){
        this.storageAdapter = storageAdapter;
    }

    /**
     * 创建bucket
     * @param bucket
     */
    publicvoidcreateBucket(String bucket){
        storageAdapter.createBucket(bucket);
    }

    /**
     * 上传图片、返回图片在minio的地址
     * @param multipartFile
     * @param bucket
     * @param objectName
     */
    public String uploadFile(MultipartFile multipartFile, String bucket, String objectName){
        storageAdapter.uploadFile(multipartFile, bucket, objectName);
        objectName = (StringUtils.isEmpty(objectName) ? "" : objectName + "/") + multipartFile.getOriginalFilename();
        return storageAdapter.getUrl(bucket, objectName);
    }
}

五、Controller层

Controller层通过注入FileService来进行操作

@RestController
@Log4j2
publicclassFileController{
    @Resource//根据名称注入
    private FileService fileService;

    /**
     * 上传文件, 返回文件在oss中的地址
     * @param uploadFile:文件, getOriginalFilename获取原始文件名
     * @param bucket:桶名称
     * @param objectName:上传后的文件在存储桶中的存储路径(存储目录)
     * @return String: 返回文件在minio的链接地址
     */
    @PostMapping("/upload")
    public Result<String> upload(MultipartFile uploadFile, String bucket, String objectName)throws Exception {
        try {
            Preconditions.checkArgument(!ObjectUtils.isEmpty(uploadFile), "文件不能为空");
            Preconditions.checkArgument(!StringUtils.isEmpty(bucket), "bucket桶名称不能为空");
            if(log.isInfoEnabled()) {
                log.info("FileController.upload.uploadFile:{}, bucket:{}, objectName:{}", uploadFile.getOriginalFilename(), bucket, objectName);
            }
            String url = fileService.uploadFile(uploadFile, bucket, objectName);
            return Result.ok(url);
        } catch (Exception e) {
            log.info("FileController.upload.error:{}", e.getMessage(), e);
            return Result.fail("上传文件失败");
        }
    }

}

六、Nacos搭建

6.1 Nacos部署

服务器需开启8848、9848端口

docker search nacos
docker pull nacos/nacos-server
# 镜像拉完之后,启动脚本
docker run -d \
  --name nacos \
  --privileged  \
  --cgroupns host \
  --env JVM_XMX=256m \
 --env MODE=standalone \
  --env JVM_XMS=256m \
  -p 8848:8848/tcp \
  -p 9848:9848/tcp \
  --restart=always \
  -w /home/nacos \
  nacos/nacos-server
  • --privileged:赋予容器扩展的特权
  • --cgroupns host:让容器使用宿主机的 cgroup 命名空间(在资源限制方面容器会遵循宿主机规则)
  • --env:设置Nacos服务使用的jvm参数
    • JVM_XMX:最大堆内存为 256m
    • JVM_XMS:初始堆内存为 256 m
  • --env MODE=standalone:nacos运行模式为单机模式
  • -w /home/nacos:指定容器内的工作目录为 “/home/nacos”,容器内执行的命令如果涉及到相对路径的操作,就会以这个目录作为当前工作目录的基准。
  • 8848:Nacos服务端端口
  • 9848:客户端gRPC请求服务端端口

6.2 引入nacos客户依赖

除了引入nacos依赖,还要引入log4j2依赖,来输出nacos日志信息

SpringCloudAlibaba 版本为2.2.6.RELEASE时,springboot版本要为2.3.8.RELEASE

图片

<!--nacos依赖(配合日志,打印nacos信息)-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    <version>2.2.6.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
    <version>2.4.2</version>
</dependency>

6.3 编写配置文件

把nacos相关配置写入bootstrap.yml文件中,项目启动后会优先读取。

图片

spring:
  application:
    name:jc-club-oss#微服务名称
profiles:
    active:dev#指定环境为开发环境
cloud:
    nacos:
      server-addr:117.72.118.73:8848
      config:
        file-extension:yaml#文件后缀名

6.4 新增配置管理

dataId:jc-club-oss-dev.yaml 服务名称+开发环境.yaml

配置内容:

图片

这时spring会根据bootstrap.yml文件中的${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}作为文件id,来读取配置

6.5 添加@RefreshScope注解开启热更新

@Value注入的变量所在类上添加注解@RefreshScop,当配置文件内容发生变化后会重新读取

当文件更新后,Bean已加入到了IOC容器,即使storageType属性值变了,Bean也无法重新加载。

所以在@Bean方法上也要加入@RefreshScop注解,当文件更新后,带有此注解的Bean能够自动重新初始化

@Configuration
@RefreshScope
publicclassStorageConfig{

    @Value("${storage.service.type}")
    private String storageType;
    
    @Bean
    @RefreshScope
    public StorageAdapter storageAdapter(){
        if("minio".equals(storageType)) {
            returnnew MinioStorageAdapter();
        } elseif("aliyun".equals(storageType)) {
            returnnew AliStorageAdapter();
        } else {
            thrownew IllegalArgumentException("为找到对应的文件存储处理器");
        }
    }
}

6.6 测试

1.type为阿里云

图片

结果为:成功返回aliyun

图片

2.修改属性为minio

图片

结果为:图片成功上传。

图片

图片

在配置文件更新时,nacos也会打印出对应的日志提示:

2024-12-03 17:05:50.719  INFO 35932 --- [.72.118.73_8848] o.s.c.e.e.RefreshEventListener           : Refresh keys changed: [storage.service.type]