在《Tomcat 對 HTTP 協議的實現(上)》一文中,對請求的解析進行了分析,接下來對 Tomcat 生成響應的設計和實現繼續分析。本文首發於(微信公眾號:頓悟源碼)
一般 Servlet 生成響應的代碼是這樣的:
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
resp.setContentType("text/html");
resp.setCharacterEncoding("utf-8");
PrintWriter writer = resp.getWriter();
writer.println("<html><head><title>Demo</title></head>");
writer.println("<body><div>Hello World!</div></body>");
writer.println("</html>");
writer.flush();
writer.close();
}
像生成響應頭和響應體並寫入緩沖區,最后寫入通道,這些都由 Tomcat 來做,來看下它是怎么設計的(可右鍵直接打開圖片查看大圖):
上圖大部分類都是相對的,可與請求處理分析中的描述對比理解。重點還是理解 ByteChunk,它內部有一個 byte[] 數組引用,用於輸入時,引用的 InternalNioInputBuffer 內的數組,表示一個字節序列的視圖;用於輸出時,會 new 一個可擴容的數組對象,存儲響應體數據。
以上面的代碼為例,分析一下,相關類的方法調用,上面的代碼生成的是一種動態內容,會使用 chunked 傳輸編碼:
1. 存儲響應體數據
調用圖中,ByteChunk 調用 append 方法后,為了直觀理解,就直接寫入了發送緩沖區,真實情況不是這樣,只有內部緩沖區滿了,或者主動調用 flush、close 才會實際寫入和發送,來看下 append 方法的代碼:
public void append( byte src[], int off, int len )
throws IOException {
makeSpace( len ); // 擴容,高版本已去掉
// 寫入長度超過最大容量,直接往底層數組寫
// 如果底層數組也超了,會直接往通道寫
if ( optimizedWrite && len == limit && end == start
&& out != null ) {
out.realWriteBytes( src, off, len );
return;
}
// 如果 len 小於剩余空間,直接寫入
if( len <= limit - end ) {
System.arraycopy( src, off, buff, end, len );
end+=len;
return;
}
// 否則就循環把長 len 的數據寫入下層的緩沖區
int avail=limit-end;
System.arraycopy(src, off, buff, end, avail);
end += avail;
// 把現有數據寫入下層緩沖區
flushBuffer();
// 循環寫入 len 長的數據
int remain = len - avail;
while (remain > (limit - end)) {
out.realWriteBytes( src, (off + len) - remain, limit - end );
remain = remain - (limit - end);
}
System.arraycopy(src, (off + len) - remain, buff, end, remain);
end += remain;
}
邏輯就是,首先寫入自己的緩沖區,滿了或不足使用 realWriteBytes 再寫入下層的緩沖區中,下層的緩沖區實際就是 NioChannel 中的 WriteBuffer,寫入之前首先會把響應頭寫入 InternalNioInputBuffer 內部的 HeaderBuffer,再提交到 WriteBuffer 中,接着就會調用響應的編碼處理器寫入響應體,編碼處理通常有兩種:identity 和 chunked。
2. identity 寫入
當明確知道要響應資源的大小,比如一個css文件,並且調用了 resp.setContentLength(1) 方法時,就會使用 identity 寫入指定長度的內容,核心代碼就是 IdentityOutputFilter 的 doWrite 方法,這里不在貼出,唯一值得注意的是,它內部的 buffer 引用是 InternalNioInputBuffer 內部的 SocketOutputBuffer。
3. chunked 寫入
當不確定長度時,會使用 chunked 傳輸編碼,跟解析相反,就是要生成請求分析一文中介紹的 chunked 協議傳輸格式,寫入邏輯如下:
public int doWrite(ByteChunk chunk, Response res)
throws IOException {
int result = chunk.getLength();
if (result <= 0) {
return 0;
}
// 生成 chunk-header
// 從7開始,是因為chunkLength后面兩位已經是\r\n了
int pos = 7;
// 比如 489 -> 1e9 -> ['1','e','9'] -> [0x31,0x65,0x39]
// 生成 chunk-size 編碼,將 int 轉為16進制字符串的形式
int current = result;
while (current > 0) {
int digit = current % 16;
current = current / 16;
chunkLength[pos--] = HexUtils.HEX[digit];
}
chunkHeader.setBytes(chunkLength, pos + 1, 9 - pos);
// 寫入 chunk-szie 包含 \r\n
buffer.doWrite(chunkHeader, res);
// 寫入實際數據 chunk-data
buffer.doWrite(chunk, res);
chunkHeader.setBytes(chunkLength, 8, 2);
// 寫入 \r\n
buffer.doWrite(chunkHeader, res);
return result;
}
所有數據塊寫入完成后,最后再寫入一個大小為0的 chunk,格式為 0\r\n\r\n。至此整個寫入完畢。
4. 阻塞寫入通道
上層所有數據的實際寫入,最后都是由 InternalNioInputBuffer 的 writeToSocket 方法完成,代碼如下:
private synchronized int writeToSocket(ByteBuffer bytebuffer,
boolean block, boolean flip) throws IOException {
// 切換為讀模式
if ( flip ) bytebuffer.flip();
int written = 0;// 寫入的字節數
NioEndpoint.KeyAttachment att = (NioEndpoint.KeyAttachment)
socket.getAttachment(false);
if ( att == null ) throw new IOException("Key must be cancelled");
long writeTimeout = att.getTimeout();
Selector selector = null;
try { // 獲取模擬阻塞使用的 Selector
// 通常是單例的 NioBlockingSelector
selector = getSelectorPool().get();
} catch ( IOException ignore ) { }
try {
// 阻塞寫入
written = getSelectorPool().write(bytebuffer, socket, selector,
writeTimeout, block,lastWrite);
do {
if (socket.flush(true,selector,writeTimeout,lastWrite)) break;
}while ( true );
}finally {
if ( selector != null ) getSelectorPool().put(selector);
}
if ( block ) bytebuffer.clear(); //only clear
this.total = 0;
return written;
}
模擬阻塞的具體實現,已在 Tomcat 對 NIO 模型實現一文中介紹,這里不再贅述。
5. 緩沖區設計
緩沖區直接關系到內存使用的大小,還影響着垃圾收集。在整個HTTP處理過程中,總共有以下幾種緩沖區:
- NioChannel 中的讀寫 ByteBuffer
- NioInputBuffer 和 NioOutputBuffer 內部使用的消息頭字節數組
- ByteChunk 用於寫入響應體時內部使用的字節數組
- 解析請求參數時,如果長度過小會使用內部緩存的一個 byte[] 數組,否則新建
以上緩沖區均可重復利用。
6. 小結
為了更好的理解HTTP的解析,盡可能的使用簡潔的代碼仿寫了這部分功能。
源碼地址:https://github.com/tonwu/rxtomcat 位於 rxtomcat-http 模塊