Tomcat 對 HTTP 協議的實現(下)

在《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 來做,來看下它是怎麼設計的(可右鍵直接打開圖片查看大圖):

Tomcat HTTP 請求體解析類圖

上圖大部分類都是相對的,可與請求處理分析中的描述對比理解。重點還是理解 ByteChunk,它內部有一個 byte[] 數組引用,用於輸入時,引用的 InternalNioInputBuffer 內的數組,表示一個字節序列的視圖;用於輸出時,會 new 一個可擴容的數組對象,存儲響應體數據。

以上面的代碼爲例,分析一下,相關類的方法調用,上面的代碼生成的是一種動態內容,會使用 chunked 傳輸編碼:

Tomcat HTTP 寫入響應方法調用

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 模塊

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