HTTP协议入门(一) - 理论篇

184 阅读14分钟

一起来学习一下HTTP协议吧。

在学习本篇文章之前,先来看一个实际的问题,当用户在浏览器地址栏里输入一个URL,按回车后发生了什么?

  1. 通过DNS协议解析出URL资源所在的IP地址;
  2. 建立TCP连接;
  3. 发送HTTP请求;
  4. 服务器进行HTTP应答;
  5. 浏览器接收到HTTP响应后,解析渲染;
  6. 连接结束;

问题

  • 为什么需要DNS解析出IP地址?
  • DNS和TCP,UDP是什么关系?
  • HTTP和TCP,UDP是什么关系?
  • TCP连接需要哪些步骤?TCP建立连接的时候,发送sync为1的报文,服务器发送sync/ack,是什么意思?
  • HTTP请求的报文的格式是什么?
  • HTTP报文和TCP报文里不包含目的IP地址,IP报文里有IP地址,那这个IP地址从哪里来?
  • HTTP响应报文格式是什么?
  • 浏览器接收到HTTP响应报文后(假如请求的是html文档),怎么把html文档渲染在页面上?html中如果有引用css,js文件,怎么处理?
  • 断开连接需要哪些步骤?
  • 完成一次HTTP后,连接必须要断开吗?如果不断开的话,那什么时候才会断开呢?

OSI七层与TCP/IP

在计算机世界中如果⼀个复杂问题通常的解决方式就是分层解决。为什么要分层呢?在设计的角度来讲变得灵活了,当某一层需要修改时,只需要拿掉对相应的层,实现可拔插,无需变动所有层。对于使用者来讲,屏蔽了底层复杂的传输过程。

思考:OSI参考模型和TCP/IP分层模型,是什么关系?

对于“网络基础”,要做到了解。对于“TCP和UDP”,要做到掌握。对于“HTTP”,要做到精通。

DNS协议

DNS是域名系统(Domain Name System)的简称,因特网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP地址。

DNS协议则是用来将域名转换为IP地址(也可以将IP地址转换为相应的域名地址)。IP地址是面向主机的,而域名则是面向用户的。 用户输入域名的时候,会自动查询DNS服务器,由DNS服务器检索数据库,得到对应的IP地址。在域名解析的过程中仍然会优先查找hosts文件的内容。域名服务主要是基于UDP实现的,服务器的端口号为53。

UDP协议

用户数据报协议 UDP(User Datagram Protocol): 无连接;尽最大努力的交付(可能会丢包) ;面向报文;无拥塞控制;支持一对一、一对多、多对一、多对多的交互通信;首部开销小(只有四个字段:源端口、目的端口、长度、检验和)。UDP是面向报文的,传输方式是应用层交给UDP多长的报文,UDP发送多长的报文,即一次发送一个报文。因此,应用程序必须选择合适大小的报文。

用户数据报有两个字段:数据字段首部字段,首部字段很简单,只有8个字节,由四个字段组成,每个字段的长度都是两个字节。各字段意义如下:

  1. 源端口: 源端口号,在需要给对方回信时使用。不需要是可全用0;
  2. 目的端口号: 这在终点交付报文时必须使用;
  3. 长度: 用户数据报UDP的长度,最小为8(仅首部);
  4. 校验和: 用于校验用户数据报在传输过程是否出错,出错则丢弃该报文;

伪首部向上,向下不传递,只做校验和的时候用到。

TCP协议

传输控制协议 TCP(Transmission Control Protocol): 面向连接;每一个TCP连接只能是点对点的(一对一);提供可靠交付服务;提供全双工通信;面向字节流。应用程序和TCP的交互是一次一个数据块(大小不等),但TCP把应用程序看成是一连串的无结构的字节流。TCP有一个缓冲,当应该程序传送的数据块太长,TCP就可以把它划分短一些再传送。

三次握手

客户端想要发送数据给服务端,在发送实际的数据之前,需要先在两端之间建⽴连接,数据发完以后也需要将该连接关闭。建⽴连接的过程就是我们常说的 TCP 三次握⼿:

图为 TCP 的三次握⼿

如上图示,建⽴ TCP 连接需要这三个步骤:

  1. 客户端发送⼀个 SYN 告诉服务端它想建议⼀个连接,SYN 代表 Sychronize,意为同步;

  2. 服务端收到 SYN 后,返回⼀个 SYN-ACK,ACK 代表 Acknowledge,意为确认;

  3. 客户端最后发送⼀个 ACK,服务端收到时,标识着三次握⼿的完成,这之后就可以愉快地传输数据了;

