使用其他 Web 服务扩展 Web 服务

作者:Reuven M. Lerner

上个月,我们了解了在线巨头 Amazon 提供的最新 Web 服务。Amazon 是最早拥抱 Web 服务的公司之一,尽管它的一些较新产品需要按月或按查询付费,但基本目录搜索仍然免费提供。

如果我们将每个单独的 Web 服务视为一个函数调用,我们可以将 Web 服务集合(例如 Amazon Web Services (AWS))视为一个软件库。尽管我们当然可以使用这些库创建有趣的应用程序,但创建位于现有库之上的新库通常很有用。在许多方面,软件的历史就是通过将库堆叠在彼此之上来创建越来越强大的抽象的历史。在课堂之外,我们大多数人从未需要实现排序算法或创建缓冲 I/O 库,仅仅是因为这些东西已经被前几代程序员编写和优化。

因此,我认为将 AWS 视为一组例程是有用的,我们可以将其合并到最终用户程序中,而不是将其视为一组低级库,我们可以在其之上(并且应该)创建适合我们特定需求的新库。

本月,我们将看一个关于我所说内容的简单示例。该项目将反映我对书籍的热爱。互联网使我难以停止购买二手书,因为很多书都以低价出售。但是,我很幸运能在伊利诺伊州斯 Skokie 度过几年,那里有一个很棒的公共图书馆。Skokie 的图书馆不仅藏书丰富,而且还具有基于 Web 的图书目录界面。因此,我们本月的项目是创建一个 Web 服务,将 Amazon 的目录与 Skokie 公共图书馆的信息集成在一起。换句话说,我们将编写一个 Web 服务,该服务本身依赖于另一个 Web 服务。我们服务的输入将是国际标准书号 (ISBN);输出将指示该书在 Amazon 和 Skokie 图书馆的可用性和价格。

在某些方面,此 Web 服务将复制出色的 Firefox Web 浏览器 Book Burro 插件,我经常使用它来查找最划算的商品。实际上,Book Burro 会查看书店和公共图书馆,以查找书籍。我向所有使用 Firefox 的人推荐 Book Burro。但是,我相信构建您自己的简单 Web 服务,即使它复制了另一个程序的功能,也是一项有价值的工作。

此外,Web 服务具有可从任何编程语言和任何应用程序访问的优势。我可以使用 Ruby 实现我的 Web 服务,人们仍然可以从 Java、Python、Perl 或几乎任何其他语言访问它。在许多方面,这实现了像 CORBA 这样的对象代理中间件服务所承诺的,只是没有使 CORBA 成为更复杂(但可以说更安全和更丰富)的编程平台的包袱。它使 Web 服务比简单的软件库更强大,因为只要请求计算机连接到互联网,就可以从任何平台或语言访问它。

搜索目录

为了集成 Skokie 图书馆的 ISBN 搜索,我们将需要一种查询图书馆以获取有关图书可用性的信息的方法。不幸的是,我的图书馆没有用于查询其数据库的 Web 服务 API。但是,它确实具有次佳选择,即我们可以查询的简单 Web 界面。

有几种方法可以查看 Web 页面的输出。由于许多站点现在使用可以像 XML 一样解析的 HTML,我们可能希望使用 XML 解析库来读取来自图书馆网站的响应,并在特定位置查找特定文本。

尽管我可能喜欢这种方法的想法,但我可能不是唯一一个采取更实用、快速和肮脏的方式的 Web 开发人员。我已经多次使用我图书馆的网站,我知道它可能会发回给我的响应数量有限。因此,我将使用可靠但有点愚蠢的方法,即在 HTTP 响应中查找特定线索。

我们的程序 (skokie-lookup.rb,清单 1) 是用 Ruby 编写的,这是一种在过去几个月中我越来越喜欢的语言。我们首先导入包含的 Net::HTTP 模块,该模块定义了提供基于 HTTP 的通信的类和方法。

清单 1. skokie-lookup.rb

#!/usr/bin/ruby

require 'net/http'

if ARGV.length == 0
  puts "#{$0}: You must enter at least one argument."
  exit
