前言

在橙单的基础框架中,为了最大化解耦业务逻辑与存储实现细节之间的关联性,我们采用了「插件化」的设计模式。修改文件存储类型,仅需调整上传字段的注解参数即可。目前已支持阿里云 OSS、腾讯云 COS、华为云 OBS、Minio 和本地文件等存储类型。如果您有编写新存储插件的需求,推荐参考阿里云 OSS 插件 (common-aliyun-oss) 的具体实现。

包依赖引入

不同存储插件的 Maven 依赖项是不同的,具体配置说明如下。

  • 橙单会根据当前工程在生成器中的配置,生成对应的依赖项。
  • 单体工程在业务服务的 pom.xml 中引入即可。
  • 微服务是在业务服务实现模块的 pom.xml 中引入。
  • 下面我们给出所有存储插件的 POM 定义,可根据实际需要引入或更换。
<!-- Minio插件 -->
<dependency>
   <groupId>your-group-name</groupId>
   <artifactId>common-minio</artifactId>
   <version>1.0.0</version>
</dependency>
<!-- 阿里云oss插件 -->
<dependency>
   <groupId>your-group-name</groupId>
   <artifactId>common-aliyun-oss</artifactId>
   <version>1.0.0</version>
</dependency>
<!-- 腾讯云cos插件 -->
<dependency>
   <groupId>your-group-name</groupId>
   <artifactId>common-qcloud-cos</artifactId>
   <version>1.0.0</version>
</dependency>
<!-- 华为云obs插件 -->
<dependency>
   <groupId>your-group-name</groupId>
   <artifactId>common-huaweicloud-obs</artifactId>
   <version>1.0.0</version>
</dependency>

插件配置

不同存储插件的配置项是不同的,具体配置说明如下。

  • 橙单会根据当前工程在生成器中的配置,生成对应的配置项。
  • 单体工程的配置项位于配置文件 applicaiton-dev.yml。
  • 微服务工程的配置项位于共享配置文件 application-dev.yaml。
  • 下面给出所有存储插件的配置项,可根据实际需要选取或更换。
# Minio插件配置
minio:
  enabled: true
  endpoint: http://localhost:19000
  accessKey: admin
  secretKey: admin123456
  bucketName: application
 
# 阿里云oss插件配置
aliyun:
  oss:
    enabled: true
    expireSeconds: 1000
    # 下面几项均需在申请阿里云OSS后,根据自己的实际情况进行配置。
    endpoint: your-endpoint
    accessKey: your-accessKey
    secretKey: your-secretKey
    bucketName: your-bucketname
   
# 腾讯云cos插件配置
qcloud:
  cos:
    enabled: true
    expireSeconds: 1000
    # 下面几项均需在申请腾讯云COS后,根据自己的实际情况进行配置。
    accessKey: your-accessKey
    secretKey: your-secretKey
    region: your-region
    bucketName: your-bucketname
   
# 华为云obs插件配置
huaweicloud:
  obs:
    enabled: true
    expireSeconds: 1000
    # 下面几项均需在申请华为云OBS后,根据自己的实际情况进行配置。
    endpoint: your-endpoint
    accessKey: your-accessKey
    secretKey: your-secretKey
    bucketName: your-bucketname

上传字段注解

不同存储插件所对应的注解参数是不同的,具体说明如下。

  • 橙单会根据当前工程在生成器中的配置,为指定的上传字段生成正确的注解参数。
  • 对应于上图中的配置,课程 (Course) 实体对象的 pictureUrl 属性会生成相关的字段注解。
@Data
@TableName(value = "zz_course")
public class Course {
   // ... ... 此处忽略其他不相干字段的声明代码。
   
   // 不同存储插件,UploadFlagColumn注解的storeType参数值不同。
   @UploadFlagColumn(storeType = UploadStoreTypeEnum.MINIO_SYSTEM)
   @TableField(value = "picture_url")
   private String pictureUrl;
}
  • @UploadFlagColumn 注解 storeType 的参数值定义。
public enum UploadStoreTypeEnum {

   // 本地系统。
   LOCAL_SYSTEM,

   // minio分布式存储。
   MINIO_SYSTEM,

   // 阿里云OSS存储。
   ALIYUN_OSS_SYTEM,

   // 腾讯云COS存储。
   QCLOUD_COS_SYTEM,

   // 华为云OBS存储。
   HUAWEI_OBS_SYSTEM
}

上传详解

