Java NIO 实战:三种文件处理场景的生产写法
场景一:大文件上传
前端传文件过来,后端接收并保存到本地或附件服务。
从 multipart 中读取保存到本地
java
@PostMapping("/upload")
public String upload(MultipartFile file) throws IOException {
String destPath = "/data/uploads/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
// MultipartFile 本身提供了 transferTo,但大文件建议用 FileChannel 更可控
try (FileChannel out = FileChannel.open(Paths.get(destPath),
StandardOpenOption.WRITE,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)) {
FileChannel in = ((FileChannel) file.getInputStream().getChannel());
// FileInputStream 获取 Channel
// 零拷贝传输
long transferred = 0;
long fileSize = in.size();
while (transferred < fileSize) {
transferred += in.transferTo(transferred, fileSize - transferred, out);
}
}
return destPath;
}
边读边写(不依赖 multipart 的 InputStream)
有时候前端不是 multipart 上传,而是直接传二进制流,比如大文件分片上传的场景。
java
@PostMapping("/upload/raw")
public void uploadRaw(HttpServletRequest request) throws IOException {
String destPath = "/data/uploads/" + UUID.randomUUID() + ".bin";
try (FileChannel out = FileChannel.open(Paths.get(destPath),
StandardOpenOption.WRITE,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)) {
// 从 request 的 InputStream 包装成 Channel
ReadableByteChannel in = Channels.newChannel(request.getInputStream());
ByteBuffer buf = ByteBuffer.allocate(8192); // 8K 缓冲区
while (in.read(buf) != -1) {
buf.flip();
out.write(buf);
buf.clear();
}
}
}
上传到 MinIO(不走本地磁盘)
如果你们用的是 MinIO 或 S3 之类的对象存储,不需要落盘:
java
@PostMapping("/upload/oss")
public String uploadToOss(MultipartFile file) throws IOException {
String objectName = "uploads/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
minioClient.putObject(PutObjectArgs.builder()
.bucket("my-bucket")
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
return objectName;
}
大文件上传的注意事项
- 不要
file.getBytes()--- 大文件会直接撑爆内存 - 不要
FileUtils.copyInputStreamToFile--- 本质也是先读到内存再写 - 分段传 --- 前端分片 + 后端按 offset 写入(如果文件超过 500M,建议前端分片)
- 限流 --- Nginx 层限制请求体大小,防止有人直接传几个 G 过来
- 临时文件清理 --- 上传过程中崩溃了,记得兜底删除残留文件
场景二:文件拷贝
把本地一个文件复制到另一个地方。
零拷贝复制(最推荐)
java
public static void copyFile(String source, String target) throws IOException {
try (FileChannel in = FileChannel.open(Paths.get(source), StandardOpenOption.READ);
FileChannel out = FileChannel.open(Paths.get(target),
StandardOpenOption.WRITE,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)) {
long transferred = 0;
long fileSize = in.size();
while (transferred < fileSize) {
transferred += in.transferTo(transferred, fileSize - transferred, out);
}
}
}
带进度的复制(大文件时给前端反馈)
java
public static void copyFileWithProgress(String source, String target, ProgressCallback callback) throws IOException {
try (FileChannel in = FileChannel.open(Paths.get(source), StandardOpenOption.READ);
FileChannel out = FileChannel.open(Paths.get(target),
StandardOpenOption.WRITE,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)) {
long transferred = 0;
long fileSize = in.size();
while (transferred < fileSize) {
transferred += in.transferTo(transferred, fileSize - transferred, out);
callback.onProgress(transferred, fileSize);
}
}
}
@FunctionalInterface
interface ProgressCallback {
void onProgress(long transferred, long total);
}
目录下的批量复制
java
public static void copyDir(String sourceDir, String targetDir) throws IOException {
Files.createDirectories(Paths.get(targetDir));
Files.walk(Paths.get(sourceDir)).forEach(sourcePath -> {
try {
String relative = sourceDir.relativize(sourcePath).toString();
Path targetPath = Paths.get(targetDir, relative);
if (Files.isDirectory(sourcePath)) {
Files.createDirectories(targetPath);
} else {
copyFile(sourcePath.toString(), targetPath.toString());
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
FileChannel 和 Files.copy 选哪个?
java
// 方式一:FileChannel.transferTo(零拷贝)
try (FileChannel in = ...; FileChannel out = ...) {
in.transferTo(0, in.size(), out);
}
// 方式二:Files.copy(简单,但内部实现取决于平台)
Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
- 同一个机器上的文件复制,两者差别不大。
Files.copy底层在 Linux 上也走 sendfile,是一样的 - 跨机器场景(本地文件系统 → 远程 socket),只能用
transferTo - 从代码简洁角度看,小文件直接用
Files.copy。几百兆以上的用FileChannel.transferTo
场景三:大文件导出
本地文件通过网络传给前端。这是最容易踩坑的场景------很多人直接把文件全读到内存再输出,文件一大就 OOM。
标准写法:FileChannel 直接写到 Response
java
@GetMapping("/export")
public void export(HttpServletResponse response) throws IOException {
File file = new File("/data/reports/monthly-report.xlsx");
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
response.setContentLengthLong(file.length());
try (FileChannel in = FileChannel.open(file.toPath(), StandardOpenOption.READ);
WritableByteChannel out = Channels.newChannel(response.getOutputStream())) {
long transferred = 0;
long fileSize = in.size();
while (transferred < fileSize) {
transferred += in.transferTo(transferred, fileSize - transferred, out);
}
}
}
用 OutputStream 写(如果框架限制只能用 OutputStream)
如果你的框架不支持 Channel,退而求其次用 Buffer 流式写:
java
@GetMapping("/download")
public void download(HttpServletResponse response) throws IOException {
Path file = Paths.get("/data/reports/monthly-report.xlsx");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=report.xlsx");
response.setContentLengthLong(Files.size(file));
try (InputStream is = Files.newInputStream(file);
OutputStream os = response.getOutputStream()) {
byte[] buf = new byte[8192];
int len;
while ((len = is.read(buf)) != -1) {
os.write(buf, 0, len);
}
}
}
Files.newInputStream 返回的是一个 FileInputStream,它底层读文件不会把整个文件加载到堆内存,而是走系统调用,每次只读 8K。所以即使文件几个 G,内存占用也是稳定的。
动态生成内容直接导出(不落盘)
有时候文件是实时生成的,比如导出数据库数据为 Excel。这时候不需要先保存成文件再发送,边生成边发:
java
@GetMapping("/export/excel")
public void exportExcel(HttpServletResponse response) throws IOException {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=export.xlsx");
// 用 SXSSFWorkbook(不缓存数据在内存)
try (SXSSFWorkbook workbook = new SXSSFWorkbook()) {
Sheet sheet = workbook.createSheet("数据");
// 边写行
for (int i = 0; i < 100000; i++) {
Row row = sheet.createRow(i);
row.createCell(0).setCellValue("第" + i + "行");
}
// 直接写到 response 流,不经过本地磁盘
workbook.write(response.getOutputStream());
}
}
大文件导出的注意事项
| 不要这么做 | 原因 |
|---|---|
FileUtils.readFileToByteArray |
整个文件读到堆内存,文件稍大就 OOM |
IOUtils.toByteArray |
同上 |
byte[] fileBytes = Files.readAllBytes(path) |
同上 |
| 先保存到本地再返回 | 要兜底清理临时文件,而且多了 IO 开销 |
| 不设置 ContentLength | 前端不知道文件多大,不能显示进度条 |
导出时的超时处理
大文件导出时间较长,Nginx 和 Spring Boot 都有默认超时,要提前配好:
yaml
# application.yml
server:
servlet:
session:
timeout: 30m
Nginx 侧:
nginx
proxy_read_timeout 600s;
proxy_send_timeout 600s;
总结
| 场景 | 推荐方式 | 核心代码 |
|---|---|---|
| 上传保存 | FileChannel.transferTo |
in.transferTo(offset, size, out) |
| 文件复制 | FileChannel.transferTo |
in.transferTo(offset, size, out) |
| 文件导出 | FileChannel.transferTo 到 Response |
in.transferTo(offset, size, Channels.newChannel(response.getOutputStream())) |
核心思想就一条:数据不经过 Java 堆内存,在内核态完成传输。 你只需要管理好 offset 和 while 循环确保全量传输,剩下的交给操作系统。