【从零开始的 Web 后端学习】文件上传一篇搞定(MultipartFile + 本地存储 + 阿里云 OSS 保姆级教程)

上一篇我们把"新增员工 + 事务管理"都搞定了,但细心的你肯定发现了------员工没有头像! 这一篇就专门解决这个问题:文件上传。全程无图、代码可直接抄 ✅,建议收藏。


ok,咱们先想一个问题 😩

你在页面里点"新增员工",表格里啥都能填:姓名、性别、薪资、入职日期...... 但就是没有头像,图片位置是空的。

可产品原型上明明有头像框啊,点一下还能选图片呢。这个"选图片 → 上传 → 显示"的功能,前端怎么把图片送到服务器?服务器怎么接住?存哪?

这就要用到今天的主题------文件上传

别小看这个功能,它比你想象的普及多了:你发微博、发朋友圈、发小红书,选的那张照片,本质上都是执行了一次文件上传 ✅


一、什么是文件上传?

一句话定义(加粗版):文件上传,就是将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。

比如在新增员工时,点加号 / 点图片,选择电脑或手机本地的一张图片,选完之后这张图就会被传送到服务器,完成上传。

一次文件上传的完整链路长这样:

复制代码
┌──────────┐   multipart/form-data    ┌─────────────┐   存储     ┌──────────┐
│   前端页面  │ ───────────────────────▶ │   后端服务器  │ ────────▶ │ 存储介质  │
│  选择本地文件│         POST 请求        │  Controller  │           │ 本地磁盘/OSS│
└──────────┘                           └─────────────┘           └──────────┘
  • 前端:负责让用户选择文件,并把文件以特殊格式打包发给服务器
  • 后端:负责接住文件,并把它存储起来(本地磁盘 or 云存储)
  • 存储介质:存到哪?这是咱们后面要重点纠结的问题

二、前端三要素(基础中的基础)

先看一个最原始的上传表单(可以直接抄 ✅):

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>上传文件</title>
</head>
<body>

    <form action="/upload" method="post" enctype="multipart/form-data">
        姓名: <input type="text" name="username"><br>
        年龄: <input type="text" name="age"><br>
        头像: <input type="file" name="file"><br>
        <input type="submit" value="提交">
    </form>

</body>
</html>

这个表单要能正确上传文件,必须同时满足三个条件(上传文件页面三要素):

# 要素 说明
1 有 file 域 <input type="file">,用于让用户选择要上传的文件
2 提交方式必须为 POST 通常上传的文件比较大,GET 装不下,必须用 POST
3 编码类型 enctype = multipart/form-data 普通默认的表单编码不适合传输大型的二进制数据

:第三条最容易被忽略。普通表单默认的编码格式(application/x-www-form-urlencoded)是"键值对"形式的,根本没法传输大段二进制数据。只有把 enctype 设成 multipart/form-data,文件数据才会被正确打包,这也就是为什么这种表单数据格式叫"multipart(多部分)"。

结论:上传文件,表单三要素缺一不可------file 域 + POST + multipart/form-data。

📌 易忘点 :把你上面的 upload.html 丢到 SpringBoot 项目的 static 目录下,启动项目后直接访问 http://localhost:8080/upload.html 就能看到这个页面,用来联调很方便。


三、后端怎么接住文件?------ MultipartFile

前端把文件发过来了,后端怎么接收?

Spring 已经给我们封装好了一个 API:MultipartFile,用它就能接到上传的文件 ✅

在 Controller 里定义一个方法,把 MultipartFile 作为参数:

java 复制代码
@Slf4j
@RestController
public class UploadController {

    /**
     * 上传文件 - 参数名 file
     */
    @PostMapping("/upload")
    public Result upload(String username, Integer age, MultipartFile file) throws Exception {
        log.info("上传文件:{}, {}, {}", username, age, file);
        if (!file.isEmpty()) {
            // transferTo:把接收到的文件转存到磁盘
            file.transferTo(new File("D:\\images\\" + file.getOriginalFilename()));
        }
        return Result.success();
    }
}

注意:方法形参的名字(usernameagefile)要和前端表单里的请求参数名保持一致,Spring 才能自动绑定上。

MultipartFile 的常用方法,记下来:

方法 作用
String getOriginalFilename() 获取原始文件名
void transferTo(File dest) 将接收的文件转存到磁盘文件中
long getSize() 获取文件大小,单位:字节
byte[] getBytes() 获取文件内容的字节数组
InputStream getInputStream() 获取接收到的文件内容的输入流

参数名不一致怎么办?------ @RequestParam

万一前端表单里那个 file 域叫 file,但你方法形参想叫 image,名字对不上,Spring 就绑不上了。这时候用 @RequestParam 显式指定:

java 复制代码
public Result upload(String username,
                     Integer age,
                     @RequestParam("file") MultipartFile image)

@RequestParam("file") 的意思是:去请求参数里找叫 file 的那一项,绑定给 image 这个形参 ✅


四、本地存储:存到服务器磁盘

上面的代码已经能"接住"文件并存到本地磁盘了。但测试时你会发现一个 bug:

上传一个 1.jpg,再传一个也叫 1.jpg 的文件------后面上传的会把前面的覆盖掉

原因很简单:文件名相同,存到同一个路径,后写的覆盖先写的。

优化:用 UUID 保证文件名不重复

改造一下:用 UUID 生成一段几乎不可能重复的字符串当文件名,再拼上原来的后缀名 ✅

java 复制代码
@Slf4j
@RestController
public class UploadController {

    private static final String UPLOAD_DIR = "D:/images/";

    /**
     * 上传文件 - 参数名 file
     */
    @PostMapping("/upload")
    public Result upload(MultipartFile file) throws Exception {
        log.info("上传文件:{}", file);
        if (!file.isEmpty()) {
            // 1. 获取原始文件名
            String originalFilename = file.getOriginalFilename();
            // 2. 截取后缀名,比如 .jpg
            String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
            // 3. 生成唯一文件名:UUID + 后缀,比如 a3f2...9b0c.jpg
            String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;
            // 4. 拼接完整路径
            File targetFile = new File(UPLOAD_DIR + uniqueFileName);

            // 5. 目标目录不存在则创建
            if (!targetFile.getParentFile().exists()) {
                targetFile.getParentFile().mkdirs();
            }
            // 6. 保存文件
            file.transferTo(targetFile);
        }
        return Result.success();
    }
}

善用类比 :UUID 生成的文件名就像身份证号,世界上几乎不可能有两个一模一样的。就算用户上传 100 个叫 1.jpg 的文件,存储端也是 100 个互不重名的新文件,谁也不覆盖谁。

坑:默认单个文件最大 1M!

当你上传一个大一点的文件(超过 1M)时,后端直接报错:

复制代码
The field file exceeds its maximum permitted size of 1048576 bytes.

SpringBoot 文件上传默认单个文件最大 1M,整个请求最大 10M。超了就会报这个错。

application.yml 里配置一下就能解决:

yaml 复制代码
spring:
  servlet:
    multipart:
      max-file-size: 10MB      # 单个文件最大 10M
      max-request-size: 100MB  # 整个请求最大 100M(多个文件总和)

结论:本地存储方案 = 接住文件 + UUID 唯一文件名 + 调整大小限制。 到这儿,本地存储就完成了。

本地存储的缺点(为什么要引出云存储)

文件直接存服务器磁盘,虽然能用,但问题不小:

  • 🔴 不安全:磁盘一旦损坏,所有文件全没了
  • 🔴 容量有限:图片多了磁盘空间扛不住(磁盘不可能无限扩容)
  • 🔴 无法直接访问:对外没法方便地给到 URL 让用户访问

怎么解决?通常两条路:

  1. 自己搭存储服务器:比如 fastDFS、MinIO
  2. 用现成的云服务:比如阿里云、腾讯云、华为云

咱们这一篇讲主流方案------阿里云 OSS


五、阿里云 OSS 云存储

5.1 先搞懂几个概念

云服务是什么?

云服务,就是通过互联网对外提供各种现成服务的统称,比如语音服务、短信服务、邮件服务、文字识别服务、对象存储服务等等。

善用类比 :你项目里要发短信,自己搞?得跟三大运营商一个个对接,费老劲了。阿里云早就替你把运营商对接好了,对外提供"短信服务",你直接调它提供的接口就能发短信。大白话:别人帮我们实现好了功能,我们只要调用即可(当然,一般要收费 😏)。

OSS 是什么?

阿里云对象存储 OSS(Object Storage Service),一款海量、安全、低成本、高可靠的云存储服务。通过网络随时存储和调用文本、图片、音频、视频等各类文件。

SDK 是什么?

SDK = Software Development Kit(软件开发工具包),包含辅助软件开发的依赖(jar 包)、代码示例等。简单说,SDK 里装着使用第三方云服务所需的依赖 + 示例代码,照着示例改就能写出入门程序。

Bucket 是什么?

