Dive-Into-Python-中文版-四-

70 阅读1小时+

Dive Into Python 中文版(四)

第十一章 HTTP Web 服务

第十一章 HTTP Web 服务

  • 11.1. 概览
  • 11.2. 避免通过 HTTP 重复地获取数据
  • 11.3. HTTP 的特性
    • 11.3.1. 用户代理 (User-Agent)
    • 11.3.2. 重定向 (Redirects)
    • 11.3.3. Last-Modified/If-Modified-Since
    • 11.3.4. ETag/If-None-Match
    • 11.3.5. 压缩 (Compression)
  • 11.4. 调试 HTTP web 服务
  • 11.5. 设置 User-Agent
  • 11.6. 处理 Last-Modified 和 ETag
  • 11.7. 处理重定向
  • 11.8. 处理压缩数据
  • 11.9. 全部放在一起
  • 11.10. 小结

11.1. 概览

11.1. 概览

在讲解如何下载 web 页和如何从 URL 解析 XML 时,你已经学习了关于 HTML 处理和 XML 处理,接下来让我们来更全面地探讨有关 HTTP web 服务的主题。

简单地讲,HTTP web 服务是指以编程的方式直接使用 HTTP 操作从远程服务器发送和接收数据。如果你要从服务器获取数据,直接使用 HTTP GET;如果您想发送新数据到服务器,使用 HTTP POST。(一些较高级的 HTTP web 服务 API 也定义了使用 HTTP PUT 和 HTTP DELETE 修改和删除现有数据的方法。) 换句话说,构建在 HTTP 协议中的 “verbs (动作)” (GET, POST, PUT 和 DELETE) 直接映射为接收、发送、修改和删除等应用级别的操作。

这种方法的主要优点是简单,并且许多不同的站点充分印证了这样的简单性是受欢迎的。数据 (通常是 XML 数据) 能静态创建和存储,或通过服务器端脚本和所有主流计算机语言 (包括用于下载数据的 HTTP 库) 动态生成。调试也很简单,因为您可以在任意浏览器中调用网络服务来查看这些原始数据。现代浏览器甚至可以为您进行良好的格式化并漂亮地打印这些 XML 数据,以便让您快速地浏览。

HTTP web 服务上的纯 XML 应用举例:

  • Amazon API 允许您从 Amazon.com 在线商店获取产品信息。
  • National Weather Service (美国) 和 Hong Kong Observatory (香港) 通过 web 服务提供天气警报。
  • Atom API 用来管理基于 web 的内容。
  • Syndicated feeds 应用于 weblogs 和新闻站点中带给您来自众多站点的最新消息。

在后面的几章里,我们将探索使用 HTTP 进行数据发送和接收传输的 API,但是不会将应用语义映射到潜在的 HTTP 语义。(所有这些都是通过 HTTP POST 这个管道完成的。) 但是本章将关注使用 HTTP GET 从远程服务器获取数据,并且将探索几个由纯 HTTP web 服务带来最大利益的 HTTP 特性。

如下所示为上一章曾经看到过的 openanything 模块的更高级版本:

例 11.1. openanything.py

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

 import urllib2, urlparse, gzip
from StringIO import StringIO
USER_AGENT = 'OpenAnything/1.0 +http://diveintopython.org/http_web_services/'
class SmartRedirectHandler(urllib2.HTTPRedirectHandler):    
    def http_error_301(self, req, fp, code, msg, headers):  
        result = urllib2.HTTPRedirectHandler.http_error_301(
            self, req, fp, code, msg, headers)              
        result.status = code                                
        return result                                       
    def http_error_302(self, req, fp, code, msg, headers):  
        result = urllib2.HTTPRedirectHandler.http_error_302(
            self, req, fp, code, msg, headers)              
        result.status = code                                
        return result                                       
class DefaultErrorHandler(urllib2.HTTPDefaultErrorHandler):   
    def http_error_default(self, req, fp, code, msg, headers):
        result = urllib2.HTTPError(                           
            req.get_full_url(), code, msg, headers, fp)       
        result.status = code                                  
        return result                                         
def openAnything(source, etag=None, lastmodified=None, agent=USER_AGENT):
    '''URL, filename, or string --> stream
    This function lets you define parsers that take any input source
    (URL, pathname to local or network file, or actual data as a string)
    and deal with it in a uniform manner.  Returned object is guaranteed
    to have all the basic stdio read methods (read, readline, readlines).
    Just .close() the object when you're done with it.
    If the etag argument is supplied, it will be used as the value of an
    If-None-Match request header.
    If the lastmodified argument is supplied, it must be a formatted
    date/time string in GMT (as returned in the Last-Modified header of
    a previous request).  The formatted date/time will be used
    as the value of an If-Modified-Since request header.
    If the agent argument is supplied, it will be used as the value of a
    User-Agent request header.
    '''
    if hasattr(source, 'read'):
        return source
    if source == '-':
        return sys.stdin
    if urlparse.urlparse(source)[0] == 'http':                                      
        # open URL with urllib2 
        request = urllib2.Request(source)                                           
        request.add_header('User-Agent', agent)                                     
        if etag:                                                                    
            request.add_header('If-None-Match', etag)                               
        if lastmodified:                                                            
            request.add_header('If-Modified-Since', lastmodified)                   
        request.add_header('Accept-encoding', 'gzip')                               
        opener = urllib2.build_opener(SmartRedirectHandler(), DefaultErrorHandler())
        return opener.open(request)                                                 
    # try to open with native open function (if source is a filename)
    try:
        return open(source)
    except (IOError, OSError):
        pass
    # treat source as string
    return StringIO(str(source))
def fetch(source, etag=None, last_modified=None, agent=USER_AGENT):  
    '''Fetch data and metadata from a URL, file, stream, or string'''
    result = {}                                                      
    f = openAnything(source, etag, last_modified, agent)             
    result['data'] = f.read()                                        
    if hasattr(f, 'headers'):                                        
        # save ETag, if the server sent one 
        result['etag'] = f.headers.get('ETag')                       
        # save Last-Modified header, if the server sent one 
        result['lastmodified'] = f.headers.get('Last-Modified')      
        if f.headers.get('content-encoding', '') == 'gzip':          
            # data came back gzip-compressed, decompress it 
            result['data'] = gzip.GzipFile(fileobj=StringIO(result['data']])).read()
    if hasattr(f, 'url'):                                            
        result['url'] = f.url                                        
        result['status'] = 200                                       
    if hasattr(f, 'status'):                                         
        result['status'] = f.status                                  
    f.close()                                                        
    return result 

进一步阅读

11.2. 避免通过 HTTP 重复地获取数据

11.2. 避免通过 HTTP 重复地获取数据

假如说你想用 HTTP 下载资源,例如一个 Atom feed 汇聚。你不仅仅想下载一次;而是想一次又一次地下载它,如每小时一次,从提供 news feed 的站点获得最新的消息。让我们首先用一种直接而原始的方法来实现它,然后看看如何改进它。

例 11.2. 用直接而原始的方法下载 feed

>>> import urllib
>>> data = urllib.urlopen('http://diveintomark.org/xml/atom.xml').read()    
>>> print data
<?xml version="1.0" encoding="iso-8859-1"?>
<feed version="0.3"

  xml:lang="en">
  <title mode="escaped">dive into mark</title>
  <link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
  <-- rest of feed omitted for brevity --> 
[1]使用 Python 通过 HTTP 下载任何东西都简单得令人难以置信;实际上,只需要一行代码。urllib 模块有一个便利的 urlopen 函数,它接受您所要获取的页面地址,然后返回一个类文件对象,您仅仅使用 read() 便可获得页面的全部内容。这再简单不过了。

那么这种方法有何不妥之处吗?当然,在测试或开发中一次性的使用没有什么不妥。我经常这样。我想要 feed 汇聚的内容,我就获取 feed 的内容。这种方法对其他 web 页面同样有效。但是一旦你开始按照 web 服务的方式去思考有规则的访问需求时 (记住,你说你计划每小时一次地重复获取这样的 feed ) 就会发现这样的做法效率实在是太低了,并且对服务器来说也太笨了。

下面先谈论一些 HTTP 的基本特性。

11.3. HTTP 的特性

11.3. HTTP 的特性

  • 11.3.1. 用户代理 (User-Agent)
  • 11.3.2. 重定向 (Redirects)
  • 11.3.3. Last-Modified/If-Modified-Since
  • 11.3.4. ETag/If-None-Match
  • 11.3.5. 压缩 (Compression)

这里有五个你必须关注的 HTTP 重要特性。

11.3.1. 用户代理 (User-Agent)

User-Agent 是一种客户端告知服务器谁在什么时候通过 HTTP 请求了一个 web 页、feed 汇聚或其他类型的 web 服务的简单途径。当客户端请求一个资源时,应该尽可能明确发起请求的是谁,以便当产生异常错误时,允许服务器端的管理员与客户端的开发者取得联系。

默认情况下 Python 发送一个通用的 User-AgentPython-urllib/1.15。下一节,您将看到更加有针对性的 User-Agent

11.3.2. 重定向 (Redirects)

有时资源移来移去。Web 站点重组内容,页面移动到了新的地址。甚至是 web 服务重组。原来位于 http://example.com/index.xml 的 feed 汇聚可能被移动到 http://example.com/xml/atom.xml。或者因为一个机构的扩展或重组,整个域被迁移。例如,http://www.example.com/index.xml 可能被重定向到 http://server-farm-1.example.com/index.xml

您每次从 HTTP 服务器请求任何类型的资源时,服务器的响应中均包含一个状态代码。状态代码 200 的意思是 “一切正常,这就是您请求的页面”。状态代码 404 的意思是 “页面没找到”。 (当浏览 web 时,你可能看到过 404 errors。)

HTTP 有两种不同的方法表示资源已经被移动。状态代码 302 表示临时重定向;这意味着 “哎呀,访问内容被临时移动” (然后在 Location: 头信息中给出临时地址)。状态代码 301 表示永久重定向;这意味着 “哎呀,访问内容被永久移动” (然后在 Location: 头信息中给出新地址)。如果您获得了一个 302 状态代码和一个新地址,HTTP 规范说您应该使用新地址获取您的请求,但是下次您要访问同一资源时,应该使用原地址重试。但是如果您获得了一个 301 状态代码和一个新地址,您应该从此使用新地址。

当从 HTTP 服务器接受到一个适当的状态代码时,urllib.urlopen 将自动 “跟踪” 重定向,但不幸的是,当它做了重定向时不会告诉你。 你将最终获得所请求的数据,却丝毫不会察觉到在这个过程中一个潜在的库 “帮助” 你做了一次重定向操作。因此你将继续不断地使用旧地址,并且每次都将获得被重定向的新地址。这一过程要往返两次而不是一次:太没效率了!本章的后面,您将看到如何改进这一点,从而适当地且有效率地处理永久重定向。

11.3.3. Last-Modified/If-Modified-Since

有些数据随时都在变化。CNN.com 的主页经常几分钟就更新。另一方面,Google.com 的主页几个星期才更新一次 (当他们上传特殊的假日 logo,或为一个新服务作广告时)。 Web 服务是不变的:通常服务器知道你所请求的数据的最后修改时间,并且 HTTP 为服务器提供了一种将最近修改数据连同你请求的数据一同发送的方法。

如果你第二次 (或第三次,或第四次) 请求相同的数据,你可以告诉服务器你上一次获得的最后修改日期:在你的请求中发送一个 If-Modified-Since 头信息,它包含了上一次从服务器连同数据所获得的日期。如果数据从那时起没有改变,服务器将返回一个特殊的 HTTP 状态代码 304,这意味着 “从上一次请求后这个数据没有改变”。这一点有何进步呢?当服务器发送状态编码 304 时,不再重新发送数据。您仅仅获得了这个状态代码。所以当数据没有更新时,你不需要一次又一次地下载相同的数据;服务器假定你有本地的缓存数据。

所有现代的浏览器都支持最近修改 (last-modified) 的数据检查。如果你曾经访问过某页,一天后重新访问相同的页时发现它没有变化,并奇怪第二次访问时页面加载得如此之快——这就是原因所在。你的浏览器首次访问时会在本地缓存页面内容,当你第二次访问,浏览器自动发送首次访问时从服务器获得的最近修改日期。服务器简单地返回 304: Not Modified (没有修改),因此浏览器就会知道从本地缓存加载页面。在这一点上,Web 服务也如此智能。

Python 的 URL 库没有提供内置的最近修改数据检查支持,但是你可以为每一个请求添加任意的头信息并在每一个响应中读取任意头信息,从而自己添加这种支持。

11.3.4. ETag/If-None-Match

ETag 是实现与最近修改数据检查同样的功能的另一种方法:没有变化时不重新下载数据。其工作方式是:服务器发送你所请求的数据的同时,发送某种数据的 hash (在 ETag 头信息中给出)。hash 的确定完全取决于服务器。当第二次请求相同的数据时,你需要在 If-None-Match: 头信息中包含 ETag hash,如果数据没有改变,服务器将返回 304 状态代码。与最近修改数据检查相同,服务器仅仅 发送 304 状态代码;第二次将不为你发送相同的数据。在第二次请求时,通过包含 ETag hash,你告诉服务器:如果 hash 仍旧匹配就没有必要重新发送相同的数据,因为你还有上一次访问过的数据。

Python 的 URL 库没有对 ETag 的内置支持,但是在本章后面你将看到如何添加这种支持。

11.3.5. 压缩 (Compression)

最后一个重要的 HTTP 特性是 gzip 压缩。 关于 HTTP web 服务的主题几乎总是会涉及在网络线路上传输的 XML。XML 是文本,而且还是相当冗长的文本,而文本通常可以被很好地压缩。当你通过 HTTP 请求一个资源时,可以告诉服务器,如果它有任何新数据要发送给我时,请以压缩的格式发送。在你的请求中包含 Accept-encoding: gzip 头信息,如果服务器支持压缩,它将返回由 gzip 压缩的数据并且使用 Content-encoding: gzip 头信息标记。

Python 的 URL 库本身没有内置对 gzip 压缩的支持,但是你能为请求添加任意的头信息。Python 还提供了一个独立的 gzip 模块,它提供了对数据进行解压缩的功能。

注意我们用于下载 feed 汇聚的小单行脚本并不支持任何这些 HTTP 特性。让我们来看看如何改善它。

11.4. 调试 HTTP web 服务

