基于TCP实现HTTP的POST请求(multipart/form-data)
本文符号声明:
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(日本人写的那个)。
本文作者:为何匆匆
本文链接:https://www.cnblogs.com/nyfblog/p/14956975.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步