由此可⻅,三次握⼿的时间消耗为⾄少⼀个 RTT(Round Trip Time),即⽹路上⾄少⼀个来回,⼤部分时候这意味着⼏百毫秒的时间,如果服务器在国外或者客户端⽹络不好,RTT 超过⼀秒也完全是有可能的。这也解释了为什么我们在谈前端优化的时候,经常提到要减少建⽴ TCP 连接

  • 雪碧图(精灵图)这种合并图⽚以减少 HTTP 请求的技术,本质上是为了减少建⽴多个 TCP 连接带来的性能损耗;
  • HTTP/2 ⾥,由于对单个 TCP 连接的多路复⽤,⽆需建⽴多个 TCP 连接;

四次挥手

当数据传送完毕,断开连接就需要进行TCP的四次挥手:

  1. 第一次挥手,客户端设置seq和 ACK ,向服务器发送一个 FIN(终结)报文段。此时,客户端进入 FIN_WAIT_1

状态,表示客户端没有数据要发送给服务端了。

  1. 第二次挥手,服务端收到了客户端发送的 FIN 报文段,向客户端回了一个 ACK 报文段。
  2. 第三次挥手,服务端向客户端发送FIN 报文段,请求关闭连接,同时服务端进入 LAST_ACK 状态。
  3. 第四次挥手,客户端收到服务端发送的 FIN 报文段后,向服务端发送 ACK 报文段,然后客户端进入 TIME_WAIT状态。服务端收到客户端的 ACK 报文段以后,就关闭连接。此时,客户端等待2MSL(指一个片段在网络中最大的存活时间)后依然没有收到回复,则说明服务端已经正常关闭,这样客户端就可以关闭连接了。

为什么需要四次挥手呢?
客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。

例子

客户端和浏览器通信不一定非要用应用层的协议来传递数据,可以直接采用TCP或UDP来交换数据。

下面,我们用TCP协议来实现一个即时通信的小程序。

原理: Net模块提供⼀个异步API能够创建基于流的TCP服务器,客户端与服务器建⽴连接后,服务器可以获得⼀个全双工Socket对象,服务器可以保存Socket对象列表,在接收某客户端消息时,推送给其他客户端。

const net = require('net');
const chatServer = net.createServer();
const clientList = [];
chatServer.on('connection', client => { 
    client.write('Hi!\n');
    clientList.push(client); 
    client.on('data', data => { 
        console.log('receive:', data.toString()); 
        clientList.forEach(v => { 
            v.write(data);
        });
    });
});

chatServer.listen(9000);

通过Telnet连接服务器:

telnet localhost 9000

TCP VS UDP

传输层有两种通讯方式分别是TCP和UDP。

两种协议都能够传输数据,区别主要是要不要提前建立连接。TCP就是需要建立连接的一个,好处在于通讯方式比较可靠。所以我们说TCP不丢包。

但是UDP也不是没有用武之地,就比如说玩游戏 ,一技能没作用我再按一次就行了,所以延时小比可靠连接更重要,所以早期的游戏很多都看上了UDP协议。

思考:UDP协议不可靠,可能会出现丢包的现象,丢包这种情况怎么处理?

HTTP协议

HTTP是超文本传输协议,它是一个基于请求与响应,无状态的,应用层的协议,常基于TCP/IP协议传输数据,互联网上应用最为广泛的一种网络协议,所有的WWW文件都必须遵守这个标准。设计HTTP的初衷是为了提供一种发布和接收HTML页面的方法。

HTTP的特点

  1. 无状态:协议对客户端没有状态存储,对事物处理没有“记忆”能力;
  2. 无连接:HTTP/1.1之前,由于无状态特点,每次请求需要通过TCP三次握手四次挥手,和服务器重新建立连接。比如某个客户机在短时间多次请求同一个资源,服务器并不能区别是否已经响应过用户的请求,所以每次需要重新响应请求,需要耗费不必要的时间和流量。
  3. 基于请求和响应:基本的特性,由客户端发起请求,服务端响应。
  4. 简单快速、灵活。
  5. 通信使用明文、请求和响应不会对通信方进行确认、无法保护数据的完整性。

为什么需要HTTP协议

上面我们已经知道了通过TCP可以收发数据,假设我们想做一个类似论坛,这样的需求怎么做?

