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 编码在概念上的区别:

  • 字符编码是将成字符翻译成二进制编码

  • Base64 编码是将二进制映射到另一种二进制,只是,其映射的结果(二进制)采用特定编码(ASCII)的时候都是可见字符。

    这篇博客base64 - 廖雪峰的官方网站通俗易懂地介绍了 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 编码结果,转化成中文字符。也就是说从最开始的中文字符到实际传输的二进制,实际上经历了两次编码:

  • 从中文字符到 UTF-8 编码对应的 16 进制字符串

  • UTF-8 编码结果(16 进制字符串)对应的再按照 ASCII 编码成的二进制

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

URL 中的编码

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

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

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

  • 网址路径的编码,用的是 UTF-8 编码,例如https://zh.wikipedia.org/wiki/春节最终的 URL 是https://zh.wikipedia.org/wiki/%E6%98%A5%E8%8A%82的 UTF-8 编码分别是E6 98 A5和"E8 8A 82",因此,%E6%98%A5%E8%8A%82就是按照顺序,在每个字节前加上%而得到的。

  • 查询字符串中的编码,用的是 UTF-8 编码。比如我们在地址栏中输入http://www.baidu.com/s?wd=春节,结果页地址为https://www.baidu.com/s?wd=%E6%98%A5%E8%8A%82,可以看到wq参数是%E6%98%A5%E8%8A%82,在百度的索框中输入春节,然后点击百度一下,结果页地址栏的wq参数的结果也是%E6%98%A5%E8%8A%82,通过 Google 搜索也是一样的效果。

    关于 URL 编码 - 阮一峰的网络日志中说字符串中的编码采用的是GB2312编码,但是我本地就是GB2312,以我本地的实际情况为准

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!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中详细解释了每一个值的意思,这里解释几个常见的

  • max-age:可以存在于请求和响应中,先只看其在响应中的意思,其值一般为max-age=N,例如Cache-Control: max-age=604800,表示响应在生成后 N 秒内依然保持新鲜,也就是缓存有效时间。在这个时间内发起一模一样的请求会在缓存中找到这个响应,然后使用缓存中的响应。

  • no-cache,可以存在于请求和响应中,先只看其在响应中的意思,在响应中Cache-Control: no-cache,意识是响应可以缓存到缓存中,但在每次后续的请求想要重用缓存中的响应之前必须与源服务器验证,看这个响应的内容有没有更新,也就是说还是会发起对服务器的连接。如果你想让缓存在重用存储的内容时总是检查内容更新,no-cache是应该使用的指令。它通过要求缓存向源服务器重新验证每个请求来实现这一点。

    注意,no-cache并不意味着“不缓存”。no-cache允许缓存存储响应,但要求它们在重用之前重新验证响应。如果你想要的“不缓存”的含义实际上是“不存储”,那么no-store就是要使用的指令。

  • no-store,可以存在于请求和响应中,先只看其在响应中的意思,例如Cache-Control: no-store,意识是任何类型的缓存 (私有或共享) 都不应存储此响应。

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的值往往有两种:

  • close

    意思是客户端或服务器在 HTTP 请求处理完之后,要关闭当前(TCP)连接。这是HTTP/1.0请求的默认值。

  • 逗号分割的多个 HTTP 消息头,通常只有keep-alive

    意思客户端和服务端在 HTTP 请求处理完之后,应该保持(TCP)连接开启。这是HTTP/1.1请求的默认设置。根据 hop-by-hop 消息头的定义,HTTP 消息如果发给了一个代理,比如 Nginx,那么 Nginx 会删除掉Connection消息头中包含的消息头列表,然后再转发给下一个服务器。

    关于keep-alive消息头,请看Keep-Alive小节

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

  • timeout:指定了一个空闲连接需要保持打开状态的最小时长(以秒为单位)(注意,是空闲连接)。什么链接是空闲连接呢?如果主机没有通过这个链接往外发送消息,也没有通过这个连接接收到任何消息,那这个连接就是空闲的。需要注意的是,如果没有在传输层(也就是 TCP 层)设置 keep-alive TCP message 的话,大于 TCP 层面的超时设置会被忽略
  • max:在连接关闭之前,在此连接上可以发送的请求的最大值。在非管道连接中,max 的配置是无效的,除了 0 以外,这个值是被忽略的,因为需要在紧跟着的响应中发送新一次的请求。在 HTTP 管道连接中,则可以用它来限制管道的使用。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 的区别:

  • HTTP 协议 (七层) 的 Keep-Alive 意图在于连接复用,希望可以短时间内在同一个连接上进行多次请求/响应。核心在于:时间要短,速度要快。

    举个例子,你搞了一个好项目,想让马云爸爸投资,马爸爸说,“我很忙,最多给你 3 分钟”,你需要在这三分钟内把所有的事情都说完。

  • TCP 协议 (四层) 的 KeepAlive 机制意图在于保活、心跳,检测连接错误核心在于:虽然频率低,但是持久。当一个 TCP 连接两端长时间没有数据传输时 (通常默认配置是 2 小时),发送 keepalive 探针,探测链接是否存活。。

    例如,我和厮大聊天,开了语音,之后我们各自做自己的事,一边聊天,有一段时间双方都没有讲话,然后一方开口说话,首先问一句,“老哥,你还在吗?”,巴拉巴拉..。又过了一会,再问,“老哥,你还在吗?”。

差别还是很明显的。

hop-by-hop 消息头

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

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

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

  • Keep-Alive

  • Transfer-Encoding

  • TE

  • Connection

  • Trailer

  • Upgrade

  • Proxy-Authorization

  • Proxy-Authenticate.

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

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

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

1
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


0%