前言
大家好,我是老馬。很高興遇到你。
我們為 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 請求頭響應頭的處理
背景
最初感覺範圍處理和檔案的處理不是相同的邏輯,所以做了拆分。
但是後來發現有很多公共的邏輯。
主要兩種最佳化方式:
-
把範圍+檔案合併到同一個檔案中處理。新增各種判斷程式碼
-
採用模板方法,便於後續擴充修改。
這裡主要嘗試下第 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