Skip to content

对象存储使用

约 4174 字大约 14 分钟

SpringBoot对象存储

2025-03-04

本文作者:程序员飞云

本站地址:https://www.flycode.icu

目前笔者正在做一个功能来实现文件上传和下载的功能,为了便于之后的使用,所以编写了这篇博客来记录一下基本的使用。包含两个方面的内容,一是最基础的文件上传下载,二是目前使用比较常见的对象存储来实现文件上传和下载。

开发前置条件

Java

Maven

Spring Boot

基础版本

最简单的就是将文件存放到服务器里面去。

  1. 首先需要引入web依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
  1. 定义参数

首先我们需要定义好两个参数

  • 文件目录:用于存放对应的文件,否则就是存放在tomcat的临时文件里面,不便于之后的回显以及下载。
  • 服务器地址: 当我们上传文件后,我们肯定是希望知道对应的链接,然后下载,这个就定义好了对应的地址
@RequestMapping( "/file" )
@RestController
public class FileController {
    /**
     * 存放文件的目录
     */
    public static final String BASE_DIR = "D:" + File.separator + "picture" + File.separator;
    /**
     * 服务器地址
     */
    public static final String BASE_URL = "http://localhost:8080/file/download?fileName=";
}
  1. 编写文件上传接口

步骤:

  • 获取文件名,便于保存
  • 获取存放文件夹的目录位置,不存在就创建
  • 上传文件
  • 回显文件下载地址
