# 使用对象存储实现文件上传下载

作者:程序员鱼皮 (opens new window)编程导航星球 (opens new window) 编号 1

# 基本思路

首先我们要思考:将文件上传到哪里?从哪里下载?

最简单的方式就是上传到后端项目所在的服务器,直接使用 Java 自带的文件读写 API 就能实现。但是,这种方式存在不少缺点,比如:

  1. 不利于扩展:单个服务器的存储是有限的,如果存满了,只能再新增存储空间或者清理文件。
  2. 不利于迁移:如果后端项目要更换服务器部署,之前所有的文件都要迁移到新服务器,非常麻烦。
  3. 不够安全:如果忘记控制权限,用户很有可能通过恶意代码访问服务器上的文件,而且想控制权限也比较麻烦,需要自己实现。
  4. 不利于管理:只能通过一些文件管理器进行简单的管理操作,但是缺乏数据处理、流量控制等多种高级能力。

因此,除了存储一些需要清理的临时文件之外,我们通常不会将用户上传并保存的文件(比如用户头像)直接上传到服务器,而是更推荐大家使用专业的第三方存储服务,专业的工具做专业的事。其中,最常用的便是 对象存储

# 什么是对象存储?

对象存储是一种存储 海量文件分布式 存储服务,具有高扩展性、低成本、可靠安全等优点。

比如开源的对象存储服务 MinIO,还有商业版的云服务,像亚马逊 S3(Amazon S3)、阿里云对象存储(OSS)、腾讯云对象存储(COS)等等。

我个人更推荐大家使用第三方云服务,不要自己再去搭建 MinIO 之类的,咱学习主打一个快速!

鱼皮使用最多的对象存储服务当属腾讯云的 COS 了,除了基本的对象存储的优点外,还可以通过控制台、API、SDK 和工具等多样化方式,简单快速地接入 COS,进行多格式文件的上传、下载和管理,实现海量数据存储和管理。

本教程中,将用腾讯云的 COS 带大家实现文件的上传和下载。鱼皮之前搭建的图床就是使用了 COS 对象存储实现,很简单。

# 创建并使用

首先进入对象存储的控制台,创建存储桶。

地址:https://console.cloud.tencent.com/cos/bucket

可以把存储桶理解为一个存储空间,和文件系统类似,都是根据路径找到文件或目录(比如 /test/aaa.jpg)。可以多个项目共用一个存储桶,也可以每个项目一个。

点击创建存储桶,注意地域选择国内(离用户较近的位置)。此处访问权限先选择“公有读私有写”,因为我们的存储桶要存储允许用户公开访问的代码生成器图片。而如果整个存储桶要存储的文件都不允许用户访问,建议选择私有读写,更安全。

默认告警一定要勾选!因为对象存储服务的存储和访问流量都是计费的,超限后我们要第一时间得到通知并进行相应的处理。

不过也不用太担心,自己做项目的话一般是没人攻击你的,而且对象存储很便宜,正常情况下消耗的费用寥寥无几。

img

然后一直点击“下一步”即可:

img

img

开通成功后,我们可以试着使用 web 控制台上传和浏览文件:

img

上传文件后,可以使用对象存储服务为我们生成的默认域名,在线访问图片:

img

当然,一般情况下我们会使用程序来操作存储桶,下面就来实现。

# 后端操作对象存储

如何在 Java 程序中使用对象存储呢?

其实非常简单,一般情况下,第三方服务都会提供比较贴心的文档教程,比如这里我们参考官方的快速入门或 Java SDK 文档,就能快速入门基本操作(增删改查都有)。

文档地址:https://cloud.tencent.com/document/product/436/10199

还有更高级的学习操作方法,如果你是腾讯云熟练用户,可以直接使用 API Explorer,在线寻找操作和示例代码。

地址:https://console.cloud.tencent.com/api/explorer?Product=cos&Version=2018-11-26&Action=PutBucket

img

# 1、初始化客户端

参考官方文档,我们要先初始化一个 COS 客户端对象,和对象存储服务进行交互。

img

对于我们的项目,只需要复用一个 COS 客户端对象即可,所以我们可以通过编写配置类初始化客户端对象。

1)打开 yuzi-generator-web-backend 项目,在 config 目录下新建 CosClientConfig 类。负责读取配置文件,并创建一个 COS 客户端的 Bean。

后端万用模板中已经整合了该文件,不用自己编写。

代码如下:

@Configuration
@ConfigurationProperties(prefix = "cos.client")
@Data
public class CosClientConfig {

    /**
     * accessKey
     */
    private String accessKey;

    /**
     * secretKey
     */
    private String secretKey;

