前言

大家好,我是老马。很高兴遇到你。

我们希望实现最简单的 http 服务信息,可以处理静态文件。

如果你想知道 servlet 如何处理的,可以参考我的另一个项目:

手写从零实现简易版 tomcat minicat

手写 nginx 系列

如果你对 nginx 原理感兴趣,可以阅读:

从零手写实现 nginx-01-为什么不能有 java 版本的 nginx?

从零手写实现 nginx-02-nginx 的核心能力

从零手写实现 nginx-03-nginx 基于 Netty 实现

从零手写实现 nginx-04-基于 netty http 出入参优化处理

从零手写实现 nginx-05-MIME类型(Multipurpose Internet Mail Extensions,多用途互联网邮件扩展类型)

从零手写实现 nginx-06-文件夹自动索引

从零手写实现 nginx-07-大文件下载

从零手写实现 nginx-08-范围查询

从零手写实现 nginx-09-文件压缩

从零手写实现 nginx-10-sendfile 零拷贝

从零手写实现 nginx-11-file+range 合并

从零手写实现 nginx-12-keep-alive 连接复用

从零手写实现 nginx-13-nginx.conf 配置文件介绍

从零手写实现 nginx-14-nginx.conf 和 hocon 格式有关系吗?

从零手写实现 nginx-15-nginx.conf 如何通过 java 解析处理?

从零手写实现 nginx-16-nginx 支持配置多个 server

什么是 http 范围查询?

HTTP范围请求(Range Requests)是一种让客户端可以请求资源(如文件)的一部分而不是全部的机制。

这在处理大文件时特别有用,例如,视频点播服务或大文件下载,用户可以请求文件的特定部分进行播放或下载。

范围请求通过在HTTP请求中添加Range头来实现。以下是Range头的一些关键点:

  1. 语法Range头的语法遵循以下格式:

    Range: bytes=<start-byte>-<end-byte>
    

    其中<start-byte><end-byte>指定了请求的字节范围(包含)。如果<end-byte>被省略,服务器将从<start-byte>发送到文件的末尾。

  2. 例子

    • 请求文件的前8192字节:
      Range: bytes=0-8191
      
    • 请求文件从第5120个字节开始到第10239个字节:
      Range: bytes=5120-10239
      
    • 请求文件从第2048个字节开始到文件末尾:
      Range: bytes=2048-
      
  3. 响应: 如果服务器支持范围请求,并且请求的范围有效,服务器将返回状态码206 Partial Content,并将请求范围内的数据发送给客户端。

    服务器还需要在响应中包含Content-Range头,指示实际发送的数据范围。

  4. Content-RangeContent-Range头的格式如下:

    Content-Range: bytes <start-byte>-<end-byte>/<total-file-size>
    
    • <start-byte><end-byte>与请求中的范围对应。
    • <total-file-size>是资源的总大小。
  5. 不支持范围请求: 如果服务器不支持范围请求,或者请求的范围无效(例如,开始字节大于文件大小),服务器将返回状态码200 OK,并发送资源的全部内容。

  6. 多范围请求: HTTP协议也支持请求多个非连续的范围,但这需要特定的服务器支持。多范围请求的Range头会包含多个范围,用逗号分隔:

    Range: bytes=500-600,601-700
    

    对于多范围请求,服务器可能返回多个部分,每个部分都有自己的Content-Range头,并且包装在multipart/byterangesContent-Type中。

  7. 用例: 范围请求常用于以下场景:

    • 恢复中断的下载。
    • 视频点播服务中的“快进”功能。
    • 大文件的增量更新或备份。
  8. 注意事项

    • 并非所有的服务器都支持范围请求,这取决于服务器的配置和能力。
    • 对于不支持范围请求的资源,客户端仍然可以使用分块下载技术来实现类似的功能。

范围请求是HTTP协议中一个强大且灵活的特性,它为客户端提供了对资源访问的细粒度控制。

netty 实现

流程