11.4. 调试 HTTP web 服务

首先,让我们开启 Python HTTP 库的调试特性并查看网络线路上的传输过程。这对本章的全部内容都很有用,因为你将添加越来越多的特性。

例 11.3. 调试 HTTP

>>> import httplib
>>> httplib.HTTPConnection.debuglevel = 1             
>>> import urllib
>>> feeddata = urllib.urlopen('http://diveintomark.org/xml/atom.xml').read()
connect: (diveintomark.org, 80)                       
send: '
GET /xml/atom.xml HTTP/1.0                            
Host: diveintomark.org                                
User-agent: Python-urllib/1.15                        
'
reply: 'HTTP/1.1 200 OK\r\n'                          
header: Date: Wed, 14 Apr 2004 22:27:30 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Content-Type: application/atom+xml
header: Last-Modified: Wed, 14 Apr 2004 22:14:38 GMT  
header: ETag: "e8284-68e0-4de30f80"                   
header: Accept-Ranges: bytes
header: Content-Length: 26848
header: Connection: close 
[1]urllib 依赖于另一个 Python 的标准库,httplib。通常你不必显式地给出 import httplib (urllib 会自动导入),但是你可以为 HTTPConnection 类 (urllib 在内部使用它来访问 HTTP 服务器) 设置调试标记。这是一种令人难以置信的有用技术。Python 其他的一些库也有类似的调试标记,但是没有命名和开启它们的特殊标准;如果有类似的特性可用,你需要阅读每一个库的文档来查看使用方法。
[2]既然已经设置了调试标记,HTTP 的请求和响应信息会实时地被打印出来。首先告诉你的是你连接服务器 diveintomark.org 的 80 端口,这是 HTTP 的标准端口。
[3]当你请求 Atom feed 时,urllib 向服务器发送三行信息。第一行指出你使用的 HTTP verb 和资源的路径 (除去域名)。在本章中所有的请求都将使用 GET,但是在下一章的 SOAP 中,你会看到所有的请求都使用 POST 。除了请求的动词不同之外,基本的语法是相同的。
[4]第二行是 Host 头信息,它指出你所访问的服务的域名。这一点很重要,因为一个独立的 HTTP 服务器可以服务于多个不同的域。当前我的服务器服务于 12 个域;其他的服务器可以服务于成百乃至上千个域。
[5]第三行是 User-Agent 头信息。在此你看到的是由 urllib 库默认添加的普通的 User-Agent 。在下一节,你会看到如何自定义它的更多细节。
[6]服务器用状态代码和一系列的头信息答复 (其中一些数据可能会被存储到 feeddata 变量中)。这里的状态代码是 200,意味着 “一切正常,这就是您请求的数据”。服务器也会告诉你响应请求的数据、一些有关服务器自身的信息,以及传给你的数据的内容类型。根据你的应用不同,这或许有用,或许没用。这充分确认了你所请求的是一个 Atom feed,瞧,你获得了 Atom feed (application/atom+xml,它是已经注册的有关 Atom feeds 的内容类型)。
[7]当此 Atom feed 有最近的修改,服务器会告诉你 (本例中,大约发生在 13 分钟之前)。当下次请求同样的 feed 时,你可以这个日期再发送给服务器,服务器将做最近修改数据检查。
[8]服务器也会告诉你这个 Atom feed 有一个值为 "e8284-68e0-4de30f80" 的 ETag hash。这个 hash 自身没有任何意义;除了在下次访问相同的 feed 时将它送还给服务器之外,你也不需要用它做什么。然后服务器使用它告诉你修改日期是否被改变了。

11.5. 设置 User-Agent

11.5. 设置 User-Agent

改善你的 HTTP web 服务客户端的第一步就是用 User-Agent 适当地鉴别你自己。为了做到这一点,你需要远离基本的 urllib 而深入到 urllib2

例 11.4. urllib2 介绍

>>> import httplib
>>> httplib.HTTPConnection.debuglevel = 1                             
>>> import urllib2
>>> request = urllib2.Request('http://diveintomark.org/xml/atom.xml') 
>>> opener = urllib2.build_opener()                                   
>>> feeddata = opener.open(request).read()                            
connect: (diveintomark.org, 80)
send: '
GET /xml/atom.xml HTTP/1.0
Host: diveintomark.org
User-agent: Python-urllib/2.1
'
reply: 'HTTP/1.1 200 OK\r\n'
header: Date: Wed, 14 Apr 2004 23:23:12 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Content-Type: application/atom+xml
header: Last-Modified: Wed, 14 Apr 2004 22:14:38 GMT
header: ETag: "e8284-68e0-4de30f80"
header: Accept-Ranges: bytes
header: Content-Length: 26848
header: Connection: close 
[1]如果你的 Python IDE 仍旧为上一节的例子而打开着,你可以略过这一步,在开启 HTTP 调试时你能看到网络线路上的实际传输过程。
[2]使用 urllib2 获取 HTTP 资源包括三个处理步骤,这会有助于你理解这一过程。 第一步是创建 Request 对象,它接受一个你最终想要获取资源的 URL。注意这一步实际上还不能获取任何东西。
[3]第二步是创建一个 URL 开启器 (opener)。它可以接受任何数量的处理器来控制响应的处理。但你也可以创建一个没有任何自定义处理器的开启器,在这儿你就是这么做的。你将在本章后面探究重定向的部分看到如何定义和使用自定义处理器的内容。
[4]最后一个步骤是,使用你创建的 Request 对象告诉开启器打开 URL。因为你能从获得的信息中看到所有调试信息,这个步骤实际上获得了资源并且把返回数据存储在了 feeddata 中。

例 11.5. 给 Request 添加头信息

>>> request                                                
<urllib2.Request instance at 0x00250AA8>
>>> request.get_full_url()
http://diveintomark.org/xml/atom.xml
>>> request.add_header('User-Agent',
`...` 'OpenAnything/1.0 +http://diveintopython.org/')    
>>> feeddata = opener.open(request).read()                 
connect: (diveintomark.org, 80)
send: '
GET /xml/atom.xml HTTP/1.0
Host: diveintomark.org
User-agent: OpenAnything/1.0 +http://diveintopython.org/   
'
reply: 'HTTP/1.1 200 OK\r\n'
header: Date: Wed, 14 Apr 2004 23:45:17 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Content-Type: application/atom+xml
header: Last-Modified: Wed, 14 Apr 2004 22:14:38 GMT
header: ETag: "e8284-68e0-4de30f80"
header: Accept-Ranges: bytes
header: Content-Length: 26848
header: Connection: close 
[1]继续前面的例子;你已经用你要访问的 URL 创建了 Request
[2]使用Request 对象的 add_header 方法,你能向请求中添加任意的 HTTP 头信息。第一个参数是头信息,第二个参数是头信息的值。User-Agent 的约定格式是:应用名,跟一个斜线,跟版本号。剩下的是自由的格式,你将看到许多疯狂的变化,但通常这里应该包含你的应用的 URL。和你的请求的其他信息一样,User-Agent 会被服务器纪录下来,其中包含你的应用的 URL。如果发生错误,服务器管理员就能通过查看他们的访问日志与你联系。
[3]之前你创建的opener 对象也可以再生,且它将再次获得相同的 feed,但这次使用了你自定义的 User-Agent 头信息。
[4]这就是你发送的自定义的 User-Agent,代替了 Python 默认发送的一般的 User-Agent。若你继续看,会注意到你定义的是 User-Agent 头信息,但实际上发送的是 User-agent 头信息。看看有何不同?urllib2 改变了大小写所以只有首字母是大写的。这没问题,因为 HTTP 规定头信息的字段名是大小写无关的。

11.6. 处理 Last-ModifiedETag

11.6. 处理 Last-ModifiedETag

既然你知道如何在你的 web 服务请求中添加自定义的 HTTP 头信息,接下来看看如何添加 Last-ModifiedETag 头信息的支持。

下面的这些例子将以调试标记置为关闭的状态来显示输出结果。如果你还停留在上一部分的开启状态,可以使用 httplib.HTTPConnection.debuglevel = 0 将其设置为关闭状态。或者,如果你认为有帮助也可以保持为开启状态。

例 11.6. 测试 Last-Modified

>>> import urllib2
>>> request = urllib2.Request('http://diveintomark.org/xml/atom.xml')
>>> opener = urllib2.build_opener()
>>> firstdatastream = opener.open(request)
>>> firstdatastream.headers.dict                       
{'date': 'Thu, 15 Apr 2004 20:42:41 GMT', 
 'server': 'Apache/2.0.49 (Debian GNU/Linux)', 
 'content-type': 'application/atom+xml',
 'last-modified': 'Thu, 15 Apr 2004 19:45:21 GMT', 
 'etag': '"e842a-3e53-55d97640"',
 'content-length': '15955', 
 'accept-ranges': 'bytes', 
 'connection': 'close'}
>>> request.add_header('If-Modified-Since',
... firstdatastream.headers.get('Last-Modified'))  
>>> seconddatastream = opener.open(request)            
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "c:\python23\lib\urllib2.py", line 326, in open
    '_open', req)
  File "c:\python23\lib\urllib2.py", line 306, in _call_chain
    result = func(*args)
  File "c:\python23\lib\urllib2.py", line 901, in http_open
    return self.do_open(httplib.HTTP, req)
  File "c:\python23\lib\urllib2.py", line 895, in do_open
    return self.parent.error('http', req, fp, code, msg, hdrs)
  File "c:\python23\lib\urllib2.py", line 352, in error
    return self._call_chain(*args)
  File "c:\python23\lib\urllib2.py", line 306, in _call_chain
    result = func(*args)
  File "c:\python23\lib\urllib2.py", line 412, in http_error_default
    raise HTTPError(req.get_full_url(), code, msg, hdrs, fp)
urllib2.HTTPError: HTTP Error 304: Not Modified 
[1]还记得当调试标记设置为开启时所有那些你看到的 HTTP 头信息打印输出吗? 这里便是用编程方式访问它们的方法: firstdatastream.headers 是一个类似 dictionary 行为的对象并且允许你获得任何个别的从 HTTP 服务器返回的头信息。
[2]在第二次请求时,你用第一次请求获得的最近修改时间添加了 If-Modified-Since 头信息。如果数据没被改变,服务器应该返回一个 304 状态代码。
[3]毫无疑问,数据没被改变。你可以从跟踪返回结果看到 urllib2 抛出了一个特殊异常,HTTPError,以响应 304 状态代码。这有点不寻常,并且完全没有任何帮助。毕竟,它不是个错误;你明确地询问服务器如果没有变化就不要发送任何数据,并且数据没有变化,所以服务器告诉你它没有为你发送任何数据。那不是个错误;实际上也正是你所期望的。

urllib2 也为你认为是错误的其他条件引发 HTTPError 异常,比如 404 (page not found)。实际上,它将为任何 除了状态代码 200 (OK)、301 (permanent redirect)或 302 (temporary redirect) 之外的状态引发 HTTPError。捕获状态代码并简单返回它,而不是抛出异常,这应该对你很有帮助。为了实现它,你将需要自定义一个 URL 处理器。

例 11.7. 定义 URL 处理器

这个自定义的 URL 处理器是 openanything.py 的一部分。

 class DefaultErrorHandler(urllib2.HTTPDefaultErrorHandler):    
    def http_error_default(self, req, fp, code, msg, headers): 
        result = urllib2.HTTPError(                           
            req.get_full_url(), code, msg, headers, fp)       
        result.status = code                                   
        return result 
[1]urllib2 是围绕 URL 处理器而设计的。每一个处理器就是一个能定义任意数量方法的类。当某事件发生时——比如一个 HTTP 错误,甚至是 304 代码——urllib2 审视用于处理它的 一系列已定义的处理器方法。在此要用到自省,与 第九章 XML 处理中为不同节点类型定义不同处理器类似。但是 urllib2 是很灵活的,还可以内省为当前请求所定义的所有处理器。
[2]当从服务器接收到一个 304 状态代码时,urllib2 查找定义的操作并调用 http_error_default 方法。通过定义一个自定义的错误处理,你可以阻止 urllib2 引发异常。取而代之的是,你创建 HTTPError 对象,返回它而不是引发异常。
[3]这是关键部分:返回之前,你保存从 HTTP 服务器返回的状态代码。这将使你从主调程序轻而易举地访问它。

例 11.8. 使用自定义 URL 处理器

>>> request.headers                           
{'If-modified-since': 'Thu, 15 Apr 2004 19:45:21 GMT'}
>>> import openanything
>>> opener = urllib2.build_opener(
... openanything.DefaultErrorHandler())   
>>> seconddatastream = opener.open(request)
>>> seconddatastream.status                   
304
>>> seconddatastream.read()                   
'' 
[1]继续前面的例子,Request 对象已经被设置,并且你已经添加了 If-Modified-Since 头信息。
[2]这是关键所在:既然已经定义了你的自定义 URL 处理器,你需要告诉 urllib2 来使用它。还记得我怎么说 urllib2 将一个 HTTP 资源的访问过程分解为三个步骤的正当理由吗?这便是为什么构建 HTTP 开启器是其步骤之一,因为你能用你自定义的 URL 操作覆盖 urllib2 的默认行为来创建它。
[3]现在你可以快速地打开一个资源,返回给你的对象既包括常规头信息 (使用 seconddatastream.headers.dict 访问它们),也包括 HTTP 状态代码。在此,正如你所期望的,状态代码是 304,意味着此数据自从上次请求后没有被修改。
[4]注意当服务器返回 304 状态代码时,并没有重新发送数据。这就是全部的关键:没有重新下载未修改的数据,从而节省了带宽。因此若你确实想要那个数据,你需要在首次获得它时在本地缓存数据。

处理 ETag 的工作也非常相似,只不过不是检查 Last-Modified 并发送 If-Modified-Since,而是检查 ETag 并发送 If-None-Match。让我们打开一个新的 IDE 会话。

例 11.9. 支持 ETag/If-None-Match

>>> import urllib2, openanything
>>> request = urllib2.Request('http://diveintomark.org/xml/atom.xml')
>>> opener = urllib2.build_opener(
... openanything.DefaultErrorHandler())
>>> firstdatastream = opener.open(request)
>>> firstdatastream.headers.get('ETag')        
'"e842a-3e53-55d97640"'
>>> firstdata = firstdatastream.read()
>>> print firstdata                            
<?xml version="1.0" encoding="iso-8859-1"?>
<feed version="0.3"

  xml:lang="en">
  <title mode="escaped">dive into mark</title>
  <link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
  <-- rest of feed omitted for brevity -->
