從零手寫實現 nginx-11-檔案處理邏輯與 range 範圍查詢合併

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

前言

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

我們為 java 開發者實現了 java 版本的 nginx

https://github.com/houbb/nginx4j

如果你想知道 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

從零手寫實現 nginx-17-nginx 預設配置最佳化

從零手寫實現 nginx-18-nginx 請求頭響應頭的處理

背景

最初感覺範圍處理和檔案的處理不是相同的邏輯,所以做了拆分。

但是後來發現有很多公共的邏輯。

主要兩種最佳化方式:

  1. 把範圍+檔案合併到同一個檔案中處理。新增各種判斷程式碼

  2. 採用模板方法,便於後續擴充修改。

這裡主要嘗試下第 2 種,便於後續的擴充。

程式碼的相似之處

首先,我們要找到二者的相同之處。

range 主要其實是開始位置和長度,和普通的處理存在差異。

基礎檔案實現

我們對常見的部分抽象出來,便於子類擴充

/**
 * 檔案
 *
 * @since 0.10.0
 * @author 老馬笑西風
 */
public class AbstractNginxRequestDispatchFile extends AbstractNginxRequestDispatch {

    /**
     * 獲取長度
     * @param context 上下文
     * @return 結果
     */
    protected long getActualLength(final NginxRequestDispatchContext context) {
        final File targetFile = context.getFile();
        return targetFile.length();
    }

    /**
     * 獲取開始位置
     * @param context 上下文
     * @return 結果
     */
    protected long getActualStart(final NginxRequestDispatchContext context) {
        return 0L;
    }

    protected void fillContext(final NginxRequestDispatchContext context) {
        long actualLength = getActualLength(context);
        long actualStart = getActualStart(context);

        context.setActualStart(actualStart);
        context.setActualFileLength(actualLength);
    }

    /**
     * 填充響應頭
     * @param context 上下文
     * @param response 響應
     * @since 0.10.0
     */
    protected void fillRespHeaders(final NginxRequestDispatchContext context,
                                   final HttpRequest request,
                                   final HttpResponse response) {
        final File targetFile = context.getFile();
        final long fileLength = context.getActualFileLength();

        // 檔案比較大,直接下載處理
        if(fileLength > NginxConst.BIG_FILE_SIZE) {
            logger.warn("[Nginx] fileLength={} > BIG_FILE_SIZE={}", fileLength, NginxConst.BIG_FILE_SIZE);
            response.headers().set(HttpHeaderNames.CONTENT_DISPOSITION, "attachment; filename=\"" + targetFile.getName() + "\"");
        }

        // 如果請求中有KEEP ALIVE資訊
        if (HttpUtil.isKeepAlive(request)) {
            response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
        }
        response.headers().set(HttpHeaderNames.CONTENT_TYPE, InnerMimeUtil.getContentTypeWithCharset(targetFile, context.getNginxConfig().getCharset()));
        response.headers().set(HttpHeaderNames.CONTENT_LENGTH, fileLength);
    }

    protected HttpResponse buildHttpResponse(NginxRequestDispatchContext context) {
        HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
        return response;
    }



    /**
     * 是否需要壓縮處理
     * @param context 上下文
     * @return 結果
     */
    protected boolean isZipEnable(NginxRequestDispatchContext context) {
        return InnerGzipUtil.isMatchGzip(context);
    }

    /**
     * gzip 的提前預處理
     * @param context  上下文
     * @param response 響應
     */
    protected void beforeZip(NginxRequestDispatchContext context, HttpResponse response) {
        File compressFile = InnerGzipUtil.prepareGzip(context, response);
        context.setFile(compressFile);
    }

    /**
     * gzip 的提前預處理
     * @param context  上下文
     * @param response 響應
     */
    protected void afterZip(NginxRequestDispatchContext context, HttpResponse response) {
        InnerGzipUtil.afterGzip(context, response);
    }

    protected boolean isZeroCopyEnable(NginxRequestDispatchContext context) {
        final NginxConfig nginxConfig = context.getNginxConfig();

        return EnableStatusEnum.isEnable(nginxConfig.getNginxSendFileConfig().getSendFile());
    }

    protected void writeAndFlushOnComplete(final ChannelHandlerContext ctx,
                                           final NginxRequestDispatchContext context) {
        // 傳輸完畢,傳送最後一個空內容,標誌傳輸結束
        ChannelFuture lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
        // 如果不支援keep-Alive,伺服器端主動關閉請求
        if (!HttpUtil.isKeepAlive(context.getRequest())) {
            lastContentFuture.addListener(ChannelFutureListener.CLOSE);
        }
    }

    @Override
    public void doDispatch(NginxRequestDispatchContext context) {
        final FullHttpRequest request = context.getRequest();
        final File targetFile = context.getFile();
        final ChannelHandlerContext ctx = context.getCtx();

        logger.info("[Nginx] start dispatch, path={}", targetFile.getAbsolutePath());
        // 長度+開始等基本資訊
        fillContext(context);

        // 響應
        HttpResponse response = buildHttpResponse(context);

        // 新增請求頭
        fillRespHeaders(context, request, response);

        //gzip
        boolean zipFlag = isZipEnable(context);
        try {
            if(zipFlag) {
                beforeZip(context, response);
            }

            // 寫基本資訊
            ctx.write(response);

            // 零複製
            boolean isZeroCopyEnable = isZeroCopyEnable(context);
            if(isZeroCopyEnable) {
                //zero-copy
                dispatchByZeroCopy(context);
            } else {
                // 普通
                dispatchByRandomAccessFile(context);
            }
        } finally {
            // 最後處理
            if(zipFlag) {
                afterZip(context, response);
            }
        }
    }