end

output = ""

# Set up our regular expressions
not_in_collection_re = /class="yourEntryWouldBeHereData"/ix
on_shelf_re = /CHECK SHELF/ix
checked_out_re = /DUE /ix

# Iterate through each of our arguments
ARGV.each do |isbn|

  # Ignore non-ISBN arguments
  if not isbn.match(/[0-9xX]{10}/)
    output << "ISBN #{isbn} is invalid.\n"
    next
  end

  # Ask the library what it knows about our ISBN
  response = Net::HTTP.get_response('catalog.skokie.lib.il.us',
                                    "/search~S4/i?SEARCH=#{isbn}")

  # Check our regular expressions against the HTML response
  if not_in_collection_re.match(response.body)
    output << "ISBN #{isbn} is not in the Skokie collection.\n"
  elsif on_shelf_re.match(response.body)
    output << "ISBN #{isbn} is on the shelf.\n"
  elsif checked_out_re.match(response.body)
    output << "ISBN #{isbn} is currently checked out.\n"
  else
    output << "ISBN #{isbn} response: Unparseable!\n"
  end
end

# Show everyone what we've learned
puts output

然后,我们通过查看内置的 ARGV 数组来检查以确保我们至少有一个命令行参数。如果 ARGV 的长度为 0,我们知道我们没有传递任何参数,我们应该向用户简要说明如何使用该程序。

然后,我们设置一些稍后需要的变量。output 变量是一个字符串,我们将在其中添加我们需要发送给用户的任何输出。我们还创建了三个 Regexp(正则表达式)对象,我们将在循环中使用它们。

接下来是程序的核心部分。我们迭代 ARGV 的每个元素,首先检查它是否是仅包含数字和字母 X 的十个字符的 ISBN。然后,我们查询 Skokie 图书馆的 Web 站点以获取该 ISBN,将主机名和程序路径传递给 Net::HTTP.get_response。HTTP 响应(包括其标头和正文)随后在我们的 response 变量中可用。

现在,我们将响应正文与我们的三个正则表达式进行比较,检查它与哪个正则表达式匹配。使用 Ruby 的 << 运算符进行连接,我们为每个 ISBN 向 output 变量添加适当的消息。最后,在程序退出之前,它会给出 ISBN 的完整报告。

组合搜索结果

上面的程序运行良好,它提供了一种比标准网页更轻松地查询 Skokie 图书馆目录的方法。但是,我对知道如果我从 Amazon 购买这本书要花多少钱以及它是否在图书馆可用感兴趣。有了所有这些信息,我就可以决定我是要买这本书,从图书馆借阅它,还是两者都不做。

上个月,我们看到了如何使用 REST 样式的请求(即,带有参数的 HTTP GET)从 Amazon 检索信息。现在,我们将编写一个程序,该程序执行该检索,然后提取相关的 XML 数据。

您可能还记得,我们可以通过向 webservices.amazon.com 发送 HTTP 请求,请求文档 /onca/xml,然后指定 Service、Operation 和 AWSAccessKeyId 名称-值对,从而从 Amazon 检索 Web 服务信息。如果我们对了解该 ISBN 的新书和二手书价格感兴趣,那么我们传递 ItemId 参数,并指示我们想要称为 OfferSummary 的 ResponseGroup。

由于 Amazon 在其所有响应(包括使用 REST 调用的响应)中都返回 XML,我们可以解析 XML 以查找我们图书的最低价格。Ruby 附带了 REXML 解析库,该库以多种不同的方式处理 XML;我们将使用它来扫描 Amazon 响应以查找适当的代码。

最后,我们可以重新设计我们现有的代码,使其可以搜索 Skokie 图书馆的 ISBN 并生成文本摘要。清单 2 包含一个程序 (combined-lookup.rb),该程序生成此类组合输出。

清单 2. combined-lookup.rb

#!/usr/bin/ruby

require 'net/http'
require 'rexml/document'

if ARGV.length == 0
  puts "#{$0}: You must enter at least one argument."
  exit
end

output = ""

