HTTP/1.x

HTTP/1.x

关于 HTTP 协议的具体使用过程,我们在《浏览器生成消息 —— 探索浏览器内部》的 HTTP 的基本思路生成 HTTP 请求消息发送请求后会收到响应 小节已经具体分析过,这里就不重复了

HTTP/1.x 像是一个没有经过充分设计的玩具。

关于 HTTP 协议 1.x 版本的发展和解决的问题,请看《HTTP 协议基础》的 HTTP/1.0 小节和 HTTP/1.1

文本如何编码

参考:

HTTP 消息 - HTTP | MDN

What character encoding should I use for a HTTP header? - Stack Overflow

HTTP/1.x 协议本身的传输使用文本协议,即消息头是以文本的格式传输的,消息体可以是文本,也可以不是文本,消息体的具体类型,请看消息头中的 Content-Type 属性

那消息头中的文本是用什么字符集呢?答案是默认使用 ASCII 字符集

首先我们简单简单说明一下字符集编码跟 Base64 编码在概念上的区别:

如果需要通过 HTTP/1.x 协议传输中文字符,我们一般会对其进行 UTF-8 编码,然后将编码结果字符串通过 HTTP 进行传递,过程是这样的:

第一次把中文字符通过 UTF-8 编码,编码结果是二进制,我们一般通过 16 进制来表示二进制串,比如 这个中文字符的 UTF-8 编码为 11100100 10111000 10100101,对应的 16 进制为 E4B8A5

关于 Unicode 编码,请看《Unicode 编码》

因为 16 进制由 0-9、A-F 这些字符表示,而这些字符都是 ASCII 字符,因此,将字符经过 UTF-8 编码的结果可以直接放到 HTTP 消息里进行传输,因为这些字符都是 ASCII 字符,所以按照 ASCII 编码成二进制,放到网络 I/O 中传输,然后服务端收到这些二进制串之后,直接以 ASCII 编码将其编码成字符串,这些字符串,就是 UTF-8 编码结果用 16 进制表示的字符串。然后,我们可以再将拿到的 UTF-8 编码结果,转化成中文字符。也就是说从最开始的中文字符到实际传输的二进制,实际上经历了两次编码:

虽然,第二次编码 ASCII 是固定的,但是第一次编码却不一定是 UTF-8 编码,在各种不同的情况下会有不同的编码格式。

URL 中的编码

阮一峰大佬的博客:关于 URL 编码 - 阮一峰的网络日志

HTTP 消息中哪个部分最有可能携带非 ASCII 字符呢?其实是 URL,一般来说,URL 只能使用英文字母、阿拉伯数字和某些标点符号,不能使用其他文字和符号(这是网络标准 RFC 1738 中规定的),

这意味着,如果 URL 中有汉字,就必须编码后使用。但是麻烦的是,RFC1738 没有规定具体的编码方法,而是交给应用程序(浏览器)自己决定。这导致 "URL 编码 " 成为了一个混乱的领域。而当我们不确定这第一次编码的编码方式的时候,我们在后端接收 URL 参数的时候就很容易因为没有采用与编码方式相同的编码来解码而导致乱码。阮一峰大佬的这篇博客:关于 URL 编码 - 阮一峰的网络日志 详细分析了各种情况下浏览器采用的默认编码方式。简单总结如下:

前面说的是直接输入网址的情况,但是更常见的情况是,在已打开的网页上,直接用 Get 或 Post 方法发出 HTTP 请求。为了实验,我们创建一个简单的 HTML 页面,发起一个 Ajax 请求试试。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

</body>
<script>
    <!-- 发起一个Ajax请求-->
    var xhr = new XMLHttpRequest();
    let queryWord = "春节";
    // 会跨域,但是没关系,我们主要看 URL 编码
    xhr.open('get', 'https://www.baidu.com/s?wd=' + queryWord, true);
    xhr.send();
    xhr.onreadystatechange = function () {
        if (xhr.readyState == 4 && xhr.status == 200) {
            console.log(xhr.responseText);
        }
    }