    /**
     * Netty 之 FileRegion 檔案傳輸: https://www.jianshu.com/p/447c2431ac32
     *
     * @param context 上下文
     */
    protected void dispatchByZeroCopy(NginxRequestDispatchContext context) {
        final ChannelHandlerContext ctx = context.getCtx();
        final File targetFile = context.getFile();

        // 分塊傳輸檔案內容
        final long actualStart = context.getActualStart();
        final long actualFileLength = context.getActualFileLength();

        try {
            RandomAccessFile randomAccessFile = new RandomAccessFile(targetFile, "r");
            FileChannel fileChannel = randomAccessFile.getChannel();

            // 使用DefaultFileRegion進行零複製傳輸
            DefaultFileRegion fileRegion = new DefaultFileRegion(fileChannel, actualStart, actualFileLength);
            ChannelFuture transferFuture = ctx.writeAndFlush(fileRegion);

            // 監聽傳輸完成事件
            transferFuture.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture future) {
                    try {
                        if (future.isSuccess()) {
                            writeAndFlushOnComplete(ctx, context);
                        } else {
                            // 處理傳輸失敗
                            logger.error("[Nginx] file transfer failed", future.cause());
                            throw new Nginx4jException(future.cause());
                        }
                    } finally {
                        // 確保在所有操作完成之後再關閉檔案通道和RandomAccessFile
                        try {
                            fileChannel.close();
                            randomAccessFile.close();
                        } catch (Exception e) {
                            logger.error("[Nginx] error closing file channel", e);
                        }
                    }
                }
            });

            // 記錄傳輸進度(如果需要,可以透過監聽器或其他方式實現)
            logger.info("[Nginx] file process >>>>>>>>>>> {}", actualFileLength);

        } catch (Exception e) {
            logger.error("[Nginx] file meet ex", e);
            throw new Nginx4jException(e);
        }
    }

    // 分塊傳輸檔案內容

    /**
     * 分塊傳輸-普通方式
     * @param context 上下文
     */
    protected void dispatchByRandomAccessFile(NginxRequestDispatchContext context) {
        final ChannelHandlerContext ctx = context.getCtx();
        final File targetFile = context.getFile();

        // 分塊傳輸檔案內容
        long actualFileLength = context.getActualFileLength();
        // 分塊傳輸檔案內容
        final long actualStart = context.getActualStart();

        long totalRead = 0;

        try(RandomAccessFile randomAccessFile = new RandomAccessFile(targetFile, "r")) {
            // 開始位置
            randomAccessFile.seek(actualStart);

            ByteBuffer buffer = ByteBuffer.allocate(NginxConst.CHUNK_SIZE);
            while (totalRead <= actualFileLength) {
                int bytesRead = randomAccessFile.read(buffer.array());
                if (bytesRead == -1) { // 檔案讀取完畢
                    logger.info("[Nginx] file read done.");
                    break;
                }

                buffer.limit(bytesRead);

                // 寫入分塊資料
                ctx.write(new DefaultHttpContent(Unpooled.wrappedBuffer(buffer)));
                buffer.clear(); // 清空緩衝區以供下次使用

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

            // 最後的處理
            writeAndFlushOnComplete(ctx, context);
        } catch (Exception e) {
            logger.error("[Nginx] file meet ex", e);
            throw new Nginx4jException(e);
        }
    }

}

這樣原來的普通檔案類只需要直接繼承。

範圍類重置如下方法即可:

/**
 * 檔案範圍查詢
 *
 * @since 0.7.0
 * @author 老馬嘯西風
 */
public class NginxRequestDispatchFileRange extends AbstractNginxRequestDispatchFile {

    private static final Log logger = LogFactory.getLog(AbstractNginxRequestDispatchFullResp.class);

    @Override
    protected HttpResponse buildHttpResponse(NginxRequestDispatchContext context) {
        long start = context.getActualStart();

        // 構造HTTP響應
        HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1,
                start < 0 ? HttpResponseStatus.OK : HttpResponseStatus.PARTIAL_CONTENT);

        return response;
    }

    @Override
    protected void fillContext(NginxRequestDispatchContext context) {
        final long fileLength = context.getFile().length();
        final HttpRequest httpRequest = context.getRequest();

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

        long[] range = parseRange(rangeHeader, fileLength);
        long start = range[0];
        long end = range[1];
        long actualLength = end - start + 1;

        context.setActualStart(start);
        context.setActualFileLength(actualLength);
    }

    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}; // 表示無效的範圍請求
    }

}

小結

模板方法對於程式碼的複用好處還是很大的,不然後續擴充特性,很多地方都需要修改多次。

下一節,我們考慮實現一下 HTTP keep-alive 的支援。

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

開源地址

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

https://github.com/houbb/nginx4j

相關文章