@PostMapping( "/upload" )
    public HashMap<String, Object> upload(@RequestParam( value = "file" ) MultipartFile file) {
        // 获取文件名
        String fileName = file.getOriginalFilename();
        // 编写对应的上传文件路径,使用目录拼接文件名
        File uploadFilePath = new File(BASE_DIR + fileName);
        try {
            // 判断目录是否存在,不存在则创建
            boolean existPath = uploadFilePath.getParentFile().exists();
            if (!existPath) {
                uploadFilePath.getParentFile().mkdirs();
            }
            // 使用MultipartFile的方法完成文件上传,上传至uploadFilePath路径
            file.transferTo(uploadFilePath);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        // 输出对应的服务器地址
        String outputUrl = BASE_URL + fileName;
        HashMap<String, Object> map = new HashMap<>();
        map.put("url", outputUrl);
        return map;
    }

里面还可以设置MultipartFile的大小,类型。

开始测试

image-20240110180141567
image-20240110180141567

上传成功,也是返回了文件下载的地址,目前是无用的,因为还没有编写下载的文件地址。现在可以查看下文件的位置是否存在。

image-20240110173952214
image-20240110173952214
  1. 编写下载接口

需要传递一个fileName也就是我们之前定义好的地址里面的参数

步骤:

  • 判断文件路径是否存在
  • 设置响应类型
  • response里面读取数据

基本上都是一些固定步骤,没什么太好说的。

/**
 * 文件下载
 *
 * @param fileName 文件名
 * @param response
 */
@GetMapping( "/download" )
public String download(@RequestParam( value = "fileName" ) String fileName, HttpServletResponse response) {
    File file = new File(BASE_DIR + fileName);
    if (!file.exists()) {
        return "文件不存在";
    }
    // 重置response
    response.reset();
    // 设置响应类型
    response.setContentType("application/octet-stream");
    response.setCharacterEncoding("utf-8");
    // 设置响应头,告诉浏览器要下载文件
    response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
    // 读取文件并写入response的输出流
    try (BufferedInputStream bis = new BufferedInputStream(Files.newInputStream(file.toPath()))) {
        byte[] bytes = new byte[1024];
        OutputStream outputStream = response.getOutputStream();
        int i = 0;
        while ((i = bis.read(bytes)) != -1) {
            outputStream.write(bytes, 0, i);
            outputStream.flush();
        }
    } catch (IOException e) {
        return "下载失败";
    }
    return "下载成功";
}
image-20240110175357692
image-20240110175357692
  1. 前端界面
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>文件</title>
</head>
<body>
<div style="padding: 100px">
    <input type="file" id="inputFile">
    <div style="margin: 100px 0">
        <button onclick="upload()">上传文件</button>
    </div>
    <div id="url"></div>
</div>
<script>
    function upload() {
        // 获取input 选择的的文件
        const fileInput = document.getElementById('inputFile')
        const fd = new FormData()
        fd.append('file', fileInput.files[0])
        fetch('http://localhost:8080/file/upload', {
            method: 'POST',
            body: fd
        }).then(res => res.json()).then(res => {
            // 获取json里面url对应的结果
            document.getElementById("url").innerText = `上传成功,文件url: ${res.url}`
            const downloadUrl = `下载链接: <a href="${res.url}" target="_blank">${res.url}</a>`;
            document.getElementById("url").innerHTML += downloadUrl;
        })
    }
</script>
</body>
</html>

开始测试

image-20240110181704845
image-20240110181704845

使用对象存储

上面的方式存在一些缺点

  1. 不利于扩展:这些数据都是存放在服务器里面,一旦说服务器内存满了,那么只能增加新的存储空间,或者清理之前的文件。
  2. 不利于迁移:一旦换了服务器,那么就需要将文件全部迁移过来,中间可能会出现文件丢失等情况。
  3. 不利于管理:现在对于文件只能进行一些初始操作,例如文件大小,上传日期等等,无法进行数据管理,流量控制等等。
  4. 不安全:如果没有做好安全防御设置,用户可能在通过一些恶意代码来访问服务器里面的资源等等。

并不是说服务器里面不能存放文件,可以存放一些临时文件,对于这些文件可以定期删除,不会影响到相关服务,但是一旦涉及到要持久化保存一些文件,用户需要下载,访问的情况,可以使用对象存储来解决这个问题。

1. 什么是对象存储

可以存储海量文件的分布式存储服务,具有高扩展,低成本,可靠安全等功能。

目前由开源的对象存储服务 MinIO,还有商业版的云服务,例如亚马逊的S3腾讯云的COS阿里云的OSS七牛云的kodo

如果需要使用对象存储的话,建议使用一些大厂的,有相对的保障,例如流量计费,防盗链等等安全性,稳定性也是可以的,除了基本的对象存储的优点外,还可以通过控制台、API、SDK 和工具等多样化方式,简单快速地接入对象存储,进行多格式文件的上传、下载和管理,实现海量数据存储和管理。像一些MinIO的开源项目,可以自己学习一下,小范围使用,不建议来实际使用。

接下来笔者将会介绍腾讯云对象存储的相关使用,以及完成文件的上传下载功能。

2. 创建并使用

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

首先需要创建存储桶,填一些基础信息,访问权限有三个,第一个只支持自己使用,第二个第你的用户通过一定的配置,也能使用你的存储桶进行存储,第三个是任何人都可以用你的存储桶来存储(非常不推荐使用),内容安全可以自选。接下来一直配置就行。这里面还可以配置一些防盗链,服务端加密,绑定域名等等,这边可以自行探索。

image-20240110192156775
image-20240110192156775

建议点击右边的排列,便于方便之后查看对应的详细信息

image-20240110193430724
image-20240110193430724

创建完成后可以查看对应文件信息,例如我这边上传了一个图片,通过访问对象地址就能访问了。

image-20240110193316504
image-20240110193316504

3. 后端开发

不管使什么对象存储,首先第一件事情就是看对应的文档,一般而言,官方文档都会有很详细的说明。快速入门

1. 引入依赖

除了基础依赖,建议引入lombok

<dependency>
     <groupId>com.qcloud</groupId>
     <artifactId>cos_api</artifactId>
     <version>5.6.155</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

2. 初始化客户端

image-20240110194934426
image-20240110194934426
@Configuration
@ConfigurationProperties( prefix = "cos.client" )
@Data
public class CosClientConfig {
    /**
     * 腾讯云账户secretId
     */
    private String secretId;

    /**
     * 腾讯云账户secretKey
     */
    private String secretKey;

    /**
     * COS的区域地址
     */
    private String region;

    /**
     * COS的Bucket名称
     */
    private String bucket;

    @Bean
    public COSClient cosClient() {
        // 初始化用户身份信息(secretId, secretKey)
        COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
        // 设置bucket的区域, COS地域的简称请参照 https://www.qcloud.com/document/product/436/6224
        ClientConfig clientConfig = new ClientConfig(new Region(region));
        // 生成cos客户端
        return new COSClient(cred, clientConfig);
    }
}

secretId,secretKey获取地址 https://console.cloud.tencent.com/cam/capi,请及时保存对应的信息,这个只会在创建的时候看见对应的信息

填写配置文件

这边建议创建新的配置文件,例如application-local.yml,在启动项目的时候采用local启动,并且在.gitignore里面将配置文件忽略,这样就能防止无意将密码传入到github或者gitee里面去,导致密钥泄露。

image-20240110200951100
image-20240110200951100

里面的id,key之前已经获取了,bucket就是存储桶的名称,region就是所属地域的英文

配置启动

image-20240110201301436
image-20240110201301436

3. 通用能力类

编写一个通用类CosManager,提供通用的对象存储操作,这样就不需要每次都要写一些基本信息,供其他代码调用

/**
 * 通用对象存储类
 */
@Component
public class CosManager {

    @Resource
    private COSClient cosClient;

    @Resource
    private CosClientConfig cosClientConfig;
  
    // 
  
}

4. 文件上传

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

image-20240111095456055
image-20240111095456055

可以看到文件上传需要返回一个PutObjectResult 的对象

有三种方式,但是这三种方式里面核心都是要对应的key,以及相应的文件信息File和bucketName,但是其中的bucketName我们已经在cosClientConfig里面定义好了,所以我们可以直接调用就能得到,所以只需要输入对应的key以及相应的文件就可以了。

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

/**
 * 文件上传
 *
 * @param key  唯一键
 * @param file 文件
 * @return
 */
public PutObjectResult putObjectRequest(String key, File file) {
    PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file);
    return cosClient.putObject(putObjectRequest);
}
1.新建常量,存储域名
/**
 * 文件常量

 */
