At the Forge - 增量表单提交

作者 Reuven M. Lerner

计算机速度惊人地快。想想看——我们通过处理器每秒可以执行多少指令来衡量原始处理器速度,而这个数字已经变得如此之大,我们四舍五入到最近的 1 亿。

当然,通常很难感觉到计算机如此快速,特别是当您坐在那里等待它们完成任务时。有时,这种等待与需要一段时间才能执行的复杂算法有关。但在许多情况下,问题是系统中更下游的延迟,这导致您的最终用户应用程序等待一段时间。

我认为,这是 Web 服务世界的主要阿喀琉斯之踵——基于 Web 的 API 使得组合(和操作)来自多个来源的数据变得越来越容易。Web 服务可能正在彻底改变分布式应用程序的开发和部署,但它们使得创建性能依赖于他人系统的软件变得诱人(有时也太容易了)。

例如,假设您提供一项 Web 服务,并且您的程序反过来依赖于第二项 Web 服务。您的系统的用户可能会在两个不同的点遇到延迟:您的 Web 服务(由于计算复杂性、系统资源不足或同时请求过多),或者您的服务所依赖的第二项 Web 服务。

几家商业公司,例如 Google、eBay 和 Amazon,向公众提供 Web 服务。但是,这些服务缺乏任何形式的正常运行时间或响应保证,并且通常限制您可以发出的请求数量。如果您编写的 Web 服务依赖于其中一项其他服务,那么对您的服务的请求增加很可能意味着您暂时超过了对这些服务的限制。

如果您允许用户一次输入一个或多个输入,则尤其如此。例如,如果您正在运营一家在线商店,您希望让人们将多件商品放入他们的购物车中。很容易想象这样一种场景:购物车中的每件商品都需要调用一项或多项 Web 服务。如果每次调用花费一秒钟,并且如果您只允许每六秒钟访问一次 Web 服务,那么尝试购买十件商品的用户最终可能要等待一分钟才能看到最终结账屏幕。如果一家实体店让您等待一分钟,您会感到沮丧。如果一家在线商店也这样做,您可能只会拿起东西离开。

那么,您应该怎么做呢?好吧,您可以简单地举手投降,并责怪较低级别的服务。或者,您可以联系较低级别的服务,并尝试为自己协商更快、更好的交易。另一种选择是尝试预测您的用户将向您提供哪些输入,并尝试预处理它们,可能在晚上,当系统上的用户较少时。

最近,我在我的一些咨询工作中一直在开发的网站上遇到了这个问题。而且,我相信我已经找到了一种技术,可以轻松解决这个问题,并演示 Ajax 编程技术不仅可以为网站增添情趣,还可以使其更具功能性。本月,我们将研究我开发的技术,我将其称为(暂且如此称呼)增量表单提交。

问题

在我们继续之前,让我们定义一下我们试图解决的问题。访问我们网站的用户会看到一个 HTML 表单。该表单包含一个 textarea 部件,用户可以在其中输入一个或多个单词。当用户单击提交按钮时,服务器端程序会获取 textarea 的内容,并将其发送到 Web 服务,该服务将每个单词转换为 Pig Latin 等效词。服务器端程序从 Web 服务检索结果,并在用户屏幕上以 HTML 形式显示 Pig Latin。

不用说,这个例子有些牵强;虽然拥有一个处理 Pig Latin 翻译的 Web 服务可能很好,但进行这种翻译(实际上,只是一个简单的文本转换)花费的时间太少,以至于存储或缓存此信息是愚蠢的。也就是说,这个例子旨在提供思考的素材,而不是一个可用于生产的软件。

让我们从我们的 HTML 文件开始,如清单 1 所示。它包含一个简短的 HTML 表单,其中包含一个 textarea 部件(名为 words)和一个提交按钮。

清单 1. pl-words.html

<html>
    <head>
        <title>Pig Latin translator</title>
    </head>
    <body> 
    <p>Enter the words you wish to translate into Pig
       Latin:</p>
        <form method="POST" action="pl-words.cgi">
            <textarea name="words">Enter words here</textarea>
            <p><input type="submit" value="Translate" /></p>
        </form>
    </body>
</html>

单击该提交按钮会将表单的内容提交给用 Ruby 编写的 CGI 程序,名为 pl-word.cgi(清单 2)。清单 2 分为两个部分。在程序的第一部分,我们定义了一个方法 pl_sentence,该方法接受一个句子(即一个字符串),将其转换为字符串数组(每个单词在一个字符串中),然后将该数组传递给我们的 Web 服务(通过 XML-RPC)。程序的后半部分从我们的 POST 请求中获取输入,将其传递给 pl_sentence 例程,然后使用 pl_sentence 的输出为用户创建一个格式良好(如果简朴)的输出。

清单 2. pl-words.cgi

#!/usr/bin/env ruby
# *-ruby-*-

require 'cgi'
require 'xmlrpc/client'

def pl_sentence(sentence)
  server = XMLRPC::Client.new2('http://127.0.0.1:9000', nil, 240)

  sentence_array = sentence.split

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

  return results.join(' ')
end