为了实现这样的功能,我们需要告诉服务器我们需要什么样的资源?比如,我想从服务器获得一张图片,那这个图片的路径是什么?图片的名字是什么?图片是什么格式?其实这就是HTTP协议。

超文本传输协议(英文:HyperText Transfer Protocol,缩写:HTTP)是一种用于分布式、协作式和超媒体信息系统的应用层协议。HTTP是万维网的数据通信的基础。HTTP协议是建立在TCP协议之上的一种应用。

显然这种功能TCP协议并没有规定,TCP协议只是提供交换数据。

HTTP连接最显著的特点是客户端发送的每次请求都需要服务器回送响应,在请求结束后,会主动释放连接。从建立连接到关闭连接的过程称为“一次连接”。

1)在HTTP 1.0中,客户端的每次请求都要求建立一次单独的连接,在处理完本次请求后,就自动释放连接。
2)在HTTP 1.1中则可以在一次连接中处理多个请求,并且多个请求可以重叠进行,不需要等待一个请求结束后再发送下一个请求。

由于HTTP在每次请求结束后都会主动释放连接,因此HTTP连接是一种“短连接”,要保持客户端程序的在线状态,需要不断地向服务器发起连接请求。通常的做法是即使不需要获得任何数据,客户端也保持每隔一段固定的时间向服务器发送一次“保持连接”的请求,服务器在收到该请求后对客户端进行回复,表明知道客 户端“在线”。若服务器长时间无法收到客户端的请求,则认为客户端“下线”,若客户端长时间无法收到服务器的回复,则认为网络已经断开。

HTTP报文

先来看一下HTTP报文的具体结构。

其实HTTP报文就是一个文本,这里面使用分隔符比如空格、回车、换行符来区分他的不同部分。

构建和解析HTTP报文

平时我们需要构建一个HTTPRequest,或者拿到HTTPResponse后进行解析,这些都是浏览器帮我们做了,如果这些需要我们自己实现,那怎么做呢?下面我们着手去用代码构建和解析一个HTTP报文。

request报文构建:

class HTTPRequest {
    CRLF = "\r\n";

    constructor(){
    }

    buildHttpReuqest({ method, url, version, headers, body }){
        // 请求行
        let requestLine = `${method} ${url} ${version}${this.CRLF}`;
        // 请求头部
        let headerData = '';
        for(let key in headers){
            headerData += `${key}:${headers[key]}${this.CRLF}`;
        }
        headerData += this.CRLF;
        
        return `${requestLine}${headerData}${body ? body : ''}`;      
    }

    parseHttpRequest(requestData){
        let data = requestData.split(this.CRLF);

        let [requestLine] = data;
        let headers = data.slice(1, -2);
        let [body] = data.slice(-1);

        let headResult = this.#parseHead(requestLine);
        let headersResult = this.#parseHeaders(headers);

        return Object.assign(headResult, headersResult, { body });
    }

    #parseHead(head){
        let data = head.split(' ');

        return {
            method: data[0],
            url: data[1],
            version: data[2]
        };
    }

    #parseHeaders(headers){
        let result = Object.create(null);
        result.headers = Object.create(null);

        headers.forEach((header) => {
            let arr = header.split(':');
            result.headers[arr[0]] = arr[1];
        });

        return result;
    }
}

module.exports = HTTPRequest;

// test
// let req = {
//     method: "POST",
//     url: "/",
//     version: "HTTP/1.1",
//     headers: { 
//         "user-agent": "my-client/6.66",
//         "accept": "*/*"
//     },
//     body: ''
// };
// let request = new HTTPRequest();
// let reqString = request.buildHttpReuqest(req);
// console.log(reqString);
// let reqObj = request.parseHttpRequest(reqString);
// console.log(reqObj);

response报文解析:

class HTTPResponse {
    CRLF = "\r\n";

    constructor(){
    }

    buildHttpResponse({ version, status, reasonPhrase, headers, body }){
        // 响应行
        let responseLine = `${version} ${status} ${reasonPhrase}${this.CRLF}`;
        // 响应头部
        let headerData = '';
        for(let key in headers){
            headerData += `${key}:${headers[key]}${this.CRLF}`;
        }
        headerData += this.CRLF;
        
        return `${responseLine}${headerData}${body}`;
    }

    parseHttpResponse(responseData){
        let data = responseData.split(this.CRLF);

        let [responseLine] = data;
        let headers = data.slice(1, -2);
        let [body] = data.slice(-1);

        let headResult = this.#parseHead(responseLine);
        let headersResult = this.#parseHeaders(headers);

        return Object.assign(headResult, headersResult, { body });
    }