插件化存储的目标是,在修改上传文件的存储类型时,可以最小化修改我们的业务代码。在橙单的基础架构中,我们定义了用于上传下载的基类 BaseUpDownloader 和工厂类 UpDownloaderFactory。插件化的逻辑如下。

  • 每个不同存储类型的上传下载实现类,均需要继承自 BaseUpDownloader。下面的代码将以 MinioUpDownloader 为例。
  • 在实现类 MinioUpDownloader 中,会被 @Component 注解标记为 bean 对象。
  • 每个实现类都会存在被 @PostConstruct 注解标记的方法,在服务启动和 bean 对象初始化时,自动将实现类所关联的存储类型,以及实现类的 this 自身对象,注册到 UpDownloaderFactory 工厂类中。
  • 实现类会基于底层存储介质,提供具体的上传和下载的实现方法。比如 MinioUpDownloader 会基于 MinioTemplate 模板对象操作 Minio。

Minio插件代码示例

以下代码的注释中,给出了非常详细的说明。

// 因为每个插件都是一个独立的common包,如common-minio。
// 组件包的引导配置文件,可以指定该插件是否生效。
@Slf4j
@Component
@ConditionalOnProperty(prefix = "minio", name = "enabled")
public class MinioUpDownloader extends BaseUpDownloader {
   @Autowired
   private MinioTemplate minioTemplate;
   @Autowired
   private UpDownloaderFactory factory;
   // 在服务启动和bean初始化时,将this自身对象,以及关联的存储类型(MINIO),注册到工厂对象中。
   @PostConstruct
   public void doRegister() {
       factory.registerUpDownloader(UploadStoreTypeEnum.MINIO_SYSTEM, this);
   }
   @Override
   public UploadResponseInfo doUpload(
           String serviceContextPath,
           String rootBaseDir,
           String modelName,
           String fieldName,
           Boolean asImage,
           MultipartFile uploadFile) throws Exception {
       UploadResponseInfo responseInfo = new UploadResponseInfo();
       if (Objects.isNull(uploadFile) || uploadFile.isEmpty()) {
           responseInfo.setUploadFailed(true);
           responseInfo.setErrorMessage(ErrorCodeEnum.INVALID_UPLOAD_FILE_ARGUMENT.getErrorMessage());
           return responseInfo;
       }
       // 根据当前实体对象类型和上传的字段,构建minio中的上传路径。这样可以保证上传文件的存储路径,
       // 与业务数据的关系,一目了然。
       String uploadPath = super.makeFullPath(null, modelName, fieldName, asImage);
       super.fillUploadResponseInfo(responseInfo, serviceContextPath, uploadFile.getOriginalFilename());
       // 调用minio的模板方法,把当前要上传的文件,按照计算后的目录存储到minio中。
       minioTemplate.putObject(uploadPath + "/" + responseInfo.getFilename(), uploadFile.getInputStream());
       return responseInfo;
   }
   @Override
   public void doDownload(
           String rootBaseDir,
           String modelName,
           String fieldName,
           String fileName,
           Boolean asImage,
           HttpServletResponse response) throws Exception {
       // 根据实体对象的上传字段数据,可以计算出该字段所有上传文件的所在目录。
       String uploadPath = this.makeFullPath(null, modelName, fieldName, asImage);
       String fullFileanme = uploadPath + "/" + fileName;
       // 直接从minio中下载文件数据。
       this.downloadInternal(fullFileanme, fileName, response);
   }
}
// 下面是保存到上传字段中的JSON数据格式。
@Data
public class UploadResponseInfo {

   // 上传是否出现错误。
   private Boolean uploadFailed = false;

   // 具体错误信息。
   private String errorMessage;

   // 返回前端的下载url。
   private String downloadUri;

   // 上传文件所在路径。
   private String uploadPath;

   // 返回给前端的文件名。
   private String filename;
}

插件工厂代码

主要职责为以下两个。

  • 为存储插件提供注册方法。
  • 根据存储类型,返回关联的上传下载实现插件的对象。

工厂类中,两个共有方法的实现逻辑非常简单,不用做过多的说明了,具体实现如下。

@Component
public class UpDownloaderFactory {
   private final Map<UploadStoreTypeEnum, BaseUpDownloader> upDownloaderMap = new HashMap<>();

   // 根据存储类型获取上传下载对象。
   // @param storeType 存储类型。
   // @return 匹配的上传下载对象。
   public BaseUpDownloader get(UploadStoreTypeEnum storeType) {
       BaseUpDownloader upDownloader = upDownloaderMap.get(storeType);
       if (upDownloader == null) {
           throw new UnsupportedOperationException(
                   "The storeType [" + storeType.name() + "] isn't supported.");
       }
       return upDownloader;
   }

   // 注册上传下载对象到工厂。
   public void registerUpDownloader(UploadStoreTypeEnum storeType, BaseUpDownloader upDownloader) {
       if (storeType == null || upDownloader == null) {
           throw new IllegalArgumentException("The Argument can't be NULL.");
       }
       if (upDownloaderMap.containsKey(storeType)) {
           throw new UnsupportedOperationException(
                   "The storeType [" + storeType.name() + "] has been registered already.");
       }
       upDownloaderMap.put(storeType, upDownloader);
   }
}

