Skip to content

文件存储与OSS ​

核心定位:文件上传是 Web 应用的基础能力,本文覆盖本地存储、阿里 OSS、MinIO 三种方案,以及分片上传、断点续传、图片处理等高级特性。

1. 文件存储方案对比 ​

┌─────────────────────────────────────────────┐
│ 文件存储方案对比                              │
├─────────────────────────────────────────────┤
│ 本地磁盘:                                    │
│   - 最简单,无额外依赖                       │
│   - 缺点:无法水平扩展、单点故障              │
│   - 适用:小型项目、临时文件                  │
│                                              │
│ 阿里 OSS / 七牛云 / 腾讯 COS:               │
│   - ★ 云存储,高可用、无限容量                │
│   - CDN 加速、图片处理                       │
│   - 适合:生产环境、面向公网                  │
│                                              │
│ ★ MinIO(自建 S3 兼容存储):                │
│   - 开源免费,S3 API 兼容                    │
│   - 可水平扩展、多副本                       │
│   - ★ 适合:私有云、离线环境、成本敏感场景    │
│                                              │
│ FastDFS:                                    │
│   - 老牌分布式文件系统                       │
│   - 已逐渐被 MinIO/OSS 替代                  │
└─────────────────────────────────────────────┘

2. 本地文件上传 ​

java
@Service
public class LocalFileService {

    @Value("${file.upload-path}")
    private String uploadPath;

    @Value("${file.allowed-types}")
    private String[] allowedTypes;

    @Value("${file.max-size}")
    private long maxSize;

    /**
     * 单文件上传
     */
    public FileInfo upload(MultipartFile file) throws IOException {
        // 1. 校验
        validateFile(file);

        // 2. 生成安全文件名
        String originalName = file.getOriginalFilename();
        String ext = FilenameUtils.getExtension(originalName);
        String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext;

        // 3. 按日期分目录
        String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
        Path targetDir = Paths.get(uploadPath, datePath);
        Files.createDirectories(targetDir);
        Path targetPath = targetDir.resolve(newName);

        // 4. 写入(流式,不占内存)
        try (InputStream is = file.getInputStream()) {
            Files.copy(is, targetPath, StandardCopyOption.REPLACE_EXISTING);
        }

        FileInfo info = new FileInfo();
        info.setOriginalName(originalName);
        info.setFileName(newName);
        info.setUrl("/file/" + datePath + "/" + newName);
        info.setSize(file.getSize());
        info.setContentType(file.getContentType());
        return info;
    }

    /**
     * ★ 文件校验
     */
    private void validateFile(MultipartFile file) {
        if (file.isEmpty()) {
            throw new BusinessException("文件不能为空");
        }
        if (file.getSize() > maxSize) {
            throw new BusinessException("文件大小超过限制");
        }

        // ★ 后缀白名单
        String ext = FilenameUtils.getExtension(file.getOriginalFilename());
        if (!Arrays.asList(allowedTypes).contains(ext.toLowerCase())) {
            throw new BusinessException("不允许的文件类型:" + ext);
        }

        // ★ 文件头校验(防止改后缀名)
        try (InputStream is = file.getInputStream()) {
            byte[] header = new byte[16];
            is.read(header);
            String realType = detectFileType(header);
            if (!isAllowedType(realType)) {
                throw new BusinessException("文件内容与后缀不匹配");
            }
        } catch (IOException e) {
            throw new BusinessException("文件校验失败");
        }
    }

    private String detectFileType(byte[] header) {
        if (header.length < 4) return "unknown";
        // PNG
        if (header[0] == (byte) 0x89 && header[1] == 0x50 &&
            header[2] == 0x4E && header[3] == 0x47) return "png";
        // JPEG
        if (header[0] == (byte) 0xFF && header[1] == (byte) 0xD8) return "jpg";
        // GIF
        if (header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46) return "gif";
        // PDF
        if (header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44 && header[3] == 0x46) return "pdf";
        return "unknown";
    }
}

3. 阿里 OSS 整合 ​

xml
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.17.4</version>
</dependency>
java
@Configuration
public class OssConfig {

    @Value("${aliyun.oss.endpoint}")
    private String endpoint;

    @Value("${aliyun.oss.access-key-id}")
    private String accessKeyId;

    @Value("${aliyun.oss.access-key-secret}")
    private String accessKeySecret;

    @Value("${aliyun.oss.bucket-name}")
    private String bucketName;