# Create an instance of CGI
cgi = CGI.new("html4")

# Get the words to translate
words = cgi.params['words']
if words.empty?
  words = ''
else
  words = words[0].downcase
end

# Send some output to the end user
cgi.out {

  cgi.html {

    # Produce a header
    cgi.head { cgi.title { "Your Pig Latin translation" }
    } +

    # Produce a body
    cgi.body {
      cgi.h1 { "Pig Latin translation results" } +
      cgi.p { "Original sentence: '#{words}'" } +
      cgi.p { "Translated sentence: '#{pl_sentence(words)}'" }
    }
  }
}

使所有这些工作的关键如清单 3 所示,清单 3 提供了我们的 XML-RPC 服务器的代码。我们首先从一个简单的英语单词及其 Pig Latin 等效词的缓存中读取。同样,以这种方式存储东西似乎很愚蠢,因为简单地编写处理 Pig Latin 规则的代码要快得多。如果您想象每次翻译需要几秒钟,您就可以看到事情可能会很快堆积起来。

清单 3. pl-server.rb

#!/usr/bin/ruby

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

# ------------------------------------------------------------
# Load the translation cache
# ------------------------------------------------------------

dictionary = { }

puts "Loading cached translations"
translation_file = 'translations.txt'

if FileTest.exists?(translation_file)
  File.open(translation_file, "r").each do |line|
    (english, piglatin) = line.chomp.split('=')
    dictionary[english] = piglatin
    puts "'#{english}' => '#{piglatin}'"
  end
else
  File.open(translation_file, 'w') do |line|
  end
end

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

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

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

  output = [ ]

  words.map {|word| word.to_s}.each do |word|

    # Have we already seen this word? Don't bother to translate it
    if dictionary.has_key?(word)
      puts "Grabbing translation of '#{word}' from the dictionary"
      output << dictionary[word]
      next
    end

    # If it's not in the cache, then go for it.
    piglatin = ''
    if word =~ /^[aeiou]/
      piglatin << word
      piglatin << 'way'
    else
      piglatin = word[1..-1]
      piglatin << word[0]
      piglatin << 'ay'
    end

    puts "Translated '#{word}' => '#{piglatin}'"

    # Cache it
    puts "Trying to cache..."
    dictionary[word] = piglatin
    File.open(translation_file, 'a') {|f| f.puts "#{word}=#{piglatin}"}
    output << piglatin
  end

  output

end

server.serve

在这个程序中,有几件事需要注意。首先是使用磁盘缓存来存储最近处理的输入。(请不要尝试模仿我实施此方法的简单而愚蠢的方式;我忽略了锁定和权限问题。)缓存本身是一个简单的文本文件,其中包含名称-值对。在计算每个项目的 Pig Latin 翻译之前,Web 服务会查阅缓存。如果该单词在缓存中,则服务会获取该值并几乎立即返回翻译后的值。

如果该单词不在缓存中,它会将英语翻译成 Pig Latin,并存储这些值以备下次使用。同样,这确保了只有当单词未出现在缓存中时,我们才需要努力工作(即,将单词翻译成 Pig Latin)。

如果您以前从未使用 Ruby 编程,您可能会对这一行感到有些反感

words.map {|word| word.to_s}.each do |word|

这告诉 Ruby 它应该获取名为 words 的数组,并将其每个元素转换为字符串。(如果元素已经是字符串,则不受影响。)然后,我们遍历数组中的每个字符串(单词),将局部变量 word 顺序分配给每个元素。

有了清单 1、2 和 3,您应该能够轻松地将句子从英语翻译成 Pig Latin。您将英语单词输入到 HTML 表单中,服务器端程序调用 Web 服务,Web 服务会快速处理事情。

清单 4. pl-words.html 的 Ajax 版本

<html>
    <head>
        <title>Pig Latin translator</title>
        <script src="/prototype.js" type="text/javascript">
        </script>
    </head>

    <body>
        <p>Enter the words you wish to translate into Pig Latin:</p>

        <form id="form" name="form" method="POST" action="pl-words.cgi">
            <textarea id="words" name="words">Enter words here</textarea>
            <p><input type="submit" value="Translate" /></p>
        </form>
    </body>

    <script language="JavaScript" type="text/javascript">
        function translateFunction() {

        var myAjax = new Ajax.Request(
            '/pl-words.cgi',
            {
                parameters: Form.serialize('form')
            });
        }

        new Form.Element.Observer($("words"), 3, translateFunction);
    </script>


</html>

提高性能

现在我们来到了这个项目的难点或有趣的部分。如果您可以想象每次 Pig Latin 翻译需要执行十秒钟,但从缓存中检索不到一秒钟,您会希望尽可能多地使用缓存。此外,考虑到每次单词查找需要多长时间,用户将需要极大的耐心来处理它。

解决方案?使用 Prototype,这是一个流行的 JavaScript 框架。它的 AjaxUpdater 会自动将 textarea 部件的内容提交到您选择的 URL——在本例中,与 POST 使用的 URL 相同——在后台,每次文本区域更改时。然后,每个单词在用户填写文本表单时被翻译,从而大大减少了翻译所需的时间。

