后端

JavaWeb文件上传与下载功能的实现教程与实战

TRAE AI 编程助手

作者:TRAE 技术写作团队
日期:2025-10-20
关键词:JavaWeb、文件上传、文件下载、Multipart、Spring Boot、TRAE IDE

引言:为什么文件传输是 Web 的"最后一公里"

在任何业务系统里,文件上传与下载都是"最后一公里"——它直接面向用户,却最容易翻车:

  • 前端进度条卡住 99% 不动;
  • 后端 Tomcat 报 java.lang.OutOfMemoryError
  • 运维凌晨 2 点打电话:"服务器 /tmp 被传满,磁盘 100%!"

本文用"原理 → 代码 → 安全 → 性能 → 实战"五段式,带你一次性把 JavaWeb 文件传输打穿。文末将示范如何用 TRAE IDE 的 AI 编码助手 5 分钟生成一套可上线的上传下载脚手架。


01|HTTP 视角:上传下载到底发生了什么

1.1 上传:把"文件"塞进报文体

浏览器把 <input type="file"> 选中内容按 RFC 7578 打成 multipart/form-data 报文:

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
 
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="avatar.jpg"
Content-Type: image/jpeg
 
<二进制数据>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

后端收到后需解析 boundary,把每一段还原成 Part 对象。

1.2 下载:把"文件"塞进响应体

服务端返回 Content-Type + Content-Disposition 告知浏览器"请保存":

HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="report.xlsx"
Content-Length: 2048000
 
<二进制数据>

02|前端:三种主流上传姿势

2.1 经典 Form 表单(零 JS)

<form action="/upload" method="post" enctype="multipart/form-data">
  <input type="file" name="file" accept=".jpg,.png" required/>
  <button type="submit">上传</button>
</form>

缺点:整页刷新、无进度、无法断点。

2.2 原生 AJAX + FormData(95% 场景够用)

const file = document.querySelector('input[type=file]').files[0];
const xhr = new XMLHttpRequest();
 
xhr.upload.onprogress = e => {
  const percent = Math.round(e.loaded / e.total * 100);
  document.querySelector('#bar').style.width = percent + '%';
};
 
xhr.open('POST', '/upload');
xhr.send(new FormData(document.querySelector('#form')));

2.3 大文件:分片 + SparkMD5 秒传

const chunkSize = 2 * 1024 * 1024; // 2MB
const chunks = Math.ceil(file.size / chunkSize);
const spark = new SparkMD5.ArrayBuffer();
 
for (let i = 0; i < chunks; i++) {
  const start = i * chunkSize;
  const end = Math.min(start + chunkSize, file.size);
  const blob = file.slice(start, end);
  const md5 = await blob.arrayBuffer().then(b => SparkMD5.ArrayBuffer.hash(b));
  await fetch('/upload/chunk', {
    method: 'POST',
    body: JSON.stringify({ index: i, md5, chunk: blob }),
    headers: { 'Content-Type': 'application/octet-stream' }
  });
}
// 合并请求略

03|后端:从 Servlet 到 Spring Boot

3.1 Servlet 3.0 原生写法(无框架)

@WebServlet("/upload")
@MultipartConfig(
    maxFileSize = 10 * 1024 * 1024,       // 单个文件 10MB
    maxRequestSize = 50 * 1024 * 1024,    // 整个请求 50MB
    fileSizeThreshold = 1 * 1024 * 1024,  // 1MB 后写磁盘
    location = "/tmp")
public class UploadServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws IOException, ServletException {
        Part part = req.getPart("file");
        String submitted = part.getSubmittedFileName();
        String ext = submitted.substring(submitted.lastIndexOf('.'));
        String uuid = UUID.randomUUID().toString();
        Path save = Paths.get("/data/file", uuid + ext);
        part.write(save.toAbsolutePath().toString());
 
        resp.getWriter().write("ok:" + uuid);
    }
}

3.2 Spring Boot 极简写法(90% 项目直接抄)

# application.yml
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 50MB
      location: ${java.io.tmpdir}
      resolve-lazily: true # 大文件异步解析
@RestController
@RequestMapping("/api/file")
public class FileController {
 
    @Value("${file.store:/data/file}")
    private String storeDir;
 
    @PostMapping("/upload")
    public Map<String,String> upload(@RequestParam("file") MultipartFile file) {
        String ext = FilenameUtils.getExtension(file.getOriginalFilename());
        String name = UUID.randomUUID() + "." + ext;
        Path path = Paths.get(storeDir, name);
        try {
            Files.createDirectories(path.getParent());
            file.transferTo(path);
        } catch (IOException e) {
            throw new RuntimeException("上传失败", e);
        }
        return Map.of("url", "/api/file/" + name);
    }
 
    @GetMapping("/{name:.+}")
    public void download(@PathVariable String name,
                         HttpServletResponse resp) throws IOException {
        Path file = Paths.get(storeDir).resolve(name).normalize();
        if (!file.startsWith(Paths.get(storeDir))) {
            resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
            return;
        }
        resp.setContentType("application/octet-stream");
        resp.setHeader("Content-Disposition",
                "attachment; filename=\"" + URLEncoder.encode(name, StandardCharsets.UTF_8) + "\"");
        Files.copy(file, resp.getOutputStream());
    }
}

3.3 存储策略:本地、NFS、OSS 怎么选?

场景方案优点缺点
单体应用、低频本地磁盘零依赖多实例不一致
多实例共享NFS/CephFS协议成熟网络瓶颈
高并发、海量阿里云 OSS无限扩容按量计费

Spring Boot 一行配置切换 OSS:

file:
  store-type: oss # local | nfs | oss
  oss:
    endpoint: oss-cn-beijing.aliyuncs.com
    bucket: my-file

