Python网络编程 9.1 HTTP客户端方法、封帧、状态码、缓存

本章从客户端程序的角度来学习HTTP协议的使用方法。这些客户端程序需要获取或缓存文档,或者向服务器提交请求或数据。HTTP的最本质的概念形式:一种用于获取与发布文档的机制。HTTP1.1版本是目前最常用的版本。

1.Python客户端库

一个第三方库是Python程序员使用HTTP时的第一选择,可以替代urllib。这个库就是Kenneth Reitz写的Requests,它基于urllib3的连接池逻辑,urllib3目前由Andrey Petrov维护。在本章的学习中,会介绍urllib和Requests两个库针对各个HTTP特性各自展现出的优缺点。它们的接口极其相似,都提供了可供调用的方法,用于打开HTTP连接,发起请求,等待接收响应头,然后将包含响应头的响应对象发送给程序员。响应体会留在套接字的接收队列中,只有程序员请求时才会读取响应体。安装后运行下面程序:Python网络编程 9.1 HTTP客户端方法、封帧、状态码、缓存可以看到二者的使用接口很类似,但是已经看到了两处区别。Requests一开始就声明其支持gzip和deflate两种压缩格式的HTTP响应,而urllib则不支持。除此之外,Requests能够自己确定正确的解码方式,并将HTTP响应从原始字节转换为文本,而urllib库只会返回原始字节(注意:原始返回的是字节,网络传输中传输的就是字节),而用户需要自己解码。

2.端口、加密与封帧

80端口是用于纯文本HTTP会话的标准端口。而有些客户端则希望首先协商一个加密的TLS会话,一旦加密连接建立完成,就是用HTTP进行通信。这是超文本传输安全协议(HTTPS)的一个变形,此时的标准端口是443端口。在加密连接内部,只要像在普通的未加密套接字上一样,直接使用HTTP即可。 TLS的目的不只是保护数据在传输过程中不被窃听,它也会对客户端连接的服务器身份进行验证(此外,如果客户端也提供证书的话,TLS也允许服务器对客户端身份进行验证)。如果某个HTTPS客户端没有检查尝试连接的服务器所提供的证书是否与其主机名相匹配的话,绝对不要使用这个客户端。

在HTTP中,客户端首先会向服务器发送一个获取文档的请求(request),一旦发送完整个请求,客户端就会进行等待,直到从服务器接收到完整的响应(response)为止。响应中可能包含错误信息,也可能会提供客户端请求的文档信息。至少在今天最流行的HTTP/1.1协议版本中,不允许客户端在尚未收到上一个请求的响应前就再同一个套接字上开始发送第二个请求。

HTTP中有一种很重要的平衡——请求和响应采取了相同的格式化与封帧规则。下面的例子给出了一对请求和响应,在阅读后面的协议描述时可以进行参考。

Python网络编程 9.1 HTTP客户端方法、封帧、状态码、缓存Python网络编程 9.1 HTTP客户端方法、封帧、状态码、缓存  请求和响应的标准名称都是HTTP消息(message),每个消息由以下三部分组成。

  • 在请求消息中,第一行包含一个方法名和要请求的文档名;在响应消息中,第一行包含了返回码和描述信息。无论是在请求还是响应消息中,第一行都以回车和换行(CR_LF,ASCII码13和10结尾)。
  • 第二部分包含零个或多个头信息,每个头信息由一个名称、一个冒号以及一个值组成。HTTP头的名称是区分大小写的,因此可以根据客户端或服务器的要求自由使用大写字母。每个头信息由一个CR-LF结尾。在列出了所有的头信息之后再跟上一个空行,无论第二部分是否包含头信息,都必须包含该空行。
  • 第三部分是一个可选的消息体。消息体紧跟着头信息后面的空行。我们会简要介绍对各实体进行封帧的一些选项。
 第一行和头信息部分都通过表示结束的CR-LF进行封帧。而这两部分作为一个整体又是通过空行来封帧的,这样服务器或客户端就可以通过调用recv()来接收这两部分信息,直到遇到CR-LF-CR-LF这四个连续字符为止。在接收时不会事先得到任何关于第一行和头信息长度的警告,因此许多服务器根据常识设置了第一行和头信息的最大长度,以防止有客户端连接并发送无限长的头信息而造成内存溢出。
 对消息体进行封帧时,有三种不同的方法可供选择。
1.最常见的封帧方法就是提供一个Content-Length头(见于上图Response的头信息),它的值是一个十进制整数,表示消息体包含的字节数。该方法实现起来非常简单。客户端可以
简单地在徐怒汉中不断运行recv()调用,直到接收到的字节总数与Content-Length中声明的字节数相等为止。然而有时候数据是动态生成的,此时无法在头信息中声明
Content-Length头,只有在整个过程完成之后,才能够确定消息体的长度。
2.如果在头信息中指定Transfer-Encoding头,并将其设置为chunked(分块),那么可以**一个更为复杂的封帧机制。该机制不会在消息体前面指定该块的长度,而是将消息体分
成一系列较小的消息快,在每个块前使用一个前缀来指定该块的长度。每个块中至少包含如下部分:一个表示块长度的十六进制数(与使用十进制表示的Content-Length有所
不同)、两个字符CR-LF、一个与给定长度相符的数据块以及最后的两个字符CE-LF。在所有块结尾,是哟个一个长度为0的块来表示消息的结束。长度为0的块是最小的块,
包含数字0、两个字符CR-LF以及最后的两个字符CR-LF。发送者可以在块长度和CR-LF之间插入一个分号,然后指定应用于该块的extension选项。在最后一个块中,发送者
可以在用0表示的块长度和CR-LF之间添加最后一些HTTP头。
3.另一个可以代替Content-Length的方法略显突兀。服务器可以指定Connection: close,然后就能够随意发送任意大小的消息体,发送完毕后关闭TCP套接字。该方法引入了一
个危险:客户端无法判断套接字是因为成功发送了完整的消息体而关闭,还是由于服务器或网络错误而提前关闭。此外,使用这种方法时,客户端每发送完一个请求都需要重
新连接服务器,这降低了协议的效率。

3.方法

HTTP请求中的第一个单词指定了客户端请求服务器时使用的操作类型。GET和POST是两种最常见的方法。除此之外,还有许多为服务器定义的不太常见的方法。这些方法为
其他想要获取文档的计算机程序提供了完整的API(比如JavaScript,服务器本身会将JavaScript脚本传输给浏览器)。

GET和POST两种基本方法提供了HTTP的基本“读”和“写”操作。
当我们在网络浏览器中键入HTTP的URL时,使用的就是GET方法。它请求服务器将请求路径指向的文档作为响应发送回浏览器。GET方法不包括消息体。HTTP标准规定
,任何情况下都不能允许客户端通过GET方法修改服务器上的数据。像?q=python或者?result=10这样附加到请求路径后面的任何参数都只能修改返回后的文档。这一限制 使得客户端能够在第一次请求常识失败时安全地重新尝试GET,也能够将GET的响应存入到缓存中。除此之外,还能使我们在运行网络抓取程序时,安全地访问任意数量 的URL,而不必担心网络抓取程序会在其遍历的网站上创建或删除内容。

当客户端希望向服务器提交新数据时,就会使用POST方法。传统的Web表单通常使用POST来提交客户端的请求(除非它们直接将表单数据复制到URL中)。面向程序员 的API同样使用POST来提交新文档、评论以及数据库行。两次运行同一个POST会在服务器上进行两次相同的操作,因此既不能将POST操作的结果存入缓存以提高后续 重复操作的速度,也不能在没有接收到响应的时候自动重试POST。
其余方法分为两大类,本质上类似于GET的方法和本质上类似POST的方法。
OPTIONS和HEAD是类似于GET的方法。OPTIONS方法请求与给定路径匹配的HTTP头的值,而HEAD方法则请求服务器做好一切发送资源的准备,但是只发送头信息。 这使得客户端能够在不需要下载整个消息体的前提下检查Content-Type等信息,降低了查询所用的花销。