</script>
</html>

此时,实际发起的请求是 https://www.baidu.com/s?wd=%E6%98%A5%E8%8A%82,也就是说此时 URL 中的中文是 UTF-8 编码,然后我们将 <meta>charset 属性改成 GB2312,此时发起的 URL 为 https://www.baidu.com/s?wd=%B4%BA%BD%DA,URL 中的中文是就是 GB2312 编码,也就是说,Ajax 发起的 GET 和 POST 方法的编码,用的是网页的编码

有没有办法,能够保证客户端只用一种编码方法向服务器发出请求?答案是有的,就是使用 Javascript 先对 URL 编码,然后再向服务器提交,即主动控制这个编码过程来避免被动处理时的多种情况,不要给浏览器插手的机会。因为 Javascript 的输出总是一致的,所以就保证了服务器得到的数据是格式统一的。

方法很简单,通过 encodeURI()encodeURIComponent() 这两个方法手动编码。编码结果为百分号编码(Url Encoding,also known as percent-encoding),是因为它的编码方式非常简单,使用 % 百分号加上两位的字符——0123456789ABCDEF——代表一个字节的十六进制形式。

在服务端,如果也需要设置编码,以正确解析 HTTP 协议中的字符,比如我们可以在 Tomcat 里的 server.xml 里配置 URIEncoding 中配置编码格式。

总结

HTTP/1.x 协议默认只能传递 ASCII 编码的文本,ASCII 表示不了的字符,则需要通过其他方式转换成 ASCII 字符,然后再交给 HTTP/1.x 协议去传输,这体现了 HTTP/1.x 设计的简单,以及面对复杂场景的不方便,如果只采用二进制传输,就不会有这个问题,因此 HTTP/2.0 应运而生。

常用 HTTP 头部

HTTP headers - HTTP | MDN 列举出了 HTTP 协议的所有的相关请求头

Date

Date - HTTP | MDN

Date 是一个通用的 HTTP 报头,可以存在请求和响应中,包含消息发出的日期和时间。

Age

Age - HTTP | MDN

Age 这个消息头只能出现在 HTTP 响应中。

Age 这个消息头包含对象(HTTP 响应)在代理缓存中的保存时间 (以秒为单位)。

Age 头的值通常接近于零。如果它是 Age: 0,这表示这个响应可能是从原始的服务器获取的,如果不是 0,则表示是从缓存中的获取的,而且其值为代理的当前日期与 HTTP 响应中包含的 Date 消息头之间的差值,也就是消息产生的时间和当前时间的差值,也就是这个消息在缓存中保存的时长。

Cache-Control

Cache-Control - HTTP | MDN

Cache-Control HTTP 报头字段可以存在请求和响应中,包含请求和响应中的指令 (指令),这些指令控制浏览器的共享缓存中的缓存,所有的值如下

Request Response
max-age max-age
max-stale -
min-fresh -
- s-maxage
no-cache no-cache
no-store no-store
no-transform no-transform
only-if-cached -
- must-revalidate
- proxy-revalidate
- must-understand
- private
- public
- immutable
- stale-while-revalidate
stale-if-error stale-if-error

Cache-Control 的值,也就是缓存指令,不区分大小写。但是,建议使用小写字母,大写的有的浏览器不支持。

这个文档 Directives 中详细解释了每一个值的意思,这里解释几个常见的

Connection

参考博客:Connection - HTTP | MDN

Connection 是一个通用消息头,可以存在于请求中也可以存在于响应中,控制当前传输完成后网络连接(指的是 TCP)是否保持打开状态。如果 Connection 的值是 keep-alive,则连接是持久的,不会关闭,允许后续对同一服务器的请求复用这个连接(一般指 TCP)。

注意,跟连接指定相关的消息头字段,比如 ConnectionKeep-AliveHTTP/2.xHTTP/3.x 中是禁止的。Chrome 和 Firefox 都会忽略 HTTP/2 的响应中的相关消息头。