Bucket(存储空间) :用来存储对象(Object,也就是文件)的容器。所有的对象都必须归属于某个存储空间,相当于 OSS 里的"根目录"。

用 OSS 的整个思路其实超简单:引入 SDK 依赖 → 参照官方示例写代码 → 把文件塞进 Bucket → 拿到访问 URL

复制代码
后端接收到文件
      │
      ▼
┌─────────────────────────┐
│ 调用 OSS SDK:putObject  │
│ (bucketName, objectName, │
│       文件字节流)          │
└─────────────────────────┘
      │
      ▼
文件存进阿里云 OSS 的 Bucket
      │
      ▼
返回文件的访问 URL(前端凭 URL 显示图片)

5.2 准备工作(照着点就行)

  1. 注册阿里云账户 并实名认证:https://account.aliyun.com/
  2. 开通 OSS 服务:控制台里找到"对象存储 OSS",第一次访问需要开通
  3. 创建 Bucket:进入 OSS 控制台 → 左侧"Bucket 列表" → 创建 Bucket,其它配置项用默认即可
  4. 配置 AccessKey
    • 点击"AccessKey 管理" → 创建 AccessKey
    • 以管理员身份打开 CMD,设置环境变量(⚠️ 值一定换成你自己的!):
cmd 复制代码
set OSS_ACCESS_KEY_ID=你的AccessKeyID
set OSS_ACCESS_KEY_SECRET=你的AccessKeySecret
cmd 复制代码
setx OSS_ACCESS_KEY_ID "%OSS_ACCESS_KEY_ID%"
setx OSS_ACCESS_KEY_SECRET "%OSS_ACCESS_KEY_SECRET%"

验证环境变量是否生效:

cmd 复制代码
echo %OSS_ACCESS_KEY_ID%
echo %OSS_ACCESS_KEY_SECRET%

坑(重要):AccessKey 就是你的"云钥匙",千万别硬编码在代码里、也别传到 GitHub!后面专门有一节讲它。

5.3 引入依赖 + 入门 Demo

pom.xml 引入阿里云 OSS 的 SDK 依赖:

xml 复制代码
<!-- 阿里云OSS依赖 -->
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.17.4</version>
</dependency>

<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.1</version>
</dependency>
<dependency>
    <groupId>javax.activation</groupId>
    <artifactId>activation</artifactId>
    <version>1.1.1</version>
</dependency>
<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
    <version>2.3.3</version>
</dependency>

提示:如果直接用新版依赖,后三个 jaxb/activation 可以不加,按你的 SpringBoot 版本试运行一下,报缺类就补上。

参照官方 SDK 文档写个入门程序(把 endpointbucketNameobjectName、本地 file 换成你自己的):

java 复制代码
package com.itheima;

import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.nio.file.Files;

public class Demo {

    public static void main(String[] args) throws Exception {
        // Endpoint:OSS 的 bucket 对应的域名(以华北2北京为例)
        String endpoint = "https://oss-cn-beijing.aliyuncs.com";
        // 从环境变量获取访问凭证(确保已配置 OSS_ACCESS_KEY_ID 和 OSS_ACCESS_KEY_SECRET)
        EnvironmentVariableCredentialsProvider credentialsProvider =
                CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
        // Bucket 名称
        String bucketName = "java-ai";
        // Object 名称(Bucket 中存储的对象的名称,不能包含 Bucket 名称)
        String objectName = "001.jpg";
        // Bucket 所属地域
        String region = "cn-beijing";

        // 创建 OSSClient 实例
        ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
        clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
        OSS ossClient = OSSClientBuilder.create()
                .endpoint(endpoint)
                .credentialsProvider(credentialsProvider)
                .clientConfiguration(clientBuilderConfiguration)
                .region(region)
                .build();

        try {
            File file = new File("C:\\Users\\deng\\Pictures\\1.jpg");
            byte[] content = Files.readAllBytes(file.toPath());
            ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
        } catch (OSSException oe) {
            System.out.println("Caught an OSSException ..." + oe.getErrorMessage());
        } catch (ClientException ce) {
            System.out.println("Caught an ClientException ..." + ce.getMessage());
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
    }
}

结论:运行成功后,本地文件就被传到了阿里云 OSS 的 Bucket 里。

5.4 集成到项目里(工具类 + Controller)

正式集成时,把上面的逻辑封装成一个工具类,交给 Spring 管理:

java 复制代码
package com.itheima.utils;

import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.stereotype.Component;

import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

@Component
public class AliyunOSSOperator {

