本文符号声明: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);方法将响应返回给客户端,客户端就会将其渲染。