>>> request.add_header('If-None-Match',
... firstdatastream.headers.get('ETag'))   
>>> seconddatastream = opener.open(request)
>>> seconddatastream.status                    
304
>>> seconddatastream.read()                    
'' 
[1]使用 firstdatastream.headers 伪字典,你可以获得从服务器返回的 ETag (如果服务器没有返回 ETag 会发生什么?答案是,这一行代码将返回 None。)
[2]OK,你获得了数据。
[3]现在进行第二次调用,将 If-None-Match 头信息设置为你第一次调用获得的 ETag
[4]第二次调用静静地成功了 (没有出现任何的异常),并且你又一次看到了从服务器返回的 304 状态代码。你第二次基于 ETag 发送请求,服务器知道数据没有被改变。
[5]无论 304 是被 Last-Modified 数据检查还是 ETag hash 匹配触发的,获得 304 的同时都不会下载数据。这就是重点所在。

注意 在这些例子中,HTTP 服务器同时支持 Last-ModifiedETag 头信息,但并非所有的服务器皆如此。作为一个 web 服务的客户端,你应该为支持两种头信息做准备,但是你的程序也应该为服务器仅支持其中一种头信息或两种头信息都不支持而做准备。

11.7. 处理重定向

11.7. 处理重定向

你可以使用两种不同的自定义 URL 处理器来处理永久重定向和临时重定向。

首先,让我们来看看重定向处理的必要性。

例 11.10. 没有重定向处理的情况下,访问 web 服务

>>> import urllib2, httplib
>>> httplib.HTTPConnection.debuglevel = 1           
>>> request = urllib2.Request(
... 'http://diveintomark.org/redir/example301.xml') 
>>> opener = urllib2.build_opener()
>>> f = opener.open(request)
connect: (diveintomark.org, 80)
send: '
GET /redir/example301.xml HTTP/1.0
Host: diveintomark.org
User-agent: Python-urllib/2.1
'
reply: 'HTTP/1.1 301 Moved Permanently\r\n'             
header: Date: Thu, 15 Apr 2004 22:06:25 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Location: http://diveintomark.org/xml/atom.xml  
header: Content-Length: 338
header: Connection: close
header: Content-Type: text/html; charset=iso-8859-1
connect: (diveintomark.org, 80)
send: '
GET /xml/atom.xml HTTP/1.0                              
Host: diveintomark.org
User-agent: Python-urllib/2.1
'
reply: 'HTTP/1.1 200 OK\r\n'
header: Date: Thu, 15 Apr 2004 22:06:25 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Last-Modified: Thu, 15 Apr 2004 19:45:21 GMT
header: ETag: "e842a-3e53-55d97640"
header: Accept-Ranges: bytes
header: Content-Length: 15955
header: Connection: close
header: Content-Type: application/atom+xml
>>> f.url                                               
'http://diveintomark.org/xml/atom.xml'
>>> f.headers.dict
{'content-length': '15955', 
'accept-ranges': 'bytes', 
'server': 'Apache/2.0.49 (Debian GNU/Linux)', 
'last-modified': 'Thu, 15 Apr 2004 19:45:21 GMT', 
'connection': 'close', 
'etag': '"e842a-3e53-55d97640"', 
'date': 'Thu, 15 Apr 2004 22:06:25 GMT', 
'content-type': 'application/atom+xml'}
>>> f.status
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
AttributeError: addinfourl instance has no attribute 'status' 
[1]你最好开启调试状态,看看发生了什么。
[2]这是一个我已经设置了永久重定向到我的 Atom feed http://diveintomark.org/xml/atom.xml 的 URL。
[3]毫无疑问,当你试图从那个地址下载数据时,服务器会返回 301 状态代码,告诉你你访问的资源已经被永久移动了。
[4]服务器同时返回 Location: 头信息,它给出了这个数据的新地址。
[5]urllib2 注意到了重定向状态代码并会自动从Location: 头信息中给出的新地址获取数据。
[6]opener 返回的对象包括新的永久地址和第二次请求获得的所有头信息 (从一个新的永久地址获得)。但是状态代码不见了,因此你无从知晓重定向到底是永久重定向还是临时重定向。这是至关重要的:如果这是临时重定向,那么你应该继续使用旧地址访问数据。但是如果是永久重定向 (正如本例),你应该从现在起使用新地址访问数据。

这不太理想,但很容易改进。实际上当 urllib2 遇到 301302 时的行为并不是我们所期望的,所以让我们来覆盖这些行为。如何实现呢?用一个自定义的处理器,正如你处理 304 代码所做的。

例 11.11. 定义重定向处理器

这个类定义在 openanything.py

 class SmartRedirectHandler(urllib2.HTTPRedirectHandler):     
    def http_error_301(self, req, fp, code, msg, headers):  
        result = urllib2.HTTPRedirectHandler.http_error_301( 
            self, req, fp, code, msg, headers)              
        result.status = code                                 
        return result                                       
    def http_error_302(self, req, fp, code, msg, headers):   
        result = urllib2.HTTPRedirectHandler.http_error_302(
            self, req, fp, code, msg, headers)              
        result.status = code                                
        return result 
[1]重定向行为定义在 urllib2 的一个叫做 HTTPRedirectHandler 的类中。我们不想完全地覆盖这些行为,只想做点扩展,所以我们子类化 HTTPRedirectHandler,从而我们仍然可以调用祖先类来实现所有原来的功能。
[2]当从服务器获得 301 状态代码,urllib2 将搜索处理器并调用 http_error_301 方法。我们首先要做的就是在祖先中调用 http_error_301 方法,它将处理查找 Location: 头信息的工作并跟踪重定向到新地址。
[3]这是关键:返回之前,你存储了状态代码 (301),所以主调程序稍后就可以访问它了。
[4]临时重定向 (状态代码 302) 以相同的方式工作:覆盖 http_error_302 方法,调用祖先,并在返回之前保存状态代码。

这将为我们带来什么?现在你可以用自定义重定向处理器构造一个的 URL 开启器,并且它依然能自动跟踪重定向,也能展示出重定向状态代码。

例 11.12. 使用重定向处理器检查永久重定向