上传代码示例

在下面的示例代码中,需要注意的有以下几点。

  • 本例中的上传接口,通常适用于后台管理系统。文件的上传和下载都会受到操作权限和数据权限的限制,因此更加安全。
  • 上传下载字段在数据表中存储的是 JSON 数据,JSON 数据中会给出关联文件具体存储位置。
  • 根据上传字段的注解参数,获取该字段所关联的存储插件实现类。
  • 最后一点,往往很容易被忽视,文件刚刚上传成功,随即就会被下载回显,因此只有当前 Session 才有权下载当前会话刚刚上传的文件。直到所在的数据记录被保存到数据表后,才会受到数据过滤权限的约束。
@PostMapping("/upload")
public void upload(
       @RequestParam String fieldName,
       @RequestParam Boolean asImage,
       @RequestParam("uploadFile") MultipartFile uploadFile) throws Exception {
   // 这里就会判断参数中指定的字段,是否支持上传操作。
   UploadStoreInfo storeInfo = MyModelUtil.getUploadStoreInfo(Course.class, fieldName);
   if (!storeInfo.isSupportUpload()) {
       ResponseResult.output(HttpServletResponse.SC_FORBIDDEN,
               ResponseResult.error(ErrorCodeEnum.INVALID_UPLOAD_FIELD));
       return;
   }
   // 根据字段注解中的存储类型,通过工厂方法获取匹配的上传下载实现类,从而解耦。
   BaseUpDownloader upDownloader = upDownloaderFactory.get(storeInfo.getStoreType());
   // 注意,这里返回的UploadResponseInfo对象,会以JSON字符串的格式,保存到数据表的上传字段中。
   UploadResponseInfo responseInfo = upDownloader.doUpload(null,
           appConfig.getUploadFileBaseDir(), Course.class.getSimpleName(), fieldName, asImage, uploadFile);
   if (responseInfo.getUploadFailed()) {
       ResponseResult.output(HttpServletResponse.SC_FORBIDDEN,
               ResponseResult.error(ErrorCodeEnum.UPLOAD_FAILED, responseInfo.getErrorMessage()));
       return;
   }
   // 最最重要的就是下面的一行代码,将当前上传的文件与当前会话的sessionId关联后,存入Redis缓存。
   // 在调用下载接口时,只能当前session才有权下载刚刚上传的文件。
   cacheHelper.putSessionUploadFile(responseInfo.getFilename());
   ResponseResult.output(ResponseResult.success(responseInfo));
}

下载详解

在橙单中下载和上传会成对出现,在处理机制上他们也是完全相同的。

代码解析

数据下载的逻辑相对比较简单,最最主要需要考虑的是数据安全问题。通常情况下,都是会受到操作权限和数据过滤权限的约束。具体实现逻辑可参考下面的代码示例和详细的注释说明。

@GetMapping("/download")
public void download(
       @RequestParam(required = false) Long courseId,
       @RequestParam String fieldName,
       @RequestParam String filename,
       @RequestParam Boolean asImage,
       HttpServletResponse response) {
   // 使用try来捕获异常,是为了保证一旦出现异常可以返回500的错误状态,便于调试。
   // 否则有可能给前端返回的是200的错误码。
   try {
       // 如果请求参数中没有包含主键Id,说明是刚刚上传的文件,现在是保存之前的下载回显。
       // 这样就需要判断该文件是否为当前session上传的。上面的上传代码示例中,调用了 
       // cacheHelper.putSessionUploadFile(responseInfo.getFilename()),
       // 将该文件和该session关联到一起了。
       if (courseId == null) {
           if (!cacheHelper.existSessionUploadFile(filename)) {
               ResponseResult.output(HttpServletResponse.SC_FORBIDDEN);
               return;
           }
       } else {
           // 如果参数中包含主键id(courseId),说明当前记录已经被保存到数据表中了。
           Course course = courseService.getById(courseId);
           if (course == null) {
               ResponseResult.output(HttpServletResponse.SC_NOT_FOUND);
               return;
           }
           // 根据参数中的字段名,获取当前记录中,上传时生成的JSON数据对象。
           String fieldJsonData = (String) ReflectUtil.getFieldValue(course, fieldName);
           if (fieldJsonData == null) {
               ResponseResult.output(HttpServletResponse.SC_BAD_REQUEST);
               return;
           }
           // 由此可见,下载的验证确实是非常严格的,这里还要继续判断下载请求参数中的文件名,
           // 是否和当前记录上传字段中包含的文件名匹配。
           if (!BaseUpDownloader.containFile(fieldJsonData, filename)) {
               ResponseResult.output(HttpServletResponse.SC_FORBIDDEN);
               return;
           }
       }
       // 通过验证之后,获取上传字段的注解信息。
       UploadStoreInfo storeInfo = MyModelUtil.getUploadStoreInfo(Course.class, fieldName);
       if (!storeInfo.isSupportUpload()) {
           ResponseResult.output(HttpServletResponse.SC_NOT_IMPLEMENTED,
                   ResponseResult.error(ErrorCodeEnum.INVALID_UPLOAD_FIELD));
           return;
       }
       // 获取指定的存储插件。
       BaseUpDownloader upDownloader = upDownloaderFactory.get(storeInfo.getStoreType());
       // 调用插件实现类的下载方法,从指定存储介质的指定目录中,下载指定的文件,并作为Http应答数据流,
       // 直接返回给前端。
       upDownloader.doDownload(appConfig.getUploadFileBaseDir(),
               Course.class.getSimpleName(), fieldName, filename, asImage, response);
   } catch (Exception e) {
       response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
       log.error(e.getMessage(), e);
   }
}

