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

一、前置准备:S3协议认知+本地RustFS搭建
先简单说下S3协议:它原本是AWS推出的对象存储交互规范,现在已经成了行业通用标准,说白了就是一套通用的“文件上传/下载/删除”接口规则。只要存储服务兼容S3协议,调用方式几乎一模一样,这也是我们能用SpringBoot3对接RustFS、MinIO、云OSS的核心原因。
本地调试没必要直接用云服务,我选了轻量的RustFS——它完美兼容S3协议,部署简单、无流量费,启动后就能模拟一套对象存储环境,避开云服务权限、扣费这些额外问题。
1.1 启动RustFS并初始化存储桶
- 下载RustFS对应系统版本的二进制包,解压后通过命令行启动服务,默认监听9000端口,配置好AccessKey、SecretKey;
- 访问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: 100MB2.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; }; }} 四、本地测试验证
- 调用POST /api/s3/upload接口,上传图片、文档等文件,记录返回的存储文件名;
- 调用/view接口预览文件、/download接口下载文件,登录RustFS控制台核对文件是否正常入库;
- 调用/delete接口删除文件,再次访问预览/下载接口,返回异常即为删除成功。
六、扩展优化:生产环境升级
本地调试通过后,生产环境部署RustFS可以做这些优化,进一步提升稳定性和安全性:
- 接入分片上传,支持GB级大文件,避免单次上传超时;
- 新增文件类型校验、大小限制、病毒扫描,过滤恶意文件;
- 封装全局异常处理器,统一接口返回格式,提升前端适配性;
- RustFS配置数据持久化、定时备份,防止文件丢失;
- AccessKey/SecretKey改用Nacos/Apollo配置中心加密存储,禁止明文配置;
- 接入监控告警,实时监控上传下载成功率、存储空间使用率。
总结
SpringBoot3对接本地RustFS(S3兼容服务),核心就是规范配置、适配路径模式、做好异常与日志。示例代码支持存储桶自检、上传校验、预览功能,本地调试无成本、无流量费,切换到云厂商S3服务时,仅需修改yml配置即可无缝迁移,极大降低开发和调试成本。