協議,直白的說就是存在一堆字節,按照協議指定的規則解析就能得出這堆字節的意義。HTTP 解析分為兩個部分:解析請求頭和請求體。
請求頭解析的難點在於它沒有固定長度的頭部,也不像其他協議那樣提供數據包長度字段,判斷是否讀取到一個完整的頭部的唯一依據就是遇到一個僅包括回車換行符的空行,好在在找尋這個空行的過程中能夠完成請求行和頭域的分析。
請求體的解析就是按照頭域的傳輸編碼和內容編碼進行解碼。那么 Tomcat 是如何設計和實現 HTTP 協議的呢?
1. 請求頭的解析
請求頭由 Ascii 碼組成,包含請求行和請求頭域兩個部分,下面是一個簡單請求的字符串格式:
POST /index.jsp?a=1&b=2 HTTP/1.1\r\n
Host: localhost:8080\r\n
Connection: keep-alive
Content-Length: 43
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Accept:*/*\r\n
Accept-Encoding: chunked, gzip\r\n
\r\n
account=Rvcg%3D%3D&passwd=f63ebe&salt=vpwMy
解析過程的本質就是遍歷這些字節,以空格、回車換行符和冒號為分隔符提取內容。在具體實現時,Tomcat 使用的是一種有限狀態機的編程方法,狀態機在非阻塞和異步編程中很重要,因為如果因數據不完整導致處理中斷,在讀取更多數據后,可以很方便的從中斷的地方繼續。Tomcat 對請求行和頭域分別設計了各種解析狀態。
1.1 解析請求行的狀態
InternalNioInputBuffer 有一個成員變量 parsingRequestLinePhase,它的不同值表示不同的解析階段:
- 0: 表示解析開始前跳過空行
- 2: 開始解析請求方法
- 3: 跳過請求方法和請求uri之間的空格或制表符
- 4: 開始解析請求URI
- 5: 與3相同
- 6: 解析協議版本,如 HTTP/1.1
1.2 解析請求頭域的狀態
解析頭域信息設計了兩種狀態,分別是 HeaderParseStatus 和 HeaderParsePosition。HeaderParseStatus 表示整個頭域解析的情況,它有三個值:
- DONE: 表示整個頭域解析結束
- HAVE_MORE_HEADERS: 解析完一個 header的 name-value,開始解析下一個
- NEED_MORE_DATA: 表示頭域數據不完整需要繼續從通道讀取
HeaderParseStatus 表示一個 Header 的 name-value 解析的狀態,它有6個狀態:
- HEADER_START:開始解析新的 Header,如果讀取到了一個 \r\n,即遇到空行,整個解析結束
- HEADER_NAME:解析 Header 的名稱
- HEADER_VALUE_START:此狀態是為了跳過 name 和 value 之間的空格
- HEADER_VALUE:解析 Header 的值
- HEADER_MULTI_LINE:解析value后,如果新的一行開始的字符為 LWS(線性空白 - 空格、制表符),表示上一個value占多行
- HEADER_SKIPLINE:解析名稱時遇到非法的字符,跳過這一行,忽略這個 Header
1.3 實現
這塊的代碼實現分別在 InternalNioInputBuffer 的 parseRequestLine、parseHeaders 和 parseHeader 方法中,具體的代碼注釋這里不在貼出,原理就是遍歷字節數組按狀態取值。
那請求元素如何表示?整個解析過程都是在操作字節數組,一個簡單的做法是直接轉字符串存儲,而 Tomcat 為了節省內存,設計了一個 MessageBytes 類,它用於表示底層 byte[] 子字節數組的視圖,只在有需要的時候才轉為字符串,並緩存,后續的讀取操作也是盡可能的減少內存復制。
從通道讀取數據的功能,由 InternalNioInputBuffer 的 fill 和 readSocket 方法完成。fill 方法主要做一些邏輯判斷,讀取請求體時,重置 pos 位置,以重復使用請求頭數據后的緩沖區,具體代碼如下:
protected boolean fill(boolean timeout, boolean block)
throws IOException, EOFException {
// 嘗試將一些數據讀取到內部緩沖區
boolean read = false; // 是否有數據讀取
if (parsingHeader) { // 如果當前處於解析請求頭域的狀態
if (lastValid == buf.length) {
// 判斷已讀字節是否超過緩沖區的大小
// 這里應該使用 headerBufferSize 而不是 buf.length
throw new IllegalArgumentException("Request header is too large");
}
// 從通道讀取數據
read = readSocket(timeout,block)>0;
} else {
// end 請求頭數據在緩沖區結束的位置下標,也是請求體數據開始的下標
lastValid = pos = end; // 重置 pos 的位置,重復利用 end 后的緩沖區
read = readSocket(timeout, block)>0;
}
return read;
}
readSocket 執行讀取操作,它有兩種模式,阻塞讀和非阻塞讀:
private int readSocket(boolean timeout, boolean block) throws IOException {
int nRead = 0; // 讀取的字節數
socket.getBufHandler().getReadBuffer().clear(); // 重置 NioChannel 中的讀緩沖區
if ( block ) { // true 模擬阻塞讀取請求體數據
Selector selector = null;
try { selector = getSelectorPool().get(); }catch ( IOException x ) {}
try {
NioEndpoint.KeyAttachment att = (NioEndpoint.KeyAttachment)socket.getAttachment(false);
if ( att == null ) throw new IOException("Key must be cancelled.");
nRead = getSelectorPool().read(socket.getBufHandler().getReadBuffer(),
socket,selector,att.getTimeout());
} catch ( EOFException eof ) { nRead = -1;
} finally {
if ( selector != null ) getSelectorPool().put(selector);
}
} else { // false 非阻塞讀取請求頭數據
nRead = socket.read(socket.getBufHandler().getReadBuffer());
}
if (nRead > 0) {
// 切換讀取模式
socket.getBufHandler().getReadBuffer().flip();
socket.getBufHandler().getReadBuffer().limit(nRead);
expand(nRead + pos); // 緩沖區沒有必要擴展,高版本已移除
socket.getBufHandler().getReadBuffer().get(buf, pos, nRead);
lastValid = pos + nRead;
return nRead;
} else if (nRead == -1) { // 客戶端關閉連接
//return false;
throw new EOFException(sm.getString("iib.eof.error"));
} else { // 讀取 0 個字節,說明通道數據還沒准備好,繼續讀取
return 0;
}
}
2. 請求體讀取
Tomcat 把請求體的讀取和解析延遲到了 Servlet 讀取請求參數的時候,此時的請求已經從 Connector 進入了 Container,需要再次從底層通道讀取數據,來看下 Tomcat 是怎么設計的(可右鍵直接打開圖片查看大圖):
上面的類圖包含了處理請求和響應的關鍵類、接口和方法,其中 ByteChunk 比較核心,有着承上啟下的作用,它內部有 ByteInputChannel 和 ByteOutputChannel 兩個接口,分別用於實際讀取和實際寫入的操作。請求體數據讀取過程的方法調用如下(可右鍵直接打開圖片查看大圖):
2.1 identity-body 編碼
identity 是定長解碼,直接按照 Content-Length 的值,讀取足夠的字節就可以了。IdentityInputFilter 用一個成員變量 remaining 來控制是否讀取了指定長度的數據,核心代碼如下:
public int doRead(ByteChunk chunk, Request req) throws IOException {
int result = -1; // 返回 -1 表示讀取完畢
if (contentLength >= 0) {
if (remaining > 0) {
// 使用 ByteChunk 記錄底層數組讀取的字節序列
int nRead = buffer.doRead(chunk, req);
if (nRead > remaining) { // 讀太多了
// 重新設置有效字節序列
chunk.setBytes(chunk.getBytes(), chunk.getStart(), (int) remaining);
result = (int) remaining;
} else {
result = nRead;
}
if (nRead > 0) {
// 計算還要在讀多少字節,可能為負數
remaining = remaining - nRead;
}
} else {
// 讀取完畢,重置 ByteChunk
chunk.recycle();
result = -1;
}
}
return result;
}
InputFilter 里的 buffer 引用的是 InternalNioInputBuffer 的內部類 SocketInputBuffer,它的作用就是控制和判斷是否讀取結束,實際的讀取操作以及存儲讀取的內容都還是由 InternalNioInputBuffer 完成。一次讀取完畢,容器通過 ByteChunk 類記錄底層數組的引用和有效字節的位置,拉取實際的字節和判斷是否還要繼續讀取。
2.2 chunked-body 編碼
chunked 是用於不確定請求體大小時的傳輸編碼,解碼讀取的主要工作就是一直解析 chunk 塊直到遇到一個大小為 0 的 Data chunk。rfc2616#section-3.6.1 中定義了 chunk 的格式:
Chunked-Body = *chunk
last-chunk
trailer
CRLF
chunk = chunk-size [ chunk-extension ] CRLF
chunk-data CRLF
chunk-size = 1*HEX
last-chunk = 1*("0") [ chunk-extension ] CRLF
chunk-extension= *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
chunk-ext-name = token
chunk-ext-val = token | quoted-string
chunk-data = chunk-size(OCTET)
trailer = *(entity-header CRLF)
其中,關鍵字段的意義是:
- chunk-size:是小寫 16 進制數字字符串,表示 chunk-data 的大小,比如 15 的十六進制是 f,字符 'f' 的 ASCII 是 0x66,所以數字 15 就被編碼成了 0x66
- chunk-extension:一個或多個以逗號分割放在 chunk-size 后面的擴展選項
- trailer:額外的 HTTP 頭域
下面是一個盡可能展示上面的定義的 chunked 編碼的例子:
HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Transfer-Encoding: chunked\r\n
Content-Encoding: gzip\r\n
Trailer: Expires\r\n
\r\n
Data chunk (15 octets)
size: 66 0d 0a
data: xx xx xx xx 0d 0a
Data chunk (4204 octets)
size: 4204;ext-name=ext-val\r\n
Data chunk (3614 octets)
Data chunk (0 octets)
size: 0d 0a
data: 0d 0a
Expires: Wed, 21 Jan 2016 20:42:10 GMT\r\n
\r\n
rfc2616 中提供了一個解碼 chunked 傳輸編碼的偽代碼:
length := 0
read chunk-size, chunk-extension (if any) and CRLF
while (chunk-size > 0) {
read chunk-data and CRLF
append chunk-data to entity-body
length := length + chunk-size
read chunk-size and CRLF
}
read entity-header
while (entity-header not empty) {
append entity-header to existing header fields
read entity-header
}
Content-Length := length
Remove "chunked" from Transfer-Encoding
邏輯還是比較清晰的,有問題可留言交流。Tomcat 實現 chunked 編碼解析讀取的類是 ChunkedInputFilter,來分析一下核心代碼的實現,整個解析邏輯都在 doRead 方法中:
public int doRead(ByteChunk chunk, Request req) throws IOException {
if (endChunk) {// 是否讀取到了最后一個 chunk
return -1; // -1 表示讀取結束
}
checkError();
if(needCRLFParse) {// 讀取一個 chunk 前,是否需要解析 \r\n
needCRLFParse = false;
parseCRLF(false);
}
if (remaining <= 0) {
if (!parseChunkHeader()) { // 讀取 chunk-size
throwIOException(sm.getString("chunkedInputFilter.invalidHeader"));
}
if (endChunk) {// 如果是最后一個塊
parseEndChunk();// 處理 Trailing Headers
return -1;
}
}
int result = 0;
if (pos >= lastValid) { // 從通道讀取數據
if (readBytes() < 0) {
throwIOException(sm.getString("chunkedInputFilter.eos"));
}
}
// lastValid - pos 的值是讀取的字節數
if (remaining > (lastValid - pos)) {
result = lastValid - pos;
// 還剩多少要讀取
remaining = remaining - result;
// ByteChunk 記錄讀取的數據
chunk.setBytes(buf, pos, result);
pos = lastValid;
} else {
result = remaining;
chunk.setBytes(buf, pos, remaining); // 記錄讀取的數據
pos = pos + remaining;
remaining = 0;
// 這時已經完成 chunk-body 的讀取,解析 \r\n
if ((pos+1) >= lastValid) {
// 此時如果調用 parseCRLF 就會溢出緩沖區,接着會觸發阻塞讀取
// 將解析推遲到下一個讀取事件
needCRLFParse = true;
} else {
// 立即解析 CRLF
parseCRLF(false); //parse the CRLF immediately
}
}
return result;
}
parseChunkHeader 就是讀取並計算 chunk-size,對於擴展選項,Tomcat只是簡單忽略:
// 十六進制字符轉十進制數的數組,由 ASCII 表直接得來
private static final int[] DEC = {
00, 01, 02, 03, 04, 05, 06, 07, 8, 9, -1, -1, -1, -1, -1, -1,
-1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, 10, 11, 12, 13, 14, 15,
};
public static int getDec(int index){
return DEC[index - '0'];
}
protected boolean parseChunkHeader() throws IOException {
...
int result = 0;
// 獲取字符對應的十進制數
int charValue = HexUtils.getDec(buf[pos]);
if (charValue != -1 && readDigit < 8) {
readDigit++;
// 一個16進制數 4bit,左移4位合並低4位
// 相當於 result = result * 16 + charValue;
result = (result << 4) | charValue;
}
...
}
3. 請求參數的解析
服務端接收到的參數形式可能有這種特殊格式:
tonwu.net/search?scope=b%20bs&q=%E5%88%9B
account=Rvcg%3D%3D&passwd=f63ebe&salt=vpwMy
這樣的解析就涉及到了編碼問題,GET 和 POST 請求的編碼方法由頁面設置的編碼決定,也就是 <meta http-equiv="Content-Type" content="text/html;charset=utf-8"。如果 URL 中有查詢參數,並且包含特殊字符或中文,就會使用它們的編碼,格式為:%加字符的 ASCII 碼,下面是一些常用的特殊符號和編碼:
%2B + 表示空格
%20 空格 也可使用 + 和編碼
%2F / 分割目錄和子目錄
%3F ? 分割URI和參數
%25 % 指定特殊字符
%23 # 錨點位置
%26 & 參數間的分隔符
%3D = 參數賦值
Tomcat 對請求參數解析的代碼在 Parameters 類的 processParameters 方法中,其中要注意的是對 % 編碼的處理,因為%后面跟的是實際數值的大寫的16進制數字字符串,就和chunk-size類似要進行一次轉換。
由於篇幅的原因下一篇將會繼續分析 HTTP 響應的處理。
小結
本文對請求頭和請求體的讀取和解析進行了分析。為了簡單直觀的理解具體的處理流程,盡可能的使用簡潔的代碼仿寫了這部分功能。
源碼地址:https://github.com/dwosc/rxtomcat 位於 rxtomcat-http 模塊