# Set up our regular expressions
not_in_collection_re = /class="yourEntryWouldBeHereData"/ix
on_shelf_re = /CHECK\s+SHELF/ix
checked_out_re = /DUE /ix

# Iterate through each of our arguments
ARGV.each do |isbn|

  # Ignore non-ISBN arguments
  if not isbn.match(/[0-9xX]{10}/)
    output << "ISBN #{isbn} is invalid.\n"
    next
  end

  output << "ISBN: #{isbn}\n"

  # ------------------------------------------------------------
  # Amazon
  # ------------------------------------------------------------

  # Put together an Amazon parameter string
  amazon_params = {'Service' => 'AWSECommerceService',
    'Operation' => 'ItemLookup',
    'AWSAccessKeyId' => 'XXX',
    'ItemId' => isbn,
    'ResponseGroup' => 'Medium,OfferFull',
    'MerchantId' => 'All'}.map {|key,value| "#{key}=#{value}"}.join("&")

  # Ask Amazon what it knows about our ISBN
  amazon_response = Net::HTTP.get_response('webservices.amazon.com',
                                           '/onca/xml?' << amazon_params)

  xml = REXML::Document.new(amazon_response.body)

  # Get the lowest new, used, and collectible prices
  new_price =
xml.root.elements["Items/Item/OfferSummary/LowestNewPrice/FormattedPrice"]
  if new_price.nil?
    output << "\tNew: None available\n"
  else
    output << "\tNew: #{new_price.text}\n"
  end

  used_price =
xml.root.elements["Items/Item/OfferSummary/LowestUsedPrice/FormattedPrice"]
  if used_price.nil?
    output << "\tUsed: None available\n"
  else
    output << "\tUsed: #{used_price.text}\n"
  end

  collectible_price =
xml.root.elements["Items/Item/OfferSummary/LowestCollectiblePrice/FormattedPrice"]
  if collectible_price.nil?
    output << "\tCollectible: None available\n"
  else
    output << "\tCollectible: #{collectible_price.text}\n"
  end

  # ------------------------------------------------------------
  # Library
  # ------------------------------------------------------------

  # Ask the library what it knows about our ISBN
  library_response = Net::HTTP.get_response('catalog.skokie.lib.il.us',
                                    "/search~S4/i?SEARCH=#{isbn}")

  # Check our regular expressions against the HTML response
  if not_in_collection_re.match(library_response.body)
    output << "\tLibrary: Not in the Skokie collection.\n"
  elsif checked_out_re.match(library_response.body)
    output << "\tLibrary: Checked out.\n"
  elsif on_shelf_re.match(library_response.body)
    output << "\tLibrary: On the shelf.\n"
  else
    output << "\tLibrary: Unparseable response\n"
  end
end

# Show everyone what we've learned
puts output

combined-lookup.rb 的开头与 skokie-lookup.rb 几乎相同,尽管它导入了 rexml/document 模块以及 net/http 模块。然后,它遍历在命令行上传递的 ISBN,忽略那些不符合严格定义的 ISBN。

此程序的主要添加部分从创建名为 amazon_params 的字符串开始。从理论上讲,我们可以通过多种不同的方式构建此字符串,其中许多方式比我选择的方法组合更简单。但是,我觉得以这种方式使用哈希会使以后修改代码更容易,即使它首先需要花费更多时间来理解。

基本思想如下:我们创建一个哈希,其中键是 AWS REST 参数名称,值是对应的参数值。为了使这些参数采用 param1=value1&param2=value2 的标准格式,我们使用 map 从哈希的键和值创建一个数组。我们的数组将包含字符串,每个字符串都采用 param=value 格式,并用等号连接在一起。最后,我们使用 join 将所有这些对与它们之间的 & 符号组合在一起,从而生成一个字符串,我们将其分配给 amazon_params。

参数就位后,我们使用 Net::HTTP.get_response,就像我们在 skokie-lookup.rb 中所做的那样。主机名将不同,并且该主机上请求的 URL 也将大相径庭,其中包含我们刚刚分配给 amazon_params 的参数。但是,请求以相同的方式发送,我们也以相同的方式检索响应。