>>> request = urllib2.Request('http://diveintomark.org/redir/example301.xml')
>>> import openanything, httplib
>>> httplib.HTTPConnection.debuglevel = 1
>>> opener = urllib2.build_opener(
... openanything.SmartRedirectHandler())           
>>> f = opener.open(request)
connect: (diveintomark.org, 80)
send: 'GET /redir/example301.xml HTTP/1.0
Host: diveintomark.org
User-agent: Python-urllib/2.1
'
reply: 'HTTP/1.1 301 Moved Permanently\r\n'            
header: Date: Thu, 15 Apr 2004 22:13:21 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Location: http://diveintomark.org/xml/atom.xml
header: Content-Length: 338
header: Connection: close
header: Content-Type: text/html; charset=iso-8859-1
connect: (diveintomark.org, 80)
send: '
GET /xml/atom.xml HTTP/1.0
Host: diveintomark.org
User-agent: Python-urllib/2.1
'
reply: 'HTTP/1.1 200 OK\r\n'
header: Date: Thu, 15 Apr 2004 22:13:21 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Last-Modified: Thu, 15 Apr 2004 19:45:21 GMT
header: ETag: "e842a-3e53-55d97640"
header: Accept-Ranges: bytes
header: Content-Length: 15955
header: Connection: close
header: Content-Type: application/atom+xml 
>>> f.status                                           
301
>>> f.url
'http://diveintomark.org/xml/atom.xml' 
[1]首先,用刚刚定义的重定向处理器创建一个 URL 开启器。
[2]你发送了一个请求,并在响应中获得了 301 状态代码。 如此一来,http_error_301 方法就被调用了。你调用了祖先类,跟踪了重定向并且发送了一个新地址 (http://diveintomark.org/xml/atom.xml) 请求。
[3]这是决定性的一步:现在,你不仅做到了访问一个新 URL,而且获得了重定向的状态代码,所以你可以断定这是一个永久重定向。下一次你请求这个数据时,就应该使用 f.url 指定的新地址 (http://diveintomark.org/xml/atom.xml)。如果你已经在配置文件或数据库中存储了这个地址,就需要更新旧地址而不是反复地使用旧地址请求服务。现在是更新你的地址簿的时候了。

同样的重定向处理也可以告诉你不该 更新你的地址簿。

例 11.13. 使用重定向处理器检查临时重定向

>>> request = urllib2.Request(
... 'http://diveintomark.org/redir/example302.xml')   
>>> f = opener.open(request)
connect: (diveintomark.org, 80)
send: '
GET /redir/example302.xml HTTP/1.0
Host: diveintomark.org
User-agent: Python-urllib/2.1
'
reply: 'HTTP/1.1 302 Found\r\n'                           
header: Date: Thu, 15 Apr 2004 22:18:21 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Location: http://diveintomark.org/xml/atom.xml
header: Content-Length: 314
header: Connection: close
header: Content-Type: text/html; charset=iso-8859-1
connect: (diveintomark.org, 80)
send: '
GET /xml/atom.xml HTTP/1.0                                
Host: diveintomark.org
User-agent: Python-urllib/2.1
'
reply: 'HTTP/1.1 200 OK\r\n'
header: Date: Thu, 15 Apr 2004 22:18:21 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Last-Modified: Thu, 15 Apr 2004 19:45:21 GMT
header: ETag: "e842a-3e53-55d97640"
header: Accept-Ranges: bytes
header: Content-Length: 15955
header: Connection: close
header: Content-Type: application/atom+xml
>>> f.status                                              
302
>>> f.url
http://diveintomark.org/xml/atom.xml 
[1]这是一个 URL,我已经设置了它,让它告诉客户端临时 重定向到 http://diveintomark.org/xml/atom.xml
[2]服务器返回 302 状态代码,标识出一个临时重定向。数据的临时新地址在 Location: 头信息中给出。
[3]urllib2 调用你的 http_error_302 方法,它调用了 urllib2.HTTPRedirectHandler 中的同名的祖先方法,跟踪重定向到一个新地址。然后你的 http_error_302 方法存储状态代码 (302) 使主调程序在稍后可以获得它。
[4]此时,已经成功追踪重定向到 http://diveintomark.org/xml/atom.xmlf.status 告诉你这是一个临时重定向,这意味着你应该继续使用原来的地址 (http://diveintomark.org/redir/example302.xml) 请求数据。也许下一次它仍然被重定向,也许不会。也许会重定向到不同的地址。这也不好说。服务器说这个重定向仅仅是临时的,你应该尊重它。并且现在你获得了能使主调程序尊重它的充分信息。

11.8. 处理压缩数据

11.8. 处理压缩数据

你要支持的最后一个重要的 HTTP 特性是压缩。许多 web 服务具有发送压缩数据的能力,这可以将网络线路上传输的大量数据消减 60% 以上。这尤其适用于 XML web 服务,因为 XML 数据 的压缩率可以很高。

服务器不会为你发送压缩数据,除非你告诉服务器你可以处理压缩数据。

例 11.14. 告诉服务器你想获得压缩数据

>>> import urllib2, httplib
>>> httplib.HTTPConnection.debuglevel = 1
>>> request = urllib2.Request('http://diveintomark.org/xml/atom.xml')
>>> request.add_header('Accept-encoding', 'gzip')        
>>> opener = urllib2.build_opener()
>>> f = opener.open(request)
connect: (diveintomark.org, 80)
send: '
GET /xml/atom.xml HTTP/1.0
Host: diveintomark.org
User-agent: Python-urllib/2.1
Accept-encoding: gzip                                    
'
reply: 'HTTP/1.1 200 OK\r\n'
header: Date: Thu, 15 Apr 2004 22:24:39 GMT
header: Server: Apache/2.0.49 (Debian GNU/Linux)
header: Last-Modified: Thu, 15 Apr 2004 19:45:21 GMT
header: ETag: "e842a-3e53-55d97640"
header: Accept-Ranges: bytes
header: Vary: Accept-Encoding
header: Content-Encoding: gzip                           
header: Content-Length: 6289                             
header: Connection: close
header: Content-Type: application/atom+xml 
[1]这是关键:一创建了 Request 对象,就添加一个 Accept-encoding 头信息告诉服务器你能接受 gzip 压缩数据。gzip 是你使用的压缩算法的名称。理论上你可以使用其它的压缩算法,但是 gzip 是 web 服务器上使用率高达 99% 的一种。
[2]这是你的头信息传越网络线路的过程。
[3]这是服务器的返回信息:Content-Encoding: gzip 头信息意味着你要回得的数据已经被 gzip 压缩了。
[4]Content-Length 头信息是已压缩数据的长度,并非解压缩数据的长度。一会儿你会看到,实际的解压缩数据长度为 15955,因此 gzip 压缩节省了 60% 以上的网络带宽!

例 11.15. 解压缩数据

>>> compresseddata = f.read()                              
>>> len(compresseddata)
6289
>>> import StringIO
>>> compressedstream = StringIO.StringIO(compresseddata)   
>>> import gzip
>>> gzipper = gzip.GzipFile(fileobj=compressedstream)      
>>> data = gzipper.read()                                  
>>> print data                                             
<?xml version="1.0" encoding="iso-8859-1"?>
<feed version="0.3"

  xml:lang="en">
  <title mode="escaped">dive into mark</title>
  <link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
  <-- rest of feed omitted for brevity -->
>>> len(data)
15955 
[1]继续上面的例子,f 是一个从 URL 开启器返回的类文件对象。使用它的 read() 方法将正常地获得非压缩数据,但是因为这个数据已经被 gzip 压缩过,所以这只是获得你想要的最终数据的第一步。
[2]好吧,只是先得有点儿凌乱的步骤。Python 有一个 gzip 模块,它能读取 (当然也能写入) 磁盘上的 gzip 压缩文件。但是磁盘上还没有文件,只在内存里有一个 gzip 压缩缓冲区,并且你不想仅仅为了解压缩而写出一个临时文件。那么怎么做来从内存数据 (compresseddata) 创建类文件对象呢?这需要使用 StringIO 模块。你首次看到 StringIO 模块是在上一章,但现在你会发现它的另一种用法。
[3]现在你可以创建 GzipFile 的一个实例,并且告诉它其中的 “文件” 是一个类文件对象 compressedstream
[4]这是做所有工作的一行:从 GzipFile 中 “读取” 将会解压缩数据。感到奇妙吗?是的,它确实解压缩了数据。gzipper 是一个类文件对象,它代表一个 gzip 压缩文件。尽管这个 “文件” 并非一个磁盘上的真实文件;但 gzipper 还是从你用 StringIO 包装了压缩数据的类文件对象中 “读取” 数据,而它仅仅是内存中的变量 compresseddata。压缩的数据来自哪呢?最初你从远程 HTTP 服务器下载它,通过从用 urllib2.build_opener 创建的类文件对象中 “读取”。令人吃惊吧,这就是所有的步骤。链条上的每一步都完全不知道上一步在造假。
[5]看看吧,实际的数据 (实际为 15955 bytes)。

“等等!” 我听见你在叫。“还能更简单吗!” 我知道你在想什么。你在,既然 opener.open 返回一个类文件对象,那么为什么不抛弃中间件 StringIO 而通过 f 直接访问 GzipFile 呢?OK,或许你没想到,但是别为此担心,因为那样无法工作。

例 11.16. 从服务器直接解压缩数据

>>> f = opener.open(request)                  
>>> f.headers.get('Content-Encoding')         
'gzip'
>>> data = gzip.GzipFile(fileobj=f).read()    
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "c:\python23\lib\gzip.py", line 217, in read
    self._read(readsize)
  File "c:\python23\lib\gzip.py", line 252, in _read
    pos = self.fileobj.tell()   # Save current position
AttributeError: addinfourl instance has no attribute 'tell' 
[1]继续前面的例子,你已经有一个设置了 Accept-encoding: gzip 头信息的 Request 对象。
[2]简单地打开请求将获得你的头信息 (虽然还没下载任何数据)。正如你从 Content-Encoding 头信息所看到的,这个数据被要求用 gzip 压缩发送。
[3]opener.open 返回了一个类文件对象,从头信息中你可以获知,你将获得 gzip 压缩数据。为什么不简单地通过那个类文件对象直接访问 GzipFile 呢?当你从 GzipFile 实例 “读取” 时,它将从远程 HTTP 服务器 “读取” 被压缩的数据并且立即解压缩。这是个好主意,但是不行。由 gzip 压缩的工作方式所致,GzipFile 需要存储其位置并在压缩文件上往返游走。当 “文件” 是来自远程服务器的字节流时无法工作;你能用它做的所有工作就是一次返回一个字节流,而不是在字节流上往返。所以使用 StringIO 这种看上去不太优雅的手段是最好的解决方案:下载压缩的数据,用 StringIO 创建一个类文件对象,并从中解压缩数据。

11.9. 全部放在一起

11.9. 全部放在一起

你已经看到了构造一个智能的 HTTP web 客户端的所有片断。现在让我们看看如何将它们整合到一起。

例 11.17. openanything 函数

这个函数定义在 openanything.py 中。

 def openAnything(source, etag=None, lastmodified=None, agent=USER_AGENT):
    # non-HTTP code omitted for brevity
    if urlparse.urlparse(source)[0] == 'http':                                       
        # open URL with urllib2 
        request = urllib2.Request(source)                                           
        request.add_header('User-Agent', agent)                                      
        if etag:                                                                    
            request.add_header('If-None-Match', etag)                                
        if lastmodified:                                                            
            request.add_header('If-Modified-Since', lastmodified)                    
        request.add_header('Accept-encoding', 'gzip')                                
        opener = urllib2.build_opener(SmartRedirectHandler(), DefaultErrorHandler()) 
        return opener.open(request) 
[1]urlparse 是一个解析 URL 的便捷的工具模块。它的主要函数也叫 urlparse,接受一个 URL 并将其拆分为 tuple (scheme (协议), domain (域名), path (路径), params (参数), query string parameters (请求字符串参数), fragment identifier (片段效验符))。当然,你唯一需要注意的就是 scheme,确认你处理的是一个 HTTP URL (urllib2 才能处理)。
[2]通过调用函数使用 User-Agent 向 HTTP 服务器确定你的身份。如果没有 User-Agent 被指定,你会使用一个默认的,就是定义在早期的 openanything.py 模块中的那个。你从来不会使用到默认的定义在 urllib2 中的那个。
[3]如果给出了 ETag,要在 If-None-Match 头信息中发送它。
[4]如果给出了最近修改日期,要在 If-Modified-Since 头信息中发送它。
[5]如果可能要告诉服务器你要获取压缩数据。
[6]使用两个 自定义 URL 处理器创建一个 URL 开启器:SmartRedirectHandler 终于处理 301302 重定向,而 DefaultErrorHandler 用于处理 304, 404 以及其它的错误条件。
[7]就是这样!打开 URL 并返回一个类文件对象给调用者。

例 11.18. fetch 函数

这个函数定义在 openanything.py 中。

 def fetch(source, etag=None, last_modified=None, agent=USER_AGENT):  
    '''Fetch data and metadata from a URL, file, stream, or string'''
    result = {}                                                      
    f = openAnything(source, etag, last_modified, agent)              
    result['data'] = f.read()                                         
    if hasattr(f, 'headers'):                                        
        # save ETag, if the server sent one 
        result['etag'] = f.headers.get('ETag')                        
        # save Last-Modified header, if the server sent one 
        result['lastmodified'] = f.headers.get('Last-Modified')       
        if f.headers.get('content-encoding', '') == 'gzip':           
            # data came back gzip-compressed, decompress it 
            result['data'] = gzip.GzipFile(fileobj=StringIO(result['data']])).read()
    if hasattr(f, 'url'):                                             
        result['url'] = f.url                                        
        result['status'] = 200                                       
    if hasattr(f, 'status'):                                          
        result['status'] = f.status                                  
    f.close()                                                        
    return result 
[1]首先,你用 URL、ETag hash、Last-Modified 日期和 User-Agent 调用 openAnything 函数。
[2]读取从服务器返回的真实数据。这可能是被压缩的;如果是,将在后面进行解压缩。
[3]保存从服务器返回的 ETag hash,这样主调程序下一次就能把它传递给你,然后再传递给 openAnything,放到 If-None-Match 头信息里发送给远程服务器。
[4]也要保存 Last-Modified 数据。
[5]如果服务器说它发送的是压缩数据,就执行解压缩。
[6]如果你的服务器返回一个 URL 就保存它,并在查明之前假定状态代码为 200
[7]如果其中一个自定义 URL 处理器捕获了一个状态代码,也要保存下来。

例 11.19. 使用 openanything.py

>>> import openanything
>>> useragent = 'MyHTTPWebServicesApp/1.0'
>>> url = 'http://diveintopython.org/redir/example301.xml'
>>> params = openanything.fetch(url, agent=useragent)              
>>> params                                                         
{'url': 'http://diveintomark.org/xml/atom.xml', 
'lastmodified': 'Thu, 15 Apr 2004 19:45:21 GMT', 
'etag': '"e842a-3e53-55d97640"', 
'status': 301,
'data': '<?xml version="1.0" encoding="iso-8859-1"?>
<feed version="0.3"
<-- rest of data omitted for brevity -->'}
>>> if params['status'] == 301:                                    
... url = params['url']
>>> newparams = openanything.fetch(
... url, params['etag'], params['lastmodified'], useragent)    
>>> newparams
{'url': 'http://diveintomark.org/xml/atom.xml', 
'lastmodified': None, 
'etag': '"e842a-3e53-55d97640"', 
'status': 304,
'data': ''} 
[1]第一次获取资源时,你没有 ETag hash 或 Last-Modified 日期,所以你不用使用这些参数。 (它们是可选参数。)
[2]你获得了一个 dictionary,它包括几个有用的头信息、HTTP 状态代码和从服务器返回的真实数据。openanything 在内部处理 gzip 压缩;在本级别上你不必关心它。
[3]如果你得到一个 301 状态代码,表示是个永久重定向,你需要把你的 URL 更新为新地址。
[4]第二次获取相同的资源时,你已经从以往获得了各种信息:URL (可能被更新了)、从上一次访问获得的 ETag、从上一次访问获得的 Last-Modified 日期,当然还有 User-Agent
[5]你重新获取了这个 dictionary,但是数据没有改变,所以你得到了一个 304 状态代码而没有数据。

11.10. 小结

11.10. 小结

openanything.py 及其函数现在可以完美地工作了。

每个客户端都应该支持 HTTP web 服务的以下 5 个重要特性:

  • 通过设置适当的 User-Agent 识别你的应用。
  • 适当地处理永久重定向。
  • 支持 Last-Modified 日期检查从而避免在数据未改变的情况下重新下载数据。
  • 支持 ETag hash 从而避免在数据未改变的情况下重新下载数据。
  • 支持 gzip 压缩从而在数据已经 改变的情况下尽可能地减少传输带宽。

第十二章 SOAP Web 服务

第十二章 SOAP Web 服务

  • 12.1. 概览
  • 12.2. 安装 SOAP 库
    • 12.2.1. 安装 PyXML
    • 12.2.2. 安装 fpconst
    • 12.2.3. 安装 SOAPpy
  • 12.3. 步入 SOAP
  • 12.4. SOAP 网络服务查错
  • 12.5. WSDL 介绍
  • 12.6. 以 WSDL 进行 SOAP 内省
  • 12.7. 搜索 Google
  • 12.8. SOAP 网络服务故障排除
  • 12.9. 小结

第十一章 关注 HTTP 上面向文档的 web 服务。“输入参数” 是 URL,“返回值” 是需要你来解析的一个实际的 XML 文档。

本章将关注更加结构化的 SOAP web 服务。SOAP 不需要你直接与 HTTP 请求和 XML 文档打交道,而是允许你模拟返回原始数据类型的函数调用。正像你将要看到的,这个描述恰如其份;你可以使用标准 Python 调用语法通过 SOAP 库去调用一个函数,这个函数也自然会返回 Python 对象和值。但揭开这层面纱,SOAP 库实际上执行了一个多个 XML 文档和远程服务器参与的复杂处理过程。

SOAP 的贴切定义很复杂,不要误认为 SOAP 就是用于调用远程函数。有些人觉得应该补充上:SOAP 还允许单向异步的信息通过,以及面向文档的 Web 服务。有这样想法的人是正确的,SOAP 的确是这样,但却不止于此。但这一章的重点在于所谓的 “RPC-style” SOAP――调用远程函数获得返回结果。

12.1. 概览

12.1. 概览

你用 Google,对吧?它是一个很流行的搜索引擎。你是否希望能以程序化的方式访问 Google 的搜索结果呢?现在你能做到了。下面是一个用 Python 搜索 Google 的程序。

例 12.1. search.py

from SOAPpy import WSDL
# you'll need to configure these two values;
# see http://www.google.com/apis/
WSDLFILE = '/path/to/copy/of/GoogleSearch.wsdl'
APIKEY = 'YOUR_GOOGLE_API_KEY'
_server = WSDL.Proxy(WSDLFILE)
def search(q):
    """Search Google and return list of {title, link, description}"""
    results = _server.doGoogleSearch(
        APIKEY, q, 0, 10, False, "", False, "", "utf-8", "utf-8")
    return [{"title": r.title.encode("utf-8"),
             "link": r.URL.encode("utf-8"),
             "description": r.snippet.encode("utf-8")}
            for r in results.resultElements]
if __name__ == '__main__':
    import sys
    for r in search(sys.argv[1])[:5]:
        print r['title']
        print r['link']
        print r['description']
        print 

你可以在较大的程序中以模块导入并使用它,也可以在命令行上运行这个脚本。在命令行上,需要把查询字符串作为命令行参数使用,之后就会打印出最前面的五个 Google 查询结果,包括:URL、标题和描述信息。

下面是以 “python” 作为命令行参数的查询结果。

例 12.2. search.py 的使用样例

C:\diveintopython\common\py> python search.py "python"
<b>Python</b> Programming Language
http://www.python.org/
Home page for <b>Python</b>, an interpreted, interactive, object-oriented,
extensible<br> programming language. <b>...</b> <b>Python</b>
is OSI Certified Open Source: OSI Certified.
<b>Python</b> Documentation Index
http://www.python.org/doc/
 <b>...</b> New-style classes (aka descrintro). Regular expressions. Database
API. Email Us.<br> docs@<b>python</b>.org. (c) 2004\. <b>Python</b>
Software Foundation. <b>Python</b> Documentation. <b>...</b>
Download <b>Python</b> Software
http://www.python.org/download/
Download Standard <b>Python</b> Software. <b>Python</b> 2.3.3 is the
current production<br> version of <b>Python</b>. <b>...</b>
<b>Python</b> is OSI Certified Open Source:
Pythonline
http://www.pythonline.com/
Dive Into <b>Python</b>
http://diveintopython.org/
Dive Into <b>Python</b>. <b>Python</b> from novice to pro. Find:
<b>...</b> It is also available in multiple<br> languages. Read
Dive Into <b>Python</b>. This book is still being written. <b>...</b> 

进一步阅读

  • www.xmethods.net/ 是一个访问 SOAP web 服务的公共知识库。
  • SOAP 规范相当可读,如果你喜欢这类东西的话。

12.2. 安装 SOAP 库

12.2. 安装 SOAP 库

  • 12.2.1. 安装 PyXML
  • 12.2.2. 安装 fpconst
  • 12.2.3. 安装 SOAPpy

与本书中的其他代码不同,本章依赖的库不是 Python 预安装的。

在深入学习 SOAP web 服务之前,你需要安装三个库:PyXML、fpconst 和 SOAPpy。

12.2.1. 安装 PyXML

你要用到的第一个库是 PyXML,它是 XML 库的一个高级组件,提供了比我们在 第九章 学习的 XML 内建库更多的功能。

过程 12.1.

下面是安装 PyXML 的步骤:

  1. 访问 pyxml.sourceforge.net/,点击 Downloads,下载适合你所使用操作系统的最新版本。

  2. 如果你所使用的是 Windows,那么你有多个选择。一定要确保你所下载的 PyXML 和你所使用的 Python 版本匹配。

  3. 双击安装程序。如果你下载的是为 Windows 提供的 PyXML 0.8.3,并且你所使用的是 Python 2.3,这个安装程序应该是 PyXML-0.8.3.win32-py2.3.exe

  4. 深入安装过程。

  5. 安装完成后,关闭安装程序,没有任何安装成功的昭示 (并没有在开始菜单、快捷栏或桌面出现图标)。因为 PyXML 仅仅是被其他程序调用的 XML 的库集合。

要检验 PyXML 安装得是否正确,可以运行 Python IDE,下面的指令可以看到 XML 库的安装版本。

例 12.3. 检验 PyXML 安装

>>> import xml
>>> xml.__version__
'0.8.3' 

这个安装版本号应该和你所下载并安装的 PyXML 安装程序版本号一致。

12.2.2. 安装 fpconst

你所需要安装的第二个库是 fpconst,它是一系列支持 IEEE754 double-precision 特殊值的常量和函数,提供了对 Not-a-Number (NaN), Positive Infinity (Inf) 和 Negative Infinity (-Inf) 等特殊值的支持,而这是 SOAP 数据类型规范的组成部分。

过程 12.2.

下面是 fpconst 的安装过程:

  1. www.analytics.washington.edu/statcomp/projects/rzope/fpconst/ 下载 fpconst 的最新版本。

  2. 提供了两种格式的下载:.tar.gz.zip。如果你使用的是 Windows 操作系统,下载 .zip 文件;其他情况下应该下载 .tar.gz 文件。

  3. 对这个文件进行解压缩。在 Windows XP 上你可以鼠标右键单击这个文件并选择“解压文件”;在较早的 Windows 版本上则需要 WinZip 之类的第三方解压程序。在 Mac OS X 上,可以右键单击压缩文件进行解压。

  4. 打开命令提示符窗口并定位到解压目录。

  5. 键入 python setup.py install 运行安装程序。

要检验 fpconst 安装得是否正确,运行 Python IDE 并查看版本号。

例 12.4. 检验 fpconst 安装

>>> import fpconst
>>> fpconst.__version__
'0.6.0' 

这个安装版本号应该和你所下载并用于安装的 fpconst 压缩包版本号一致。

12.2.3. 安装 SOAPpy

第三个,也是最后一个需要安装的库是 SOAP 库本身:SOAPpy。

过程 12.3.

下面是安装 SOAPpy 的过程:

  1. 访问 pywebsvcs.sourceforge.net/ 并选择 SOAPpy 部分中最新的官方发布。

  2. 提供了两种格式的下载。如果你使用的是 Windows,那么下载 .zip 文件;其他情况则下载 .tar.gz 文件。

  3. 和安装 fpconst 时一样先解压下载的文件.

  4. 打开命令提示符窗口并定位到解压 SOAPpy 文件的目录。

  5. 键入 python setup.py install 运行安装程序。

要检验 SOAPpy 安装得是否正确,运行 Python IDE 并查看版本号。

例 12.5. 检验 SOAPpy 安装

>>> import SOAPpy
>>> SOAPpy.__version__
'0.11.4' 

这个安装版本号应该和你所下载并用于安装的 SOAPpy 压缩包版本号一致。

12.3. 步入 SOAP

12.3. 步入 SOAP

调用远程函数是 SOAP 的核心功能。有很多提供公开 SOAP 访问的服务器提供用于展示的简单功能。

最受欢迎的 SOAP 公开访问服务器是 www.xmethods.net/。这个例子使用了一个展示函数,可以根据美国邮政编码返回当地气温。

例 12.6. 获得现在的气温

>>> from SOAPpy import SOAPProxy            
>>> url = 'http://services.xmethods.net:80/soap/servlet/rpcrouter'
>>> namespace = 'urn:xmethods-Temperature'  
>>> server = SOAPProxy(url, namespace)      
>>> server.getTemp('27502')                 
80.0 
[1]你通过 SOAPProxy 这个代理 (proxy) 类访问远程 SOAP 服务器。这个代理处理了所有的 SOAP 内部事务,其中包括:根据函数名和参数列表创建 XML 请求文档,并将这个请求文档通过 HTTP 发送到远程 SOAP 服务器;解析 XML 返回文档,并创建本地的 Python 返回值。在下一节中你将看到这个 XML 文档。
[2]每个 SOAP 服务都有一个 URL 用以处理所有请求。相同的 URL 可以用于所有的函数请求。每个特定服务则只有一个函数。但稍后你将看到的 Google API 却有多个函数。这个服务的 URL 提供给所有函数分享。每个 SOAP 服务都有一个命名空间 (namespace),这个命名空间是由服务器任意命名的。这不过是为调用 SOAP 方法设置的。它使得服务器让多个不相关的服务共享服务 URL 和路径请求成为可能。这与 Python 中模块相对于包的关系类似。
[3]这里你创建了包含服务 URL 和服务命名空间的 SOAPProxy。此时还不会连接到 SOAP 服务器;仅仅是建立了一个本地 Python 对象。
[4]到此为止,如果你的设置完全正确,应该可以向调用本地函数一样调用远程 SOAP 方法。这和给普通函数传递参数并接收返回值一样,但在背后却隐藏着很多的工作。

让我们看一看这些背后的工作。

12.4. SOAP 网络服务查错

12.4. SOAP 网络服务查错

SOAP 提供了一个很方便的方法用以查看背后的情形。

SOAPProxy 的两个小设置就可以打开查错模式。

例 12.7. SOAP 网络服务查错

>>> from SOAPpy import SOAPProxy
>>> url = 'http://services.xmethods.net:80/soap/servlet/rpcrouter'
>>> n = 'urn:xmethods-Temperature'
>>> server = SOAPProxy(url, namespace=n)     
>>> server.config.dumpSOAPOut = 1            
>>> server.config.dumpSOAPIn = 1
>>> temperature = server.getTemp('27502')    
*** Outgoing SOAP ******************************************************
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
  xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"

  xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
  >
<SOAP-ENV:Body>
<ns1:getTemp  SOAP-ENC:root="1">
<v1 xsi:type="xsd:string">27502</v1>
</ns1:getTemp>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
************************************************************************
*** Incoming SOAP ******************************************************
<?xml version='1.0' encoding='UTF-8'?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"

  >
<SOAP-ENV:Body>
<ns1:getTempResponse 
  SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<return xsi:type="xsd:float">80.0</return>
</ns1:getTempResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
************************************************************************ 
>>> temperature
80.0 
[1]首先,和平常一样,建立带有服务 URL 和命名空间的 SOAPProxy
[2]然后,通过设置 server.config.dumpSOAPInserver.config.dumpSOAPOut 打开查错模式。
[3]最后,和平常一样,调用远程 SOAP 方法。SOAP 库将会输出送出的 XML 请求文档和收到的 XML 返回文档。这是 SOAPProxy 为你做的所有工作。有点恐怖,不是吗?让我们来分析一下。

大部分 XML 请求文档都基于模板文件。忽略所有命名空间声明这些对于所有 SOAP 调用都一成不变的东西。这个 “函数调用” 的核心是&lt;Body&gt; 当中的部分:

<ns1:getTemp                                 

  SOAP-ENC:root="1">
<v1 xsi:type="xsd:string">27502</v1>         
</ns1:getTemp> 
[1]这个元素名 getTemp 就是函数名。SOAPProxy 使用 getattr 作为分发器。有别于使用方法名分别调用本地方法,这里使用方法名构造了一个 XML 请求文档。
[2]函数的 XML 元素被存储于一个特别的命名空间,这个命名空间就是你在建立 SOAPProxy 对象时所指定的那个命名空间。也不必为 SOAP-ENC:root 而苦恼,因为它也是基于模板文件的。
[3]函数的参数也被记入 XML 文档。SOAPProxy 查看并确定每个参数的数据类型 (这里是 string 字符串类型)。参数的数据类型记入 xsi:type 属性,并在其后记入实际的字符串值。

返回的 XML 文档同样容易理解,重点在于知道应该忽略掉哪些内容。把注意力集中在 &lt;Body&gt; 部分:

<ns1:getTempResponse                             

  SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<return xsi:type="xsd:float">80.0</return>       
</ns1:getTempResponse> 
[1]服务器传回的值记录在 &lt;getTempResponse&gt; 部分的几行中。通常包括函数名和回应 (Response)。当然其他的内容也可能出现在这里,但 SOAPProxy 所重视的不是这里的元素名,而是命名空间。
[2]服务器返回时所使用的命名空间就是在请求时所用的命名空间,也就是在创建 SOAPProxy 对象时所指定的命名空间。本章稍后的部分中,我们将看到在创建 SOAPProxy 对象时忘记指定功能名空间会怎样。
[3]这是返回值和它的数据类型 (浮点类型 float)。SOAPProxy 使用显式数据类型创建一个本地数据类型的 Python 对象并返回之。

12.5. WSDL 介绍

12.5. WSDL 介绍

SOAPProxy 类本地方法调用并透明地转向到远程 SOAP 方法。正如你所看到的,这是很多的工作,SOAPProxy 快速和透明地完成他们。它没有做到的是提供方法自省的手段。

考虑一下:前面两部分所展现的调用只有一个参数和返回的简单远程 SOAP 方法。服务 URL 和一系列参数及它们的数据类型需要被知道并跟踪。任何的缺失或错误都会导致整体的失败。

这并没有什么可惊讶的。如果我要调用一个本地函数,我需要知道函数所在的包和模块名 (与之对应的则是服务 URL 和命名空间)。我还需要知道正确的函数名以及其函数个数。Python 精妙地不需明示类型,但我还是需要知道有多少个参数需要传递,多少个值将被返回。

最大的区别就在于内省。就像你在 第四章 看到的那样,Python 擅长于让你实时地去探索模块和函数的情况。你可以对一个模块中的所有函数进行列表,并不费吹灰之力地明了函数的声明和参数情况。

WSDL 允许你对 SOAP 网络服务做相同的事情。WSDL 是 “网络服务描述语言 (Web Services Description Language)”的缩写。它尽管是为自如地表述多种类型的网络服务而设定,却也经常用于描述 SOAP 网络服务。

一个 WSDL 文件不过就是一个文件。更具体地讲,是一个 XML 文件。通常存储于你所访问的 SOAP 网络服务这个被描述对象所在的服务器上,并没有什么特殊之处。在本章稍后的位置,我们将下载 Google API 的 WSDL 文件并在本地使用它。这并不意味着本地调用 Google,这个 WSDL 文件所描述的仍旧是 Google 服务器上的远程函数。

在 WSDL 文件中描述了调用相应的 SOAP 网络服务的一切:

  • 服务 URL 和命名空间
  • 网络服务的类型 (可能是 SOAP 的函数调用,但我说过,WSDL 足够自如地去描述网络服务的广泛内容)
  • 有效函数列表
  • 每个函数的参数
  • 每个参数的类型
  • 每个函数的返回值及其数据类型

换言之,一个 WSDL 文件告诉你调用 SOAP 所需要知道的一切。

12.6. 以 WSDL 进行 SOAP 内省

12.6. 以 WSDL 进行 SOAP 内省

就像网络服务舞台上的所有事物,WSDL 也经历了一个充满明争暗斗而且漫长多变的历史。我不打算讲述这段令我伤心的历史。还有一些其他的标准提供相同的支持,但 WSDL 还是胜出,所以我们还是来学习一下如何使用它。

WSDL 最基本的功能便是让你揭示 SOAP 服务器所提供的有效方法。

例 12.8. 揭示有效方法

>>> from SOAPpy import WSDL          
>>> wsdlFile = 'http://www.xmethods.net/sd/2001/TemperatureService.wsdl'
>>> server = WSDL.Proxy(wsdlFile)    
>>> server.methods.keys()            
[u'getTemp'] 
[1]SOAPpy 包含一个 WSDL 解析器。在本书写作之时,它被标示为开发的初级阶段,但我从来没有在解析任何 WSDL 文件时遇到问题。
[2]使用一个 WSDL 文件,你还是要用到一个 proxy 类:WSDL.Proxy,它只需一个参数:WSDL 文件。我指定的是存储在远程服务器上的 WSDL 的 URL,但是这个 proxy 类对于本地的 WSDL 副本工作同样出色。创建 WSDL proxy 将会下载 WSDL 文件并解析它,所以如果 WSDL 文件有任何问题 (或者由于网络问题不能获得) 你会立刻知道。
[3]WSDL proxy 类通过 Python 字典 server.methods 揭示有效函数。所以列出有效方法只需调用字典方法 keys()

好的,你知道这个 SOAP 服务器提供一个方法:getTemp。但是如何去调用它呢?WSDL 也在这方面提供信息。

例 12.9. 揭示一个方法的参数

>>> callInfo = server.methods['getTemp']  
>>> callInfo.inparams                     
[<SOAPpy.wstools.WSDLTools.ParameterInfo instance at 0x00CF3AD0>]
>>> callInfo.inparams[0].name             
u'zipcode'
>>> callInfo.inparams[0].type             
(u'http://www.w3.org/2001/XMLSchema', u'string') 
[1]server.methods 字典中记录一个 SOAPpy 的特别结构,被称为 CallInfoCallInfo 对象中包含着特定函数和函数参数的信息。
[2]函数参数信息存储在 callInfo.inparams 中,这是一个记录每一个参数信息的 ParameterInfo 对象的 Python 列表。
[3]每个 ParameterInfo 对象包含一个 name 属性,这便是参数名。在通过 SOAP 调用函数时,你不需要知道参数名,但 SOAP 支持在调用函数时使用参数名 (类似于 Python)。如果使用参数名,WSDL.Proxy 将会正确地把这些参数关联到远程函数。
[4]每个参数都是都是显式类型的,使用的是在 XML Schema 定义的数据类型。你可以在上一节中发现这一点:XML Schema 命名空间是我让你忽略的模版的一部分。就目前而言,你还是可以继续忽略它。zipcode 参数是一个字符串,如果你向 WSDL.Proxy 对象传递一个 Python 字符串,它会被正确地关联和传递到服务器。

WSDL 还允许你自省函数的返回值。

例 12.10. 揭示方法返回值

>>> callInfo.outparams            
[<SOAPpy.wstools.WSDLTools.ParameterInfo instance at 0x00CF3AF8>]
>>> callInfo.outparams[0].name    
u'return'
>>> callInfo.outparams[0].type
(u'http://www.w3.org/2001/XMLSchema', u'float') 
[1]与揭示函数参数的 callInfo.inparams 对应的是揭示返回值的 callInfo.outparams。它也同样是一个列表,因为通过 SOAP 调用函数时可以返回多个值,就像 Python 函数一样。
[2]ParameterInfo 对象包含 nametype。这个函数返回一个浮点值,它的名字是 return

让我们整合一下,通过 WSDL proxy 调用一个 SOAP 网络服务。

例 12.11. 通过 WSDL proxy 调用一个 SOAP 网络服务

>>> from SOAPpy import WSDL
>>> wsdlFile = 'http://www.xmethods.net/sd/2001/TemperatureService.wsdl')
>>> server = WSDL.Proxy(wsdlFile)               
>>> server.getTemp('90210')                     
66.0
>>> server.soapproxy.config.dumpSOAPOut = 1     
>>> server.soapproxy.config.dumpSOAPIn = 1
>>> temperature = server.getTemp('90210')
*** Outgoing SOAP ******************************************************
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
  xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"

  xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
  >
<SOAP-ENV:Body>
<ns1:getTemp  SOAP-ENC:root="1">
<v1 xsi:type="xsd:string">90210</v1>
</ns1:getTemp>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
************************************************************************
*** Incoming SOAP ******************************************************
<?xml version='1.0' encoding='UTF-8'?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"

  >
<SOAP-ENV:Body>
<ns1:getTempResponse 
  SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<return xsi:type="xsd:float">66.0</return>
</ns1:getTempResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
************************************************************************ 
>>> temperature
66.0 
[1]这比直接调用 SOAP 服务时的设置简单,因为在 WSDL 文件中包含着调用服务所需要的服务 URL 和命名空间。创建 WSDL.Proxy 对象将会下载 WSDL 文件,解析之,并设置一个用以调用实际的 SOAP 网络服务的 SOAPProxy 对象。
[2]只要创建了 WSDL.Proxy 对象,你就可以像调用 SOAPProxy 对象一样简单地调用一个函数。这并不奇怪,WSDL.Proxy 就是一个具有自省方法的 SOAPProxy 封装套件,所以调用函数的语法也是一样的。
[3]你可以通过 server.soapproxy 访问 WSDL.ProxySOAPProxy。这对于打开查错模式很重要,这样一来当你通过 WSDL proxy 调用函数时,它的 SOAPProxy 将会把线路上来往的 XML 文档甩下来。

12.7. 搜索 Google

12.7. 搜索 Google

让我们回到这章开始时你看到的那段代码,获得比当前气温更有价值和令人振奋的信息。

Google 提供了一个 SOAP API,以便通过程序进行 Google 搜索。使用它的前提是,你注册了 Google 网络服务。

过程 12.4. 注册 Google 网络服务

  1. 访问 www.google.com/apis/ 并创建一个账号。唯一的需要是提供一个 E-mail 地址。注册之后,你将通过 E-mail 收到你的 Google API 许可证 (license key)。你需要在调用 Google 搜索函数时使用这个许可证。

  2. 还是在 www.google.com/apis/ 上,下载 Google 网络 APIs 开发工具包 (Google Web APIs developer kit)。它包含着包括 Python 在内的多种语言的样例代码,更重要的是它包含着 WSDL 文件。

  3. 解压这个开发工具包并找到 GoogleSearch.wsdl。将这个文件拷贝到你本地驱动器的一个永久地址。在本章后面位置你会用到它。

你有了开发许可证和 Google WSDL 文件之后就可以和 Google 网络服务打交道了。

例 12.12. 内省 Google 网络服务

>>> from SOAPpy import WSDL
>>> server = WSDL.Proxy('/path/to/your/GoogleSearch.wsdl') 
>>> server.methods.keys()                                  
[u'doGoogleSearch', u'doGetCachedPage', u'doSpellingSuggestion']
>>> callInfo = server.methods['doGoogleSearch']
>>> for arg in callInfo.inparams:                          
... print arg.name.ljust(15), arg.type
key             (u'http://www.w3.org/2001/XMLSchema', u'string')
q               (u'http://www.w3.org/2001/XMLSchema', u'string')
start           (u'http://www.w3.org/2001/XMLSchema', u'int')
maxResults      (u'http://www.w3.org/2001/XMLSchema', u'int')
filter          (u'http://www.w3.org/2001/XMLSchema', u'boolean')
restrict        (u'http://www.w3.org/2001/XMLSchema', u'string')
safeSearch      (u'http://www.w3.org/2001/XMLSchema', u'boolean')
lr              (u'http://www.w3.org/2001/XMLSchema', u'string')
ie              (u'http://www.w3.org/2001/XMLSchema', u'string')
oe              (u'http://www.w3.org/2001/XMLSchema', u'string') 
[1]步入 Google 网络服务很简单:建立一个 WSDL.Proxy 对象并指向到你复制到本地的 Google WSDL 文件。
[2]由 WSDL 文件可知,Google 提供三个函数:doGoogleSearchdoGetCachedPagedoSpellingSuggestion。顾名思义,执行 Google 搜索并返回结果;获得 Google 最后一次扫描该页时获得的缓存;基于常见拼写错误提出单词拼写建议。
[3]doGoogleSearch 函数需要一系列不同类型的参数。注意:WSDL 文件可以告诉你有哪些参数和他们的参数类型,但不能告诉你它们的含义和使用方法。在参数值有限定的情况下,理论上它能够告诉你参数的取值范围,但 Google 的 WSDL 没有那么细化。WSDL.Proxy 不会变魔术,它只能给你 WSDL 文件中提供的信息。

这里简要地列出了 doGoogleSearch 函数的所有参数:

  • key――你注册 Google 网络服务时获得的 Google API 许可证。
  • q――你要搜索的词或词组。其语法与 Google 的网站表单处完全相同,你所知道的高级搜索语法和技巧这里完全适用。
  • start――起始的结果编号。与使用 Google 网页交互搜索时相同,这个函数每次返回 10 个结果。如果你需要查看 “第二” 页结果则需要将 start 设置为 10。
  • maxResults――返回的结果个数。目前的值是 10,当然如果你只对少数返回结果感兴趣或者希望节省网络带宽,也可以定义为返回更少的结果。
  • filter――如果设置为 True,Google 将会过滤结果中重复的页面。
  • restrict――这里设置 country 并跟上一个国家代码可以限定只返回特定国家的结果。例如:countryUK 用于在英国搜索页面。你也可以设定 linuxmac 或者 bsd 以便搜索 Google 定义的技术站点组,或者设为 unclesam 来搜索美国政府站点。
  • safeSearch――如果设置为 True,Google 将会过滤掉色情站点。
  • lr (“language restrict”,语言限制)――这里设置语言限定值返回特定语言的站点。
  • ieoe (“input encoding”,输入编码和 “output encoding”,输出编码)――不赞成使用,都应该是 utf-8

例 12.13. 搜索 Google

>>> from SOAPpy import WSDL
>>> server = WSDL.Proxy('/path/to/your/GoogleSearch.wsdl')
>>> key = 'YOUR_GOOGLE_API_KEY'
>>> results = server.doGoogleSearch(key, 'mark', 0, 10, False, "",
... False, "", "utf-8", "utf-8")             
>>> len(results.resultElements)                  
10
>>> results.resultElements[0].URL                
'http://diveintomark.org/'
>>> results.resultElements[0].title
'dive into <b>mark</b>' 
[1]在设置好 WSDL.Proxy 对象之后,你可以使用十个参数来调用 server.doGoogleSearch。记住要使用你注册 Google 网络服务时授权给你自己的 Google API 许可证。
[2]有很多的返回信息,但我们还是先来看一下实际的返回结果。它们被存储于 results.resultElements 之中,你可以像使用普通的 Python 列表那样来调用它。
[3]resultElements 中的每个元素都是一个包含 URLtitlesnippet 以及其他属性的对象。基于这一点,你可以使用诸如 dir(results.resultElements[0]) 的普通 Python 自省技术来查看有效属性,或者通过 WSDL proxy 对象查看函数的 outparams。不同的方法能带给你相同的结果。

results 对象中所加载的不仅仅是实际的搜索结果。它也含有搜索行为自身的信息,比如耗时和总结果数等 (尽管只返回了 10 条结果)。Google 网页界面中显示了这些信息,通过程序你也同样能获得它们。

例 12.14. 从 Google 获得次要信息

>>> results.searchTime                     
0.224919
>>> results.estimatedTotalResultsCount     
29800000
>>> results.directoryCategories            
[<SOAPpy.Types.structType item at 14367400>:
 {'fullViewableName':
  'Top/Arts/Literature/World_Literature/American/19th_Century/Twain,_Mark',
  'specialEncoding': ''}]
>>> results.directoryCategories[0].fullViewableName
'Top/Arts/Literature/World_Literature/American/19th_Century/Twain,_Mark' 
[1]这个搜索耗时 0.224919 秒。这不包括用于发送和接收 SOAP XML 文档的时间,仅仅是 Google 在接到搜索请求后执行搜索所花费的时间。
[2]总共有接近 30,000,000 个结果信息。通过让 start 参数以 10 递增来重复调用 server.doGoogleSearch,你能够获得全部的结果。
[3]对于有些请求,Google 还返回一个 Google Directory 中的类别列表。你可以用这些 URLs 到 directory.google.com/ 建立到 directory category 页面的链接。

12.8. SOAP 网络服务故障排除

12.8. SOAP 网络服务故障排除

是的,SOAP 网络服务的世界中也不总是欢乐和阳光。有时候也会有故障。

正如你在本章中看到的,SOAP 牵扯了很多层面。SOAP 向 HTTP 服务器发送 XML 文档并接收返回的 XML 文档时需要用到 HTTP 层。这样一来,你在 第十一章 HTTP Web 服务 学到的调试技术在这里都有了用武之地。你可以 import httplib 并设置 httplib.HTTPConnection.debuglevel = 1 来查看潜在的 HTTP 传输。

在 HTTP 层之上,还有几个可能发生问题的地方。SOAPpy 隐藏 SOAP 语法的本领令你惊叹不已,但也意味着在发生问题时更难确定问题所在。

下面的这些例子是我在使用 SOAP 网络服务时犯过的一些常见错误以及所产生的错误信息。

例 12.15. 以错误的设置调用 Proxy 方法

>>> from SOAPpy import SOAPProxy
>>> url = 'http://services.xmethods.net:80/soap/servlet/rpcrouter'
>>> server = SOAPProxy(url)                                        
>>> server.getTemp('27502')                                        
<Fault SOAP-ENV:Server.BadTargetObjectURI:
Unable to determine object id from call: is the method element namespaced?>
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 453, in __call__
    return self.__r_call(*args, **kw)
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 475, in __r_call
    self.__hd, self.__ma)
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 389, in __call
    raise p
