大文件导出:零拷贝发送 Excel、Word、PDF、PPT
报表、合同、课件这类导出文件动辄几十 MB。Resps 的导出方法除了接收 byte[],还可以直接接收 File:文件不会读进堆内存,而是作为文件区间交给传输层发送。普通 HTTP 连接上由操作系统的 sendfile 完成零拷贝传输,TLS 连接上按块加密发送。
这样做的好处:
- 内存占用与文件大小无关:无论导出 1 MB 还是 1 GB,业务代码都不需要分配同样大小的字节数组,也不会因此触发大对象 GC。
- CPU 占用更低:数据直接从内核页缓存进入网络通道,省去应用层的读写搬运。
- 响应头一次到位:Content-Type、
Content-Length、带中文文件名的Content-Disposition都由框架设置;文件不存在时返回404。 - 对反向代理友好:响应自带
X-Accel-Buffering: no,nginx 边收边发,不会把大文件缓冲到代理机的临时目录。
一、导出方法
每个方法都以 HttpResponse 为入口,写回传入的对象并返回它,配合 TioRequestContext.getResponse() 使用。
Resps.excel(response, file, filename):xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheetResps.excelXls(response, file, filename):xls,application/vnd.ms-excelResps.docx(response, file, filename):docx,application/vnd.openxmlformats-officedocument.wordprocessingml.documentResps.doc(response, file, filename):doc,application/mswordResps.pdf(response, file, filename):pdf,application/pdfResps.pptx(response, file, filename):pptx,application/vnd.openxmlformats-officedocument.presentationml.presentationResps.ppt(response, file, filename):ppt,application/vnd.ms-powerpointResps.download(response, file, contentType, filename):任意类型;contentType为空时按扩展名推断Resps.download(response, file, filename):按扩展名推断类型
filename 是浏览器保存时使用的文件名,可以包含中文;为空时使用磁盘文件本身的名字。推断类型使用 Resps.contentTypeOf(filename),xlsx、docx、pptx 返回 Office Open XML 类型,识别不出时返回 application/octet-stream。
已经在内存里的小文件继续使用 byte[] 版本即可,两组方法的响应头一致,详见 Resps。
二、完整示例
示例提供两个接口:
GET /export/orders?rows=50000:用 EasyExcel 生成订单表,再零拷贝发送。GET /export/report?type=pdf|docx|pptx:发送已经生成好的 PDF、Word、PPT 报告。
表格写入使用 EasyExcel,依赖配置见 EasyExcel 导出。Word、PDF、PPT 可以由任何生成库输出到磁盘文件,发送方式完全相同。
启动类:
package com.example.export;
import com.example.export.config.ExportConfig;
import nexus.io.tio.boot.TioApplication;
public class ExportApp {
public static void main(String[] args) {
TioApplication.run(ExportApp.class, new ExportConfig(), args);
}
}
注册路由:
package com.example.export.config;
import com.example.export.handler.OrderExcelExportHandler;
import com.example.export.handler.ReportFileHandler;
import nexus.io.context.BootConfiguration;
import nexus.io.tio.boot.server.TioBootServer;
import nexus.io.tio.http.server.router.HttpRequestRouter;
public class ExportConfig implements BootConfiguration {
@Override
public void config() {
HttpRequestRouter router = TioBootServer.me().getRequestRouter();
router.get("/export/orders", new OrderExcelExportHandler());
router.get("/export/report", new ReportFileHandler());
}
}
service 负责生成文件。导出文件写到专门的目录,文件名用雪花 ID 保证唯一:
package com.example.export.service;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import com.alibaba.excel.EasyExcel;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
public class OrderExportService {
/** 导出文件目录。文件在 handler 返回后才开始发送,所以按时间清理,而不是发送前删除。 */
private static final Path EXPORT_DIR = Paths.get("exports");
public File exportOrders(int rows) throws IOException {
Files.createDirectories(EXPORT_DIR);
removeExpiredFiles();
List<List<String>> head = new ArrayList<>();
head.add(List.of("订单号"));
head.add(List.of("下单日期"));
head.add(List.of("金额"));
List<List<Object>> data = new ArrayList<>(rows);
for (int i = 1; i <= rows; i++) {
data.add(List.of("SO" + (100000 + i), LocalDate.of(2026, 1, 1).plusDays(i % 365).toString(), i * 1.5));
}
File file = EXPORT_DIR.resolve("orders-" + SnowflakeIdUtils.id() + ".xlsx").toFile();
EasyExcel.write(file).head(head).sheet("订单").doWrite(data);
return file;
}
/** 删除一小时前生成的导出文件。 */
private void removeExpiredFiles() throws IOException {
Instant expireBefore = Instant.now().minus(1, ChronoUnit.HOURS);
try (Stream<Path> files = Files.list(EXPORT_DIR)) {
for (Path path : (Iterable<Path>) files::iterator) {
if (Files.getLastModifiedTime(path).toInstant().isBefore(expireBefore)) {
Files.deleteIfExists(path);
}
}
}
}
}
Excel 导出 handler,通过 Aop 获取 service:
package com.example.export.handler;
import java.io.File;
import java.time.LocalDate;
import com.example.export.service.OrderExportService;
import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.server.handler.HttpRequestHandler;
import nexus.io.tio.http.server.util.Resps;
/** GET /export/orders?rows=50000 */
public class OrderExcelExportHandler implements HttpRequestHandler {
@Override
public HttpResponse handle(HttpRequest request) throws Exception {
int rows = request.getInt("rows", 50000);
OrderExportService orderExportService = Aop.get(OrderExportService.class);
File file = orderExportService.exportOrders(rows);
HttpResponse response = TioRequestContext.getResponse();
return Resps.excel(response, file, "订单-" + LocalDate.now() + ".xlsx");
}
}
PDF、Word、PPT 报告 handler,按 type 选择对应方法:
package com.example.export.handler;
import java.io.File;
import nexus.io.model.body.RespBodyVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.server.handler.HttpRequestHandler;
import nexus.io.tio.http.server.util.Resps;
/** GET /export/report?type=pdf|docx|pptx */
public class ReportFileHandler implements HttpRequestHandler {
@Override
public HttpResponse handle(HttpRequest request) throws Exception {
HttpResponse response = TioRequestContext.getResponse();
String type = request.getParam("type", "pdf");
File file = new File("reports", "annual-report." + type);
String filename = "年度报告." + type;
switch (type) {
case "pdf":
return Resps.pdf(response, file, filename);
case "docx":
return Resps.docx(response, file, filename);
case "pptx":
return Resps.pptx(response, file, filename);
default:
response.setStatus(400);
return response.respond(RespBodyVo.fail("type 只支持 pdf、docx、pptx"));
}
}
}
type 先经过 switch 白名单,只有 pdf、docx、pptx 会读取文件,请求参数无法指向 reports 以外的路径。
三、运行与验证
在工作目录下准备 reports/annual-report.pdf(以及 docx、pptx),启动 ExportApp 后执行:
curl -sS -D - -o orders.xlsx "http://127.0.0.1/export/orders?rows=50000"
curl -sS -D - -o report.pdf "http://127.0.0.1/export/report?type=pdf"
curl -sS "http://127.0.0.1/export/report?type=exe"
未配置端口时框架使用 80,按实际端口替换。Excel 接口返回的响应头如下:
HTTP/1.1 200 OK
content-length:981209
content-disposition:attachment; filename="__-2026-10-31.xlsx"; filename*=UTF-8''%E8%AE%A2%E5%8D%95-2026-10-31.xlsx
content-type:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
x-accel-buffering:no
浏览器读取 filename*,保存为「订单-2026-10-31.xlsx」;filename 是给老客户端的 ASCII 备用名。下载得到的 PDF 与磁盘文件逐字节一致。type=exe 返回 400 和 JSON:
{"msg":"type 只支持 pdf、docx、pptx","ok":false,"code":0,"error":null,"data":null}
四、发送过程
handler 返回后,框架按以下步骤发送:
- HTTP 编码器根据文件长度写出响应头,
Content-Length等于文件大小。 - 普通 HTTP 连接通过
FileChannel.transferTo(...)发送文件区间,在 Linux 上对应sendfile,数据不经过应用层缓冲区。 - HTTPS 连接需要加密,框架按块读取文件、加密后写出,内存占用仍然只有一个块的大小。
文件响应不做 gzip:xlsx、docx、pptx 本身就是 zip 压缩格式,PDF 通常也已压缩,再压缩几乎没有收益。
五、导出文件的存放与清理
文件在 handler 返回之后才开始发送,所以不要在 handler 里删除刚生成的文件。推荐的做法:
- 导出文件写入专门的目录,例如上面的
exports,不要和静态资源、上传文件混放。 - 文件名使用
SnowflakeIdUtils.id()等唯一 ID,避免并发导出互相覆盖;用户看到的中文名通过filename参数单独给出。 - 按生成时间清理。示例在每次导出前删除一小时前的文件,也可以交给定时任务统一清理。
六、放在 nginx 后面
nginx 反向代理默认开启 proxy_buffering,响应超过内存缓冲区的部分会写进代理机的临时文件。文件响应自带 X-Accel-Buffering: no,nginx 收到后对这个响应边收边发,并且不会把该头转发给浏览器;其他代理和浏览器会忽略它。
需要关闭时,设置配置项 server.http.file.disable.proxy.buffering=false,或环境变量 SERVER_HTTP_FILE_DISABLE_PROXY_BUFFERING=false。内置的 HttpFileDataHandler 使用同一个开关。
