HTTP 协议基本工作流程,也就是“请求——应答”,“一发一收”的模式。
HTTP 的工作模式是由于
TCP/IP
协议负责底层的具体传输工作,HTTP 协议基本上不用操心。单从这一点上来看,所谓的“超文本传输协议”其实并不怎么管“传输”的事情,有点“名不副实”。
HTTP 协议的核心部分是它传输的报文内容。
HTTP 协议在规范文档里详细定义了报文的格式,规定了组成部分,解析规则,还有处理策略,所以可以在 TCP/IP 层之上实现更灵活丰富的功能,例如:
TCP 报文在实际要传输的数据之前附加了一个 20 字节的头部数据,存储 TCP 协议必须的额外信息,例如:
有了这个附加的 TCP 头,数据包才能够正确传输,到了目的地后把头部去掉,就可以拿到真正的数据。
TCP/UDP
类似,同样也需要在实际传输的数据前附加一些头数据,TCP/UDP
不同的是,它是一个“纯文本”的协议,所以头数据都是 ASCII 码的文本,可以很容易地用肉眼阅读,不用借助程序解析也能够看懂。HTTP 协议的请求报文和响应报文的结构基本相同,由三大部分组成:
前两部分起始行和头部字段经常又合称为“请求头”或“响应头”,消息正文又称为“实体”,但与“header”对应,很多时候就直接称为“body”。
HTTP 协议规定报文必须有 header,但可以没有 body,而且在 header 之后必须要有一个“空行”,也就是“CRLF”,十六进制的“0D0A”。
下面是wireshark抓取的HTTP请求的报文:
GET / HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
在这个浏览器发出的请求报文里:
GET / HTTP/1.1
”就是请求行在很多时候,特别是浏览器发送
GET
请求的时候都是这样,HTTP 报文经常是只有 header 而没 body。虽然 HTTP 协议对 header 的大小没有做限制,但各个 Web 服务器都不允许过大的请求头,因为头部太大可能会占用大量的服务器资源,影响运行效率。
请求报文里的起始行也就是请求行(request line),它简要地描述了客户端想要如何操作服务器端的资源。请求行由三部分构成:
GET/POST
,表示对资源的操作;这三个部分通常使用空格(space)来分隔,也可以是制表符(tab),最后要用 CRLF 换行表示结束。
GET / HTTP/1.1
在这个请求行里,“GET”是请求方法,“/”是请求目标,“HTTP/1.1”是版本号,把这三部分连起来,意思就是“服务器你好,我想获取网站根目录下的默认文件,我用的协议版本号是 1.1,请不要用 1.0 或者 2.0 回复我。”
响应报文里的起始行,叫“状态行”(status line),意思是服务器响应的状态。
状态行要简单一些,同样也是由三部分构成:
HTTP/1.1 200 OK
请求行或状态行再加上头部字段集合就构成了 HTTP 报文里完整的请求头或响应头,如下两个示意图。
请求头和响应头的结构是基本一样的,唯一的区别是起始行。
头部字段是 key-value 的形式,key 和 value 之间用“:
”分隔,最后用 CRLF 换行表示字段结束。
比如在“Host: 127.0.0.1”这一行里 key 就是“Host”,value 就是“127.0.0.1”。
HTTP 头字段非常灵活,不仅可以使用标准里的 Host、Connection 等已有头,也可以任意添加自定义头,这就给 HTTP 协议带来了无限的扩展可能。不过使用头字段需要注意下面几点:
详细的常用头字段介绍看这里。
HTTP 协议规定了非常多的头部字段,实现各种各样的功能,但基本上可以分为四大类:
对 HTTP 报文的解析和处理实际上主要就是对头字段的处理,理解了头字段也就理解了 HTTP 报文。
属于请求字段,只能出现在请求头里,同时也是唯一一个 HTTP/1.1
规范里要求必须出现的字段,如果请求头里没有 Host,那这就是一个错误的报文。
Host 字段告诉服务器这个请求应该由哪个主机来处理,当一台计算机上托管了多个虚拟主机的时候,服务器端就需要用 Host 字段来选择,有点像是一个简单的“路由重定向”。
属于请求字段,只出现在请求头里。它使用一个字符串来描述发起 HTTP 请求的客户端,服务器可以依据它来返回最合适此浏览器显示的页面。
由于历史的原因,User-Agent 非常混乱,每个浏览器都自称是“Mozilla”“Chrome”“Safari”,企图使用这个字段来互相“伪装”,导致 User-Agent 变得越来越长,最终变得毫无意义。
有的比较“诚实”的爬虫会在 User-Agent 里用“spider”标明自己是爬虫,所以可以利用这个字段实现简单的反爬虫策略。
属于通用字段,但通常出现在响应头里,表示 HTTP 报文创建的时间,客户端可以使用这个时间再搭配其他字段决定缓存策略。
属于响应字段,只能出现在响应头里。它告诉客户端当前正在提供 Web 服务的软件名称和版本号。
Server 字段不是必须要出现的,因为这会把服务器的一部分信息暴露给外界,如果这个版本恰好存在 bug,那么黑客就有可能利用 bug 攻陷服务器。所以,有的网站响应头里要么没有这个字段,要么就给出一个完全无关的描述信息。
属于实体字段,表示报文里 body 的长度,也就是请求头或响应头空行后面数据的长度。
HTTP 协议设计时的定位是要用 HTTP 协议构建一个超链接文档系统,使用 URI 来定位这些文档,也就是资源。那么,该怎么在协议里操作这些资源呢?很显然,需要有某种“动作的指示”,告诉操作这些资源的方式。所以,就这么出现了“请求方法”。
请求方法的实际含义就是客户端发出了一个“动作指令”,要求服务器端对 URI 定位的资源执行这个动作。目前 HTTP/1.1
规定了八种方法,单词都必须是大写的形式:
这些方法,就像是对文件或数据库的“增删改查”操作,只不过这些动作操作的目标不是本地资源,而是远程服务器上的资源,所以只能由客户端“请求”或者“指示”服务器来完成。
请求方法是一个“指示”,客户端没有决定权,服务器掌控着所有资源,它收到 HTTP 请求报文后,看到请求方法,可以执行也可以拒绝,或者改变动作的含义,毕竟 HTTP 是一个“协议”,两边都要“商量着来”。
比如,发起了一个 GET 请求,想获取“/orders
”保密级别比较高的文件,服务器就可以有如下的几种响应方式:
404 Not found
;403 Forbidden
;405 Method Not Allowed
,然后用 Allow 头告诉你可以用 HEAD 方法获取文件的元信息。GET 方法是 HTTP 协议里最知名,用的最多的请求方法,自 0.9 版出现并一直被保留至今。它的含义是请求从服务器获取资源,这个资源既可以是静态的文本、页面、图片、视频,也可以是由 PHP、Java 动态生成的页面或者其他格式的数据。
GET 方法虽然基本动作比较简单,但搭配 URI 和其他头字段就能实现对资源更精细的操作。例如:
#
”,就可以在获取页面后直接定位到某个标签所在的位置If-Modified-Since
字段就变成了“有条件的请求”,仅当资源被修改时才会执行获取动作Range
字段就是“范围请求”,只获取资源的一部分数据HEAD 方法也是请求从服务器获取资源,但服务器不会返回请求的实体数据,只会传回响应头,也就是资源的“元信息”。
HEAD 方法可以看做是 GET 方法的一个“简化版”或者“轻量版”。
HEAD 的响应头与 GET 完全相同,所以可以用在很多并不真正需要资源的场合,避免传输 body 数据的浪费。例如:
GET 和 HEAD 方法是从服务器获取数据,而 POST 和 PUT 方法则是向 URI 指定的资源提交数据,数据就放在报文的 body 里。
POST 是一个常用到的请求方法,使用频率仅次于 GET,应用的场景非常多,只要向服务器发送数据,用的大多数都是 POST。例如:
PUT 方法也可以向服务器提交数据,但与 POST 存在微妙的不同,通常 POST 表示的是“新建”“create
”的含义,而 PUT 则是“修改”“update
”的含义。
在实际应用中,PUT 用到的比较少。而且,因为它与 POST 的语义、功能太过近似,有的服务器甚至就直接禁止使用 PUT 方法,只用 POST 方法上传数据。
Allow
字段里返回。它的功能很有限,用处也不大,有的服务器(例如 Nginx)干脆就没有实现对它的支持。虽然 HTTP/1.1
里规定了八种请求方法,但它并没有限制只能用这八种方法,这也体现了 HTTP 协议良好的扩展性,我们可以任意添加请求动作,只要请求方和响应方都能理解就行。
例如著名的愚人节玩笑 RFC2324,它定义了协议 HTCPCP,即“超文本咖啡壶控制协议”,为 HTTP 协议增加了用来煮咖啡的 BREW 方法,要求添牛奶的 WHEN 方法。
还有一些得到了实际应用的请求方法(WebDAV),例如 MKCOL、COPY、MOVE、LOCK、UNLOCK、PATCH 等。
如果有合适的场景,可以把它们应用到系统里,例如:
这些方法是非标准的,所以需要为客户端和服务器编写额外的代码才能添加支持。
也完全可以根据实际需求发明新的方法,例如:
关于请求方法还有两个比较重要的概念:安全与幂等。
在 HTTP 协议里,所谓的“安全”是指请求方法不会“破坏”服务器上的资源,即不会对服务器上的资源造成实质的修改。
所谓的“幂等”意思是多次执行相同的操作,结果也都是相同的,即多次“幂”后结果“相等”。
把 POST 理解成 INSERT,把 PUT 理解成 UPDATE。多次 INSERT 会添加多条记录,而多次 UPDATE 只操作一条记录,而且效果相同。
严格地说,URI 不完全等同于网址,它包含有 URL 和 URN 两个部分,在 HTTP 世界里用的网址实际上是 URL,即统一资源定位符(Uniform Resource Locator)。但因为 URL 实在是太普及了,所以常常把这两者简单地视为相等。
URL有绝对URL和相对URL之分,多用在HTML页面标记应用的其他资源,而在HTTP请求行里则不会出现。
URI 本质上是一个字符串,这个字符串的作用是唯一地标记资源的位置或者名字。
注意:它不仅能够标记万维网的资源,也可以标记其他的,如邮件系统、本地文件系统等任意资源。
“资源”既可以是存在磁盘上的静态文本、页面数据,也可以是由 Java、PHP 提供的动态服务。
下面的这张图显示了 URI 最常用的形式,由 scheme
、host:port
、path
和 query
四个部分组成,有的部分可以视情况省略。
URI 第一部分 scheme(“方案名”或者“协议名”),表示资源应该使用哪种协议来访问。
浏览器或应用程序看到 URI 里的 scheme,下一步就会调用相应的 HTTP 或者 HTTPS 下层 API。如果一个 URI 没有提供 scheme,即使后面的地址再完善,也是无法处理的。
在 scheme 之后,必须是三个特定的字符“://
”,它把 scheme 和后面的部分分离开。在“://”之后,是被称为“authority”的部分,表示资源所在的主机名,通常的形式是“host:port
”,即主机名加端口号。
有了协议名和主机地址、端口号,再加上后面标记资源所在位置的 path,浏览器就可以连接服务器访问资源了。
URI 里 path 采用了类似文件系统“目录”“路径”的表示方式,因为早期互联网上的计算机多是 UNIX 系统,所以采用了 UNIX 的“/”风格,path 部分必须以“/”开始,也就是必须包含“/”。
http://nginx.org #省略端口号,http默认为80,省略路径,默认为根目录/
http://www.chrono.com:8080/11-1
https://tools.ietf.org/html/rfc7230 #省略端口号,https默认为443
file:///D:/http_study/www/ # file协议,表示本地文件,省略authority部分,默认为localhost,后面直接path部分
客户端和服务器看到的 URI 是不一样的。
如 Nginx 作为一个 Web 服务器,它的 location、rewrite 等指令操作的 URI 其实指的是真正 URI 里的 path 和后续的部分。
使用“协议名 + 主机名 + 路径”的方式,可以精确定位网络上的任何资源,但这还不够,很多时候还想在操作资源的时候附加一些额外的修饰参数,例如:
仅用“协议名 + 主机名 + 路径”的方式是无法适应这些场景的,所以 URI 后面还有一个“query”部分,它在 path 之后,用一个“?
”开始,但不包含“?”,表示对资源附加的额外要求。这是个很形象的符号,比“://
”要好的多,很明显地表示了“查询”的含义。
查询参数 query 的格式是多个“key=value
”的字符串,这些 KV 值用字符“&
”连接,浏览器和服务器都可以按照这个格式把长串的查询参数解析成可理解的字典或关联数组形式。
查询参数 query 也可以不适用 “key=value
”的形式,只是单纯的“key”,这样“value”就是空字符串。
如果查询参数 query 太长,也可以使用GET方法,放在body里发送给服务器。
URI “真正”的完整形态,如下图所示。
这个“真正”形态比基本形态多了两部分。
user:passwd@
”,表示登录主机时的用户名和密码,但现在已经不推荐使用这种形式了(RFC7230),因为它把敏感信息以明文形式暴露出来,存在严重的安全隐患。#fragment
”,它是 URI 所定位的资源内部的一个“锚点”或者说是“标签”,浏览器可以在获取资源后直接跳转到它指示的位置。片段标识符仅能由浏览器这样的客户端使用,服务器是看不到的。浏览器永远不会把带“#fragment
”的 URI 发送给服务器,服务器也永远不会用这种方式去处理资源的片段。
@&?
"等起界定符作用的字符,会导致 URI 解析错误,这时又该怎么办?URI 引入了编码机制,对于 ASCII 码以外的字符集和特殊字符做一个特殊的操作,把它们转换成与 URI 语义不冲突的形式。这在 RFC 规范里称为“escape”和“unescape”,俗称“转义”。
URI 转义的规则“简单粗暴”,直接把非 ASCII 码或特殊字符转换成十六进制字节值,然后前面再加上一个“%
”。例如:
%20
”%3F
”%E9%93%B6%E6%B2%B3
”有了编码规则 URI 就可以支持任意的字符集用任何语言来标记资源。在浏览器的地址栏里通常是不会看到这些转义后的“乱码”的,这是浏览器一种“友好”表现,隐藏了 URI 编码后的“丑陋一面”。
注意,URI编码转义与HTML里的编码转义是完全不同的,URI的转义使用的是“
%
”,而HTML转义使用的是“”。
响应报文由响应头加响应体数据组成,响应头又由状态行和头字段构成。状态行的结构,有三部分:
它的名字是“状态码”不是“错误码”,它的含义不仅是错误,更重要的意义在于表达 HTTP 数据处理的“状态”,客户端可以依据代码适时转换处理状态,例如:
目前 RFC 标准里规定的状态码是三位数,所以取值范围就是从 000 到 999。如果把状态码简单地从 000 开始顺序编下去就显得有点太“low”,不灵活、不利于扩展,所以状态码也被设计成有一定的格式。
RFC 标准把状态码分成了五类,用数字的第一位表示分类,而 0~99 不用,这样状态码的实际可用范围就大大缩小了,由 000~999 变成了 100~599。这五类的具体含义是:
在 HTTP 协议中,正确地理解并应用这些状态码不是客户端或服务器单方的责任,而是双方共同的责任。
目前 RFC 标准里总共有 41 个状态码,但状态码的定义是开放的,允许自行扩展。所以 Apache、Nginx 等 Web 服务器都定义了一些专有的状态码。
如果开发 Web 应用,也完全可以在不冲突的前提下定义新的代码。
1××
类状态码属于提示信息,是协议处理的中间状态,实际能够用到的时候很少。
101 Switching Protocols
”:意思是客户端使用 Upgrade
头字段,要求在 HTTP 协议的基础上改成其他的协议继续通信,比如 WebSocket。而如果服务器也同意变更协议,就会发送状态码 101,但这之后的数据传输就不会再使用 HTTP 了。2××
类状态码表示服务器收到并成功处理了客户端的请求,这也是客户端最愿意看到的状态码。
200 OK
”:是最常见的成功状态码,表示一切正常,服务器如客户端所期望的那样返回了处理结果,如果是非 HEAD 请求,通常在响应头后都会有 body 数据。204 No Content
”:是很常见的成功状态码,它的含义与“200 OK
”基本相同,但响应头后没有 body 数据。所以对于 Web 服务器来说,正确地区分 200 和 204 是很必要的。206 Partial Content
”:是 HTTP 分块下载或断点续传的基础,在客户端发送“范围请求”、要求获取资源的部分数据时出现,它与 200 一样,也是服务器成功处理了请求,但 body 里的数据不是资源的全部,而是其中的一部分。状态码 206 通常还会伴随着头字段“Content-Range
”,表示响应报文里 body 数据的具体范围,供客户端确认,例如“Content-Range: bytes 0-99/2000
”,意思是此次获取的是总计 2000 个字节的前 100 个字节。
3××
类状态码表示客户端请求的资源发生了变动,客户端必须用新的 URI 重新发送请求获取资源,也就是通常所说的“重定向”,包括著名的 301、302 跳转。
301 Moved Permanently
”:俗称“永久重定向”,含义是此次请求的资源已经不存在了,需要改用新的 URI 再次访问。302 Found
”:曾经的描述短语是“Moved Temporarily”,俗称“临时重定向”,意思是请求的资源还在,但需要暂时用另一个 URI 来访问。304 Not Modified
”:是一个比较有意思的状态码,它用于 If-Modified-Since
等条件请求,表示资源未修改,用于缓存控制。它不具有通常的跳转含义,但可以理解成“重定向已到缓存的文件”(即“缓存重定向”)。301、302 和 304 分别涉及了 HTTP 协议里重要的“重定向跳转”和“缓存控制”。
301 和 302 都会在响应头里使用字段 Location
指明后续要跳转的 URI,最终的效果很相似,浏览器都会重定向到新的 URI。两者的根本区别在于语义,一个是“永久”,一个是“临时”,所以在场景、用法上差距很大。比如:
301和302还有另外两个等价的状态码“
308 Permanent Redirect
”和“307 Temporary Redirect
”,但是这两个状态码不允许后续的请求更改请求方法。
4××
类状态码表示客户端发送的请求报文有误,服务器无法处理,它就是真正的“错误码”含义了。
400 Bad Request
”:是一个通用的错误码,表示请求报文有错误,但具体是数据格式错误、缺少请求头还是 URI 超长它没有明确说,只是一个笼统的错误,客户端看到 400 只会是“一头雾水”“不知所措”。所以,在开发 Web 应用时应当尽量避免给客户端返回 400,而是要用其他更有明确含义的状态码。403 Forbidden
”:实际上不是客户端的请求出错,而是表示服务器禁止访问资源。原因可能多种多样,例如信息敏感、法律禁止等,如果服务器友好一点,可以在 body 里详细说明拒绝请求的原因,不过现实中通常都是直接给一个“闭门羹”。404 Not Found
”:可能是最常看见也是最不愿意看到的一个状态码,它的原意是资源在本服务器上未找到,所以无法提供给客户端。但现在已经被“用滥了”,只要服务器“不高兴”就可以给出个 404,而我们也无从得知后面到底是真的未找到,还是有什么别的原因,某种程度上它比 403 还要令人讨厌。405 Method Not Allowed
”:不允许使用某些方法操作资源,例如不允许 POST 只能 GET;406 Not Acceptable
”:资源无法满足客户端请求的条件,例如请求中文但只有英文;408 Request Timeout
”:请求超时,服务器等待了过长的时间;409 Conflict
”:多个请求发生了冲突,可以理解为多线程并发时的竞态;413 Request Entity Too Large
”:请求报文里的 body 太大;414 Request-URI Too Long
”:请求行里的 URI 太大;429 Too Many Requests
”:客户端发送了太多的请求,通常是由于服务器的限连策略;431 Request Header Fields Too Large
”:请求头某个字段或总体太大;5××
类状态码表示客户端请求报文正确,但服务器在处理时内部发生了错误,无法返回应有的响应数据,是服务器端的“错误码”。
500 Internal Server Error
”:与 400 类似,是一个通用的错误码,服务器究竟发生了什么错误是不知道的。不过对于服务器来说这应该算是好事,通常不应该把服务器内部的详细信息,例如出错的函数调用栈告诉外界。虽然不利于调试,但能够防止黑客的窥探或者分析。501 Not Implemented
”:表示客户端请求的功能还不支持,这个错误码比 500 要“温和”一些,和“即将开业,敬请期待”的意思差不多,不过具体什么时候“开业”就不好说了。502 Bad Gateway
”:通常是服务器作为网关或者代理时返回的错误码,表示服务器自身工作正常,访问后端服务器时发生了错误,但具体的错误原因也是不知道的。503 Service Unavailable
”:表示服务器当前很忙,暂时无法响应服务,上网时有时候遇到的“网络服务正忙,请稍后重试”的提示信息就是状态码 503。503 是一个“临时”的状态,很可能过几秒钟后服务器就不那么忙了,可以继续提供服务,所以 503 响应报文里通常还会有一个“Retry-After
”字段,指示客户端可以在多久以后再次尝试发送请求。