此外还要注意,Connection 是一个 hop-by-hop 消息头,

关于什么是 hop-by-hop 消息头,请看 hop-by-hop 消息头 小节

Connection 的值往往有两种:

Keep-Alive

Keep-Alive - HTTP | MDN

Keep-Alive 消息头可以用在请求中也可以用在响应中,这个请求头的作用是允许发送方提示接收方如何复用(TCP)连接,有两种方式,来设置超时和最大请求量。注意,

Keep-Alive 消息头需要配合 Connection 消息头才能起作用,我们需要将 Connection 设置为 keep-alive 才能让 Keep-Alive 消息头起作用。

注意,跟连接指定相关的消息头字段,比如 ConnectionKeep-AliveHTTP/2.xHTTP/3.x 中是禁止的。Chrome 和 Firefox 都会忽略 HTTP/2 的响应中的相关消息头。

Keep-Alive 消息头的值是一系列用逗号隔开的参数,每一个参数由一个标识符和一个值构成,并使用等号 ('=') 隔开。有超时时间和复用次数两个标识符:

一个包含 Keep-Alive 消息头的 HTTP 响应的例子

HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Thu, 11 Aug 2016 15:23:13 GMT
Keep-Alive: timeout=5, max=1000
Last-Modified: Mon, 25 Jul 2016 04:32:39 GMT
Server: Apache

(body)

具体应用

参考博客:Http——Keep-Alive 机制 - 曹伟雄 - 博客园

keep-alive 技术创建的目的,就是能在多次 HTTP 之间重用同一个 TCP 连接,从而减少创建/关闭多个 TCP 连接的开销(包括响应时间、CPU 资源、减少拥堵等)

参考如下示意图:

然而天下没有免费的午餐,如果客户端在接收完所有的信息之后还没有关闭连接则服务端相应的资源还在被占用(尽管已经没用了)。例如 Tomcat 的 BIO 实现中,未关闭的连接会占用对应的处理线程,如果一个长连接实际上已经处理完毕,但关闭的超时时间未到,则该线程会一直被占用(使用 NIO 的实现没有该问题)。

显然,如果客户端和服务端的确需要进行多次通信,则开启 keep-alive 是更好的选择,例如在微服务架构中,通常微服务的使用方和提供方会长期有交流。

在一些 TPS/QPS 很高的 REST 服务中,如果使用的是短连接(即没有开启 keep-alive),则很可能发生客户端端口被占满的情形。这是由于短时间内会创建大量 TCP 连接,而在 TCP 四次挥手结束后,客户端的端口会处于 TIME_WAIT 一段时间 (2*MSL),这期间端口不会被释放,从而导致端口被占满。这种情况下最好使用长连接。

一个最简单的场景就是通过 Jmeter 压测 Rest 接口的时候,如果不勾选 keep-alive,很快就会将端口用完的情况,具体请看《JMeter 实践》

TCP KeepAlive 机制

参考:TCP KeepAlive 机制理解与实践小结 - huey_x - 博客园

HTTP 协议有 keep-alive 消息头,TCP 协议也有 KeepAlive 机制,注意区分,不要混淆。

简单介绍一下 TCP 协议的 KeepAlive 机制:

TCP 长连接下,客户端和服务器若长时间无数据交互情况下,若一方出现异常情况关闭连接,抑或是连接中间路由出于某种机制断开连接,而此时另一方不知道对方状态而一直维护连接,浪费系统资源的同时,也会引起下次数据交互时出错。

为了解决此问题,引入了 TCP KeepAlive 机制(并非标准规范,但操作系统一旦实现,默认情况下须为关闭,可以被上层应用开启和关闭)。其基本原理是在此机制开启时,当长连接无数据交互一定时间间隔时,连接的一方会向对方发送保活探测包,如连接仍正常,对方将对此确认回应。