但是,Skokie 图书馆以 HTML 形式发送其响应,而 Amazon 使用 XML 回复。因此,我们启动 REXML,使用 Amazon 响应的内容创建一个新的 REXML::Document 实例。然后,我们使用响应的根节点上的 elements 方法来查找最低的新书、二手书和收藏品价格。(Amazon 分别提供这些价格,我承认这有点烦人。)如果该节点中的文本为 nil,则不存在此类价格,我们会向用户指示这一点。否则,我们可以假设我们收到了一个价格——以及一个以美元符号和小数点格式化的价格——并将其显示给用户。

创建 Web 服务

现在我们已经创建了一个组合查找工具,我们如何将其变成 Web 服务?(为了简单起见,我将使用 XML-RPC。使用 SOAP 甚至查找 REST 参数也同样有效。)

答案比您想象的要容易。我们将需要修改程序以从 Web 而不是 ARGV 获取其输入。我们还需要通过 XML-RPC 将输出发送回发送原始请求的客户端。

但是最终结果,正如您在清单 3 中看到的那样,与我们在清单 2 中的结果没有太大不同。并且由于它作为 Web 服务运行,我们现在可以将它的结果合并到我们可能编写的新程序中。更好的是,我们可以创建新的 Web 服务,将此服务用作底层基础,从而将功能堆叠得更深,形成更有用的库。

清单 3. xmlrpc-lookup.rb

#!/usr/bin/ruby

require 'net/http'
require 'rexml/document'
require 'xmlrpc/server'

# Set our regular expressions
not_in_collection_re = /class="yourEntryWouldBeHereData"/ix
on_shelf_re = /CHECK\s+SHELF/ix
checked_out_re = /DUE /ix

# ------------------------------------------------------------
# XML-RPC
# ------------------------------------------------------------

# Start an HTTP server on port 8080, to listen for clients
server = XMLRPC::Server.new(8080)

server.add_handler(name="atf.books",
                   signature=['array', 'array']) do |isbns|

  output = [ ]

  # Iterate through each of our arguments
  isbns.each do |isbn|

    isbn_output = {'ISBN' => isbn}

    # Ignore non-ISBN arguments
    if not isbn.match(/^[0-9xX]{10}$/)
      isbn_output['message'] = "ISBN #{isbn} is invalid."
      output << isbn_output
      next
    end

    # ------------------------------------------------------------
    # Amazon
    # ------------------------------------------------------------

    # Put together an Amazon parameter string
    amazon_params = {'Service' => 'AWSECommerceService',
      'Operation' => 'ItemLookup',
      'AWSAccessKeyId' => 'XXX',
      'ItemId' => isbn,
      'ResponseGroup' => 'Medium,OfferFull',
      'MerchantId' => 'All'}.map {|key,value|
"#{key}=#{value}"}.join("&")

    # Ask Amazon what it knows about our ISBN
    amazon_response = Net::HTTP.get_response('webservices.amazon.com',
                                             '/onca/xml?' <<
amazon_params)

    xml = REXML::Document.new(amazon_response.body)

    # Get the lowest new, used, and collectible prices
    new_price =
xml.root.elements["Items/Item/OfferSummary/LowestNewPrice/FormattedPrice"]
    if new_price.nil?
      isbn_output['New'] = "None available"
    else
      isbn_output['New'] = new_price.text
    end

    used_price =
xml.root.elements["Items/Item/OfferSummary/LowestUsedPrice/FormattedPrice"]
    if used_price.nil?
      isbn_output['Used'] = "None available"
    else
      isbn_output['Used'] = used_price.text
    end

    collectible_price =
xml.root.elements["Items/Item/OfferSummary/LowestCollectiblePrice/FormattedPrice"]
    if collectible_price.nil?
      isbn_output['Collectible'] = "None available"
    else
      isbn_output['Collectible'] = collectible_price.text
    end

    # ------------------------------------------------------------
    # Library
    # ------------------------------------------------------------

    # Ask the library what it knows about our ISBN
    library_response = Net::HTTP.get_response('catalog.skokie.lib.il.us',
                                              "/search~S4/i?SEARCH=#{isbn}")

    # Check our regular expressions against the HTML response
    if not_in_collection_re.match(library_response.body)
      isbn_output['Library'] = "Library: Not in the Skokie collection."
    elsif checked_out_re.match(library_response.body)
      isbn_output['Library'] = "Checked out."
    elsif on_shelf_re.match(library_response.body)
      isbn_output['Library'] = "On the shelf."
    else
      isbn_output['Library'] = "Unparseable response."
    end

    output << isbn_output
  end

  output
end

server.serve

清单 3 首先在端口 8080 上创建 XMLRPC::Server 的新实例。然后,它添加一个新的处理程序,我们将其称为 atf.books,它接受数组作为输入并返回数组作为输出。使用 Ruby 的块表示法,处理程序随后迭代它通过 XML-RPC 方法调用接收的每个 ISBN。

程序的其余部分与 combined-lookup.rb 大致相同,除了输出。至少在这个 Ruby 库中,XML-RPC 方法调用的输出是通过将输出放置在块的最后一行来完成的。由于我们计划返回一个数组,因此我们需要创建和填充该数组。因此,我们将 output 变量定义为一个空数组,并为我们检查的每个 ISBN 向其添加一个元素。然后,该数组的每个元素都将是一个哈希(在 XML-RPC 术语中称为结构),其中 ISBN 键指向图书的 ISBN,而 New、Used 和 Collectible 键指向从 Amazon 检索的价格。

然后,服务器程序以调用 server.serve 结束,启动一个简单 HTTP 服务器的无限侦听器循环。

要测试此程序,您需要一个 RPC 客户端;清单 4 中显示了一个简单的客户端,它从命令行获取其参数。您会注意到,我们使用 Ruby 的异常处理机制来监视潜在的问题。如果服务器上出现错误,我们可以捕获它并打印有用的调试消息。

清单 4. xmlrpc-lookup-client.rb

#!/usr/bin/ruby

require 'xmlrpc/client'

# Get the ISBNs from the command line
isbns = ARGV

# Connect to the server
server = XMLRPC::Client.new2("http://127.0.0.1:8080/", nil, 120)

# Send the ISBNs, and catch any faults that we find
begin
  results = server.call("atf.books", isbns)
rescue XMLRPC::FaultException => e
  puts "Error:"
  puts e.faultCode
  puts e.faultString
end

# Display the results!
results.each do |result|
  result.each do |key, value|
    if key == "ISBN"
      puts "ISBN: #{value}\n"
    else
      puts "\t#{key}: #{value}\n"
    end
  end
end

结论

经验丰富的程序员很少自己实现所有内容。每个应用程序都需要自己的视频和打印机驱动程序,更不用说文件系统或操作系统了,这样的日子早已过去。相反,我们现在拥有软件库的层次结构,每个库都利用较低级别的数据和功能,并且还为更高级别的库执行类似的任务。

Web 服务并没有改变在旧库之上构建新库的需求。实际上,我们可以预期未来会出现大量此类新库。不同之处在于,新库通常将基于 Web 服务,后者提供平台和语言独立性。我们将看到基本的、中间件的和高级的 Web 服务,它们可以从互联网上的任何地方获得,并且可以从任何操作系统或语言调用。本月,我们研究了一种从旧的 Web 服务中创建新的 Web 服务的方法。对我们的 xmlrpc-lookup 服务器的每次调用都会触发对 Amazon Web 服务的查询。然后,来自 Amazon 的信息与另一个数据集结合在一起,其结果对居住在伊利诺伊州 Skokie 的任何人都有用。我们可以预期未来会出现类似的聚合 Web 服务,既有免费的,也有付费的。

本文的资源: /article/8828

Reuven M. Lerner 是一位长期从事 Web/数据库咨询的顾问,目前是伊利诺伊州 Evanston 西北大学学习科学专业的博士生。他和他的妻子最近庆祝了他们的儿子 Amotz David 的诞生。

加载 Disqus 评论