前言
大家好,我是老馬。很高興遇到你。
我們希望實現最簡單的 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);
這段程式碼的主要變化如下:
- 讀取檔案內容:使用
randomAccessFile.read(fileContent)
一次性讀取整個檔案到位元組陣列fileContent
中。 - 建立
FullHttpResponse
:使用DefaultFullHttpResponse
建立一個完整的HTTP響應物件,並將檔案內容寫入到響應的content()
中。 - 設定
Content-Length
:在FullHttpResponse
的headers中設定Content-Length
。 - 傳送響應並關閉連線:使用
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);
這段程式碼的主要變化如下:
-
移除
Content-Length
頭:由於是分塊傳輸,我們不需要在響應頭中設定Content-Length
。 -
分塊讀取檔案:使用一個固定大小的緩衝區
ByteBuffer
來分塊讀取檔案內容。 -
傳送分塊資料:在迴圈中,每次讀取檔案內容到緩衝區後,建立一個
DefaultHttpContent
物件,並將緩衝區的資料包裝在Unpooled.wrappedBuffer()
中,然後寫入Channel。 -
傳送結束標記:在檔案讀取完畢後,傳送一個空的
LastHttpContent
物件,以標記HTTP訊息體的結束。 -
關閉連線:在傳送完最後一個分塊後,使用
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
作為引數。以下是該方法的主要步驟:
-
開啟檔案:使用
RandomAccessFile
開啟要傳輸的檔案,並獲取FileChannel
。 -
計算檔案大小:透過
fileChannel.size()
獲取檔案的總大小。 -
分頁傳輸:使用一個迴圈來逐頁讀取檔案內容。在每次迭代中,我們計算要傳輸的資料塊的大小,並使用
FileRegion
來表示這部分資料。 -
寫入Channel:將
FileRegion
寫入Netty的Channel
。 -
更新位置:更新
position
變數以指向下一頁的開始位置。 -
檢查傳輸狀態:透過
region.isWritten()
檢查資料是否成功寫入。 -
傳送結束標記:傳輸完成後,傳送
LastHttpContent.EMPTY_LAST_CONTENT
來標記訊息結束,並關閉連線。 -
錯誤處理:如果在傳輸過程中發生異常,傳送一個錯誤響應。
請注意,這個示例是一個簡化的版本,它沒有處理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