    @Bean(destroyMethod = "shutdown")
    public OSS ossClient() {
        return new OSSClientBuilder()
            .build(endpoint, accessKeyId, accessKeySecret);
    }

    @Bean
    public OssProperties ossProperties() {
        OssProperties props = new OssProperties();
        props.setEndpoint(endpoint);
        props.setBucketName(bucketName);
        return props;
    }
}

@Service
public class OssFileService {

    @Autowired
    private OSS ossClient;

    @Autowired
    private OssProperties ossProps;

    /**
     * 普通上传
     */
    public String upload(MultipartFile file) throws IOException {
        String fileName = generateFileName(file.getOriginalFilename());

        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(file.getSize());
        metadata.setContentType(file.getContentType());
        metadata.setCacheControl("max-age=2592000");  // 30天缓存

        ossClient.putObject(ossProps.getBucketName(), fileName,
            file.getInputStream(), metadata);

        return "https://" + ossProps.getBucketName() + "." +
               ossProps.getEndpoint() + "/" + fileName;
    }

    /**
     * ★ 分片上传(大文件)
     */
    public String multipartUpload(MultipartFile file) throws IOException {
        String objectName = generateFileName(file.getOriginalFilename());

        // 1. 初始化分片上传
        InitiateMultipartUploadRequest initRequest = new InitiateMultipartUploadRequest(
            ossProps.getBucketName(), objectName);
        InitiateMultipartUploadResult initResult = ossClient.initiateMultipartUpload(initRequest);
        String uploadId = initResult.getUploadId();

        // 2. 分片上传
        long fileSize = file.getSize();
        long partSize = 5 * 1024 * 1024;  // 每片 5MB
        int partCount = (int) (fileSize / partSize);
        if (fileSize % partSize != 0) partCount++;

        List<PartETag> partETags = new ArrayList<>();
        try (InputStream is = file.getInputStream()) {
            for (int i = 0; i < partCount; i++) {
                long skipBytes = partSize * i;
                long remain = fileSize - skipBytes;
                long thisPartSize = Math.min(partSize, remain);

                byte[] buffer = new byte[(int) thisPartSize];
                is.skip(skipBytes - (i > 0 ? partSize * (i - 1) : 0));
                is.read(buffer);

                UploadPartRequest partRequest = new UploadPartRequest()
                    .withBucketName(ossProps.getBucketName())
                    .withKey(objectName)
                    .withUploadId(uploadId)
                    .withPartNumber(i + 1)
                    .withInputStream(new ByteArrayInputStream(buffer))
                    .withPartSize(thisPartSize);

                UploadPartResult partResult = ossClient.uploadPart(partRequest);
                partETags.add(partResult.getPartETag());
            }
        }

        // 3. 完成分片上传
        CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest(
            ossProps.getBucketName(), objectName, uploadId, partETags);
        ossClient.completeMultipartUpload(completeRequest);

        return generateUrl(objectName);
    }

    /**
     * ★ STS 临时凭证直传(减轻服务器压力)
     */
    public Map<String, String> generateStsToken() {
        // 创建 STS 客户端获取临时凭证
        // 前端直接用临时凭证上传 OSS,不经过应用服务器
        Map<String, String> result = new HashMap<>();
        result.put("accessKeyId", "STS.xxx");
        result.put("accessKeySecret", "xxx");
        result.put("securityToken", "xxx");
        result.put("bucket", ossProps.getBucketName());
        result.put("endpoint", ossProps.getEndpoint());
        result.put("expireTime", "3600");
        return result;
    }
}

4. MinIO 整合 ​

xml
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.7</version>
</dependency>
java
@Configuration
public class MinioConfig {

    @Bean
    public MinioClient minioClient(
            @Value("${minio.endpoint}") String endpoint,
            @Value("${minio.access-key}") String accessKey,
            @Value("${minio.secret-key}") String secretKey,
            @Value("${minio.bucket-name}") String bucketName) {

        MinioClient client = MinioClient.builder()
            .endpoint(endpoint)
            .credentials(accessKey, secretKey)
            .build();

        // 自动创建 bucket
        try {
            if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) {
                client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
                log.info("创建 MinIO bucket: {}", bucketName);
            }
        } catch (Exception e) {
            log.error("创建 bucket 失败", e);
        }

        return client;
    }
}

@Service
public class MinioFileService {

    @Autowired
    private MinioClient minioClient;

    @Value("${minio.bucket-name}")
    private String bucketName;