    /**
     * 区域
     */
    private String region;

    /**
     * 桶名
     */
    private String bucket;

    @Bean
    public COSClient cosClient() {
        // 初始化用户身份信息(secretId, secretKey)
        COSCredentials cred = new BasicCOSCredentials(accessKey, secretKey);
        // 设置bucket的区域, COS地域的简称请参照 https://www.qcloud.com/document/product/436/6224
        ClientConfig clientConfig = new ClientConfig(new Region(region));
        // 生成cos客户端
        return new COSClient(cred, clientConfig);
    }
}
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
26
27
28
29
30
31
32
33
34
35

2)填写配置文件。

一定要注意防止密码泄露! 所以我们新建 application-local.yml 文件,并且在 .gitignore 中忽略该文件的提交,这样就不会将代码等敏感配置提交到代码仓库了。

配置代码如下:

# 本地配置文件
# 对象存储
cos:
  client:
    accessKey: xxx
    secretKey: xxx
    region: xxx
    bucket: xxx
1
2
3
4
5
6
7
8

可以通过如下方式分别获取需要的配置。

accessKey、secretKey 密钥对:在腾讯云访问管理 => 密钥管理中获取。

地址:https://console.cloud.tencent.com/cam/capi

img

region 表示地域名,可以点此获取:https://cloud.tencent.com/document/product/436/6224

bucket 是存储桶名,可以轻松获取:

img

# 2、通用能力类

com.yupi.web.manager 包下新建 CosManager 类,提供通用的对象存储操作,比如文件上传、文件下载等,供其他代码(比如 Service)调用。

该类需要引入对象存储配置和 COS 客户端,用于和 COS 进行交互。代码如下:

后端万用模板中已经整合了该文件,不用自己编写。

@Component
public class CosManager {

    @Resource
    private CosClientConfig cosClientConfig;

    @Resource
    private COSClient cosClient;

    ... 一些操作 COS 的方法
}
1
2
3
4
5
6
7
8
9
10
11

# 3、文件上传

参考官方文档的“上传对象”部分,可以编写出文件上传的代码。

地址:https://cloud.tencent.com/document/product/436/65935

1)CosManager 新增两个上传对象的方法,代码如下:

@Component
public class CosManager {

    @Resource
    private CosClientConfig cosClientConfig;

    @Resource
    private COSClient cosClient;

    /**
     * 上传对象
     *
     * @param key           唯一键
     * @param localFilePath 本地文件路径
     * @return
     */
    public PutObjectResult putObject(String key, String localFilePath) {
        PutObjectRequest putObjectRequest = new PutObjectRequest(
            cosClientConfig.getBucket(), key, new File(localFilePath));
        return cosClient.putObject(putObjectRequest);
    }

    /**
     * 上传对象
     *
     * @param key  唯一键
     * @param file 文件
     * @return
     */
    public PutObjectResult putObject(String key, File file) {
        PutObjectRequest putObjectRequest = new PutObjectRequest(
            cosClientConfig.getBucket(), key, file);
        return cosClient.putObject(putObjectRequest);
    }
}
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
26
27
28
29
30
31
32
33
34
35

2)修改 FileConstant 常量中的 COS 访问域名,便于接下来测试访问已上传的文件。

代码如下:

package com.yupi.web.constant;

/**
 * 文件常量
 *
 * @author <a href="https://github.com/liyupi">程序员鱼皮</a>
 * @from <a href="https://yupi.icu">编程导航知识星球</a>
 */
public interface FileConstant {

    /**
     * COS 访问地址
     * todo 需替换配置
     */
    String COS_HOST = "https://yuzi-1256524210.cos.ap-shanghai.myqcloud.com";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

该域名可以在 COS 控制台的域名信息部分找到:

img

3)为了方便测试,在 FileController 中编写测试文件上传接口。

核心流程是先接受用户上传的文件,指定上传的路径,然后调用 cosManager.putObject 方法上传文件到 COS 对象存储;上传成功后,会返回一个文件的 key(其实就是文件路径),便于我们访问和下载文件。

需要注意,测试接口一定要加上管理员权限!防止任何用户随意上传文件。

测试文件上传接口代码如下:

/**
 * 测试文件上传
 *
 * @param multipartFile
 * @return
 */
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@PostMapping("/test/upload")
public BaseResponse<String> testUploadFile(@RequestPart("file") MultipartFile multipartFile) {
    // 文件目录
    String filename = multipartFile.getOriginalFilename();
    String filepath = String.format("/test/%s", filename);
    File file = null;
    try {
        // 上传文件
        file = File.createTempFile(filepath, null);
        multipartFile.transferTo(file);
        cosManager.putObject(filepath, file);
        // 返回可访问地址
        return ResultUtils.success(filepath);
    } catch (Exception e) {
        log.error("file upload error, filepath = " + filepath, e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
    } finally {
        if (file != null) {
            // 删除临时文件
            boolean delete = file.delete();
            if (!delete) {
                log.error("file delete error, filepath = {}", filepath);
            }
        }
    }
}
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
26
27
28
29
30
31
32
33

4)测试接口。

