前言 大家好,我是老馬。很高興遇到你。 我們希望實現最簡單的 http 服務信息,可以處理靜態文件。 如果你想知道 servlet 如何處理的,可以參考我的另一個項目: 手寫從零實現簡易版 tomcat minicat 手寫 nginx 系列 如果你對 nginx 原理感興趣,可以閱讀: 從零手寫實 ...
前言
大家好,我是老馬。很高興遇到你。
我們希望實現最簡單的 http 服務信息,可以處理靜態文件。
如果你想知道 servlet 如何處理的,可以參考我的另一個項目:
手寫從零實現簡易版 tomcat minicat
手寫 nginx 系列
如果你對 nginx 原理感興趣,可以閱讀:
從零手寫實現 nginx-01-為什麼不能有 java 版本的 nginx?
從零手寫實現 nginx-03-nginx 基於 Netty 實現
從零手寫實現 nginx-04-基於 netty http 出入參優化處理
從零手寫實現 nginx-05-MIME類型(Multipurpose Internet Mail Extensions,多用途互聯網郵件擴展類型)
從零手寫實現 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 開源