Tomcat 對 HTTP 協議的實現(上)

協議,直白的說就是存在一堆字節,按照協議指定的規則解析就能得出這堆字節的意義。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 是怎麼設計的(可右鍵直接打開圖片查看大圖):

Tomcat HTTP 請求體解析類圖

上面的類圖包含了處理請求和響應的關鍵類、接口和方法,其中 ByteChunk 比較核心,有着承上啓下的作用,它內部有 ByteInputChannel 和 ByteOutputChannel 兩個接口,分別用於實際讀取和實際寫入的操作。請求體數據讀取過程的方法調用如下(可右鍵直接打開圖片查看大圖):

Tomcat HTTP 請求體解析方法序列圖

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/tonwu/rxtomcat 位於 rxtomcat-http 模塊

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章