SOAPpy.Types.faultType: <Fault SOAP-ENV:Server.BadTargetObjectURI:
Unable to determine object id from call: is the method element namespaced?> 
[1]你看出错误了吗?你手工地创建了一个 SOAPProxy,你正确地指定了服务 URL,但是你没有指定命名空间。由于多个服务可能被路由到相同的服务 URL,命名空间是确定你所调用的服务和方法的重要内容。
[2]服务器返回的是一个 SOAP 错误 (Fault),SOAPpy 把它转换为 Python 异常 SOAPpy.Types.faultType。从任何 SOAP 服务器返回的错误都是 SOAP 错误,因此你可以轻易地捕获这个异常。就此处而言,我们能从 SOAP 错误信息中看出端倪:由于源 SOAPProxy 对象没有设置服务命名空间,因此方法元素也就没有了命名空间。

错误配置 SOAP 服务的基本元素是 WSDL 着眼解决的问题。WSDL 文件包含服务 URL 和命名空间,所以你应该不会在这里犯错。但是,还有其他可能出错的地方。

例 12.16. 以错误参数调用方法

>>> wsdlFile = 'http://www.xmethods.net/sd/2001/TemperatureService.wsdl'
>>> server = WSDL.Proxy(wsdlFile)
>>> temperature = server.getTemp(27502)                                
<Fault SOAP-ENV:Server: Exception while handling service request:
services.temperature.TempService.getTemp(int) -- no signature match>   [2]
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 453, in __call__
    return self.__r_call(*args, **kw)
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 475, in __r_call
    self.__hd, self.__ma)
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 389, in __call
    raise p