    private String endpoint = "https://oss-cn-beijing.aliyuncs.com";
    private String bucketName = "java-ai";
    private String region = "cn-beijing";

    public String upload(byte[] content, String originalFilename) throws Exception {
        // 从环境变量中获取访问凭证
        EnvironmentVariableCredentialsProvider credentialsProvider =
                CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();

        // Object 路径按日期分组,比如 2026/08/xxxx.jpg,方便管理
        String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
        // 生成不重复的新文件名(UUID + 原后缀)
        String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
        String objectName = dir + "/" + newFileName;

        // 创建 OSSClient 实例
        ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
        clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
        OSS ossClient = OSSClientBuilder.create()
                .endpoint(endpoint)
                .credentialsProvider(credentialsProvider)
                .clientConfiguration(clientBuilderConfiguration)
                .region(region)
                .build();

        try {
            // 上传文件
            ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
        } finally {
            ossClient.shutdown();
        }

        // 拼接文件的访问 URL,返回给前端
        return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
    }
}

改造 UploadController,把文件交给工具类上传,并把 URL 返回给前端:

java 复制代码
@Slf4j
@RestController
public class UploadController {

    @Autowired
    private AliyunOSSOperator aliyunOSSOperator;

    @PostMapping("/upload")
    public Result upload(MultipartFile file) throws Exception {
        log.info("上传文件:{}", file);
        if (!file.isEmpty()) {
            // 生成唯一文件名
            String originalFilename = file.getOriginalFilename();
            String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
            String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;
            // 上传到 OSS,拿到访问 URL
            String url = aliyunOSSOperator.upload(file.getBytes(), uniqueFileName);
            return Result.success(url);
        }
        return Result.error("上传失败");
    }
}

接口测试通过后,前端拿到这个 URL,<img src="url"> 就能展示员工的头像了 ✅

复制代码
新增员工上传头像流程:
前端选图片
  → POST /upload(multipart/form-data)
  → UploadController 接到 MultipartFile
  → AliyunOSSOperator.upload() 上传到 OSS
  → 返回访问 URL
  → 前端把 URL 和员工信息一起保存
  → 列表页 <img src="URL"> 展示头像

六、功能优化:把配置抽出来

刚才的工具类里,endpointbucketNameregion 直接写死在 Java 代码里了。这在学习时没问题,但上线就很要命

  • 测试环境、生产环境要换参数,难道改代码重新部署?
  • 大型项目里所有参数都写死在代码中,根本没法维护

解决办法:把这些易变参数放到配置文件里

方式一:@Value 一个个注入

yaml 复制代码
# application.yml
aliyun:
  oss:
    endpoint: https://oss-cn-beijing.aliyuncs.com
    bucketName: java-ai
    region: cn-beijing
java 复制代码
@Component
public class AliyunOSSOperator {

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

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

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

    // ... 其余代码不变
}

📌 易忘点 :如果只有一两个属性要注入、不考虑复用,用 @Value 就够了。但如果配置项多,一个个 @Value 注入会非常繁琐,不方便维护和复用。

方式二:@ConfigurationProperties 整类注入(推荐)

Spring 提供了一种"批量注入"的简化套路,三步:

