基於TCP實現HTTP的POST請求(multipart/form-data)

本文符號聲明:html

LF  // 換行
CR  // 回車
SPACE // 空格
COLON // 冒號

本文目的是要實現一個HTTP服務端(簡陋勿噴),在接到某個客戶端的HTTP請求時,將HTTP請求報文進行解析,獲得其中全部字段信息,而後識別請求所需的資源,並將放在響應中送回給請求方。web

測試樣例是使用POST方式傳遞參數並請求一個HTML頁面,瀏覽器能夠將其正確渲染出來纔算成功。瀏覽器

本文分爲三部分服務器

HTTP請求頭與響應頭的結構app

請求頭的解析socket

參數的解析ide

響應體的構造post

HTTP請求頭與響應頭的結構

HTTP的POST請求頭格式:

請求行
請求首部
請求首部
...
請求首部
空行
消息體(body)

其中測試

請求行結構:方法+SP+請求路徑+SP+協議/版本+CRLFthis

請求首部結構:key+COLON+SP+value+CRLF

!!!消息體body結構:

當傳參時Content-Type爲multipart/form-data時,Content-Type中帶有一串boundary分隔符,參數會被這樣的分隔符分隔成幾部分。

因此這種狀況下的body的格式爲(本文就是解析了這樣的格式):

分隔符
Content-Disposition: form-data; name="參數名"
空行
參數值
分隔符
Content-Disposition: form-data; name="參數名"
空行
參數值
分隔符
Content-Disposition: form-data; name="upload"; filename="h.html"
Content-Type: text/html
空行
文件內容
分隔符

上邊一段就是一個完整的請求報文,例如本次測試時用的一個報文攜帶了兩個參數和一個文件:

POST /getHtml HTTP/1.1
Host: localhost:81
Connection: keep-alive
Content-Length: 898
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXEfCkZnHjoOSnPc0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36 Edg/91.0.864.59
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9

------WebKitFormBoundaryXEfCkZnHjoOSnPc0
Content-Disposition: form-data; name="firstname"

中君
------WebKitFormBoundaryXEfCkZnHjoOSnPc0
Content-Disposition: form-data; name="lastname"

雲
------WebKitFormBoundaryXEfCkZnHjoOSnPc0
Content-Disposition: form-data; name="upload"; filename="h.html"
Content-Type: text/html

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>菜雞互啄(google.com)</title>
</head>
<body>

<h1>個人第一個標題</h1>
<p>個人第一個段落。</p>
<form id="upload-form" action="http://localhost:81/getHtml" method="post" enctype="multipart/form-data" >
	First name: <input type="text" name="firstname"><br>
	Last name: <input type="text" name="lastname">	
	<input type="file" id="upload" name="upload" /> <br />
	<input type="submit" value="Upload" />
</form>
</body>
</html>
------WebKitFormBoundaryXEfCkZnHjoOSnPc0--

HTTP的響應頭格式:

響應行
響應首部
響應首部
...
響應首部
空行
響應體

其中

響應結構:協議/版本+SP+狀態碼+狀態碼描述+CRLF

響應首部結構:key+COLON+SP+value+CRLF

響應首部中Content-Length和Transfer-Encoding不會同時出現,本次測試用Content-Length,屬於實體首部,表明返回的相應實體的長度。(Transfer-Encoding)不在本次討論範圍內。

HTTP請求頭的解析:

狀態機的幾種狀態表明當前正在讀取請求報文的哪一部分,羅列以下:

INIT: 0, // 默認狀態
START: 1, // 開始調用parser方法讀取,但未開始讀取請求行
REQUEST_LINE: 2, // 正在讀取並請求行
HEADER_FIELD_START: 3, // 請求首部的key的開始部分,但還沒有讀取key的值
HEADER_FIELD: 4, // 請求首部的key的值
HEADER_VALUE_START: 5, // 請求首部的value的開始部分,但還沒有讀取value的值
HEADER_VALUE: 6, // 請求首部的value的值
BODY: 7, // 消息主體

狀態機的狀態轉換圖以下所示:

請求頭解析狀態機

附解析請求時的代碼,負責返回請求方法、資源路徑、頭部字段、請求體:

const LF = '\n', // 換行
      CR = '\r', // 回車
      SPACE = ' ', // 空格
      COLON = ':'; // 冒號

const STATE = {
  INIT: 0,
  START: 1,
  REQUEST_LINE: 2,
  HEADER_FIELD_START: 3,
  HEADER_FIELD: 4,
  HEADER_VALUE_START: 5,
  HEADER_VALUE: 6,
  BODY: 7,
}

class Parser {
  state: number;
  
  constructor() {
    this.state = STATE.INIT;
  }

