Skip to content

零拷贝 ​

通过 HttpFileDataHandler 和一条 GET 路由,可以把本地文件发布为 HTTP 资源,并复用框架的单段 Range、客户端缓存和零拷贝文件发送能力。

示例源码:tio-http-file-server。

使用 tio-boot 搭建支持零拷贝的 HTTP 文件服务器 ​

以下是示例项目的启动类,保留了启动耗时输出:

java
package nexus.io.tio.http.file.server;

import nexus.io.context.BootConfiguration;
import nexus.io.tio.boot.TioApplication;
import nexus.io.tio.boot.http.handler.common.HttpFileDataHandler;
import nexus.io.tio.boot.server.TioBootServer;
import nexus.io.tio.http.server.router.HttpRequestRouter;

public class FileServer {

  public static void main(String[] args) {
    long start = System.currentTimeMillis();
    TioApplication.run(new BootConfiguration() {

      @Override
      public void config() throws Exception {
        TioBootServer server = TioBootServer.me();
        HttpRequestRouter requestRouter = server.getRequestRouter();

        requestRouter.get("/**", new HttpFileDataHandler(false));
      }
    }, args);
    long end = System.currentTimeMillis();
    System.out.println((end - start) + "ms");
  }
}

这里使用 TioApplication.run(BootConfiguration, args),不需要传入 FileServer.class:所有路由都在 config() 中显式注册,没有使用启动类上的 @AComponentScan 或 @AImport。框架准备好服务器和路由器后执行该配置,因此可以直接取得 HttpRequestRouter。

get("/**", ...) 注册 GET 通配路径。Handler 使用普通实例注册,无需通过 Aop 创建。

启动与访问 ​

需要 JDK 21 和 Maven。使用仓库已有的 pom.xml 管理依赖和打包,不需要另外创建 Spring Boot 启动类。

bash
git clone https://github.com/litongjava/tio-http-file-server.git
cd tio-http-file-server
mkdir -p files
printf '%s' 'Hello tio-boot!' > files/hello.txt
mvn spring-boot:run -Dgpg.skip=true -Dspring-boot.run.arguments="--server.port=8080"

以上文件准备命令适用于 Git Bash / Linux shell。在另一个终端发起请求:

bash
curl -i http://127.0.0.1:8080/files/hello.txt
curl -f http://127.0.0.1:8080/files/hello.txt -o hello-copy.txt

第一个请求返回文件内容,第二个将响应保存到本地。未指定端口时,框架默认使用 80;示例明确使用 8080。

工作目录与文件路径 ​

Handler 以 Java 进程的当前工作目录为文件查找起点。例如,从包含 files/hello.txt 的目录启动时,请求 /files/hello.txt 就会读取这个文件;不存在的文件返回 404。

工作目录由启动位置或 IDE 的 Working directory 决定,不是由 Java 类所在包决定。访问时使用具体文件 URL;此示例不提供目录列表或自动首页功能。

部署时使用专门的公开文件目录,按需在入口增加鉴权和路径范围校验。这个最小示例不是具备目录隔离和访问控制的文件管理系统。

Range 与客户端缓存 ​

准备一个大于 1 KiB 的 files/demo.zip 后,可以验证单段 Range:

bash
curl -sS -D - -H "Range: bytes=0-1023" http://127.0.0.1:8080/files/demo.zip -o demo.part
curl -f -C - http://127.0.0.1:8080/files/demo.zip -o demo.zip

合法的单段范围返回 206 Partial Content,并携带 Content-Range;第二个命令可根据本地已有文件长度继续下载。发送区间小于 1 MiB 时,即使原文件很大,也会使用小区间字节数组响应。

当前支持 bytes=start-end、bytes=start- 和 bytes=-length;多段范围回退到完整文件响应。对于识别为 HLS 播放列表或 video/mp2t 的内容,返回完整内容。不要把这个最小服务当作完整媒体流服务器。

普通文件响应包含 ETag、Last-Modified 等缓存信息。先读取响应头,再将实际 ETag 填入下面的条件请求:

bash
curl -sS -D - -o /dev/null http://127.0.0.1:8080/files/hello.txt
curl -i -H 'If-None-Match: "替换为响应中的ETag值"' http://127.0.0.1:8080/files/hello.txt

缓存命中时返回 304 Not Modified,不重复发送文件内容。不同资源类型采用不同缓存策略;经常更新的静态资源适合使用带内容标识的文件名。Handler 还会设置跨域响应头,便于网页请求文件。

打包运行 ​

bash
mvn clean package -Pproduction -DskipTests -Dgpg.skip=true

Spring Boot Maven 插件仅用于生成可运行 JAR。打包后,从专门的公开文件目录启动,-jar 后填写 target 下实际生成的 JAR 路径:

bash
java -jar /path/to/tio-http-file-server.jar --server.port=8080

