基于TCP实现HTTP的POST请求(multipart/form-data)

本文符号声明:

LF  // 换行
CR  // 回车
SPACE // 空格
COLON // 冒号

本文目的是要实现一个HTTP服务端(简陋勿喷),在接到某个客户端的HTTP请求时,将HTTP请求报文进行解析,得到其中所有字段信息,然后识别请求所需的资源,并将放在响应中送回给请求方。

测试样例是使用POST方式传递参数并请求一个HTML页面,浏览器可以将其正确渲染出来才算成功。

本文分为三部分

  1. HTTP请求头与响应头的结构

  2. 请求头的解析

  3. 响应体的构造

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 中国大陆许可协议进行许可。

posted @   为何匆匆  阅读(2057)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起