從零手寫實現 nginx-07-大檔案傳輸 分塊傳輸(chunked transfer)/ 分頁傳輸(paging)

老马啸西风發表於2024-06-06

前言

大家好,我是老馬。很高興遇到你。

我們希望實現最簡單的 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

目標

前面的內容我們實現了小檔案的傳輸,但是如果檔案的內容特別大,全部載入到記憶體會導致伺服器報廢。

那麼,應該怎麼解決呢?

思路

我們可以把一個非常大的檔案直接拆分為多次,然後分段傳輸過去。

傳輸完成後,告訴瀏覽器已經傳輸完成了,傳送一個結束標識即可。

大檔案傳輸的方式

一次梭哈

這種方式通常用於傳送較小的檔案,因為整個檔案內容會被載入到記憶體中。

程式碼示例:

RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r"); // 以只讀的方式開啟檔案

long fileLength = randomAccessFile.length();
// 建立一個預設的HTTP響應
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
// 設定Content Length
HttpUtil.setContentLength(response, fileLength);


// 讀取檔案內容到位元組陣列
byte[] fileContent = new byte[(int) fileLength];
int bytesRead = randomAccessFile.read(fileContent);
if (bytesRead != fileLength) {
    sendError(ctx, INTERNAL_SERVER_ERROR);
    return;
}

// 將檔案內容轉換為FullHttpResponse
FullHttpResponse fullHttpResponse = new DefaultFullHttpResponse(HTTP_1_1, OK);
fullHttpResponse.content().writeBytes(fileContent);
fullHttpResponse.headers().set(HttpHeaderNames.CONTENT_LENGTH, fileLength);
// 寫入HTTP響應並關閉連線
ctx.writeAndFlush(fullHttpResponse).addListener(ChannelFutureListener.CLOSE);

這段程式碼的主要變化如下:

  1. 讀取檔案內容:使用randomAccessFile.read(fileContent)一次性讀取整個檔案到位元組陣列fileContent中。
  2. 建立FullHttpResponse:使用DefaultFullHttpResponse建立一個完整的HTTP響應物件,並將檔案內容寫入到響應的content()中。
  3. 設定Content-Length:在FullHttpResponse的headers中設定Content-Length
  4. 傳送響應並關閉連線:使用ctx.writeAndFlush(fullHttpResponse)一次性傳送整個響應,並透過.addListener(ChannelFutureListener.CLOSE)確保在傳送完成後關閉連線。

請注意,這種方式適用於檔案大小不是很大的情況,因為整個檔案內容被載入到了記憶體中。

如果檔案非常大,這種方式可能會導致記憶體溢位。

對於大檔案,推薦使用分塊傳輸(chunked transfer)或者分頁傳輸(paging)的方式。

分塊傳輸(chunked transfer)

分塊傳輸(Chunked Transfer)是一種HTTP協議中用於傳輸資料的方法,允許伺服器在知道整個響應內容大小之前就開始傳送資料。

這在傳送大檔案或動態生成的內容時非常有用。

以下是使用Netty實現分塊傳輸的一個示例:

RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r"); // 以只讀的方式開啟檔案
long fileLength = randomAccessFile.length();

// 建立一個預設的HTTP響應
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);

// 由於是分塊傳輸,移除Content-Length頭
response.headers().remove(HttpHeaderNames.CONTENT_LENGTH);

// 如果request中有KEEP ALIVE資訊
if (HttpUtil.isKeepAlive(request)) {
    response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
}

// 將HTTP響應寫入Channel
ctx.write(response);

// 分塊傳輸檔案內容
final int chunkSize = 8192; // 設定分塊大小
ByteBuffer buffer = ByteBuffer.allocate(chunkSize);
while (true) {
    int bytesRead = randomAccessFile.read(buffer.array());
    if (bytesRead == -1) { // 檔案讀取完畢
        break;
    }
    buffer.limit(bytesRead);
    // 寫入分塊資料
    ctx.write(new DefaultHttpContent(Unpooled.wrappedBuffer(buffer)));
    buffer.clear(); // 清空緩衝區以供下次使用
}

// 寫入最後一個分塊,即空的HttpContent,表示傳輸結束
ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT).addListener(ChannelFutureListener.CLOSE);