使用 local 配置启动项目:

img

打开 Swagger 接口文档,测试文件上传:

img

# 4、文件下载

官方文档介绍了 2 种文件下载方式。一种是直接下载 COS 的文件到后端服务器(适合服务器端处理文件),另一种是获取到文件下载输入流(适合返回给前端用户)。

参考官方文档:

  • https://cloud.tencent.com/document/product/436/65937
  • https://cloud.tencent.com/document/product/436/10199#.E4.B8.8B.E8.BD.BD.E5.AF.B9.E8.B1.A1

其实还有第三种“下载方式”,直接通过路径链接访问,适用于单一的、可以被用户公开访问的资源,比如用户头像、本项目中的代码生成器图片。

但是对于本项目中的代码生成器产物包文件,更建议通过后端服务器从 COS 下载文件并返回给前端,这样可以在后端限制只有登录用户才能下载。

1)首先在 CosManager 中新增对象下载方法,根据对象的 key 获取存储信息:

/**
 * 下载对象
 *
 * @param key 唯一键
 * @return
 */
public COSObject getObject(String key) {
    GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);
    return cosClient.getObject(getObjectRequest);
}
1
2
3
4
5
6
7
8
9
10

2)为了方便测试,在 FileController 中编写测试文件下载接口。

核心流程是根据路径获取到 COS 文件对象,然后将文件对象转换为文件流,并写入到 Servlet 的 Response 对象中。注意要设置文件下载专属的响应头。

同上,测试接口一定要加上管理员权限!防止任何用户随意上传文件。

测试文件下载接口代码如下:

/**
 * 测试文件下载
 *
 * @param filepath
 * @param response
 * @return
 */
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@GetMapping("/test/download/")
public void testDownloadFile(String filepath, HttpServletResponse response) throws IOException {
    COSObjectInputStream cosObjectInput = null;
    try {
        COSObject cosObject = cosManager.getObject(filepath);
        cosObjectInput = cosObject.getObjectContent();
        // 处理下载到的流
        byte[] bytes = IOUtils.toByteArray(cosObjectInput);
        // 设置响应头
        response.setContentType("application/octet-stream;charset=UTF-8");
        response.setHeader("Content-Disposition", "attachment; filename=" + filepath);
        // 写入响应
        response.getOutputStream().write(bytes);
        response.getOutputStream().flush();
    } catch (Exception e) {
        log.error("file download error, filepath = " + filepath, e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "下载失败");
    } finally {
        if (cosObjectInput != null) {
            cosObjectInput.close();
        }
    }
}
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
26
27
28
29
30
31

3)启动项目,打开 Swagger 接口文档,测试文件下载:

img

至此,后端操作对象存储的代码已编写完成,下面写一个前端页面来测试文件的上传和下载。

# 前端文件上传 / 下载

基于 编程导航前端万用模板 (opens new window) 开发

1)首先使用 openAPI 工具生成接口(或者自己编写类似的代码)。

可以看到工具为我们生成了文件上传请求函数,等会儿直接用就行,爽歪歪~

img

2)新建文件上传下载测试页面,并添加路由:

{
  path: '/test/file',
  icon: 'home',
  component: './Test/File',
  name: '文件上传下载测试',
  hideInMenu: true,
},
1
2
3
4
5
6
7

新建的文件目录结构如下:

img

3)新增对象存储相关常量。

修改 constants/index.ts 文件,添加下列代码:

/**
 * COS 访问地址
 */
export const COS_HOST = "https://yuzi-1256524210.cos.ap-shanghai.myqcloud.com";
1
2
3
4

4)开发页面。

遵循 Flex 左右布局,左边上传文件,右边展示和下载文件。

对于文件上传,直接使用 Ant Design 的拖拽文件上传组件。

官方文档:https://ant.design/components/upload-cn#components-upload-demo-drag

img

参考代码如下,我们在 customRequest 字段中自定义了上传文件的请求逻辑:

const props: UploadProps = {
  name: 'file',
  multiple: false,
  maxCount: 1,
  customRequest: async (fileObj: any) => {
    try {
      const res = await testUploadFileUsingPost({}, fileObj.file);
      fileObj.onSuccess(res.data);
      setValue(res.data);
    } catch (e: any) {
      message.error('上传失败,' + e.message);
      fileObj.onError(e);
    }
  },
  onRemove() {
    setValue(undefined);
  },
};

