本文符號聲明:html
LF // 換行 CR // 回車 SPACE // 空格 COLON // 冒號
本文目的是要實現一個HTTP服務端(簡陋勿噴),在接到某個客戶端的HTTP請求時,將HTTP請求報文進行解析,獲得其中全部字段信息,而後識別請求所需的資源,並將放在響應中送回給請求方。web
測試樣例是使用POST方式傳遞參數並請求一個HTML頁面,瀏覽器能夠將其正確渲染出來纔算成功。瀏覽器
本文分爲三部分服務器
HTTP請求頭與響應頭的結構app
請求頭的解析socket
參數的解析ide
響應體的構造post
請求行 請求首部 請求首部 ... 請求首部 空行 消息體(body)
其中測試
請求行結構:方法
+SP
+請求路徑
+SP
+協議/版本
+CRLF
this
請求首部結構: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--
響應行 響應首部 響應首部 ... 響應首部 空行 響應體
其中
響應結構:協議/版本
+SP
+狀態碼
+狀態碼描述
+CRLF
響應首部結構:key
+COLON
+SP
+value
+CRLF
響應首部中Content-Length和Transfer-Encoding不會同時出現,本次測試用Content-Length,屬於實體首部,表明返回的相應實體的長度。(Transfer-Encoding)不在本次討論範圍內。
狀態機的幾種狀態表明當前正在讀取請求報文的哪一部分,羅列以下:
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);方法將響應返回給客戶端,客戶端就會將其渲染。