    /**
     * 上传
     */
    public String upload(MultipartFile file) throws Exception {
        String objectName = generateFileName(file.getOriginalFilename());

        minioClient.putObject(
            PutObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .stream(file.getInputStream(), file.getSize(), -1)
                .contentType(file.getContentType())
                .build());

        return generateUrl(objectName);
    }

    /**
     * 下载
     */
    public InputStream download(String objectName) throws Exception {
        return minioClient.getObject(
            GetObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .build());
    }

    /**
     * 生成预签名 URL(限时访问)
     */
    public String generatePresignedUrl(String objectName, int expireSeconds) throws Exception {
        return minioClient.getPresignedObjectUrl(
            GetPresignedObjectUrlArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .method(Method.GET)
                .expiry(expireSeconds)
                .build());
    }
}

5. 断点续传 ​

java
// 上传状态持久化
public interface UploadProgressService {
    UploadProgress getProgress(String uploadId);
    void saveProgress(UploadProgress progress);
    void clearProgress(String uploadId);
}

@Service
public class ResumableUploadService {

    @Autowired
    private RedisUtils redisUtils;

    /**
     * ★ 断点续传
     */
    public String resumableUpload(String uploadId, int partNumber,
                                   MultipartFile part, String fileName) throws IOException {
        String progressKey = "upload:progress:" + uploadId;

        // 1. 查询已上传的分片
        UploadProgress progress = redisUtils.get(progressKey, UploadProgress.class);
        if (progress == null) {
            progress = new UploadProgress();
            progress.setUploadId(uploadId);
            progress.setFileName(fileName);
            progress.setUploadedParts(new ArrayList<>());
        }

        // 2. 检查是否已上传
        if (progress.getUploadedParts().contains(partNumber)) {
            return "已上传,跳过";  // ★ 幂等
        }

        // 3. 上传分片到 OSS
        uploadPart(uploadId, partNumber, part);

        // 4. 记录进度
        progress.getUploadedParts().add(partNumber);
        redisUtils.set(progressKey, progress, 24, TimeUnit.HOURS);

        // 5. 判断是否全部完成
        if (progress.getUploadedParts().size() == progress.getTotalParts()) {
            // 合并分片
            String url = completeMultipartUpload(uploadId);
            redisUtils.delete(progressKey);
            return url;
        }

        return null;  // 未完成
    }
}

6. 常见问题 ​

┌─────────────────────────────────────────────┐
│ 文件存储常见问题                              │
├─────────────────────────────────────────────┤
│ 1. 安全                                      │
│    - ★ 后缀白名单                            │
│    - ★ 文件头校验(防改后缀)                │
│    - ★ 随机文件名(防路径穿越)              │
│    - ★ 图片二次渲染(防恶意图片)             │
│                                              │
│ 2. 大文件                                    │
│    - ★ 分片上传                              │
│    - ★ 断点续传                              │
│    - 流式读写(避免 OOM)                    │
│                                              │
│ 3. CDN 加速                                  │
│    - 静态资源走 CDN                          │
│    - OSS + CDN 是标准做法                    │
│                                              │
│ 4. 直传策略                                  │
│    - ★ STS 临时凭证直传(减轻服务器压力)     │
│    - 前端直接上传 OSS,服务器只记录 URL       │
└─────────────────────────────────────────────┘

关联笔记 ​

  • [[后端/JavaWeb/JWT认证与Web安全]]:文件上传安全
  • [[后端/SpringBoot/整合Web开发]]:MultipartFile 参数绑定
  • [[后端/Java工程化与部署/Docker与Nginx部署Java应用]]:Nginx 文件大小限制

练习:把「文件存储与OSS」变成可验证能力 ​

  1. 从本文任选一个示例,在可丢弃环境重跑;记录运行时/服务版本、输入、关键输出和清理动作。
  2. 保持其他条件不变,只制造一个边界:空值、重复请求、并发、超时、断连、权限不足或资源耗尽。
  3. 用四句话复盘:现象是什么、原理在哪一层、证据是什么、生产中如何止损。

验收清单

  • [ ] 示例能从空环境重复执行,必要依赖和版本已写明。
  • [ ] 同时保留成功路径与至少一个失败路径,不用“应该没问题”冒充证据。
  • [ ] 结论写明适用边界,并能链接回对应学习路线的能力域。
  • [ ] 练习结束后已清理临时数据、凭据、端口或进程。

对照 中间件练习答案与复盘尺。先交自己的证据再翻答案;答案是镜子,不是传送门。

用心记录每一步成长