SOAPpy.Types.faultType: <Fault SOAP-ENV:Server: Exception while handling service request:
services.temperature.TempService.getTemp(int) -- no signature match> 
[1]你看出错误了吗?这是一个不易察觉的错误:你在使用整数而不是字符串来调用 server.getTemp 。自省 WSDL 文件不难发现,getTemp() 这个 SOAP 函数接受一个参数 zipcode,这是一个字符串参数。WSDL.Proxy 会为你强制转换数据类型;你需要根据服务器需要的数据类型传递数据。
[2]又是这样,服务器传回一个 SOAP 错误,你能从 SOAP 错误信息中看出端倪:你在使用整数类型的参数调用 getTemp 函数,但却没有一个以此命名的函数接收整数参数。理论上讲,SOAP 允许你重载 (overload) 函数,也就是可以在同一个 SOAP 服务中存在同名函数,并且参数个数也相同,但是参数的数据类型不同。这就是数据类型必须匹配的原因,也说明了为什么 WSDL.Proxy 不强制地为你改变数据类型。如果真的强制改变了数据类型,发生这样的错误时,调用的可能是另外一个不相干的函数。看来产生这样的错误是件幸运的事。对于数据类型多加注意会让事情简单很多,一旦搞错了数据类型便立刻会发生错误。

Python 所期待的返回值个数与远程函数的实际返回值个数不同是另一种可能的错误。

例 12.17. 调用时方法所期待的返回值个数错误

>>> wsdlFile = 'http://www.xmethods.net/sd/2001/TemperatureService.wsdl'
>>> server = WSDL.Proxy(wsdlFile)
>>> (city, temperature) = server.getTemp(27502)  
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
TypeError: unpack non-sequence 
[1]你看出错误了吗?server.getTemp 只返回一个浮点值,但你写的代码却期待着获得两个值,并把它们赋值给不同的两个变量。注意这不是一个 SOAP 错误。就远程服务器而言没有发生任何错误。错误发生在完成 SOAP 交割之后WSDL.Proxy 返回一个浮点数,你本地的 Python 解释器试图将这个浮点数分成两个变量。由于函数只返回了一个值,你在试图分割它时所获得的是一个 Python 异常,而不是 SOAP 错误。

那么 Google 网络服务方面又如何呢?我曾经犯过的最常见的错误是忘记正确设置应用许可证。

例 12.18. 调用方法返回一个应用特定的错误

