前言
在橙单的基础框架中,为了最大化解耦业务逻辑与存储实现细节之间的关联性,我们采用了「插件化」的设计模式。修改文件存储类型,仅需调整上传字段的注解参数即可。目前已支持阿里云 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);
}
}
结语
赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。