04|安全:别让上传成为"getshell"入口

4.1 白名单校验 MIME + 魔数

private static final Map<String,String> MAGIC = Map.of(
        "jpg","FFD8FF","png","89504E47","pdf","25504446");
 
public boolean isValid(MultipartFile file) throws IOException {
    String mine = file.getContentType();
    if (!List.of("image/jpeg","image/png").contains(mine)) return false;
    try (InputStream is = file.getInputStream()) {
        byte[] b = new byte[8];
        is.read(b);
        String hex = bytesToHex(b).toUpperCase();
        return hex.startsWith(MAGIC.get(FilenameUtils.getExtension(file.getOriginalFilename())));
    }
}

4.2 路径穿越过滤

Path target = Paths.get(storeDir, name).normalize();
if (!target.startsWith(Paths.get(storeDir))) {
    throw new SecurityException("非法路径");
}

4.3 病毒扫描(ClamAV 集成)

@Async
public void virusScan(Path file) {
    int exit = new ProcessBuilder("clamscan", "--no-summary", file.toString())
            .inheritIO().start().waitFor();
    if (exit != 0) {
        Files.deleteIfExists(file);
        throw new RuntimeException("病毒检测失败");
    }
}

05|性能:秒传、断点续传、异步合并

5.1 分片上传流程(秒传核心)

sequenceDiagram 浏览器->>后端: 预上传 /beforeUpload {md5,size} 后端-->>浏览器: 已存在返回 skip=true 浏览器->>后端: 逐片 POST /chunk 浏览器->>后端: 合并 POST /merge {md5,chunks} 后端-->>浏览器: 返回 url

5.2 断点续传:Redis 记录已传分片

@PostMapping("/chunk")
public String chunk(@RequestBody ChunkDto dto) {
    String key = "chunk:" + dto.getMd5() + ":" + dto.getIndex();
    redisTemplate.opsForValue().set(key, "1", Duration.ofHours(1));
    Path path = Paths.get(tmpDir, dto.getMd5() + "-" + dto.getIndex());
    Files.write(path, dto.getData());
    return "ok";
}

5.3 异步合并 & 线程池隔离

@Async("mergeExecutor")
public void merge(String md5, int chunks) {
    try (FileChannel out = FileChannel.open(Paths.get(storeDir, md5),
            StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
        for (int i = 0; i < chunks; i++) {
            Path c = Paths.get(tmpDir, md5 + "-" + i);
            try (FileChannel in = FileChannel.open(c)) {
                in.transferTo(0, in.size(), out);
            }
            Files.delete(c);
        }
    }
}

06|实战:一个可落地的配置清单

建议值备注
单文件上限50MB超过走 OSS 直传
分片大小2MB移动端弱网平衡值
线程池core=4,max=8,queue=100仅合并任务
临时目录/data/tmp每日凌晨定时清理 3 天前
日志记录 MD5、耗时、UA方便追踪秒传率
监控Grafana 看板上传 QPS、平均耗时、异常数

07|TRAE IDE:让文件上传下载"零样板"

7.1 AI 一键生成骨架代码

在 TRAE 侧边对话输入:

帮我生成 Spring Boot 文件上传下载模块,要支持分片、秒传、OSS,再加单元测试。

TRAE 会在 30 秒内输出:

  • FileController.java(含本地 & OSS 双策略)
  • ChunkDto.javaMergeService.java
  • FileServiceTest.java(MockMvc + TestContainers)
  • application-dev.ymlapplication-prod.yml

7.2 行内对话:自动补全 MIME 白名单

把光标停在 List.of("image/jpeg"...) 旁,按 Ctrl+I 输入:

再加常见的文档类型,顺便把魔数也补上。

TRAE 会立即补全:

private static final Map<String,String> ALLOW = Map.of(
    "jpg","image/jpeg","png","image/png",
    "pdf","application/pdf","docx","application/vnd.openxmlformats-officedocument.wordprocessingml.document");

7.3 智能检查:防止路径穿越

保存文件瞬间,TRAE 静态分析会高亮:

Path target = Paths.get(storeDir, name);

提示:normalize() 后未做 startsWith 校验,存在目录穿越风险。一键修复即可。

7.4 远程调试:直接 attach 到测试服务器

TRAE 内置 Remote SSH,点击左侧"远程"图标 → 选择测试机 → 自动同步代码 → 断点调试 /merge 接口,无需手动打包、上传、重启。


08|常见问题 FAQ

Q1: 前端进度条到 100% 但后端还没合并?
A: 100% 仅代表分片上传完成,合并是异步任务,可轮询 /status/{md5} 或改用 WebSocket 推送。

Q2: 生产环境偶发 IOException: No space left on device
A: 检查 location 是否落在 /tmp,systemd 默认 30 天清理;建议挂载独立盘并加 inode 监控。

Q3: 阿里云 OSS 报 403 SignatureDoesNotMatch?
A: 确认 access-keyPutObject 权限,且 endpoint 与 bucket 地域一致;TRAE 自动生成的 OssAutoConfiguration 已内置时钟同步,避免 15 分钟误差。


结语:把"能跑"变成"敢上线"

文件上传下载的坑,归根结底就三句话:

  1. 前端看进度,后端看磁盘;
  2. 安全看白名单,性能看分片;
  3. 能用 TRAE 写,就别手写样板。

把本文代码拖进 TRAE IDE 跑一遍,你会发现——原来上线前的最后一公里,可以这么短。

思考题:如果让你再优化一次,你会把"合并"这一步放到客户端本地 WASM 里做吗?欢迎在评论区聊聊你的脑洞。

(此内容由 AI 辅助生成,仅供参考)