本文符號聲明:
LF // 換行
CR // 回車
SPACE // 空格
COLON // 冒號
本文目的是要實現一個HTTP服務端(簡陋勿噴),在接到某個客戶端的HTTP請求時,將HTTP請求報文進行解析,得到其中所有字段信息,然后識別請求所需的資源,並將放在響應中送回給請求方。
測試樣例是使用POST方式傳遞參數並請求一個HTML頁面,瀏覽器可以將其正確渲染出來才算成功。
本文分為三部分
-
HTTP請求頭與響應頭的結構
-
請求頭的解析
-
響應體的構造
HTTP請求頭與響應頭的結構
HTTP的POST請求頭格式:
請求行
請求首部
請求首部
...
請求首部
空行
消息體(body)
其中
請求行結構:方法+SP+請求路徑+SP+協議/版本+CRLF
請求首部結構: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>菜雞互啄(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>
構造完畢之后,就可以調用socket.end(response);方法將響應返回給客戶端,客戶端就會將其渲染。
參考書目:HTTP協議(RCF2616),HTTP權威指南(小松鼠),圖解HTTP(日本人寫的那個)。