在Netty中实现HTTP范围请求(Range Requests),你需要处理HTTP请求,解析Range头,并根据请求的范围发送相应的响应。以下是实现这一功能的步骤:

  1. 解析HTTP请求:首先,你需要解析客户端发送的HTTP请求,特别是Range请求头。

  2. 处理Range头:根据Range头指定的范围,确定要发送的字节区间。

  3. 构造HTTP响应:创建HTTP响应,如果范围有效,设置状态码为206 Partial Content,否则使用200 OK

  4. 设置Content-Range头:在响应中添加Content-Range头,指示实际发送的数据范围。

  5. 发送数据:使用适当的方式发送请求范围内的数据。

  6. 结束响应:发送结束标记,如LastHttpContent.EMPTY_LAST_CONTENT

核心实现

    public void doDispatch(NginxRequestDispatchContext context) {
        final HttpRequest request = context.getRequest();
        final File file = context.getFile();
        final ChannelHandlerContext ctx = context.getCtx();

        // 解析Range头
        String rangeHeader = request.headers().get("Range");
        logger.info("[Nginx] fileRange start rangeHeader={}", rangeHeader);

        long fileLength = file.length(); // 假设file是你要发送的File对象
        long[] range = parseRange(rangeHeader, fileLength);
        long start = range[0];
        long end = range[1];

        // 构造HTTP响应
        HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1,
                start < 0 ? HttpResponseStatus.OK : HttpResponseStatus.PARTIAL_CONTENT);
        // 设置Content-Type
        response.headers().set(HttpHeaderNames.CONTENT_TYPE, InnerMimeUtil.getContentType(file));

        if (start >= 0) {
            // 设置Content-Range
            if (end < 0) {
                end = fileLength - 1;
            }
            response.headers().set(HttpHeaderNames.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + fileLength);

            // 设置Content-Length
            int contentLength = (int) (end - start + 1);
            response.headers().set(HttpHeaderNames.CONTENT_LENGTH, contentLength);

            // 发送响应头
            ctx.write(response);

            try (FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.READ)) {
                fileChannel.position(start); // 设置文件通道的起始位置

                ByteBuffer buffer = ByteBuffer.allocate(NginxConst.CHUNK_SIZE);
                while (end >= start) {
                    // 读取文件到ByteBuffer
                    int bytesRead = fileChannel.read(buffer);
                    if (bytesRead == -1) { // 文件读取完毕
                        break;
                    }
                    buffer.flip(); // 切换到读模式
                    ctx.write(new DefaultHttpContent(Unpooled.wrappedBuffer(buffer)));
                    buffer.compact(); // 保留未读取的数据,并为下次读取腾出空间
                    start += bytesRead; // 更新下一个读取的起始位置
                }
                ctx.flush(); // 确保所有数据都被发送

                // 发送结束标记
                ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
                        .addListener(ChannelFutureListener.CLOSE); // 如果连接断开,则关闭
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

        }
    }

范围处理如下:

    protected long[] parseRange(String rangeHeader, long totalLength) {
        // 简单解析Range头,返回[start, end]
        // Range头格式为: "bytes=startIndex-endIndex"
        if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
            String range = rangeHeader.substring("bytes=".length());
            String[] parts = range.split("-");
            long start = parts[0].isEmpty() ? totalLength - 1 : Long.parseLong(parts[0]);
            long end = parts.length > 1 ? Long.parseLong(parts[1]) : totalLength - 1;
            return new long[]{start, end};
        }
        return new long[]{-1, -1}; // 表示无效的范围请求
    }

请求测试

>curl -i -H "Range: bytes=0-" http://192.168.1.12:8080/mime/1.css
HTTP/1.1 206 Partial Content
content-type: text/css
content-range: bytes 0-198/199
content-length: 199

body {
       font-family: Arial, sans-serif;
       margin: 0;
       padding: 0;
   }

   h1 {
       color: #333333;
   }

   .container {
       width: 80%;
       margin: auto;
   }

>curl -i -H "Range: bytes=127-" http://192.168.1.12:8080/mime/1.css
HTTP/1.1 206 Partial Content
content-type: text/css
content-range: bytes 127-198/199
content-length: 72


   }

   .container {
       width: 80%;
       margin: auto;
   }

或者过长的内容:

>curl -i -H "Range: bytes=255-" http://192.168.1.12:8080/mime/1.css
HTTP/1.1 206 Partial Content
content-type: text/css
content-range: bytes 255-198/199
curl: (8) Invalid Content-Length: value