  parse(buffer: string) {
    let requestLine = '';
    const headers = {}; 
    let char;
    let headerField = '';
    let headerValue = '';

    this.state = STATE.START;

    for (let i = 0; i< buffer.length; i++) {
      char = buffer[i];
      switch(this.state) {
        case STATE.START:
          this.state = STATE.REQUEST_LINE;
          this['requestLineMark'] = i; // 記錄一下請求行開始的索引,注意沒有加break
        case STATE.REQUEST_LINE:
          if(char === CR){
            requestLine = buffer.substring(this['requestLineMark'], i);
            break;
          } else if (char === LF) {
            this.state = STATE.HEADER_FIELD_START;   
          }
          break; //若是是普通字符就break
        case STATE.HEADER_FIELD_START:
          if(char === CR) {
            //下面該讀請求體了
            this.state = STATE.BODY;
            this['bodyMark'] = i + 2; // 由於那個空行
          } else {
            this.state = STATE.HEADER_FIELD;
            this['headerFieldMark'] = i; // 記錄一下請求頭開始的索引,注意沒有加break
          }
        case STATE.HEADER_FIELD:
          if(char === COLON) {
            headerField = buffer.substring(this['headerFieldMark'], i);
            this.state = STATE.HEADER_VALUE_START;
          }
          break;
        case STATE.HEADER_VALUE_START:
          if(char === SPACE) {
            break;
          }
          this['headerValueMark'] = i;
          this.state = STATE.HEADER_VALUE;
        case STATE.HEADER_VALUE:
          if(char === CR) {
            headerValue = buffer.substring(this['headerValueMark'], i);
            headers[headerField] = headerValue;
            headerField = headerValue = '';
          } else if (char === LF) {
            this.state = STATE.HEADER_FIELD_START;
          }
      }
    }

    const [ method, url ] = requestLine.split(' ');

    const body = buffer.substring(this['bodyMark']);

    return { method, url, headers, body };
  }
}

module.exports = Parser;

在構造相應以前天然是先接到請求,因此首先利用socket創建一個TCP服務器,接收到請求時,利用Parser類的parse方法來解析請求頭。net.createServer()方法建立一個 TCP 服務器,server.listen()方法監聽指定端口 port 和 主機 host 鏈接,當瀏覽器訪問這個端口時服務器就與其創建鏈接。

this.server = net.createServer((socket: Socket) => {
      socket.on('data', (data: Buffer) => {
        const parser = new Parser();

        const { url, headers, body } = parser.parse(data.toString());
        console.log('headers以下\r\n', headers);

        const { paramMap, file } = this.dataAnalyzer(headers, body);

        console.log('接到參數以下\r\n', paramMap);
    
        const resource = this.getResource(url, file);
    
        const response = this.responseProducer(resource);
        socket.end(response);

      });
    
      socket.on('end', () => {
        console.log('觸發end事件');
      });
    
    });

上段代碼中,首先用parse解開請求頭,拿到請求路徑、請求頭、body,請求參數則在body中。dataAnalyzer方法會按照分隔符將body中的參數值取出存放在paramMap中,文件存放在file中,dataAnalyzer方法以下:

dataAnalyzer(headers, body: Buffer) {
    const contentType = headers['Content-Type'] as string;
    if (!contentType) {
      return { undefined };
    }

    const paramMap = new Map<string, any>();
    let fileContentType;
    let fileContent = '';
    // 普通參數
    if (contentType.startsWith('application/x-www-form-urlencoded')) {
      const params = body.toString().split('&');
      for (const item of params) {
        const paramName = item.substring(0, item.indexOf('='));
        const paramValue = item.substring(item.indexOf('=') + 1);
        console.log(paramName, paramValue);

        paramMap.set(paramName, paramValue);
      }
    } else if (contentType.startsWith('multipart/form-data')) {
      const boundary = contentType.substring(contentType.indexOf('=') + 1);

      const trueBody = body.toString().substring(2);

      const formData = trueBody.split(boundary);

      for (const item of formData) {
        const lines = item.split('\r\n');

        // 最後一行
        if (lines.length === 1) {
          continue;
        }

        if (lines[2].includes('Content-Type')) { // 遇到文件了
          fileContentType = lines[2];
          for (let k = 4; k < lines.length - 1; k++) {
            fileContent += lines[k];
          }
          break;
        }

        if(lines[1].includes('form-data')) { // 普通參數
          const paramName = lines[1].substring(lines[1].indexOf('"') + 1, lines[1].lastIndexOf('"'));
          const paramValue = lines[3];
  
          paramMap.set(paramName, paramValue);
        } 
        
      }
    }

    return { paramMap, file: { fileContentType, fileContent } };
  }

拿到參數和文件以後,參數打印,文件返回。

接下來構造響應頭,因爲返回的時html文件,因此Content-Type值爲 text/html,Content-Length值爲445,表明這個HTML文件數據長度爲445,客戶端讀到這麼多數據就能夠認爲數據接收完畢。

響應頭的協議版本默認爲HTTP/1.1,因爲資源確定是找到了,因此狀態碼是200,OK,資源得到時間是當前時間,過時時間先隨便設置一個,響應體就是文件內容,一個響應頭就算產生了:

HTTP/1.1 200 OK
Content-Type: text/html
Date: Sun, 27 Jun 2021 09:59:29 GMT
expires: Fri, 18 Jun 2021 21:11:46 GMT
Content-Length: 445

<!DOCTYPE html><html><head><meta charset="utf-8"><title>菜鳥教程(runoob.com)</title></head><body><h1>個人第一個標題</h1><p>個人第一個段落。</p><form id="upload-form" action="http://localhost:81/getHtml" method="post" enctype="multipart/form-data" >	First name: <input type="text" name="firstname"><br>	Last name: <input type="text" name="lastname">		<input type="file" id="upload" name="upload" /> <br />	<input type="submit" value="Upload" /></form></body></html>

構造完畢以後,就能夠調用socket.end(response);方法將響應返回給客戶端,客戶端就會將其渲染。

相關文章
相關標籤/搜索