换句话说,我打赌用户需要足够的时间输入整个句子,我可以在他们打字时收集并翻译大部分或全部翻译后的单词。此外,因为我知道 Web 服务正在缓存结果,所以我可以每隔几秒钟传递整个 textarea 的内容,因为我知道从缓存中检索项目非常快速。

此功能的关键是 JavaScript 中 Form.Element.Observer 对象的使用。此对象允许我们随着时间的推移监视任何表单元素,并在表单元素更改时将表单的内容提交到任意 URL。我们将使用它,以及我们对 Pig Latin 服务器 (pl-server.rb) 缓存已翻译单词的了解,每隔几秒钟提交表单,甚至在用户单击提交按钮之前。

我们通过向我们的 textarea 添加一个 id 属性(其值为 words)以及添加以下 JavaScript 代码来做到这一点

new Form.Element.Observer($("words"), 3, translateFunction);

换句话说,我们将每三秒检查 textarea 中的单词是否发生更改。如果发生更改,浏览器将调用方法 translateFunction。此函数定义如下

function translateFunction() {

var myAjax = new Ajax.Request(
    '/pl-words.cgi',
    {
        parameters: Form.serialize('form')
    });
}

换句话说,translateFunction 在后台创建一个新的 Ajax 请求,将表单的内容提交到 URL /pl-words.cgi——与表单在过程结束时将提交到的程序相同。但是,对于我们的增量提交,我们更关心副作用(即,缓存的翻译),而不是生成的 HTML。因此,我们忽略来自 pl-words.cgi 的输出。

由于我们构建服务器端程序的方式,它们根本不需要更改即可使这种 Ajax 样式的添加生效。我们所需要做的就是修改 HTML 文件,添加几行 JavaScript 代码。

现在,当然,这并没有改变翻译每个单词甚至整个句子所需的时间量。但是,这不是重点。相反,我们正在利用这样一个事实,即许多人倾向于打字缓慢,并且他们会花时间在 textarea 部件中输入单词。

如果用户打字很快,或者输入非常短的句子,我们实际上并没有损失任何东西。翻译这些人的句子将花费很长时间,他们只需要等待即可。如果人们的想法发生很大变化,我们可能会最终得到各种永远不会再次使用的缓存的翻译单词。但是,鉴于缓存是所有用户共享的,因此这似乎是一个相对较小的风险。

如果您正在考虑走这条路线——即将增量表单提交与缓存结合使用,则需要考虑一些事项。首先,请注意我们正在迭代 textarea 中的每个单词。这意味着有人可能会对您的服务器发起拒绝服务攻击,只需在您的 textarea 部件中输入荒谬的长文本字符串即可。防止这种情况的一种方法是限制您从任何给定 textarea 部件检查的单词数。当然,您可以限制您愿意从增量提交而不是完整和最终提交中翻译的单词数。

另一个要记住的项目是,您不应暴露您的内部 API。API 供外部使用;一旦人们了解了您的内部数据结构和方法,他们可能会利用它们来对付您。这些示例不包括对传递到服务器的数据进行任何清理或测试;在实际情况下,您可能需要在简单地将其传递给另一个程序之前执行此操作。

最后,如果您的网站变得流行,您可能需要多个服务器来处理 Web 服务。这很好,甚至是个好主意。但是,您应该获得多少台服务器,以及它们应该如何存储数据?一种可能性,也是我希望在未来几个月内撰写的,是亚马逊的 EC2(弹性计算云)技术,该技术允许您快速且以合理的价格启动几乎无限数量的 Web 服务器。将 EC2 与这种缓存 Web 服务相结合可能会很好地工作,特别是如果您有一种在服务器之间共享动态数据的好方法。

结论

Web 服务是服务器共享数据的绝佳方式。但是,当 Web 服务成为瓶颈,并且我们无法控制瓶颈的大小时,我们必须尝试找到创造性的解决方案。本月,我们研究了一种我称之为增量发布的方法,旨在随着用户键入内容,将负担分散到一段时间内。即使此解决方案不太适合您,您也可能会在某种程度上受到启发,将此或其他 Ajax 技术融入到您自己的网站中。

资源

本月的程序是用 Ruby 编写的,Ruby 是一种流行的通用编程语言。您可以在 ruby-lang.org 上阅读有关 Ruby 的更多信息,并下载或浏览文档。

如果您想了解有关 JavaScript 的 Prototype 库的更多信息,请访问 www.prototypejs.org

您可以从许多网站了解 Ajax 编程技术。《Ajax 设计模式》是 Michael Mahemoff 撰写,O'Reilly 出版的一本我最喜欢的关于该主题的书。我还发现关注 www.ajaxian.com 上(看似无限的)JavaScript 和 Ajax 新闻非常有用且有趣。

Reuven M. Lerner,一位长期的 Web/数据库开发人员和顾问,是西北大学学习科学专业的博士候选人,研究在线学习社区。在芝加哥地区生活四年后,他最近与妻子和三个孩子一起返回他们在以色列莫迪因的家。

加载 Disqus 评论