这里的 JAR 名称是占位示例,以构建输出为准。文件查找仍以启动命令所在的工作目录为起点,与 JAR 存放位置无关。

零拷贝与文件响应 ​

常规文件发送通常先把文件读入应用缓冲区,再写入网络通道。零拷贝通过操作系统提供的文件传输能力,减少文件数据在用户空间与内核空间之间的搬运。

text
常规路径:文件 → 内核页缓存 → 应用缓冲区 → 网络发送缓冲区
零拷贝路径:文件 → 内核页缓存 → 网络通道

这里的“零拷贝”主要指省去应用层的数据搬运,不表示磁盘和网卡之间完全没有数据传输。read()、write() 各自包含进入和返回内核的过程;sendfile() 可以减少系统调用次数,但实际发送仍可能分多次完成,不宜把整个文件发送描述为固定一次切换。

tio-boot 的文件响应发送过程如下:

  1. HTTP 编码器生成响应头,并根据文件范围设置响应长度。
  2. 非 TLS 连接使用 EnhanceAsynchronousSocketChannel 时,通过文件传输接口调用 FileChannel.transferTo(...),利用平台提供的高效传输能力。
  3. TLS 连接或其他通道使用分块读取、写出路径;TLS 场景还需要加密。

因此,setFileBody(...) 是文件发送入口,实际是否采用操作系统零拷贝取决于通道、TLS、JDK 和操作系统。使用文件响应仍可避免业务代码为整个大文件分配一个字节数组。

HttpFileDataHandler 的自动选择 ​

HttpFileDataHandler 根据请求路径寻找工作目录中的文件,并调用 HttpFileDataUtils 处理响应:

  • 完整文件大小达到 **1 MiB(1,048,576 字节)**时,使用文件响应体。
  • 单段 Range 请求按照本次返回区间的大小判断是否达到 1 MiB,而不是按照原文件大小判断。
  • 小于阈值时,读取对应内容并以字节数组返回。
  • 文件响应体路径禁用 gzip,以便直接发送原始文件内容。
  • 文件响应体附带 X-Accel-Buffering: no,放在 nginx 后面时,大文件边收边发,不会写入代理机的临时文件。配置项 server.http.file.disable.proxy.buffering=false 可关闭。

这个阈值来自 HttpFileDataUtils.ZERO_COPY_THRESHOLD,是内置 Handler 的策略;直接调用 setFileBody(...) 不受此阈值限制。new HttpFileDataHandler(false) 的参数控制请求路径日志,与是否启用零拷贝无关。

自定义下载接口:直接返回文件 ​

下面的示例提供 GET /download,发送工作目录下的 files/demo.zip。先准备好文件,再运行 DownloadServer.main。

java
import java.io.File;

import nexus.io.context.BootConfiguration;
import nexus.io.tio.boot.TioApplication;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.boot.server.TioBootServer;
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.router.HttpRequestRouter;
import nexus.io.tio.http.server.util.Resps;

public class DownloadServer {

  public static void main(String[] args) {
    TioApplication.run(new BootConfiguration() {
      @Override
      public void config() throws Exception {
        HttpRequestRouter router = TioBootServer.me().getRequestRouter();
        router.get("/download", new DownloadHandler());
      }
    }, args);
  }

  public static class DownloadHandler implements HttpRequestHandler {
    @Override
    public HttpResponse handle(HttpRequest request) {
      HttpResponse response = TioRequestContext.getResponse();
      // 文件不存在时返回 404;Content-Type 按扩展名推断为 application/zip
      return Resps.download(response, new File("files/demo.zip"), "demo.zip");
    }
  }
}

Resps.download(response, file, filename) 设置类型、Content-Disposition 和文件响应体,并禁用 gzip。导出 Excel、Word、PDF、PPT 时还有对应的快捷方法,见大文件导出:零拷贝发送 Excel、Word、PDF、PPT。

需要更细的控制时,也可以直接操作响应:

java
response.setContentType("application/zip");
response.setFileBody(file);

setFileBody(file) 发送整个文件;setFileBody(file, start, length) 指定起点和字节数。自行处理 Range 时,还需要设置 206、Content-Range 等响应信息。需要完整的文件服务时,优先使用内置 HttpFileDataHandler。

验证与性能观察 ​

先确认响应状态、Content-Length、Range 区间和下载文件内容正确,再比较吞吐、CPU、堆内存、GC、磁盘 I/O 与网络带宽。可分别测试完整大文件、小区间下载及 TLS 场景。

HTTP 下载成功、出现文件响应日志,都不能单独证明系统实际执行了 sendfile()。确认操作系统零拷贝需要结合运行通道和系统调用跟踪;Linux 上可在测试环境使用 strace 观察。性能结论应以实际部署环境的测量为准。

MIT Licensed