>>> from SOAPpy import WSDL
>>> server = WSDL.Proxy(r'/path/to/local/GoogleSearch.wsdl')
>>> results = server.doGoogleSearch('foo', 'mark', 0, 10, False, "", 
... False, "", "utf-8", "utf-8")
<Fault SOAP-ENV:Server:                                              [2]
 Exception from service object: Invalid authorization key: foo:
 <SOAPpy.Types.structType detail at 14164616>:
 {'stackTrace':
  'com.google.soap.search.GoogleSearchFault: Invalid authorization key: foo
   at com.google.soap.search.QueryLimits.lookUpAndLoadFromINSIfNeedBe(
     QueryLimits.java:220)
   at com.google.soap.search.QueryLimits.validateKey(QueryLimits.java:127)
   at com.google.soap.search.GoogleSearchService.doPublicMethodChecks(
     GoogleSearchService.java:825)
   at com.google.soap.search.GoogleSearchService.doGoogleSearch(
     GoogleSearchService.java:121)
   at sun.reflect.GeneratedMethodAccessor13.invoke(Unknown Source)
   at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
   at java.lang.reflect.Method.invoke(Unknown Source)
   at org.apache.soap.server.RPCRouter.invoke(RPCRouter.java:146)
   at org.apache.soap.providers.RPCJavaProvider.invoke(
     RPCJavaProvider.java:129)
   at org.apache.soap.server.http.RPCRouterServlet.doPost(
     RPCRouterServlet.java:288)
   at javax.servlet.http.HttpServlet.service(HttpServlet.java:760)
   at javax.servlet.http.HttpServlet.service(HttpServlet.java:853)
   at com.google.gse.HttpConnection.runServlet(HttpConnection.java:237)
   at com.google.gse.HttpConnection.run(HttpConnection.java:195)
   at com.google.gse.DispatchQueue$WorkerThread.run(DispatchQueue.java:201)
Caused by: com.google.soap.search.UserKeyInvalidException: Key was of wrong size.
   at com.google.soap.search.UserKey.<init>(UserKey.java:59)
   at com.google.soap.search.QueryLimits.lookUpAndLoadFromINSIfNeedBe(
     QueryLimits.java:217)
   ... 14 more
'}>
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 453, in __call__
    return self.__r_call(*args, **kw)
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 475, in __r_call
    self.__hd, self.__ma)
  File "c:\python23\Lib\site-packages\SOAPpy\Client.py", line 389, in __call
    raise p
SOAPpy.Types.faultType: <Fault SOAP-ENV:Server: Exception from service object:
Invalid authorization key: foo:
<SOAPpy.Types.structType detail at 14164616>:
{'stackTrace':
  'com.google.soap.search.GoogleSearchFault: Invalid authorization key: foo
   at com.google.soap.search.QueryLimits.lookUpAndLoadFromINSIfNeedBe(
     QueryLimits.java:220)
   at com.google.soap.search.QueryLimits.validateKey(QueryLimits.java:127)
   at com.google.soap.search.GoogleSearchService.doPublicMethodChecks(
     GoogleSearchService.java:825)
   at com.google.soap.search.GoogleSearchService.doGoogleSearch(
     GoogleSearchService.java:121)
   at sun.reflect.GeneratedMethodAccessor13.invoke(Unknown Source)
   at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
   at java.lang.reflect.Method.invoke(Unknown Source)
   at org.apache.soap.server.RPCRouter.invoke(RPCRouter.java:146)
   at org.apache.soap.providers.RPCJavaProvider.invoke(
     RPCJavaProvider.java:129)
   at org.apache.soap.server.http.RPCRouterServlet.doPost(
     RPCRouterServlet.java:288)
   at javax.servlet.http.HttpServlet.service(HttpServlet.java:760)
   at javax.servlet.http.HttpServlet.service(HttpServlet.java:853)
   at com.google.gse.HttpConnection.runServlet(HttpConnection.java:237)
   at com.google.gse.HttpConnection.run(HttpConnection.java:195)
   at com.google.gse.DispatchQueue$WorkerThread.run(DispatchQueue.java:201)
Caused by: com.google.soap.search.UserKeyInvalidException: Key was of wrong size.
   at com.google.soap.search.UserKey.<init>(UserKey.java:59)
   at com.google.soap.search.QueryLimits.lookUpAndLoadFromINSIfNeedBe(
     QueryLimits.java:217)
   ... 14 more
'}> 
[1]你看出错误了吗?调用的语法,参数个数以及数据类型都没有错误。这个问题是应用特定的:第一个参数应该是我的应用许可证,但 foo 不是一个有效的 Google 许可证。
[2]Google 服务器返回的是一个 SOAP 错误和一大串特别长的错误信息,其中包含了完整的 Java 堆栈跟踪。记住所有 的 SOAP 错误都被标示为 SOAP Faults: errors in configuration (设置错误), errors in function arguments (函数参数错误),或者是应用特定的错误 (这里就是) 等等。在其中埋藏的至关重要信息是:Invalid authorization key: foo (非有效授权许可证:foo)。

进一步阅读

  • New developments for SOAPpy 一步步连接到另一个不名副其实的 SOAP 服务。

12.9. 小结

12.9. 小结

SOAP 网络服务是很复杂的,雄心勃勃的它试图涵盖网络服务的很多不同应用。这一章我们接触了它的一个简单应用。

在开始下一章的学习之前,确保你能自如地做如下工作:

  • 连接到 SOAP 服务器并调用远程方法
  • 通过 WSDL 文件自省远程方法
  • 有效排除 SOAP 调用中的错误
  • 排除常见的 SOAP 相关错误

第十三章 单元测试

第十三章 单元测试

  • 13.1. 罗马数字程序介绍 II
  • 13.2. 深入
  • 13.3. romantest.py 介绍
  • 13.4. 正面测试 (Testing for success)
  • 13.5. 负面测试 (Testing for failure)
  • 13.6. 完备性检测 (Testing for sanity)

13.1. 罗马数字程序介绍 II

13.1. 罗马数字程序介绍 II

在前面的章节中,通过阅读代码,你迅速“深入”,以最快的速度理解了各个程序。既然你已对 Python 有了一定的了解,那么接下来让我们看看程序开发之前 的工作。

在接下来的几章中,你将会编写、调试和优化一系列工具函数来进行罗马数字和阿拉伯数字之间的转换。你已从第 7.3 节 “个案研究:罗马字母”中获知构造和验证罗马数字的机制,现在我们要做的事是退后一步去思考如何将这些机制扩展到一个双向转换的工具。

罗马数字的规则有如下一些有趣的特点:

  1. 一个特定数字以罗马数字表示时只有单一方式。
  2. 反之亦然:一个有效的罗马数字表示的数也只对应一个阿拉伯数字表示。(也就是说转换成阿拉伯数字表示只有一种方法。)
  3. 我们研究的是 13999 之间的数字的罗马数字表示。(罗马数字有很多方法用以记录更大的数,例如在数字上加线表示1000倍的数,但你不必去理会这些。就本章而言,我们姑且把罗马数字限定在 13999 之间)。
  4. 罗马数字无法表示 0。(令人诧异,古罗马竟然没有 0 这个数字的概念。数字是为数数服务的,没有怎么数呢?)
  5. 罗马数字不能表示负数。
  6. 罗马数字无法表示分数和非整数。

基于如上所述,你将如何构造罗马数字转换函数呢?

roman.py 功能需求

  1. toRoman 应该能返回 13999 中任意数的罗马数字表示。
  2. toRoman 在遇到 13999 之外的数字时应该失败。
  3. toRoman 在遇到非整数时应该失败。
  4. fromRoman 应该能将给定的有效罗马数字表示转换为阿拉伯数字表示。
  5. fromRoman 在遇到无效罗马数字表示时应该失败。
  6. 将一个数转换为罗马数字表示,再转换回阿拉伯数字表示后应该和最初的数相同。因此,fromRoman(toRoman(n)) == n 对于 1..3999 之间所有 n 都适用。
  7. toRoman 返回的罗马数字应该使用大写字母。
  8. fromRoman 应该只接受大写罗马数字 (也就是说给定小写字母进行转换时应该失败)。

进一步阅读

  • 这个站点 有关于罗马数字更多的内容,包括罗马人如何使用罗马数字的迷人 历史 (简言之:充满偶然性和反复无常)。

13.2. 深入

13.2. 深入

现在你已经定义了你的转换程序所应有的功能,下面一步会有点儿出乎你的意料:你将要开发一个测试组件 (test suite) 来测试你未来的函数以确保它们工作正常。没错:你将为还未开发的程序开发测试代码。

这就是所谓的单元测试,因为这两个转换函数可以被当作一个单元来开发和测试,不用考虑它们可能今后成为一个大程序的一部分。Python 有一个单元测试框架,被恰如其分地称作 unittest 模块。

注意 Python 2.1 和之后的版本已经包含了 unittest。Python 2.0 用户则可以从 pyunit.sourceforge.net下载。

单元测试是以测试为核心开发策略的重要组成部分。如果你要写单元测试代码,尽早 (最好是在被测试代码开发之前) 开发并根据代码开发和需求的变化不断更新是很重要的。单元测试不能取代更高层面的功能和系统测试,但在开发的每个阶段都很重要:

  • 代码开发之前,强迫你以有效的方式考虑需求的细节。
  • 代码开发中,防止过度开发。通过了所有测试用例,程序的开发就完成了。
  • 重构代码时,确保新版和旧版功能一致。
  • 维护代码时,当你的代码更改导致别人代码出问题时帮你留住面子。(“但是先生,我检入 (check in) 代码时所有的单元测试都通过了……”)
  • 在团队开发时,可以使你有信心,保证自己提交的代码不会破坏其他人的代码,因为你可以 先运行其他人的单元测试代码。(我在“代码风暴”中见过这种事情。一个团队将任务拆分,每个人都根据自己那部分的需求开发单元测试,然后与其他成员共享。没有人会出太大的偏差而导致代码无法集成。)

13.3. romantest.py 介绍

13.3. romantest.py 介绍

这是将被开发并保存为 roman.py 的罗马数字转换程序的完整测试组件 (test suite)。很难立刻看出它们是如何协同工作的,似乎所有类或者方法之间都没有关系。这是有原因的,而且你很快就会明了。

例 13.1. romantest.py

如果您还没有下载本书附带的样例程序, 可以 下载本程序和其他样例程序

"""Unit test for roman.py"""
import roman
import unittest
class KnownValues(unittest.TestCase):                          
    knownValues = ( (1, 'I'),
                    (2, 'II'),
                    (3, 'III'),
                    (4, 'IV'),
                    (5, 'V'),
                    (6, 'VI'),
                    (7, 'VII'),
                    (8, 'VIII'),
                    (9, 'IX'),
                    (10, 'X'),
                    (50, 'L'),
                    (100, 'C'),
                    (500, 'D'),
                    (1000, 'M'),
                    (31, 'XXXI'),
                    (148, 'CXLVIII'),
                    (294, 'CCXCIV'),
                    (312, 'CCCXII'),
                    (421, 'CDXXI'),
                    (528, 'DXXVIII'),
                    (621, 'DCXXI'),
                    (782, 'DCCLXXXII'),
                    (870, 'DCCCLXX'),
                    (941, 'CMXLI'),
                    (1043, 'MXLIII'),
                    (1110, 'MCX'),
                    (1226, 'MCCXXVI'),
                    (1301, 'MCCCI'),
                    (1485, 'MCDLXXXV'),
                    (1509, 'MDIX'),
                    (1607, 'MDCVII'),
                    (1754, 'MDCCLIV'),
                    (1832, 'MDCCCXXXII'),
                    (1993, 'MCMXCIII'),
                    (2074, 'MMLXXIV'),
                    (2152, 'MMCLII'),
                    (2212, 'MMCCXII'),
                    (2343, 'MMCCCXLIII'),
                    (2499, 'MMCDXCIX'),
                    (2574, 'MMDLXXIV'),
                    (2646, 'MMDCXLVI'),
                    (2723, 'MMDCCXXIII'),
                    (2892, 'MMDCCCXCII'),
                    (2975, 'MMCMLXXV'),
                    (3051, 'MMMLI'),
                    (3185, 'MMMCLXXXV'),
                    (3250, 'MMMCCL'),
                    (3313, 'MMMCCCXIII'),
                    (3408, 'MMMCDVIII'),
                    (3501, 'MMMDI'),
                    (3610, 'MMMDCX'),
                    (3743, 'MMMDCCXLIII'),
                    (3844, 'MMMDCCCXLIV'),
                    (3888, 'MMMDCCCLXXXVIII'),
                    (3940, 'MMMCMXL'),
                    (3999, 'MMMCMXCIX'))                       
    def testToRomanKnownValues(self):                          
        """toRoman should give known result with known input"""
        for integer, numeral in self.knownValues:              
            result = roman.toRoman(integer)                    
            self.assertEqual(numeral, result)                  
    def testFromRomanKnownValues(self):                          
        """fromRoman should give known result with known input"""
        for integer, numeral in self.knownValues:                
            result = roman.fromRoman(numeral)                    
            self.assertEqual(integer, result)                    
class ToRomanBadInput(unittest.TestCase):                            
    def testTooLarge(self):                                          
        """toRoman should fail with large input"""                   
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 4000)
    def testZero(self):                                              
        """toRoman should fail with 0 input"""                       
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 0)   
    def testNegative(self):                                          
        """toRoman should fail with negative input"""                
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, -1)  
    def testNonInteger(self):                                        
        """toRoman should fail with non-integer input"""             
        self.assertRaises(roman.NotIntegerError, roman.toRoman, 0.5) 
class FromRomanBadInput(unittest.TestCase):                                      
    def testTooManyRepeatedNumerals(self):                                       
        """fromRoman should fail with too many repeated numerals"""              
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):             
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s)
    def testRepeatedPairs(self):                                                 
        """fromRoman should fail with repeated pairs of numerals"""              
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):               
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s)
    def testMalformedAntecedent(self):                                           
        """fromRoman should fail with malformed antecedents"""                   
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):                       
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s)
class SanityCheck(unittest.TestCase):        
    def testSanity(self):                    
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 4000):       
            numeral = roman.toRoman(integer) 
            result = roman.fromRoman(numeral)
            self.assertEqual(integer, result)
class CaseCheck(unittest.TestCase):                   
    def testToRomanCase(self):                        
        """toRoman should always return uppercase"""  
        for integer in range(1, 4000):                
            numeral = roman.toRoman(integer)          
            self.assertEqual(numeral, numeral.upper())
    def testFromRomanCase(self):                      
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 4000):                
            numeral = roman.toRoman(integer)          
            roman.fromRoman(numeral.upper())          
            self.assertRaises(roman.InvalidRomanNumeralError,
                              roman.fromRoman, numeral.lower())
if __name__ == "__main__":
    unittest.main() 

进一步阅读

13.4. 正面测试 (Testing for success)

13.4. 正面测试 (Testing for success)

单元测试的基础是构建独立的测试用例 (test case)。一个测试用例只回答一个关于被测试代码的问题。

一个测试用例应该做到:

  • 完全独立运行,不需要人工输入。单元测试应该是自动的。
  • 可以自己判断被测试函数是通过还是失败,不需要人工干预结果。
  • 独立运行,可以与其他测试用例隔离 (尽管它们可能测试着同一个函数)。每个测试用例是一个孤岛。

基于如上原则,让我们构建第一个测试用例。应符合如下要求:

  1. toRoman 应该为所有 13999 的整数返回罗马数字表示。

例 13.2. testToRomanKnownValues

 class KnownValues(unittest.TestCase):                           
    knownValues = ( (1, 'I'),
                    (2, 'II'),
                    (3, 'III'),
                    (4, 'IV'),
                    (5, 'V'),
                    (6, 'VI'),
                    (7, 'VII'),
                    (8, 'VIII'),
                    (9, 'IX'),
                    (10, 'X'),
                    (50, 'L'),
                    (100, 'C'),
                    (500, 'D'),
                    (1000, 'M'),
                    (31, 'XXXI'),
                    (148, 'CXLVIII'),
                    (294, 'CCXCIV'),
                    (312, 'CCCXII'),
                    (421, 'CDXXI'),
                    (528, 'DXXVIII'),
                    (621, 'DCXXI'),
                    (782, 'DCCLXXXII'),
                    (870, 'DCCCLXX'),
                    (941, 'CMXLI'),
                    (1043, 'MXLIII'),
                    (1110, 'MCX'),
                    (1226, 'MCCXXVI'),
                    (1301, 'MCCCI'),
                    (1485, 'MCDLXXXV'),
                    (1509, 'MDIX'),
                    (1607, 'MDCVII'),
                    (1754, 'MDCCLIV'),
                    (1832, 'MDCCCXXXII'),
                    (1993, 'MCMXCIII'),
                    (2074, 'MMLXXIV'),
                    (2152, 'MMCLII'),
                    (2212, 'MMCCXII'),
                    (2343, 'MMCCCXLIII'),
                    (2499, 'MMCDXCIX'),
                    (2574, 'MMDLXXIV'),
                    (2646, 'MMDCXLVI'),
                    (2723, 'MMDCCXXIII'),
                    (2892, 'MMDCCCXCII'),
                    (2975, 'MMCMLXXV'),
                    (3051, 'MMMLI'),
                    (3185, 'MMMCLXXXV'),
                    (3250, 'MMMCCL'),
                    (3313, 'MMMCCCXIII'),
                    (3408, 'MMMCDVIII'),
                    (3501, 'MMMDI'),
                    (3610, 'MMMDCX'),
                    (3743, 'MMMDCCXLIII'),
                    (3844, 'MMMDCCCXLIV'),
                    (3888, 'MMMDCCCLXXXVIII'),
                    (3940, 'MMMCMXL'),
                    (3999, 'MMMCMXCIX'))                        
    def testToRomanKnownValues(self):                           
        """toRoman should give known result with known input"""
        for integer, numeral in self.knownValues:              
            result = roman.toRoman(integer)                      
            self.assertEqual(numeral, result) 
[1]编写测试用例的第一步就是继承 unittest 模块中的 TestCase 类,它提供了很多可以用在你的测试用例中来测试特定情况的有用方法。
[2]这是我手工转换的一个 integer/numeral 对列表。它包含了最小的十个数、最大的数、每个单字符罗马数字对应的数,以及其他随机挑选的有效数样本。单元测试的关键不在于所有可能的输入,而是一个有代表性的样本。
[3]每个独立测试本身都是一个方法,既不需要参数也不返回任何值。如果该方法正常退出没有引发异常,测试被认为通过;如果测试引发异常,测试被认为失败。
[4]这里你真正调用了 toRoman 函数。(当然,函数还没有编写,但一旦被编写,这里便是调用之处。) 注意你在这里为 toRoman 函数定义了 API :它必须接受整数 (待转换的数) 并返回一个字符串 (对应的罗马数字表示),如果 API 不是这样,测试将失败。
[5]同样值得注意,你在调用 toRoman 时没有试图捕捉任何可能发生的异常。这正是我们所希望的。以有效输入调用 toRoman 不会引发任何异常,而你看到的这些输入都是有效的。如果 toRoman 引发了异常,则测试失败。
[6]假设 toRoman 函数被正确编写,正确调用,运行成功并返回一个值,最后一步便是检查这个返回值正确 与否。这是一个常见的问题,TestCase 类提供了一个方法:assertEqual,来测试两个值是否相等。如果 toRoman 返回的结果 (value) 不等于我们预期的值 (numeral),assertEqual 将会引发一个异常,测试也就此失败。如果两个值相等,assertEqual 什么也不做。如果每个从 toRoman 返回的值都等于预期值,assertEqual 便不会引发异常,于是 testToRomanKnownValues 最终正常退出,这意味着 toRoman 通过了该测试。

13.5. 负面测试 (Testing for failure)

13.5. 负面测试 (Testing for failure)

使用有效输入确保函数成功通过测试还不够,你还需要测试无效输入导致函数失败的情形。但并不是任何失败都可以,必须如你预期地失败。

还记得 toRoman 的其他要求吧:

  1. toRoman 在输入值为 13999 之外时失败。
  2. toRoman 在输入值为非整数时失败。

在 Python 中,函数以引发异常的方式表示失败。unittest 模块提供了用于测试函数是否在给定无效输入时引发特定异常的方法。

例 13.3. 测试 toRoman 的无效输入

 class ToRomanBadInput(unittest.TestCase):                            
    def testTooLarge(self):                                          
        """toRoman should fail with large input"""                   
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 4000) 
    def testZero(self):                                              
        """toRoman should fail with 0 input"""                       
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 0)    
    def testNegative(self):                                          
        """toRoman should fail with negative input"""                
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, -1)  
    def testNonInteger(self):                                        
        """toRoman should fail with non-integer input"""             
        self.assertRaises(roman.NotIntegerError, roman.toRoman, 0.5) 
[1]unittest 模块中的 TestCase 类提供了 assertRaises 方法,它接受这几个参数:预期的异常、测试的函数,以及传递给函数的参数。(如果被测试函数有不止一个参数,把它们按顺序全部传递给 assertRaises ,它会把这些参数传给被测的函数。) 特别注意这里的操作:不是直接调用 toRoman 再手工查看是否引发特定异常 (使用 try...except 块捕捉异常),assertRaises 为我们封装了这些。所有你要做的就是把异常 (roman.OutOfRangeError)、函数 (toRoman) 以及 toRoman 的参数 (4000) 传递给 assertRaises ,它会调用 toRoman 查看是否引发 roman.OutOfRangeError 异常。(还应注意到你是把 toRoman 函数本身当作一个参数,而不是调用它,传递它的时候也不是把它的名字作为一个字符串。我提到过吗?无论是函数还是异常, Python 中万物皆对象)。
[2]与测试过大的数相伴的便是测试过小的数。记住,罗马数字不能表示 0 和负数,所以你要分别编写测试用例 ( testZerotestNegative)。在 testZero 中,你测试 toRoman 调用 0 引发的 roman.OutOfRangeError 异常,如果没能 引发 roman.OutOfRangeError (不论是返回了一个值还是引发了其他异常),则测试失败。
[3]要求 #3:toRoman 不能接受非整数输入,所以这里你测试 toRoman 在输入 0.5 时引发 roman.NotIntegerError 异常。如果 toRoman 没有引发 roman.NotIntegerError 异常,则测试失败。

接下来的两个要求与前三个类似,不同点是他们所针对的是 fromRoman 而不是 toRoman

  1. fromRoman 应该能将输入的有效罗马数字转换为相应的阿拉伯数字表示。
  2. fromRoman 在输入无效罗马数字时应该失败。

要求 #4 与要求 #1 的处理方法相同,即测试一个已知样本中的一个个数字对。要求 #5 与 #2 和 #3 的处理方法相同,即通过无效输入确认 fromRoman 引发恰当的异常。

例 13.4. 测试 fromRoman 的无效输入

 class FromRomanBadInput(unittest.TestCase):                                      
    def testTooManyRepeatedNumerals(self):                                       
        """fromRoman should fail with too many repeated numerals"""              
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):             
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s) 
    def testRepeatedPairs(self):                                                 
        """fromRoman should fail with repeated pairs of numerals"""              
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):               
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s)
    def testMalformedAntecedent(self):                                           
        """fromRoman should fail with malformed antecedents"""                   
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):                       
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s) 
[1]没什么新鲜的,与测试 toRoman 无效输入时相同的模式,只是你有了一个新的异常:roman.InvalidRomanNumeralErrorroman.py 中一共要定义三个异常 (另外的两个是 roman.OutOfRangeErrorroman.NotIntegerError)。稍后你在开始编写 roman.py 时将会知道如何定义这些异常。

13.6. 完备性检测 (Testing for sanity)

13.6. 完备性检测 (Testing for sanity)

你经常会发现一组代码中包含互逆的转换函数,一个把 A 转换为 B ,另一个把 B 转换为 A。在这种情况下,创建“完备性检测”可以使你在由 A 转 B 再转 A 的过程中不会出现丢失精度或取整等错误。

考虑这个要求:

  1. 如果你给定一个数,把它转化为罗马数字表示,然后再转换回阿拉伯数字表示,你所得到的应该是最初给定的那个数。因此,对于 1..3999 中的nfromRoman(toRoman(n)) == n 总成立。

例 13.5. 以 toRoman 测试 fromRoman 的输出

 class SanityCheck(unittest.TestCase):        
    def testSanity(self):                    
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 4000):         
            numeral = roman.toRoman(integer) 
            result = roman.fromRoman(numeral)
            self.assertEqual(integer, result) 
[1]你已经见到过 range 函数,但这里它以两个参数被调用,返回了从第一个参数 (1) 开始到但不包括 第二个参数 (4000) 的整数列表。因此,1..3999 就是准备转换为罗马数字表示的有效值列表。
[2]我想提一下,这里的 integer 并不是一个 Python 关键字,而只是没有什么特别的变量名。
[3]这里的测试逻辑显而易见:把一个数 (integer) 转换为罗马数字表示的数 (numeral),然后再转换回来 (result) 并确保最后的结果和最初的数是同一个数。如果不是,assertEqual 便会引发异常,测试也便立刻失败。如果所有的结果都和初始数一致,assertEqual 将会保持沉默,整个 testSanity 方法将会最终也保持沉默,测试则将会被认定为通过。

最后两个要求和其他的要求不同,似乎既武断而又微不足道:

  1. toRoman 返回的罗马数字应该使用大写字母。
  2. fromRoman 应该只接受大写罗马数字 (也就是说给定小写字母进行转换时应该失败)。

事实上,它们确实有点武断,譬如你完全可以让 fromRoman 接受小写和大小写混合的输入;但他们也不是完全武断;如果 toRoman 总是返回大写的输出,那么 fromRoman 至少应该接受大写字母输入,不然 “完备性检测” (要求 #6) 就会失败。不管怎么说, 接受大写输入还是武断的,但就像每个系统都会告诉你的那样,大小写总会出问题,因此事先规定这一点还是有必要的。既然有必要规定,那么也就有必要测试。

例 13.6. 大小写测试

 class CaseCheck(unittest.TestCase):                   
    def testToRomanCase(self):                        
        """toRoman should always return uppercase"""  
        for integer in range(1, 4000):                
            numeral = roman.toRoman(integer)          
            self.assertEqual(numeral, numeral.upper())         
    def testFromRomanCase(self):                      
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 4000):                
            numeral = roman.toRoman(integer)          
            roman.fromRoman(numeral.upper())                    
            self.assertRaises(roman.InvalidRomanNumeralError,
                              roman.fromRoman, numeral.lower()) 
[1]关于这个测试用例最有趣的一点不在于它测试了什么,而是它不测试什么。它不会测试 toRoman 的返回值是否正确或者一致;这些问题由其他测试用例来回答。整个测试用例仅仅测试大写问题。你也许觉得应该将它并入到完备性测试,毕竟都要遍历整个输入值范围并调用 toRoman。[11]但是这样将会违背一条基本规则"):每个测试用例只回答一个的问题。试想一下,你将这个测试并入到完备性测试中,然后遇到了测试失败。你还需要进一步分析以便判定测试用例的哪部分出了问题。如果你需要分析方能找出问题所在,无疑你的测试用例在设计上出了问题。
[2]这有一个和前面相似的情况:尽管 “你知道” toRoman 总是返回大写字母,你还是需要把返回值显式地转换成大写字母后再传递给只接受大写的 fromRoman 进行测试。为什么?因为 toRoman 只返回大写字母是一个独立的需求。如果你改变了这个需求,例如改成总是返回小写字母,那么 testToRomanCase 测试用例也应作出调整,但这个测试用例应该仍能通过。这是另外一个基本规则"):每个测试用例必须可以与其他测试用例隔离工作,每个测试用例是一个“孤岛”。
[3]注意你并没有使用 fromRoman 的返回值。这是一个有效的 Python 语法:如果一个函数返回一个值,但没有被使用,Python 会直接把这个返回值扔掉。这正是你所希望的,这个测试用例并不对返回值进行测试,只是测试 fromRoman 接受大写字母而不引发异常。
[4]这行有点复杂,但是它与 ToRomanBadInputFromRomanBadInput 测试很相似。 你在测试以特定值 (numeral.lower(),循环中目前罗马数字的小写版) 调用特定函数 (roman.fromRoman) 会确实引发特定的异常 (roman.InvalidRomanNumeralError)。如果 (在循环中的每一次) 确实如此,测试通过;如果有一次不是这样 (比如引发另外的异常或者不引发异常),测试失败。

在下一章中,你将看到如何编写可以通过这些测试的代码。

Footnotes

[11] “除了诱惑什么我都能抗拒。 (I can resist everything except temptation.)”――Oscar Wilde