PUT和DELETE是类似于POST的操作,其相同点在于,它们都可能会对存储在服务器上的内容做出不可逆转的修改。顾名思义,PUT的目的就是向服务器发送一个新的 文档,该文档上传后就会存在于请求指定的路径上。DELETE则请求服务器扇出指定路径及所有存在于该路径下的内容。有趣的是,尽管这两个方法都请求对服务器进行“ 写”操作,但是与POST不同的是,它们在某种意义上是安全的,因为它们是幂等的,客户端可以进行任意次数的重试,多次运行这两个操作与单词运行的效果相同。
最后HTTP标准指定了一个用于调试的TRACE方法和一个用于将所使用的协议从HTTP切换为其他协议的CONNECT方法,不过这两个方法很少使用,而且不涉及文档传输。我们可以从本章中看到,文档传输才是HTTP协议的核心工作。

需要注意的是,标准库的urlopen()方法隐式地选择了HTTP方法。如果调用者指定了一个数据参数,那么使用POST方法,否则使用GET方法。因为HTTP方法的正确使用对于客户端和服务器设计安全性都是非常重要的,所以这并不是一个明智的选择。Requests库的选择就好多了,它为不同的基础方法都提供了get()和post()方法。

4.路径与主机

第一个版本的HTTP允许只在请求中包含方法名和路径。 GET /html/rfc7230 这在互联网早起没有问题,因为当时每台服务器上只会托管一个网站。但是后来管理员开始希望在大型HTTP服务器上部署几十上百个网站,此时上述做法就行不通了。如果只提供路径的话,服务器难以推测用户在URL中输入的是哪个主机名。尤其是现在几乎每个网站都有/这样的路径。
解决方法就是至少要强制使用Host头。现代HTTP协议也要求提供协议版本,一个请求至少需要提供下述信息:
GET /html/rfc7230 HTTP/1.1
Host: tools.ietf.org
如果客户端没有提供Host头支持在URL中使用的主机名,许多HTTP服务器就会发出一个客户端错误,通常是400 Bad Request。

5.状态码

响应首行以协议版本开始,这与以协议版本结尾的请求首行有所不同。协议版本后面跟着一个标准状态码,最后是对状态的非正式文本描述,以供用户阅读或记录日志。上图中的正常情况,状态码为200,响应首行为 HTTP /1.1 200 OK,因为跟在状态码后面的文本是非正式的,所以服务器可以将OK改为Okay等,甚至可以进行国际化,使用运行服务器的国家的本地语言。