public interface FileConstant {

    /**
     * COS 访问地址
     * todo 需替换配置
     */
    String COS_HOST = "xxx";
}

这个域名可以在概览里面查看

image-20240111101708660
image-20240111101708660
2. 编写上传接口

步骤

  • 获取文件原始信息,例如文件名
  • 创建临时文件,调用对象存储
  • 完成上传,删除临时文件
@RequestMapping( "/file" )
@RestController
@Slf4j
public class FileController {
    @Resource
    private CosManager cosManager;

    @PostMapping( "/test/upload" )
    public String upload(@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.putObjectRequest(filePath, file);
            // 返回地址
            return filePath;
        } catch (IOException e) {
            log.error("上传文件失败,filePath=" + filePath, e);
            throw new RuntimeException(e);
        } finally {
            if (file != null) {
                // 删除临时文件
                boolean delete = file.delete();
                if (!delete) {
                    log.error("删除临时文件失败,filePath=" + filePath);
                }
            }
        }
    }
}
3. 测试上传
image-20240111102954421
image-20240111102954421

这里可以在对应的存储桶里面看到对应的文件。

5. 文件下载

官方文档 https://cloud.tencent.com/document/product/436/65937

image-20240111103515207
image-20240111103515207

里面有两种方式,第一种方式比较高级,我们可以使用第二种,相对而言比较简单,直接返回给前端使用就可以。可以看到里面有个CosObject的类,我们可以在之前的Manager里面编写对应的方法,只需要传入对应的key,以及bucket

/**
 * 文件下载
 * @param filepath 唯一键,文件路径
 * @return
 */
public COSObject getCosObject(String filepath) {
    GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), filepath);
    return cosClient.getObject(getObjectRequest);
}

编写controller

