一起来学习一下HTTP协议吧。
在学习本篇文章之前,先来看一个实际的问题,当用户在浏览器地址栏里输入一个URL,按回车后发生了什么?
- 通过DNS协议解析出URL资源所在的IP地址;
- 建立TCP连接;
- 发送HTTP请求;
- 服务器进行HTTP应答;
- 浏览器接收到HTTP响应后,解析渲染;
- 连接结束;
问题:
- 为什么需要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个字节,由四个字段组成,每个字段的长度都是两个字节。各字段意义如下:
- 源端口: 源端口号,在需要给对方回信时使用。不需要是可全用0;
- 目的端口号: 这在终点交付报文时必须使用;
- 长度: 用户数据报UDP的长度,最小为8(仅首部);
- 校验和: 用于校验用户数据报在传输过程是否出错,出错则丢弃该报文;
伪首部向上,向下不传递,只做校验和的时候用到。
TCP协议
传输控制协议 TCP(Transmission Control Protocol): 面向连接;每一个TCP连接只能是点对点的(一对一);提供可靠交付服务;提供全双工通信;面向字节流。应用程序和TCP的交互是一次一个数据块(大小不等),但TCP把应用程序看成是一连串的无结构的字节流。TCP有一个缓冲,当应该程序传送的数据块太长,TCP就可以把它划分短一些再传送。
三次握手
客户端想要发送数据给服务端,在发送实际的数据之前,需要先在两端之间建⽴连接,数据发完以后也需要将该连接关闭。建⽴连接的过程就是我们常说的 TCP 三次握⼿:
图为 TCP 的三次握⼿
如上图示,建⽴ TCP 连接需要这三个步骤:
-
客户端发送⼀个 SYN 告诉服务端它想建议⼀个连接,SYN 代表 Sychronize,意为同步;
-
服务端收到 SYN 后,返回⼀个 SYN-ACK,ACK 代表 Acknowledge,意为确认;
-
客户端最后发送⼀个 ACK,服务端收到时,标识着三次握⼿的完成,这之后就可以愉快地传输数据了;
由此可⻅,三次握⼿的时间消耗为⾄少⼀个 RTT(Round Trip Time),即⽹路上⾄少⼀个来回,⼤部分时候这意味着⼏百毫秒的时间,如果服务器在国外或者客户端⽹络不好,RTT 超过⼀秒也完全是有可能的。这也解释了为什么我们在谈前端优化的时候,经常提到要减少建⽴ TCP 连接。
- 雪碧图(精灵图)这种合并图⽚以减少 HTTP 请求的技术,本质上是为了减少建⽴多个 TCP 连接带来的性能损耗;
- HTTP/2 ⾥,由于对单个 TCP 连接的多路复⽤,⽆需建⽴多个 TCP 连接;
四次挥手
当数据传送完毕,断开连接就需要进行TCP的四次挥手:
- 第一次挥手,客户端设置seq和 ACK ,向服务器发送一个 FIN(终结)报文段。此时,客户端进入 FIN_WAIT_1
状态,表示客户端没有数据要发送给服务端了。
- 第二次挥手,服务端收到了客户端发送的 FIN 报文段,向客户端回了一个 ACK 报文段。
- 第三次挥手,服务端向客户端发送FIN 报文段,请求关闭连接,同时服务端进入 LAST_ACK 状态。
- 第四次挥手,客户端收到服务端发送的 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的特点
- 无状态:协议对客户端没有状态存储,对事物处理没有“记忆”能力;
- 无连接:HTTP/1.1之前,由于无状态特点,每次请求需要通过TCP三次握手四次挥手,和服务器重新建立连接。比如某个客户机在短时间多次请求同一个资源,服务器并不能区别是否已经响应过用户的请求,所以每次需要重新响应请求,需要耗费不必要的时间和流量。
- 基于请求和响应:基本的特性,由客户端发起请求,服务端响应。
- 简单快速、灵活。
- 通信使用明文、请求和响应不会对通信方进行确认、无法保护数据的完整性。
为什么需要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及相关协议,是不是已经都掌握了呢,其实这才是刚刚入门,请期待我后续的文章吧。