RFC7231标准制定了二十多个返回码,既覆盖了通用情况,也覆盖了一些特定情况。一般来说,200~300的状态码表示成功,300~400表示重定向,400~500表示客户端的请求无法被识别或非法,500~600表示服务器错误导致了一些意外错误。
200 OK:请求成功。如果是POST操作的话,表明已经对服务器产生了预期的影响。
301 Moved Permanently:尽管路径合法,但是该路径已经不是锁清秋资源目前的官方路径了。客户端若要获取响应,应请求Location头中给出的URl。如果客户端希望将 新URL存入缓存,则所有后续的请求都会直接忽略URL,直接转向新URL。
303 See Other:通过某个路径请求资源时,客户端可以通过使用GET方法对相应信息的Location头中给出的URL进行请求,以获取响应结果,但是对该资源的后续请求仍 然需要通过当前请求路径来完成。该状态码对于网站的设计是至关重要的。任何使用POST正确提交的表单都应该返回303状态码,这样就能通过安全、幂等GET方法获取 客户端实际看到的页面了。
304 Not Modified:不需要在响应中包含文档内容,原因在于请求头指出了客户端已经在缓存中存储了锁清秋文档的最新版本。
307 Temporary Redirect:无论客户端使用GET或POST方法发起了什么样的请求,都需要使用相应的Location头给出的另一个URL重新发送请求。不过对于同一资源的后 续请求还是需要通过当前请求路径来发起。该状态码允许在服务器宕机或不可用时暂时将表单提交到另一个可用的地址。
400 Bad Request:请求不是一个合法的HTTP请求。
403 Forbidden:客户端没有再请求中向服务器提供正确的密码、cookie或其他验证数据来证明客户端有访问服务器的权限。
404 Not Found:路径没有指向一个已经存在的资源。
405 Method Not Allowed:服务器能够识别方法和路径,但是该方法无法应用于该路径。
500 Server Error:这是另一个熟悉的状态。服务器希望完成请求,但是由于某些内部错误暂时无法请求。
502 Bad Gateway:请求的服务器是一个网管或代理,它无法连接到真正为该请求路径提供响应的服务器。
鉴于我们正在学习一个特定的Python HTTP客户端,有两个关于状态码的问题值得一提。
第一个问题是,库是否会自动进行重定向。如果不提供重定向功能,我们就需要自行检查状态码为3xx的响应的Location头。尽管标准库内置的底层httplib模块并没有提供 自动重定向功能,但是urllib模块会根据标准为我们提供该功能。Requests库也提供了重定向功能,它提供了一个history变量,将整个重定向链从头到尾列了出来。Python网络编程 9.1 HTTP客户端方法、封帧、状态码、缓存  此外,Requests库还允许我们在需要的时候关闭重定向功能。要关闭该功 能,只需要一个简单的关键字参数即可(使用urllib)也可以做到这点,但是要难得多。Python网络编程 9.1 HTTP客户端方法、封帧、状态码、缓存
关于HTTP客户端,还有一个问题值得探究:如果我们尝试访问的URL返回了4xx或5xx的错误状态码,客户端会采取什么样的方法通知我们?一旦返回了这样的错误码,标 准库里的urlopen()函数就会抛出一个异常,以防止我们的代码意外地以处理正常数据的方式处理服务器返回的错误页面。那么,如果urlopen抛出了异常并中断了程序的 话,我们该如何查看响应的细节呢?答案就是,只要查看异常对象即可。异常对象的作用有两个:第一,表示发生的异常;第二,作为包含了响应头和响应体的响应对 象。Python网络编程 9.1 HTTP客户端方法、封帧、状态码、缓存
相比于urlopen(),Requests库的处理方法更令人意想不到。即使只请求获取状态码,Requests库也会直接向调用方返回一个响应对象。调用方要负责检查响应的状态码, 或者通过手动调用raise_for_status(),在状态码为4xx或5xx时抛出异常。Python网络编程 9.1 HTTP客户端方法、封帧、状态码、缓存如果担心会忘记在每次调用requests.get的时候进行状 态检查的话,就可以考虑自己封装一个函数,来进行自动检查。

6.缓存与验证

为了防止客户端对频繁使用的资源进行重复的GET请求,HTTP采用了多种精心设计的机制,不过这些机制只在服务器将相应的头信息加入到允许这些机制的资源中时才适用。对于服务器程序编写者来说,充分考虑并在任何可能的情况下采用缓存方案是相当重要的。使用缓存不仅能够减少网络流量,还能够降低服务器负载,加快客户端应用程序的运行速度。

服务架构者在想要添加HTTP头来允许缓存时需要考虑一个最重要的问题:是不是只要两个请求的路径完全相同,就应该返回同一个文档?有没有其他的因素可能导致这两个请求返回不同的文档?如果有的话,该服务就需要在每个响应中包含Vary头信息,列出文档内容所依赖的其他HTTP头,Vary头中常见的选项有Host和Accept-Encoding。如果设计者将不同的文档返回给不同的用户,那么Cookie选项也是极为常见的。
一旦正确设置了Vary头,就可以**多个不同级别的缓存。
可以完全禁止将资源存储在客户端缓存中,这样可以防止客户端以任何形式自动复制非易失存储器中的响应。这一做法的目的是让用户来决定是否要选择“保存”来讲资源的 副本保存到硬盘中去,对应的响应头的信息是 Cache-control: no-store 。
反之,如果允许缓存,那么服务器通常会希望在用户每次请求资源时都返回所请求资源的缓存版本(在缓存过期前)。如果某个文档或图片的每个版本都会永久存储,并 且每个版本都对应一个特定的路径,那么服务器无需担心缓存有效期的问题,可以永远返回缓存版本的资源。例如,如果设计师每次完成一个新版本的公司logo时,都将 获取logo的URL末尾的版本号自增或是改变URL末尾的散列值,那么任意特定版本的logo都能够被永久保存。
服务器可以使用两种方法来避免永远向用户返回存储在客户端的缓存版本的资源。第一种是指定一个过期日期和时间,如果要在该时间之后访问资源,就必须重新向服务器发送请求。不过这种使用过期日期的方法引入了一种威胁:如果没有正确设置客户端的时钟,那么存储在缓存中的资源的有效时间就可能会过长。现代的机制采用了一种好得多的方法,那就是指定资源在缓存中的有效时间(以秒为单位)。只要客户端的时钟没有停止,这种方法就是有效的—— Cache-control: max-age=3600