HTTP 中的 keep-alive 消息头跟 TCP 协议中 keep-alive 的区别:

差别还是很明显的。

hop-by-hop 消息头

参考博客:hop-by-hop headers - HackTricks

HTTP 报头/消息头,总共分两类:hop-by-hop 消息头(跳跳消息头)和 end-to-end 消息头(端到端消息头),端到端消息头,会发送给请求或响应的最终接收者,并在每一次转发的时候都带上,但是跳跳消息头相反,跳跳消息头(hop-by-hop headers)是设计为由当前处理请求的代理处理和使用的消息头,不会转发到下一跳。

根据 RFC 2616,在 HTTP/1.1 规范中,默认将以下几个消息头视为跳跳消息头:

当在请求中遇到这些消息头时,符合规范的代理比如 Nginx 应该处理或按照这些标头所指示的内容执行相应的操作,而且不会将它们转发到下一跳。

比较常见的例子就是当我们在用 Nginx 转发 WebSocket 请求的时候,需要手动添加 ConnectionUpgrade 这两个请求头

除了这些默认的跳跳消息头,你可以通过将消息头添加到 Connection 消息头中来将其声明为一个跳跳消息头,除非你是故意为之,否则 Connection 消息头中不能包含端到端消息头,比如 Cache-Control,否则会影响正常功能,一般都是自定义消息头。

Connection: close, X-Foo, X-Bar

关于 Connection 消息头的作用,请看 Connection - HTTP | MDN官方标准文档

hop-by-hop 消息头在某一个场景下很有用处,比如绕过 IP 白名单通常,代理会在 X-Forwarded-For 报头中添加实际发起请求的客户端的 IP,这样下一跳就会知道请求实际来自哪里。但是,如果攻击者发送一个请求,其 Connection 值为 Connection: close, X-Forwarded-For,那么第一个代理在转发这个请求的时候,就会删除 X-Forward-For 报头。最后,服务端将不知道是谁发送了请求,并可能认为发送请求的是最后一个代理,于是在这种情况下,攻击者可能能够访问受 IP 白名单保护的资源。

此外,绕过防御功能也很有用。例如,如果缺少某个报头意味着请求不应由 WAF 处理,则可以使用此技术绕过 WAF。

WAF: Web Application Firewall

Forwarded / X-Forwarded-* 转发相关消息头

在现代网络架构中,用户发起 HTTP 请求之后,往往并不是由服务器直接处理,而是先经过各种反向代理服务或者负载均衡服务进行转发,经过层层转发之后才会到达最终实际处理该请求的服务器,如果是中间的代理服务是四层代理(TCP 层代理,并不修改 HTTP 层的内容),那么经过代理和不经过代理对最终处理请求的服务器来说也没什么差别(服务器最终只处理 HTTP 层的数据),但是如果是七层代理,即代理会拿到最里层的消息体(即 HTTP 层的消息体),转发的时候,会重新构造一个 HTTP 消息,如果是这样的话,最终处理该请求的服务器拿到的 HTTP 消息就跟客户端最开始发出的 HTTP 消息就不一样了,比如请求的 URL,请求的端口,协议等,其实实际生产过程各种,大部分的代理都是七层代理,比如 Nginx,所以,一个请求经过 Nginx 代理之后再发给上游服务七,在服务器看来,Nginx 就是发起这个请求的客户端,服务端是无法判断出 Nginx 是请求的原始发起者,还是中间的转发代理,因此,为了让服务端能够知道请求的原始发起者发起的请求的各方便的信息,我们添加了以下这些 HTTP 消息头。

这些消息头最开始有代理软件开发商自定义使用,后来使用者多了之后,逐步演变为实际的标准,最终 MDN 也开始将其纳入 HTTP Header 标准中。

HTTP Headers 手册

TODO


以上三个消息头其实可以用一个消息头来代替,上面几个都可以合并成 Forwarded,用着一个表示即可

Forwarded

现在应该用标准的 Forwarded