作为一名前端程序员,我们基本每天都要接触到http协议,但是你真的了解http的具体细节以及底层原理吗?
这篇文章可以帮助大家建立一个完整的 HTTP 知识体系,并达到一定的深度,从而从容地应对各种http灵魂之问,同时提升自己作为一个 web 开发的专业素养。
01.http的报文结构
对于HTTP而言,它的报文结构为header + body结构,具体一点就是
起始行 + 头部 + 空行 + 实体
由于 http 请求报文和响应报文是有一定区别的,因此我们分开介绍。
起始行
对于请求报文来说,起始行类似下面这样:
GET /home HTTP/1.1
也就是方法 + 路径 + http版本。
对于响应报文来说,起始行一般长这样:
HTTP/1.1 200 OK
响应报文的起始行也叫做状态行。由http版本、状态码和原因三部分组成。
值得注意的是,在起始行中,每两个部分之间用空格隔开,最后一个部分后面接一个换行,严格遵循ABNF语法规范,并且这些头部字段遵循如下格式
- 字段名不区分大小写
- 字段名不允许出现空格,不可以出现下划线
_ - 字段名后面必须紧接着
:
http头部
请求头头部图解示例
响应头头部图解示例
空行
用来分隔头部和实体,如果在头部中间故意放一个空行,那么空行后面的部分都会被当做实体
实体
就是具体的数据,也就是body部分。请求报文对应请求体, 响应报文对应响应体。
02.如何理解HTTP的请求方法
有哪些请求方法?
http/1.1规定了以下请求方法(注意,都是大写):
- GET: 通常用来获取资源
- HEAD: 获取资源的元信息
- POST: 提交数据,即上传数据
- PUT: 修改数据
- DELETE: 删除资源
- CONNECT: 建立连接隧道,用于代理服务器
- OPTIONS: 列出可对资源实行的请求方法,用来跨域请求(一般用于预检请求)。
- TRACE: 追踪请求-响应的传输路径
GET 和 POST 有什么区别?
- 从缓存的角度,GET 请求会被浏览器主动缓存下来,留下历史记录,而 POST 默认不会。
- 从编码的角度,GET 只能进行 URL 编码,只能接收 ASCII 字符,而 POST 没有限制。
- 从参数的角度,GET 一般放在 URL 中,因此不安全,POST 放在请求体中,更适合传输敏感信息。
- 从幂等性的角度,
GET是幂等的,而POST不是。(幂等表示执行相同的操作,结果也是相同的) - 从TCP的角度,GET 请求会把请求报文一次性发出去,而 POST 会分为两个 TCP 数据包,首先发 header 部分,如果服务器响应 100(continue), 然后发 body 部分。(火狐浏览器除外,它的 POST 请求只发一个 TCP 包)
03.如何理解URI统一资源标识符(Uniform Resource Identifier,URI)
首先它的作用很简单,就是区分互联网上不同的资源,但是,它并不是我们常说的网址,网址指的是URL,实际上URI包含了URN和URL两个部分,由于 URL 过于普及,我们就默认将 URI 视为 URL 了。
URI的结构
可能你会有疑问,好像跟平时见到的不太一样啊!先别急,我们来一一拆解。
scheme 表示协议名,比如http, https, file等等。后面必须和://连在一起。
user:passwd 表示登录主机时的用户信息,很不安全,不推荐使用,也不常用。
host:port表示主机名和端口。
path表示请求路径,标记资源所在位置。
query表示查询参数,为key=val这种形式,多个键值对之间用&隔开。
fragment表示 URI 所定位的资源内的一个锚点,浏览器可以根据这个锚点跳转到对应的位置。
举个例子:
https://www.baidu.com/s?wd=HTTP&rsv_spt=1
这个 URI 中,https即scheme部分,www.baidu.com为host:port部分(注意,http 和 https 的默认端口分别为80、443),/s为path部分,而wd=HTTP&rsv_spt=1就是query部分。
URI 编码
URI 只能使用ASCII, ASCII 之外的字符是不支持显示的,而且还有一部分符号是界定符,如果不加以处理就会导致解析出错。
因此,URI 引入了编码机制,将所有非 ASCII 码字符和界定符转为十六进制字节值,然后在前面加个%。
如,空格被转义成了%20,龙哥被转义成了%E9%BE%99%E5%93%A5。
04.如何理解HTTP状态码
RFC 规定 HTTP 的状态码为三位数,被分为五类:
- 1xx: 表示目前是协议处理的中间状态,还需要后续操作。
- 2xx: 表示成功状态。
- 3xx: 重定向状态,资源位置发生变动,需要重新请求。
- 4xx: 请求报文有误。
- 5xx: 服务器端发生错误。
接下来就一一分析这里面具体的状态码。
1xx
101 Switching Protocols。在HTTP升级为WebSocket的时候,如果服务器同意变更,就会发送状态码 101。
2xx
200 OK是见得最多的成功状态码。通常在响应体中放有数据。
204 No Content含义与 200 相同,但响应头后没有 body 数据。
206 Partial Content顾名思义,表示部分内容,它的使用场景为 HTTP 分块下载和断点续传,当然也会带上相应的响应头字段Content-Range。
3xx
301 Moved Permanently即永久重定向,对应着302 Move Temporarily,即临时重定向。
比如你的网站从 HTTP 升级到了 HTTPS 了,以前的站点再也不用了,应当返回301,这个时候浏览器默认会做缓存优化,在第二次访问的时候自动访问重定向的那个地址。
而如果只是暂时不可用,那么直接返回302即可,和301不同的是,浏览器并不会做缓存优化。
304 Not Modified: 当协商缓存命中时会返回这个状态码。详见浏览器缓存
4xx
400 Bad Request: 开发者经常看到一头雾水,只是笼统地提示了一下错误,并不知道哪里出错了。
401 Unauthorized: 当前请求需要用户验证,比如登录时用户名或密码错误,则返回此状态码。
403 Forbidden: 这实际上并不是请求报文出错,而是服务器禁止访问,原因有很多,比如法律禁止、信息敏感。
404 Not Found: 资源未找到,表示没在服务器上找到相应的资源。
405 Method Not Allowed: 请求方法不被服务器端允许。
406 Not Acceptable: 资源无法满足客户端的条件。
408 Request Timeout: 服务器等待了太长时间。
409 Conflict: 多个请求发生了冲突。
413 Request Entity Too Large: 请求体的数据过大。
414 Request-URI Too Long: 请求行里的 URI 太大。
429 Too Many Request: 客户端发送的请求过多。
431 Request Header Fields Too Large请求头的字段内容太大。
5xx
500 Internal Server Error: 仅仅告诉你服务器出错了,出了啥错咱也不知道。
501 Not Implemented: 表示客户端请求的功能还不支持。
502 Bad Gateway: 服务器自身是正常的,但访问的时候出错了,啥错误咱也不知道。
503 Service Unavailable: 表示服务器当前很忙,暂时无法响应服务。
04.HTTP的特点及缺点
HTTP 特点
HTTP 的特点概括如下:
- 灵活可扩展,主要体现在两个方面。一个是语义上的自由,只规定了基本格式,比如空格分隔单词,换行分隔字段,其他的各个部分都没有严格的语法限制。另一个是传输形式的多样性,不仅仅可以传输文本,还能传输图片、视频等任意数据,非常方便。
- 可靠传输。HTTP 基于 TCP/IP,因此把这一特性继承了下来。
- 请求-应答。也就是
一发一收、有来有回, 当然这个请求方和应答方不单单指客户端和服务器之间,如果某台服务器作为代理来连接后端的服务端,那么这台服务器也会扮演请求方的角色。 - 无状态。这里的状态是指通信过程的上下文信息,而每次 http 请求都是独立、无关的,默认不保留状态信息。
HTTP 缺点
无状态
所谓的优点和缺点是要分场景来看的,对于 HTTP 而言,最具争议的地方在于它的无状态。
在需要长连接的场景中,需要保存大量的上下文信息,以免传输大量重复的信息,那么这时候无状态就是 http 的缺点了。
但与此同时,另外一些应用仅仅只是为了获取一些数据,不需要保存连接上下文信息,无状态反而减少了网络开销,成为了 http 的优点。
明文传输
即协议里的报文(主要指的是头部)不使用二进制数据,而是文本形式。 这使得使用HTTP传输的html文件很容易被第三方XSS攻击,比如黑客可以截取并分析你的网络报文,然后修改报文,在html文件中插入一个script或img等标签来注入恶意代码,这时用户是很难察觉到的。
队头阻塞问题
当 http 开启长连接时,共用一个 TCP 连接,同一时刻只能处理一个请求,那么当前请求耗时过长的情况下,其它的请求只能处于阻塞状态,也就是著名的队头阻塞问题。
06.HTTP的Accept字段
对于Accept系列字段的介绍分为四个部分: 数据格式、压缩方式、支持语言和字符集。
数据格式
上一节谈到 HTTP 灵活的特性,它支持非常多的数据格式,那么这么多格式的数据一起到达客户端,客户端怎么知道它的格式呢?答案其实很简单,其实,HTTP 从MIME type中取了一部分来标记报文 body 部分的数据类型,这些类型体现在Content-Type这个字段,当然这是针对于发送端而言,接收端想要收到特定类型的数据,也可以用Accept字段。
具体而言,这两个字段的取值可以分为下面几类:
- text: text/html, text/plain, text/css 等
- image: image/gif, image/jpeg, image/png 等
- audio/video: audio/mpeg, video/mp4 等
- application: application/json, application/javascript, application/pdf, application/octet-stream
压缩方式
当然一般这些数据都是会进行编码压缩的,采取什么样的压缩方式就体现在了发送方的Content-Encoding字段上, 同样,接收什么样的压缩方式体现在了接受方的Accept-Encoding字段上。这个字段的取值有下面几种:
- gzip: 当今最流行的压缩格式
- deflate: 另外一种著名的压缩格式
- br: 一种专门为 HTTP 发明的压缩算法
// 发送端
Content-Encoding: gzip
// 接收端
Accept-Encoding: gzip
支持语言
对于发送方而言,还有一个Content-Language字段,在需要实现国际化的方案当中,可以用来指定支持的语言,在接受方对应的字段为Accept-Language。如:
// 发送端
Content-Language: zh-CN, zh, en
// 接收端
Accept-Language: zh-CN, zh, en
字符集
最后是一个比较特殊的字段, 在接收端对应为Accept-Charset,指定可以接受的字符集,而在发送端并没有对应的Content-Charset, 而是直接放在了Content-Type中,以charset属性指定。如:
// 发送端
Content-Type: text/html; charset=utf-8
// 接收端
Accept-Charset: charset=utf-8
最后以一张图来总结一下吧:
07.对于定长和不定长的数据,HTTP 是怎么传输的?
定长包体
对于定长包体而言,发送端在传输的时候一般会带上 Content-Length, 来指明包体的长度。
我们用一个nodejs服务器来模拟一下:
const http = require('http');
const server = http.createServer();
server.on('request', (req, res) => {
if(req.url === '/') {
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Length', 10);
res.write("helloworld");
}
})
server.listen(8080, () => {
console.log("成功启动");
})
启动后访问: localhost:8080。
浏览器中显示如下:
helloworld
这是长度正确的情况,那不正确的情况是如何处理的呢?
我们试着把这个长度设置的小一些:
res.setHeader('Content-Length', 8);
重启服务,再次访问,现在浏览器中内容如下:
hellowor
那后面的ld哪里去了呢?实际上在 http 的响应体中直接被截去了。
然后我们试着将这个长度设置得大一些:
res.setHeader('Content-Length', 12);
此时浏览器显示如下:
直接无法显示了。可以看到
Content-Length对于 http 传输过程起到了十分关键的作用,如果设置不当可以直接导致传输失败。
不定长包体
上述是针对于定长包体,那么对于不定长包体而言是如何传输的呢?
这里就必须介绍另外一个 http 头部字段了:
Transfer-Encoding: chunked
表示分块传输数据,设置这个字段后会自动产生两个效果:
- Content-Length 字段会被忽略
- 基于长连接持续推送动态内容
我们依然以一个实际的例子来模拟分块传输,nodejs 程序如下:
const http = require('http');
const server = http.createServer();
server.on('request', (req, res) => {
if(req.url === '/') {
res.setHeader('Content-Type', 'text/html; charset=utf8');
res.setHeader('Content-Length', 10);
res.setHeader('Transfer-Encoding', 'chunked');
res.write("<p>来啦</p>");
setTimeout(() => {
res.write("第一次传输<br/>");
}, 1000);
setTimeout(() => {
res.write("第二次传输");
res.end()
}, 2000);
}
})
server.listen(8009, () => {
console.log("成功启动");
})
访问效果入下:
用 telnet 抓到的响应如下:
注意,Connection: keep-alive及之前的为响应行和响应头,后面的内容为响应体,这两部分用换行符隔开。
响应体的结构比较有意思,如下所示:
chunk长度(16进制的数)
第一个chunk的内容
chunk长度(16进制的数)
第二个chunk的内容
......
0
最后是留有有一个空行的,这一点请大家注意。
以上便是 http 对于定长数据和不定长数据的传输方式。
08.HTTP之cookie字段
Cookie 简介
前面说到了 HTTP 是一个无状态的协议,每次 http 请求都是独立、无关的,默认不需要保留状态信息。但有时候需要保存一些状态,怎么办呢?
HTTP 为此引入了 Cookie。Cookie 本质上就是浏览器里面存储的一个很小的文本文件,内部以键值对的方式来存储。向同一个域名下发送请求,都会携带相同的 Cookie,服务器拿到 Cookie 进行解析,便能拿到客户端的状态。而服务端可以通过响应头中的Set-Cookie字段来对客户端写入Cookie。举例如下:
// 请求头
Cookie: a=xxx;b=xxx
// 响应头
Set-Cookie: a=xxx
set-Cookie: b=xxx
Cookie 属性
生存周期
Cookie 的有效期可以通过Expires和Max-Age两个属性来设置。
- Expires即
过期时间,它指的是一个时间点 - Max-Age用的是一段时间间隔,单位是秒,从浏览器收到报文开始计算。
若 Cookie 过期,则这个 Cookie 会被删除,并不会发送给服务端。
作用域
关于作用域也有两个属性: Domain和path, 它们分别给 Cookie 绑定了域名和路径,在发送请求之前,发现域名或者路径和这两个属性不匹配,那么就不会带上 Cookie。如果路径是/表示域名下的任意路径都允许使用 Cookie。
安全相关
如果带上Secure,说明只能通过 HTTPS 传输 cookie。
如果 cookie 字段带上HttpOnly,那么说明只能通过 HTTP 协议传输,不能通过 JS 访问,这也是预防 XSS 攻击的重要手段。
相应的,对于 CSRF 攻击的预防,也有SameSite属性。
SameSite可以设置为三个值,Strict、Lax和None。
a. 在Strict模式下,浏览器完全禁止第三方请求携带Cookie。比如请求sanyuan.com网站只能在sanyuan.com域名当中请求才能携带 Cookie,在其他网站请求都不能。
b. 在Lax模式,就宽松一点了,但是只能在 get 方法提交表单况或者a 标签发送 get 请求的情况下可以携带 Cookie,其他情况均不能。
c. 在None模式下,也就是默认模式,请求会自动携带上 Cookie。
Cookie 的缺点
- 容量缺陷。Cookie 的体积上限只有
4KB,只能用来存储少量的信息。 - 性能缺陷。Cookie 紧跟域名,不管域名下面的某一个地址需不需要这个 Cookie ,请求都会携带上完整的 Cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容。但可以通过
Domain和Path指定作用域来解决。 - 安全缺陷。由于 Cookie 以纯文本的形式在浏览器和服务器中传递,很容易被非法用户截获,然后进行一系列的篡改,在 Cookie 的有效期内重新发送给服务器,这是相当危险的。另外,在
HttpOnly为 false 的情况下,Cookie 信息能直接通过 JS 脚本来读取。
09.HTTP/2 有哪些改进?
对于 HTTP/2 而言,它对于性能的提升主要在于两点:
- 头部压缩
- 多路复用
当然还有一些颠覆性的功能实现:
- 设置请求优先级
- 服务器推送
这些重大的提升本质上也是为了解决 HTTP 本身的问题而产生的。接下来我们来看看 HTTP/2 解决了哪些问题,以及解决方式具体是如何的。
头部压缩
在 HTTP/1.1 及之前的时代,请求体一般会有响应的压缩编码过程,通过Content-Encoding头部字段来指定,但你有没有想过头部字段本身的压缩呢?当请求字段非常复杂的时候,尤其对于 GET 请求,请求报文几乎全是请求头,这个时候还是存在非常大的优化空间的。HTTP/2 针对头部字段,也采用了对应的压缩算法——HPACK,对请求头进行压缩。
HPACK 算法是专门为 HTTP/2 服务的,它主要的亮点有两个:
- 首先是在服务器和客户端之间建立哈希表,将用到的字段存放在这张表中,那么在传输的时候对于之前出现过的值,只需要把索引(比如0,1,2,...)传给对方即可,对方拿到索引查表就行了。这种传索引的方式,可以说让请求头字段得到极大程度的精简和复用。
HTTP/2 当中废除了起始行的概念,将起始行中的请求方法、URI、状态码转换成了头字段,不过这些字段都有一个":"前缀,用来和其它请求头区分开。
- 其次是对于整数和字符串进行哈夫曼编码,哈夫曼编码的原理就是先将所有出现的字符建立一张索引表,然后让出现次数多的字符对应的索引尽可能短,传输的时候也是传输这样的索引序列,可以达到非常高的压缩率。
多路复用
HTTP 队头阻塞
我们之前讨论了 HTTP 队头阻塞的问题,其根本原因在于HTTP 基于请求-响应的模型,在同一个 TCP 长连接中,前面的请求没有得到响应,后面的请求就会被阻塞。
后面我们又讨论到用并发连接和域名分片的方式来解决这个问题,但这并没有真正从 HTTP 本身的层面解决问题,只是增加了 TCP 连接,分摊风险而已。而且这么做也有弊端,多条 TCP 连接会竞争有限的带宽,让真正优先级高的请求不能优先处理。
而 HTTP/2 便从 HTTP 协议本身解决了队头阻塞问题。注意,这里并不是指的TCP队头阻塞,而是HTTP队头阻塞,两者并不是一回事。TCP 的队头阻塞是在数据包层面,单位是数据包,前一个报文没有收到便不会将后面收到的报文上传给 HTTP,而HTTP 的队头阻塞是在 HTTP 请求-响应层面,前一个请求没处理完,后面的请求就要阻塞住。两者所在的层次不一样。
那么 HTTP/2 如何来解决所谓的队头阻塞呢?
二进制分帧
首先,HTTP/2 认为明文传输对机器而言太麻烦了,不方便计算机的解析,因为对于文本而言会有多义性的字符,比如回车换行到底是内容还是分隔符,在内部需要用到状态机去识别,效率比较低。于是 HTTP/2 干脆把报文全部换成二进制格式,全部传输01串,方便了机器的解析。
原来Headers + Body的报文格式如今被拆分成了一个个二进制的帧,用Headers帧存放头部字段,Data帧存放请求体数据。分帧之后,服务器看到的不再是一个个完整的 HTTP 请求报文,而是一堆乱序的二进制帧。这些二进制帧不存在先后关系,因此也就不会排队等待,也就没有了 HTTP 的队头阻塞问题。
通信双方都可以给对方发送二进制帧,这种二进制帧的双向传输的序列,也叫做流(Stream)。HTTP/2 用流来在一个 TCP 连接上来进行多个数据帧的通信,这就是多路复用的概念。
可能你会有一个疑问,既然是乱序首发,那最后如何来处理这些乱序的数据帧呢?
首先要声明的是,所谓的乱序,指的是不同 ID 的 Stream 是乱序的,但同一个 Stream ID 的帧一定是按顺序传输的。二进制帧到达后对方会将 Stream ID 相同的二进制帧组装成完整的请求报文和响应报文。当然,在二进制帧当中还有其他的一些字段,实现了优先级和流量控制等功能,我们放到下一节再来介绍。
服务器推送
另外值得一说的是 HTTP/2 的服务器推送(Server Push)。在 HTTP/2 当中,服务器已经不再是完全被动地接收请求,响应请求,它也能新建 stream 来给客户端发送消息,当 TCP 连接建立之后,比如浏览器请求一个 HTML 文件,服务器就可以在返回 HTML 的基础上,将 HTML 中引用到的其他资源文件一起返回给客户端,减少客户端的等待。
总结
当然,HTTP/2 新增那么多的特性,是不是 HTTP 的语法要重新学呢?不需要,HTTP/2 完全兼容之前 HTTP 的语法和语义,如请求头、URI、状态码、头部字段都没有改变,完全不用担心。同时,在安全方面,HTTP 也支持 TLS,并且现在主流的浏览器都公开只支持加密的 HTTP/2, 因此你现在能看到的 HTTP/2 也基本上都是跑在 TLS 上面的了。最后放一张分层图给大家参考: