作者: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-file04|安全:别让上传成为"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 分片上传流程(秒传核心)
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.java、MergeService.javaFileServiceTest.java(MockMvc + TestContainers)application-dev.yml与application-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-key 有 PutObject 权限,且 endpoint 与 bucket 地域一致;TRAE 自动生成的 OssAutoConfiguration 已内置时钟同步,避免 15 分钟误差。
结语:把"能跑"变成"敢上线"
文件上传下载的坑,归根结底就三句话:
- 前端看进度,后端看磁盘;
- 安全看白名单,性能看分片;
- 能用 TRAE 写,就别手写样板。
把本文代码拖进 TRAE IDE 跑一遍,你会发现——原来上线前的最后一公里,可以这么短。
思考题 :如果让你再优化一次,你会把"合并"这一步放到客户端本地 WASM 里做吗?欢迎在评论区聊聊你的脑洞。
(此内容由 AI 辅助生成,仅供参考)