    #parseHead(head){
        let data = head.split(' ');

        return {
            version: data[0],
            status: data[1],
            reasonPhrase: data[2]
        };
    }

    #parseHeaders(headers){
        let result = Object.create(null);
        result.headers = Object.create(null);

        headers.forEach((header) => {
            let arr = header.split(':');
            result.headers[arr[0]] = arr[1];
        });

        return result;
    }
}

module.exports = HTTPResponse;

// test
// let res = {
//     version: "HTTP/1.1",
//     status: "200",
//     reasonPhrase: "OK",
//     headers: {
//       Date: "Sat, 26 Mar 2022",
//       Connection: "keep-alive",
//       "Content-Type": "text/html; charset=UTF-8",
//       "Content-Length": 10
//     },
//     body: "<h1> Hello HTTP<h1>",
//   };
// let response = new HTTPResponse();
// let responeString = response.buildHttpResponse(res);
// console.log(responeString);
// let resObj = response.parseHttpResponse(responeString);
// console.log(resObj);

日常中,我们都是使用浏览器发送的HTTP请求,现在我自己实现用TCP协议把我组装好的HTTP request报文发送给服务器,这样是否可行呢?

实验例子

封装HTTP请求数据,通过TCP协议访问百度,百度服务器也响应了。注意,我们并没有使用HTTP协议,而是向百度发送了一个TCP请求,使用的报文是刚才实现的。

const net = require("net");
const HTTPRequest = require("./request.js");

// HTTPRequest请求
let req = {
    method: "GET",
    url: "/",
    version: "HTTP/1.1",
    headers: { 
        "user-agent": "my-client/6.66",
        "accept": "*/*"
    },
    body: ''
};
let request = new HTTPRequest();
// 注意host不能带http或https,只能是单纯的域名
const client = net.connect(80, "www.baidu.com", () => {
    console.log("连接到百度服务器了!");
    let requestString = request.buildHttpReuqest(req);
    client.write(requestString);
});
client.on("data", (data) => {
    console.log(data.toString());
    client.end();
});
client.on("end", (data) => {
    console.log("断开百度服务器连接了!");
});

运行效果:

实现能被浏览器访问的HTTP服务器。客户端(chrome)发出请求后,服务器向客户端发送了自己组装的响应,而且浏览器也正确的渲染了页面。

const net = require("net");
const HTTPResponse = require("./response.js");

// HTTPResponse响应
// Content-Length不要给定值,可以不给
let res = {
    version: "HTTP/1.1",
    status: "200",
    reasonPhrase: "OK",
    headers: {
      Date: "Sat, 26 Mar 2022",
      Connection: "keep-alive",
      "Content-Type": "text/html; charset=UTF-8"
      //"Content-Length": 100
    },
    body: "<h1>Hello?我是Tom</h1>"
  };
let response = new HTTPResponse();

const server = net.createServer(function(client){
    console.log("客户端已连接!");

    client.on("data", (data) => {
        console.log(data.toString());
    });

    client.on("end", (data) => {
        console.log("客户端关闭连接!");
    });

    let responseString = response.buildHttpResponse(res);
    console.log(responseString);
    client.end(responseString);
});
server.listen(8080, ()=>{
    console.log("服务器启动了!");
});

运行效果:

HTTP持久连接

如果有大量的连接,每次在连接,关闭都要经历三次握手,四次挥手,这显然会造成性能低下。因此。HTTP有一种叫做长连接(keepalive connections) 的机制。它可以在传输数据后仍保持连接,当客户端需要再次获取数据时,直接使用刚刚空闲下来的连接而无需再次握手。

问题:那什么时候关闭持久连接呢?(不会一直保留下去的)

HTTP和TCP

TCP属于传输层,HTTP属于应用层,HTTP是要基于TCP连接基础上的。简单的说,TCP就是单纯的建立连接,进行数据传输;而HTTP是用来收发数据,即实际应用上来的(FTP,SMTP等)。

  • TCP是在传输层,它是底层通信协议,定义的是数据传输和连接方式的规范;
  • HTTP是在应用层,它是应用层协议,定义的是数据传输的内容的规范;
  • HTTP协议中的数据是利用TCP协议传输的,所以支持HTTP就一定支持TCP;

这下关于HTTP及相关协议,是不是已经都掌握了呢,其实这才是刚刚入门,请期待我后续的文章吧。