@GetMapping( "/test/download" )
public void download(String filepath, HttpServletResponse response) throws IOException {
    COSObjectInputStream cosObjectInput = null;
    try {
        COSObject cosObject = cosManager.getCosObject(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 RuntimeException("下载失败");
    } finally {
        if (cosObjectInput != null) {
            cosObjectInput.close();
        }
    }
}

6. 单个文件删除

删除对象

image-20240129172908852
image-20240129172908852
/**
 * 删除单个文件
 *
 * @param key 文件的key
 * @throws CosClientException
 * @throws CosServiceException
 */
public void deleteObject(String key) throws CosClientException, CosServiceException {
    String bucketName = cosClientConfig.getBucket();
    cosClient.deleteObject(bucketName, key);
}

测试

    @Test
    void deleteObject() {
        cosManager.deleteObject("/test/gly1.jpg");
    }

进行测试的时候需修改测试运行配置,否则无法识别对应的local配置

image-20240129175444864
image-20240129175444864

7. 删除多个文件

一定要注意文件名不能以/开头

/**
 * 删除多个文件
 * @param keyList
 * @return
 * @throws MultiObjectDeleteException
 * @throws CosClientException
 * @throws CosServiceException
 */
public DeleteObjectsResult deleteObjects(List<String> keyList)
        throws MultiObjectDeleteException, CosClientException, CosServiceException {
    DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(cosClientConfig.getBucket());
    // 设置要删除的key列表, 最多一次删除1000个
    ArrayList<DeleteObjectsRequest.KeyVersion> keyVersions = new ArrayList<>();
    // 传入要删除的文件名
    // 注意文件名不允许以正斜线/或者反斜线\开头,例如:
    // 存储桶目录下有a/b/c.txt文件,如果要删除,只能是 keyList.add(new KeyVersion("a/b/c.txt")), 若使用 keyList.add(new KeyVersion("/a/b/c.txt"))会导致删除不成功
    for (String key : keyList) {
        keyVersions.add(new DeleteObjectsRequest.KeyVersion(key));
    }
    deleteObjectsRequest.setKeys(keyVersions);
    DeleteObjectsResult deleteObjectsResult = cosClient.deleteObjects(deleteObjectsRequest);
    return deleteObjectsResult;
}

测试:

@Test
void deleteObjects() {
    cosManager.deleteObjects(Arrays.asList("test/logo.jpg", "test/logo.png"));
}

8. 删除目录

删除目录

一定需要注意删除目录的时候一定要加上后缀/,例如/a/这样的形式,因为如果是/a可能会将其他包含/a的数据删除

/**
     * 删除目录
     *
     * @param delPrefix 包含后缀/
     * @throws CosClientException
     * @throws CosServiceException
     */
    public void deleteDir(String delPrefix) throws CosClientException, CosServiceException {
        ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
        // 设置 bucket 名称
        listObjectsRequest.setBucketName(cosClientConfig.getBucket());
        // prefix 表示列出的对象名以 prefix 为前缀
        // 这里填要列出的目录的相对 bucket 的路径
        listObjectsRequest.setPrefix(delPrefix);
        // 设置最大遍历出多少个对象, 一次 listobject 最大支持1000
        listObjectsRequest.setMaxKeys(1000);

        // 保存每次列出的结果
        ObjectListing objectListing = null;

        do {
            objectListing = cosClient.listObjects(listObjectsRequest);
            // 这里保存列出的对象列表
            List<COSObjectSummary> cosObjectSummaries = objectListing.getObjectSummaries();
            if (CollUtil.isEmpty(cosObjectSummaries)) {
                break;
            }

            ArrayList<DeleteObjectsRequest.KeyVersion> delObjects = new ArrayList<>();
            for (COSObjectSummary cosObjectSummary : cosObjectSummaries) {
                delObjects.add(new DeleteObjectsRequest.KeyVersion(cosObjectSummary.getKey()));
            }

            DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(cosClientConfig.getBucket());
            deleteObjectsRequest.setKeys(delObjects);
            cosClient.deleteObjects(deleteObjectsRequest);

            // 标记下一次开始的位置
            String nextMarker = objectListing.getNextMarker();
            listObjectsRequest.setMarker(nextMarker);
        } while (objectListing.isTruncated());
    }

测试

@Test
void deleteDir() {
    cosManager.deleteDir("/test/");
}

完整的代码

1. COSClientConfig

/**
 * 腾讯云对象存储客户端
 */
@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);
    }
}

2.对象存储操作 CosManager

/**
 * Cos 对象存储操作
 */
@Component
public class CosManager {

    @Resource
    private CosClientConfig cosClientConfig;

    @Resource
    private COSClient cosClient;

    private TransferManager transferManager;

    /**
     * 让transferManager在CosManager初始化完成的时候创建
     */
    @PostConstruct
    public void init() {
        // 自定义线程池大小,建议在客户端与 COS 网络充足(例如使用腾讯云的 CVM,同地域上传 COS)的情况下,设置成16或32即可,可较充分的利用网络资源
        // 对于使用公网传输且网络带宽质量不高的情况,建议减小该值,避免因网速过慢,造成请求超时。
        ExecutorService threadPool = Executors.newFixedThreadPool(32);
        // 传入一个 threadpool, 若不传入线程池,默认 TransferManager 中会生成一个单线程的线程池。
        transferManager = new TransferManager(cosClient, threadPool);

    }