图片静态化

从上面的 upload 和 download 接口中可以发现,都存在一个 asImage 的接口参数,如果该值为 TRUE,那么上传的所有图片文件都会存储到 /image 的子目录内,否则都会以附件形式存储在 /attachment 子目录下。简单解释一下这样设计的原因,在很多业务场景中,都是后台操作人员录入并上传业务所需的基础数据信息,如课程及课程的封面图等。上传后的图片数据,又很有可能直接被前端 APP 或网站免登录使用。有鉴于此,我们可以通过 Nginx 以静态资源的方式直接代理 /image 目录下的所有图片文件,并由此得到更高的下载效率。与此同时,还能为 CDN 提供统一的图片溯源目录。

结合上面的目录,Nginx 的参考配置如下。

server {
   listen       8087;
   server_name  localhost;
   location / {
       root   /Users/orange-form/WebRoot-report;
       index  index.html index.htm;
   }
   # 这里的配置是指向前面提到的上传和下载目录了。
   # 上面截图中的图片访问地址为:http://localhost:8087/course/pictureUrl/1.png
   location ~ .*\.(gif|jpg|pdf|jpeg|png)$ {
       root /Users/orange-form/Desktop/DemoSingle-Report/zz-resource/upload-files/app/image;
   }
}

边界性安全问题

该问题曾被橙单的用户多次问及,这里我们用专门的一个小节来进行介绍,具体业务场景如下。

  • 在新建表单中,当前表单包含上传文件字段。
  • 文件上传成功后,后台会返回上传文件的存储信息至前端,前端调用下载接口回显上传的图片。
  • 上图中的安全隐患在于,如果返回的存储信息被其他人利用,并作为下载接口的参数去直接调用,就会导致文件被越权下载的安全问题。
  • 针对以上问题,我们将上传文件的存储信息与当前会话的 SessionId 进行关联后存入 Redis 缓存,具体实现见如下文件上传代码及其关键性注释。
@PostMapping("/upload")
public void upload(
       @RequestParam String fieldName,
       @RequestParam Boolean asImage,
       @RequestParam("uploadFile") MultipartFile uploadFile) throws Exception {
   // ... ... 此处忽略了大量其余不相干代码。
 
   // 最最重要的就是下面的一行代码,将当前上传的文件与当前会话的sessionId关联后,存入Redis缓存。
   // 在调用下载接口时,只能当前session才有权下载刚刚上传的文件。
   cacheHelper.putSessionUploadFile(responseInfo.getFilename());
   ResponseResult.output(ResponseResult.success(responseInfo));
}
  • 在下载接口中,会查询下载的文件是否属于当前会话。由此可见,只有之前上传文件的会话才能正常访问该文件,具体实现见如下文件下载代码及其关键性注释。
@GetMapping("/download")
public void download(
       @RequestParam(required = false) Long courseId,
       @RequestParam String fieldName,
       @RequestParam String filename,
       @RequestParam Boolean asImage,
       HttpServletResponse response) {
   try {
       // 强调一下,必须要判断主键Id为null的条件,表示只有当新建表单时才执行该验证逻辑。
       // 否则如果表格列表中需要显示下载的图片时,就会被报出应答状态为403的禁止访问错误。
       if (courseId == null) {
           // 最最重要的就是下面的一行代码,需要判断该文件是否为当前session上传的。在上面的上传代码示例中,
           // 调用了 cacheHelper.putSessionUploadFile(responseInfo.getFilename()) 方法,
           // 将该文件和该session关联到一起了。
           if (!cacheHelper.existSessionUploadFile(filename)) {
               ResponseResult.output(HttpServletResponse.SC_FORBIDDEN);
               return;
           }
       } else {
           // ... ... 此处忽略了大量其余不相干代码。
       }
       // ... ... 此处忽略了大量其余不相干代码。
   } catch (Exception e) {
       response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
       log.error(e.getMessage(), e);
   }
}

结语

赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。