不过,如果服务器希望对使用缓存版本的资源还是从服务器返回最新版本的资源保留决定权的话,就需要客户端在每次想要使用这个资源时,通过一个HTTP请求向服务器进行验证。由于直接使用缓存中的副本不需要进行任何网络操作,因此这种方法的花销会更大。但是,如果客户端缓存中存储的副本确实已经过期了的话,服务器仍然需要发送最新版本的资源,因此这种方法仍然能够节省时间。

如果服务器希望客户端在每次请求资源时都发送测试请求,询问要使用那个版本的资源,并且尽可能地重用客户端缓存中的资源副本,那么有两种机制可供选择。由于只有在这些测试的结果表明客户端缓存中的资源版本已经过期时,服务器才会发送消息体,因此将这些测试请求称为条件(conditional)请求
第一种机制要求服务器直到资源的最近修改时间。如果客户端请求的资源时存储在文件系统上的文件,那么要获取最近修改时间是很容易的。但是,如果请求的资源要从 数据库表中查询得到,而该数据库表又没有维护审计日志或是最近修改日期,那么采用这种机制就会变得非常困难。如果能获得资源的最近修改时间,服务器就可以在每 个响应中包含该信息。  Last-Modified: Tue, 15 Nov 1997 12:30:18 GMT                           如果想要重用缓存中的资源副本,则客户端可以将最近修改时间也存储到缓存中 。在下一次需要使用该资源时,将缓存中的最近修改时间发回给服务器。服务器进行比对,检查资源是否有改动,如果没有的话,就不会返回消息体,而是只返回消息头 和特殊状态码304.  request的形式:  If-Modified: Tue, 15 Nov 1997 12:30:18 GMT ......         Response形式:.. HTTP/1.1 304 Not Modified
第二种形式不通过修改时间来实现,而是通过资源ID来实现。在这种机制中,服务器需要通过一些方法为某个资源的每个版本创建一个唯一的标签,并且保证任何时候只 要资源发生改变,该标签也会更改为一个新的值。校验码或者数据库的UUID(通用唯一识别码)就是可以作为标签的两种信息。服务器在构造响应时需要将该标签放在 ETag头中传输给客户端。ETag: "sdfsdghdf004dfgh11h5k4uy4f"                  一旦客户端在缓存中保存了该版本的资源副本,就可以在想要重用该副本时向服务器发送一个 资源请求,并且在请求中包含缓存的标签。这样一来,如果缓存中的版本仍然是最新版本的话,服务器就不需要再次传输该资源了。   request形式:If-None-Match: "sdfsdghdf004dfgh11h5k4uy4f"     Response形式  :  HTTP/1.1 304 Not Modified.        If-None-Match和ETag中使用了引号,说明这种机制不仅仅可以比较两个字符串是 否相等,还可以进行功能更强大的字符串比较操作。
需要注意的是,无论是If-Modified还是 If-None-Match,都只是通过防止资源重复传输来节约用于传输的时间,从而节省带宽。因此,在客户端能够使用资源前,仍然至少需要一次从客户端到服务器的请求响应往返。

缓存技术功能强大,对现代网络性能来说极其重要。不过我们介绍的两个Python客户端库在默认情况下都不会进行缓存。urllib和Requests都认为它们的主要工作是在需要时进行实时HTTP网络请求,而不是管理缓存来减少网络通信。想要实现缓存功能,需要寻找别的第三方库。