  1. 创建一个配置类,属性名和配置文件里的 key 一一对应 ,并提供 getter/setter(用 Lombok 的 @Data 搞定)
  2. 把这个类交给 Spring 的 IOC 容器管理(加 @Component
  3. 类上加 @ConfigurationProperties,用 prefix 指定配置项前缀
java 复制代码
package com.itheima.utils;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSProperties {
    private String endpoint;
    private String bucketName;
    private String region;
}

然后把工具类改成注入这个配置类:

java 复制代码
@Component
public class AliyunOSSOperator {

    @Autowired
    private AliyunOSSProperties aliyunOSSProperties;

    public String upload(byte[] content, String originalFilename) throws Exception {
        String endpoint = aliyunOSSProperties.getEndpoint();
        String bucketName = aliyunOSSProperties.getBucketName();
        String region = aliyunOSSProperties.getRegion();
        // ... 其余代码不变
    }
}

@ConfigurationProperties(prefix = "aliyun.oss") 会去配置里找 aliyun.oss.* 开头的配置项,按属性名自动匹配 注入。以后换环境,只改 application.yml,一行 Java 代码都不用动 ✅


七、易忘点 & 坑 总结

📌 易忘点

  1. 前端上传三要素 :file 域 + POST 提交 + enctype="multipart/form-data",缺一不可。
  2. 后端用 MultipartFile 接文件;形参名要和请求参数名一致 ,不一致用 @RequestParam("原名")
  3. MultipartFile 常用方法:getOriginalFilename()transferTo(File)getSize()getBytes()getInputStream()
  4. 文件名重复会互相覆盖 → 用 UUID + 后缀生成唯一文件名。
  5. SpringBoot 默认单个文件最大 1M → 用 spring.servlet.multipart.max-file-size 调整。
  6. 本地存储三大缺点:不安全、容量有限、无法直接访问 → 引出云存储。
  7. OSS 三个概念:SDK (开发工具包)、Bucket (存储空间,所有文件都归属某个 Bucket)、Object(对象,就是文件)。
  8. 易变参数不要写死在代码里,用 @Value@ConfigurationProperties 注入。

❌ 坑

  1. 永远不要信任 getOriginalFilename() :它可能带着 ../ 之类的路径穿越字符,被拼接进路径后可能写到任意位置。一定用 UUID 重命名,不要直接用原文件名。
  2. 大文件不要用 getBytes() :它会把整个文件一次性加载进 JVM 堆内存,并发上传大文件极易 OOM 。大文件请用 getInputStream() 流式处理。
  3. 文件类型可以伪造 :客户端传来的 Content-Type 和扩展名都不可信(一个叫 photo.jpg 的文件内容可能是任意字节)。生产环境要做真实类型校验(如 Apache Tika 检测 Magic Bytes),不能只信文件名。
  4. 大小限制是多层的 :Spring 配了还不够,前面还有 Nginx 默认 client_max_body_size 1M(超了直接 413),后面还有 Tomcat 限制。排查"传不了大文件"要层层查。
  5. AccessKey 千万别泄露:一旦泄露,别人可以凭它操作你的云资源(删库、偷数据、刷账单)。⚠️ 硬编码在代码里、传到 GitHub、写进前端 JS,都是高危操作。
  6. Linux 部署上传报错 :Tomcat 默认用 /tmp 存上传临时文件,有些 Linux 的 /tmp 被加了 noexec 等挂载参数,会导致"临时目录无效"报错,需要显式配置 spring.servlet.multipart.location
  7. 事务和上传常配合踩坑:上传成功后要往数据库存 URL,如果存库失败,OSS 上的文件就变成"孤儿文件"了。生产环境要设计补偿/清理策略(进阶话题,这里先埋个伏笔 😏)。

结语

好,今天这篇信息量不小,咱们回顾一下:

  • 文件上传是啥:本地文件 → 服务器,供人浏览或下载
  • 前端三要素:file 域 + POST + multipart/form-data
  • 后端用 MultipartFile 接文件
  • 本地存储:UUID 防重名 + 调大文件大小限制
  • 云存储:阿里云 OSS,SDK 上传 + Bucket 存储 + URL 访问
  • 配置优化:@Value@ConfigurationProperties
  • 一堆易忘点和坑

代码全都能直接抄 ✅。建议你跟着把"本地存储"先跑通,再尝试接阿里云 OSS------OSS 那部分只要你有一个阿里云账号(有免费试用额度),按步骤走一遍就通了。

下一篇预告:登录校验(JWT)?还是 MyBatis 动态 SQL?评论区告诉我,咱们继续一篇一篇啃 💪


如果本文对你有帮助,欢迎点赞、收藏、转发~ 评论区可以告诉我你还想看什么,咱们把它做成一个系列慢慢啃完!

相关推荐
风骏时光牛马12 小时前
AIRAGDebug智能检索链路异常定位与调试分析
前端
高亦真12 小时前
今天是学习嵌入式的第32天
linux·学习·算法
ljt272496066113 小时前
Flutter笔记--envied
前端·笔记·flutter
Flynt13 小时前
React Router v8 升了之后,我花了两天才把项目跑起来
前端·typescript·preact
泯泷19 小时前
手搓JSVMM第 8 篇:函数调用:参数、返回值与调用帧
前端·javascript·前端框架
Sherotree20 小时前
Google Antigravity——Agent 被提到主界面
前端·ide·后端·edge浏览器
老猿AI洞察20 小时前
阿里云启用巴西数据中心,AI服务出海落子南半球
人工智能·阿里云·云计算
Bruce_Liuxiaowei20 小时前
从基础理论开始学习人工智能(三):盲目搜索——从状态空间图到生成-测试范式
人工智能·学习
里欧跑得慢20 小时前
Flutter 像素级还原:设计稿到代码的视觉无损转换技巧
前端·css·flutter·web