后端开发存储(SpringBoot3实战:无痛接入云对象存储,搞定文件上传下载)

后端开发存储(SpringBoot3实战:无痛接入云对象存储,搞定文件上传下载)
SpringBoot3实战:无痛接入云对象存储,搞定文件上传下载

做后端开发,文件存储这块坑真不少:本地磁盘扩容麻烦、集群部署后文件同步难,权限管控也乱。相比之下,兼容S3协议的对象存储才是更通用、更易维护的方案,不管是自建服务还是云存储,一套代码就能适配,开发成本低太多。

后端开发存储(SpringBoot3实战:无痛接入云对象存储,搞定文件上传下载)

一、前置准备:S3协议认知+本地RustFS搭建

先简单说下S3协议:它原本是AWS推出的对象存储交互规范,现在已经成了行业通用标准,说白了就是一套通用的“文件上传/下载/删除”接口规则。只要存储服务兼容S3协议,调用方式几乎一模一样,这也是我们能用SpringBoot3对接RustFS、MinIO、云OSS的核心原因。

本地调试没必要直接用云服务,我选了轻量的RustFS——它完美兼容S3协议,部署简单、无流量费,启动后就能模拟一套对象存储环境,避开云服务权限、扣费这些额外问题。

1.1 启动RustFS并初始化存储桶

  1. 下载RustFS对应系统版本的二进制包,解压后通过命令行启动服务,默认监听9000端口,配置好AccessKey、SecretKey
  2. 访问RustFS控制台(默认http://localhost:9000),登录后创建存储桶,桶名自定义,记录密钥信息;

二、项目搭建

基于SpringBoot3.2.5版本开发,沿用AWS SDK v2(兼容所有S3协议服务),适配本地RustFS,无需改动核心API,通用性拉满。

2.1 引入Maven依赖

直接在pom.xml添加核心依赖:

<!-- AWS S3 SDK v2(兼容RustFS/MinIO等S3协议服务) -->    software.amazon.awssdk    s3    2.42.15<!-- lombok简化代码,生产必备 -->    org.projectlombok    lombok    true

2.2 编写本地配置文件

在application.yml中配置RustFS本地连接参数,替换为自己的服务信息,同时放开文件上传限制:

aws:  s3:    access-key: access-key    secret-key: secret-key    region: local-1       # 本地区域,自定义即可    bucket-name: myfiles  # 本地创建的桶名    endpoint: http://192.168.137.117:9000  # RustFS本地服务端点    # 上传超时配置(单位:毫秒)    connection-timeout: 120000    socket-timeout: 300000# 文件上传配置,防止大文件上传失败spring:  servlet:    multipart:      max-file-size: 100MB      max-request-size: 100MB

2.3 S3客户端配置类

采用配置绑定模式读取参数,开启路径风格访问适配本地RustFS:

import lombok.Data;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;import software.amazon.awssdk.regions.Region;import software.amazon.awssdk.services.s3.S3Client;import java.net.URI;@Configuration@ConfigurationProperties(prefix = "aws.s3")@Datapublic class S3Config {    private String accessKey;    private String secretKey;    private String bucketName;    private String region;    private String endpoint;    @Bean    public S3Client s3Client() {        return S3Client.builder()                // 本地RustFS服务端点                .endpointOverride(URI.create(endpoint))                // 读取配置文件区域,避免硬编码                .region(Region.of(region))                // 凭证配置                .credentialsProvider(StaticCredentialsProvider.create(                        AwsBasicCredentials.create(accessKey, secretKey)))                // 开启路径模式,适配本地S3服务                .forcePathStyle(true)                .build();    }}

三、核心编码:上传+下载+预览+删除全功能

本次封装FileStorageService替代简易业务类,新增存储桶自检、文件校验、日志打印、异常细分处理,兼顾本地调试与生产稳定性,支持上传、下载、预览、删除四大核心场景。

3.1 存储业务服务层

内置存储桶是否存在的校验、文件上传校验、全链路日志、细分异常捕获,解决本地调试常见的桶不存在、文件丢失、上传无感知等问题:

import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Service;import org.springframework.web.multipart.MultipartFile;import software.amazon.awssdk.core.ResponseInputStream;import software.amazon.awssdk.core.sync.RequestBody;import software.amazon.awssdk.services.s3.S3Client;import software.amazon.awssdk.services.s3.model.*;import java.util.UUID;@Service@Slf4jpublic class FileStorageService {    @Autowired    private S3Client s3Client;    @Value("${aws.s3.bucket-name}")    private String bucketName;    /**     * 上传文件     * @param file 上传的文件     * @return 存储文件名(唯一标识)     */    public String uploadFile(MultipartFile file) {        try {            if (file == null || file.isEmpty()) {                throw new IllegalArgumentException("上传文件不能为空");            }            // 存储桶自检,不存在则创建            ensureBucketExists();            // 生成唯一文件名,防止覆盖            String fileName = generateFileName(file.getOriginalFilename());            log.info("开始上传文件:{}, 原始名称:{}, 大小:{} bytes, 类型:{}",                    fileName, file.getOriginalFilename(), file.getSize(), file.getContentType());            byte[] fileBytes = file.getBytes();            PutObjectRequest putObjectRequest = PutObjectRequest.builder()                    .bucket(bucketName)                    .key(fileName)                    .contentType(file.getContentType())                    .contentLength((long) fileBytes.length)                    .build();            s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes));            // 上传后校验文件是否成功入库            if (verifyUpload(fileName)) {                log.info("文件上传并验证成功:{}", fileName);            } else {                log.warn("文件上传验证失败,但文件已存在:{}", fileName);            }            // 返回文件名,用于后续下载/删除            return fileName;        } catch (NoSuchBucketException e) {            log.error("存储桶不存在:{}", bucketName, e);            throw new RuntimeException("存储桶配置错误:" + bucketName);        } catch (S3Exception e) {            log.error("S3 服务异常:文件上传失败 - {}, 错误码:{}", e.getMessage(), e.awsErrorDetails().errorCode());            throw new RuntimeException("文件上传失败:" + e.getMessage());        } catch (Exception e) {            log.error("文件上传失败:文件名={}, 大小={}",                    file.getOriginalFilename(), file.getSize(), e);            throw new RuntimeException("文件上传失败:" + e.getMessage());        }    }    /**     * 下载文件     */    public byte[] downloadFile(String fileName) {        try {            log.info("开始下载文件:{}", fileName);            ResponseInputStream response =                    s3Client.getObject(                            GetObjectRequest.builder()                                    .bucket(bucketName)                                    .key(fileName)                                    .build()                    );            byte[] fileBytes = response.readAllBytes();            log.info("文件下载成功:{}, 大小:{} bytes", fileName, fileBytes.length);            return fileBytes;        } catch (NoSuchKeyException e) {            log.error("文件不存在:{}", fileName, e);            throw new RuntimeException("文件不存在:" + fileName);        } catch (S3Exception e) {            log.error("S3 服务异常:文件下载失败 - {}", e.getMessage(), e);            throw new RuntimeException("文件下载失败:" + e.getMessage());        } catch (Exception e) {            log.error("文件下载失败:{}", fileName, e);            throw new RuntimeException("文件下载失败:" + e.getMessage());        }    }    /**     * 删除文件     */    public void deleteFile(String fileName) {        try {            log.info("开始删除文件:{}", fileName);            s3Client.deleteObject(                    DeleteObjectRequest.builder()                            .bucket(bucketName)                            .key(fileName)                            .build()            );            log.info("文件删除成功:{}", fileName);        } catch (NoSuchKeyException e) {            log.warn("文件不存在,无需删除:{}", fileName);            throw new RuntimeException("文件不存在:" + fileName);        } catch (S3Exception e) {            log.error("S3 服务异常:文件删除失败 - {}", e.getMessage(), e);            throw new RuntimeException("文件删除失败:" + e.getMessage());        } catch (Exception e) {            log.error("文件删除失败:{}", fileName, e);            throw new RuntimeException("文件删除失败:" + e.getMessage());        }    }    /**     * 获取文件元信息     */    public HeadObjectResponse getFileInfo(String fileName) {        try {            log.debug("获取文件信息:{}", fileName);            return s3Client.headObject(                    HeadObjectRequest.builder()                            .bucket(bucketName)                            .key(fileName)                            .build()            );        } catch (NoSuchKeyException e) {            log.error("文件不存在:{}", fileName, e);            throw new RuntimeException("文件不存在:" + fileName);        } catch (S3Exception e) {            log.error("S3 服务异常:获取文件信息失败 - {}", e.getMessage(), e);            throw new RuntimeException("获取文件信息失败:" + e.getMessage());        }    }    /**     * 检查存储桶是否存在,不存在则自动创建     */    private void ensureBucketExists() {        try {            s3Client.headBucket(                    HeadBucketRequest.builder()                            .bucket(bucketName)                            .build()            );            log.debug("存储桶 {} 已存在", bucketName);        } catch (NoSuchBucketException e) {            log.info("存储桶 {} 不存在,正在创建...", bucketName);            s3Client.createBucket(                    CreateBucketRequest.builder()                            .bucket(bucketName)                            .build()            );            log.info("存储桶 {} 创建成功", bucketName);        } catch (S3Exception e) {            log.error("检查存储桶失败:{}", e.getMessage(), e);            throw new RuntimeException("存储桶检查失败:" + e.getMessage());        }    }    /**     * 验证文件是否上传成功     */    private boolean verifyUpload(String fileName) {        try {            HeadObjectResponse response = s3Client.headObject(                    HeadObjectRequest.builder()                            .bucket(bucketName)                            .key(fileName)                            .build()            );            return response != null && response.contentLength() > 0;        } catch (Exception e) {            log.warn("文件验证失败:{}", fileName, e);            return false;        }    }    /**     * 生成唯一文件名,保留后缀     */    private String generateFileName(String originalFileName) {        String extension = "";        if (originalFileName != null && !originalFileName.isEmpty() && originalFileName.contains(".")) {            extension = originalFileName.substring(originalFileName.lastIndexOf("."));        }        return UUID.randomUUID() + extension;    }}

3.2 控制层接口

提供标准化REST接口,支持上传、下载、预览(浏览器直接打开)、删除,根据文件类型自动匹配响应头:

import jakarta.annotation.Resource;import org.springframework.http.HttpHeaders;import org.springframework.http.MediaType;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile;@RestController@RequestMapping("/api/s3")public class FileStorageController {    @Resource    private FileStorageService fileStorageService;    /**     * 文件上传接口     */    @PostMapping("/upload")    public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) {        String fileName = fileStorageService.uploadFile(file);        return ResponseEntity.ok("文件上传成功:" + fileName);    }    /**     * 文件下载接口(附件形式)     */    @GetMapping("/download")    public ResponseEntity downloadFile(@RequestParam("fileName") String fileName) {        try {            byte[] fileBytes = fileStorageService.downloadFile(fileName);            return ResponseEntity.ok()                    .header(HttpHeaders.CONTENT_DISPOSITION,                            "attachment; filename=\"" + fileName + "\"")                    .contentType(MediaType.APPLICATION_OCTET_STREAM)                    .contentLength(fileBytes.length)                    .body(fileBytes);        } catch (Exception e) {            return ResponseEntity.badRequest().build();        }    }    /**     * 文件预览接口(直接在浏览器打开)     */    @GetMapping("/view")    public ResponseEntity viewFile(@RequestParam("fileName") String fileName) {        try {            byte[] fileBytes = fileStorageService.downloadFile(fileName);            MediaType mediaType = getMediaType(fileName);            return ResponseEntity.ok()                    .contentType(mediaType)                    .contentLength(fileBytes.length)                    .body(fileBytes);        } catch (Exception e) {            return ResponseEntity.badRequest().build();        }    }    /**     * 文件删除接口     */    @DeleteMapping("/delete")    public ResponseEntity deleteFile(@RequestParam("fileName") String fileName) {        fileStorageService.deleteFile(fileName);        return ResponseEntity.ok("文件删除成功");    }    /**     * 根据文件后缀匹配媒体类型     */    private MediaType getMediaType(String fileName) {        if (fileName == null) {            return MediaType.APPLICATION_OCTET_STREAM;        }        String extension = "";        if (fileName.contains(".")) {            extension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();        }        return switch (extension) {            case "jpg", "jpeg" -> MediaType.IMAGE_JPEG;            case "png" -> MediaType.IMAGE_PNG;            case "gif" -> MediaType.IMAGE_GIF;            case "pdf" -> MediaType.APPLICATION_PDF;            case "txt" -> MediaType.TEXT_PLAIN;            case "html", "htm" -> MediaType.TEXT_HTML;            case "json" -> MediaType.APPLICATION_JSON;            case "xml" -> MediaType.APPLICATION_XML;            default -> MediaType.APPLICATION_OCTET_STREAM;        };    }}

四、本地测试验证

  1. 调用POST /api/s3/upload接口,上传图片、文档等文件,记录返回的存储文件名

  1. 调用/view接口预览文件、/download接口下载文件,登录RustFS控制台核对文件是否正常入库;

  1. 调用/delete接口删除文件,再次访问预览/下载接口,返回异常即为删除成功。

六、扩展优化:生产环境升级

本地调试通过后,生产环境部署RustFS可以做这些优化,进一步提升稳定性和安全性:

  • 接入分片上传,支持GB级大文件,避免单次上传超时;
  • 新增文件类型校验、大小限制、病毒扫描,过滤恶意文件;
  • 封装全局异常处理器,统一接口返回格式,提升前端适配性;
  • RustFS配置数据持久化、定时备份,防止文件丢失;
  • AccessKey/SecretKey改用Nacos/Apollo配置中心加密存储,禁止明文配置;
  • 接入监控告警,实时监控上传下载成功率、存储空间使用率。

总结

SpringBoot3对接本地RustFS(S3兼容服务),核心就是规范配置、适配路径模式、做好异常与日志。示例代码支持存储桶自检、上传校验、预览功能,本地调试无成本、无流量费,切换到云厂商S3服务时,仅需修改yml配置即可无缝迁移,极大降低开发和调试成本。

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有

相关阅读

最新文章

热门文章

本栏目文章