    /**
     * 上传对象
     *
     * @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);
    }

    /**
     * 文件下载
     *
     * @param filepath 唯一键,文件路径
     * @return
     */
    public COSObject getCosObject(String filepath) {
        GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), filepath);
        return cosClient.getObject(getObjectRequest);
    }

    /**
     * 将对象写入到指定的文件
     *
     * @param key
     * @param localFilePath
     * @return
     */
    public Download download(String key, String localFilePath) throws InterruptedException {
        GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);
        // 返回一个异步结果 Download, 可同步的调用 waitForCompletion 等待下载结束, 成功返回 void, 失败抛出异常
        Download download = transferManager.download(getObjectRequest, new File(localFilePath));
        download.waitForCompletion();
        return download;
    }

    /**
     * 删除单个文件
     *
     * @param key 文件的key
     * @throws CosClientException
     * @throws CosServiceException
     */
    public void deleteObject(String key) throws CosClientException, CosServiceException {
        String bucketName = cosClientConfig.getBucket();
        cosClient.deleteObject(bucketName, key);
    }

    /**
     * 删除多个文件
     * @param keyList
     * @return
     * @throws MultiObjectDeleteException
     * @throws CosClientException
     * @throws CosServiceException
     */
    public DeleteObjectsResult deleteObjects(List<String> keyList)
            throws MultiObjectDeleteException, CosClientException, CosServiceException {
        DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(cosClientConfig.getBucket());
        // 设置要删除的key列表, 最多一次删除1000个
        ArrayList<DeleteObjectsRequest.KeyVersion> keyVersions = new ArrayList<>();
        // 传入要删除的文件名
        // 注意文件名不允许以正斜线/或者反斜线\开头,例如:
        // 存储桶目录下有a/b/c.txt文件,如果要删除,只能是 keyList.add(new KeyVersion("a/b/c.txt")), 若使用 keyList.add(new KeyVersion("/a/b/c.txt"))会导致删除不成功
        for (String key : keyList) {
            keyVersions.add(new DeleteObjectsRequest.KeyVersion(key));
        }
        deleteObjectsRequest.setKeys(keyVersions);
        DeleteObjectsResult deleteObjectsResult = cosClient.deleteObjects(deleteObjectsRequest);
        return deleteObjectsResult;
    }


    /**
     * 删除目录
     *
     * @param delPrefix 包含后缀/
     * @throws CosClientException
     * @throws CosServiceException
     */
    public void deleteDir(String delPrefix) throws CosClientException, CosServiceException {
        ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
        // 设置 bucket 名称
        listObjectsRequest.setBucketName(cosClientConfig.getBucket());
        // prefix 表示列出的对象名以 prefix 为前缀
        // 这里填要列出的目录的相对 bucket 的路径
        listObjectsRequest.setPrefix(delPrefix);
        // 设置最大遍历出多少个对象, 一次 listobject 最大支持1000
        listObjectsRequest.setMaxKeys(1000);

        // 保存每次列出的结果
        ObjectListing objectListing = null;

        do {
            objectListing = cosClient.listObjects(listObjectsRequest);
            // 这里保存列出的对象列表
            List<COSObjectSummary> cosObjectSummaries = objectListing.getObjectSummaries();
            if (CollUtil.isEmpty(cosObjectSummaries)) {
                break;
            }

            ArrayList<DeleteObjectsRequest.KeyVersion> delObjects = new ArrayList<>();
            for (COSObjectSummary cosObjectSummary : cosObjectSummaries) {
                delObjects.add(new DeleteObjectsRequest.KeyVersion(cosObjectSummary.getKey()));
            }

            DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(cosClientConfig.getBucket());
            deleteObjectsRequest.setKeys(delObjects);
            cosClient.deleteObjects(deleteObjectsRequest);

            // 标记下一次开始的位置
            String nextMarker = objectListing.getNextMarker();
            listObjectsRequest.setMarker(nextMarker);
        } while (objectListing.isTruncated());
    }
}

贡献者

  • flycodeuflycodeu

公告板

2025-03-04正式迁移知识库到此项目