這段程式碼的主要變化如下:

  1. 移除Content-Length:由於是分塊傳輸,我們不需要在響應頭中設定Content-Length

  2. 分塊讀取檔案:使用一個固定大小的緩衝區ByteBuffer來分塊讀取檔案內容。

  3. 傳送分塊資料:在迴圈中,每次讀取檔案內容到緩衝區後,建立一個DefaultHttpContent物件,並將緩衝區的資料包裝在Unpooled.wrappedBuffer()中,然後寫入Channel。

  4. 傳送結束標記:在檔案讀取完畢後,傳送一個空的LastHttpContent物件,以標記HTTP訊息體的結束。

  5. 關閉連線:在傳送完最後一個分塊後,使用addListener(ChannelFutureListener.CLOSE)確保關閉連線。

分頁傳輸

分頁傳輸通常是指將大檔案分成多個小的部分(頁),然後逐個傳送這些部分。

這種方式適用於在網路程式設計中傳輸大檔案,因為它可以減少記憶體的使用,並且允許接收方逐步處理資料。

在Netty中,實現分頁傳輸通常涉及到手動控制資料的傳送,而不是使用HTTP分塊編碼(chunked encoding)。

以下是一個簡化的分頁傳輸實現示例,我們將使用Netty的FileRegion來實現高效的檔案傳輸:

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.FileRegion;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.stream.ChunkedFile;

import java.io.RandomAccessFile;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;

public class FilePageTransfer {

    public static void sendFile(ChannelHandlerContext ctx, Path filePath) {
        try {
            RandomAccessFile randomAccessFile = new RandomAccessFile(filePath.toFile(), "r");
            FileChannel fileChannel = randomAccessFile.getChannel();

            long fileSize = fileChannel.size();
            long position = 0;
            final long pageSize = 8192; // 定義每頁的大小,可以根據實際情況調整

            while (position < fileSize) {
                long remaining = fileSize - position;
                long size = remaining > pageSize ? pageSize : remaining;

                // 使用FileRegion進行傳輸
                FileRegion region = new DefaultFileRegion(fileChannel, position, size);
                ((SocketChannel) ctx.channel()).write(region);

                // 更新位置
                position += size;

                // 檢查傳輸是否成功
                if (!region.isWritten()) {
                    // 傳輸失敗,可以進行重試或者傳送錯誤響應
                    break;
                }
            }

            // 傳送結束標記
            ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT).addListener(ChannelFutureListener.CLOSE);
        } catch (IOException e) {
            e.printStackTrace();
            // 傳送錯誤響應
            ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND));
        }
    }
}

在這個示例中,我們定義了一個sendFile方法,它接受一個ChannelHandlerContext和一個檔案路徑Path作為引數。以下是該方法的主要步驟:

  1. 開啟檔案:使用RandomAccessFile開啟要傳輸的檔案,並獲取FileChannel

  2. 計算檔案大小:透過fileChannel.size()獲取檔案的總大小。

  3. 分頁傳輸:使用一個迴圈來逐頁讀取檔案內容。在每次迭代中,我們計算要傳輸的資料塊的大小,並使用FileRegion來表示這部分資料。

  4. 寫入Channel:將FileRegion寫入Netty的Channel

  5. 更新位置:更新position變數以指向下一頁的開始位置。

  6. 檢查傳輸狀態:透過region.isWritten()檢查資料是否成功寫入。

  7. 傳送結束標記:傳輸完成後,傳送LastHttpContent.EMPTY_LAST_CONTENT來標記訊息結束,並關閉連線。

  8. 錯誤處理:如果在傳輸過程中發生異常,傳送一個錯誤響應。

請注意,這個示例是一個簡化的版本,它沒有處理HTTP協議的細節,也沒有設定HTTP頭資訊。

在實際的HTTP伺服器實現中,你需要在傳送檔案內容之前傳送一個包含適當頭資訊的HTTP響應。

此外,LastHttpContent.EMPTY_LAST_CONTENT用於HTTP/1.1,如果你使用的是HTTP/1.0,可能需要不同的處理方式。

改進後的核心程式碼

統一的分發

為了避免實現膨脹,難以管理,我們將實現全部抽象。

protected NginxRequestDispatch getDispatch(NginxRequestDispatchContext context) {
    final FullHttpRequest requestInfoBo = context.getRequest();
    final NginxConfig nginxConfig = context.getNginxConfig();
    // 訊息解析不正確
    /*如果無法解碼400*/
    if (!requestInfoBo.decoderResult().isSuccess()) {
        return NginxRequestDispatches.http400();
    }
    // 檔案
    File targetFile = getTargetFile(requestInfoBo, nginxConfig);
    // 是否存在
    if(targetFile.exists()) {
        // 設定檔案
        context.setFile(targetFile);
        // 如果是資料夾
        if(targetFile.isDirectory()) {
            return NginxRequestDispatches.fileDir();
        }
        long fileSize = targetFile.length();
        if(fileSize <= NginxConst.BIG_FILE_SIZE) {
            return NginxRequestDispatches.fileSmall();
        }
        return NginxRequestDispatches.fileBig();
    }  else {
        return NginxRequestDispatches.http404();
    }
}