<Card title="文件上传">
  <Dragger {...props}>
    <p className="ant-upload-drag-icon">
      <InboxOutlined />
    </p>
    <p className="ant-upload-text">Click or drag file to this area to upload</p>
    <p className="ant-upload-hint">
      Support for a single or bulk upload. Strictly prohibited from uploading company data or
      other banned files.
    </p>
  </Dragger>
</Card>
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
26
27
28
29
30
31

对于文件下载,使用 img 标签直接拼接图片地址并展示:

<div>文件地址:{COS_HOST + value}</div>
<Divider />
<img src={COS_HOST + value} height={280} />
1
2
3

使用 file-saver 库,可以下载后端返回的 blob 内容为文件。

先安装 file-saver 库:

npm install file-saver
npm i --save-dev @types/file-saver
1
2

下载文件代码如下:

import { saveAs } from 'file-saver';

<Button
  onClick={async () => {
    const blob = await testDownloadFileUsingGet({
      filepath: value,
    }, {
      responseType: "blob",
    });
    // 使用 file-saver 来保存文件
    const fullPath = COS_HOST + value;
    saveAs(blob, fullPath.substring(fullPath.lastIndexOf("/") + 1));
  }}
>
  点击下载文件
</Button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

由于后端下载文件接口不返回 code 状态码,所以需要修改响应拦截器,对于文件下载请求,直接返回 blob 对象。修改 requestConfig.ts 的部分代码如下:

// 响应拦截器
responseInterceptors: [
  (response) => {
    // 请求地址
    const requestPath: string = response.config.url ?? '';

    // 响应
    const { data } = response as unknown as ResponseStructure;
    if (!data) {
      throw new Error('服务异常');
    }

    // 文件下载时,直接返回
    if (requestPath.includes("download")) {
      return response;
    }

  	...
  },
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

页面的完整代码如下:

import { COS_HOST } from '@/constants';
import {
  testDownloadFileUsingGet,
  testUploadFileUsingPost,
} from '@/services/backend/fileController';
import { InboxOutlined } from '@ant-design/icons';
import { Button, Card, Divider, Flex, message, Upload, UploadProps } from 'antd';
import { saveAs } from 'file-saver';
import React, { useState } from 'react';

const { Dragger } = Upload;

/**
 * 文件上传下载测试页面
 * @constructor
 */
const TestFilePage: React.FC = () => {
  const [value, setValue] = useState<string>();

  const props: UploadProps = {
    name: 'file',
    multiple: false,
    maxCount: 1,
    customRequest: async (fileObj: any) => {
      try {
        const res = await testUploadFileUsingPost({}, fileObj.file);
        fileObj.onSuccess(res.data);
        setValue(res.data);
      } catch (e: any) {
        message.error('上传失败,' + e.message);
        fileObj.onError(e);
      }
    },
    onRemove() {
      setValue(undefined);
    },
  };

  return (
    <Flex gap={16}>
      <Card title="文件上传">
        <Dragger {...props}>
          <p className="ant-upload-drag-icon">
            <InboxOutlined />
          </p>
          <p className="ant-upload-text">Click or drag file to this area to upload</p>
          <p className="ant-upload-hint">
            Support for a single or bulk upload. Strictly prohibited from uploading company data or
            other banned files.
          </p>
        </Dragger>
      </Card>
      <Card title="文件下载" loading={!value}>
        <div>文件地址:{COS_HOST + value}</div>
        <Divider />
        <img src={COS_HOST + value} height={280} />
        <Divider />
        <Button
          onClick={async () => {
            const blob = await testDownloadFileUsingGet(
              {
                filepath: value,
              },
              {
                responseType: 'blob',
              },
            );
            // 使用 file-saver 来保存文件
            const fullPath = COS_HOST + value;
            saveAs(blob, fullPath.substring(fullPath.lastIndexOf('/') + 1));
          }}
        >
          点击下载文件
        </Button>
      </Card>
    </Flex>
  );
};

export default TestFilePage;
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

5)打开页面,测试文件上传、显示和下载。页面效果如下:

img

至此,通用的文件上传和下载功能已开发完成。

# 实践

编程导航星球的定制化代码生成项目会演示如何使用对象存储实现文件上传 / 下载功能。

👉🏻 编程导航原创项目教程系列:https://yuyuanweb.feishu.cn/wiki/SePYwTc9tipQiCktw7Uc7kujnCd

最近更新: 1/30/2024, 1:22:42 PM
编程导航   |