大檔案的核心邏輯

大檔案我們使用 chunk 的方式

    public void doDispatch(NginxRequestDispatchContext context) {
        final FullHttpRequest request = context.getRequest();
        final File targetFile = context.getFile();
        final String bigFilePath = targetFile.getAbsolutePath();
        final long fileLength = targetFile.length();


        logger.info("[Nginx] match big file, path={}", bigFilePath);

        HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
        response.headers().set(HttpHeaderNames.CONTENT_DISPOSITION, "attachment; filename=\"" + targetFile.getName() + "\"");
        response.headers().set(HttpHeaderNames.CONTENT_TYPE, InnerMimeUtil.getContentType(targetFile));
        response.headers().set(HttpHeaderNames.CONTENT_LENGTH, fileLength);

        final ChannelHandlerContext ctx = context.getCtx();
        ctx.write(response);

        // 分塊傳輸檔案內容
        long totalLength = targetFile.length();
        long totalRead = 0;

        try(RandomAccessFile randomAccessFile = new RandomAccessFile(targetFile, "r")) {
            ByteBuffer buffer = ByteBuffer.allocate(NginxConst.CHUNK_SIZE);
            while (true) {
                int bytesRead = randomAccessFile.read(buffer.array());
                if (bytesRead == -1) { // 檔案讀取完畢
                    break;
                }
                buffer.limit(bytesRead);
                // 寫入分塊資料
                ctx.write(new DefaultHttpContent(Unpooled.wrappedBuffer(buffer)));
                buffer.clear(); // 清空緩衝區以供下次使用

                // process 可以考慮加一個 listener
                totalRead += bytesRead;
                logger.info("[Nginx] bigFile process >>>>>>>>>>> {}/{}", totalRead, totalLength);
            }

            // 傳送結束標記
            ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
                    .addListener(ChannelFutureListener.CLOSE);
        } catch (Exception e) {
            logger.error("[Nginx] bigFile meet ex", e);
        }
    }

這裡採用的是直接下載的方式。

當然,也可以實現線上播放,但是試了下效果不好,後續有時間可以嘗試下。

測試日誌

[INFO] [2024-05-26 15:53:58.498] [nioEventLoopGroup-3-3] [c.g.h.n.s.h.NginxNettyServerHandler.channelRead0] - [Nginx] channelRead writeAndFlush start request=HttpObjectAggregator$AggregatedFullHttpRequest(decodeResult: success, version: HTTP/1.1, content: CompositeByteBuf(ridx: 0, widx: 0, cap: 0, components=0))
GET /mime/2.mp4 HTTP/1.1
Host: 192.168.1.12:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
content-length: 0, id=40a5effffe257be0-00001c6c-00000003-0824dff434805bd3-b09fd676
[INFO] [2024-05-26 15:53:58.498] [nioEventLoopGroup-3-3] [c.g.h.n.s.r.d.h.AbstractNginxRequestDispatchFullResp.doDispatch] - [Nginx] match big file, path=D:\data\nginx4j\mime\2.mp4
[INFO] [2024-05-26 15:53:58.514] [nioEventLoopGroup-3-3] [c.g.h.n.s.r.d.h.AbstractNginxRequestDispatchFullResp.doDispatch] - [Nginx] bigFile process >>>>>>>>>>> 8388608/668918096
...
[INFO] [2024-05-26 15:53:59.616] [nioEventLoopGroup-3-3] [c.g.h.n.s.r.d.h.AbstractNginxRequestDispatchFullResp.doDispatch] - [Nginx] bigFile process >>>>>>>>>>> 668918096/668918096
[INFO] [2024-05-26 15:53:59.627] [nioEventLoopGroup-3-3] [c.g.h.n.s.h.NginxNettyServerHandler.channelRead0] - [Nginx] channelRead writeAndFlush DONE id=40a5effffe257be0-00001c6c-00000003-0824dff434805bd3-b09fd676

小結

本節我們實現了一個大檔案的下載處理,主要思想就是分段。

可以考慮類似於影片軟體,採用分段載入實時播放的方式。

下一節,我們考慮實現以下檔案的範圍查詢。

我是老馬,期待與你的下次重逢。

開源地址

為了便於大家學習,已經將 nginx 開源

https://github.com/houbb/nginx4j

相關文章